summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Bugzilla/Auth.pm21
-rw-r--r--Bugzilla/Auth/Login/Cookie.pm12
-rw-r--r--Bugzilla/Auth/Verify/DB.pm11
-rw-r--r--Bugzilla/Install.pm1
-rw-r--r--Bugzilla/Install/Requirements.pm15
-rw-r--r--Bugzilla/MFA.pm64
-rw-r--r--Bugzilla/MFA/TOTP.pm79
-rw-r--r--Bugzilla/Token.pm9
-rw-r--r--Bugzilla/User.pm38
-rw-r--r--Bugzilla/WebService/Server/REST/Resources/User.pm10
-rw-r--r--Bugzilla/WebService/User.pm14
-rwxr-xr-xeditusers.cgi4
-rw-r--r--extensions/BMO/lib/Reports/Groups.pm30
-rw-r--r--extensions/BMO/template/en/default/pages/group_members.html.tmpl12
-rw-r--r--extensions/GitHubAuth/lib/Login.pm27
-rw-r--r--extensions/Persona/lib/Login.pm6
-rw-r--r--js/account.js90
-rwxr-xr-xscripts/sanitizeme.pl1
-rw-r--r--skins/standard/admin.css48
-rw-r--r--template/en/default/account/cancel-token.txt.tmpl3
-rw-r--r--template/en/default/account/prefs/mfa.html.tmpl134
-rw-r--r--template/en/default/account/prefs/prefs.html.tmpl7
-rw-r--r--template/en/default/admin/users/userdata.html.tmpl22
-rw-r--r--template/en/default/global/header.html.tmpl3
-rw-r--r--template/en/default/global/messages.html.tmpl2
-rw-r--r--template/en/default/global/setting-descs.none.tmpl1
-rw-r--r--template/en/default/global/user-error.html.tmpl21
-rw-r--r--template/en/default/mfa/totp/enroll.html.tmpl59
-rw-r--r--template/en/default/mfa/totp/verify.html.tmpl29
-rw-r--r--template/en/default/setup/strings.txt.pl1
-rwxr-xr-xtoken.cgi19
-rwxr-xr-xuserprefs.cgi58
32 files changed, 821 insertions, 30 deletions
diff --git a/Bugzilla/Auth.pm b/Bugzilla/Auth.pm
index 88eadbe19..a4f2dd9a9 100644
--- a/Bugzilla/Auth.pm
+++ b/Bugzilla/Auth.pm
@@ -33,7 +33,7 @@ use fields qw(
use Bugzilla::Constants;
use Bugzilla::Error;
use Bugzilla::Mailer;
-use Bugzilla::Util qw(datetime_from);
+use Bugzilla::Util qw(datetime_from i_am_webservice);
use Bugzilla::User::Setting ();
use Bugzilla::Auth::Login::Stack;
use Bugzilla::Auth::Verify::Stack;
@@ -93,9 +93,28 @@ sub login {
}
$user->set_authorizer($self);
+ # trigger multi-factor auth. once verified the provider calls mfa_verified()
+ if ($self->{_info_getter}->{successful}->requires_verification
+ && $user->mfa
+ && !Bugzilla->sudoer
+ && !i_am_webservice()
+ ) {
+ $user->mfa_provider->prompt({ user => $user, type => $type });
+ exit;
+ }
+
return $self->_handle_login_result($login_info, $type);
}
+sub mfa_verified {
+ my ($self, $user, $type) = @_;
+ require Bugzilla::Auth::Login::CGI;
+ $self->{_info_getter}->{successful} = Bugzilla::Auth::Login::CGI->new();
+ $self->_handle_login_result({ user => $user }, $type);
+ print Bugzilla->cgi->redirect('index.cgi');
+ exit;
+}
+
sub successful_info_getter {
my ($self) = @_;
diff --git a/Bugzilla/Auth/Login/Cookie.pm b/Bugzilla/Auth/Login/Cookie.pm
index 738d26b21..0b5842523 100644
--- a/Bugzilla/Auth/Login/Cookie.pm
+++ b/Bugzilla/Auth/Login/Cookie.pm
@@ -40,7 +40,7 @@ sub get_login_info {
my ($self) = @_;
my $cgi = Bugzilla->cgi;
my $dbh = Bugzilla->dbh;
- my ($user_id, $login_cookie);
+ my ($user_id, $login_cookie, $is_internal);
if (!Bugzilla->request_cache->{auth_no_automatic_login}) {
$login_cookie = $cgi->cookie("Bugzilla_logincookie");
@@ -82,6 +82,7 @@ sub get_login_info {
{
ThrowUserError('auth_invalid_token', { token => $api_token });
}
+ $is_internal = 1;
}
}
}
@@ -112,6 +113,15 @@ sub get_login_info {
# If the cookie is valid, return a valid username.
if (defined $db_cookie && $login_cookie eq $db_cookie) {
+
+ # forbid logging in with a cookie if only api-keys are allowed
+ if (i_am_webservice() && !$is_internal) {
+ my $user = Bugzilla::User->new({ id => $user_id, cache => 1 });
+ if ($user->settings->{api_key_only}->{value} eq 'on') {
+ ThrowUserError('invalid_cookies_or_token');
+ }
+ }
+
# If we logged in successfully, then update the lastused
# time on the login cookie
$dbh->do("UPDATE logincookies SET lastused = NOW()
diff --git a/Bugzilla/Auth/Verify/DB.pm b/Bugzilla/Auth/Verify/DB.pm
index aaa1b6c87..ea86346e1 100644
--- a/Bugzilla/Auth/Verify/DB.pm
+++ b/Bugzilla/Auth/Verify/DB.pm
@@ -53,6 +53,7 @@ sub check_credentials {
}
my $password = $login_data->{password};
+ return { failure => AUTH_NODATA } unless defined $login_data->{password};
my $real_password_crypted = $user->cryptpassword;
# Using the internal crypted password as the salt,
@@ -105,6 +106,16 @@ sub check_credentials {
$user->update();
}
+ if (i_am_webservice() && $user->settings->{api_key_only}->{value} eq 'on') {
+ # api-key verification happens in Auth/Login/APIKey
+ # token verification happens in Auth/Login/Cookie
+ # if we get here from an api call then we must be using user/pass
+ return {
+ failure => AUTH_ERROR,
+ user_error => 'invalid_auth_method',
+ };
+ }
+
return $login_data;
}
diff --git a/Bugzilla/Install.pm b/Bugzilla/Install.pm
index d5f4f04cd..8a1113741 100644
--- a/Bugzilla/Install.pm
+++ b/Bugzilla/Install.pm
@@ -99,6 +99,7 @@ sub SETTINGS {
possible_duplicates => { options => ['on', 'off'], default => 'on' },
# 2011-10-11 glob@mozilla.com -- Bug 301656
requestee_cc => { options => ['on', 'off'], default => 'on' },
+ api_key_only => { options => ['on', 'off'], default => 'off' },
}
};
diff --git a/Bugzilla/Install/Requirements.pm b/Bugzilla/Install/Requirements.pm
index 41a4b04e1..e653e5f8c 100644
--- a/Bugzilla/Install/Requirements.pm
+++ b/Bugzilla/Install/Requirements.pm
@@ -411,6 +411,20 @@ sub OPTIONAL_MODULES {
version => '0',
feature => ['elasticsearch'],
},
+
+ # multi factor auth - totp
+ {
+ package => 'Auth-GoogleAuth',
+ module => 'Auth::GoogleAuth',
+ version => '1.01',
+ feature => ['mfa'],
+ },
+ {
+ package => 'GD-Barcode-QRcode',
+ module => 'GD::Barcode::QRcode',
+ version => '0',
+ feature => ['mfa'],
+ },
);
my $extra_modules = _get_extension_requirements('OPTIONAL_MODULES');
@@ -434,6 +448,7 @@ use constant FEATURE_FILES => (
patch_viewer => ['Bugzilla/Attachment/PatchReader.pm'],
updates => ['Bugzilla/Update.pm'],
memcached => ['Bugzilla/Memcache.pm'],
+ mfa => ['Bugzilla/MFA/TOTP.pm'],
);
# This implements the REQUIRED_MODULES and OPTIONAL_MODULES stuff
diff --git a/Bugzilla/MFA.pm b/Bugzilla/MFA.pm
new file mode 100644
index 000000000..564f124cd
--- /dev/null
+++ b/Bugzilla/MFA.pm
@@ -0,0 +1,64 @@
+# 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.
+
+package Bugzilla::MFA;
+use strict;
+
+sub new {
+ my ($class, $user) = @_;
+ return bless({ user => $user }, $class);
+}
+
+# abstract methods
+
+# api call, returns required data to user-prefs enrollment page
+sub enroll {}
+
+# called after the user has confirmed enrollment
+sub enrolled {}
+
+# display page with verification prompt
+sub prompt {}
+
+# throws errors if code is invalid
+sub check {}
+
+# during-login verification
+sub check_login {}
+
+
+# helpers
+
+sub property_get {
+ my ($self, $name) = @_;
+ return scalar Bugzilla->dbh->selectrow_array(
+ "SELECT value FROM profile_mfa WHERE user_id = ? AND name = ?",
+ undef, $self->{user}->id, $name);
+}
+
+sub property_set {
+ my ($self, $name, $value) = @_;
+ Bugzilla->dbh->do(
+ "INSERT INTO profile_mfa (user_id, name, value) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE value = ?",
+ undef, $self->{user}->id, $name, $value, $value);
+}
+
+sub property_delete {
+ my ($self, $name) = @_;
+ Bugzilla->dbh->do(
+ "DELETE FROM profile_mfa WHERE user_id = ? AND name = ?",
+ undef, $self->{user}->id, $name);
+}
+
+sub property_delete_all {
+ my ($self) = @_;
+ Bugzilla->dbh->do(
+ "DELETE FROM profile_mfa WHERE user_id",
+ undef, $self->{user}->id);
+}
+
+1;
diff --git a/Bugzilla/MFA/TOTP.pm b/Bugzilla/MFA/TOTP.pm
new file mode 100644
index 000000000..95e3d89aa
--- /dev/null
+++ b/Bugzilla/MFA/TOTP.pm
@@ -0,0 +1,79 @@
+# 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.
+
+package Bugzilla::MFA::TOTP;
+use strict;
+use parent 'Bugzilla::MFA';
+
+use Auth::GoogleAuth;
+use Bugzilla::Error;
+use Bugzilla::Token qw( issue_session_token );
+use Bugzilla::Util qw( template_var generate_random_password );
+use GD::Barcode::QRcode;
+use MIME::Base64 qw( encode_base64 );
+
+sub _auth {
+ my ($self) = @_;
+ return Auth::GoogleAuth->new({
+ secret => $self->property_get('secret') // $self->property_get('secret.temp'),
+ issuer => template_var('terms')->{BugzillaTitle},
+ key_id => $self->{user}->login,
+ });
+}
+
+sub enroll {
+ my ($self) = @_;
+
+ # create a new secret for the user
+ # store it in secret.temp to avoid overwriting a valid secret
+ $self->property_set('secret.temp', generate_random_password(16));
+
+ # build the qr code
+ my $auth = $self->_auth();
+ my $otpauth = $auth->qr_code(undef, undef, undef, 1);
+ my $png = GD::Barcode::QRcode->new($otpauth, { Version => 10, ModuleSize => 3 })->plot()->png();
+ return { png => encode_base64($png), secret32 => $auth->secret32 };
+}
+
+sub enrolled {
+ my ($self) = @_;
+
+ # make the temporary secret permanent
+ $self->property_set('secret', $self->property_get('secret.temp'));
+ $self->property_delete('secret.temp');
+}
+
+sub prompt {
+ my ($self, $params) = @_;
+ my $template = Bugzilla->template;
+
+ my $vars = {
+ user => $params->{user},
+ token => scalar issue_session_token('mfa', $params->{user}),
+ type => $params->{type},
+ };
+
+ print Bugzilla->cgi->header();
+ $template->process('mfa/totp/verify.html.tmpl', $vars)
+ || ThrowTemplateError($template->error());
+}
+
+sub check {
+ my ($self, $code) = @_;
+ $self->_auth()->verify($code)
+ || ThrowUserError('mfa_totp_bad_code');
+}
+
+sub check_login {
+ my ($self, $user) = @_;
+ my $cgi = Bugzilla->cgi;
+
+ $self->check($cgi->param('code') // '');
+ $user->authorizer->mfa_verified($user, $cgi->param('type'));
+}
+
+1;
diff --git a/Bugzilla/Token.pm b/Bugzilla/Token.pm
index b7227144f..6e1414db6 100644
--- a/Bugzilla/Token.pm
+++ b/Bugzilla/Token.pm
@@ -219,11 +219,12 @@ sub IssuePasswordToken {
}
sub issue_session_token {
+ my ($data, $user) = @_;
# Generates a random token, adds it to the tokens table, and returns
# the token to the caller.
- my $data = shift;
- return _create_token(Bugzilla->user->id, 'session', $data);
+ $user //= Bugzilla->user;
+ return _create_token($user->id, 'session', $data);
}
sub issue_hash_token {
@@ -657,12 +658,14 @@ although they can be used separately.
=over
-=item C<issue_session_token($event)>
+=item C<issue_session_token($event [, $user])>
Description: Creates and returns a token used internally.
Params: $event - The event which needs to be stored in the DB for future
reference/checks.
+ $user - The user to bind the token with. Uses the current user
+ if not provided.
Returns: A unique token.
diff --git a/Bugzilla/User.pm b/Bugzilla/User.pm
index 4a0c2166d..d3bb807b3 100644
--- a/Bugzilla/User.pm
+++ b/Bugzilla/User.pm
@@ -108,6 +108,7 @@ sub DB_COLUMNS {
$dbh->sql_date_format('last_seen_date', '%Y-%m-%d') . ' AS last_seen_date',
'profiles.password_change_required',
'profiles.password_change_reason',
+ 'profiles.mfa',
),
}
@@ -125,6 +126,7 @@ use constant VALIDATORS => {
is_enabled => \&_check_is_enabled,
password_change_required => \&Bugzilla::Object::check_boolean,
password_change_reason => \&_check_password_change_reason,
+ mfa => \&_check_mfa,
};
sub UPDATE_COLUMNS {
@@ -138,6 +140,7 @@ sub UPDATE_COLUMNS {
is_enabled
password_change_required
password_change_reason
+ mfa
);
push(@cols, 'cryptpassword') if exists $self->{cryptpassword};
return @cols;
@@ -266,6 +269,10 @@ sub update {
$self->derive_regexp_groups();
}
+ if (exists $changes->{mfa} && $self->mfa eq '') {
+ $dbh->do("DELETE FROM profile_mfa WHERE user_id = ?", undef, $self->id);
+ }
+
# Logout the user if necessary.
Bugzilla->logout_user($self)
if (!$options->{keep_session}
@@ -357,6 +364,13 @@ sub _check_password_change_reason {
: '';
}
+sub _check_mfa {
+ my ($self, $provider) = @_;
+ $provider = lc($provider // '');
+ return 'TOTP' if $provider eq 'totp';
+ return '';
+}
+
################################################################################
# Mutators
################################################################################
@@ -394,6 +408,15 @@ sub set_disabledtext {
$self->set('disable_mail', 1) if !$self->is_enabled;
}
+sub set_mfa {
+ my ($self, $value) = @_;
+ if ($value eq '' && $self->mfa) {
+ $self->mfa_provider->property_delete_all();
+ }
+ $self->set('mfa', $value);
+ delete $self->{mfa_provider};
+}
+
sub set_groups {
my $self = shift;
$self->_set_groups(GROUP_MEMBERSHIP, @_);
@@ -561,6 +584,21 @@ sub authorizer {
return $self->{authorizer};
}
+sub mfa { $_[0]->{mfa} }
+sub mfa_provider {
+ my ($self) = @_;
+ my $mfa = $self->{mfa} || return undef;
+ return $self->{mfa_provider} if exists $self->{mfa_provider};
+ if ($mfa eq 'TOTP') {
+ require Bugzilla::MFA::TOTP;
+ $self->{mfa_provider} = Bugzilla::MFA::TOTP->new($self);
+ }
+ else {
+ $self->{mfa_provider} = undef;
+ }
+ return $self->{mfa_provider};
+}
+
# Generate a string to identify the user by name + login if the user
# has a name or by login only if she doesn't.
sub identity {
diff --git a/Bugzilla/WebService/Server/REST/Resources/User.pm b/Bugzilla/WebService/Server/REST/Resources/User.pm
index badbc94b2..b9ecc21ba 100644
--- a/Bugzilla/WebService/Server/REST/Resources/User.pm
+++ b/Bugzilla/WebService/Server/REST/Resources/User.pm
@@ -58,7 +58,15 @@ sub _rest_resources {
return { $param => [ $_[0] ] };
}
}
- }
+ },
+ qr{^/user/mfa/([^/]+)/enroll$}, {
+ GET => {
+ method => 'mfa_enroll',
+ params => sub {
+ return { provider => $_[0] };
+ }
+ },
+ },
];
return $rest_resources;
}
diff --git a/Bugzilla/WebService/User.pm b/Bugzilla/WebService/User.pm
index 8592d809c..2d3f5f185 100644
--- a/Bugzilla/WebService/User.pm
+++ b/Bugzilla/WebService/User.pm
@@ -416,6 +416,20 @@ sub _login_to_hash {
return $item;
}
+#
+# MFA
+#
+
+sub mfa_enroll {
+ my ($self, $params) = @_;
+ my $provider_name = lc($params->{provider});
+
+ my $user = Bugzilla->login(LOGIN_REQUIRED);
+ $user->set_mfa($provider_name);
+ my $provider = $user->mfa_provider // die "Unknown MTA provider\n";
+ return $provider->enroll();
+}
+
1;
__END__
diff --git a/editusers.cgi b/editusers.cgi
index bb23279ff..41e247229 100755
--- a/editusers.cgi
+++ b/editusers.cgi
@@ -270,6 +270,10 @@ if ($action eq 'search') {
? $cgi->param('password_change_reason')
: ''
);
+ if ($user->in_group('admin') && $cgi->param('mfa') eq '') {
+ $otherUser->set_mfa('');
+ Bugzilla->audit(sprintf('%s disabled 2FA for %s', $user->login, $otherUser->login));
+ }
$changes = $otherUser->update();
}
diff --git a/extensions/BMO/lib/Reports/Groups.pm b/extensions/BMO/lib/Reports/Groups.pm
index 4a831fab3..3a5cd75dd 100644
--- a/extensions/BMO/lib/Reports/Groups.pm
+++ b/extensions/BMO/lib/Reports/Groups.pm
@@ -174,10 +174,12 @@ sub members_report {
action => 'run',
object => 'group_admins' });
- my @grouplist =
- ($user->in_group('editusers') || $user->in_group('infrasec'))
- ? map { lc($_->name) } Bugzilla::Group->get_all
- : _get_public_membership_groups();
+ my $privileged = $user->in_group('editusers') || $user->in_group('infrasec');
+ $vars->{privileged} = $privileged;
+
+ my @grouplist = $privileged
+ ? map { lc($_->name) } Bugzilla::Group->get_all
+ : _get_public_membership_groups();
my $include_disabled = $cgi->param('include_disabled') ? 1 : 0;
$vars->{'include_disabled'} = $include_disabled;
@@ -240,20 +242,26 @@ sub members_report {
if ($page eq 'group_members.json') {
my %users;
foreach my $rh (@types) {
- my $group_name = $rh->{name} eq '_direct' ? 'direct' : $rh->{name};
foreach my $member (@{ $rh->{members} }) {
my $login = $member->login;
if (exists $users{$login}) {
- push @{ $users{$login}->{groups} }, $group_name;
+ push @{ $users{$login}->{groups} }, $rh->{name} if $privileged;
}
else {
- $users{$login} = {
+ my $rh_user = {
login => $login,
- membership => $rh->{name} eq '_direct' ? 'direct' : 'indirect',
- group => $group_name,
- groups => [ $group_name ],
- lastseen => $member->{lastseen},
+ membership => $rh->{name} eq 'direct' ? 'direct' : 'indirect',
+ rh_name => $rh->{name},
};
+ if ($privileged) {
+ $rh_user->{group} = $rh->{name};
+ $rh_user->{groups} = [ $rh->{name} ];
+ $rh_user->{lastseeon} = $member->{lastseen};
+ $rh_user->{mfa} = $member->mfa;
+ $rh_user->{api_key_only} = $member->settings->{api_key_only}->{value} eq 'on'
+ ? JSON::true : JSON::false;
+ }
+ $users{$login} = $rh_user;
}
}
}
diff --git a/extensions/BMO/template/en/default/pages/group_members.html.tmpl b/extensions/BMO/template/en/default/pages/group_members.html.tmpl
index bd27b8be2..98679c1b7 100644
--- a/extensions/BMO/template/en/default/pages/group_members.html.tmpl
+++ b/extensions/BMO/template/en/default/pages/group_members.html.tmpl
@@ -11,8 +11,6 @@
style_urls = [ "extensions/BMO/web/styles/reports.css" ]
%]
-[% SET privileged = (user.in_group('editusers') || user.in_group('infrasec')) %]
-
<form method="GET" action="page.cgi">
<input type="hidden" name="id" value="group_members.html">
@@ -51,7 +49,7 @@
<th>Count</th>
<th>Members</th>
[% IF privileged %]
- <th class="right">Last Seen (days ago)</th>
+ <th class="right">2FA, Last Seen (days ago)</th>
[% END %]
</tr>
@@ -93,6 +91,14 @@
</a>
</td>
[% IF privileged %]
+ <td nowrap>
+ [% IF member.mfa %]
+ [% member.mfa FILTER html %]
+ [% " (weakened)" IF member.settings.api_key_only.value == "off" %]
+ [% ELSE %]
+ -
+ [% END %]
+ </td>
<td align="right" nowrap>
[% member.lastseen FILTER html %]
</td>
diff --git a/extensions/GitHubAuth/lib/Login.pm b/extensions/GitHubAuth/lib/Login.pm
index 8c91fc08a..933dc6572 100644
--- a/extensions/GitHubAuth/lib/Login.pm
+++ b/extensions/GitHubAuth/lib/Login.pm
@@ -43,14 +43,30 @@ sub get_login_info {
return { failure => AUTH_NODATA } unless $github_login;
+ my $response;
if ($github_email_key && $github_email) {
trick_taint($github_email);
trick_taint($github_email_key);
- return $self->_get_login_info_from_email($github_email, $github_email_key);
+ $response = $self->_get_login_info_from_email($github_email, $github_email_key);
}
else {
- return $self->_get_login_info_from_github();
+ $response = $self->_get_login_info_from_github();
}
+
+ if (!exists $response->{failure}) {
+ my $user = $response->{user};
+ return { failure => AUTH_ERROR,
+ user_error => 'github_auth_account_too_powerful' } if $user->in_group('no-github-auth');
+ return { failure => AUTH_ERROR,
+ user_error => 'mfa_prevents_login',
+ details => { provider => 'GitHub' } } if $user->mfa;
+ $response = {
+ username => $user->login,
+ user_id => $user->id,
+ github_auth => 1,
+ };
+ }
+ return $response;
}
sub _get_login_info_from_github {
@@ -117,7 +133,7 @@ sub _get_login_info_from_github {
if (@allowed_bugzilla_users == 1) {
my ($user) = @allowed_bugzilla_users;
$cgi->remove_cookie('Bugzilla_github_token');
- return { username => $user->login, user_id => $user->id, github_auth => 1 };
+ return { user => $user };
}
elsif (@allowed_bugzilla_users > 1) {
$self->{github_failure} = {
@@ -160,11 +176,8 @@ sub _get_login_info_from_email {
}
my $user = Bugzilla::User->new({name => $github_email, cache => 1});
- return { failure => AUTH_ERROR,
- user_error => 'github_auth_account_too_powerful' } if $user && $user->in_group('no-github-auth');
-
$cgi->remove_cookie('Bugzilla_github_token');
- return { username => $github_email, github_auth => 1 };
+ return { user => $user };
}
sub fail_nodata {
diff --git a/extensions/Persona/lib/Login.pm b/extensions/Persona/lib/Login.pm
index ece92a3c0..c2f8caf2b 100644
--- a/extensions/Persona/lib/Login.pm
+++ b/extensions/Persona/lib/Login.pm
@@ -98,6 +98,12 @@ sub get_login_info {
user_error => 'persona_account_too_powerful' };
}
+ if ($user->mfa) {
+ return { failure => AUTH_ERROR,
+ user_error => 'mfa_prevents_login',
+ details => { provider => 'Persona' } };
+ }
+
$login_data->{'user'} = $user;
$login_data->{'user_id'} = $user->id;
diff --git a/js/account.js b/js/account.js
index c2c7a6282..c287309d8 100644
--- a/js/account.js
+++ b/js/account.js
@@ -6,6 +6,9 @@
* defined by the Mozilla Public License, v. 2.0. */
$(function() {
+
+ // account disabling
+
$('#account-disable-toggle')
.click(function(event) {
event.preventDefault();
@@ -38,4 +41,91 @@ $(function() {
$(window).on('pageshow', function() {
$('#account_disable').val('');
});
+
+ // mfa
+
+ $('#mfa-enable')
+ .click(function(event) {
+ event.preventDefault();
+ $('#mfa-enable-container').show();
+ $('#mfa-api-blurb').show();
+ $(this).hide();
+ });
+
+ $('#mfa')
+ .change(function(event) {
+ var mfa = $(this).val();
+
+ $('.mfa-provider').hide();
+ $('#update').attr('disabled', true);
+ if (mfa === '') {
+ $('#mfa-confirm').hide();
+ }
+ else {
+ $('#mfa-confirm').show();
+ if (mfa === 'TOTP') {
+ $('#mfa-enable-totp').show();
+ $('#mfa-totp-throbber').show();
+ $('#mfa-totp-issued').hide();
+ var url = 'rest/user/mfa/totp/enroll' +
+ '?Bugzilla_api_token=' + encodeURIComponent(BUGZILLA.api_token);
+ $.ajax({
+ "url": url,
+ "contentType": "application/json",
+ "processData": false
+ })
+ .done(function(data) {
+ $('#mfa-totp-throbber').hide();
+ var iframe = $('#mfa-enable-totp-frame').contents();
+ iframe.find('#qr').attr('src', 'data:image/png;base64,' + data.png);
+ iframe.find('#secret').text(data.secret32);
+ $('#mfa-totp-issued').show();
+ $('#mfa-totp-enable-code').focus();
+ $('#update').attr('disabled', false);
+ })
+ .error(function(data) {
+ $('#mfa-totp-throbber').hide();
+ if (data.statusText === 'abort')
+ return;
+ var message = data.responseJSON ? data.responseJSON.message : 'Unexpected Error';
+ console.log(message);
+ });
+ }
+ }
+ })
+ .change();
+
+ $('#mfa-disable')
+ .click(function(event) {
+ event.preventDefault();
+ $('#mfa-disable-container').show();
+ $('#mfa-confirm').show();
+ $('#mfa-api-blurb').hide();
+ $('#mfa-totp-disable-code').focus();
+ $('#update').attr('disabled', false);
+ $(this).hide();
+ });
+
+ var totp_popup;
+ $('#mfa-totp-apps, #mfa-totp-text')
+ .click(function(event) {
+ event.preventDefault();
+ totp_popup = $('#' + $(this).attr('id') + '-popup').bPopup({
+ speed: 100,
+ followSpeed: 100,
+ modalColor: '#444'
+ });
+ });
+ $('.mfa-totp-popup-close')
+ .click(function(event) {
+ event.preventDefault();
+ totp_popup.close();
+ });
+
+ if ($('#mfa-action').length) {
+ $('#update').attr('disabled', true);
+ $(window).on('pageshow', function() {
+ $('#mfa').val('').change();
+ });
+ }
});
diff --git a/scripts/sanitizeme.pl b/scripts/sanitizeme.pl
index 6d4119905..c9dc814bd 100755
--- a/scripts/sanitizeme.pl
+++ b/scripts/sanitizeme.pl
@@ -195,6 +195,7 @@ sub delete_sensitive_user_data {
$dbh->do("DELETE FROM user_api_keys");
$dbh->do("DELETE FROM profiles_activity");
$dbh->do("DELETE FROM profile_search");
+ $dbh->do("DELETE FROM profile_mfa");
$dbh->do("DELETE FROM namedqueries");
$dbh->do("DELETE FROM tokens");
$dbh->do("DELETE FROM logincookies");
diff --git a/skins/standard/admin.css b/skins/standard/admin.css
index a8fabb645..e45865490 100644
--- a/skins/standard/admin.css
+++ b/skins/standard/admin.css
@@ -190,12 +190,12 @@ input[disabled] {
}
#prefnav {
- width: 14em;
+ width: 15em;
float: left;
}
#prefcontent {
- margin-left: 15em;
+ margin-left: 16em;
padding: .2em 1.5em 1.5em 1.5em;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
background: #FFF none repeat scroll 0% 0%;
@@ -229,3 +229,47 @@ input[disabled] {
font-weight: bold;
background: #fff;
}
+
+/* mfa */
+
+#mfa {
+ margin-bottom: 1em;
+}
+
+#mfa-confirm {
+ margin-top: 2em;
+}
+
+#mfa-container {
+ margin-bottom: 2em;
+}
+
+#mfa-totp-throbber {
+ width: 300px;
+ height: 280px;
+}
+
+.mfa-totp-popup {
+ background: #fff;
+ padding: 10px 30px;
+ box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
+}
+
+.mfa-totp-popup ul {
+ padding: 0 0 0 2em;
+}
+
+.mfa-totp-popup-close {
+ float: right;
+}
+
+#mfa-enable-totp-frame {
+ border: none;
+ width: 300px;
+ height: 220px;
+}
+
+#mfa-enroll-embedded {
+ background: none;
+ padding: 0;
+}
diff --git a/template/en/default/account/cancel-token.txt.tmpl b/template/en/default/account/cancel-token.txt.tmpl
index 6619dedd3..bc35e2d4c 100644
--- a/template/en/default/account/cancel-token.txt.tmpl
+++ b/template/en/default/account/cancel-token.txt.tmpl
@@ -93,6 +93,9 @@ Canceled Because: [% PROCESS cancelactionmessage %]
[% ELSIF cancelaction == 'wrong_token_for_creating_account' %]
You have tried to use the token to create a user account.
+ [% ELSIF cancelaction == 'wrong_token_for_mfa' %]
+ You have tried to use the token for MFA.
+
[% ELSE %]
[%# Give sensible error if the cancel-token function is used incorrectly.
#%]
diff --git a/template/en/default/account/prefs/mfa.html.tmpl b/template/en/default/account/prefs/mfa.html.tmpl
new file mode 100644
index 000000000..750e34cee
--- /dev/null
+++ b/template/en/default/account/prefs/mfa.html.tmpl
@@ -0,0 +1,134 @@
+[%# 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.
+ #%]
+
+[% IF NOT Bugzilla.feature('mfa') %]
+ <input type="hidden" name="mfa_action" id="mfa-action" value="">
+ <p>
+ Two-factor Authentication is not available.
+ </p>
+ [% RETURN %]
+[% END %]
+[% IF user.cryptpassword == '*' %]
+ <input type="hidden" name="mfa_action" id="mfa-action" value="">
+ <p>
+ Two-factor Authentication is not available on your account because you are
+ using an external authentication provider.
+ </p>
+ [% RETURN %]
+[% END %]
+
+<div id="mfa-container">
+ [% IF user.mfa %]
+ <p>
+ Two-factor authentication is currently <b>enabled</b> using
+ <b>[% SWITCH user.mfa %]
+ [% CASE "TOTP" %]TOTP
+ [% END %]</b>.
+ </p>
+ <input type="hidden" name="mfa_action" id="mfa-action" value="disable">
+
+ <button type="button" id="mfa-disable">Disable Two-factor Authentication</button>
+
+ <div id="mfa-disable-container" style="display:none">
+
+ [% IF user.mfa == "TOTP" %]
+ A verification code is required to confirm this change.<br><br>
+ <b>Code:</b>
+ <input type="text" name="mfa_disable_code" id="mfa-totp-disable-code"
+ placeholder="123456" maxlength="6" pattern="\d{6}" size="10"
+ autocomplete="off" required autofocus>
+ [% END %]
+
+ </div>
+
+ [% ELSE %]
+ <p>
+ Two-factor authentication is currently <b>disabled</b>.
+ </p>
+ <input type="hidden" name="mfa_action" id="mfa-action" value="enable">
+
+ <button type="button" id="mfa-enable">Enable Two-factor Authentication</button>
+
+ <div id="mfa-enable-container" style="display:none">
+ <b>System:</b>
+ <select name="mfa" id="mfa">
+ <option value="" selected></option>
+ <option value="TOTP">Time-based One-Time Password (TOTP)</option>
+ </select>
+
+ [%# TOTP %]
+ <div id="mfa-enable-totp" class="mfa-provider" style="display:none">
+
+ <div id="mfa-totp-throbber">
+ Working.. <img src="skins/standard/throbber.gif" width="16" height="11">
+ </div>
+
+ <div id="mfa-totp-issued" style="display:none">
+ <iframe id="mfa-enable-totp-frame" src="userprefs.cgi?tab=mfa&frame=totp"></iframe>
+ <div id="mfa-totp-blurb">
+ Scan this QR code with your <a href="#" id="mfa-totp-apps">TOTP App</a>,
+ then enter the six digit code the app generates.<br>
+ <br>
+ <b>Code:</b>
+ <input type="text" name="mfa_enable_code" id="mfa-totp-enable-code"
+ placeholder="123456" maxlength="6" pattern="\d{6}" size="10"
+ autocomplete="off" required autofocus>
+ </div>
+ </div>
+
+ <div id="mfa-totp-apps-popup" class="mfa-totp-popup" style="display:none">
+ Example TOTP Applications:<br>
+ <ul>
+ <li>Android and iOS:
+ <a href="http://guide.duosecurity.com/third-party-accounts" target="_blank">Duo Mobile</a>,
+ <a href="https://support.google.com/accounts/answer/1066447" target="_blank">Google Authenticator</a>
+ </li>
+ <li>Firefox OS:
+ <a href="https://marketplace.firefox.com/app/gauth/" target="_blank">GAuth</a>
+ </li>
+ <li>Windows Phone:
+ <a href="http://www.windowsphone.com/en-us/store/app/authenticator/021dd79f-0598-e011-986b-78e7d1fa76f8"
+ target="_blank">Authenticator</a>
+ </li>
+ </ul>
+ <button type="button" class="mfa-totp-popup-close">Close</button>
+ </div>
+
+ <div id="mfa-totp-text-popup" class="mfa-totp-popup" style="display:none">
+ Your two-factor secret:
+ <div id="mfa-totp-secret"></div>
+ <button type="button" class="mfa-totp-popup-close">Close</button>
+ </div>
+
+ </div>
+
+ </div>
+
+ [% END %]
+
+ <div id="mfa-confirm" style="display:none">
+ <p>
+ Two-factor authentication settings will not be updated until you provide
+ your current password and <b>Submit Changes</b>.
+ </p>
+
+ <p>
+ <b>Current Password:</b>
+ <input type="password" name="password" required>
+ </p>
+
+ <p id="mfa-api-blurb" style="display:none">
+ Enabling two-factor authentication will also require systems that
+ interface with [% terms.Bugzilla %]'s API to use <a href="userprefs.cgi?tab=apikey">API-Keys</a>
+ for authentication.<br>
+ While not recommended, this limitation can be lifted by changing the
+ <a href="userprefs.cgi?tab=settings#api_key_only">Require API-Key authentication for API requests</a>
+ preference after 2FA is enabled.
+ </div>
+
+</div>
diff --git a/template/en/default/account/prefs/prefs.html.tmpl b/template/en/default/account/prefs/prefs.html.tmpl
index 679a3cb30..853841bff 100644
--- a/template/en/default/account/prefs/prefs.html.tmpl
+++ b/template/en/default/account/prefs/prefs.html.tmpl
@@ -44,6 +44,7 @@
generate_api_token = 1
style_urls = ['skins/standard/admin.css']
javascript_urls = ['js/util.js', 'js/field.js', 'js/TUI.js', 'js/account.js']
+ jquery = ['bPopup'],
doc_section = "userpreferences.html";
tabs = [
@@ -72,6 +73,12 @@
saveable => "1"
},
{
+ name => "mfa",
+ label => "Two-Factor Authentication",
+ link => "userprefs.cgi?tab=mfa",
+ saveable => "1"
+ },
+ {
name => "sessions",
label => "Sessions",
link => "userprefs.cgi?tab=sessions",
diff --git a/template/en/default/admin/users/userdata.html.tmpl b/template/en/default/admin/users/userdata.html.tmpl
index ebe7451e4..c24074df9 100644
--- a/template/en/default/admin/users/userdata.html.tmpl
+++ b/template/en/default/admin/users/userdata.html.tmpl
@@ -122,6 +122,28 @@
explain why.)
</td>
</tr>
+ [% IF editform %]
+ <tr>
+ <th><label for="mfa">Two-factor Auth:</label></th>
+ <td>
+ [% IF user.in_group('admin') %]
+ [% IF otheruser.mfa %]
+ <select name="mfa" value="mfa">
+ <option value="">Disable</option>
+ [% SWITCH otheruser.mfa %]
+ [% CASE "TOTP" %]
+ <option value="TOTP" selected>Enabled - TOTP</option>
+ [% END %]
+ </select>
+ [% ELSE %]
+ Disabled
+ [% END %]
+ [% ELSE %]
+ [% user.mfa ? "Enabled - " _ user.mfa : "Disabled" FILTER html %]
+ [% END %]
+ </td>
+ </tr>
+ [% END %]
[% END %]
[% Hook.process('end') %]
diff --git a/template/en/default/global/header.html.tmpl b/template/en/default/global/header.html.tmpl
index aafbbca70..3f70b9453 100644
--- a/template/en/default/global/header.html.tmpl
+++ b/template/en/default/global/header.html.tmpl
@@ -36,6 +36,7 @@
# message: string. A message to display to the user. May contain HTML.
# atomlink: Atom link URL, May contain HTML
# generate_api_token: generate a token which can be used to make authenticated webservice calls
+ # no_body: if true the body element will not be generated
#%]
[% IF message %]
@@ -262,6 +263,8 @@
[% Hook.process("additional_header") %]
</head>
+[% RETURN IF no_body %]
+
[%# Migration note: contents of the old Param 'bodyhtml' go in the body tag,
# but set the onload attribute in the DEFAULT directive above.
#%]
diff --git a/template/en/default/global/messages.html.tmpl b/template/en/default/global/messages.html.tmpl
index 4cefe2a3f..d5d4a563d 100644
--- a/template/en/default/global/messages.html.tmpl
+++ b/template/en/default/global/messages.html.tmpl
@@ -60,6 +60,8 @@
A new password has been set.
[% ELSIF field == 'disabledtext' %]
The disable text has been modified.
+ [% ELSIF field == 'mfa' %]
+ Two-factor authentication has been disabled.
[% ELSIF field == 'is_enabled' %]
The user has been [% otheruser.is_enabled ? 'enabled' : 'disabled' %].
[% ELSIF field == 'extern_id' %]
diff --git a/template/en/default/global/setting-descs.none.tmpl b/template/en/default/global/setting-descs.none.tmpl
index 5ba100183..5005f4efe 100644
--- a/template/en/default/global/setting-descs.none.tmpl
+++ b/template/en/default/global/setting-descs.none.tmpl
@@ -55,6 +55,7 @@
"bugmail_new_prefix" => "Add 'New:' to subject line of email sent when a new $terms.bug is filed",
"possible_duplicates" => "Display possible duplicates when reporting a new $terms.bug",
"requestee_cc" => "Automatically add me to the CC list of $terms.bugs I am requested to review",
+ "api_key_only" => "Require API-Key authentication for API requests",
}
%]
diff --git a/template/en/default/global/user-error.html.tmpl b/template/en/default/global/user-error.html.tmpl
index 98076ce1c..6f352e5ac 100644
--- a/template/en/default/global/user-error.html.tmpl
+++ b/template/en/default/global/user-error.html.tmpl
@@ -1012,6 +1012,10 @@
[% title = "Invalid Attachment ID" %]
The attachment id [% attach_id FILTER html %] is invalid.
+ [% ELSIF error == "invalid_auth_method" %]
+ [% title = "Invalid Authentication Method" %]
+ API-Key authentication is required.
+
[% ELSIF error == "bug_id_does_not_exist" %]
[% title = BLOCK %]Invalid [% terms.Bug %] ID[% END %]
[% terms.Bug %] [%= bug_id FILTER html %] does not exist.
@@ -1198,6 +1202,15 @@
[%# Used for non-web-based LOGIN_REQUIRED situations. %]
You must log in before using this part of [% terms.Bugzilla %].
+ [% ELSIF error == "mfa_prevents_login" %]
+ Unable to log in with [% provider FILTER html %] because two-factor
+ authentication is enabled on your account.<br>
+ <br>
+ Please log in using your username and password.
+
+ [% ELSIF error == "mfa_totp_bad_code" %]
+ Invalid verification code.
+
[% ELSIF error == "migrate_config_created" %]
The file <kbd>[% file FILTER html %]</kbd> contains configuration
variables that must be set before continuing with the migration.
@@ -1462,6 +1475,10 @@
[% title = "Passwords Don't Match" %]
The two passwords you entered did not match.
+ [% ELSIF error == "password_incorrect" %]
+ [% title = "Incorrect Password" %]
+ You did not enter your password correctly.
+
[% ELSIF error == "password_too_short" %]
[% title = "Password Too Short" %]
The password must be at least
@@ -1921,6 +1938,10 @@
[% title = "Wrong Token" %]
That token cannot be used to create a user account.
+ [% ELSIF error == "wrong_token_for_mfa" %]
+ [% title = "Wrong Token" %]
+ That token cannot be used for MFA.
+
[% ELSIF error == "xmlrpc_invalid_value" %]
"[% value FILTER html %]" is not a valid value for a
&lt;[% type FILTER html %]&gt; field. (See the XML-RPC specification
diff --git a/template/en/default/mfa/totp/enroll.html.tmpl b/template/en/default/mfa/totp/enroll.html.tmpl
new file mode 100644
index 000000000..63fc74698
--- /dev/null
+++ b/template/en/default/mfa/totp/enroll.html.tmpl
@@ -0,0 +1,59 @@
+[%# 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.
+ #%]
+
+[% js = BLOCK %]
+
+$(function() {
+
+ $('#show-text')
+ .click(function(event) {
+ event.preventDefault();
+ $('#qr, #show-text').hide();
+ $('#secret, #show-qr').show();
+ });
+
+ $('#show-qr')
+ .click(function(event) {
+ event.preventDefault();
+ $('#secret, #show-qr').hide();
+ $('#qr, #show-text').show();
+ });
+
+});
+
+[% END %]
+
+[% css = BLOCK %]
+
+#secret {
+ font-size: 120%;
+ padding: 12px;
+}
+
+#show-text, #show-qr {
+ padding-left: 12px;
+}
+
+[% END %]
+
+[%
+ PROCESS global/header.html.tmpl
+ style_urls = ['skins/standard/admin.css']
+ no_body = 1
+ javascript = js
+ style = css
+%]
+<body id="mfa-enroll-embedded">
+ <div id="toggle">
+ <a href="#" id="show-text">Show as text</a>
+ <a href="#" id="show-qr" style="display:none">Show as QR code</a>
+ </div>
+ <img id="qr" width="195" height="195">
+ <div id="secret" style="display:none"></div>
+</body>
+</html>
diff --git a/template/en/default/mfa/totp/verify.html.tmpl b/template/en/default/mfa/totp/verify.html.tmpl
new file mode 100644
index 000000000..3ff720d62
--- /dev/null
+++ b/template/en/default/mfa/totp/verify.html.tmpl
@@ -0,0 +1,29 @@
+[%# 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.
+ #%]
+
+[% INCLUDE global/header.html.tmpl
+ title = "Account Verification"
+%]
+
+<h1>Account Verification</h1>
+
+<p>
+ Please enter your verification code from your TOTP application:
+</p>
+
+<form method="POST" action="token.cgi">
+<input type="hidden" name="a" value="mfa">
+<input type="hidden" name="t" value="[% token FILTER html %]">
+<input type="text" name="code" id="code"
+ placeholder="123456" maxlength="6" pattern="\d{6}" size="10"
+ autocomplete="off" required autofocus><br>
+<br>
+<input type="submit" value="Submit">
+</form>
+
+[% INCLUDE global/footer.html.tmpl %]
diff --git a/template/en/default/setup/strings.txt.pl b/template/en/default/setup/strings.txt.pl
index f8a2920d0..657b3ce92 100644
--- a/template/en/default/setup/strings.txt.pl
+++ b/template/en/default/setup/strings.txt.pl
@@ -105,6 +105,7 @@ END
feature_new_charts => 'New Charts',
feature_old_charts => 'Old Charts',
feature_memcached => 'Memcached Support',
+ feature_mfa => 'Two-Factor Authentication',
feature_mod_perl => 'mod_perl',
feature_moving => 'Move Bugs Between Installations',
feature_patch_viewer => 'Patch Viewer',
diff --git a/token.cgi b/token.cgi
index a326b2f32..9ae307215 100755
--- a/token.cgi
+++ b/token.cgi
@@ -93,6 +93,10 @@ if ($token) {
Bugzilla::Token::Cancel($token, 'wrong_token_for_creating_account');
ThrowUserError('wrong_token_for_creating_account');
}
+ if ($action eq 'mfa' && $tokentype ne 'session') {
+ Bugzilla::Token::Cancel($token, 'wrong_token_for_mfa');
+ ThrowUserError('wrong_token_for_mfa');
+ }
}
@@ -168,6 +172,8 @@ if ($action eq 'reqpw') {
confirm_create_account($token);
} elsif ($action eq 'cancel_new_account') {
cancel_create_account($token);
+} elsif ($action eq 'mfa') {
+ verify_mfa($token);
} else {
ThrowUserError('unknown_action', {action => $action});
}
@@ -408,3 +414,16 @@ sub cancel_create_account {
$template->process('global/message.html.tmpl', $vars)
|| ThrowTemplateError($template->error());
}
+
+sub verify_mfa {
+ my $token = shift;
+ my ($user_id) = Bugzilla::Token::GetTokenData($token);
+ my $user = Bugzilla::User->check({ id => $user_id, cache => 1 });
+ if (!$user->mfa) {
+ delete_token($token);
+ print Bugzilla->cgi->redirect('index.cgi');
+ exit;
+ }
+ $user->mfa_provider->check_login($user);
+ delete_token($token);
+}
diff --git a/userprefs.cgi b/userprefs.cgi
index 72a8dfb69..f0899f164 100755
--- a/userprefs.cgi
+++ b/userprefs.cgi
@@ -38,6 +38,7 @@ use Bugzilla::User::Setting qw(clear_settings_cache);
use Bugzilla::User::Session;
use Bugzilla::User::APIKey;
use Bugzilla::Token;
+use DateTime;
use constant SESSION_MAX => 20;
@@ -142,6 +143,7 @@ sub SaveAccount {
}
$user->set_name($cgi->param('realname'));
+ $user->set_mfa($cgi->param('mfa'));
$user->update({ keep_session => 1, keep_tokens => 1 });
$dbh->bz_commit_transaction;
}
@@ -542,6 +544,55 @@ sub SaveSavedSearches {
Bugzilla->memcached->clear({ table => 'profiles', id => $user->id });
}
+sub SaveMFA {
+ my $cgi = Bugzilla->cgi;
+ my $dbh = Bugzilla->dbh;
+ my $user = Bugzilla->user;
+ my $action = $cgi->param('mfa_action') // '';
+ return unless $action eq 'enable' || $action eq 'disable';
+
+ my $crypt_password = $user->cryptpassword;
+ if (bz_crypt($cgi->param('password'), $crypt_password) ne $crypt_password) {
+ ThrowUserError('password_incorrect');
+ }
+
+ $dbh->bz_start_transaction;
+ if ($action eq 'enable') {
+ $user->set_mfa($cgi->param('mfa'));
+ $user->mfa_provider->check($cgi->param('mfa_enable_code') // '');
+ $user->mfa_provider->enrolled();
+ }
+ else {
+ $user->mfa_provider->check($cgi->param('mfa_disable_code') // '');
+ $user->set_mfa('');
+ }
+
+ $user->update({ keep_session => 1, keep_tokens => 1 });
+
+ my $settings = Bugzilla->user->settings;
+ $settings->{api_key_only}->set('on');
+ clear_settings_cache(Bugzilla->user->id);
+
+ $dbh->bz_commit_transaction;
+}
+
+sub DoMFA {
+ my $cgi = Bugzilla->cgi;
+ return unless my $provider = $cgi->param('frame');
+
+ print $cgi->header(
+ -Cache_Control => 'no-cache, no-store, must-revalidate',
+ -Expires => 'Thu, 01 Dec 1994 16:00:00 GMT',
+ -Pragma => 'no-cache',
+ );
+ if ($provider =~ /^[a-z]+$/) {
+ trick_taint($provider);
+ $template->process("mfa/$provider/enroll.html.tmpl", $vars)
+ || ThrowTemplateError($template->error());
+ }
+ exit;
+}
+
sub SaveSessions {
my $cgi = Bugzilla->cgi;
my $dbh = Bugzilla->dbh;
@@ -574,7 +625,7 @@ sub DoSessions {
my $info_getter = $user->authorizer && $user->authorizer->successful_info_getter();
if ($info_getter) {
- foreach my $session (@$sessions) {
+ foreach my $session (@$sessions) {
$session->{current} = $info_getter->cookie eq $session->{cookie};
}
}
@@ -722,6 +773,11 @@ SWITCH: for ($current_tab_name) {
DoSessions();
last SWITCH;
};
+ /^mfa$/ && do {
+ SaveMFA() if $save_changes;
+ DoMFA();
+ last SWITCH;
+ };
ThrowUserError("unknown_tab",
{ current_tab_name => $current_tab_name });