Commit 72cb2bc7 authored by mkanat%bugzilla.org's avatar mkanat%bugzilla.org

Bug 355283: Lock out a user account on a particular IP for 30 minutes if they…

Bug 355283: Lock out a user account on a particular IP for 30 minutes if they fail to log in 5 times from that IP. Patch by Max Kanat-Alexander <mkanat@bugzilla.org> r=LpSolit, a=LpSolit
parent cb4a8bf4
...@@ -32,6 +32,9 @@ use fields qw( ...@@ -32,6 +32,9 @@ use fields qw(
use Bugzilla::Constants; use Bugzilla::Constants;
use Bugzilla::Error; use Bugzilla::Error;
use Bugzilla::Mailer;
use Bugzilla::Util qw(datetime_from);
use Bugzilla::User::Setting ();
use Bugzilla::Auth::Login::Stack; use Bugzilla::Auth::Login::Stack;
use Bugzilla::Auth::Verify::Stack; use Bugzilla::Auth::Verify::Stack;
use Bugzilla::Auth::Persist::Cookie; use Bugzilla::Auth::Persist::Cookie;
...@@ -162,7 +165,10 @@ sub _handle_login_result { ...@@ -162,7 +165,10 @@ sub _handle_login_result {
# the password was just wrong. (This makes it harder for a cracker # the password was just wrong. (This makes it harder for a cracker
# to find account names by brute force) # to find account names by brute force)
elsif ($fail_code == AUTH_LOGINFAILED or $fail_code == AUTH_NO_SUCH_USER) { elsif ($fail_code == AUTH_LOGINFAILED or $fail_code == AUTH_NO_SUCH_USER) {
ThrowUserError("invalid_username_or_password"); my $remaining_attempts = MAX_LOGIN_ATTEMPTS
- ($result->{failure_count} || 0);
ThrowUserError("invalid_username_or_password",
{ remaining => $remaining_attempts });
} }
# The account may be disabled # The account may be disabled
elsif ($fail_code == AUTH_DISABLED) { elsif ($fail_code == AUTH_DISABLED) {
...@@ -173,6 +179,40 @@ sub _handle_login_result { ...@@ -173,6 +179,40 @@ sub _handle_login_result {
ThrowUserError("account_disabled", ThrowUserError("account_disabled",
{'disabled_reason' => $result->{user}->disabledtext}); {'disabled_reason' => $result->{user}->disabledtext});
} }
elsif ($fail_code == AUTH_LOCKOUT) {
my $attempts = $user->account_ip_login_failures;
# We want to know when the account will be unlocked. This is
# determined by the 5th-from-last login failure (or more/less than
# 5th, if MAX_LOGIN_ATTEMPTS is not 5).
my $determiner = $attempts->[scalar(@$attempts) - MAX_LOGIN_ATTEMPTS];
my $unlock_at = datetime_from($determiner->{login_time},
Bugzilla->local_timezone);
$unlock_at->add(minutes => LOGIN_LOCKOUT_INTERVAL);
# If we were *just* locked out, notify the maintainer about the
# lockout.
if ($result->{just_locked_out}) {
# We're sending to the maintainer, who may be not a Bugzilla
# account, but just an email address. So we use the
# installation's default language for sending the email.
my $default_settings = Bugzilla::User::Setting::get_defaults();
my $template = Bugzilla->template_inner($default_settings->{lang});
my $vars = {
locked_user => $user,
attempts => $attempts,
unlock_at => $unlock_at,
};
my $message;
$template->process('email/lockout.txt.tmpl', $vars, \$message)
|| ThrowTemplateError($template->error);
MessageToMTA($message);
}
$unlock_at->set_time_zone($user->timezone);
ThrowUserError('account_locked',
{ ip_addr => $determiner->{ip_addr}, unlock_at => $unlock_at });
}
# If we get here, then we've run out of options, which shouldn't happen. # If we get here, then we've run out of options, which shouldn't happen.
else { else {
ThrowCodeError("authres_unhandled", { value => $fail_code }); ThrowCodeError("authres_unhandled", { value => $fail_code });
...@@ -234,6 +274,11 @@ various fields to be used in the error message. ...@@ -234,6 +274,11 @@ various fields to be used in the error message.
An incorrect username or password was given. An incorrect username or password was given.
The hashref may also contain a C<failure_count> element, which specifies
how many times the account has failed to log in within the lockout
period (see L</AUTH_LOCKOUT>). This is used to warn the user when
he is getting close to being locked out.
=head2 C<AUTH_NO_SUCH_USER> =head2 C<AUTH_NO_SUCH_USER>
This is an optional more-specific version of C<AUTH_LOGINFAILED>. This is an optional more-specific version of C<AUTH_LOGINFAILED>.
...@@ -251,6 +296,15 @@ should never be communicated to the user, for security reasons. ...@@ -251,6 +296,15 @@ should never be communicated to the user, for security reasons.
The user successfully logged in, but their account has been disabled. The user successfully logged in, but their account has been disabled.
Usually this is throw only by C<Bugzilla::Auth::login>. Usually this is throw only by C<Bugzilla::Auth::login>.
=head2 C<AUTH_LOCKOUT>
The user's account is locked out after having failed to log in too many
times within a certain period of time (as specified by
L<Bugzilla::Constants/LOGIN_LOCKOUT_INTERVAL>).
The hashref will also contain a C<user> element, representing the
L<Bugzilla:User> whose account is locked out.
=head1 LOGIN TYPES =head1 LOGIN TYPES
The C<login> function (below) can do different types of login, depending The C<login> function (below) can do different types of login, depending
......
...@@ -41,37 +41,51 @@ sub check_credentials { ...@@ -41,37 +41,51 @@ sub check_credentials {
my $dbh = Bugzilla->dbh; my $dbh = Bugzilla->dbh;
my $username = $login_data->{username}; my $username = $login_data->{username};
my $user_id = login_to_id($username); my $user = new Bugzilla::User({ name => $username });
return { failure => AUTH_NO_SUCH_USER } unless $user_id; return { failure => AUTH_NO_SUCH_USER } unless $user;
$login_data->{bz_username} = $username; $login_data->{user} = $user;
my $password = $login_data->{password}; $login_data->{bz_username} = $user->login;
if ($user->account_is_locked_out) {
return { failure => AUTH_LOCKOUT, user => $user };
}
trick_taint($username); my $password = $login_data->{password};
my ($real_password_crypted) = $dbh->selectrow_array( my $real_password_crypted = $user->cryptpassword;
"SELECT cryptpassword FROM profiles WHERE userid = ?",
undef, $user_id);
# Using the internal crypted password as the salt, # Using the internal crypted password as the salt,
# crypt the password the user entered. # crypt the password the user entered.
my $entered_password_crypted = bz_crypt($password, $real_password_crypted); my $entered_password_crypted = bz_crypt($password, $real_password_crypted);
return { failure => AUTH_LOGINFAILED } if ($entered_password_crypted ne $real_password_crypted) {
if $entered_password_crypted ne $real_password_crypted; # Record the login failure
$user->note_login_failure();
# Immediately check if we are locked out
if ($user->account_is_locked_out) {
return { failure => AUTH_LOCKOUT, user => $user,
just_locked_out => 1 };
}
return { failure => AUTH_LOGINFAILED,
failure_count => scalar(@{ $user->account_ip_login_failures }),
};
}
# The user's credentials are okay, so delete any outstanding # The user's credentials are okay, so delete any outstanding
# password tokens they may have generated. # password tokens or login failures they may have generated.
Bugzilla::Token::DeletePasswordTokens($user_id, "user_logged_in"); Bugzilla::Token::DeletePasswordTokens($user->id, "user_logged_in");
$user->clear_login_failures();
# If their old password was using crypt() or some different hash # If their old password was using crypt() or some different hash
# than we're using now, convert the stored password to using # than we're using now, convert the stored password to using
# whatever hashing system we're using now. # whatever hashing system we're using now.
my $current_algorithm = PASSWORD_DIGEST_ALGORITHM; my $current_algorithm = PASSWORD_DIGEST_ALGORITHM;
if ($real_password_crypted !~ /{\Q$current_algorithm\E}$/) { if ($real_password_crypted !~ /{\Q$current_algorithm\E}$/) {
my $new_crypted = bz_crypt($password); $user->set_password($password);
$dbh->do('UPDATE profiles SET cryptpassword = ? WHERE userid = ?', $user->update();
undef, $new_crypted, $user_id);
} }
return $login_data; return $login_data;
......
...@@ -34,6 +34,7 @@ package Bugzilla::Config::Common; ...@@ -34,6 +34,7 @@ package Bugzilla::Config::Common;
use strict; use strict;
use Email::Address;
use Socket; use Socket;
use Bugzilla::Util; use Bugzilla::Util;
...@@ -50,7 +51,7 @@ use base qw(Exporter); ...@@ -50,7 +51,7 @@ use base qw(Exporter);
check_user_verify_class check_user_verify_class
check_mail_delivery_method check_notification check_utf8 check_mail_delivery_method check_notification check_utf8
check_bug_status check_smtp_auth check_theschwartz_available check_bug_status check_smtp_auth check_theschwartz_available
check_maxattachmentsize check_maxattachmentsize check_email
); );
# Checking functions for the various values # Checking functions for the various values
...@@ -94,6 +95,14 @@ sub check_regexp { ...@@ -94,6 +95,14 @@ sub check_regexp {
return $@; return $@;
} }
sub check_email {
my ($value) = @_;
if ($value !~ $Email::Address::mailbox) {
return "must be a valid email address.";
}
return "";
}
sub check_sslbase { sub check_sslbase {
my $url = shift; my $url = shift;
if ($url ne '') { if ($url ne '') {
......
...@@ -43,7 +43,8 @@ sub get_param_list { ...@@ -43,7 +43,8 @@ sub get_param_list {
{ {
name => 'maintainer', name => 'maintainer',
type => 't', type => 't',
default => 'THE MAINTAINER HAS NOT YET BEEN SET' default => 'please.set.the.maintainer.parameter@administration.parameters',
checker => \&check_email,
}, },
{ {
......
...@@ -55,6 +55,7 @@ use File::Basename; ...@@ -55,6 +55,7 @@ use File::Basename;
AUTH_LOGINFAILED AUTH_LOGINFAILED
AUTH_DISABLED AUTH_DISABLED
AUTH_NO_SUCH_USER AUTH_NO_SUCH_USER
AUTH_LOCKOUT
USER_PASSWORD_MIN_LENGTH USER_PASSWORD_MIN_LENGTH
...@@ -149,6 +150,8 @@ use File::Basename; ...@@ -149,6 +150,8 @@ use File::Basename;
MAX_TOKEN_AGE MAX_TOKEN_AGE
MAX_LOGINCOOKIE_AGE MAX_LOGINCOOKIE_AGE
MAX_LOGIN_ATTEMPTS
LOGIN_LOCKOUT_INTERVAL
SAFE_PROTOCOLS SAFE_PROTOCOLS
LEGAL_CONTENT_TYPES LEGAL_CONTENT_TYPES
...@@ -227,6 +230,7 @@ use constant AUTH_ERROR => 2; ...@@ -227,6 +230,7 @@ use constant AUTH_ERROR => 2;
use constant AUTH_LOGINFAILED => 3; use constant AUTH_LOGINFAILED => 3;
use constant AUTH_DISABLED => 4; use constant AUTH_DISABLED => 4;
use constant AUTH_NO_SUCH_USER => 5; use constant AUTH_NO_SUCH_USER => 5;
use constant AUTH_LOCKOUT => 6;
# The minimum length a password must have. # The minimum length a password must have.
use constant USER_PASSWORD_MIN_LENGTH => 6; use constant USER_PASSWORD_MIN_LENGTH => 6;
...@@ -373,6 +377,12 @@ use constant MAX_TOKEN_AGE => 3; ...@@ -373,6 +377,12 @@ use constant MAX_TOKEN_AGE => 3;
# How many days a logincookie will remain valid if not used. # How many days a logincookie will remain valid if not used.
use constant MAX_LOGINCOOKIE_AGE => 30; use constant MAX_LOGINCOOKIE_AGE => 30;
# Maximum failed logins to lock account for this IP
use constant MAX_LOGIN_ATTEMPTS => 5;
# If the maximum login attempts occur during this many minutes, the
# account is locked.
use constant LOGIN_LOCKOUT_INTERVAL => 30;
# Protocols which are considered as safe. # Protocols which are considered as safe.
use constant SAFE_PROTOCOLS => ('afs', 'cid', 'ftp', 'gopher', 'http', 'https', use constant SAFE_PROTOCOLS => ('afs', 'cid', 'ftp', 'gopher', 'http', 'https',
'irc', 'mid', 'news', 'nntp', 'prospero', 'telnet', 'irc', 'mid', 'news', 'nntp', 'prospero', 'telnet',
......
...@@ -23,6 +23,7 @@ ...@@ -23,6 +23,7 @@
# Lance Larsh <lance.larsh@oracle.com> # Lance Larsh <lance.larsh@oracle.com>
# Dennis Melentyev <dennis.melentyev@infopulse.com.ua> # Dennis Melentyev <dennis.melentyev@infopulse.com.ua>
# Akamai Technologies <bugzilla-dev@akamai.com> # Akamai Technologies <bugzilla-dev@akamai.com>
# Elliotte Martin <emartin@everythingsolved.com>
package Bugzilla::DB::Schema; package Bugzilla::DB::Schema;
...@@ -982,6 +983,25 @@ use constant ABSTRACT_SCHEMA => { ...@@ -982,6 +983,25 @@ use constant ABSTRACT_SCHEMA => {
], ],
}, },
login_failure => {
FIELDS => [
user_id => {TYPE => 'INT3', NOTNULL => 1,
REFERENCES => {TABLE => 'profiles',
COLUMN => 'userid',
DELETE => 'CASCADE'}},
login_time => {TYPE => 'DATETIME', NOTNULL => 1},
ip_addr => {TYPE => 'varchar(40)', NOTNULL => 1},
],
INDEXES => [
# We do lookups by every item in the table simultaneously, but
# having an index with all three items would be the same size as
# the table. So instead we have an index on just the smallest item,
# to speed lookups.
login_failure_user_id_idx => ['user_id'],
],
},
# "tokens" stores the tokens users receive when a password or email # "tokens" stores the tokens users receive when a password or email
# change is requested. Tokens provide an extra measure of security # change is requested. Tokens provide an extra measure of security
# for these changes. # for these changes.
......
...@@ -65,6 +65,11 @@ use base qw(Bugzilla::Object Exporter); ...@@ -65,6 +65,11 @@ use base qw(Bugzilla::Object Exporter);
# Constants # Constants
##################################################################### #####################################################################
# Used as the IP for authentication failures for password-lockout purposes
# when there is no IP (for example, if we're doing authentication from the
# command line for some reason).
use constant NO_IP => '255.255.255.255';
use constant USER_MATCH_MULTIPLE => -1; use constant USER_MATCH_MULTIPLE => -1;
use constant USER_MATCH_FAILED => 0; use constant USER_MATCH_FAILED => 0;
use constant USER_MATCH_SUCCESS => 1; use constant USER_MATCH_SUCCESS => 1;
...@@ -247,6 +252,15 @@ sub is_disabled { $_[0]->disabledtext ? 1 : 0; } ...@@ -247,6 +252,15 @@ sub is_disabled { $_[0]->disabledtext ? 1 : 0; }
sub showmybugslink { $_[0]->{showmybugslink}; } sub showmybugslink { $_[0]->{showmybugslink}; }
sub email_disabled { $_[0]->{disable_mail}; } sub email_disabled { $_[0]->{disable_mail}; }
sub email_enabled { !($_[0]->{disable_mail}); } sub email_enabled { !($_[0]->{disable_mail}); }
sub cryptpassword {
my $self = shift;
# We don't store it because we never want it in the object (we
# don't want to accidentally dump even the hash somewhere).
my ($pw) = Bugzilla->dbh->selectrow_array(
'SELECT cryptpassword FROM profiles WHERE userid = ?',
undef, $self->id);
return $pw;
}
sub set_authorizer { sub set_authorizer {
my ($self, $authorizer) = @_; my ($self, $authorizer) = @_;
...@@ -1655,6 +1669,54 @@ sub create { ...@@ -1655,6 +1669,54 @@ sub create {
return $user; return $user;
} }
###########################
# Account Lockout Methods #
###########################
sub account_is_locked_out {
my $self = shift;
my $login_failures = scalar @{ $self->account_ip_login_failures };
return $login_failures >= MAX_LOGIN_ATTEMPTS ? 1 : 0;
}
sub note_login_failure {
my $self = shift;
my $ip_addr = Bugzilla->cgi->remote_addr || NO_IP;
trick_taint($ip_addr);
Bugzilla->dbh->do("INSERT INTO login_failure (user_id, ip_addr, login_time)
VALUES (?, ?, LOCALTIMESTAMP(0))",
undef, $self->id, $ip_addr);
delete $self->{account_ip_login_failures};
}
sub clear_login_failures {
my $self = shift;
my $ip_addr = Bugzilla->cgi->remote_addr || NO_IP;
trick_taint($ip_addr);
Bugzilla->dbh->do(
'DELETE FROM login_failure WHERE user_id = ? AND ip_addr = ?',
undef, $self->id, $ip_addr);
delete $self->{account_ip_login_failures};
}
sub account_ip_login_failures {
my $self = shift;
my $dbh = Bugzilla->dbh;
my $time = $dbh->sql_interval(LOGIN_LOCKOUT_INTERVAL, 'MINUTE');
my $ip_addr = Bugzilla->cgi->remote_addr || NO_IP;
trick_taint($ip_addr);
$self->{account_ip_login_failures} ||= Bugzilla->dbh->selectall_arrayref(
"SELECT login_time, ip_addr, user_id FROM login_failure
WHERE user_id = ? AND login_time > LOCALTIMESTAMP(0) - $time
AND ip_addr = ?
ORDER BY login_time", {Slice => {}}, $self->id, $ip_addr);
return $self->{account_ip_login_failures};
}
###############
# Subroutines #
###############
sub is_available_username { sub is_available_username {
my ($username, $old_username) = @_; my ($username, $old_username) = @_;
...@@ -1848,6 +1910,29 @@ groups. ...@@ -1848,6 +1910,29 @@ groups.
=back =back
=head2 Account Lockout
=over
=item C<account_is_locked_out>
Returns C<1> if the account has failed to log in too many times recently,
and thus is locked out for a period of time. Returns C<0> otherwise.
=item C<account_ip_login_failures>
Returns an arrayref of hashrefs, that contains information about the recent
times that this account has failed to log in from the current remote IP.
The hashes contain C<ip_addr>, C<login_time>, and C<user_id>.
=item C<note_login_failure>
This notes that this account has failed to log in, and stores the fact
in the database. The storing happens immediately, it does not wait for
you to call C<update>.
=back
=head2 Other Methods =head2 Other Methods
=over =over
......
[%# The contents of this file are subject to the Mozilla Public
# License Version 1.1 (the "License"); you may not use this file
# except in compliance with the License. You may obtain a copy of
# the License at http://www.mozilla.org/MPL/
#
# Software distributed under the License is distributed on an "AS
# IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
# implied. See the License for the specific language governing
# rights and limitations under the License.
#
# The Original Code is the Bugzilla Bug Tracking System.
#
# The Initial Developer of the Original Code is the Mozilla Corporation.
# Portions created by the Initial Developer are Copyright (C) 2008
# the Initial Developer. All Rights Reserved.
#
# Contributor(s):
# Max Kanat-Alexander <mkanat@bugzilla.org>
#%]
[% PROCESS global/variables.none.tmpl %]
From: [% Param('mailfrom') %]
To: [% Param('maintainer') %]
Subject: [[% terms.Bugzilla %]] Account Lock-Out: [% locked_user.login %] ([% attempts.0.ip_addr %])
X-Bugzilla-Type: admin
The IP address [% attempts.0.ip_addr %] failed too many login attempts (
[%- constants.MAX_LOGIN_ATTEMPTS +%]) for
the account [% locked_user.login %].
The login attempts occurred at these times:
[% FOREACH login = attempts %]
[%+ login.login_time FILTER time %]
[% END %]
This IP will be able to log in again using this account at
[%+ unlock_at FILTER time %].
...@@ -77,6 +77,12 @@ ...@@ -77,6 +77,12 @@
that login name. that login name.
[% END %] [% END %]
[% ELSIF error == "account_locked" %]
[% title = "Account Locked" %]
Your IP ([% ip_addr FILTER html %]) has been locked out of this
account until [% unlock_at FILTER time %], as you have
exceeded the maximum number of login attempts.
[% ELSIF error == "alias_has_comma_or_space" %] [% ELSIF error == "alias_has_comma_or_space" %]
[% title = "Invalid Characters In Alias" %] [% title = "Invalid Characters In Alias" %]
The alias you entered, <em>[% alias FILTER html %]</em>, The alias you entered, <em>[% alias FILTER html %]</em>,
...@@ -962,6 +968,15 @@ ...@@ -962,6 +968,15 @@
[% ELSIF error == "invalid_username_or_password" %] [% ELSIF error == "invalid_username_or_password" %]
[% title = "Invalid Username Or Password" %] [% title = "Invalid Username Or Password" %]
The username or password you entered is not valid. The username or password you entered is not valid.
[%# People get two login attempts before being warned about
# being locked out.
#%]
[% IF remaining <= 2 %]
If you do not enter the correct password after
[%+ remaining FILTER html %] more attempt(s), you will be
locked out of this account for
[%+ constants.LOGIN_LOCKOUT_INTERVAL FILTER html %] minutes.
[% END %]
[% ELSIF error == "json_rpc_post_only" %] [% ELSIF error == "json_rpc_post_only" %]
For security reasons, you may only use JSON-RPC with the POST For security reasons, you may only use JSON-RPC with the POST
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment