File Coverage

lib/Net/LDAP/SPNEGO.pm
Criterion Covered Total %
statement 26 108 24.0
branch 0 22 0.0
condition 0 2 0.0
subroutine 9 25 36.0
pod 2 3 66.6
total 37 160 23.1


line stmt bran cond sub pod time code
1             package Net::LDAP::SPNEGO;
2             our $VERSION = '0.1.5';
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 --> S 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 link to
    119             that particular framework, except that NTLM authentication relies on a persistant
    120             http connetions (keepalive) to linke 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   543 use v5.10;
      1         2  
    139 1     1   3 use strict;
      1         1  
      1         15  
    140 1     1   7 use warnings;
      1         1  
      1         25  
    141              
    142 1     1   404 use parent 'Net::LDAP';
      1         223  
      1         4  
    143 1     1   108551 use Net::LDAP::Constant qw(LDAP_SASL_BIND_IN_PROGRESS LDAP_SUCCESS LDAP_LOCAL_ERROR);
      1         1  
      1         50  
    144              
    145 1     1   492 use MIME::Base64 qw(decode_base64 encode_base64);
      1         488  
      1         51  
    146 1     1   5 use Net::LDAP::Message;
      1         1  
      1         15  
    147 1     1   395 use Encoding::BER::DER;
      1         5820  
      1         7  
    148 1     1   23 use Encode;
      1         1  
      1         960  
    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 = $self->search(
    275             base => $self->_get_base_dn,
    276             filter => '(|'
    277             .'(objectSID='._ldap_quote($primaryGroupSID).')'
    278             .'(member:1.2.840.113556.1.4.1941:='.$userDN.')'
    279             .'(member:1.2.840.113556.1.4.1941:='.$primaryGroup->dn.')'
    280             .')',
    281             attrs => ['sAMAccountName','description']
    282             )->entries;
    283              
    284             return {
    285             map {
    286 0           scalar $_->get_value('samaccountname') => {
      0            
    287             dn => $_->dn,
    288             description => scalar $_->get_value('description')
    289             }
    290             } @groups
    291             }
    292             }
    293              
    294             # AD LDAP helpers
    295             #
    296             sub _get_base_dn {
    297 0     0     my $self = shift;
    298 0 0         if (not $self->{baseDN}){
    299 0           my $rootDSE = $self->search(
    300             base => '',
    301             filter => '(objectclass=*)',
    302             scope => 'base',
    303             attrs => ['defaultNamingContext'],
    304             )->entry(0);
    305 0           $self->{baseDN} = $rootDSE->get_value('defaultnamingcontext');
    306             }
    307 0           return $self->{baseDN};
    308             }
    309              
    310             sub _get_ad_user {
    311 0     0     my $self = shift;
    312 0   0       my $sAMAccountName = shift // '';
    313 0           my $user = $self->search(
    314             base => $self->_get_base_dn,
    315             scope => 'sub',
    316             filter => "(sAMAccountName=".$sAMAccountName.')',
    317             attrs => [],
    318             )->entry(0);
    319              
    320 0 0         return undef unless ref $user;
    321              
    322             return {
    323             map {
    324 0           lc($_) => scalar $user->get_value($_)
      0            
    325             } $user->attributes
    326             };
    327             }
    328              
    329             sub _ldap_quote {
    330 0     0     return join '', map { sprintf "\\%02x", $_ } unpack('C*',shift);
      0            
    331             }
    332             # with inspiration from
    333             # https://github.com/josephglanville/posix-ldap-overlay/blob/master/lib/SID.pm
    334              
    335             sub _unpack_sid {
    336 0     0     return unpack 'C Vxx C V*', shift;
    337             }
    338              
    339             sub _sid2string {
    340 0     0     my ($rev, $auth, $sa_cnt, @sa) = _unpack_sid(shift);
    341 0           return join '-', 'S', $rev, $auth, @sa;
    342             }
    343              
    344             sub _sid2rid {
    345 0     0     return [_unpack_sid(shift)]->[-1];
    346             }
    347              
    348             sub _rid2sid {
    349 0     0     my ($rev, $auth, $sacnt, @sa) = _unpack_sid(shift);
    350 0           $sa[-1] = shift;
    351 0           return pack 'C Vxx C V*', $rev, $auth, scalar @sa, @sa;
    352             }
    353              
    354              
    355             # wrap and send an spnego token
    356             sub _send_spnego {
    357 0     0     my $self = shift;
    358 0           my $token = shift;
    359 0           my $mesg = Net::LDAP::Message->new($self);
    360 0           $mesg->encode(
    361             bindRequest => {
    362             name => '',
    363             version => $self->version,
    364             authentication => {
    365             sasl => {
    366             mechanism => 'GSS-SPNEGO',
    367             credentials => $token
    368             }
    369             }
    370             },
    371             controls => undef
    372             );
    373 0           $self->_sendmesg($mesg);
    374             }
    375              
    376             # our BER encoder and decoder
    377              
    378             sub _ber_encoder {
    379 0     0     my $self = shift;
    380 0 0         return $self->{_ber_encoder} if $self->{_ber_encoder};
    381 0     0     my $enc = $self->{_ber_encoder} = Encoding::BER::DER->new( error => sub{ die "BER: $_[1]\n" } );
      0            
    382 0           $enc->add_implicit_tag('context', 'constructed', 'mechToken', 2,'octet_string');
    383 0           $enc->add_implicit_tag('context', 'constructed', 'supportedMech', 1,'oid');
    384 0           $enc->add_implicit_tag('context', 'constructed', 'negResult', 0,'enum');
    385 0           $enc->add_implicit_tag('application','constructed','spnego',0,'sequence');
    386             };
    387              
    388             # prepare the ntlm token for the SPNEGO request to the ldap server
    389             sub _wrap_type1_token {
    390 0     0     my $self = shift;
    391 0           my $ntlm_token = shift;
    392 0           my $enc = $self->_ber_encoder;
    393 0           my $spnegoOID = '1.3.6.1.5.5.2';
    394 0           my $ntlmOID = '1.3.6.1.4.1.311.2.2.10';
    395 0           return $enc->encode({
    396             type => 'spnego',
    397             value => [
    398             {
    399             type => 'oid',
    400             value => $spnegoOID
    401             },
    402             {
    403             type => ['context','constructed',0],
    404             value => [{
    405             type => 'sequence',
    406             value => [
    407             {
    408             type => ['context','constructed',0],
    409             value => [{
    410             type => 'sequence',
    411             value => [
    412             {
    413             type => 'oid',
    414             value => $ntlmOID
    415             }
    416             ]
    417             }]
    418             },
    419             {
    420             type => 'mechToken',
    421             value => $ntlm_token
    422             }
    423             ]
    424             }]
    425             }
    426             ]
    427             });
    428             }
    429              
    430             # prepare the type3 token for next step in the authentication process
    431             sub _wrap_type3_token {
    432 0     0     my $self = shift;
    433 0           my $ntlm_token = shift;
    434 0           my $enc = $self->_ber_encoder;
    435 0           return $enc->encode({
    436             type => ['context','constructed',1],
    437             value => [{
    438             type => 'sequence',
    439             value => [{
    440             type => 'mechToken',
    441             value => $ntlm_token
    442             }]
    443             }]
    444             });
    445             }
    446              
    447             # parse a ntlm type3 token to figure out the username, domain and host
    448             # of the connecting browser
    449              
    450             sub _get_user_from_ntlm_type3 {
    451 0     0     my $self = shift;
    452 0           my $msg = shift;
    453 0           my $sb = 'v xx V';
    454 0           my ($sig,$type,$lmL,$lmO,$ntL,$ntO,
    455             $dnL,$dnO, #domain
    456             $unL,$unO, #user
    457             $hnL,$hnO, #host
    458             $skL,$skO, #sessionkey
    459             $flags, $osMin,$osMaj,$osBuild,$NTLMv) = unpack('Z8 V' . ($sb x 6) . ' V C C v C',$msg);
    460             # Parse a Type3 NTLM message (binary form, not encoded in Base64).
    461             #return a tuple (username, domain)
    462 0           my $NTLMSSP_NEGOTIATE_UNICODE = 0x00000001;
    463 0           my $username = substr($msg,$unO,$unL);
    464             # my $domain = substr($msg,$dnO,$dnL);
    465             # my $host = substr($msg,$hnO,$hnL);
    466 0 0         if ($flags & $NTLMSSP_NEGOTIATE_UNICODE){
    467             # $domain = decode('utf-16-le',$domain);
    468 0           $username = decode('utf-16-le',$username);
    469             # $host = decode('utf-16-le',$host);
    470             }
    471 0           return $username;
    472             }
    473              
    474             1;
    475              
    476             __END__