Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add MFA protection to several pages of the PAUSE #455

Draft
wants to merge 19 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cpanfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
requires 'Apache::Session::Counted';
requires 'Auth::GoogleAuth', '1.05';
requires 'BSD::Resource';
requires 'CPAN::Checksums', '1.050';
requires 'CPAN::DistnameInfo';
Expand Down
3 changes: 3 additions & 0 deletions doc/authen_pause.schema.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ 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,
mfa_recovery_codes text DEFAULT NULL,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like I'm probably going to ask a lot of questions. I hope it's useful and not just annoying.

  1. Does mfa mean "MFA is enabled"? If so, is there any value to having that and mfa_secret32? I would expect that you can't temporarily turn off MFA and turn it back on with the same secret, so no extra field is needed.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to store mfa_last_verified_at with the time window in which it was verified. If a OTP can be used more than once, it isn't a OTP! Better practice is something like:

  • TOTP tokens change every 30s
  • we accept the TOTP token for now, 30s ago, and 30s from now
  • we store the time seed of any verified TOTP
  • we never accept a TOTP with a seed less than or equal to the last one

Otherwise, the token used at 12:00:00 has 89 seconds to be exfiltrated and used again.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does mfa mean "MFA is enabled"? If so, is there any value to having that and mfa_secret32

Maybe we don't need mfa column anymore.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed mfa column with e1549a5

PRIMARY KEY (`user`),
KEY usertable_password (`password`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1 PACK_KEYS=1;
Expand Down
3 changes: 3 additions & 0 deletions doc/schemas/authen_pause.schema.sqlite
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ 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,
mfa_recovery_codes text DEFAULT NULL,
PRIMARY KEY (user)
);

Expand Down
1 change: 1 addition & 0 deletions lib/PAUSE.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 4 additions & 1 deletion lib/pause_2017/PAUSE/Web.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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});
}
}
Expand Down
23 changes: 21 additions & 2 deletions lib/pause_2017/PAUSE/Web/Config.pm
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ our %Actions = (
cat => "User/01Files/01up",
desc => "This is the heart of the <b>Upload Server</b>, 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"},
Expand Down Expand Up @@ -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"},
Expand All @@ -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"},
Expand All @@ -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",
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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
Expand All @@ -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"},
Expand Down
12 changes: 12 additions & 0 deletions lib/pause_2017/PAUSE/Web/Context.pm
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use Email::MIME;
use Data::Dumper;
use PAUSE::Web::Config;
use PAUSE::Web::Exception;
use Auth::GoogleAuth;

our $VERSION = "1072";

Expand Down Expand Up @@ -40,6 +41,17 @@ sub version {
$version;
}

sub authenticator_for {
my ($self, $user) = @_;
my $cpan_alias = lc($user->{userid}) . '@cpan.org';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should add the @cpan.org bit. I don't use a cpan email address and this confuses me. I would just have it be the PAUSE login name

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed @cpan.org part with abb0141

my $secret32 = $user->{mfa_secret32};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A comment here explaining that $user->{mfa_secret32} will be undef the first time this is set up, and that Auth::GoogleAuth will generate one for us, would be nice I think.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated secret32 at https://metacpan.org/dist/Auth-GoogleAuth/source/lib/Auth/GoogleAuth.pm#L21-25 might not be strong enough. Is it better for us to generate it by ourselves using Crypt::URandom instead of just adding a comment?

return Auth::GoogleAuth->new({
secret32 => $secret32,
issuer => $PAUSE::Config->{MFA_ISSUER} || 'PAUSE',
key_id => $cpan_alias,
});
}

sub hostname {
my $self = shift;
$PAUSE::Config->{SERVER_NAME} || Sys::Hostname::hostname();
Expand Down
80 changes: 80 additions & 0 deletions lib/pause_2017/PAUSE/Web/Controller/User/Mfa.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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);

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} = $auth->qr_code;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow. I don't love this module. qr_code gives a link to quickchart.io which means the url containing the secrets to set up the 2fa are in someone else's web logs. I don't think we should do this.

I think we should construct our own images and inline them, something like:

  my $uri = "otpauth://totp/$label?secret=$key&issuer=$org.$domain";

  my $img = plot_qrcode($uri, {
    size          => 4,
    margin        => 4,
    version       => 1,
    level         => 'M',
    casesensitive => 1,
  });

  $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');

  # And now in the html  <img src="$data" />

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add _generate_qrcode with c2472f1

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and modified parameters with 0d09dbd

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: <i>%s</i>.},$dbh->errstr);
}

if (uc $req->method eq 'POST' and $req->param("pause99_mfa_sub")) {
Copy link
Collaborator

@wolfsage wolfsage Apr 28, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block of code says:

  1. The user is updating their mfa (pause99_mfa_sub has a value)
  2. Did they provided a 6 digit code in pause99_mfa_code? Does it verify? No? Fail
  3. Did they provide a backup code in pause99_mfa_code? Does it verify? No? Fail
  4. Success. Update/disable the mfa

This means that you can supply an empty string in pause99_mfa_code, or any string that doesn't match the two regexps, and successfully disable mfa

It would probably be better to invert the logic and start with my $success = 0; and then only set $success to true under very explicit circumstances. Then, we only take action if $success is true. This can help avoid these kind of logic bugs

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inverted the logic with 684f2f0

my $code = $req->param("pause99_mfa_code");
$req->param("pause99_mfa_code", undef);
if ($code =~ /\A[0-9]{6}\z/ && !$auth->verify($code)) {
$pause->{error}{invalid_code} = 1;
return;
} 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) {
$pause->{error}{invalid_code} = 1;
return;
}
}
my ($mfa, $secret32, $recovery_codes);
if ($req->param("pause99_mfa_reset")) {
$mfa = 0;
$secret32 = undef;
$recovery_codes = undef;
$c->flash(mfa_disabled => 1);
} else {
$mfa = 1;
$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 = ?, mfa_secret32 = ?, mfa_recovery_codes = ?, changed = ?, changedby = ? WHERE user = ?";
if ($dbh->do($sql, undef, $mfa, $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: <i>%s</i>.},$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;
}

1;
40 changes: 34 additions & 6 deletions lib/pause_2017/PAUSE/Web/Controller/User/Uri.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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")
) {
Expand Down
93 changes: 93 additions & 0 deletions lib/pause_2017/PAUSE/Web/Plugin/WithMFAProtection.pm
Original file line number Diff line number Diff line change
@@ -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};

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: <i>%s</i>.},$dbh->errstr);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably it's fine, but: probably we need to escape $dbh->errstr for entities?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the PAUSE is now a Mojo app, error messages stored in the ERROR list should eventually be escaped at https://github.com/andk/pause/blob/master/lib/pause_2017/templates/layouts/layout.html.ep#L58 , but it's arguable if we should show this kind of raw error message to a user.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sort of internal error is really none of the user’s business. Normal users won’t be able to act on it anyway, so it’s no use to them, but it might be of use to an attacker, whom it might help with crafting an injection if such a vulnerability is ever present somewhere. Showing the raw error to the user is fine if the application is running in some kind of dev/debug mode, but in production it should only be logged somewhere (ideally with some kind of a request ID (which maybe already exists – apologies for not knowing)) and shown to the user only as a generic 500 “sorry, something went wrong” error screen. I don’t know how the rest of the code handles errors though (apologies again), so this comment might be about the codebase in general and not actionable within this PR.

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;
Loading
Loading