Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
3 changes: 2 additions & 1 deletion .github/workflows/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ apt-get install -y libmoox-types-mooselike-datetime-perl libdatetime-format-date
apt-get install -y libfile-slurp-perl libfile-mimeinfo-perl liblist-compare-perl libnet-oauth2-authorizationserver-perl libfontconfig1
apt-get install -y libctrlo-pdf-perl libpdf-builder-perl fonts-liberation libdate-holidays-gb-perl libcgi-deurl-xs-perl libfile-bom-perl
apt-get install -y libdatetime-format-iso8601-perl liblog-log4perl-perl libwww-mechanize-chrome-perl chromium libfile-libmagic-perl libnet-saml2-perl
apt-get install -y liburl-encode-perl libtext-markdown-perl libtest-tempdir-tiny-perl libtest-mocktime-perl
apt-get install -y liburl-encode-perl libtext-markdown-perl libtest-tempdir-tiny-perl libtest-mocktime-perl libauth-yubikey-webclient-perl
apt-get install -y libauth-yubikey-webclient-perl libauthen-oath-perl libconvert-base32-perl libimager-qrcode-perl libimager-perl
5 changes: 3 additions & 2 deletions lib/GADS.pm
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ use Dancer2::Plugin::Auth::Extensible::Provider::DBIC 0.623;
use Dancer2::Plugin::LogReport 'linkspace';

use GADS::API; # API routes
use GADS::MFA;

