From d69cebd8c703f0a1f6839944f1c949bce350b02e Mon Sep 17 00:00:00 2001 From: Byron Jones Date: Tue, 13 Oct 2015 00:49:00 +0800 Subject: Bug 1199089 - add support for duo-security --- Bugzilla/Config.pm | 6 ++ Bugzilla/Config/Auth.pm | 21 +++++ Bugzilla/DuoWeb.pm | 193 +++++++++++++++++++++++++++++++++++++++ Bugzilla/Install/Requirements.pm | 2 +- Bugzilla/MFA.pm | 28 +++++- Bugzilla/MFA/Dummy.pm | 26 ++++++ Bugzilla/MFA/Duo.pm | 53 +++++++++++ Bugzilla/MFA/TOTP.pm | 8 +- Bugzilla/User.pm | 10 +- Bugzilla/WebService/User.pm | 2 +- 10 files changed, 336 insertions(+), 13 deletions(-) create mode 100644 Bugzilla/DuoWeb.pm create mode 100644 Bugzilla/MFA/Dummy.pm create mode 100644 Bugzilla/MFA/Duo.pm (limited to 'Bugzilla') 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 { -- cgit v1.2.3-24-g4f1b