diff --git a/cpanfile b/cpanfile index b0009040a..a8da8a4e6 100644 --- a/cpanfile +++ b/cpanfile @@ -1,4 +1,5 @@ requires 'Apache::Session::Counted'; +requires 'Auth::GoogleAuth', '1.05'; requires 'BSD::Resource'; requires 'CPAN::Checksums', '1.050'; requires 'CPAN::DistnameInfo'; @@ -22,6 +23,7 @@ requires 'HTML::Entities'; requires 'HTTP::Date'; requires 'HTTP::Status'; requires 'HTTP::Tiny', '0.059'; +requires 'Imager::QRCode'; requires 'IO::Socket::SSL', '1.56'; requires 'IPC::Run3'; requires 'JSON'; diff --git a/doc/authen_pause.schema.txt b/doc/authen_pause.schema.txt index 00b335dc8..a801abf3e 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_secret32 varchar(16) DEFAULT NULL, + mfa_recovery_codes text DEFAULT NULL, PRIMARY KEY (`user`), KEY usertable_password (`password`) ) ENGINE=InnoDB DEFAULT CHARSET=latin1 PACK_KEYS=1; diff --git a/doc/schemas/authen_pause.schema.sqlite b/doc/schemas/authen_pause.schema.sqlite index 189abb95a..a1046d5be 100644 --- a/doc/schemas/authen_pause.schema.sqlite +++ b/doc/schemas/authen_pause.schema.sqlite @@ -36,6 +36,8 @@ CREATE TABLE usertable ( changed int(11) DEFAULT NULL, changedby char(10) DEFAULT NULL, lastvisit datetime DEFAULT NULL, + mfa_secret32 varchar(16) DEFAULT NULL, + mfa_recovery_codes text DEFAULT NULL, PRIMARY KEY (user) ); diff --git a/lib/PAUSE.pm b/lib/PAUSE.pm index ec3ec466c..2127a90b5 100644 --- a/lib/PAUSE.pm +++ b/lib/PAUSE.pm @@ -92,6 +92,7 @@ $PAUSE::Config ||= HTTP_ERRORLOG => '/var/log/nginx/error.log', # harmless use in cron-daily INCOMING => 'file://data/pause/incoming/', INCOMING_LOC => '/data/pause/incoming', + INCOMING_TMP => '/data/pause/tmp/', MAIL_MAILER => ["sendmail"], MAXRETRIES => 16, MIRRORCONFIG => '/usr/local/mirror/mymirror.config', diff --git a/lib/pause_2017/PAUSE/Web.pm b/lib/pause_2017/PAUSE/Web.pm index f2ba1ae2f..80a855849 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"); @@ -80,7 +81,9 @@ sub startup { my $action = $app->pause->config->action($name); 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_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->with_csrf_and_mfa_protection if $method eq "post" and $action->{x_csrf_and_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 04d4e1230..4df7bee8b 100644 --- a/lib/pause_2017/PAUSE/Web/Config.pm +++ b/lib/pause_2017/PAUSE/Web/Config.pm @@ -111,6 +111,7 @@ our %Actions = ( cat => "User/01Files/01up", desc => "This is the heart of the Upload Server, the page most heavily used on PAUSE.", method => 'POST', + x_mfa_protection => 1, x_form => { HIDDENNAME => {form_type => "hidden_field"}, CAN_MULTIPART => {form_type => "hidden_field"}, @@ -427,7 +428,7 @@ our %Actions = ( cat => "User/06Account/02", desc => "Change your password any time you want.", method => 'POST', - x_csrf_protection => 1, + x_csrf_and_mfa_protection => 1, x_form => { HIDDENNAME => {form_type => "hidden_field"}, ABRA => {form_type => "hidden_field"}, @@ -443,7 +444,7 @@ our %Actions = ( cat => "User/06Account/01", 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_csrf_and_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", @@ -493,6 +509,7 @@ our %Actions = ( cat => "01usr/01add", desc => "Admins can add users or mailinglists.", method => 'POST', + x_mfa_protection => 1, x_form => { SUBMIT_pause99_add_user_Soundex => {form_type => "submit_button"}, SUBMIT_pause99_add_user_Metaphone => {form_type => "submit_button"}, @@ -520,6 +537,7 @@ our %Actions = ( cat => "01usr/02", desc => "Admins and mailing list representatives can change the name, address and description of a mailing list.", method => 'POST', + x_mfa_protection => 1, x_form => { HIDDENNAME => {form_type => "hidden_field"}, pause99_edit_ml_3 => {form_type => "select_field"}, # mailing lists @@ -544,6 +562,7 @@ our %Actions = ( cat => "01usr/03", desc => "Admins can access PAUSE as-if they were somebody else. Here they select a user/action pair.", method => 'POST', + x_mfa_protection => 1, x_form => { HIDDENNAME => {form_type => "select_field"}, ACTIONREQ => {form_type => "select_field"}, diff --git a/lib/pause_2017/PAUSE/Web/Context.pm b/lib/pause_2017/PAUSE/Web/Context.pm index aa84e0b2f..cb104bb8a 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,17 @@ sub version { $version; } +sub authenticator_for { + my ($self, $user) = @_; + my $userid = lc($user->{userid}); + my $secret32 = $user->{mfa_secret32}; + return Auth::GoogleAuth->new({ + secret32 => $secret32, + issuer => $PAUSE::Config->{MFA_ISSUER} || 'PAUSE', + key_id => $userid, + }); +} + 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..a0e48f78e --- /dev/null +++ b/lib/pause_2017/PAUSE/Web/Controller/User/Mfa.pm @@ -0,0 +1,95 @@ +package PAUSE::Web::Controller::User::Mfa; + +use Mojo::Base "Mojolicious::Controller"; +use Auth::GoogleAuth; +use PAUSE::Crypt; +use Crypt::URandom qw(urandom); +use Convert::Base32 qw(encode_base32); +use Imager::QRCode qw(plot_qrcode); +use URI; + +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 $auth = $c->app->pause->authenticator_for($u); + $pause->{mfa_qrcode} = _generate_qrcode($auth); + if (!$u->{mfa_secret32}) { + my $dbh = $mgr->authen_connect; + my $tbl = $PAUSE::Config->{AUTHEN_USER_TABLE}; + my $sql = "UPDATE $tbl SET mfa_secret32 = ?, changed = ?, changedby = ? WHERE user = ?"; + $dbh->do($sql, undef, $auth->secret32, time, $pause->{User}{userid}, $u->{userid}) + or push @{$pause->{ERROR}}, sprintf(qq{Could not enter the data into the database: %s.},$dbh->errstr); + } + + 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); + my $verified; + if ($code =~ /\A[0-9]{6}\z/ && $auth->verify($code)) { + $verified = 1; + } elsif ($code =~ /\A[a-z0-9]{5}\-[a-z0-9]{5}\z/ && $u->{mfa_recovery_codes} && $req->param("pause99_mfa_reset")) { + my @recovery_codes = split / /, $u->{mfa_recovery_codes} // ''; + if (grep { PAUSE::Crypt::password_verify($code, $_) } @recovery_codes) { + $verified = 1; + } + } + unless ($verified) { + $pause->{error}{invalid_code} = 1; + return; + } + my ($secret32, $recovery_codes); + if ($req->param("pause99_mfa_reset")) { + $secret32 = undef; + $recovery_codes = undef; + $c->flash(mfa_disabled => 1); + } else { + $secret32 = $auth->secret32; + $c->flash(mfa_enabled => 1); + my @codes = _generate_recovery_codes(); + $c->flash(recovery_codes => \@codes); + $recovery_codes = join " ", map { PAUSE::Crypt::hash_password($_) } @codes; + } + my $dbh = $mgr->authen_connect; + my $tbl = $PAUSE::Config->{AUTHEN_USER_TABLE}; + my $sql = "UPDATE $tbl SET mfa_secret32 = ?, mfa_recovery_codes = ?, changed = ?, changedby = ? WHERE user = ?"; + if ($dbh->do($sql, undef, $secret32, $recovery_codes, 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); + } + $c->redirect_to('/authenquery?ACTION=mfa'); + } +} + +sub _generate_recovery_codes { + my @codes; + for (1 .. 8) { + my $code = encode_base32(urandom(6)); + $code =~ tr/lo/89/; + $code =~ s/^(.{5})/$1-/; + push @codes, $code; + } + @codes; +} + +# using $auth->qr_code directly is handy but insecure +sub _generate_qrcode { + my $auth = shift; + my $otpauth = $auth->qr_code(undef, undef, undef, 1); + my $img = plot_qrcode($otpauth, { casesensitive => 1, size => 4, margin => 4, version => 1, level => 'M' }); + $img->write(data => \my $qr_png, type => 'png') or die "Failed to write image: " . $img->errstr; + my $data = URI->new("data:"); + $data->data($qr_png); + $data->media_type('image/png'); + $data; +} + +1; diff --git a/lib/pause_2017/PAUSE/Web/Controller/User/Uri.pm b/lib/pause_2017/PAUSE/Web/Controller/User/Uri.pm index 7f5169e29..cde6bdbe4 100644 --- a/lib/pause_2017/PAUSE/Web/Controller/User/Uri.pm +++ b/lib/pause_2017/PAUSE/Web/Controller/User/Uri.pm @@ -12,6 +12,7 @@ sub add { my $req = $c->req; $PAUSE::Config->{INCOMING_LOC} =~ s|/$||; + $PAUSE::Config->{INCOMING_TMP} =~ s|/$||; my $u = $c->active_user_record; die PAUSE::Web::Exception @@ -26,11 +27,20 @@ sub add { if ($req->param("SUBMIT_pause99_add_uri_HTTPUPLOAD") || $req->param("SUBMIT_pause99_add_uri_httpupload")) { - my $upl = $req->upload('pause99_add_uri_httpupload'); - unless ($upl->size) { - warn "Warning: maybe they hit RETURN, no upload size, not doing HTTPUPLOAD"; - $req->param("SUBMIT_pause99_add_uri_HTTPUPLOAD",""); - $req->param("SUBMIT_pause99_add_uri_httpupload",""); + if (my $stashed = $req->param("pause99_add_uri_httpupload_stashed")) { + my $stashed_file = "$PAUSE::Config->{INCOMING_TMP}/$stashed"; + if (!-e $stashed_file) { + warn "Warning: maybe their files are already gone, not doing HTTPUPLOAD"; + $req->param("SUBMIT_pause99_add_uri_HTTPUPLOAD",""); + $req->param("SUBMIT_pause99_add_uri_httpupload",""); + } + } else { + my $upl = $req->upload('pause99_add_uri_httpupload'); + unless ($upl->size) { + warn "Warning: maybe they hit RETURN, no upload size, not doing HTTPUPLOAD"; + $req->param("SUBMIT_pause99_add_uri_HTTPUPLOAD",""); + $req->param("SUBMIT_pause99_add_uri_httpupload",""); + } } } if (! $req->param("SUBMIT_pause99_add_uri_HTTPUPLOAD") @@ -54,7 +64,25 @@ sub add { ) { { # $pause->{UseModuleSet} eq "ApReq" my $upl; - if ( + if (my $filename = $req->param("pause99_add_uri_httpupload_stashed")) { + my $stashed_file = "$PAUSE::Config->{INCOMING_TMP}/$filename"; + my $to = "$PAUSE::Config->{INCOMING_LOC}/$filename"; + rename $stashed_file => $to or die PAUSE::Web::Exception + ->new(ERROR => "Couldn't copy file '$filename' to '$to': $!"); + $pause->{successfully_copied_to} = $to; + warn "h1[File successfully copied to '$to']filename[$filename]"; + if ($req->param("pause99_add_uri_httpupload_renamed_from")) { + require Dumpvalue; + my $dv = Dumpvalue->new; + $req->param("pause99_add_uri_httpupload",$filename); + $pause->{upload_is_renamed} = { + from => $dv->stringify($req->param("pause99_add_uri_httpupload_renamed_from")), + to => $dv->stringify($filename), + }; + } + $uri = $filename; + } + elsif ( $upl = $req->upload("pause99_add_uri_httpupload") or # from 990806 $upl = $req->upload("HTTPUPLOAD") ) { 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..95f0ae171 --- /dev/null +++ b/lib/pause_2017/PAUSE/Web/Plugin/WithMFAProtection.pm @@ -0,0 +1,93 @@ +package PAUSE::Web::Plugin::WithMFAProtection; + +use Mojo::Base 'Mojolicious::Plugin'; + +our $VERSION = '1.00'; + +sub register { + my ( $self, $app ) = @_; + + my $routes = $app->routes; + + $routes->add_condition( + with_mfa_protection => sub { + my ( $route, $c ) = @_; + + my $u = $c->active_user_record; + + # XXX: The active user record does not have mfa when an admin user is pretending someone else. + return 1 unless $u->{mfa_secret32}; + + my $otp = $c->req->body_params->param('otp'); + if (defined $otp and $otp ne '') { + if ($otp =~ /\A[0-9]{6}\z/) { + return 1 if $c->app->pause->authenticator_for($u)->verify($otp); + } elsif ($otp =~ /\A[a-z0-9]{5}\-[a-z0-9]{5}\z/) { # maybe one of the recovery codes? + require PAUSE::Crypt; + my $pause = $c->stash(".pause"); + my @recovery_codes = split / /, $u->{mfa_recovery_codes} // ''; + for my $code (@recovery_codes) { + if (PAUSE::Crypt::password_verify($otp, $code)) { + my $new_codes = join ' ', grep { $_ ne $code } @recovery_codes; + my $dbh = $c->app->pause->authen_connect; + my $tbl = $PAUSE::Config->{AUTHEN_USER_TABLE}; + my $sql = "UPDATE $tbl SET mfa_recovery_codes = ?, changed = ?, changedby = ? WHERE user = ?"; + $dbh->do($sql, undef, $new_codes, time, $pause->{User}{userid}, $u->{userid}) + or push @{$pause->{ERROR}}, sprintf(qq{Could not enter the data into the database: %s.},$dbh->errstr); + return 1; + } + } + } + } + # special case for upload + if (my $upload = $c->req->upload("pause99_add_uri_httpupload")) { + if ($upload->size) { + $PAUSE::Config->{INCOMING_TMP} =~ s|/$||; + + my $filename = $upload->filename; + $filename =~ s(.*/)()gs; # no slash + $filename =~ s(.*\\)()gs; # no backslash + $filename =~ s(.*:)()gs; # no colon + $filename =~ s/[^A-Za-z0-9_\-\.\@\+]//g; # only ASCII-\w and - . @ + allowed + my $to = "$PAUSE::Config->{INCOMING_TMP}/$filename"; + # my $fhi = $upl->fh; + if (-f $to && -s _ == 0) { # zero sized files are a common problem + unlink $to; + } + if (eval { $upload->move_to($to) }){ + warn "h1[File successfully copied to '$to']filename[$filename]"; + } else { + die PAUSE::Web::Exception->new(ERROR => "Couldn't copy file '$filename' to '$to': $!"); + } + unless ($upload->filename eq $filename) { + require Dumpvalue; + my $dv = Dumpvalue->new; + $c->req->param("pause99_add_uri_httpupload",$filename); + $c->req->param("pause99_add_uri_httpupload_renamed_from",$upload->filename); + } + $c->req->param("pause99_add_uri_httpupload_stashed", $filename); + } + } + $c->render('mfa_check'); + return; + } + ); + + $routes->add_shortcut( + with_mfa_protection => sub { + my ($route) = @_; + return $route->requires( with_mfa_protection => 1 ); + } + ); + + $routes->add_shortcut( + with_csrf_and_mfa_protection => sub { + my ($route) = @_; + return $route->requires( with_csrf_protection => 1, 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/mfa_check.html.ep b/lib/pause_2017/templates/mfa_check.html.ep new file mode 100644 index 000000000..928aeb163 --- /dev/null +++ b/lib/pause_2017/templates/mfa_check.html.ep @@ -0,0 +1,16 @@ +% layout 'layout'; +% my $pause = stash(".pause") || {}; + +
Authentication code
+%= text_field 'otp'; + +% for my $name (@{ $c->req->params->names }) { + % for my $value (@{ $c->req->every_param($name) }) { + % next if $name eq 'ACTION'; + % next if $name eq 'otp'; + %= hidden_field $name => $value; + % } +% } + +%= submit_button 'verify'; + 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..452f85cf5 --- /dev/null +++ b/lib/pause_2017/templates/user/mfa/edit.html.ep @@ -0,0 +1,58 @@ +% layout 'layout'; +% my $pause = stash(".pause") || {}; + + + +% if (flash('mfa_enabled')) { + +% } elsif (flash('mfa_disabled')) { + +% } + +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 => 11, + maxlength => 11, +%> +
+