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(
use Bugzilla::Constants;
use Bugzilla::Error;
use Bugzilla::Mailer;
use Bugzilla::Util qw(datetime_from);
use Bugzilla::User::Setting ();
use Bugzilla::Auth::Login::Stack;
use Bugzilla::Auth::Verify::Stack;
use Bugzilla::Auth::Persist::Cookie;
......@@ -162,7 +165,10 @@ sub _handle_login_result {
# the password was just wrong. (This makes it harder for a cracker
# to find account names by brute force)
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
elsif ($fail_code == AUTH_DISABLED) {
......@@ -173,6 +179,40 @@ sub _handle_login_result {
ThrowUserError("account_disabled",
{'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.
else {
ThrowCodeError("authres_unhandled", { value => $fail_code });
......@@ -234,6 +274,11 @@ various fields to be used in the error message.
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>
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.
The user successfully logged in, but their account has been disabled.
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
The C<login> function (below) can do different types of login, depending
......
......@@ -41,37 +41,51 @@ sub check_credentials {
my $dbh = Bugzilla->dbh;
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;
my $password = $login_data->{password};
$login_data->{user} = $user;
$login_data->{bz_username} = $user->login;
if ($user->account_is_locked_out) {
return { failure => AUTH_LOCKOUT, user => $user };
}
trick_taint($username);
my ($real_password_crypted) = $dbh->selectrow_array(
"SELECT cryptpassword FROM profiles WHERE userid = ?",
undef, $user_id);
my $password = $login_data->{password};
my $real_password_crypted = $user->cryptpassword;
# Using the internal crypted password as the salt,
# crypt the password the user entered.
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
# password tokens they may have generated.
Bugzilla::Token::DeletePasswordTokens($user_id, "user_logged_in");
# password tokens or login failures they may have generated.
Bugzilla::Token::DeletePasswordTokens($user->id, "user_logged_in");
$user->clear_login_failures();
# If their old password was using crypt() or some different hash
# than we're using now, convert the stored password to using
# whatever hashing system we're using now.
my $current_algorithm = PASSWORD_DIGEST_ALGORITHM;
if ($real_password_crypted !~ /{\Q$current_algorithm\E}$/) {
my $new_crypted = bz_crypt($password);
$dbh->do('UPDATE profiles SET cryptpassword = ? WHERE userid = ?',
undef, $new_crypted, $user_id);
$user->set_password($password);
$user->update();
}
return $login_data;
......
......@@ -34,6 +34,7 @@ package Bugzilla::Config::Common;
use strict;
use Email::Address;
use Socket;
use Bugzilla::Util;
......@@ -50,7 +51,7 @@ use base qw(Exporter);
check_user_verify_class
check_mail_delivery_method check_notification check_utf8
check_bug_status check_smtp_auth check_theschwartz_available
check_maxattachmentsize
check_maxattachmentsize check_email
);
# Checking functions for the various values
......@@ -94,6 +95,14 @@ sub check_regexp {
return $@;
}
sub check_email {
my ($value) = @_;
if ($value !~ $Email::Address::mailbox) {
return "must be a valid email address.";
}
return "";
}
sub check_sslbase {
my $url = shift;
if ($url ne '') {
......
......@@ -43,7 +43,8 @@ sub get_param_list {
{
name => 'maintainer',
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;
AUTH_LOGINFAILED
AUTH_DISABLED
AUTH_NO_SUCH_USER
AUTH_LOCKOUT
USER_PASSWORD_MIN_LENGTH
......@@ -149,6 +150,8 @@ use File::Basename;
MAX_TOKEN_AGE
MAX_LOGINCOOKIE_AGE
MAX_LOGIN_ATTEMPTS
LOGIN_LOCKOUT_INTERVAL
SAFE_PROTOCOLS
LEGAL_CONTENT_TYPES
......@@ -227,6 +230,7 @@ use constant AUTH_ERROR => 2;
use constant AUTH_LOGINFAILED => 3;
use constant AUTH_DISABLED => 4;
use constant AUTH_NO_SUCH_USER => 5;
use constant AUTH_LOCKOUT => 6;
# The minimum length a password must have.
use constant USER_PASSWORD_MIN_LENGTH => 6;
......@@ -373,6 +377,12 @@ use constant MAX_TOKEN_AGE => 3;
# How many days a logincookie will remain valid if not used.
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.
use constant SAFE_PROTOCOLS => ('afs', 'cid', 'ftp', 'gopher', 'http', 'https',
'irc', 'mid', 'news', 'nntp', 'prospero', 'telnet',
......
......@@ -23,6 +23,7 @@
# Lance Larsh <lance.larsh@oracle.com>
# Dennis Melentyev <dennis.melentyev@infopulse.com.ua>
# Akamai Technologies <bugzilla-dev@akamai.com>
# Elliotte Martin <emartin@everythingsolved.com>
package Bugzilla::DB::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
# change is requested. Tokens provide an extra measure of security
# for these changes.
......
......@@ -65,6 +65,11 @@ use base qw(Bugzilla::Object Exporter);
# 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_FAILED => 0;
use constant USER_MATCH_SUCCESS => 1;
......@@ -247,6 +252,15 @@ sub is_disabled { $_[0]->disabledtext ? 1 : 0; }
sub showmybugslink { $_[0]->{showmybugslink}; }
sub email_disabled { $_[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 {
my ($self, $authorizer) = @_;
......@@ -1655,6 +1669,54 @@ sub create {
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 {
my ($username, $old_username) = @_;
......@@ -1848,6 +1910,29 @@ groups.
=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
=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 @@
that login name.
[% 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" %]
[% title = "Invalid Characters In Alias" %]
The alias you entered, <em>[% alias FILTER html %]</em>,
......@@ -962,6 +968,15 @@
[% ELSIF error == "invalid_username_or_password" %]
[% title = "Invalid Username Or Password" %]
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" %]
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