lemonldap-ng/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/2F/Engines/Default.pm

662 lines
22 KiB
Perl

# Default 2FA engine
#
# 2FA engine provides 3 functions and 1 interface:
# - init()
# - run($req): called during auth process after session populating
# - display2fRegisters($req, $session): indicates if a 2F registration is
# available for this user
# - /2fregisters: the URL path that displays 2F registration menu
package Lemonldap::NG::Portal::2F::Engines::Default;
use strict;
use Mouse;
use MIME::Base64 qw(encode_base64);
use JSON qw(from_json to_json);
use POSIX qw(strftime);
use Lemonldap::NG::Portal::Main::Constants qw(
PE_OK
PE_ERROR
PE_NOTOKEN
PE_SENDRESPONSE
PE_TOKENEXPIRED
PE_NO_SECOND_FACTORS
);
our $VERSION = '2.0.15';
extends 'Lemonldap::NG::Portal::Main::Plugin';
with 'Lemonldap::NG::Portal::Lib::OverConf';
# INITIALIZATION
has sfModules => ( is => 'rw', default => sub { [] } );
has sfRModules => ( is => 'rw', default => sub { [] } );
has sfReq => ( is => 'rw' );
has sfMsgRule => ( is => 'rw' );
has ott => (
is => 'rw',
lazy => 1,
default => sub {
my $ott =
$_[0]->{p}->loadModule('Lemonldap::NG::Portal::Lib::OneTimeToken');
$ott->timeout( $_[0]->{conf}->{sfLoginTimeout}
|| $_[0]->{conf}->{formTimeout} );
return $ott;
}
);
has regOtt => (
is => 'rw',
lazy => 1,
default => sub {
my $ott =
$_[0]->{p}->loadModule('Lemonldap::NG::Portal::Lib::OneTimeToken');
my $timeout = $_[0]->{conf}->{sfRegisterTimeout}
// $_[0]->{conf}->{formTimeout};
$ott->timeout($timeout);
return $ott;
}
);
sub init {
my ($self) = @_;
# Load 2F modules
for my $i ( 0 .. 1 ) {
foreach (
split /,\s*/,
$self->conf->{
$i
? 'available2FSelfRegistration'
: 'available2F'
}
)
{
my $prefix = lc($_);
$prefix =~ s/2f$//i;
# Activation parameter
my $ap = $prefix . ( $i ? '2fSelfRegistration' : '2fActivation' );
$self->logger->debug("Checking $ap");
# Unless $rule, skip loading
if ( $self->conf->{$ap} ) {
$self->logger->debug("Trying to load $_ 2F");
my $m =
$self->p->loadPlugin( $i ? "::2F::Register::$_" : "::2F::$_" )
or return 0;
# Rule and prefix may be modified by 2F module, reread them
my $rule = $self->conf->{$ap};
$prefix = $m->prefix;
# Compile rule
$rule = $self->p->HANDLER->substitute($rule);
unless ( $rule = $self->p->HANDLER->buildSub($rule) ) {
$self->error( 'External 2F rule error: '
. $self->p->HANDLER->tsv->{jail}->error );
return 0;
}
# Store module
push @{ $self->{ $i ? 'sfRModules' : 'sfModules' } },
{ p => $prefix, m => $m, r => $rule };
}
else {
$self->logger->debug(' -> not enabled');
}
}
}
# Extra 2F modules
$self->logger->debug('Processing Extra 2F modules');
foreach my $extraKey ( sort keys %{ $self->conf->{sfExtra} } ) {
my $moduleType = $self->conf->{sfExtra}->{$extraKey}->{type};
next unless ($moduleType);
my $over = $self->conf->{sfExtra}->{$extraKey}->{over};
$self->logger->debug(
"Loading extra 2F module $extraKey of type $moduleType");
my $m =
$self->loadPlugin( "::2F::$moduleType", $over, prefix => $extraKey, )
or return 0;
# Rule and prefix may be modified by 2F module, reread them
my $rule = $self->conf->{sfExtra}->{$extraKey}->{rule} || 1;
my $prefix = $m->prefix;
# Overwrite logo, label, level from user configuration
$m->logo( $self->conf->{sfExtra}->{$extraKey}->{logo} )
if $self->conf->{sfExtra}->{$extraKey}->{logo};
$m->label( $self->conf->{sfExtra}->{$extraKey}->{label} )
if $self->conf->{sfExtra}->{$extraKey}->{label};
$m->authnLevel( $self->conf->{sfExtra}->{$extraKey}->{level} )
if $self->conf->{sfExtra}->{$extraKey}->{level};
# Compile rule
$rule = $self->p->HANDLER->substitute($rule);
unless ( $rule = $self->p->HANDLER->buildSub($rule) ) {
$self->error( 'External 2F rule error: '
. $self->p->HANDLER->tsv->{jail}->error );
return 0;
}
# Store module
push @{ $self->{'sfModules'} }, { p => $prefix, m => $m, r => $rule };
}
unless (
$self->sfReq(
$self->p->HANDLER->buildSub(
$self->p->HANDLER->substitute( $self->conf->{sfRequired} )
)
)
)
{
$self->error( 'Error in sfRequired rule'
. $self->p->HANDLER->tsv->{jail}->error );
return 0;
}
unless (
$self->sfMsgRule(
$self->p->HANDLER->buildSub(
$self->p->HANDLER->substitute(
$self->conf->{sfRemovedMsgRule}
)
)
)
)
{
$self->error( 'Error in sfRemovedMsg rule'
. $self->p->HANDLER->tsv->{jail}->error );
return 0;
}
# Enable REST request only if more than 1 2F module is enabled
if ( @{ $self->{sfModules} } > 1 ) {
$self->addAuthRoute( '2fchoice' => '_choice', ['POST'] );
$self->addAuthRoute( '2fchoice' => '_redirect', ['GET'] );
$self->addUnauthRoute( '2fchoice' => '_choice', ['POST'] );
$self->addUnauthRoute( '2fchoice' => '_redirect', ['GET'] );
}
# Enable 2F registration URL only if at least 1 registration module
# is enabled
if ( @{ $self->{sfRModules} } ) {
# Registration base
$self->addAuthRoute( '2fregisters' => '_displayRegister', ['GET'] );
$self->addAuthRoute( '2fregisters' => 'register', ['POST'] );
$self->addUnauthRoute(
'2fregisters' => 'restoreSession',
[ 'GET', 'POST' ]
) if ( $self->conf->{sfRequired} );
}
return 1;
}
# RUNNING METHODS
# public PE_CODE run($req)
#
# run() is called at each authentication, just after sessionInfo populated
sub run {
my ( $self, $req ) = @_;
my $checkLogins = $req->param('checkLogins');
my $forceUpgrade = $req->param('forceUpgrade');
my $stayconnected = $req->param('stayconnected');
my $spoofId = $req->param('spoofId') || '';
$self->logger->debug("2F checkLogins set") if $checkLogins;
$self->logger->debug("2F forceUpgrade set") if $forceUpgrade;
# Skip 2F unless a module has been registered
unless ( @{ $self->sfModules } ) {
if ( $self->conf->{sfOnlyUpgrade} and $req->data->{doingSfUpgrade} ) {
$self->logger->error(
"Trying to perform 2FA session upgrade but no "
. "second factor modules are configured" );
return PE_ERROR;
}
else {
return PE_OK;
}
}
# Skip 2F if authnLevel is already high enough
if (
$self->conf->{sfOnlyUpgrade}
and !$forceUpgrade
and ( ( $req->pdata->{targetAuthnLevel} || 0 ) <=
( $req->sessionInfo->{authenticationLevel} || 0 ) )
)
{
$self->logger->debug(
"Current authentication level satisfied target service,"
. " skipping 2FA" );
return PE_OK;
}
# Remove expired 2F devices
my $session = $req->sessionInfo;
if ( $session->{_2fDevices} ) {
$self->logger->debug("Loading 2F devices...");
# Read existing 2FDevices
my $_2fDevices =
eval { from_json( $session->{_2fDevices}, { allow_nonref => 1 } ); };
if ($@) {
$self->logger->error("Bad encoding in _2fDevices: $@");
return PE_ERROR;
}
$self->logger->debug(" -> 2F Device(s) found");
$self->logger->debug("Looking for expired 2F device(s)...");
my $removed = 0;
my $name = '';
my $now = time();
foreach my $device (@$_2fDevices) {
my $type = lc( $device->{type} );
$type =~ s/2f$//i;
$type = 'yubikey' if $type eq 'ubk';
my $ttl = $self->conf->{ $type . '2fTTL' };
if ( $ttl and $ttl > 0 and $now - $device->{epoch} > $ttl ) {
$self->logger->debug(
"Remove $device->{type} -> $device->{name} / $device->{epoch}"
);
$self->userLogger->info("Remove expired $device->{type}");
$device->{type} = 'EXPIRED';
$name .= "$device->{name}; ";
$removed++;
}
}
if ($removed) {
$name =~ s/;\s$//;
$self->logger->debug(
"Found $removed EXPIRED 2F device(s) => Update persistent session"
);
$self->userLogger->notice(
" -> $removed expired 2F device(s) removed ($name)");
@$_2fDevices =
map { $_->{type} =~ /\bEXPIRED\b/ ? () : $_ } @$_2fDevices;
$self->p->updatePersistentSession( $req,
{ _2fDevices => to_json($_2fDevices) } );
# Display message if required
if ( $self->sfMsgRule->( $req, $req->sessionInfo ) ) {
my $uid = $req->user;
my $date = strftime "%Y-%m-%d", localtime;
my $ref = $self->conf->{sfRemovedNotifRef} || 'RemoveSF';
$ref .= '-' . time();
my $title = $self->conf->{sfRemovedNotifTitle}
|| 'Second factor notification';
my $msg = $self->conf->{sfRemovedNotifMsg}
|| "$removed expired second factor(s) has/have been removed ($name)!";
$msg =~ s/\b_removedSF_\b/$removed/;
$msg =~ s/\b_nameSF_\b/$name/;
my $params =
$removed > 1
? { trspan => "expired2Fremoved, $removed, $name" }
: { trspan => "oneExpired2Fremoved, $name" };
my $notifEngine = $self->p->loadedModules->{
'Lemonldap::NG::Portal::Plugins::Notifications'};
my $res =
( $self->conf->{sfRemovedUseNotif} && $notifEngine )
? $self->createNotification( $req, $uid, $date, $ref, $title,
$msg )
: $self->displayTemplate( $req, 'simpleInfo', $params );
return $res if $res;
}
}
}
# Search for authorized modules for this user
my @am = $self->searchForAuthorized2Fmodules($req);
# If no 2F module is authorized, skipping 2F
# Note that a rule may forbid access after (GrantSession plugin)
unless (@am) {
# Except if 2FA is required, move to registration
if ( $self->sfReq->( $req, $req->sessionInfo ) ) {
$self->logger->debug("2F is required...");
$self->logger->debug(" -> Register 2F");
$req->pdata->{sfRegToken} =
$self->regOtt->createToken( $req->sessionInfo );
$self->logger->debug("Just one 2F is enabled");
$self->logger->debug(" -> Redirect to 2fregisters/");
$req->response( [
302,
[ Location => $self->conf->{portal} . '2fregisters/' ], []
]
);
return PE_SENDRESPONSE;
}
else {
if ( $self->conf->{sfOnlyUpgrade} and $req->data->{doingSfUpgrade} )
{
# cancel redirection to issuer/vhost
delete $req->pdata->{_url};
return PE_NO_SECOND_FACTORS;
}
else {
return PE_OK;
}
}
}
$self->userLogger->info( 'Second factor required for '
. $req->sessionInfo->{ $self->conf->{whatToTrace} } );
# Store user data in a token
$req->sessionInfo->{_2fRealSession} = $req->id;
$req->sessionInfo->{_2fUrldc} = $req->urldc;
$req->sessionInfo->{_2fUtime} = $req->{sessionInfo}->{_utime};
if ( $self->conf->{impersonationRule} ) {
$req->sessionInfo->{_impSpoofId} = $spoofId;
$req->sessionInfo->{_impUser} = $req->user;
}
my $token = $self->ott->createToken( $req->sessionInfo );
delete $req->{authResult};
# If only one 2F is authorized, display it
unless ($#am) {
$self->userLogger->info( 'Second factor '
. $am[0]->prefix
. '2F selected for '
. $req->sessionInfo->{ $self->conf->{whatToTrace} } );
my $res = $am[0]->run( $req, $token );
$req->authResult($res);
return $res;
}
# More than 1 2F has been found, display choice
$self->logger->debug("Prepare 2F choice");
my $res = $self->p->sendHtml(
$req,
'2fchoice',
params => {
MAIN_LOGO => $self->conf->{portalMainLogo},
SKIN => $self->p->getSkin($req),
LANGS => $self->conf->{showLanguages},
CHECKLOGINS => $checkLogins,
STAYCONNECTED => $stayconnected,
TOKEN => $token,
MSG => $self->canUpdateSfa($req) || 'choose2f',
ALERT => ( $self->canUpdateSfa($req) ? 'warning' : 'positive' ),
MODULES => [
map {
{
CODE => $_->prefix,
LOGO => $_->logo,
LABEL => $_->label
}
} @am
],
}
);
$req->response($res);
return PE_SENDRESPONSE;
}
# bool public display2fRegisters($req, $session)
#
# Return true if at least 1 register module is available for this user.
# Used by Menu for displaying or not /2fregisters page
sub display2fRegisters {
my ( $self, $req, $session ) = @_;
foreach my $m ( @{ $self->sfRModules } ) {
return 1 if ( $m->{r}->( $req, $session ) );
}
return 0;
}
sub _choice {
my ( $self, $req ) = @_;
my $token;
# Restore session
unless ( $token = $req->param('token') ) {
$self->userLogger->error( $self->prefix . ' 2F access without token' );
$req->mustRedirect(1);
return $self->p->do( $req, [ sub { PE_NOTOKEN } ] );
}
my $session;
unless ( $session = $self->ott->getToken($token) ) {
$self->userLogger->info('Token expired');
$req->noLoginDisplay(1);
return $self->p->do( $req, [ sub { PE_TOKENEXPIRED } ] );
}
unless ( $session->{_2fRealSession} ) {
$self->logger->error("Invalid 2FA session token");
$req->noLoginDisplay(1);
return $self->p->do( $req, [ sub { PE_ERROR } ] );
}
$req->sessionInfo($session);
# New token
$token = $self->ott->createToken($session);
my $ch = $req->param('sf');
foreach my $m ( @{ $self->sfModules } ) {
if ( $m->{m}->prefix eq $ch ) {
$self->userLogger->info( 'Second factor '
. $m->{m}->prefix
. '2F selected for '
. $req->sessionInfo->{ $self->conf->{whatToTrace} } );
my $res = $m->{m}->run( $req, $token );
$req->authResult($res);
return $self->p->do(
$req,
[
sub { $res }, 'controlUrl',
'buildCookie', @{ $self->p->endAuth },
]
);
}
}
$self->userLogger->error('Bad 2F choice');
return $self->p->lmError( $req, 500 );
}
sub _redirect {
my ( $self, $req ) = @_;
my $arg = $req->env->{QUERY_STRING};
$self->logger->debug('Call sfEngine _redirect method');
return [
302, [ Location => $self->conf->{portal} . ( $arg ? "?$arg" : '' ) ], []
];
}
sub _displayRegister {
my ( $self, $req, $prefix ) = @_;
# After verifying rule:
# - display template if $prefix
# - else display choice template
if ($prefix) {
my ($m) =
grep { $_->{m}->prefix eq $prefix } @{ $self->sfRModules };
return $self->p->sendError( $req, 'Inexistent register module', 400 )
unless $m;
return $self->p->sendError( $req, 'Registration not authorized', 403 )
unless $m->{r}->( $req, $req->userData );
return $self->p->sendHtml(
$req,
$m->{m}->template,
params => {
MAIN_LOGO => $self->conf->{portalMainLogo},
PREFIX => $prefix,
"PREFIX_$prefix" => 1,
LANGS => $self->conf->{showLanguages},
MSG => $self->canUpdateSfa($req) || $m->{m}->welcome,
ALERT => ( $self->canUpdateSfa($req) ? 'warning' : 'positive' ),
}
);
}
my @am;
foreach my $m ( @{ $self->sfRModules } ) {
$self->logger->debug(
'Looking if ' . $m->{m}->prefix . '2F register is available' );
if ( $m->{r}->( $req, $req->userData ) ) {
push @am,
{
CODE => $m->{m}->prefix,
URL => '/2fregisters/' . $m->{m}->prefix,
LOGO => $m->{m}->logo,
LABEL => $m->{m}->label
};
}
}
# Retrieve user all second factors
my $_2fDevices = [];
unless ( $self->canUpdateSfa($req) ) {
$_2fDevices = $req->userData->{_2fDevices}
? eval {
from_json( $req->userData->{_2fDevices}, { allow_nonref => 1 } );
}
: undef;
unless ($_2fDevices) {
$self->logger->debug("None 2F device found");
$_2fDevices = [];
}
}
else {
$self->userLogger->warn("Do not display 2F devices!");
}
# If only one 2F is available, redirect to it
return [ 302, [ Location => $self->conf->{portal} . $am[0]->{URL} ], [] ]
if (
@am == 1
and not( @$_2fDevices
or $req->data->{sfRegRequired} )
);
# Parse second factors to display delete button if allowed and upgrade button
my $displayUpgBtn = 0;
my $action = '';
foreach
my $type ( split /,\s*/, $self->conf->{available2FSelfRegistration} )
{
foreach (@$_2fDevices) {
$_->{type} =~ s/^UBK$/Yubikey/;
if ( $_->{type} eq $type ) {
my $t = lc($type);
$t =~ s/2f$//i;
# Display delete button
$_->{delAllowed} =
$self->conf->{ $t . '2fActivation' }
&& $self->conf->{ $t . '2fUserCanRemoveKey' }
&& $self->conf->{ $t . '2fSelfRegistration' };
# Display upgrade button
$displayUpgBtn ||= $self->conf->{ $t . '2fAuthnLevel' }
&& $self->conf->{ $t . '2fAuthnLevel' } >
$req->userData->{authenticationLevel};
}
$action ||= $_->{delAllowed};
$_->{type} =~ s/^Yubikey$/UBK/;
}
}
$displayUpgBtn = 0 unless $self->conf->{upgradeSession};
# Display template
return $self->p->sendHtml(
$req,
'2fregisters',
params => {
MAIN_LOGO => $self->conf->{portalMainLogo},
SKIN => $self->p->getSkin($req),
LANGS => $self->conf->{showLanguages},
MODULES => \@am,
SFDEVICES => $_2fDevices,
ACTION => $action,
REG_REQUIRED => $req->data->{sfRegRequired},
DISPLAY_UPG => $displayUpgBtn,
MSG => $self->canUpdateSfa($req) || 'choose2f',
ALERT => ( $self->canUpdateSfa($req) ? 'warning' : 'positive' ),
SFREGISTERS_URL =>
encode_base64( "$self->{conf}->{portal}2fregisters", '' )
}
);
}
# Check rule and display
sub register {
my ( $self, $req, $prefix, @args ) = @_;
# After verifying rule:
# - call register run method if $prefix
# - else give JSON list of available registers for this user
if ($prefix) {
my ($m) =
grep { $_->{m}->prefix eq $prefix } @{ $self->sfRModules };
unless ($m) {
return $self->p->sendError( $req,
'Inexistent register module', 400 );
}
unless ( $m->{r}->( $req, $req->userData ) ) {
$self->userLogger->error("$prefix 2F registration refused");
return $self->p->sendError( $req, 'Registration refused', 403 );
}
return $m->{m}->run( $req, @args );
}
my @am;
foreach my $m ( @{ $self->sfRModules } ) {
$self->logger->debug(
'Looking if ' . $m->{m}->prefix . '2F register is available' );
if ( $m->{r}->( $req, $req->userData ) ) {
$self->logger->debug(' -> OK');
my $name = $m->{m}->prefix;
push @am,
{
name => $name,
logo => $m->{m}->logo,
url => "/2fregisters/$name"
};
}
}
return $self->p->sendJSONresponse( $req, \@am );
}
sub restoreSession {
my ( $self, $req, @path ) = @_;
my $token = $req->pdata->{sfRegToken}
or return [ 302, [ Location => $self->conf->{portal} ], [] ];
$req->userData( $self->regOtt->getToken( $token, 1 ) );
$req->data->{sfRegRequired} = 1;
return $req->method eq 'POST'
? $self->register( $req, @path )
: $self->_displayRegister( $req, @path );
}
sub searchForAuthorized2Fmodules {
my ( $self, $req ) = @_;
my @am;
foreach my $m ( @{ $self->sfModules } ) {
$self->logger->debug(
'Looking if ' . $m->{m}->prefix . '2F is available' );
if ( $m->{r}->( $req, $req->sessionInfo ) ) {
$self->logger->debug(' -> OK');
push @am, $m->{m};
}
}
return @am;
}
1;