# YAML needs to save and load blessed objects for the sessio serializer (for
# the notification messages). Since YAML 1.25 this is disabled by default, so
Expand Down Expand Up @@ -264,14 +265,14 @@ hook before => sub {
if (config->{gads}->{user_status} && !session('status_accepted'))
{
# Redirect to user status page if required and not seen this session
redirect '/user_status' unless request->uri =~ m!^/(user_status|aup)!;
redirect '/user_status' unless request->uri =~ m!^/(user_status|aup|mfa)!;
}
elsif (logged_in_user_password_expired && !session('is_sso'))
{
# Redirect to user details page if password expired
forwardHome({ danger => "Your password has expired. Please use the Change password button
below to set a new password." }, 'myaccount')
unless request->uri eq '/myaccount' || request->uri eq '/logout';
unless request->uri eq '/myaccount' || request->uri eq '/logout' || request->uri eq '/mfa';
}

# CSP
Expand Down
41 changes: 41 additions & 0 deletions lib/GADS/Config.pm
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,47 @@ has url => (
},
);

has sms_config => (
is => 'lazy',
);

sub _build_sms_config
{ my $self = shift;
my $sms_config = ref $self->gads eq 'HASH' && $self->gads->{sms}
or panic "SMS not configured";
my $url = "https://api.bulksms.com/v1/messages";
my $username = $sms_config->{username}
or panic "SMS username not defined";
my $password = $sms_config->{password}
or panic "SMS password not defined";
my $from = $sms_config->{from}
or panic "SMS sender configuration not defined";
{
url => $url,
username => $username,
password => $password,
from => $from,
}
}

has yubi_config => (
is => 'lazy',
);

sub _build_yubi_config
{ my $self = shift;
my $yubi_config = ref $self->gads eq 'HASH' && $self->gads->{yubi}
or panic "Yubikey MFA is not configured";
my $id = $yubi_config->{id}
or panic "Yubikey API ID is not defined";
my $key = $yubi_config->{key}
or panic "Yubikey API ID is not defined";
{
id => $id,
key => $key,
}
}

has dateformat => (
is => 'ro',
lazy => 1,
Expand Down
204 changes: 204 additions & 0 deletions lib/GADS/MFA.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
package GADS::MFA;

use DateTime;
use Session::Token;

use Dancer2 appname => 'GADS';
use Dancer2::Plugin::Auth::Extensible;
use Dancer2::Plugin::DBIC;
use Dancer2::Plugin::LogReport 'linkspace';

hook before => sub {

if (my $user = logged_in_user())
{
my $mfa_cookie = cookie 'MFATOKEN';
# MFA needed before access?
redirect '/mfa' if request->uri !~ m!^/(mfa|logout)$!

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Should the / delimiter have an \ escape before it?

&& $user->need_mfa && !$user->recent_mfa($mfa_cookie);
# Prevent user being suddenly forced to enter MFA whilst logged in and
# using the application. This continuously refreshes the recent MFA
# cookie, as long it hasn't already expired (as per check above). This
# means that whilst logged-in the user will not need to refresh it, as
# it will always be recent. However, if they logout and remain
# logged-out after the "recent" expiry, they will be forced to
# re-enter.
_refresh_recent_mfa_cookie($mfa_cookie->value)
if $mfa_cookie;
}

};

any ['get', 'post'] => '/mfa' => require_login sub {

my $schema = schema;
my $user = logged_in_user;

$user->need_mfa
or return redirect '/';

my $params = {};

if ($user->need_mfa_setup)
{
# Does user need to choose their MFA?
if (!$user->mfa_type_effective)
{
if (my $submitted = body_parameters->get('choose_mfa'))
{
$user->update({ mfa_type => $submitted });
}
else {
$params->{choose_mfa} = 1;
}
}
if ($user->mfa_type_effective)
{
if ($user->mfa_type_effective eq 'otp')
{
# At this stage, we need to ensure the user can successfully
# generate an OTP token before saving it persistently to the
# database. Store in the session initially, and then once it is
# correct save to their login
my $key = session('otp_key') || $user->seed_key;
session 'otp_key' => $key;
if (my $submitted = body_parameters->get('get_key'))
{
if ($user->check_token($submitted, $key))
{
$user->update({
mfa_secret => $key,
});
success __"Key has been added successfully";
redirect '/mfa/';
}
error __"Incorrect key submitted";
}
$params->{qr} = $user->key_qr_base64($key);
$params->{key} = $key;
$params->{setup_mfa} = 'otp';
}
elsif ($user->mfa_type_effective eq 'yub')
{
# For Yubikey we simply make sure we have a valid key before saving
if (my $submitted = body_parameters->get('get_key'))
{
if (my $yubi_id = $user->get_yubikey($submitted))
{
$user->update({
mfa_secret => $yubi_id,
});
success __"Key has been added successfully";
redirect '/mfa/';
}
error __"Incorrect key submitted";
}
$params->{setup_mfa} = 'yub';
}
elsif ($user->mfa_type_effective eq 'sms')
{
if (body_parameters->get('mobile'))
{
# Mobile number submitted for update
if (my $mobile = body_parameters->get('mobile'))
{
if (process sub { $user->update({ mobile => $mobile }) })
{
success __"Mobile number has been added successfully";
redirect '/mfa/';
}
}
else {
$params->{get_mobile} = 1;
}
}
elsif (body_parameters->get('sms-not-received'))
{
notice __"Please re-enter your mobile number and try again";
$user->update({ mobile => undef });
redirect '/mfa/';
}
elsif (my $token = body_parameters->get('token'))
{
if ($user->verify_mobile($token))
{
success __"Mobile number has been verified successfully";
# Also use this token as a valid MFA validation and log the
# user straight in. Otherwise they would need to go through
# the same process again for the MFA token
return _mfa_token_success($user, $token); # Redirects
}
else {
report {is_fatal=>0}, ERROR => __"The token entered was not valid. Please re-enter your mobile number.";
redirect '/mfa/';
}
}

$params->{setup_mfa} = 'sms';
}
else {
panic "Unexpected MFA type: ".$user->mfa_type_effective;
}
}
}
else {
# Ask user to authenticate
if (my $token = body_parameters->get('token'))
{
# Check for brute-force lockout
if ($user->mfa_failcount > 5 && $user->mfa_lastfail->add(minutes => 15) > DateTime->now)
{
error __"Multi-factor authentication is currently unavailable, please try again shortly.";
}
# Submitted token correct?
elsif ($user->check_token($token))
{
return _mfa_token_success($user, $token); # Redirects
}
else {
# Failure, will be prompted again
report WARNING => __"Incorrect or invalid token entered";
$user->update({
mfa_failcount => $user->mfa_failcount + 1,
mfa_lastfail => DateTime->now,
});
}
}
$user->send_mfa_sms
if $user->mfa_type_effective eq 'sms';
$params->{mfa_type} = $user->mfa_type_effective;
$params->{get_code} = 1;
}

template 'mfa', $params;
};

sub _mfa_token_success
{ my ($user, $token) = @_;
# Has the same token already been used recently, maybe by
# an attacker, so warn user
warning "The authentication token has already been used in the last 5 minutes, possibly an attacker?"
if $user->mfa_token_previous && $user->mfa_token_previous eq $token
&& $user->mfa_token_previous_used->clone->add(minutes => 5) > DateTime->now;
# Allow a token to be reused. Generate a random token and

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I don't believe we wish to make the tokens reusable.

# store it in both a cookie and the database, to check for
# validity and stop it being faked
my $key = Session::Token->new(length => 32)->get;
$user->update({
mfa_failcount => 0,
mfa_lastfail => undef,
mfa_token_previous => $token,
mfa_token_previous_type => $user->mfa_type_effective,
mfa_token_previous_used => DateTime->now,
mfa_token_previous_key => $key,
});
_refresh_recent_mfa_cookie($key);
return redirect '/';
}

sub _refresh_recent_mfa_cookie
{ my $key = shift;
cookie MFATOKEN => $key, expires => '7d', secure => 1, http_only => 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

HTTP isn't a secure means, do we need the http_only flag?

}

1;
2 changes: 1 addition & 1 deletion lib/GADS/Schema.pm
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use base 'DBIx::Class::Schema';

__PACKAGE__->load_namespaces;

our $VERSION = 110;
our $VERSION = 111;

our $IGNORE_PERMISSIONS;
our $IGNORE_PERMISSIONS_SEARCH;
Expand Down
2 changes: 2 additions & 0 deletions lib/GADS/Schema/Result/Site.pm
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ __PACKAGE__->add_columns(
{ data_type => "text", is_nullable => 1 },
"site_logo",
{ data_type => "longblob", is_nullable => 1 },
"force_mfa",
{ data_type => "char", is_nullable => 1, size => 3 },
);

__PACKAGE__->set_primary_key("id");
Expand Down
Loading
Loading