File Coverage

lib/Net/LDAP/SPNEGO.pm
Criterion Covered Total %
statement 29 122 23.7
branch 0 28 0.0
condition 0 2 0.0
subroutine 10 26 38.4
pod 2 3 66.6
total 41 181 22.6


line stmt bran cond sub pod time code
1             package Net::LDAP::SPNEGO;
2             our $VERSION = '0.1.7';
3              
4             =encoding utf8
5              
6             =head1 NAME
7              
8             Net::LDAP::SPNEGO - Net::LDAP support for NTLM/SPNEGO authentication
9              
10             =head1 SYNOPSIS
11              
12             use Net::LDAP::SPNEGO;
13             use Mojolicious::Lite;
14              
15             my $SERVER = $ENV{AD_SERVER} // die "AD_SERVER env variable not set";
16              
17             my %cCache;
18              
19             app->secrets(['My secret passphrase here']);
20              
21             hook before_dispatch => sub {
22             my $c = shift;
23              
24             # once the user property is set, we are happy
25             # and don't try to re-authenticate
26             return if $c->session('user');
27              
28             my $cId = $c->tx->connection;
29             my $cCache = $cCache{$cId} //= { status => 'init' };
30             my $authorization = $c->req->headers->header('Authorization') // '';
31             my ($AuthBase64) = ($authorization =~ /^NTLM\s(.+)$/);
32             for ($AuthBase64 and $cCache->{status} =~ /^expect(Type\d)/){
33             my $ldap = $cCache->{ldapObj}
34             //= Net::LDAP::SPNEGO->new($SERVER,debug=>0);
35             /^Type1/ && do {
36             my $mesg = $ldap->bind_type1($AuthBase64);
37             if ($mesg->{ntlm_type2_base64}){
38             $c->res->headers->header(
39             'WWW-Authenticate' => 'NTLM '.$mesg->{ntlm_type2_base64}
40             );
41             $c->render(
42             text => 'Waiting for Type3 NTLM Token',
43             status => 401
44             );
45             $cCache->{status} = 'expectType3';
46             return;
47             }
48             # lets try with a new connection
49             $ldap->unbind;
50             delete $cCache->{ldapObj};
51             };
52             /^Type3/ && do {
53             my $mesg = $ldap->bind_type3($AuthBase64);
54             if (my $user = $mesg->{ldap_user_entry}){
55             $c->session('user',$user->{samaccountname});
56             $c->session('name',$user->{displayname});
57             my $groups = $ldap->get_ad_groups($user->{samaccountname});
58             $c->session('groups',[ sort keys %$groups]);
59             }
60             $ldap->unbind;
61             delete $cCache->{ldapObj};
62             };
63             }
64             $c->res->headers->header( 'WWW-Authenticate' => 'NTLM' );
65             $c->render( text => 'Waiting for Type 1 NTLM Token', status => 401 );
66             $cCache->{status} = 'expectType1';
67             };
68              
69             get '/' => 'index';
70              
71             app->start;
72              
73             __DATA__
74              
75             @@ index.html.ep
76            
77            
78            
79             NTLM Auth Test
80            
81            
82            

Hello <%= session 'name' %>

