| 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. |
|---|
| | 156 | A file containing username/password information. It contains a colon-separated list of |
|---|
| | 157 | username, realm, Digest credentials, Basic credentials. You can manage password files by |
|---|
| | 158 | calling this plugin as standalone perl script. One password file can contain multiple |
|---|
| | 159 | passwords for each user, one per realm. So take care to specify the correct realm value |
|---|
| | 160 | when you add/change passwords. |
|---|
| | 161 | |
|---|
| | 162 | If you have Digest::SHA or Digest::SHA::PurePerl installed, Basic credentials are stored |
|---|
| | 163 | in a more secure fashion. |
|---|
| | 164 | |
|---|
| | 165 | For compatibility, Apache-style htpasswd and htdigest files are also accepted. |
|---|
| | 166 | |
|---|
| | 167 | =head2 C<AuthDomain> I<domain(s)> |
|---|
| | 168 | |
|---|
| | 169 | A string value to send as C<domain> option in HTTP Digest authorization. It consists of a |
|---|
| | 170 | quoted string with one or more absolute URIs or absolute paths, separated by space and |
|---|
| | 171 | specifies where this authentication session is valid. It is perfectly legal to have URIs |
|---|
| | 172 | for different hosts in that list. Default value is "/", i.e., the whole server. |
|---|
| | 173 | |
|---|
| | 174 | =head1 API |
|---|
| | 203 | sub quoted_string($) { |
|---|
| | 204 | my $str = shift; |
|---|
| | 205 | $str =~ s/(["\\])/\\$1/g; |
|---|
| | 206 | return "\"$str\""; |
|---|
| | 207 | } |
|---|
| | 208 | |
|---|
| | 209 | my $_SERVER_SECRET = rand().rand(); # FIXME: get some kind of cryptographically secure randomness |
|---|
| | 210 | my %_NC; |
|---|
| | 211 | |
|---|
| | 212 | =head2 C<< logout >> |
|---|
| | 213 | |
|---|
| | 214 | Inhvalidates (logs out) the current session. Any further attempt to access protected |
|---|
| | 215 | pages will result in a new login prompt. |
|---|
| | 216 | |
|---|
| | 217 | =cut |
|---|
| | 218 | |
|---|
| | 219 | sub 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 | |
|---|
| | 228 | sub 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 | |
|---|
| | 246 | sub send_digest_forbidden { |
|---|
| | 247 | my ($self, $msg) = @_; |
|---|
| | 248 | $self->log(LOGWARN,"Invalid access! Possible hacking attempt. $msg"); |
|---|
| | 249 | return FORBIDDEN; |
|---|
| | 250 | } |
|---|
| | 251 | |
|---|
| 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; |
|---|
| 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; |
|---|