Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
3 changes: 2 additions & 1 deletion 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,7 +265,7 @@ 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'))
{
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
189 changes: 189 additions & 0 deletions lib/GADS/MFA.pm
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
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())
{
# MFA needed before access?
redirect '/mfa' if request->uri !~ m!^/(mfa|logout)$!
&& $user->need_mfa && !$user->recent_mfa(cookie 'MFATOKEN');
}

};

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
# 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,
});
cookie MFATOKEN => $key, expires => '7d', secure => 1, http_only => 1;
return redirect '/';
}

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