Changeset 215

Show
Ignore:
Timestamp:
09/26/06 15:45:32 (2 years ago)
Author:
jwalt
Message:
  • do not overwrite an existing Last-Modified header in serve_file
  • implement HTTP Digest auth (qop=auth only for now)
Files:

Legend:

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

    r214 r215  
    4242            'origcase',     # href; lowercase header -> provided case 
    4343            'hdorder',      # aref; order headers were received (canonical order) 
    44             'method',       # scalar; request method (if GET request) 
     44            'method',       # scalar; request method 
    4545            'uri',          # scalar; request URI (if GET request) 
    4646            'file',         # scalar; request File 
  • trunk/plugins/aio/serve_file

    r177 r215  
    7878            } 
    7979             
    80             my $mtime = http_date((stat(_))[9]); 
     80            my $mtime = $client->headers_out->header("Last-Modified") || http_date((stat(_))[9]); 
    8181            my $ifmod = $client->headers_in->header('If-Modified-Since') || ""; 
    8282             
  • trunk/plugins/authenticate

    r214 r215  
    1616# 
    1717 
    18 use Digest::MD5 qw(md5_base64); 
     18use Digest::MD5 qw(md5_base64 md5_hex md5); 
    1919use MIME::Base64; 
     20use bytes; 
     21use constant DEFAULT_REALM => 'Protected Area'; 
     22 
     23# regexes for RFC2616/2617 
     24my $TOKEN = "[!#$%&'*+.0-9`^_|~a-zA-Z-]+"; 
     25my $CTRL = "\x00-\x08\x0b\x0c\x0e-\x1f"; 
     26my $QUOTED_STRING = "\"(?:[^$CTRL\"\\\\]+\\\\[^$CTRL])*[^$CTRL\"\\\\]*\""; 
    2027 
    2128BEGIN { 
     
    2532    my $has_sha = !$@; 
    2633 
     34    # If called standalone, manage passwd files 
    2735    if ($0 =~ m{(^|/)authenticate$}) { 
    28         if (@ARGV < 2 || @ARGV > 3) { 
    29             print "Usage: $0 <file> [ -d ] <username> [ <password> ]\n"; 
     36        if (@ARGV < 2 || @ARGV > 4) { 
     37            print "Usage: $0 <file> [ -d ] <username> [ <realm> [ <password> ] ]\n"; 
    3038            exit(1); 
    3139        } 
    3240 
    33         my ($file, $newuser, $newpass) = @ARGV; 
     41        my ($file, $newuser, $newrealm, $newpass) = @ARGV; 
    3442        my %data; 
    3543 
     
    3846                next if $line =~ m/^\s*(#.*)?$/; 
    3947                $line =~ s/\s*$//; 
    40                 my ($user, $pass) = split(/:/,$line); 
    41                 $data{$user} = $pass; 
     48                my ($user, $realm, $digest, $pass) = split(/:/,$line); 
     49                # convert old passwd files/two variants of Apache password files 
     50                if (!defined $digest) { 
     51                    $pass = $realm; 
     52                    $realm = DEFAULT_REALM; 
     53                    $digest = ''; 
     54                } elsif (!defined $pass) { 
     55                    $pass = ''; 
     56                } 
     57                $data{"$user:$realm"} = "$digest:$pass"; 
    4258            } 
    4359            close($fh); 
     
    4763            delete $data{$newpass}; 
    4864        } else { 
     65            $newrealm = DEFAULT_REALM unless defined $newrealm; 
     66            $newrealm =~ s/[\x00-\x08\x0b\x0c\x0e-\x1f:]//g; 
    4967            if (!defined $newpass) { 
    5068                $|++; 
     
    6179                print "\n"; 
    6280 
    63                 $term->getattr; 
    6481                $term->setlflag($term->getlflag | &POSIX::ECHO); 
    6582                $term->setattr; 
     
    6885            } 
    6986 
     87            $data{"$newuser:$newrealm"} = md5_hex($newuser.':'.$newrealm.':'.$newpass); 
    7088            my $salt = join("", map({ ('a'..'z','A'..'Z',0..9,'/','.')[int(rand(64))] } 0..7) ); 
    7189            if ($has_sha) { 
    72                 $data{$newuser} = '{SHA256}'.$salt.sha256_base64($salt.$newpass); 
     90                $data{"$newuser:$newrealm"} .= ':{SHA256}'.$salt.sha256_base64($salt.$newpass); 
    7391            } else { 
    74                 $data{$newuser} = '{MD5}'.$salt.md5_base64($salt.$newpass); 
     92                $data{"$newuser:$newrealm"} .= ':{MD5}'.$salt.md5_base64($salt.$newpass); 
    7593            } 
    7694        } 
     
    108126or other mechanisms. Any user in C<AuthFile> is granted access. 
    109127 
     128If you use Digest authentication, you also gain a free session ID for all 
     129authenticated users without needing cookies or URL parameters. It is generated 
     130at login and can be retrieved through C<< $client->headers_in->header('Digest-Session') >>. 
     131If you use that mechanism, you can even log out users using the logout function. 
     132 
    110133=head1 CONFIG 
    111134 
     
    131154=head2 C<AuthFile> I<filename> 
    132155 
    133 A file containing username/password information. The exact contents depend on the 
    134 authentication method. You can manage password files by calling this plugin as standalone 
    135 perl script. 
    136  
    137 For Basic authentication, it should contain colon-separated pairs of username and password, 
    138 one per line. Call this plugin as standalone perl script to add entries. By default, passwords 
    139 are hashed with MD5, but for compatibility with Apache password files, passwords encrypted 
    140 with crypt() or SHA-1 are also allowed. If you have Digest::SHA installed, SHA-256 is used 
    141 instead of MD5. 
     156A file containing username/password information. It contains a colon-separated list of 
     157username, realm, Digest credentials, Basic credentials. You can manage password files by 
     158calling this plugin as standalone perl script. One password file can contain multiple 
     159passwords for each user, one per realm. So take care to specify the correct realm value 
     160when you add/change passwords. 
     161 
     162If you have Digest::SHA or Digest::SHA::PurePerl installed, Basic credentials are stored 
     163in a more secure fashion. 
     164 
     165For compatibility, Apache-style htpasswd and htdigest files are also accepted. 
     166 
     167=head2 C<AuthDomain> I<domain(s)> 
     168 
     169A string value to send as C<domain> option in HTTP Digest authorization. It consists of a 
     170quoted string with one or more absolute URIs or absolute paths, separated by space and 
     171specifies where this authentication session is valid. It is perfectly legal to have URIs 
     172for different hosts in that list. Default value is "/", i.e., the whole server. 
     173 
     174=head1 API 
    142175 
    143176=cut 
     
    145178sub conf_AuthType : Validate(sub { _die("Invalid type") if $_[1] !~ m/^(basic|digest)$/i; lc($_[1]); }); 
    146179sub conf_AuthRealm : Default("Protected Area"); 
     180sub conf_AuthDomain : Default("/"); 
    147181sub conf_AuthFile : Validate(FILE) Default({}) { 
    148182    my ($parser, $file) = @_; 
     
    152186        next if $line =~ m/^\s*(#.*)?$/; 
    153187        $line =~ s/\s*$//; 
    154         my ($user, $pass) = split(/:/,$line); 
    155         $data{$user} = $pass; 
     188        my ($user, $realm, $digest, $pass) = split(/:/,$line); 
     189        # compatibility to Apache's htpasswd and htdigest 
     190        if (!defined $digest) { 
     191            $pass = $realm; 
     192            $realm = DEFAULT_REALM; 
     193            $digest = ''; 
     194        } elsif (!defined $pass) { 
     195            $pass = ''; 
     196        } 
     197        $data{"$user:$realm"} = [ $digest, $pass ]; 
    156198    } 
    157199    close($fh); 
     
    159201} 
    160202 
     203sub quoted_string($) { 
     204    my $str = shift; 
     205    $str =~ s/(["\\])/\\$1/g; 
     206    return "\"$str\""; 
     207} 
     208 
     209my $_SERVER_SECRET = rand().rand(); # FIXME: get some kind of cryptographically secure randomness 
     210my %_NC; 
     211 
     212=head2 C<< logout >> 
     213 
     214Inhvalidates (logs out) the current session. Any further attempt to access protected 
     215pages will result in a new login prompt. 
     216 
     217=cut 
     218 
     219sub logout { 
     220    my ($self) = @_; 
     221    my $session = $self->client->headers_in->header('Digest-Session'); 
     222    return unless defined $session; 
     223    for my $nonce (grep { m/^\d+:\Q$session:/ } keys %_NC) { 
     224        delete $_NC{$nonce}; 
     225    } 
     226} 
     227 
     228sub send_digest_challenge { 
     229    my ($self, $oldnonce, $session) = @_; 
     230    delete $_NC{$oldnonce} if defined $oldnonce; # This is not failsafe in the face of pipelined requests; it doesn't need to be. 
     231    my $nonce = time().':'.(defined $session?$session:md5_hex(rand(time()))); 
     232    $nonce .= ":".md5($nonce.":".$_SERVER_SECRET); 
     233    $_NC{$nonce} = 0 if defined $session; 
     234    my $header = "Digest realm=" . quoted_string($self->config->auth_realm) . ", " . 
     235        "domain=".quoted_string($self->config->auth_domain).", " . 
     236        "qop=\"auth,auth-int\", " . 
     237        "algorithm=MD5, " . 
     238        "stale=" . (defined $session?'true':'false') . ", " . 
     239        "nonce=".quoted_string(encode_base64($nonce,"")); 
     240    $self->log(LOGINFO,"Sent header [$header]"); 
     241    $self->client->headers_out->header("WWW-Authenticate",$header); 
     242 
     243    return UNAUTHORIZED; 
     244} 
     245 
     246sub send_digest_forbidden { 
     247    my ($self, $msg) = @_; 
     248    $self->log(LOGWARN,"Invalid access! Possible hacking attempt. $msg"); 
     249    return FORBIDDEN; 
     250} 
     251 
    161252sub hook_authentication { 
    162253    my ($self, $hd) = @_; 
     
    166257 
    167258    my $auth = $hd->header('Authorization'); 
    168     $self->log(LOGINFO, "$want_type (got: $auth)"); 
    169  
    170     if (defined $auth) { 
    171         my ($type,$args) = $auth =~ m{^\s*([!#$%&'*+.0-9`^_|~a-zA-Z-]+)\s+(.*?)\s*$}; 
    172  
    173         if (lc($type) ne $want_type) { 
    174             # fall through 
    175  
    176         } elsif ($type eq 'Basic') { 
    177             $args = decode_base64($args); 
    178             my ($user, $pass) = split(/:/,$args,2); 
    179             my $hash = $self->config->auth_file->{$user}; 
    180  
    181             if (defined $hash) { 
    182                 $hd->user($user); 
    183                 return OK if ($hash =~ m/^{SHA256}(.{8})(.{43})/ && sha256_base64($1.$pass) eq $2); 
    184                 return OK if ($hash =~ m/^{MD5}(.{8})(.{22})/ && md5_base64($1.$pass) eq $2); 
    185  
    186                 # for Apache compatibility 
    187                 return OK if ($hash =~ m/^{SHA}(.{27})/ && sha1_base64($pass) eq $1); 
    188                 return OK if (crypt($pass,$hash) eq $hash); 
     259    $self->log(LOGINFO, "$want_type ".(defined $auth?"(got: $auth)":"(no authorization header)")); 
     260 
     261    no warnings "uninitialized"; 
     262 
     263    my ($type,$args) = $auth =~ m{^\s*($TOKEN)\s+(.*?)\s*$}; 
     264 
     265    undef $args if (lc($type) ne $want_type); 
     266 
     267    if ($want_type eq 'basic') { 
     268        $args = decode_base64($args); 
     269        my ($user, $pass) = split(/:/,$args,2); 
     270        my $hash = $self->config->auth_file->{$user.':'.$self->config->auth_realm}->[1]; 
     271 
     272        if ($args && defined $hash) { 
     273            $hd->user($user); 
     274            return OK if ($hash =~ m/^{SHA256}(.{8})(.{43})/ && sha256_base64($1.$pass) eq $2); 
     275            return OK if ($hash =~ m/^{MD5}(.{8})(.{22})/ && md5_base64($1.$pass) eq $2); 
     276 
     277            # for Apache compatibility 
     278            return OK if ($hash =~ m/^{SHA}(.{27})/ && sha1_base64($pass) eq $1); 
     279            return OK if (crypt($pass,$hash) eq $hash); 
     280        } 
     281 
     282        $self->client->headers_out->header("WWW-Authenticate","Basic realm=" . quoted_string($self->config->auth_realm)); 
     283        return UNAUTHORIZED; 
     284 
     285    } elsif ($want_type eq 'digest') { 
     286        return $self->send_digest_challenge if (!$args); 
     287 
     288        my %param; 
     289        my @args = $args =~ m/(?:^|\s)($TOKEN=(?:$TOKEN|$QUOTED_STRING)),?/g; 
     290        foreach my $arg (@args) { 
     291            my ($param, $value) = split(/=/,$arg,2); 
     292            if ($value =~ m/^"(.*)"$/) { 
     293                $value =~ s/^"|"$//g; 
     294                $value =~ s/\\(.)/$1/g; 
    189295            } 
    190  
    191         } else { 
    192             return BAD_REQUEST; 
    193         } 
     296            warn("Param: $param, Value: $value\n"); 
     297            $param{lc($param)} = $value; 
     298        } 
     299 
     300        # Something fishy going on. Or the well-known IE bug. 
     301        # FIXME: proxies may alter the request URI. Does this really happen? Allowing mismatches lowers security. 
     302        if ($param{uri} ne $hd->request_uri) { 
     303            my $uri = $hd->request_uri; 
     304            $uri =~ s/\?.*//; 
     305            return $self->send_digest_forbidden("invalid uri") if ($hd->header('User-Agent') !~ m/MSIE/ || $param{uri} ne $uri); 
     306        } 
     307 
     308        # This could be a hacking attempt, a config change, or an ambiguous config. 
     309        return $self->send_digest_challenge if ($param{realm} ne $self->config->auth_realm); 
     310 
     311        my $hash = $self->config->auth_file->{$param{username}.':'.$param{realm}}; 
     312        # Unknown username/realm combination 
     313        return $self->send_digest_challenge if (!$hash); 
     314 
     315        $param{nonce} = decode_base64($param{nonce}); 
     316        my ($timestamp,$session,$nonce) = split(/:/,$param{nonce},3); 
     317        # This can't possibly be one of our nonces, so there is probably something fishy going on. 
     318        return $self->send_digest_forbidden("impossible nonce") if (int($timestamp) ne $timestamp || !defined $nonce || int($timestamp) > time()); 
     319 
     320        my $bodyhash; # TODO 
     321        my $A2 = md5_hex( $hd->request_method . ':' . $param{uri} . ($param{qop} eq 'auth-int'? ':' . $bodyhash : '') ); 
     322        my $response = md5_hex( $hash->[0] . ':' . encode_base64($param{nonce},"") . ':' . 
     323                                ($param{qop}? $param{nc} . ':' . $param{cnonce} . ':' . $param{qop} . ':' : '') . 
     324                                $A2 ); 
     325        # Wrong password, or worse. TODO: add a "failed password" callback 
     326        return $self->send_digest_challenge if ($response ne $param{response}); 
     327 
     328        my $nonce = $timestamp.':'.$session.':'.md5($timestamp.':'.$session.':'.$_SERVER_SECRET); 
     329        # This could be a hacking attempt, or a server restart. 
     330        return $self->send_digest_challenge($param{nonce},$session) if ($param{nonce} ne $nonce); 
     331 
     332 
     333        $hd->header('Digest-Session',$session); 
     334        if (!defined $_NC{$nonce}) { 
     335            # Force relogin if the first challenge response takes too long (2 minutes). 
     336            return $self->send_digest_challenge if int($timestamp) < time()-120; 
     337            $_NC{nonce} = 0; 
     338            # TODO: add a 'successful login' callback 
     339        } 
     340 
     341        # Backwards compatiblity, and Konqueror bug; vulnerable to replay attacks. 
     342        if (!$param{qop} || $param{qop} eq 'auth,auth-int') { 
     343            # Change nonce once per minute to reduce replay attack time window. 
     344            return $self->send_digest_challenge($param{nonce},$session) if $timestamp < time()-60; 
     345            return OK; 
     346        } 
     347 
     348        # Replay attack, or logged out session. 
     349        return $self->send_digest_challenge if (!$_NC{$nonce} && $param{nc} ne '00000001'); 
     350        # Replay attack. 
     351        return $self->send_digest_forbidden("replay attack") if ($param{nc} ne sprintf("%08x",++$_NC{$nonce})); 
     352 
     353        # TODO: add an "idle-time" callback 
     354 
     355        # All checks passed. 
     356        return OK; 
    194357    } 
    195358 
    196     if ($want_type eq 'basic') { 
    197         $self->client->headers_out->header('WWW-Authenticate','Basic realm="'.$self->config->auth_realm.'"'); 
    198     } 
    199  
    200359    return UNAUTHORIZED; 
    201360} 
  • trunk/plugins/serve_file

    r205 r215  
    6767            } 
    6868             
    69             my $mtime = http_date((stat(_))[9]); 
     69            my $mtime = $client->headers_out->header("Last-Modified") || http_date((stat(_))[9]); 
    7070            my $ifmod = $client->headers_in->header('If-Modified-Since') || ""; 
    7171