83            
Your account '<%= session 'user' %>'
84             belongs to the following groups:
85            
86             % for my $group (@{session 'groups' }) {
87            
  • '<%= $group %>'
  • 88             % }
    89            
    90            
    91            
    92              
    93             =head1 DESCRIPTION
    94              
    95             C provides the essential building blocks to implement NTLM SSO
    96             from Windows clients to webservers. Its purpose is to proxy NTLM tokens
    97             from the webbrowser to an active directory server using the SPNEGO protocol.
    98              
    99             The dialog between browser and the webserver in an NTLM authentication dialog looks
    100             like this:
    101              
    102             1: C --> S GET ...
    103             S --> C 401 Unauthorized
    104             WWW-Authenticate: NTLM
    105              
    106             2: C --> S GET ...
    107             Authorization: NTLM
    108             S --> C 401 Unauthorized
    109             WWW-Authenticate: NTLM
    110              
    111             3: C --> S GET ...
    112             Authorization: NTLM
    113             S --> C 200 Ok
    114              
    115             In contrast to modern web APIs, the NTLM authentication exchange relies on a presistant
    116             connection between browser and server to correlate steps 2 and 3 of the dialog.
    117              
    118             The example above uses L but there is no inherent dependency on
    119             that particular framework, except that NTLM authentication relies on a persistent
    120             http connections (keepalive) to link the multi step authentication together.
    121             In other words, a CGI implementation will not work since the CGI process gets
    122             restarted with every request.
    123              
    124             Windows will only engage in seamless NTLM negotiation with sites residing in the
    125             local zone this may have to be configured in the Internet Settings dialog.
    126              
    127             The module works with NTML as well as NTLMv2 tokens.
    128              
    129             If you are working with L you may find the L
    130             of interest.
    131              
    132             =head1 METHODS
    133              
    134             B provides all the methods of L as well as the following:
    135              
    136             =cut
    137              
    138 1     1   652 use v5.10;
      1         3  
    139 1     1   4 use strict;
      1         2  
      1         17  
    140 1     1   4 use warnings;
      1         1  
      1         24  
    141              
    142 1     1   397 use parent 'Net::LDAP';
      1         260  
      1         4  
    143 1     1   165345 use Net::LDAP::Constant qw(LDAP_SASL_BIND_IN_PROGRESS LDAP_SUCCESS LDAP_LOCAL_ERROR);
      1         2  
      1         55  
    144 1     1   2775 use Net::LDAP::Util qw(escape_filter_value);
      1         62  
      1         58  
    145 1     1   408 use MIME::Base64 qw(decode_base64 encode_base64);
      1         506  
      1         56  
    146 1     1   7 use Net::LDAP::Message;
      1         2  
      1         19  
    147 1     1   432 use Encoding::BER::DER;
      1         7190  
      1         8  
    148 1     1   30 use Encode;
      1         2  
      1         1445  
    149              
    150             =head2 my $response = $ldap->bind_type1($type1B64)
    151              
    152             Start binding the ldap connection. The argument to this method is the base64 encoded type1
    153             NTLM token received from a browser request in the C header.
    154              
    155             Authorization: NTLM Base64EncodedNtlmToken
    156              
    157             The C call encodes this token in an SPNEGO message and uses it to
    158             initiate a bind call to the active directory server.
    159              
    160             The C call returns the L object received from the
    161             AD server in the same way the L call will in a regular bind request.
    162             If the request has been successful the response has an C
    163             property you can hand to your webbrowser to trigger a type3 reponse.
    164              
    165             WWW-Authenticate: NTLM $res->{ntlm_type2_base64}
    166              
    167             =cut
    168              
    169              
    170             sub bind_type1 {
    171 0     0 1   my $self = shift;
    172 0           my $tokenType1 = decode_base64(shift);
    173 0           my $resp = $self->_send_spnego($self->_wrap_type1_token($tokenType1));
    174 0 0         if ( $resp->code == LDAP_SASL_BIND_IN_PROGRESS){
    175 0 0         if (my $serverSaslCreds = $resp->{serverSaslCreds}){
    176 0 0         if (my $data = $self->_ber_encoder->decode($serverSaslCreds)){
    177 0 0         if (my $token = $data->{value}[0]{value}[2]{value}) {
    178 0 0         if ($token =~ /^NTLMSSP/){
    179 0           my $base64Token = encode_base64($token);
    180 0           $base64Token =~ s/[\s\n\r]+//g;
    181 0           $resp->{ntlm_type2_base64} = $base64Token;
    182 0           return $resp;
    183             }
    184             }
    185             }
    186             }
    187 0           $resp->set_error(LDAP_LOCAL_ERROR, 'no type2 token found in server response');
    188             }
    189 0           return $resp;
    190             }
    191              
    192             =head2 my $mesg = $ldap->bind_type3($type3B64)
    193              
    194             Complete binding the ldap connection. The argument to this method is the base64
    195             encoded type3 NTLM token received from the browser request in the C
    196             header.
    197              
    198             Authorization: NTLM Base64EncodedNtlmToken
    199              
    200             The C call returns the L object received from the
    201             AD server in the same way the L call will in a regular bind request.
    202              
    203             The successful response object comes with the extra property: C
    204             containing the ldap user information.
    205              
    206             {
    207             'pwdlastset' => '131153165937657397',
    208             'objectcategory' => 'CN=Person,CN=Schema,CN=Configuration,DC=oetiker,DC=local',
    209             'displayname' => 'Tobi Test',
    210             'usncreated' => '362412',
    211             'distinguishedname' => 'CN=TobiTest TT. Tobi,CN=Users,DC=oetiker,DC=local',
    212             'countrycode' => '0',
    213             'whenchanged' => '20160820154613.0Z',
    214             'instancetype' => '4',
    215             'lastlogontimestamp' => '131161815735975291',
    216             ...
    217             }
    218              
    219             =cut
    220              
    221             sub bind_type3 {
    222 0     0 1   my $self = shift;
    223 0           my $tokenType3 = decode_base64(shift);
    224 0           my $resp = $self->_send_spnego($self->_wrap_type3_token($tokenType3));
    225 0 0         if ($resp->code == LDAP_SUCCESS) {
    226 0           my $username = $self->_get_user_from_ntlm_type3($tokenType3);
    227 0           $resp->{ldap_user_entry} = $self->_get_ad_user($username);
    228             }
    229 0           return $resp;
    230             }
    231              
    232             =head2 my $group_hash = $ldap->get_value_ad_groups($username)
    233              
    234             Query the ldap server for all the users group memberships,
    235             including the primary group and all the inherited group memberships.
    236              
    237             The function uses the magic C query
    238             to effect a recursive search.
    239              
    240             The function returns a hash indexed by the C of the groups
    241             containing the DN and the description of each group.
    242              
    243             {
    244             'Remote Desktop Users' => {
    245             'dn' => 'CN=Remote Desktop Users,CN=Builtin,DC=oetiker,DC=local',
    246             'description' => 'Members in this group are granted the right ...'
    247             },
    248             'Users' => {
    249             'dn' => 'CN=Users,CN=Builtin,DC=oetiker,DC=local',
    250             'description' => 'Users are prevented from making accidental ...'
    251             },
    252             'Domain Users' => {
    253             'description' => 'All domain users',
    254             'dn' => 'CN=Domain Users,CN=Users,DC=oetiker,DC=local'
    255             }
    256             }
    257              
    258             =cut
    259              
    260             sub get_ad_groups {
    261 0     0 0   my $self = shift;
    262 0           my $user = $self->_get_ad_user(shift);
    263 0 0         return [] unless $user;
    264              
    265 0           my $userDN = $user->{distinguishedname};
    266 0           my $primaryGroupSID = _rid2sid($user->{objectsid},$user->{primarygroupid});
    267              
    268 0           my $primaryGroup = $self->search(
    269             base => $self->_get_base_dn,
    270             filter => '(objectSID='._ldap_quote($primaryGroupSID).')',
    271             attrs => [],
    272             )->entry(0);
    273              
    274 0           my @groups;
    275 0           my $search = $self->search(
    276             base => $self->_get_base_dn,
    277             filter => '(|'
    278             .'(objectSID='._ldap_quote($primaryGroupSID).')'
    279             .'(member:1.2.840.113556.1.4.1941:='.escape_filter_value($userDN).')'
    280             .'(member:1.2.840.113556.1.4.1941:='.escape_filter_value($primaryGroup->dn).')'
    281             .')',
    282             attrs => ['sAMAccountName','description']
    283             );
    284 0 0         if ($search->is_error) {
    285 0           warn "LDAP Search failed: ".$search->error;
    286 0           return {};
    287             }
    288 0           while (my $entry = eval { $search->shift_entry }){
      0            
    289 0           push @groups, $entry;
    290             };
    291 0 0         if ($@) {
    292 0           warn "Problem fetching search entry $@";
    293             }
    294 0 0         if ($search->is_error) {
    295 0           warn "LDAP Search error: ".$search->error;
    296             }
    297              
    298             return {
    299             map {
    300 0           scalar $_->get_value('samaccountname') => {
      0            
    301             dn => $_->dn,
    302             description => scalar $_->get_value('description')
    303             }
    304             } @groups
    305             }
    306             }
    307              
    308             # AD LDAP helpers
    309             #
    310             sub _get_base_dn {
    311 0     0     my $self = shift;
    312 0 0         if (not $self->{baseDN}){
    313 0           my $rootDSE = $self->search(
    314             base => '',
    315             filter => '(objectclass=*)',
    316             scope => 'base',
    317             attrs => ['defaultNamingContext'],
    318             )->entry(0);
    319 0           $self->{baseDN} = $rootDSE->get_value('defaultnamingcontext');
    320             }
    321 0           return $self->{baseDN};
    322             }
    323              
    324             sub _get_ad_user {
    325 0     0     my $self = shift;
    326 0   0       my $sAMAccountName = shift // '';
    327 0           my $user = $self->search(
    328             base => $self->_get_base_dn,
    329             scope => 'sub',
    330             filter => "(sAMAccountName=".escape_filter_value($sAMAccountName).')',
    331             attrs => [],
    332             )->entry(0);
    333              
    334 0 0         return undef unless ref $user;
    335              
    336             return {
    337             map {
    338 0           lc($_) => scalar $user->get_value($_)
      0            
    339             } $user->attributes
    340             };
    341             }
    342              
    343             sub _ldap_quote {
    344 0     0     return join '', map { sprintf "\\%02x", $_ } unpack('C*',shift);
      0            
    345             }
    346             # with inspiration from
    347             # https://github.com/josephglanville/posix-ldap-overlay/blob/master/lib/SID.pm
    348              
    349             sub _unpack_sid {
    350 0     0     return unpack 'C Vxx C V*', shift;
    351             }
    352              
    353             sub _sid2string {
    354 0     0     my ($rev, $auth, $sa_cnt, @sa) = _unpack_sid(shift);
    355 0           return join '-', 'S', $rev, $auth, @sa;
    356             }
    357              
    358             sub _sid2rid {
    359 0     0     return [_unpack_sid(shift)]->[-1];
    360             }
    361              
    362             sub _rid2sid {
    363 0     0     my ($rev, $auth, $sacnt, @sa) = _unpack_sid(shift);
    364 0           $sa[-1] = shift;
    365 0           return pack 'C Vxx C V*', $rev, $auth, scalar @sa, @sa;
    366             }
    367              
    368              
    369             # wrap and send an spnego token
    370             sub _send_spnego {
    371 0     0     my $self = shift;
    372 0           my $token = shift;
    373 0           my $mesg = Net::LDAP::Message->new($self);
    374 0           $mesg->encode(
    375             bindRequest => {
    376             name => '',
    377             version => $self->version,
    378             authentication => {
    379             sasl => {
    380             mechanism => 'GSS-SPNEGO',
    381             credentials => $token
    382             }
    383             }
    384             },
    385             controls => undef
    386             );
    387 0           $self->_sendmesg($mesg);
    388             }
    389              
    390             # our BER encoder and decoder
    391              
    392             sub _ber_encoder {
    393 0     0     my $self = shift;
    394 0 0         return $self->{_ber_encoder} if $self->{_ber_encoder};
    395 0     0     my $enc = $self->{_ber_encoder} = Encoding::BER::DER->new( error => sub{ die "BER: $_[1]\n" } );
      0            
    396 0           $enc->add_implicit_tag('context', 'constructed', 'mechToken', 2,'octet_string');
    397 0           $enc->add_implicit_tag('context', 'constructed', 'supportedMech', 1,'oid');
    398 0           $enc->add_implicit_tag('context', 'constructed', 'negResult', 0,'enum');
    399 0           $enc->add_implicit_tag('application','constructed','spnego',0,'sequence');
    400             };
    401              
    402             # prepare the ntlm token for the SPNEGO request to the ldap server
    403             sub _wrap_type1_token {
    404 0     0     my $self = shift;
    405 0           my $ntlm_token = shift;
    406 0           my $enc = $self->_ber_encoder;
    407 0           my $spnegoOID = '1.3.6.1.5.5.2';
    408 0           my $ntlmOID = '1.3.6.1.4.1.311.2.2.10';
    409 0           return $enc->encode({
    410             type => 'spnego',
    411             value => [
    412             {
    413             type => 'oid',
    414             value => $spnegoOID
    415             },
    416             {
    417             type => ['context','constructed',0],
    418             value => [{
    419             type => 'sequence',
    420             value => [
    421             {
    422             type => ['context','constructed',0],
    423             value => [{
    424             type => 'sequence',
    425             value => [
    426             {
    427             type => 'oid',
    428             value => $ntlmOID
    429             }
    430             ]
    431             }]
    432             },
    433             {
    434             type => 'mechToken',
    435             value => $ntlm_token
    436             }
    437             ]
    438             }]
    439             }
    440             ]
    441             });
    442             }
    443              
    444             # prepare the type3 token for next step in the authentication process
    445             sub _wrap_type3_token {
    446 0     0     my $self = shift;
    447 0           my $ntlm_token = shift;
    448 0           my $enc = $self->_ber_encoder;
    449 0           return $enc->encode({
    450             type => ['context','constructed',1],
    451             value => [{
    452             type => 'sequence',
    453             value => [{
    454             type => 'mechToken',
    455             value => $ntlm_token
    456             }]
    457             }]
    458             });
    459             }
    460              
    461             # parse a ntlm type3 token to figure out the username, domain and host
    462             # of the connecting browser
    463              
    464             sub _get_user_from_ntlm_type3 {
    465 0     0     my $self = shift;
    466 0           my $msg = shift;
    467 0           my $sb = 'v xx V';
    468 0           my ($sig,$type,$lmL,$lmO,$ntL,$ntO,
    469             $dnL,$dnO, #domain
    470             $unL,$unO, #user
    471             $hnL,$hnO, #host
    472             $skL,$skO, #sessionkey
    473             $flags, $osMin,$osMaj,$osBuild,$NTLMv) = unpack('Z8 V' . ($sb x 6) . ' V C C v C',$msg);
    474             # Parse a Type3 NTLM message (binary form, not encoded in Base64).
    475             #return a tuple (username, domain)
    476 0           my $NTLMSSP_NEGOTIATE_UNICODE = 0x00000001;
    477 0           my $username = substr($msg,$unO,$unL);
    478             # my $domain = substr($msg,$dnO,$dnL);
    479             # my $host = substr($msg,$hnO,$hnL);
    480 0 0         if ($flags & $NTLMSSP_NEGOTIATE_UNICODE){
    481             # $domain = decode('utf-16-le',$domain);
    482 0           $username = decode('utf-16-le',$username);
    483             # $host = decode('utf-16-le',$host);
    484             }
    485 0           return $username;
    486             }
    487              
    488             1;
    489              
    490             __END__