diff options
author | Byron Jones <glob@mozilla.com> | 2015-10-12 18:49:00 +0200 |
---|---|---|
committer | Byron Jones <glob@mozilla.com> | 2015-10-12 18:49:00 +0200 |
commit | d69cebd8c703f0a1f6839944f1c949bce350b02e (patch) | |
tree | 0c38317335ffe054597a56e281160fb7bcc8ebfb | |
parent | 07791e2b9be26347cd3e7bbb8a5f004211841908 (diff) | |
download | bugzilla-d69cebd8c703f0a1f6839944f1c949bce350b02e.tar.gz bugzilla-d69cebd8c703f0a1f6839944f1c949bce350b02e.tar.xz |
Bug 1199089 - add support for duo-security
-rw-r--r-- | Bugzilla/Config.pm | 6 | ||||
-rw-r--r-- | Bugzilla/Config/Auth.pm | 21 | ||||
-rw-r--r-- | Bugzilla/DuoWeb.pm | 193 | ||||
-rw-r--r-- | Bugzilla/Install/Requirements.pm | 2 | ||||
-rw-r--r-- | Bugzilla/MFA.pm | 28 | ||||
-rw-r--r-- | Bugzilla/MFA/Dummy.pm | 26 | ||||
-rw-r--r-- | Bugzilla/MFA/Duo.pm | 53 | ||||
-rw-r--r-- | Bugzilla/MFA/TOTP.pm | 8 | ||||
-rw-r--r-- | Bugzilla/User.pm | 10 | ||||
-rw-r--r-- | Bugzilla/WebService/User.pm | 2 | ||||
-rw-r--r-- | images/duo.png | bin | 0 -> 5188 bytes | |||
-rw-r--r-- | js/account.js | 21 | ||||
-rw-r--r-- | js/duo-min.js | 32 | ||||
-rw-r--r-- | skins/standard/admin.css | 14 | ||||
-rw-r--r-- | t/Support/Files.pm | 8 | ||||
-rw-r--r-- | template/en/default/account/prefs/mfa.html.tmpl | 72 | ||||
-rw-r--r-- | template/en/default/admin/params/auth.html.tmpl | 28 | ||||
-rw-r--r-- | template/en/default/admin/users/userdata.html.tmpl | 2 | ||||
-rw-r--r-- | template/en/default/global/user-error.html.tmpl | 2 | ||||
-rw-r--r-- | template/en/default/mfa/dummy/verify.html.tmpl | 28 | ||||
-rw-r--r-- | template/en/default/mfa/duo/verify.html.tmpl | 95 | ||||
-rw-r--r-- | template/en/default/mfa/totp/enroll.html.tmpl | 2 | ||||
-rwxr-xr-x | userprefs.cgi | 82 |
23 files changed, 684 insertions, 51 deletions
diff --git a/Bugzilla/Config.pm b/Bugzilla/Config.pm index 3e9b793a6..7cc6c5dcb 100644 --- a/Bugzilla/Config.pm +++ b/Bugzilla/Config.pm @@ -216,6 +216,12 @@ sub update_params { } } + # Generate unique Duo integration secret key + if ($param->{duo_akey} eq '') { + require Bugzilla::Util; + $param->{duo_akey} = Bugzilla::Util::generate_random_password(40); + } + $param->{'utf8'} = 1 if $new_install; # --- REMOVE OLD PARAMS --- diff --git a/Bugzilla/Config/Auth.pm b/Bugzilla/Config/Auth.pm index 217805bea..36287b107 100644 --- a/Bugzilla/Config/Auth.pm +++ b/Bugzilla/Config/Auth.pm @@ -148,6 +148,27 @@ sub get_param_list { type => 'b', default => 0, }, + + { + name => 'duo_host', + type => 't', + default => '', + }, + { + name => 'duo_akey', + type => 't', + default => '', + }, + { + name => 'duo_ikey', + type => 't', + default => '', + }, + { + name => 'duo_skey', + type => 't', + default => '', + }, ); return @param_list; } diff --git a/Bugzilla/DuoWeb.pm b/Bugzilla/DuoWeb.pm new file mode 100644 index 000000000..4fb28df9e --- /dev/null +++ b/Bugzilla/DuoWeb.pm @@ -0,0 +1,193 @@ +# https://github.com/duosecurity/duo_perl +# +# Copyright (c) 2012, Duo Security, Inc. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. The name of the author may not be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package Bugzilla::DuoWeb; + +use strict; +use warnings; + +use MIME::Base64; +use Digest::HMAC_SHA1 qw(hmac_sha1_hex); + +my $DUO_PREFIX = 'TX'; +my $APP_PREFIX = 'APP'; +my $AUTH_PREFIX = 'AUTH'; + +my $DUO_EXPIRE = 300; +my $APP_EXPIRE = 3600; + +my $IKEY_LEN = 20; +my $SKEY_LEN = 40; +my $AKEY_LEN = 40; + +our $ERR_USER = 'ERR|The username passed to sign_request() is invalid.'; +our $ERR_IKEY = 'ERR|The Duo integration key passed to sign_request() is invalid.'; +our $ERR_SKEY = 'ERR|The Duo secret key passed to sign_request() is invalid.'; +our $ERR_AKEY = "ERR|The application secret key passed to sign_request() must be at least $AKEY_LEN characters."; +our $ERR_UNKNOWN = 'ERR|An unknown error has occurred.'; + + +sub _sign_vals { + my ($key, $vals, $prefix, $expire) = @_; + + my $exp = time + $expire; + + my $val = join '|', @{$vals}, $exp; + my $b64 = encode_base64($val, ''); + my $cookie = "$prefix|$b64"; + + my $sig = hmac_sha1_hex($cookie, $key); + + return "$cookie|$sig"; +} + + +sub _parse_vals { + my ($key, $val, $prefix, $ikey) = @_; + + my $ts = time; + + if (not defined $val) { + return ''; + } + + my @parts = split /\|/, $val; + if (scalar(@parts) != 3) { + return ''; + } + my ($u_prefix, $u_b64, $u_sig) = @parts; + + my $sig = hmac_sha1_hex("$u_prefix|$u_b64", $key); + + if (hmac_sha1_hex($sig, $key) ne hmac_sha1_hex($u_sig, $key)) { + return ''; + } + + if ($u_prefix ne $prefix) { + return ''; + } + + my @cookie_parts = split /\|/, decode_base64($u_b64); + if (scalar(@cookie_parts) != 3) { + return ''; + } + my ($user, $u_ikey, $exp) = @cookie_parts; + + if ($u_ikey ne $ikey) { + return ''; + } + + if ($ts >= $exp) { + return ''; + } + + return $user; +} + +=pod + Generate a signed request for Duo authentication. + The returned value should be passed into the Duo.init() call! + in the rendered web page used for Duo authentication. + + Arguments: + + ikey -- Duo integration key + skey -- Duo secret key + akey -- Application secret key + username -- Primary-authenticated username +=cut + +sub sign_request { + my ($ikey, $skey, $akey, $username) = @_; + + if (not $username) { + return $ERR_USER; + } + + if (index($username, '|') != -1) { + return $ERR_USER; + } + + if (not $ikey or length $ikey != $IKEY_LEN) { + return $ERR_IKEY; + } + + if (not $skey or length $skey != $SKEY_LEN) { + return $ERR_SKEY; + } + + if (not $akey or length $akey < $AKEY_LEN) { + return $ERR_AKEY; + } + + my $vals = [ $username, $ikey ]; + + my $duo_sig = _sign_vals($skey, $vals, $DUO_PREFIX, $DUO_EXPIRE); + my $app_sig = _sign_vals($akey, $vals, $APP_PREFIX, $APP_EXPIRE); + + if (not $duo_sig or not $app_sig) { + return $ERR_UNKNOWN; + } + + return "$duo_sig:$app_sig"; +} + +=pod + + Validate the signed response returned from Duo. + + Returns the username of the authenticated user, or '' (empty + string) if secondary authentication was denied. + + Arguments: + + ikey -- Duo integration key + skey -- Duo secret key + akey -- Application secret key + sig_response -- The signed response POST'ed to the server + +=cut + +sub verify_response { + my ($ikey, $skey, $akey, $sig_response) = @_; + + if (not defined $sig_response) { + return ''; + } + + my ($auth_sig, $app_sig) = split /:/, $sig_response; + my $auth_user = _parse_vals($skey, $auth_sig, $AUTH_PREFIX, $ikey); + my $app_user = _parse_vals($akey, $app_sig, $APP_PREFIX, $ikey); + + if ($auth_user ne $app_user) { + return ''; + } + + return $auth_user; +} +1; diff --git a/Bugzilla/Install/Requirements.pm b/Bugzilla/Install/Requirements.pm index e653e5f8c..bfd7e7bfa 100644 --- a/Bugzilla/Install/Requirements.pm +++ b/Bugzilla/Install/Requirements.pm @@ -448,7 +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'], + mfa => ['Bugzilla/MFA/*.pm'], ); # This implements the REQUIRED_MODULES and OPTIONAL_MODULES stuff diff --git a/Bugzilla/MFA.pm b/Bugzilla/MFA.pm index 4f0d8a547..868a75a7e 100644 --- a/Bugzilla/MFA.pm +++ b/Bugzilla/MFA.pm @@ -10,18 +10,38 @@ use strict; use Bugzilla::RNG qw( irand ); use Bugzilla::Token qw( issue_short_lived_session_token set_token_extra_data get_token_extra_data delete_token ); -use Bugzilla::Util qw( trick_taint); +use Bugzilla::Util qw( trick_taint ); sub new { my ($class, $user) = @_; return bless({ user => $user }, $class); } +sub new_from { + my ($class, $user, $mfa) = @_; + $mfa //= ''; + if ($mfa eq 'TOTP') { + require Bugzilla::MFA::TOTP; + return Bugzilla::MFA::TOTP->new($user); + } + elsif ($mfa eq 'Duo' && Bugzilla->params->{duo_host}) { + require Bugzilla::MFA::Duo; + return Bugzilla::MFA::Duo->new($user); + } + else { + require Bugzilla::MFA::Dummy; + return Bugzilla::MFA::Dummy->new($user); + } +} + # abstract methods -# api call, returns required data to user-prefs enrollment page +# called during enrollment sub enroll {} +# api call, returns required data to user-prefs enrollment page +sub enroll_api {} + # called after the user has confirmed enrollment sub enrolled {} @@ -31,6 +51,10 @@ sub prompt {} # throws errors if code is invalid sub check {} +# if true verifcation can happen inline (during enrollment/pref changes) +# if false then the mfa provider requires an intermediate verification page +sub can_verify_inline { 0 } + # verification sub verify_prompt { diff --git a/Bugzilla/MFA/Dummy.pm b/Bugzilla/MFA/Dummy.pm new file mode 100644 index 000000000..d91f7ae42 --- /dev/null +++ b/Bugzilla/MFA/Dummy.pm @@ -0,0 +1,26 @@ +# 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::Dummy; +use strict; +use parent 'Bugzilla::MFA'; + +# if a user is configured to use a disabled or invalid mfa provider, we return +# this dummy provider. +# +# it provides no 2fa protection at all, but prevents crashing. + +sub prompt { + my ($self, $vars) = @_; + my $template = Bugzilla->template; + + print Bugzilla->cgi->header(); + $template->process('mfa/dummy/verify.html.tmpl', $vars) + || ThrowTemplateError($template->error()); +} + +1; diff --git a/Bugzilla/MFA/Duo.pm b/Bugzilla/MFA/Duo.pm new file mode 100644 index 000000000..4c9aa1184 --- /dev/null +++ b/Bugzilla/MFA/Duo.pm @@ -0,0 +1,53 @@ +# 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::Duo; +use strict; +use parent 'Bugzilla::MFA'; + +use Bugzilla::DuoWeb; +use Bugzilla::Error; + +sub can_verify_inline { + return 0; +} + +sub enroll { + my ($self, $params) = @_; + + $self->property_set('user', $params->{username}); +} + +sub prompt { + my ($self, $vars) = @_; + my $template = Bugzilla->template; + + $vars->{sig_request} = Bugzilla::DuoWeb::sign_request( + Bugzilla->params->{duo_ikey}, + Bugzilla->params->{duo_skey}, + Bugzilla->params->{duo_akey}, + $self->property_get('user'), + ); + + print Bugzilla->cgi->header(); + $template->process('mfa/duo/verify.html.tmpl', $vars) + || ThrowTemplateError($template->error()); +} + +sub check { + my ($self, $params) = @_; + + return if Bugzilla::DuoWeb::verify_response( + Bugzilla->params->{duo_ikey}, + Bugzilla->params->{duo_skey}, + Bugzilla->params->{duo_akey}, + $params->{sig_response} + ); + ThrowUserError('mfa_bad_code'); +} + +1; diff --git a/Bugzilla/MFA/TOTP.pm b/Bugzilla/MFA/TOTP.pm index 64efcfc8d..36791da15 100644 --- a/Bugzilla/MFA/TOTP.pm +++ b/Bugzilla/MFA/TOTP.pm @@ -16,6 +16,10 @@ use Bugzilla::Util qw( template_var generate_random_password ); use GD::Barcode::QRcode; use MIME::Base64 qw( encode_base64 ); +sub can_verify_inline { + return 1; +} + sub _auth { my ($self) = @_; return Auth::GoogleAuth->new({ @@ -25,7 +29,7 @@ sub _auth { }); } -sub enroll { +sub enroll_api { my ($self) = @_; # create a new secret for the user @@ -65,7 +69,7 @@ sub check { ThrowUserError('mfa_totp_bad_enrolment_code'); } else { - ThrowUserError('mfa_totp_bad_code'); + ThrowUserError('mfa_bad_code'); } } diff --git a/Bugzilla/User.pm b/Bugzilla/User.pm index 6678e6171..d2de6b548 100644 --- a/Bugzilla/User.pm +++ b/Bugzilla/User.pm @@ -368,6 +368,7 @@ sub _check_mfa { my ($self, $provider) = @_; $provider = lc($provider // ''); return 'TOTP' if $provider eq 'totp'; + return 'Duo' if $provider eq 'duo'; return ''; } @@ -586,13 +587,8 @@ 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; - } + require Bugzilla::MFA; + $self->{mfa_provider} = Bugzilla::MFA->new_from($self, $mfa); return $self->{mfa_provider}; } diff --git a/Bugzilla/WebService/User.pm b/Bugzilla/WebService/User.pm index 5812fbed2..a9dcdf4be 100644 --- a/Bugzilla/WebService/User.pm +++ b/Bugzilla/WebService/User.pm @@ -428,7 +428,7 @@ sub mfa_enroll { my $user = Bugzilla->login(LOGIN_REQUIRED); $user->set_mfa($provider_name); my $provider = $user->mfa_provider // die "Unknown MTA provider\n"; - return $provider->enroll(); + return $provider->enroll_api(); } sub whoami { diff --git a/images/duo.png b/images/duo.png Binary files differnew file mode 100644 index 000000000..4b81f82ef --- /dev/null +++ b/images/duo.png diff --git a/js/account.js b/js/account.js index 84a7da5bf..31c1a50e6 100644 --- a/js/account.js +++ b/js/account.js @@ -59,8 +59,10 @@ $(function() { $('#mfa-select').hide(); $('#update').attr('disabled', true); + $('#mfa-totp-enable-code').attr('required', true); $('#mfa-confirm').show(); $('.mfa-api-blurb').show(); + $('#mfa-enable-shared').show(); $('#mfa-enable-totp').show(); $('#mfa-totp-throbber').show(); $('#mfa-totp-issued').hide(); @@ -90,10 +92,25 @@ $(function() { }); }); + $('#mfa-select-duo') + .click(function(event) { + event.preventDefault(); + $('#mfa').val('Duo'); + + $('#mfa-select').hide(); + $('#update').attr('disabled', false); + $('#mfa-duo-user').attr('required', true); + $('#mfa-confirm').show(); + $('.mfa-api-blurb').show(); + $('#mfa-enable-shared').show(); + $('#mfa-enable-duo').show(); + $('#mfa-password').focus(); + }); + $('#mfa-disable') .click(function(event) { event.preventDefault(); - $('.mfa-api-blurb, #mfa-buttons').hide(); + $('.mfa-api-blurb, .mfa-buttons').hide(); $('#mfa-disable-container, #mfa-auth-container').show(); $('#mfa-confirm').show(); $('#mfa-password').focus(); @@ -105,7 +122,7 @@ $(function() { $('#mfa-recovery') .click(function(event) { event.preventDefault(); - $('.mfa-api-blurb, #mfa-buttons').hide(); + $('.mfa-api-blurb, .mfa-buttons').hide(); $('#mfa-recovery-container, #mfa-auth-container').show(); $('#mfa-password').focus(); $('#update').attr('disabled', false).val('Generate Printable Recovery Codes'); diff --git a/js/duo-min.js b/js/duo-min.js new file mode 100644 index 000000000..a7d8a24fc --- /dev/null +++ b/js/duo-min.js @@ -0,0 +1,32 @@ +// https://github.com/duosecurity/duo_perl +// +// Copyright (c) 2012, Duo Security, Inc. +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// 2. Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// 3. The name of the author may not be used to endorse or promote products +// derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR +// IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES +// OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +// IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, +// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT +// NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +// THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +(function(a){var d,f,e=1,i,j=this,k,l=j.postMessage;a.postMessage=function(b,c,h){if(c){b=typeof b==="string"?b:a.param(b);h=h||parent;if(l)h.postMessage(b,c.replace(/([^:]+:\/\/[^\/]+).*/,"$1"));else if(c)h.location=c.replace(/#.*$/,"")+"#"+ +new Date+e++ +"&"+b}};a.receiveMessage=k=function(b,c,h){if(l){if(b){i&&k();i=function(g){if(typeof c==="string"&&g.origin!==c||a.isFunction(c)&&c(g.origin)===false)return false;b(g)}}if(j.addEventListener)j[b?"addEventListener":"removeEventListener"]("message", +i,false);else j[b?"attachEvent":"detachEvent"]("onmessage",i)}else{d&&clearInterval(d);d=null;if(b)d=setInterval(function(){var g=document.location.hash,m=/^#?\d+&/;if(g!==f&&m.test(g)){f=g;b({data:g.replace(m,"")})}},typeof c==="number"?c:typeof h==="number"?h:100)}}})(jQuery); +var D=jQuery,Duo={init:function(a){if(a)if(a.host){Duo._host=a.host;if(a.sig_request){Duo._sig_request=a.sig_request;if(Duo._sig_request.indexOf("ERR|")==0){a=Duo._sig_request.split("|");alert("Error: "+a[1])}else if(Duo._sig_request.indexOf(":")==-1)alert("Invalid sig_request value");else{var d=Duo._sig_request.split(":");if(d.length!=2)alert("Invalid sig_request value");else{Duo._duo_sig=d[0];Duo._app_sig=d[1];if(!a.post_action)a.post_action="";Duo._post_action=a.post_action;if(!a.post_argument)a.post_argument= +"sig_response";Duo._post_argument=a.post_argument}}}else alert("Error: missing 'sig_request' argument in Duo.init()")}else alert("Error: missing 'host' argument in Duo.init()");else alert("Error: missing arguments in Duo.init()")},ready:function(){var a=D("#duo_iframe");if(a.length){var d=D.param({tx:Duo._duo_sig,parent:document.location.href});a.attr("src","https://"+Duo._host+"/frame/web/v1/auth?"+d);D.receiveMessage(function(f){f=f.data+":"+Duo._app_sig;f=D('<input type="hidden">').attr("name", +Duo._post_argument).val(f);var e=D("#duo_form");if(!e.length){e=D("<form>");e.insertAfter(a)}e.attr("method","POST");e.attr("action",Duo._post_action);e.append(f);e.submit()},"https://"+Duo._host)}else alert("Error: missing IFRAME element with id 'duo_iframe'")}};D(document).ready(function(){Duo.ready()}); diff --git a/skins/standard/admin.css b/skins/standard/admin.css index 134dc2cee..6a91965e4 100644 --- a/skins/standard/admin.css +++ b/skins/standard/admin.css @@ -275,10 +275,15 @@ input[disabled] { padding: 0; } -#mfa-recovery { +.mfa-buttons button { margin-top: 4px; } +.mfa-buttons blockquote { + margin-top: 4px; + font-style: italic; +} + #mfa-recovery-frame { display: block; margin-top: 8px; @@ -288,9 +293,14 @@ input[disabled] { height: 200px; } -label.mfa-totp { +#mfa-container label { display: inline-block; width: 155px; text-align: right; font-weight: bold; } + +#mfa-container #duo-logo { + float: left; + margin-right: 1em; +} diff --git a/t/Support/Files.pm b/t/Support/Files.pm index 2898fdd3f..49bfdb8e8 100644 --- a/t/Support/Files.pm +++ b/t/Support/Files.pm @@ -27,6 +27,10 @@ use Bugzilla; use File::Find; +use constant IGNORE => qw( + Bugzilla/DuoWeb.pm +); + @additional_files = (); @files = glob('*'); @@ -46,6 +50,10 @@ sub isTestingFile { my ($file) = @_; my $exclude; + foreach my $ignore (IGNORE) { + return undef if $ignore eq $file; + } + if ($file =~ /\.cgi$|\.pl$|\.pm$/) { return 1; } diff --git a/template/en/default/account/prefs/mfa.html.tmpl b/template/en/default/account/prefs/mfa.html.tmpl index df272f7d3..2d80520a1 100644 --- a/template/en/default/account/prefs/mfa.html.tmpl +++ b/template/en/default/account/prefs/mfa.html.tmpl @@ -40,11 +40,12 @@ Two-factor authentication is currently <b>enabled</b> using <b>[% SWITCH user.mfa %] [% CASE "TOTP" %]TOTP + [% CASE "Duo" %]Duo Security [% END %]</b>. </p> <input type="hidden" name="mfa_action" id="mfa-action" value="disable"> - <div id="mfa-buttons"> + <div class="mfa-buttons"> <div> <button type="button" id="mfa-disable">Disable Two-factor Authentication</button> [% INCLUDE "mfa/protected.html.tmpl" %] @@ -92,15 +93,26 @@ <div id="mfa-auth-container" style="display:none"> <p> - <label class="mfa-totp">Current Password:</label> + <label>Current Password:</label> <input type="password" name="password" id="mfa-password" required> </p> + [%# disable/recovery - totp %] [% IF user.mfa == "TOTP" %] - <label class="mfa-totp">Code:</label> + + <label>Code:</label> <input type="text" name="code" placeholder="123456" maxlength="9" pattern="\d{6,9}" size="10" - autocomplete="off" required autofocus> + autocomplete="off" required> + + [%# disable/recovery - duo %] + [% ELSIF user.mfa == "Duo" %] + + <p> + <img src="images/duo.png" id="duo-logo" width="32" height="32"> + Verification with Duo Security will be performed before your account is updated. + </p> + [% END %] </div> @@ -111,23 +123,39 @@ <input type="hidden" name="mfa_action" id="mfa-action" value="enable"> <input type="hidden" name="mfa" id="mfa"> - <div id="mfa-select"> + <div id="mfa-select" class="mfa-buttons"> <p> Select the two-factor system you want to use: </p> - <button type="button" id="mfa-select-totp">Time-based One-Time Password (TOTP)</button> - </div> - [%# TOTP %] - <div id="mfa-enable-totp" class="mfa-provider" style="display:none"> + <button type="button" id="mfa-select-totp">Time-based One-Time Password (TOTP)</button><br> + <blockquote> + Requires a smartphone and a TOTP app (such as + <a href="https://support.google.com/accounts/answer/1066447" target="_blank">Google Authenticator</a> + or <a href="https://fedorahosted.org/freeotp/" target="_blank">Red Hat FreeOTP</a>). + </blockquote> + + [% IF Param("duo_host") && user.in_group("mozilla-employee-confidential") %] + <button type="button" id="mfa-select-duo">Duo Security</button><br> + <blockquote> + Requires a smartphone and a <a href="https://www.duosecurity.com/" target="_blank">Duo Security</a> + account (recommended for Mozilla employees). + </blockquote> + [% END %] + </div> + <div id="mfa-enable-shared" style="display:none"> <p> Your current password is required to enable two-factor authentication. </p> <p> - <label class="mfa-totp">Current Password:</label> + <label>Current Password:</label> <input type="password" name="password" id="mfa-password" required> </p> + </div> + + [%# enable - TOTP %] + <div id="mfa-enable-totp" style="display:none"> <div id="mfa-totp-throbber"> Generating new QR code.. <img src="skins/standard/throbber.gif" width="16" height="11"> @@ -139,10 +167,10 @@ 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> - <label class="mfa-totp">Code:</label> + <label>Code:</label> <input type="text" name="code" id="mfa-totp-enable-code" placeholder="123456" maxlength="6" pattern="\d{6}" size="10" - autocomplete="off" required autofocus> + autocomplete="off"> </div> </div> @@ -174,6 +202,26 @@ </div> + [%# enable - duo %] + <div id="mfa-enable-duo" style="display:none"> + + <p> + <label>Duo Username:</label> + <input type="text" name="username" id="mfa-duo-user"> + </p> + + <p> + <img src="images/duo.png" id="duo-logo" width="32" height="32"> + Verification with Duo Security will be performed before your account is updated.<br> + + [% IF user.in_group("mozilla-employee-confidential") %] + You must <a href="https://login.mozilla.com/duo_enrollments/" target="_blank"> + sign up for Duo Security via login.mozilla.com</a> before you can use Duo 2FA. + [% END %] + </p> + + </div> + [% END %] <div id="mfa-confirm" style="display:none"> diff --git a/template/en/default/admin/params/auth.html.tmpl b/template/en/default/admin/params/auth.html.tmpl index fea4239b3..a6cb8d3b1 100644 --- a/template/en/default/admin/params/auth.html.tmpl +++ b/template/en/default/admin/params/auth.html.tmpl @@ -142,16 +142,34 @@ "<li>letters_numbers - Passwords must contain at least one UPPER and one " _ "lower case letter and a number.</li>" _ "<li>letters_numbers_specialchars - Passwords must contain at least one " _ - "UPPER or one lower case letter, a number and a special character.</li></ul>" - }, + "UPPER or one lower case letter, a number and a special character.</li></ul>", password_check_on_login => "If set, $terms.Bugzilla will check that the password meets the current " _ "complexity rules and minimum length requirements when the user logs " _ "into the $terms.Bugzilla web interface. If it doesn't, the user would " _ - "not be able to log in, and recieve a message to reset their password." + "not be able to log in, and recieve a message to reset their password.", - auth_delegation => + auth_delegation => "If set, $terms.Bugzilla will allow third party applications " _ - "to request API keys for users." + "to request API keys for users.", + + duo_host => + "The 'API hostname' for Duo 2FA. This value is provided by your " _ + "Duo Security administrator. Set this to a blank value to disable" _ + "Duo 2FA.", + + duo_akey => + "The 'integration secret key' for Duo 2FA. This is automatically " _ + "generated by checksetup.pl.", + + duo_ikey => + "The 'integration key' for Duo 2FA. This value is provided by your " _ + "Duo Security administrator.", + + duo_skey => + "The 'secret key' for Duo 2FA. This value is provided by your " _ + "Duo Security administrator.", + + }, %] diff --git a/template/en/default/admin/users/userdata.html.tmpl b/template/en/default/admin/users/userdata.html.tmpl index 72fe4349c..a455ef84b 100644 --- a/template/en/default/admin/users/userdata.html.tmpl +++ b/template/en/default/admin/users/userdata.html.tmpl @@ -133,6 +133,8 @@ [% SWITCH otheruser.mfa %] [% CASE "TOTP" %] <option value="TOTP" selected>Enabled - TOTP</option> + [% CASE "Duo" %] + <option value="Duo" selected>Enabled - Duo Security</option> [% END %] </select> [% ELSE %] diff --git a/template/en/default/global/user-error.html.tmpl b/template/en/default/global/user-error.html.tmpl index 7a3a536cd..66573ecb1 100644 --- a/template/en/default/global/user-error.html.tmpl +++ b/template/en/default/global/user-error.html.tmpl @@ -1212,7 +1212,7 @@ <br> Please log in using your username and password. - [% ELSIF error == "mfa_totp_bad_code" %] + [% ELSIF error == "mfa_bad_code" %] Invalid verification code. [% ELSIF error == "mfa_totp_bad_enrolment_code" %] diff --git a/template/en/default/mfa/dummy/verify.html.tmpl b/template/en/default/mfa/dummy/verify.html.tmpl new file mode 100644 index 000000000..9b9501e66 --- /dev/null +++ b/template/en/default/mfa/dummy/verify.html.tmpl @@ -0,0 +1,28 @@ +[%# 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> + <b>[% reason FILTER html %]</b> requires verification, and your configured + two-factor provider is no longer available. +</p> + +<form method="POST" id="duo_form" action="[% postback.action FILTER none %]"> + [% FOREACH field IN postback.fields.keys %] + <input type="hidden" name="[% field FILTER html %]" value="[% postback.fields.item(field) FILTER html %]"> + [% END %] + <input type="submit" value="Verify"> +</form> + +[% INCLUDE global/footer.html.tmpl %] diff --git a/template/en/default/mfa/duo/verify.html.tmpl b/template/en/default/mfa/duo/verify.html.tmpl new file mode 100644 index 000000000..627b82039 --- /dev/null +++ b/template/en/default/mfa/duo/verify.html.tmpl @@ -0,0 +1,95 @@ +[%# 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. + #%] + +[% is_enrolment = action == "enable" %] + +[% js = BLOCK %] +$(function() { + + $('#recovery-toggle') + .click(function(event) { + event.preventDefault(); + + if ($('#duo_container').is(':visible')) { + $('#duo_container').hide(); + $('#recovery').show(); + $('#code').attr('required', true).focus(); + $('#recovery-submit').attr('disabled', false); + $(this).text('Verify using Duo Security'); + } + else { + $('#duo_container').show(); + $('#recovery').hide(); + $('#code').attr('required', false); + $('#recovery-submit').attr('disabled', true); + $(this).text('Verify using a recovery code'); + } + }); + +}); +[% END %] + +[% css = BLOCK %] + + #duo_container { + background: #fff url(skins/standard/throbber.gif) 10px 10px no-repeat; + width: 620px; + height: 330px; + border: 1px solid #000; + } + +[% END %] + +[% + INCLUDE global/header.html.tmpl + title = "Account Verification" + javascript_urls = ['js/duo-min.js'] + javascript = js + style = css +%] + +<h1>Account Verification</h1> + +<p> + <b>[% reason FILTER html %]</b> requires verification.<br> + [% UNLESS is_enrolment %] + <a href="#" id="recovery-toggle">Verify using a recovery code</a>. + [% END %] +</p> + +<div id="duo_container"> + <iframe id="duo_iframe" width="620" height="330" frameborder="0"></iframe> +</div> + +<form method="POST" id="duo_form" action="[% postback.action FILTER none %]"> + [% FOREACH field IN postback.fields.keys %] + <input type="hidden" name="[% field FILTER html %]" value="[% postback.fields.item(field) FILTER html %]"> + [% END %] + [% UNLESS is_enrolment %] + <div id="recovery" style="display:none"> + <p> + Provide a two-factor recovery code: + </p> + <input type="text" name="code" id="code" + placeholder="123456789" maxlength="9" pattern="\d{9}" size="10" + autocomplete="off"><br> + <br> + <input type="submit" value="Submit" id="recovery-submit" disabled> + </div> + [% END %] +</form> + +<script> + Duo.init({ + 'host': '[% Param('duo_host') FILTER js %]', + 'sig_request': '[% sig_request FILTER js %]', + 'post_action': '[% postback.action FILTER js %]' + }); +</script> + +[% INCLUDE global/footer.html.tmpl %] diff --git a/template/en/default/mfa/totp/enroll.html.tmpl b/template/en/default/mfa/totp/enroll.html.tmpl index 63fc74698..fda7689a5 100644 --- a/template/en/default/mfa/totp/enroll.html.tmpl +++ b/template/en/default/mfa/totp/enroll.html.tmpl @@ -7,7 +7,6 @@ #%] [% js = BLOCK %] - $(function() { $('#show-text') @@ -25,7 +24,6 @@ $(function() { }); }); - [% END %] [% css = BLOCK %] diff --git a/userprefs.cgi b/userprefs.cgi index 4c196adf5..6c6a246ff 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 Bugzilla::MFA; use DateTime; use constant SESSION_MAX => 20; @@ -277,7 +278,7 @@ sub SaveSettings { } else { $setting->validate_value($value); - if ($mfa_event) { + if ($name eq 'api_key_only' && $mfa_event) { $mfa_event->{set} = $value; } else { @@ -653,43 +654,95 @@ sub SaveSavedSearches { } sub SaveMFA { - my $cgi = Bugzilla->cgi; - my $dbh = Bugzilla->dbh; - my $user = Bugzilla->user; + my $cgi = Bugzilla->cgi; + my $user = Bugzilla->user; my $action = $cgi->param('mfa_action') // ''; - return unless $action eq 'enable' || $action eq 'recovery' || $action eq 'disable'; + my $params = Bugzilla->input_params; my $crypt_password = $user->cryptpassword; - if (bz_crypt($cgi->param('password'), $crypt_password) ne $crypt_password) { + if (bz_crypt(delete $params->{password}, $crypt_password) ne $crypt_password) { ThrowUserError('password_incorrect'); } - $dbh->bz_start_transaction; + my $mfa = $cgi->param('mfa') // $user->mfa; + my $provider = Bugzilla::MFA->new_from($user, $mfa) // return; + + my $reason; if ($action eq 'enable') { - $user->set_mfa($cgi->param('mfa')); - $user->mfa_provider->check(Bugzilla->input_params); + $provider->enroll(Bugzilla->input_params); + $reason = 'Two-factor enrolment'; + } + elsif ($action eq 'recovery') { + $reason = 'Recovery code generation'; + } + elsif ($action eq 'disable') { + $reason = 'Disabling two-factor authentication'; + } + + if ($provider->can_verify_inline) { + $provider->verify_check($params); + SaveMFAupdate($cgi->param('mfa_action'), $mfa); + } + else { + my $mfa_event = { + postback => { + action => 'userprefs.cgi', + fields => { + tab => 'mfa', + mfa => $mfa, + }, + }, + reason => $reason, + action => $action, + }; + $provider->verify_prompt($mfa_event); + } +} + +sub SaveMFAupdate { + my ($action, $mfa) = @_; + my $user = Bugzilla->user; + my $dbh = Bugzilla->dbh; + $action //= ''; + + if ($action eq 'enable') { + $dbh->bz_start_transaction; + + $user->set_mfa($mfa); $user->mfa_provider->enrolled(); my $settings = Bugzilla->user->settings; $settings->{api_key_only}->set('on'); clear_settings_cache(Bugzilla->user->id); + + $user->update({ keep_session => 1, keep_tokens => 1 }); + $dbh->bz_commit_transaction; } elsif ($action eq 'recovery') { - $user->mfa_provider->verify_check(Bugzilla->input_params); my $codes = $user->mfa_provider->generate_recovery_codes(); my $token = issue_short_lived_session_token('mfa-recovery'); set_token_extra_data($token, $codes); $vars->{mfa_recovery_token} = $token; + } - else { - $user->mfa_provider->verify_check(Bugzilla->input_params); + elsif ($action eq 'disable') { $user->set_mfa(''); + $user->update({ keep_session => 1, keep_tokens => 1 }); + } +} - $user->update({ keep_session => 1, keep_tokens => 1 }); - $dbh->bz_commit_transaction; +sub SaveMFAcallback { + my $cgi = Bugzilla->cgi; + my $user = Bugzilla->user; + + my $mfa = $cgi->param('mfa'); + my $provider = Bugzilla::MFA->new_from($user, $mfa) // return; + my $event = $provider->verify_token($cgi->param('mfa_token')); + + SaveMFAupdate($event->{action}, $mfa); } sub DoMFA { @@ -971,6 +1024,7 @@ SWITCH: for ($current_tab_name) { last SWITCH; }; /^mfa$/ && do { + SaveMFAcallback() if $mfa_token; SaveMFA() if $save_changes; DoMFA(); last SWITCH; |