Changeset 217

Show
Ignore:
Timestamp:
09/29/06 21:14:53 (2 years ago)
Author:
jwalt
Message:
  • improve Digest auth, add secure PRNG, better validity tests, cleanup code
  • document and improve message passing mechanism
Files:

Legend:

Unmodified
Added
Removed
Modified
Copied
Moved
  • trunk/lib/AxKit2/Plugin.pm

    r216 r217  
    121121} 
    122122 
     123 
     124my %_HANDLER_ATTRIB; 
     125 
     126sub Stacked : ATTR(CODE) { 
     127    my ($package, $symbol, $referent) = @_; 
     128    $_HANDLER_ATTRIB{$referent} = 1; 
     129} 
     130 
    123131sub dispatch_message { 
    124132    my $self = shift; 
    125133    my $message = shift; 
    126134    if (my $sub = $self->can("message_$message")) { 
     135        $sub->($self,@_), return DECLINED if ($_HANDLER_ATTRIB{$sub}); 
    127136        return OK, $sub->($self,@_); 
    128137    } 
     
    295304Retrieve the name of the currently executing hook 
    296305 
     306=head2 C<< $plugin->notes >> 
     307 
     308If you need to store plugin-private data associated with a request, you can store 
     309them with C<< $plugin->notes($key,$value) >> and retrieve them via C<< $plugin->notes($key) >>. 
     310It works exactly like C<< $client->notes(...) >>, except that the key is made unique for 
     311each plugin. 
     312 
     313=head2 C<< $plugin->send >> 
     314 
     315This is the interface to user-defined callbacks and message passing. Plugins can define 
     316messages they handle like this: 
     317 
     318  sub message_login { # sent by plugins/authenticate 
     319    my ($self, $user) = @_; 
     320    # let an imaginary database setting do something on login 
     321    $self->config->database->login($user); 
     322  } 
     323 
     324Other plugins can then send a message using C<< $plugin->send('login',$user) >>. This is 
     325intended for typical callback situations, but it also allows plugins to offer services 
     326to other plugins, like this: 
     327 
     328  my $cached_object = $plugin->send('cache',$key); 
     329 
     330Since all plugins are searched for a corresponding message handler, plugins are independent 
     331of the actual implementation of such services. 
     332 
     333By default, the first message handler that is found is run and it's return value returned. 
     334If you want your handler to stack, i.e., that other handlers are run after it, specify the 
     335attribute C<Stacked> on the sub: 
     336 
     337  sub message_login : Stacked { 
     338    my ($self, $user) = @_; 
     339    # log it somewhere special, but do not interfere with regular login processing. 
     340    open(my $fh, '>>', 'userlog'); print $fh time()." ".$user."\n"; close($fh); 
     341  } 
     342 
     343Of course, the return value of stacked handlers is ignored. 
     344 
     345If you need even more complex processing, see C<< hook_handler >> below. 
     346 
    297347=head1 CONFIGURATION DIRECTIVES 
    298348 
     
    351401For full details and more ways of designing configuration directives, see L<AxKit2::Config>. 
    352402 
     403=head1 OTHER FACILITIES 
     404 
     405Since AxKit2 is based on L<Danga::Socket>, those facilities are also available to plugins. 
     406You should read it's documentation for details, but two use cases are rather common: 
     407 
     408=head2 Timers 
     409 
     410Have a custom callback at (roughly) a certain time from now: 
     411 
     412  # add a timed callback 
     413  my $timer = Danga::Socket->AddTimer($seconds, $subref); 
     414  # later, if you decide the timer is no longer needed 
     415  $timer->cancel; 
     416 
     417Pass a closure if you need values from C<$plugin>, as the callback is called 
     418without arguments or C<$self> reference. 
     419 
     420=head2 Watching additional FDs 
     421 
     422If you want to watch other file descriptors for IO, for example a permanent 
     423network connection to some other host, or a subprocess that you spawned for 
     424some asynchronous processing, add a new package at the end of your plugin, 
     425like this: 
     426 
     427  # ... your regular plugin code ... 
     428   
     429  package My::Plugin::SubprocessEvent 
     430  use base 'Danga::Socket'; 
     431  # ... and so on, copy the example code from Danga::Socket docs 
     432 
     433Since watching a FD happens in parallel to other requests, you will have to 
     434use global variables or similar to communicate with your plugin. 
     435 
    353436=head1 AVAILABLE HOOKS 
    354437 
     
    612695 
    613696=back 
     697 
     698 
     699=head2 message 
     700 
     701Params: MESSAGE_NAME, ARGUMENTS 
     702 
     703Called whenever a plugin C<send>'s a message. 
     704 
     705Return Value: 
     706 
     707=over 4 
     708 
     709=item * C<DECLINED> - go on dispatching the message 
     710 
     711=item * C<OK>, I<RETVAL> - Finish this message dispatch, return I<RETVAL> to caller. 
     712 
     713=back 
     714 
    614715 
    615716 
  • trunk/plugins/authenticate

    r216 r217  
    138138 
    139139Digest is recommended, since it is the best you can get short of going 
    140 the SSL route, i.e. it protects you from everything but man-in-the-middle attacks and 
    141 eavesdropping on HTML data, which only SSL can defend against, at a price. 
     140the SSL route, i.e. it protects you from everything but eavesdropping 
     141on transferred data, which only SSL can defend against, at a price. Not only 
     142does it protect your password (an attacker can never read or guess your password 
     143just by sniffing on the line), it also effectively prevents more advanced 
     144attacks like man-in-the-middle or replay attacks. 
    142145 
    143146Basic provides practically no security, since anyone snooping on your connection can 
    144147read the password in (superficially obfuscated) plain text. It is widely 
    145148supported. This is relevant if you expect media players and other embedded HTTP 
    146 clients to read your data. All regular web browsers know how to speak Digest. 
     149clients to read your data that may not yet know Digest. All regular web browsers 
     150know how to speak Digest, so this is probably not an issue anymore. 
     151 
     152Digest authentication needs a little bit of server-side state to be really secure. 
     153Each session uses about 70 bytes. This may sound a bit non-HTTPish, but in return 
     154the possibilities of Digest authentication are used for maximum security. Sessions 
     155that are not explicitly logged out are purged after one to two days. 
    147156 
    148157=head2 C<AuthRealm> I<realm> 
     
    217226} 
    218227 
    219 my $_SERVER_SECRET = rand().rand(); # FIXME: get some kind of cryptographically secure randomness 
    220 my %_NC; 
     228sub _secret() { 
     229    # MersenneTwister is not a cryptographically secure PRNG unless fed through a hash function. 
     230    return md5(&rand()); 
     231
     232 
     233# a secret used to protect first-time requests 
     234my $_SERVER_SECRET; 
     235 
     236sub init { 
     237    my $self = shift; 
     238 
     239    # Try to get secure random numbers. Without, security does not withstand reasonably determined attack. 
     240    eval { 
     241        require Math::Random::MT::Auto; 
     242        Math::Random::MT::Auto->import('rand','srand'); 
     243        my $init; 
     244        eval { &srand('/dev/random'); $init = 1; } if -c "/dev/random"; 
     245        eval { require Win32::API; &srand('win32'); $init = 1; }; 
     246        $self->log(LOGWARN,"Could not seed PRNG. You need to call Math::Random::MT::Auto::srand yourself.") unless $init; 
     247    }; 
     248    $self->log(LOGWARN,"Could not load suitable PRNG. Digest auth will be insecure. Please install Math::Random::MT::Auto.") if ($@); 
     249 
     250    $_SERVER_SECRET = _secret; 
     251    cleanup(); 
     252
     253 
     254# records nonce-count, timestamp, secret and username for sessions 
     255my %_SESSIONS; 
     256 
     257sub cleanup { 
     258    my $limit = time()-86400; 
     259 
     260    foreach my $session (keys %_SESSIONS) { 
     261        my ($nc, $timestamp) = @{$_SESSIONS{$session}}; 
     262        # remove sessions that haven't been touched for at least a day. 
     263        delete $_SESSIONS{$session} if ($timestamp <= $limit); 
     264    } 
     265 
     266    Danga::Socket->AddTimer(86400,\&cleanup); 
     267
    221268 
    222269=head2 C<< logout >> 
     
    236283might want to lock out users after a certain number of failed attempts. 
    237284 
    238 =head2 C<< login_timeout >> 
    239  
    240 This message is sent whenever a login session times out. This can happen I<after> the user 
    241 has logged in with another session, but it will sooner or later be sent for every session 
    242 not logged out via message C<logout>. 
    243  
    244285=cut 
    245286 
    246287sub message_logout { 
    247     my ($self) = @_; 
    248     my $session = $self->client->headers_in->header('Digest-Session')
     288    my ($self, $session) = @_; 
     289    $session = $self->client->headers_in->header('Digest-Session') unless defined $session
    249290    return unless defined $session; 
    250     for my $nonce (grep { m/^\d+:\Q$session:/ } keys %_NC) { 
    251         delete $_NC{$nonce}; 
     291    delete $_SESSIONS{$session}; 
     292
     293 
     294sub send_digest_challenge { 
     295    my ($self, $session, $user) = @_; 
     296    my $secret = $_SERVER_SECRET; 
     297 
     298    my $timestamp = time(); 
     299    if (exists $_SESSIONS{$session}) { 
     300        $_SESSIONS{$session}[0] = 1; 
     301        $_SESSIONS{$session}[1] = int($timestamp); 
     302        $secret = $_SESSIONS{$session}[2] = _secret; 
     303    } else { 
     304        undef $session; 
    252305    } 
    253 
    254  
    255 sub send_digest_challenge { 
    256     my ($self, $oldnonce, $session) = @_; 
    257     delete $_NC{$oldnonce} if defined $oldnonce; # This is not failsafe in the face of pipelined requests; it doesn't need to be. 
    258     my $nonce = time().':'.(defined $session?$session:md5_hex(rand(time()))); 
    259     $nonce .= ":".md5($nonce.":".$_SERVER_SECRET); 
    260     $_NC{$nonce} = 0 if defined $session; 
     306 
     307    my $nonce = $timestamp . ":" . ( defined $session? $session : _secret ) . ":" . $user . ":"; 
     308    $nonce .= md5($nonce . $secret); 
    261309    my $header = "Digest realm=" . quoted_string($self->config->auth_realm) . ", " . 
    262310        "domain=".quoted_string($self->config->auth_domain).", " . 
     
    336384        } 
    337385 
     386        # Cheap tests first, save processing time on mismatch. 
     387 
    338388        # This could be a hacking attempt, a config change, or an ambiguous config. 
    339389        return $self->send_digest_challenge if ($param{realm} ne $self->config->auth_realm); 
    340390 
     391        # Unknown username/realm combination 
    341392        my $hash = $self->config->auth_file->{$param{username}.':'.$param{realm}}; 
    342         # Unknown username/realm combination 
    343393        return $self->send_digest_challenge if (!$hash); 
    344394 
     395        # Validate nonce. 
    345396        $param{nonce} = decode_base64($param{nonce}); 
    346         my ($timestamp,$session,$nonce) = split(/:/,$param{nonce},3); 
     397        my ($timestamp,$session,$user,$nonce) = split(/:/,$param{nonce},4); 
     398 
    347399        # This can't possibly be one of our nonces, so there is probably something fishy going on. 
    348400        return $self->send_digest_forbidden("impossible nonce") if (int($timestamp) ne $timestamp || !defined $nonce || int($timestamp) > time()); 
    349401 
     402        my $s = $_SESSIONS{$session}; 
     403        if ($s) { 
     404            # Validate nc if present. 
     405            return $self->send_digest_forbidden("replay attack") if $param{qop} && $param{nc} ne sprintf("%08x",$$s[0]); 
     406            # Timestamp invalid: replay attack. 
     407            return $self->send_digest_forbidden("invalid timestamp") if $timestamp != $$s[1]; 
     408            # Username mismatch: attempt to take over different user's session 
     409            return $self->send_digest_forbidden("user mismatch in server nonce") if $param{username} ne $user; 
     410        } else { 
     411            # Replay attack, expired or logged out session. Force relogin. 
     412            return $self->send_digest_challenge if $param{qop} && $param{nc} ne '00000001'; 
     413            # Username mismatch: attempt to take over different user's session, or ancient session with old client. 
     414            return $self->send_digest_challenge if length($user); 
     415        } 
     416 
     417        # More expensive checks. 
     418 
     419        # If the nonce doesn't match what it should be, something fishy is going on: A user tries to act as a different user,  
     420        # take over another user's session id, or it could be a replay attack. 
     421        # Unfortunately, this can also happen after a server restart. Force a relogin, however. 
     422        my $secret = ( $s? $$s[2] : $_SERVER_SECRET ); 
     423        $nonce = "$timestamp:$session:$user:" . md5("$timestamp:$session:$user:$secret"); 
     424        return $self->send_digest_challenge if ($param{nonce} ne $nonce); 
     425 
     426        # The heart of Digest auth: check the response hash. 
    350427        my $bodyhash = ($self->notes('bodyhash')? $self->notes('bodyhash')->hexdigest : '0' x 32); 
    351428        my $A2 = md5_hex( $hd->request_method . ':' . $param{uri} . ($param{qop} eq 'auth-int'? ':' . $bodyhash : '') ); 
     
    353430                                ($param{qop}? $param{nc} . ':' . $param{cnonce} . ':' . $param{qop} . ':' : '') . 
    354431                                $A2 ); 
    355         # Wrong password, or worse. 
    356         $self->send('login_failed',$param{username}), return $self->send_digest_challenge if ($response ne $param{response}); 
    357  
     432 
     433        if ($response ne $param{response}) { 
     434            # Wrong password. 
     435            $self->send('login_failed',$param{username}), return $self->send_digest_challenge if (!$s); 
     436            # Hacking attempt. 
     437            $self->send_digest_forbidden("response mismatch"); 
     438        } 
     439 
     440        # At this point, we know for sure that a known user is (or was) involved with this request. 
     441        # Only from here on we do checks that establish/alter server-side state. 
     442 
     443        # All clients get a new nonce once per hour. 
     444        return $self->send_digest_challenge($session,$param{username}) if $timestamp < time()-3600; 
     445 
     446        # Create response authentication. 
    358447        $A2 = md5_hex( ':' . $param{uri} ); # TODO: provide auth-int? . ($param{qop} eq 'auth-int'? ':' . $bodyhash : '') 
    359448        $response = md5_hex( $hash->[0] . ':' . encode_base64($param{nonce},"") . ':' . 
     
    362451        $self->client->headers_out->header('Authentication-Info',"qop=auth, cnonce=".quoted_string($param{cnonce}).", nc=$param{nc}, rspauth=".quoted_string($response)); 
    363452 
    364         $nonce = $timestamp.':'.$session.':'.md5($timestamp.':'.$session.':'.$_SERVER_SECRET); 
    365         # This could be a hacking attempt, or a server restart. 
    366         return $self->send_digest_challenge($param{nonce},$session) if ($param{nonce} ne $nonce); 
    367  
    368453        $hd->header('Digest-Session',$session); 
    369         if (!defined $_NC{$nonce}) { 
    370             # Force relogin if the first challenge response takes too long (2 minutes). 
    371             return $self->send_digest_challenge if int($timestamp) < time()-120; 
    372             $_NC{$nonce} = 0; 
     454        if (!defined $_SESSIONS{$session}[0]) { 
     455            # successful login, establish server-side state 
     456            $_SESSIONS{$session} = [ -1 ]; 
    373457            $self->send('login',$param{username}); 
     458            # need another round-trip, since we want to tie the session id to the user name 
     459            # but avoid as much server-side state as possible without compromising security. 
     460            return $self->send_digest_challenge($session,$param{username}); 
    374461        } 
    375462 
     
    377464        if (!$param{qop} || $param{qop} eq 'auth,auth-int') { 
    378465            # Change nonce once per minute to reduce replay attack time window. 
    379             return $self->send_digest_challenge($param{nonce},$session) if $timestamp < time()-60; 
     466            return $self->send_digest_challenge($session,$param{username}) if $timestamp < time()-60; 
    380467            return OK; 
    381468        } 
    382469 
    383         # Replay attack, or logged out session. 
    384         return $self->send_digest_challenge if (!$_NC{$nonce} && $param{nc} ne '00000001'); 
    385         # Replay attack. 
    386         return $self->send_digest_forbidden("replay attack") if ($param{nc} ne sprintf("%08x",++$_NC{$nonce})); 
     470        # Increase nonce-count if authentication passed. 
     471        $$s[0]++; 
    387472 
    388473        # All checks passed.