diff --git a/cpanfile b/cpanfile index 6a3eb4900..d8bff390a 100644 --- a/cpanfile +++ b/cpanfile @@ -1,4 +1,5 @@ requires 'Apache::Session::Counted'; +requires 'Auth::GoogleAuth'; requires 'BSD::Resource'; requires 'CPAN::Checksums', '1.050'; requires 'CPAN::DistnameInfo'; diff --git a/doc/authen_pause.schema.txt b/doc/authen_pause.schema.txt index 00b335dc8..07f4f38e8 100644 --- a/doc/authen_pause.schema.txt +++ b/doc/authen_pause.schema.txt @@ -56,6 +56,8 @@ CREATE TABLE usertable ( `changed` int(11) DEFAULT NULL, changedby char(10) DEFAULT NULL, lastvisit datetime DEFAULT NULL, + mfa tinyint(1) DEFAULT 0, + mfa_secret32 varchar(16) DEFAULT NULL, PRIMARY KEY (`user`), KEY usertable_password (`password`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1 PACK_KEYS=1; diff --git a/lib/pause_2017/PAUSE/Web.pm b/lib/pause_2017/PAUSE/Web.pm index f2ba1ae2f..d62cda8e5 100644 --- a/lib/pause_2017/PAUSE/Web.pm +++ b/lib/pause_2017/PAUSE/Web.pm @@ -33,6 +33,7 @@ sub startup { # Load plugins to modify path/set stash values/provide helper methods $app->plugin("WithCSRFProtection"); + $app->plugin("PAUSE::Web::Plugin::WithMFAProtection"); $app->plugin("PAUSE::Web::Plugin::ConfigPerRequest"); $app->plugin("PAUSE::Web::Plugin::IsPauseClosed"); $app->plugin("PAUSE::Web::Plugin::GetActiveUserRecord"); @@ -81,6 +82,7 @@ sub startup { for my $method (qw/get post/) { my $route = $private->$method("/$name"); $route->with_csrf_protection if $method eq "post" and $action->{x_csrf_protection}; + $route->with_mfa_protection if $method eq "post" and $action->{x_mfa_protection}; $route->to($action->{x_mojo_to}); } } diff --git a/lib/pause_2017/PAUSE/Web/Config.pm b/lib/pause_2017/PAUSE/Web/Config.pm index 7fcd07489..88e00bc1c 100644 --- a/lib/pause_2017/PAUSE/Web/Config.pm +++ b/lib/pause_2017/PAUSE/Web/Config.pm @@ -444,6 +444,7 @@ our %Actions = ( desc => "Edit your user name, your email addresses (both public and secret one), change the URL of your homepage.", method => 'POST', x_csrf_protection => 1, + x_mfa_protection => 1, x_form => { HIDDENNAME => {form_type => "hidden_field"}, pause99_edit_cred_fullname => {form_type => "text_field"}, @@ -456,6 +457,21 @@ our %Actions = ( pause99_edit_cred_sub => {form_type => "submit_button"}, }, }, + mfa => { + x_mojo_to => "user-mfa#edit", + verb => "Multifactor Auth", + priv => "user", + cat => "User/06Account/03", + desc => "Multifactor Authentication.", + method => 'POST', + x_csrf_protection => 1, + x_form => { + HIDDENNAME => {form_type => "hidden_field"}, + pause99_mfa_code => {form_type => "text_field"}, + pause99_mfa_reset => {form_type => "hidden_field"}, + pause99_mfa_sub => {form_type => "submit_button"}, + }, + }, pause_logout => { x_mojo_to => "user#pause_logout", verb => "About Logging Out", @@ -572,6 +588,7 @@ our @AllowAdminTakeover = qw( make_dist_comaint remove_dist_comaint giveup_dist_comaint + mfa ); our @AllowMlreprTakeover = qw( diff --git a/lib/pause_2017/PAUSE/Web/Context.pm b/lib/pause_2017/PAUSE/Web/Context.pm index aa84e0b2f..3a1812147 100644 --- a/lib/pause_2017/PAUSE/Web/Context.pm +++ b/lib/pause_2017/PAUSE/Web/Context.pm @@ -10,6 +10,7 @@ use Email::MIME; use Data::Dumper; use PAUSE::Web::Config; use PAUSE::Web::Exception; +use Auth::GoogleAuth; our $VERSION = "1072"; @@ -40,6 +41,15 @@ sub version { $version; } +sub authenticator_for { + my ($self, $cpan_alias) = @_; + return Auth::GoogleAuth->new({ + secret => $PAUSE::Config->{MFA_SECRET} || 'PAUSE MFA', + issuer => 'PAUSE', + key_id => $cpan_alias, + }); +} + sub hostname { my $self = shift; $PAUSE::Config->{SERVER_NAME} || Sys::Hostname::hostname(); diff --git a/lib/pause_2017/PAUSE/Web/Controller/User/Mfa.pm b/lib/pause_2017/PAUSE/Web/Controller/User/Mfa.pm new file mode 100644 index 000000000..d86af7af5 --- /dev/null +++ b/lib/pause_2017/PAUSE/Web/Controller/User/Mfa.pm @@ -0,0 +1,49 @@ +package PAUSE::Web::Controller::User::Mfa; + +use Mojo::Base "Mojolicious::Controller"; +use Auth::GoogleAuth; + +sub edit { + my $c = shift; + my $pause = $c->stash(".pause"); + my $mgr = $c->app->pause; + my $req = $c->req; + my $u = $c->active_user_record; + + my $cpan_alias = lc($u->{userid}) . '@cpan.org'; + my $auth = $c->app->pause->authenticator_for($cpan_alias); + $pause->{mfa_qrcode} = $auth->qr_code; + + if (uc $req->method eq 'POST' and $req->param("pause99_mfa_sub")) { + my $code = $req->param("pause99_mfa_code"); + $req->param("pause99_mfa_code", undef); + if (!$auth->verify($code)) { + $pause->{error}{invalid_code} = 1; + return; + } + my ($mfa, $secret32); + if ($req->param("pause99_mfa_reset")) { + $mfa = 0; + $secret32 = undef; + $pause->{mfa_disabled} = 1; + } else { + $mfa = 1; + $secret32 = $auth->secret32; + $pause->{mfa_enabled} = 1; + } + my $dbh = $mgr->authen_connect; + my $tbl = $PAUSE::Config->{AUTHEN_USER_TABLE}; + my $sql = "UPDATE $tbl SET mfa = ?, mfa_secret32 = ?, changed = ?, changedby = ? WHERE user = ?"; + if ($dbh->do($sql, undef, $mfa, $secret32, time, $pause->{User}{userid}, $u->{userid})) { + my $mailblurb = $c->render_to_string("email/user/mfa/edit", format => "email"); + my $header = {Subject => "User update for $u->{userid}"}; + my @to = $u->{secretemail}; + $mgr->send_mail_multi(\@to, $header, $mailblurb); + } else { + push @{$pause->{ERROR}}, sprintf(qq{Could not enter the data + into the database: %s.},$dbh->errstr); + } + } +} + +1; diff --git a/lib/pause_2017/PAUSE/Web/Plugin/WithMFAProtection.pm b/lib/pause_2017/PAUSE/Web/Plugin/WithMFAProtection.pm new file mode 100644 index 000000000..f81dfde12 --- /dev/null +++ b/lib/pause_2017/PAUSE/Web/Plugin/WithMFAProtection.pm @@ -0,0 +1,51 @@ +package PAUSE::Web::Plugin::WithMFAProtection; + +use Mojo::Base 'Mojolicious::Plugin'; + +our $VERSION = '1.00'; + +sub register { + my ( $self, $app ) = @_; + + my $routes = $app->routes; + + $app->helper( + 'reply.bad_mfa_code' => sub { + my ($c) = @_; + $c->res->code(403); + $c->render_maybe('bad_mfa_code') + or $c->render( text => 'Failed MFA check' ); + return; + } + ); + + $routes->add_condition( + with_mfa_protection => sub { + my ( $route, $c ) = @_; + + my $code = $c->param('mfa_code'); + my $u = $c->active_user_record; + return 1 unless $u->{mfa}; + + my $cpan_alias = lc($u->{userid}) . '@cpan.org'; + + unless ( $code && $c->app->pause->authenticator_for($cpan_alias)->verify($code) ) { + $c->reply->bad_mfa_code; + return; + } + + return 1; + } + ); + + $routes->add_shortcut( + with_mfa_protection => sub { + my ($route) = @_; + return $route->requires( with_mfa_protection => 1 ); + } + ); + + return; +} + +1; diff --git a/lib/pause_2017/templates/email/user/mfa/edit.email.ep b/lib/pause_2017/templates/email/user/mfa/edit.email.ep new file mode 100644 index 000000000..454cf824d --- /dev/null +++ b/lib/pause_2017/templates/email/user/mfa/edit.email.ep @@ -0,0 +1,19 @@ +% my $pause = stash(".pause") || {}; +% +%#------------------------------------------------------------------ +% +Record update in the PAUSE users database: + +<%== sprintf "%11s: [%s]", "userid", $pause->{HiddenUser}{userid} %> + +% if ($pause->{mfa_enabled}) { +Multifactor Authentication is enabled. +% } elsif ($pause->{mfa_disabled}) { +Multifactor Authentication is disabled. +% } + +Data were entered by <%== $pause->{User}{userid} %> (<%== $pause->{User}{fullname} %>). +Please check if they are correct. + +Thanks, +The PAUSE Team diff --git a/lib/pause_2017/templates/user/cred/edit.html.ep b/lib/pause_2017/templates/user/cred/edit.html.ep index e74fc8fe1..d5dde664b 100644 --- a/lib/pause_2017/templates/user/cred/edit.html.ep +++ b/lib/pause_2017/templates/user/cred/edit.html.ep @@ -139,4 +139,9 @@ Account can be removed %= csrf_field +% if ($pause->{HiddenUser}{mfa}) { +
MFA: +%= text_field 'mfa_code' +
+% } diff --git a/lib/pause_2017/templates/user/mfa/edit.html.ep b/lib/pause_2017/templates/user/mfa/edit.html.ep new file mode 100644 index 000000000..0a48df1d2 --- /dev/null +++ b/lib/pause_2017/templates/user/mfa/edit.html.ep @@ -0,0 +1,54 @@ +% layout 'layout'; +% my $pause = stash(".pause") || {}; +% my $cpan_alias = lc($pause->{HiddenUser}{userid}) . '@cpan.org'; + + + + +Submit 6-digit code to enable Multifactor Authentication.
+Submit 6-digit code to disable Multifactor Authentication.
+<%= hidden_field "pause99_mfa_reset" => 1 %> +% } + +CODE: <%= text_field "pause99_mfa_code" => '', + size => 10, + maxlength => 10, +%> +
+