From 421ff7f194875db9634ea783d9dd5b6111f19df3 Mon Sep 17 00:00:00 2001 From: Byron Jones Date: Tue, 1 Sep 2015 13:01:20 +0800 Subject: Bug 1197073 - add support for 2fa using totp (eg. google authenticator) --- Bugzilla/Auth.pm | 21 +++++- Bugzilla/Auth/Login/Cookie.pm | 12 +++- Bugzilla/Auth/Verify/DB.pm | 11 ++++ Bugzilla/Install.pm | 1 + Bugzilla/Install/Requirements.pm | 15 +++++ Bugzilla/MFA.pm | 64 ++++++++++++++++++ Bugzilla/MFA/TOTP.pm | 79 +++++++++++++++++++++++ Bugzilla/Token.pm | 9 ++- Bugzilla/User.pm | 38 +++++++++++ Bugzilla/WebService/Server/REST/Resources/User.pm | 10 ++- Bugzilla/WebService/User.pm | 14 ++++ 11 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 Bugzilla/MFA.pm create mode 100644 Bugzilla/MFA/TOTP.pm (limited to 'Bugzilla') 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 +=item C 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__ -- cgit v1.2.3-24-g4f1b