diff options
-rw-r--r-- | Bugzilla.pm | 20 | ||||
-rw-r--r-- | Bugzilla/DB/Schema.pm | 4 | ||||
-rw-r--r-- | Bugzilla/User.pm | 48 | ||||
-rwxr-xr-x | editusers.cgi | 6 | ||||
-rwxr-xr-x | reset_password.cgi | 72 | ||||
-rw-r--r-- | skins/standard/admin.css | 4 | ||||
-rw-r--r-- | template/en/default/account/reset-password.html.tmpl | 210 | ||||
-rw-r--r-- | template/en/default/admin/users/edit.html.tmpl | 12 | ||||
-rw-r--r-- | template/en/default/admin/users/userdata.html.tmpl | 37 | ||||
-rw-r--r-- | template/en/default/global/messages.html.tmpl | 4 | ||||
-rw-r--r-- | template/en/default/global/user-error.html.tmpl | 4 | ||||
-rwxr-xr-x | token.cgi | 25 |
12 files changed, 401 insertions, 45 deletions
diff --git a/Bugzilla.pm b/Bugzilla.pm index 8a0ff2fd7..fa95128d1 100644 --- a/Bugzilla.pm +++ b/Bugzilla.pm @@ -367,8 +367,26 @@ sub login { } my $authenticated_user = $authorizer->login($type); - + # At this point, we now know if a real person is logged in. + + # Check if a password reset is required + if ($authenticated_user->password_change_required) { + # We cannot show the password reset UI for API calls, so treat those as + # a disabled account. + if (i_am_webservice()) { + ThrowUserError("account_disabled", { disabled_reason => $authenticated_user->password_change_reason }); + } + + # only allow the reset-password and token pages to handle requests + # (tokens handles the 'forgot password' process) + # otherwise redirect user to the reset-password page. + if ($ENV{SCRIPT_NAME} !~ m#/(?:reset_password|token)\.cgi$#) { + print Bugzilla->cgi->redirect('reset_password.cgi'); + exit; + } + } + # We must now check to see if an sudo session is in progress. # For a session to be in progress, the following must be true: # 1: There must be a logged in user diff --git a/Bugzilla/DB/Schema.pm b/Bugzilla/DB/Schema.pm index a9dfa6d8e..abe52be90 100644 --- a/Bugzilla/DB/Schema.pm +++ b/Bugzilla/DB/Schema.pm @@ -947,8 +947,8 @@ use constant ABSTRACT_SCHEMA => { mybugslink => {TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'TRUE'}, extern_id => {TYPE => 'varchar(64)'}, - is_enabled => {TYPE => 'BOOLEAN', NOTNULL => 1, - DEFAULT => 'TRUE'}, + is_enabled => {TYPE => 'BOOLEAN', NOTNULL => 1, + DEFAULT => 'TRUE'}, last_seen_date => {TYPE => 'DATETIME'}, password_change_required => { TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'FALSE' }, password_change_reason => { TYPE => 'varchar(64)' }, diff --git a/Bugzilla/User.pm b/Bugzilla/User.pm index 9bfb4046f..4a0c2166d 100644 --- a/Bugzilla/User.pm +++ b/Bugzilla/User.pm @@ -106,6 +106,8 @@ sub DB_COLUMNS { 'profiles.extern_id', 'profiles.is_enabled', $dbh->sql_date_format('last_seen_date', '%Y-%m-%d') . ' AS last_seen_date', + 'profiles.password_change_required', + 'profiles.password_change_reason', ), } @@ -114,13 +116,15 @@ use constant ID_FIELD => 'userid'; use constant LIST_ORDER => NAME_FIELD; use constant VALIDATORS => { - cryptpassword => \&_check_password, - disable_mail => \&_check_disable_mail, - disabledtext => \&_check_disabledtext, - login_name => \&check_login_name_for_creation, - realname => \&_check_realname, - extern_id => \&_check_extern_id, - is_enabled => \&_check_is_enabled, + cryptpassword => \&_check_password, + disable_mail => \&_check_disable_mail, + disabledtext => \&_check_disabledtext, + login_name => \&check_login_name_for_creation, + realname => \&_check_realname, + extern_id => \&_check_extern_id, + is_enabled => \&_check_is_enabled, + password_change_required => \&Bugzilla::Object::check_boolean, + password_change_reason => \&_check_password_change_reason, }; sub UPDATE_COLUMNS { @@ -132,13 +136,16 @@ sub UPDATE_COLUMNS { realname extern_id is_enabled + password_change_required + password_change_reason ); push(@cols, 'cryptpassword') if exists $self->{cryptpassword}; return @cols; }; use constant VALIDATOR_DEPENDENCIES => { - is_enabled => ['disabledtext'], + is_enabled => [ 'disabledtext' ], + password_change_reason => [ 'password_change_required' ], }; use constant EXTRA_REQUIRED_FIELDS => qw(is_enabled); @@ -343,13 +350,22 @@ sub _check_is_enabled { return $disabledtext ? 0 : 1; } +sub _check_password_change_reason { + my ($self, $value) = @_; + return $self->password_change_required + ? trim($_[1]) || '' + : ''; +} + ################################################################################ # Mutators ################################################################################ -sub set_disable_mail { $_[0]->set('disable_mail', $_[1]); } -sub set_email_enabled { $_[0]->set('disable_mail', !$_[1]); } -sub set_extern_id { $_[0]->set('extern_id', $_[1]); } +sub set_disable_mail { $_[0]->set('disable_mail', $_[1]); } +sub set_email_enabled { $_[0]->set('disable_mail', !$_[1]); } +sub set_extern_id { $_[0]->set('extern_id', $_[1]); } +sub set_password_change_required { $_[0]->set('password_change_required', $_[1]); } +sub set_password_change_reason { $_[0]->set('password_change_reason', $_[1]); } sub set_login { my ($self, $login) = @_; @@ -364,7 +380,12 @@ sub set_name { delete $self->{identity}; } -sub set_password { $_[0]->set('cryptpassword', $_[1]); } +sub set_password { + my ($self, $password) = @_; + $self->set('cryptpassword', $password); + $self->set('password_change_required', 0); + $self->set('password_change_reason', ''); +} sub set_disabledtext { my ($self, $text) = @_; @@ -514,6 +535,9 @@ sub showmybugslink { $_[0]->{showmybugslink}; } sub email_disabled { $_[0]->{disable_mail} || !$_[0]->{is_enabled}; } sub email_enabled { !$_[0]->email_disabled; } sub last_seen_date { $_[0]->{last_seen_date}; } +sub password_change_required { $_[0]->{password_change_required}; } +sub password_change_reason { $_[0]->{password_change_reason}; } + sub cryptpassword { my $self = shift; # We don't store it because we never want it in the object (we diff --git a/editusers.cgi b/editusers.cgi index a55fd04a7..bb23279ff 100755 --- a/editusers.cgi +++ b/editusers.cgi @@ -264,6 +264,12 @@ if ($action eq 'search') { $otherUser->set_disable_mail($cgi->param('disable_mail')); $otherUser->set_extern_id($cgi->param('extern_id')) if defined($cgi->param('extern_id')); + $otherUser->set_password_change_required($cgi->param('password_change_required')); + $otherUser->set_password_change_reason( + $otherUser->password_change_required + ? $cgi->param('password_change_reason') + : '' + ); $changes = $otherUser->update(); } diff --git a/reset_password.cgi b/reset_password.cgi new file mode 100755 index 000000000..f784afb81 --- /dev/null +++ b/reset_password.cgi @@ -0,0 +1,72 @@ +#!/usr/bin/perl -wT + +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +use strict; + +use lib qw(. lib); + +use Bugzilla; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Token; +use Bugzilla::User qw( validate_password ); +use Bugzilla::Util qw( bz_crypt ); + +my $cgi = Bugzilla->cgi; +my $user = Bugzilla->login(LOGIN_REQUIRED); +my $template = Bugzilla->template; +my $dbh = Bugzilla->dbh; + +if ($cgi->param('do_save')) { + my $token = $cgi->param('token'); + check_token_data($token, 'reset_password'); + + my $old_password = $cgi->param('old_password') // ''; + my $password_1 = $cgi->param('new_password1') // ''; + my $password_2 = $cgi->param('new_password2') // ''; + + # make sure passwords never show up in the UI + foreach my $field (qw( old_password new_password1 new_password2 )) { + $cgi->delete($field); + } + + # validation + my $old_crypt_password = $user->cryptpassword; + if (bz_crypt($old_password, $old_crypt_password) ne $old_crypt_password) { + ThrowUserError('old_password_incorrect'); + } + if ($password_1 eq '' || $password_2 eq '') { + ThrowUserError('new_password_missing'); + } + if ($old_password eq $password_1) { + ThrowUserError('new_password_same'); + } + validate_password($password_1, $password_2); + + # update + $dbh->bz_start_transaction; + $user->set_password($password_1); + $user->update({ keep_session => 1, keep_tokens => 1 }); + Bugzilla->logout(LOGOUT_KEEP_CURRENT); + delete_token($token); + $dbh->bz_commit_transaction; + + # done + print $cgi->header(); + $template->process('index.html.tmpl', { message => 'password_changed' }) + || ThrowTemplateError($template->error()); +} + +else { + my $token = issue_session_token('reset_password'); + + print $cgi->header(); + $template->process('account/reset-password.html.tmpl', { token => $token }) + || ThrowTemplateError($template->error()); +} diff --git a/skins/standard/admin.css b/skins/standard/admin.css index 7782086a4..a8fabb645 100644 --- a/skins/standard/admin.css +++ b/skins/standard/admin.css @@ -179,6 +179,10 @@ th.title { cursor: default; } +input[disabled] { + background-color: transparent !important; +} + /* User Preferences Page */ #prefmain { diff --git a/template/en/default/account/reset-password.html.tmpl b/template/en/default/account/reset-password.html.tmpl new file mode 100644 index 000000000..2b1d297dc --- /dev/null +++ b/template/en/default/account/reset-password.html.tmpl @@ -0,0 +1,210 @@ +[%# This Source Code Form is subject to the terms of the Mozilla Public + # License, v. 2.0. If a copy of the MPL was not distributed with this + # file, You can obtain one at http://mozilla.org/MPL/2.0/. + # + # This Source Code Form is "Incompatible With Secondary Licenses", as + # defined by the Mozilla Public License, v. 2.0. + #%] + +[% inline_style = BLOCK %] + +.field-hr, .field-row { + clear: both; +} + +.field-row { + height: 2.5em; +} + +.field-name { + text-align: right; + width: 150px; + float: left; + padding-top: 2px; + font-weight: bold; +} + +.field-value { + margin-left: 160px; +} + +#errors, #complexity_rules { + margin-left: 160px; + max-width: 500px; +} + +#errors { + color: #dd4848; + position: absolute; + margin-left: 500px; +} + +#errors ul { + padding: 0; + margin: 0; +} + +#complexity_rules { + margin-bottom: 50px; +} + +[% END %] + +[% inline_js = BLOCK %] +$(function() { + + $('#old_password, #new_password1, #new_password2') + .keyup(function() { + var errors = []; + var old = $('#old_password').val(); + var new1 = $('#new_password1').val(); + var new2 = $('#new_password2').val(); + + if (old === '') { + errors.push('Missing current password'); + } + if (new1 === '' || new2 === '') { + errors.push('Missing new password'); + } + else if (new1 !== new2) { + errors.push('New passwords do not match'); + } + else if (new1 === old) { + errors.push('Your new password must be different from your old password'); + } + else if (new1.length < [% constants.USER_PASSWORD_MIN_LENGTH FILTER none %]) { + errors.push('Your password must be at least [% constants.USER_PASSWORD_MIN_LENGTH FILTER none %] long'); + } + else { + var complexity_fn; + [% SWITCH Param('password_complexity') %] + [% CASE 'no_constraints' %] + complexity_fn = function() {}; + [% CASE 'mixed_letters' %] + complexity_fn = function(pass, errors) { + if ( + pass.search(/[a-z]/) == -1 || + pass.search(/[A-Z]/) == -1 + ) { + errors.push('New password is not complex enough'); + } + }; + [% CASE 'letters_numbers' %] + complexity_fn = function(pass, errors) { + if ( + pass.search(/[a-z]/) == -1 || + pass.search(/[A-Z]/) == -1 || + pass.search(/[0-9]/) == -1 + ) { + errors.push('New password is not complex enough'); + } + }; + [% CASE 'letters_numbers_specialchars' %] + complexity_fn = function(pass, errors) { + if ( + pass.search(/[a-z]/) == -1 || + pass.search(/[A-Z]/) == -1 || + pass.search(/[0-9]/) == -1 || + pass.search(/\W/) == -1 + ) { + errors.push('New password is not complex enough'); + } + }; + [% END %] + complexity_fn(new1, errors); + } + + $('#submit').attr('disabled', errors.length > 0); + if ((old !== '' || new1 !== '' || new2 !== '') && errors.length) { + $('#errors').html('<ul><li>' + errors.join('</li><li>') + '</li></ul>'); + } + else { + $('#errors').html(''); + } + }) + .keyup(); + + $('#forgot_password') + .click(function(event) { + event.preventDefault(); + $('#forgot-form').submit(); + }); +}); + +[% END %] + +[% PROCESS global/header.html.tmpl + title = "Password change required" + style = inline_style + javascript = inline_js +%] + +<h1>Password Reset</h1> + +<p> + [% user.password_change_reason || "You are required to update your password." FILTER html %] +</p> + +<form method="POST" action="reset_password.cgi"> +<input type="hidden" name="token" value="[% token FILTER html %]"> +<input type="hidden" name="do_save" value="1"> + +<div id="password-reset"> + <div class="field-hr"> </div> + <div class="field-row"> + <div class="field-name">Email</div> + <div class="field-value"> + [% user.login FILTER html %] + </div> + </div> + <div class="field-row"> + <div class="field-name">Current Password</div> + <div class="field-value"> + <input type="password" name="old_password" id="old_password" size="30"> + </div> + </div> + <div class="field-hr"> </div> + <div id="errors"></div> + <div class="field-row"> + <div class="field-name">New Password</div> + <div class="field-value"> + <input type="password" name="new_password1" id="new_password1" size="30"> + </div> + </div> + <div class="field-row"> + <div class="field-name">New Password</div> + <div class="field-value"> + <input type="password" name="new_password2" id="new_password2" size="30"> + (again) + </div> + </div> + <div class="field-hr"> </div> + <div class="field-row"> + <div class="field-value"> + <input type="submit" id="submit" value="Update Password"> + <a id="forgot_password" href="#">Forgot Password</a> + </div> + </div> +</div> + +</form> + +<p id="complexity_rules"> + Your password must be a minimum of [% constants.USER_PASSWORD_MIN_LENGTH FILTER none %] characters long + [% SWITCH Param('password_complexity') %] + [% CASE 'mixed_letters' %] + and must contain at least one UPPER and one lowercase letter + [% CASE 'letters_numbers' %] + and must contain at least one UPPER and one lowercase letter and a number + [% CASE 'letters_numbers_specialchars' %] + and must contain at least one letter, a number and a special character + [% END ~%]. +</p> + +<form action="token.cgi" method="post" id="forgot-form"> + <input type="hidden" name="loginname" value="[% user.login FILTER html %]"> + <input type="hidden" name="a" value="reqpw"> + <input type="hidden" name="token" value="[% issue_hash_token(['reqpw']) FILTER html %]"> +</form> + +[% PROCESS global/footer.html.tmpl %] diff --git a/template/en/default/admin/users/edit.html.tmpl b/template/en/default/admin/users/edit.html.tmpl index a5ab5cf03..fbf283574 100644 --- a/template/en/default/admin/users/edit.html.tmpl +++ b/template/en/default/admin/users/edit.html.tmpl @@ -38,6 +38,18 @@ $(function() { } }) .keyup(); + + $('#password_change_required') + .change(function() { + $('#password').prop('disabled', $(this).is(':checked')); + if ($(this).is(':checked')) { + $('#password_change_reason_container').show(); + } + else { + $('#password_change_reason_container').hide(); + } + }) + .change(); }); [% END %] diff --git a/template/en/default/admin/users/userdata.html.tmpl b/template/en/default/admin/users/userdata.html.tmpl index 63f69cfe9..ebe7451e4 100644 --- a/template/en/default/admin/users/userdata.html.tmpl +++ b/template/en/default/admin/users/userdata.html.tmpl @@ -64,24 +64,37 @@ </td> </tr> -[%# XXX This condition (can_change_password) will cause a problem - # if we ever have a login system that can create accounts through - # createaccount.cgi but can't change passwords. - #%] - [% IF editusers %] - [% IF user.authorizer.can_change_password %] <tr> - <th><label for="password">Password:</label></th> + <th> + <label for="password"> + [% IF editform %] + [% otheruser.cryptpassword == '*' ? "Set" : "Update" =%] + [% END %] + Password: + </label> + </th> <td> - <input type="password" size="16" name="password" id="password" - value="" autocomplete="off"> - [% IF editform %]<br> - (Enter new password to change.) + <input type="password" size="16" name="password" id="password" value="" autocomplete="off"> + [%# if a user's cryptpassword is '*' it means they use an auth provider + # such as github, or you can't log in with that account. in either case + # forcing a password reset isn't valid %] + [% IF otheruser.cryptpassword != '*' && editform %] + <br> + <input type="checkbox" name="password_change_required" id="password_change_required" maxlength="64" + [% " checked" IF otheruser.password_change_required %]> + <label for="password_change_required">User must change their password immediately</label> + <div id="password_change_reason_container"> + Password change reason (will be displayed to the user):<br> + <input type="text" size="64" + name="password_change_reason" id="password_change_reason" + value="[% otheruser.password_change_reason || "You are required to change your password." FILTER html %]" + > + </div> [% END %] </td> </tr> - [% END %] + <tr> <th><label for="disable_mail">[% terms.Bug %]mail Disabled:</label></th> <td> diff --git a/template/en/default/global/messages.html.tmpl b/template/en/default/global/messages.html.tmpl index 0f408842f..4cefe2a3f 100644 --- a/template/en/default/global/messages.html.tmpl +++ b/template/en/default/global/messages.html.tmpl @@ -70,6 +70,10 @@ [% ELSE %] [% terms.Bug %]mail has been enabled. [% END %] + [% ELSIF field == 'password_change_required' %] + The user [% otheruser.password_change_required ? "must" : "no longer needs to" %] update their password. + [% ELSIF field == 'password_change_reason' %] + The password change reason has been modified. [% END %] </li> [% END %] diff --git a/template/en/default/global/user-error.html.tmpl b/template/en/default/global/user-error.html.tmpl index 814a02c13..98076ce1c 100644 --- a/template/en/default/global/user-error.html.tmpl +++ b/template/en/default/global/user-error.html.tmpl @@ -1352,6 +1352,10 @@ [% title = "New Password Missing" %] You must enter a new password. + [% ELSIF error == "new_password_same" %] + [% title = "Password Unchanged" %] + Your new password cannot be the same as your old password. + [% ELSIF error == "no_axes_defined" %] [% title = "No Axes Defined" %] [% docslinks = {'reporting.html' => 'Reporting'} %] @@ -212,25 +212,14 @@ sub changePassword { my ($token, $password) = @_; my $dbh = Bugzilla->dbh; - # Create a crypted version of the new password - my $cryptedpassword = bz_crypt($password); - - # Get the user's ID from the tokens table. - my ($userid) = $dbh->selectrow_array('SELECT userid FROM tokens - WHERE token = ?', undef, $token); - - # Update the user's password in the profiles table and delete the token - # from the tokens table. - $dbh->bz_start_transaction(); - $dbh->do(q{UPDATE profiles - SET cryptpassword = ? - WHERE userid = ?}, - undef, ($cryptedpassword, $userid) ); - Bugzilla->memcached->clear({ table => 'profiles', id => $userid }); - $dbh->do('DELETE FROM tokens WHERE token = ?', undef, $token); - $dbh->bz_commit_transaction(); + my ($user_id) = $dbh->selectrow_array('SELECT userid FROM tokens WHERE token = ?', undef, $token); + my $user = Bugzilla::User->check({ id => $user_id }); + $user->set_password($password); + $user->update(); + delete_token($token); + $dbh->do("DELETE FROM tokens WHERE userid = ? AND tokentype = 'password'", undef, $user_id); - Bugzilla->logout_user_by_id($userid); + Bugzilla->logout_user_by_id($user_id); $vars->{'message'} = "password_changed"; |