diff options
author | Byron Jones <glob@mozilla.com> | 2015-09-01 07:01:20 +0200 |
---|---|---|
committer | Byron Jones <glob@mozilla.com> | 2015-09-01 07:01:20 +0200 |
commit | 421ff7f194875db9634ea783d9dd5b6111f19df3 (patch) | |
tree | 5806e9f3001fa4f33ba85aa94856b70a7f878cf8 | |
parent | bcc93f83a64a76cd73501eaefaf5fd073fbc3f0d (diff) | |
download | bugzilla-421ff7f194875db9634ea783d9dd5b6111f19df3.tar.gz bugzilla-421ff7f194875db9634ea783d9dd5b6111f19df3.tar.xz |
Bug 1197073 - add support for 2fa using totp (eg. google authenticator)
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 <[% type FILTER html %]> 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', @@ -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 }); |