From 5555b8239ece3419a78575109ebd9c6a0d026b7c Mon Sep 17 00:00:00 2001 From: Maxime Besson Date: Wed, 2 Aug 2023 16:06:46 +0200 Subject: [PATCH 1/6] Extract trusted browser system from StayConnected (#2490) --- lemonldap-ng-portal/MANIFEST | 1 + .../lib/Lemonldap/NG/Portal/Main/Plugins.pm | 1 + .../NG/Portal/Plugins/StayConnected.pm | 329 +--------------- .../NG/Portal/Plugins/TrustedBrowser.pm | 364 ++++++++++++++++++ 4 files changed, 374 insertions(+), 321 deletions(-) create mode 100644 lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/TrustedBrowser.pm diff --git a/lemonldap-ng-portal/MANIFEST b/lemonldap-ng-portal/MANIFEST index 4e6bd672e3..1eed476c56 100644 --- a/lemonldap-ng-portal/MANIFEST +++ b/lemonldap-ng-portal/MANIFEST @@ -149,6 +149,7 @@ lib/Lemonldap/NG/Portal/Plugins/SingleSession.pm lib/Lemonldap/NG/Portal/Plugins/SOAPServer.pm lib/Lemonldap/NG/Portal/Plugins/Status.pm lib/Lemonldap/NG/Portal/Plugins/StayConnected.pm +lib/Lemonldap/NG/Portal/Plugins/TrustedBrowser.pm lib/Lemonldap/NG/Portal/Plugins/Upgrade.pm lib/Lemonldap/NG/Portal/Register/AD.pm lib/Lemonldap/NG/Portal/Register/Base.pm diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Plugins.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Plugins.pm index 4e1ba6dea2..b14893e267 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Plugins.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Plugins.pm @@ -20,6 +20,7 @@ our @pList = ( cda => '::Plugins::CDA', notification => '::Plugins::Notifications', rememberAuthChoiceRule => '::Plugins::RememberAuthChoice', + stayConnected => '::Plugins::TrustedBrowser', stayConnected => '::Plugins::StayConnected', portalCheckLogins => '::Plugins::History', bruteForceProtection => '::Plugins::BruteForceProtection', diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/StayConnected.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/StayConnected.pm index 87b6d51a0f..e23f3fc18e 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/StayConnected.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/StayConnected.pm @@ -6,193 +6,24 @@ use strict; use Mouse; use Lemonldap::NG::Portal::Main::Constants qw( PE_OK - PE_SENDRESPONSE ); our $VERSION = '2.17.0'; extends qw( Lemonldap::NG::Portal::Main::Plugin - Lemonldap::NG::Portal::Lib::OtherSessions - Lemonldap::NG::Common::TOTP ); # INTERFACE -use constant endAuth => 'newDevice'; -use constant beforeAuth => 'check'; -use constant beforeLogout => 'logout'; +use constant beforeAuth => 'check'; # INITIALIZATION -has rule => ( is => 'rw', default => sub { 0 } ); -has ott => ( - is => 'rw', - lazy => 1, - default => sub { - my $ott = - $_[0]->{p}->loadModule('Lemonldap::NG::Portal::Lib::OneTimeToken'); - $ott->timeout( $_[0]->conf->{formTimeout} ); - return $ott; - } -); -has cookieName => ( - is => 'ro', - lazy => 1, - default => sub { - $_[0]->conf->{stayConnectedCookieName} || 'llngconnection'; - } -); -has singleSession => ( - is => 'ro', - lazy => 1, - default => sub { - $_[0]->conf->{stayConnectedSingleSession}; - } -); - -# Default timeout: 1 month -has timeout => ( - is => 'ro', - lazy => 1, - default => sub { - $_[0]->conf->{stayConnectedTimeout} || 2592000; - } -); - sub init { my ($self) = @_; - $self->addAuthRoute( registerbrowser => 'storeBrowser', ['POST'] ); - - # Parse activation rule - $self->rule( - $self->p->buildRule( $self->conf->{stayConnected}, 'stayConnected' ) ); - return 0 unless $self->rule; - return 1; } -# RUNNING METHODS - -# Registration: detect if user wants to stay connected. -# Then ask for browser fingerprint -sub newDevice { - my ( $self, $req ) = @_; - my $checkLogins = $req->param('checkLogins'); - $self->logger->debug("StayConnected: checkLogins set") if $checkLogins; - - if ( $req->param('stayconnected') - && $self->rule->( $req, $req->sessionInfo ) ) - { - my $totpSecret = $self->newSecret; - my $token = $self->ott->createToken( { - name => $req->sessionInfo->{ $self->conf->{whatToTrace} }, - totpSecret => $totpSecret, - ( - $checkLogins - ? ( history => $req->sessionInfo->{_loginHistory} ) - : () - ) - } - ); - $req->response( - $self->p->sendHtml( - $req, - '../common/registerBrowser', - params => { - URL => $req->urldc, - TOTPSEC => $totpSecret, - TOKEN => $token, - ACTION => '/registerbrowser', - CHECKLOGINS => $checkLogins - } - ) - ); - return PE_SENDRESPONSE; - } - return PE_OK; -} - -# Store data in a long-time session -sub storeBrowser { - my ( $self, $req ) = @_; - $req->urldc( $req->param('url') ); - $req->mustRedirect(1); - if ( $self->rule->( $req, $req->sessionInfo ) ) { - if ( my $token = $req->param('token') ) { - if ( my $tmp = $self->ott->getToken($token) ) { - my $uid = $req->userData->{ $self->conf->{whatToTrace} }; - if ( $tmp->{name} eq $uid ) { - if ( my $fg = $req->param('fg') ) { - my $isTotp = ( $fg =~ s/^TOTP_// ) ? 1 : 0; - if ( - $isTotp - and !$self->verifyCode( - 30, 1, 6, $tmp->{totpSecret}, $fg - ) - ) - { - $self->logger->warn( - "Failed to register device, bad TOTP"); - } - else { - my $ps = $self->newConnectionSession( { - _utime => time + $self->timeout, - _session_uid => $uid, - _connectedSince => time, - dataKeep => $req->data->{dataToKeep}, - ( - $isTotp - ? ( totpSecret => $tmp->{totpSecret} ) - : ( fingerprint => $fg ) - ), - }, - ); - - # Cookie available 30 days by default - $req->addCookie( - $self->p->cookie( - name => $self->cookieName, - value => $ps->id, - max_age => $self->timeout, - secure => $self->conf->{securedCookie}, - ) - ); - - $req->sessionInfo->{_loginHistory} = $tmp->{history} - if exists $tmp->{history}; - - # Store connection ID in current session - $self->p->updateSession( $req, - { _stayConnectedSession => $ps->id } ); - } - } - else { - $self->logger->warn( - "Browser did not return fingerprint"); - } - } - else { - $self->userLogger->error( - "StayConnected: mismatch UID ($tmp->{name} / $uid)"); - } - } - else { - $self->userLogger->error( - "StayConnected called with an expired token"); - } - } - else { - $self->userLogger->error('StayConnected called without token'); - } - } - else { - $self->userLogger->error('StayConnected not allowed'); - } - - # Return persistent connection cookie - return $self->p->do( $req, [ @{ $self->p->endAuth }, sub { PE_OK } ] ); -} - # Check for: # - persistent connection cookie # - valid session @@ -200,169 +31,25 @@ sub storeBrowser { # Then delete authentication methods from "steps" array. sub check { my ( $self, $req ) = @_; - if ( $self->rule->( $req, $req->sessionInfo ) ) { - if ( my $cid = $req->cookies->{ $self->cookieName } ) { - my $ps = Lemonldap::NG::Common::Session->new( - storageModule => $self->conf->{globalStorage}, - storageModuleOptions => $self->conf->{globalStorageOptions}, - kind => "SSO", - id => $cid, - ); - if ( $ps - and my $uid = $ps->data->{_session_uid} - and time() < $ps->data->{_utime} ) - { - $self->logger->debug('Persistent connection found'); - if ( my $fg = $req->param('fg') - and my $token = $req->param('token') ) - { - if ( my $prm = $self->ott->getToken($token) ) { - $req->data->{dataKeep} = $ps->data->{dataKeep}; - $self->logger->debug('Persistent connection found'); - if ( $self->conf->{stayConnectedBypassFG} ) { - return $self->skipAuthentication( $req, $uid, $cid, - 0 ); - } - else { - if ( $fg =~ s/^TOTP_// ) { - return $self->skipAuthentication( $req, $uid, - $cid, 1 ) - if $self->verifyCode( 30, 1, 6, - $ps->data->{totpSecret}, $fg ) > 0; - } - elsif ( $fg eq $ps->data->{fingerprint} ) { - return $self->skipAuthentication( $req, $uid, - $cid, 1 ); - } - $self->userLogger->warn( - "Fingerprint changed for $uid"); - $ps->remove; - $self->logout($req); - } - } - else { - $self->userLogger->notice( - "StayConnected: expired token for $uid"); - } - } - else { - my $token = $self->ott->createToken( $req->parameters ); - $req->response( - $self->p->sendHtml( - $req, - '../common/registerBrowser', - params => { - TOKEN => $token, - ACTION => '#', - } - ) - ); - return PE_SENDRESPONSE; - } - } - else { - $self->userLogger->notice('Persistent connection expired'); - unless ( $ps->{error} ) { - $self->logger->debug( - 'Persistent connection session id = ' . $ps->{id} ); - $self->logger->debug( - 'Persistent connection session _utime = ' - . $ps->data->{_utime} ); - $ps->remove; - } - } - } - } - else { - $self->userLogger->error('StayConnected not allowed'); - } - return PE_OK; -} - -sub logout { - my ( $self, $req ) = @_; - $req->addCookie( - $self->p->cookie( - name => $self->cookieName, - value => 0, - expires => 'Wed, 21 Oct 2015 00:00:00 GMT', - secure => $self->conf->{securedCookie}, - ) - ); - - # Try to clean stayconnected cookie - my $cid = $req->sessionInfo->{_stayConnectedSession}; - if ($cid) { - my $ps = Lemonldap::NG::Common::Session->new( - storageModule => $self->conf->{globalStorage}, - storageModuleOptions => $self->conf->{globalStorageOptions}, - kind => "SSO", - id => $cid, - ); - if ($ps) { - $self->logger->debug("Cleaning up StayConnected session $cid"); - $ps->remove; - } + if ( my $uid = $req->data->{_trustedUser} ) { + return $self->skipAuthentication($req); } - return PE_OK; } # Remove authentication steps from the login flow sub skipAuthentication { - my ( $self, $req, $uid, $cid, $fp ) = @_; + my ( $self, $req ) = @_; + my $uid = $req->data->{_trustedUser}; $req->user($uid); - $req->sessionInfo->{_stayConnectedSession} = $cid; + $req->sessionInfo->{_stayConnectedSession} = + $req->data->{_stayConnectedSession}; my @steps = grep { ref $_ or $_ !~ /^(?:extractFormInfo|authenticate)$/ } @{ $req->steps }; $req->steps( \@steps ); - $self->userLogger->notice( "$uid connected by StayConnected cookie" - . ( $fp ? "" : " without fingerprint checking" ) ); + $self->userLogger->notice("$uid connected by StayConnected cookie"); return PE_OK; } -sub removeExistingSessions { - my ( $self, $uid ) = @_; - $self->logger->debug("StayConnected: removing all sessions for $uid"); - - my $sessions = - $self->module->searchOn( $self->moduleOpts, '_session_uid', $uid ); - - foreach ( keys %{ $sessions || {} } ) { - if ( - my $ps = Lemonldap::NG::Common::Session->new( - storageModule => $self->conf->{globalStorage}, - storageModuleOptions => $self->conf->{globalStorageOptions}, - kind => "SSO", - id => $_, - ) - ) - { - # If this is a StayConnected session, remove it - $ps->remove if $ps->{data}->{_connectedSince}; - $self->logger->debug("StayConnected removed session $_"); - } - else { - $self->logger->debug("StayConnected session $_ expired"); - } - } -} - -sub newConnectionSession { - my ( $self, $info ) = @_; - - # Remove existing sessions - if ( $self->singleSession ) { - $self->removeExistingSessions( $info->{_session_uid} ); - } - - return Lemonldap::NG::Common::Session->new( - storageModule => $self->conf->{globalStorage}, - storageModuleOptions => $self->conf->{globalStorageOptions}, - kind => "SSO", - info => $info, - ); -} - 1; diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/TrustedBrowser.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/TrustedBrowser.pm new file mode 100644 index 0000000000..da37124434 --- /dev/null +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/TrustedBrowser.pm @@ -0,0 +1,364 @@ +# Plugin to enable "stay connected on this device" feature + +package Lemonldap::NG::Portal::Plugins::TrustedBrowser; + +use strict; +use Mouse; +use Lemonldap::NG::Portal::Main::Constants qw( + PE_OK + PE_SENDRESPONSE +); + +our $VERSION = '2.17.0'; + +extends qw( + Lemonldap::NG::Portal::Main::Plugin + Lemonldap::NG::Portal::Lib::OtherSessions + Lemonldap::NG::Common::TOTP +); + +# INTERFACE + +use constant endAuth => 'newDevice'; +use constant beforeAuth => 'check'; +use constant beforeLogout => 'logout'; + +# INITIALIZATION +has rule => ( is => 'rw', default => sub { 0 } ); +has ott => ( + is => 'rw', + lazy => 1, + default => sub { + my $ott = + $_[0]->{p}->loadModule('Lemonldap::NG::Portal::Lib::OneTimeToken'); + $ott->timeout( $_[0]->conf->{formTimeout} ); + return $ott; + } +); +has cookieName => ( + is => 'ro', + lazy => 1, + default => sub { + $_[0]->conf->{stayConnectedCookieName} || 'llngconnection'; + } +); +has singleSession => ( + is => 'ro', + lazy => 1, + default => sub { + $_[0]->conf->{stayConnectedSingleSession}; + } +); + +# Default timeout: 1 month +has timeout => ( + is => 'ro', + lazy => 1, + default => sub { + $_[0]->conf->{stayConnectedTimeout} || 2592000; + } +); + +sub init { + my ($self) = @_; + $self->addAuthRoute( registerbrowser => 'storeBrowser', ['POST'] ); + + # Parse activation rule + $self->rule( + $self->p->buildRule( $self->conf->{stayConnected}, 'stayConnected' ) ); + return 0 unless $self->rule; + + return 1; +} + +# RUNNING METHODS + +# Registration: detect if user wants to stay connected. +# Then ask for browser fingerprint +sub newDevice { + my ( $self, $req ) = @_; + my $checkLogins = $req->param('checkLogins'); + $self->logger->debug("StayConnected: checkLogins set") if $checkLogins; + + if ( $req->param('stayconnected') + && $self->rule->( $req, $req->sessionInfo ) ) + { + my $totpSecret = $self->newSecret; + my $token = $self->ott->createToken( { + name => $req->sessionInfo->{ $self->conf->{whatToTrace} }, + totpSecret => $totpSecret, + ( + $checkLogins + ? ( history => $req->sessionInfo->{_loginHistory} ) + : () + ) + } + ); + $req->response( + $self->p->sendHtml( + $req, + '../common/registerBrowser', + params => { + URL => $req->urldc, + TOTPSEC => $totpSecret, + TOKEN => $token, + ACTION => '/registerbrowser', + CHECKLOGINS => $checkLogins + } + ) + ); + return PE_SENDRESPONSE; + } + return PE_OK; +} + +# Store data in a long-time session +sub storeBrowser { + my ( $self, $req ) = @_; + $req->urldc( $req->param('url') ); + $req->mustRedirect(1); + if ( $self->rule->( $req, $req->sessionInfo ) ) { + if ( my $token = $req->param('token') ) { + if ( my $tmp = $self->ott->getToken($token) ) { + my $uid = $req->userData->{ $self->conf->{whatToTrace} }; + if ( $tmp->{name} eq $uid ) { + if ( my $fg = $req->param('fg') ) { + my $isTotp = ( $fg =~ s/^TOTP_// ) ? 1 : 0; + if ( + $isTotp + and !$self->verifyCode( + 30, 1, 6, $tmp->{totpSecret}, $fg + ) + ) + { + $self->logger->warn( + "Failed to register device, bad TOTP"); + } + else { + my $ps = $self->newConnectionSession( { + _utime => time + $self->timeout, + _session_uid => $uid, + _connectedSince => time, + dataKeep => $req->data->{dataToKeep}, + ( + $isTotp + ? ( totpSecret => $tmp->{totpSecret} ) + : ( fingerprint => $fg ) + ), + }, + ); + + # Cookie available 30 days by default + $req->addCookie( + $self->p->cookie( + name => $self->cookieName, + value => $ps->id, + max_age => $self->timeout, + secure => $self->conf->{securedCookie}, + ) + ); + + $req->sessionInfo->{_loginHistory} = $tmp->{history} + if exists $tmp->{history}; + + # Store connection ID in current session + $self->p->updateSession( $req, + { _stayConnectedSession => $ps->id } ); + } + } + else { + $self->logger->warn( + "Browser did not return fingerprint"); + } + } + else { + $self->userLogger->error( + "StayConnected: mismatch UID ($tmp->{name} / $uid)"); + } + } + else { + $self->userLogger->error( + "StayConnected called with an expired token"); + } + } + else { + $self->userLogger->error('StayConnected called without token'); + } + } + else { + $self->userLogger->error('StayConnected not allowed'); + } + + # Return persistent connection cookie + return $self->p->do( $req, [ @{ $self->p->endAuth }, sub { PE_OK } ] ); +} + +# Check for: +# - persistent connection cookie +# - valid session +# - uniq id is kept +# Then delete authentication methods from "steps" array. +sub check { + my ( $self, $req ) = @_; + if ( $self->rule->( $req, $req->sessionInfo ) ) { + if ( my $cid = $req->cookies->{ $self->cookieName } ) { + my $ps = Lemonldap::NG::Common::Session->new( + storageModule => $self->conf->{globalStorage}, + storageModuleOptions => $self->conf->{globalStorageOptions}, + kind => "SSO", + id => $cid, + ); + if ( $ps + and my $uid = $ps->data->{_session_uid} + and time() < $ps->data->{_utime} ) + { + $self->logger->debug('Persistent connection found'); + if ( my $fg = $req->param('fg') + and my $token = $req->param('token') ) + { + if ( my $prm = $self->ott->getToken($token) ) { + $req->data->{dataKeep} = $ps->data->{dataKeep}; + $self->logger->debug('Persistent connection found'); + if ( $self->conf->{stayConnectedBypassFG} ) { + return $self->skipAuthentication( $req, $uid, $cid, + 0 ); + } + else { + if ( $fg =~ s/^TOTP_// ) { + return $self->skipAuthentication( $req, $uid, + $cid, 1 ) + if $self->verifyCode( 30, 1, 6, + $ps->data->{totpSecret}, $fg ) > 0; + } + elsif ( $fg eq $ps->data->{fingerprint} ) { + return $self->skipAuthentication( $req, $uid, + $cid, 1 ); + } + $self->userLogger->warn( + "Fingerprint changed for $uid"); + $ps->remove; + $self->logout($req); + } + } + else { + $self->userLogger->notice( + "StayConnected: expired token for $uid"); + } + } + else { + my $token = $self->ott->createToken( $req->parameters ); + $req->response( + $self->p->sendHtml( + $req, + '../common/registerBrowser', + params => { + TOKEN => $token, + ACTION => '#', + } + ) + ); + return PE_SENDRESPONSE; + } + } + else { + $self->userLogger->notice('Persistent connection expired'); + unless ( $ps->{error} ) { + $self->logger->debug( + 'Persistent connection session id = ' . $ps->{id} ); + $self->logger->debug( + 'Persistent connection session _utime = ' + . $ps->data->{_utime} ); + $ps->remove; + } + } + } + } + else { + $self->userLogger->error('StayConnected not allowed'); + } + return PE_OK; +} + +sub logout { + my ( $self, $req ) = @_; + $req->addCookie( + $self->p->cookie( + name => $self->cookieName, + value => 0, + expires => 'Wed, 21 Oct 2015 00:00:00 GMT', + secure => $self->conf->{securedCookie}, + ) + ); + + # Try to clean stayconnected cookie + my $cid = $req->sessionInfo->{_stayConnectedSession}; + if ($cid) { + my $ps = Lemonldap::NG::Common::Session->new( + storageModule => $self->conf->{globalStorage}, + storageModuleOptions => $self->conf->{globalStorageOptions}, + kind => "SSO", + id => $cid, + ); + if ($ps) { + $self->logger->debug("Cleaning up StayConnected session $cid"); + $ps->remove; + } + } + + return PE_OK; +} + +# Remove authentication steps from the login flow +sub skipAuthentication { + my ( $self, $req, $uid, $cid, $fp ) = @_; + + # FIXME + $req->data->{_trustedUser} = $uid; + $req->data->{_stayConnectedSession} = $cid; + return PE_OK; +} + +sub removeExistingSessions { + my ( $self, $uid ) = @_; + $self->logger->debug("StayConnected: removing all sessions for $uid"); + + my $sessions = + $self->module->searchOn( $self->moduleOpts, '_session_uid', $uid ); + + foreach ( keys %{ $sessions || {} } ) { + if ( + my $ps = Lemonldap::NG::Common::Session->new( + storageModule => $self->conf->{globalStorage}, + storageModuleOptions => $self->conf->{globalStorageOptions}, + kind => "SSO", + id => $_, + ) + ) + { + # If this is a StayConnected session, remove it + $ps->remove if $ps->{data}->{_connectedSince}; + $self->logger->debug("StayConnected removed session $_"); + } + else { + $self->logger->debug("StayConnected session $_ expired"); + } + } +} + +sub newConnectionSession { + my ( $self, $info ) = @_; + + # Remove existing sessions + if ( $self->singleSession ) { + $self->removeExistingSessions( $info->{_session_uid} ); + } + + return Lemonldap::NG::Common::Session->new( + storageModule => $self->conf->{globalStorage}, + storageModuleOptions => $self->conf->{globalStorageOptions}, + kind => "SSO", + info => $info, + ); +} + +1; -- GitLab From 23d013abd7f5bcaa544634cae5be2ab8822fa9b4 Mon Sep 17 00:00:00 2001 From: Maxime Besson Date: Thu, 24 Aug 2023 10:52:01 +0200 Subject: [PATCH 2/6] Move trusted browser detection to a dedicated login step (#2490) --- .../lib/Lemonldap/NG/Portal/Main/Init.pm | 10 ++ .../lib/Lemonldap/NG/Portal/Main/Plugins.pm | 1 - .../lib/Lemonldap/NG/Portal/Main/Run.pm | 8 +- .../NG/Portal/Plugins/TrustedBrowser.pm | 108 +++++++++--------- 4 files changed, 70 insertions(+), 57 deletions(-) diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Init.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Init.pm index b0496e409b..db275a4b50 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Init.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Init.pm @@ -31,6 +31,7 @@ has _userDB => ( is => 'rw' ); has _passwordDB => ( is => 'rw' ); has _sfEngine => ( is => 'rw' ); has _captcha => ( is => 'rw' ); +has _trustedBrowser => ( is => 'rw' ); has loadedModules => ( is => 'rw' ); @@ -329,6 +330,15 @@ sub reloadConf { unless $self->{_sfEngine} = $self->loadPlugin( $self->conf->{'sfEngine'} ); + # Load trusted browser engine + return $self->fail + unless $self->_trustedBrowser( + $self->loadPlugin( + $self->conf->{'trustedBrowserEngine'} + || "::Plugins::TrustedBrowser" + ) + ); + # Load Captcha module return $self->fail unless $self->_captcha( diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Plugins.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Plugins.pm index b14893e267..4e1ba6dea2 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Plugins.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Plugins.pm @@ -20,7 +20,6 @@ our @pList = ( cda => '::Plugins::CDA', notification => '::Plugins::Notifications', rememberAuthChoiceRule => '::Plugins::RememberAuthChoice', - stayConnected => '::Plugins::TrustedBrowser', stayConnected => '::Plugins::StayConnected', portalCheckLogins => '::Plugins::History', bruteForceProtection => '::Plugins::BruteForceProtection', diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Run.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Run.pm index 794c895c9c..53ed27f6ca 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Run.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Main/Run.pm @@ -40,7 +40,7 @@ sub sessionData { } sub validSession { - qw(storeHistory buildCookie); + qw(storeHistory rememberBrowser buildCookie); } # RESPONSE HANDLER @@ -1384,4 +1384,10 @@ sub buildUrl { return $uri->as_string; } +sub rememberBrowser { + my ( $self, $req ) = @_; + + return $self->_trustedBrowser->newDevice($req); +} + 1; diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/TrustedBrowser.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/TrustedBrowser.pm index da37124434..d5dffd0635 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/TrustedBrowser.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/TrustedBrowser.pm @@ -6,6 +6,7 @@ use strict; use Mouse; use Lemonldap::NG::Portal::Main::Constants qw( PE_OK + PE_ERROR PE_SENDRESPONSE ); @@ -19,7 +20,6 @@ extends qw( # INTERFACE -use constant endAuth => 'newDevice'; use constant beforeAuth => 'check'; use constant beforeLogout => 'logout'; @@ -61,7 +61,7 @@ has timeout => ( sub init { my ($self) = @_; - $self->addAuthRoute( registerbrowser => 'storeBrowser', ['POST'] ); + $self->addUnauthRoute( registerbrowser => 'storeBrowser', ['POST'] ); # Parse activation rule $self->rule( @@ -77,6 +77,7 @@ sub init { # Then ask for browser fingerprint sub newDevice { my ( $self, $req ) = @_; + my $checkLogins = $req->param('checkLogins'); $self->logger->debug("StayConnected: checkLogins set") if $checkLogins; @@ -85,8 +86,10 @@ sub newDevice { { my $totpSecret = $self->newSecret; my $token = $self->ott->createToken( { - name => $req->sessionInfo->{ $self->conf->{whatToTrace} }, - totpSecret => $totpSecret, + name => $req->sessionInfo->{ $self->conf->{whatToTrace} }, + id => $req->id, + sessionInfo => $req->sessionInfo, + totpSecret => $totpSecret, ( $checkLogins ? ( history => $req->sessionInfo->{_loginHistory} ) @@ -120,65 +123,59 @@ sub storeBrowser { if ( $self->rule->( $req, $req->sessionInfo ) ) { if ( my $token = $req->param('token') ) { if ( my $tmp = $self->ott->getToken($token) ) { - my $uid = $req->userData->{ $self->conf->{whatToTrace} }; - if ( $tmp->{name} eq $uid ) { - if ( my $fg = $req->param('fg') ) { - my $isTotp = ( $fg =~ s/^TOTP_// ) ? 1 : 0; - if ( - $isTotp - and !$self->verifyCode( - 30, 1, 6, $tmp->{totpSecret}, $fg - ) - ) - { - $self->logger->warn( - "Failed to register device, bad TOTP"); - } - else { - my $ps = $self->newConnectionSession( { - _utime => time + $self->timeout, - _session_uid => $uid, - _connectedSince => time, - dataKeep => $req->data->{dataToKeep}, - ( - $isTotp - ? ( totpSecret => $tmp->{totpSecret} ) - : ( fingerprint => $fg ) - ), - }, - ); - - # Cookie available 30 days by default - $req->addCookie( - $self->p->cookie( - name => $self->cookieName, - value => $ps->id, - max_age => $self->timeout, - secure => $self->conf->{securedCookie}, - ) - ); - - $req->sessionInfo->{_loginHistory} = $tmp->{history} - if exists $tmp->{history}; - - # Store connection ID in current session - $self->p->updateSession( $req, - { _stayConnectedSession => $ps->id } ); - } + $req->sessionInfo( $tmp->{sessionInfo} ); + $req->id( $tmp->{id} ); + my $name = $tmp->{name}; + if ( my $fg = $req->param('fg') ) { + my $isTotp = ( $fg =~ s/^TOTP_// ) ? 1 : 0; + if ( $isTotp + and + !$self->verifyCode( 30, 1, 6, $tmp->{totpSecret}, $fg ) + ) + { + $self->logger->warn( + "Failed to register device, bad TOTP"); } else { - $self->logger->warn( - "Browser did not return fingerprint"); + my $ps = $self->newConnectionSession( { + _utime => time + $self->timeout, + _session_uid => $name, + _connectedSince => time, + dataKeep => $req->data->{dataToKeep}, + ( + $isTotp + ? ( totpSecret => $tmp->{totpSecret} ) + : ( fingerprint => $fg ) + ), + }, + ); + + # Cookie available 30 days by default + $req->addCookie( + $self->p->cookie( + name => $self->cookieName, + value => $ps->id, + max_age => $self->timeout, + secure => $self->conf->{securedCookie}, + ) + ); + + $req->sessionInfo->{_loginHistory} = $tmp->{history} + if exists $tmp->{history}; + + # Store connection ID in current session + $self->p->updateSession( $req, + { _stayConnectedSession => $ps->id } ); } } else { - $self->userLogger->error( - "StayConnected: mismatch UID ($tmp->{name} / $uid)"); + $self->logger->warn("Browser did not return fingerprint"); } } else { $self->userLogger->error( - "StayConnected called with an expired token"); + "StayConnected called with an invalid token"); + return $self->p->do( $req, [ sub { PE_ERROR } ] ); } } else { @@ -190,7 +187,8 @@ sub storeBrowser { } # Return persistent connection cookie - return $self->p->do( $req, [ @{ $self->p->endAuth }, sub { PE_OK } ] ); + return $self->p->do( $req, + [ 'buildCookie', @{ $self->p->endAuth }, sub { PE_OK } ] ); } # Check for: -- GitLab From 3d2db405bfd7dfcf77747490fc213953a197381a Mon Sep 17 00:00:00 2001 From: Maxime Besson Date: Fri, 25 Aug 2023 11:40:38 +0200 Subject: [PATCH 3/6] Update tests for TrustedBrowser --- .../t/64-StayConnected-singleSession.t | 3 +-- .../t/64-StayConnected-with-2F-and-History.t | 27 +++++++++---------- .../t/64-StayConnected-with-History.t | 10 +++---- ...ayConnected-without-fingerprint-checking.t | 4 +-- lemonldap-ng-portal/t/64-StayConnected.t | 6 ++--- 5 files changed, 20 insertions(+), 30 deletions(-) diff --git a/lemonldap-ng-portal/t/64-StayConnected-singleSession.t b/lemonldap-ng-portal/t/64-StayConnected-singleSession.t index 50fb083128..d1387ca8b2 100644 --- a/lemonldap-ng-portal/t/64-StayConnected-singleSession.t +++ b/lemonldap-ng-portal/t/64-StayConnected-singleSession.t @@ -35,7 +35,6 @@ sub login_create_persistent_cookie_from_scratch { 'Auth query' ); count(1); - my $id = expectCookie($res); my ( $host, $url, $query ) = expectForm( $res, undef, '/registerbrowser', 'fg', 'token' ); @@ -46,12 +45,12 @@ sub login_create_persistent_cookie_from_scratch { '/registerbrowser', IO::String->new($query), length => length($query), - cookie => "lemonldap=$id", accept => 'text/html', ), 'Post fingerprint' ); count(1); + my $id = expectCookie($res); expectRedirection( $res, 'http://auth.example.com/' ); my $cid = expectCookie( $res, 'llngpersistent' ); return ( $id, $cid ); diff --git a/lemonldap-ng-portal/t/64-StayConnected-with-2F-and-History.t b/lemonldap-ng-portal/t/64-StayConnected-with-2F-and-History.t index 3e6b1bbdf0..b0934a26be 100644 --- a/lemonldap-ng-portal/t/64-StayConnected-with-2F-and-History.t +++ b/lemonldap-ng-portal/t/64-StayConnected-with-2F-and-History.t @@ -229,7 +229,6 @@ JjTJecOOS+88fK8qL1TrYv5rapIdqUI7aQ== ), 'Post code' ); - $id = expectCookie($res); ( $host, $url, $query ) = expectForm( $res, undef, '/registerbrowser', 'fg', 'token' ); @@ -240,11 +239,11 @@ JjTJecOOS+88fK8qL1TrYv5rapIdqUI7aQ== '/registerbrowser', IO::String->new($query), length => length($query), - cookie => "lemonldap=$id", accept => 'text/html', ), 'Post fingerprint' ); + $id = expectCookie($res); my $cid = expectCookie( $res, 'llngconnection' ); # History is displayed @@ -334,17 +333,6 @@ JjTJecOOS+88fK8qL1TrYv5rapIdqUI7aQ== 'Push U2F signature' ); - # See https://github.com/mschout/perl-authen-u2f-tester/issues/2 - if ( $Authen::U2F::Tester::VERSION >= 0.03 ) { - $id = expectCookie($res); - } - else { - count(1); - pass( -'Authen::2F::Tester-0.02 signatures are not recognized by Yubico library' - ); - } - ( $host, $url, $query ) = expectForm( $res, undef, '/registerbrowser', 'fg', 'token' ); @@ -355,11 +343,22 @@ JjTJecOOS+88fK8qL1TrYv5rapIdqUI7aQ== '/registerbrowser', IO::String->new($query), length => length($query), - cookie => "lemonldap=$id", accept => 'text/html', ), 'Post fingerprint' ); + + # See https://github.com/mschout/perl-authen-u2f-tester/issues/2 + if ( $Authen::U2F::Tester::VERSION >= 0.03 ) { + $id = expectCookie($res); + } + else { + count(1); + pass( +'Authen::2F::Tester-0.02 signatures are not recognized by Yubico library' + ); + } + $cid = expectCookie( $res, 'llngconnection' ); # History is displayed diff --git a/lemonldap-ng-portal/t/64-StayConnected-with-History.t b/lemonldap-ng-portal/t/64-StayConnected-with-History.t index 5b0d9fd8ed..e34c30867b 100644 --- a/lemonldap-ng-portal/t/64-StayConnected-with-History.t +++ b/lemonldap-ng-portal/t/64-StayConnected-with-History.t @@ -34,7 +34,6 @@ ok( 'Auth query' ); count(1); -my $id = expectCookie($res); my ( $host, $url, $query ) = expectForm( $res, undef, '/registerbrowser', 'fg', 'token' ); @@ -46,12 +45,11 @@ ok( '/registerbrowser', IO::String->new($query), length => length($query), - cookie => "lemonldap=$id", accept => 'text/html', ), 'Post fingerprint' ); -expectRedirection( $res, 'http://auth.example.com/' ); +expectPortalError( $res, 24 ); count(1); # Try to authenticate @@ -65,7 +63,6 @@ ok( 'Auth query' ); count(1); -$id = expectCookie($res); ( $host, $url, $query ) = expectForm( $res, undef, '/registerbrowser', 'fg', 'token' ); @@ -76,11 +73,11 @@ ok( '/registerbrowser', IO::String->new($query), length => length($query), - cookie => "lemonldap=$id", accept => 'text/html', ), 'Post fingerprint' ); +my $id = expectCookie($res); expectRedirection( $res, 'http://auth.example.com/' ); my $cid = expectCookie( $res, 'llngpersistent' ); ok( $res->[1]->[5] =~ /\bsecure\b/, ' Secure cookie found' ) @@ -192,7 +189,6 @@ ok( 'Auth query' ); count(1); -$id = expectCookie($res); ( $host, $url, $query ) = expectForm( $res, undef, '/registerbrowser', 'fg', 'token' ); @@ -203,12 +199,12 @@ ok( '/registerbrowser', IO::String->new($query), length => length($query), - cookie => "lemonldap=$id", accept => 'text/html', ), 'Post fingerprint' ); count(1); +$id = expectCookie($res); $cid = expectCookie( $res, 'llngpersistent' ); ok( $res->[2]->[0] =~ qr%new($query), length => length($query), - cookie => "lemonldap=$id", accept => 'text/html', ), 'Post fingerprint' ); expectRedirection( $res, 'http://auth.example.com/' ); +my $id = expectCookie($res); my $cid = expectCookie( $res, 'llngconnection' ); ok( $res->[1]->[5] =~ /\bHttpOnly=1\b/, ' HTTP cookie found' ) or explain( $res->[1]->[5], 'HTTP cookie found' ); diff --git a/lemonldap-ng-portal/t/64-StayConnected.t b/lemonldap-ng-portal/t/64-StayConnected.t index 83a384232f..6aeac8c470 100644 --- a/lemonldap-ng-portal/t/64-StayConnected.t +++ b/lemonldap-ng-portal/t/64-StayConnected.t @@ -31,7 +31,6 @@ subtest "Register session, use it, then logout" => sub { ), 'Auth query' ); - my $id = expectCookie($res); my ( $host, $url, $query ) = expectForm( $res, undef, '/registerbrowser', 'fg', 'token' ); @@ -42,11 +41,11 @@ subtest "Register session, use it, then logout" => sub { '/registerbrowser', IO::String->new($query), length => length($query), - cookie => "lemonldap=$id", accept => 'text/html', ), 'Post fingerprint' ); + my $id = expectCookie($res); expectRedirection( $res, 'http://auth.example.com/' ); my $cid = expectCookie( $res, 'llngpersistent' ); ok( $res->[1]->[5] =~ /\bsecure\b/, ' Secure cookie found' ) @@ -99,7 +98,6 @@ subtest "Make sure connection ID is saved on first login too" => sub { ), 'Auth query' ); - my $id = expectCookie($res); my ( $host, $url, $query ) = expectForm( $res, undef, '/registerbrowser', 'fg', 'token' ); @@ -110,11 +108,11 @@ subtest "Make sure connection ID is saved on first login too" => sub { '/registerbrowser', IO::String->new($query), length => length($query), - cookie => "lemonldap=$id", accept => 'text/html', ), 'Post fingerprint' ); + my $id = expectCookie($res); expectRedirection( $res, 'http://auth.example.com/' ); my $cid = expectCookie( $res, 'llngpersistent' ); ok( $res->[1]->[5] =~ /\bsecure\b/, ' Secure cookie found' ) -- GitLab From 5b302f8d283a07188d3db3cfaf1dc5ffac3ea95d Mon Sep 17 00:00:00 2001 From: Maxime Besson Date: Thu, 12 Oct 2023 17:20:37 +0200 Subject: [PATCH 4/6] Refactor TrustedBrowser check --- .../NG/Portal/Plugins/StayConnected.pm | 32 +- .../NG/Portal/Plugins/TrustedBrowser.pm | 442 ++++++++++-------- 2 files changed, 267 insertions(+), 207 deletions(-) diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/StayConnected.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/StayConnected.pm index e23f3fc18e..641a8fd2ba 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/StayConnected.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/StayConnected.pm @@ -17,33 +17,47 @@ extends qw( # INTERFACE use constant beforeAuth => 'check'; +has rule => ( is => 'rw', default => sub { 0 } ); # INITIALIZATION sub init { my ($self) = @_; + + # Parse activation rule + $self->rule( + $self->p->buildRule( $self->conf->{stayConnected}, 'stayConnected' ) ); + return 0 unless $self->rule; + return 1; } # Check for: -# - persistent connection cookie -# - valid session -# - uniq id is kept -# Then delete authentication methods from "steps" array. sub check { my ( $self, $req ) = @_; - if ( my $uid = $req->data->{_trustedUser} ) { - return $self->skipAuthentication($req); + + if ( !$self->rule->( $req, $req->sessionInfo ) ) { + $self->logger->debug("Stay Connected not allowed"); + } + + my $trustedBrowser = $self->p->_trustedBrowser; + + # Run TrustedBrowser challenge + if ( $trustedBrowser->mustChallenge($req) ) { + return $trustedBrowser->challenge( $req, '#'); + } + elsif ( my $state = $trustedBrowser->getKnownBrowserState($req) ) { + return $self->skipAuthentication( $req, $state ); } return PE_OK; } # Remove authentication steps from the login flow sub skipAuthentication { - my ( $self, $req ) = @_; - my $uid = $req->data->{_trustedUser}; + my ( $self, $req, $state ) = @_; + my $uid = $state->{_trustedUser}; $req->user($uid); $req->sessionInfo->{_stayConnectedSession} = - $req->data->{_stayConnectedSession}; + $state->{_stayConnectedSession}; my @steps = grep { ref $_ or $_ !~ /^(?:extractFormInfo|authenticate)$/ } @{ $req->steps }; diff --git a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/TrustedBrowser.pm b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/TrustedBrowser.pm index d5dffd0635..3cf1510c9c 100644 --- a/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/TrustedBrowser.pm +++ b/lemonldap-ng-portal/lib/Lemonldap/NG/Portal/Plugins/TrustedBrowser.pm @@ -1,4 +1,26 @@ -# Plugin to enable "stay connected on this device" feature +# Trusted Browser service +# This package provides the ability to remember a browser across SSO logins +# It is meant to be used by multiple features +# - Auto-login (StayConnected) +# - 2FA bypass +# +# Browser registration is entirely handled by this plugin, and trigger by setting +# $req->param('stayconnected'), usually by the user clicking a checkbox. +# Request state is handled automatically during browser registration. +# +# Recognition of an already registered browser is not triggered by the plugin +# itself but by other plugins (StayConnected, 2FA engine). +# Plugins should call 'mustChallenge' to know if they need to start a TOTP +# challenge with the web browser. If it is the case, they run 'challenge' to +# prepare the validation form containing the JS challenge. +# +# Plugins must indicate a route for the challenge form to POST to. Plugins must +# implement that route, and in that route, call 'getKnownBrowserState' to check +# that the browser really is trusted, and then, continue the current flow. +# +# Plugins are responsible for saving the request state and restoring it during +# the recognition phase. +# package Lemonldap::NG::Portal::Plugins::TrustedBrowser; @@ -20,11 +42,9 @@ extends qw( # INTERFACE -use constant beforeAuth => 'check'; use constant beforeLogout => 'logout'; # INITIALIZATION -has rule => ( is => 'rw', default => sub { 0 } ); has ott => ( is => 'rw', lazy => 1, @@ -63,50 +83,28 @@ sub init { my ($self) = @_; $self->addUnauthRoute( registerbrowser => 'storeBrowser', ['POST'] ); - # Parse activation rule - $self->rule( - $self->p->buildRule( $self->conf->{stayConnected}, 'stayConnected' ) ); - return 0 unless $self->rule; - return 1; } # RUNNING METHODS -# Registration: detect if user wants to stay connected. -# Then ask for browser fingerprint +# This method is called by the portal in standard auth flows + sub newDevice { my ( $self, $req ) = @_; - my $checkLogins = $req->param('checkLogins'); - $self->logger->debug("StayConnected: checkLogins set") if $checkLogins; - - if ( $req->param('stayconnected') - && $self->rule->( $req, $req->sessionInfo ) ) - { + if ( $req->param('stayconnected') ) { my $totpSecret = $self->newSecret; - my $token = $self->ott->createToken( { - name => $req->sessionInfo->{ $self->conf->{whatToTrace} }, - id => $req->id, - sessionInfo => $req->sessionInfo, - totpSecret => $totpSecret, - ( - $checkLogins - ? ( history => $req->sessionInfo->{_loginHistory} ) - : () - ) - } - ); + + my $state = $self->_getRegisterStateTplParams( $req, $totpSecret ); $req->response( $self->p->sendHtml( $req, '../common/registerBrowser', params => { - URL => $req->urldc, - TOTPSEC => $totpSecret, - TOKEN => $token, - ACTION => '/registerbrowser', - CHECKLOGINS => $checkLogins + TOTPSEC => $totpSecret, + ACTION => '/registerbrowser', + %$state, } ) ); @@ -115,166 +113,230 @@ sub newDevice { return PE_OK; } -# Store data in a long-time session +# Store TOTP secret + request state into a temporary session +# and return relevant template parameters +sub _getRegisterStateTplParams { + my ( $self, $req, $totpSecret ) = @_; + + my $checkLogins = $req->param('checkLogins') || 0; + my $url = $req->urldc; + my $token = $self->ott->createToken( { + id => $req->id, + sessionInfo => $req->sessionInfo, + totpSecret => $totpSecret, + } + ); + my %state = ( + CHECKLOGINS => $checkLogins, + URL => $url, + TOKEN => $token, + ); + return \%state; +} + +# This method handles the browser response to the registerBrowser JS code +# and persists the registration sub storeBrowser { my ( $self, $req ) = @_; - $req->urldc( $req->param('url') ); - $req->mustRedirect(1); - if ( $self->rule->( $req, $req->sessionInfo ) ) { - if ( my $token = $req->param('token') ) { - if ( my $tmp = $self->ott->getToken($token) ) { - $req->sessionInfo( $tmp->{sessionInfo} ); - $req->id( $tmp->{id} ); - my $name = $tmp->{name}; - if ( my $fg = $req->param('fg') ) { - my $isTotp = ( $fg =~ s/^TOTP_// ) ? 1 : 0; - if ( $isTotp - and - !$self->verifyCode( 30, 1, 6, $tmp->{totpSecret}, $fg ) - ) - { - $self->logger->warn( - "Failed to register device, bad TOTP"); - } - else { - my $ps = $self->newConnectionSession( { - _utime => time + $self->timeout, - _session_uid => $name, - _connectedSince => time, - dataKeep => $req->data->{dataToKeep}, - ( - $isTotp - ? ( totpSecret => $tmp->{totpSecret} ) - : ( fingerprint => $fg ) - ), - }, - ); - - # Cookie available 30 days by default - $req->addCookie( - $self->p->cookie( - name => $self->cookieName, - value => $ps->id, - max_age => $self->timeout, - secure => $self->conf->{securedCookie}, - ) - ); - - $req->sessionInfo->{_loginHistory} = $tmp->{history} - if exists $tmp->{history}; - - # Store connection ID in current session - $self->p->updateSession( $req, - { _stayConnectedSession => $ps->id } ); - } - } - else { - $self->logger->warn("Browser did not return fingerprint"); - } + + if ( my $totpSecret = $self->_restoreRegisterState($req) ) { + my $name = $req->sessionInfo->{ $self->conf->{whatToTrace} }; + if ( my $fg = $req->param('fg') ) { + my $isTotp = ( $fg =~ s/^TOTP_// ) ? 1 : 0; + if ( $isTotp + and !$self->verifyCode( 30, 1, 6, $totpSecret, $fg ) ) + { + $self->logger->warn( "Failed to register device, bad TOTP, " + . "device will not be remembered" ); } else { - $self->userLogger->error( - "StayConnected called with an invalid token"); - return $self->p->do( $req, [ sub { PE_ERROR } ] ); + my $ps = $self->newConnectionSession( { ( + _session_uid => $name, + $isTotp + ? ( totpSecret => $totpSecret ) + : ( fingerprint => $fg ) + ), + }, + ); + + # Cookie available 30 days by default + $req->addCookie( + $self->p->cookie( + name => $self->cookieName, + value => $ps->id, + max_age => $self->timeout, + secure => $self->conf->{securedCookie}, + ) + ); + + # Store connection ID in current session + $self->p->updateSession( $req, + { _stayConnectedSession => $ps->id } ); } } else { - $self->userLogger->error('StayConnected called without token'); + $self->logger->warn( "Browser did not return fingerprint, " + . "browser will not be remembered" ); } } else { - $self->userLogger->error('StayConnected not allowed'); + $self->userLogger->error( + "Cannot restore trusted browser registration state"); + return $self->p->do( $req, [ sub { PE_ERROR } ] ); } - # Return persistent connection cookie + # Resume normal login flow + $req->mustRedirect(1); return $self->p->do( $req, [ 'buildCookie', @{ $self->p->endAuth }, sub { PE_OK } ] ); } -# Check for: -# - persistent connection cookie -# - valid session -# - uniq id is kept -# Then delete authentication methods from "steps" array. -sub check { +sub _restoreRegisterState { my ( $self, $req ) = @_; - if ( $self->rule->( $req, $req->sessionInfo ) ) { - if ( my $cid = $req->cookies->{ $self->cookieName } ) { - my $ps = Lemonldap::NG::Common::Session->new( - storageModule => $self->conf->{globalStorage}, - storageModuleOptions => $self->conf->{globalStorageOptions}, - kind => "SSO", - id => $cid, - ); - if ( $ps - and my $uid = $ps->data->{_session_uid} - and time() < $ps->data->{_utime} ) - { - $self->logger->debug('Persistent connection found'); - if ( my $fg = $req->param('fg') - and my $token = $req->param('token') ) - { - if ( my $prm = $self->ott->getToken($token) ) { - $req->data->{dataKeep} = $ps->data->{dataKeep}; - $self->logger->debug('Persistent connection found'); - if ( $self->conf->{stayConnectedBypassFG} ) { - return $self->skipAuthentication( $req, $uid, $cid, - 0 ); - } - else { - if ( $fg =~ s/^TOTP_// ) { - return $self->skipAuthentication( $req, $uid, - $cid, 1 ) - if $self->verifyCode( 30, 1, 6, - $ps->data->{totpSecret}, $fg ) > 0; - } - elsif ( $fg eq $ps->data->{fingerprint} ) { - return $self->skipAuthentication( $req, $uid, - $cid, 1 ); - } - $self->userLogger->warn( - "Fingerprint changed for $uid"); - $ps->remove; - $self->logout($req); - } - } - else { - $self->userLogger->notice( - "StayConnected: expired token for $uid"); - } - } - else { - my $token = $self->ott->createToken( $req->parameters ); - $req->response( - $self->p->sendHtml( - $req, - '../common/registerBrowser', - params => { - TOKEN => $token, - ACTION => '#', - } - ) - ); - return PE_SENDRESPONSE; - } - } - else { - $self->userLogger->notice('Persistent connection expired'); - unless ( $ps->{error} ) { - $self->logger->debug( - 'Persistent connection session id = ' . $ps->{id} ); - $self->logger->debug( - 'Persistent connection session _utime = ' - . $ps->data->{_utime} ); - $ps->remove; - } - } + + if ( my $token = $req->param('token') ) { + if ( my $saved_state = $self->ott->getToken($token) ) { + + # checkLogins is restored automatically as a request parameter + $req->urldc( $req->param('url') ); + $req->sessionInfo( $saved_state->{sessionInfo} ); + $req->id( $saved_state->{id} ); + return $saved_state->{totpSecret}; + } + else { + $self->userLogger->error("Invalid trusted browser state token"); } } else { - $self->userLogger->error('StayConnected not allowed'); + $self->userLogger->error('Missing trusted browser state token'); } - return PE_OK; + return; + +} + +sub getTrustedBrowserSessionFromReq { + my ( $self, $req ) = @_; + + if ( my $cid = $req->cookies->{ $self->cookieName } ) { + return $self->getTrustedBrowserSession($cid); + } + else { + $self->logger->debug("No trusted browser cookie was sent"); + } + return; +} + +sub getTrustedBrowserSession { + my ( $self, $cid ) = @_; + my $ps = Lemonldap::NG::Common::Session->new( + storageModule => $self->conf->{globalStorage}, + storageModuleOptions => $self->conf->{globalStorageOptions}, + kind => "SSO", + id => $cid, + ); + if ( $ps->data->{_session_uid} and ( time() < $ps->data->{_utime} ) ) { + $self->logger->debug('Persistent connection found'); + return $ps; + } + else { + $self->userLogger->notice('Persistent connection expired'); + unless ( $ps->{error} ) { + $self->logger->debug( + 'Persistent connection session id = ' . $ps->{id} ); + $self->logger->debug( 'Persistent connection session _utime = ' + . $ps->data->{_utime} ); + $ps->remove; + } + } + return; +} + +sub _hasFingerprintParams { + my ( $self, $req ) = @_; + return ( $req->param('fg') and $req->param('token') ); +} + +sub mustChallenge { + my ( $self, $req, $expected_user ) = @_; + + my $ps = $self->getTrustedBrowserSessionFromReq($req); + if ( $ps and not( $self->_hasFingerprintParams($req) ) ) { + if ($expected_user) { + return ( $ps->data->{_session_uid} + and $ps->data->{_session_uid} eq $expected_user ); + } + else { + return 1; + } + } + return 0; +} + +sub getKnownBrowserState { + my ( $self, $req ) = @_; + + my $ps = $self->getTrustedBrowserSessionFromReq($req); + if ( $ps and $self->_hasFingerprintParams($req) ) { + my $fg = $req->param('fg'); + my $token = $req->param('token'); + my $uid = $ps->data->{_session_uid}; + + my $token_data = $self->ott->getToken($token); + return unless $token_data; + + if ( $self->checkFingerprint( $req, $ps, $uid, $fg ) ) { + return { + _trustedUser => $uid, + _stayConnectedSession => $ps->id, + data => $token_data, + }; + } + else { + $self->userLogger->warn("Fingerprint changed for $uid"); + $ps->remove; + $self->logout($req); + } + } + else { + $self->logger->debug("No fingerprint or token parameter was sent"); + } + return; +} + +sub checkFingerprint { + my ( $self, $req, $ps, $uid, $fg ) = @_; + $self->logger->debug('Persistent connection found'); + if ( $self->conf->{stayConnectedBypassFG} ) { + return 1; + } + else { + if ( $fg =~ s/^TOTP_// ) { + return 1 + if ( + $self->verifyCode( 30, 1, 6, $ps->data->{totpSecret}, $fg ) > + 0 ); + } + elsif ( $fg eq $ps->data->{fingerprint} ) { + return 1; + } + } + return 0; +} + +sub challenge { + my ( $self, $req, $action, $info ) = @_; + my $token = $self->ott->createToken($info); + $req->response( + $self->p->sendHtml( + $req, + '../common/registerBrowser', + params => { + TOKEN => $token, + ACTION => $action, + } + ) + ); + return PE_SENDRESPONSE; } sub logout { @@ -291,12 +353,7 @@ sub logout { # Try to clean stayconnected cookie my $cid = $req->sessionInfo->{_stayConnectedSession}; if ($cid) { - my $ps = Lemonldap::NG::Common::Session->new( - storageModule => $self->conf->{globalStorage}, - storageModuleOptions => $self->conf->{globalStorageOptions}, - kind => "SSO", - id => $cid, - ); + my $ps = $self->getTrustedBrowserSession($cid); if ($ps) { $self->logger->debug("Cleaning up StayConnected session $cid"); $ps->remove; @@ -306,16 +363,6 @@ sub logout { return PE_OK; } -# Remove authentication steps from the login flow -sub skipAuthentication { - my ( $self, $req, $uid, $cid, $fp ) = @_; - - # FIXME - $req->data->{_trustedUser} = $uid; - $req->data->{_stayConnectedSession} = $cid; - return PE_OK; -} - sub removeExistingSessions { my ( $self, $uid ) = @_; $self->logger->debug("StayConnected: removing all sessions for $uid"); @@ -324,15 +371,8 @@ sub removeExistingSessions { $self->module->searchOn( $self->moduleOpts, '_session_uid', $uid ); foreach ( keys %{ $sessions || {} } ) { - if ( - my $ps = Lemonldap::NG::Common::Session->new( - storageModule => $self->conf->{globalStorage}, - storageModuleOptions => $self->conf->{globalStorageOptions}, - kind => "SSO", - id => $_, - ) - ) - { + if ( my $ps = $self->getTrustedBrowserSession($_) ) { + # If this is a StayConnected session, remove it $ps->remove if $ps->{data}->{_connectedSince}; $self->logger->debug("StayConnected removed session $_"); @@ -346,6 +386,8 @@ sub removeExistingSessions { sub newConnectionSession { my ( $self, $info ) = @_; + $info ||= {}; + # Remove existing sessions if ( $self->singleSession ) { $self->removeExistingSessions( $info->{_session_uid} ); @@ -355,7 +397,11 @@ sub newConnectionSession { storageModule => $self->conf->{globalStorage}, storageModuleOptions => $self->conf->{globalStorageOptions}, kind => "SSO", - info => $info, + info => { + _utime => time + $self->timeout, + _connectedSince => time, + %$info, + } ); } -- GitLab From a2cbdb0bd53f5cacbc91b1f7c66af371ad7dd155 Mon Sep 17 00:00:00 2001 From: Maxime Besson Date: Fri, 11 Aug 2023 15:04:27 +0200 Subject: [PATCH 5/6] Rename unit test for stayConnectedSingleSession --- ...ected-singleSession.t => 64-StayConnected-single-connection.t} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lemonldap-ng-portal/t/{64-StayConnected-singleSession.t => 64-StayConnected-single-connection.t} (100%) diff --git a/lemonldap-ng-portal/t/64-StayConnected-singleSession.t b/lemonldap-ng-portal/t/64-StayConnected-single-connection.t similarity index 100% rename from lemonldap-ng-portal/t/64-StayConnected-singleSession.t rename to lemonldap-ng-portal/t/64-StayConnected-single-connection.t -- GitLab From d1932ec70e3e0b0153f5980b0dd9c3a77f67d571 Mon Sep 17 00:00:00 2001 From: Maxime Besson Date: Fri, 11 Aug 2023 15:29:04 +0200 Subject: [PATCH 6/6] Unit test for #2983 --- .../t/64-StayConnected-singleSession.t | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 lemonldap-ng-portal/t/64-StayConnected-singleSession.t diff --git a/lemonldap-ng-portal/t/64-StayConnected-singleSession.t b/lemonldap-ng-portal/t/64-StayConnected-singleSession.t new file mode 100644 index 0000000000..b3b3c84a30 --- /dev/null +++ b/lemonldap-ng-portal/t/64-StayConnected-singleSession.t @@ -0,0 +1,165 @@ +use warnings; +use Test::More; +use strict; +use IO::String; + +require 't/test-lib.pm'; + +my $client = LLNG::Manager::Test->new( { + ini => { + logLevel => 'error', + stayConnected => 1, + singleSession => 1, + notifyDeleted => 1, + loginHistoryEnabled => 1, + securedCookie => 1, + stayConnectedTimeout => 1000, + stayConnectedSingleSession => 1, + } + } +); + +sub login_create_persistent_cookie_from_scratch { + my ( $client, $notifydeleted ) = @_; + my $res; + + ok( + $res = $client->_post( + '/', + IO::String->new('user=dwho&password=dwho&stayconnected=1'), + length => 39 + ), + 'Auth query' + ); + my ( $host, $url, $query ) = + expectForm( $res, undef, '/registerbrowser', 'fg', 'token' ); + + # Push fingerprint + $query =~ s/fg=/fg=aaa/; + ok( + $res = $client->_post( + '/registerbrowser', + IO::String->new($query), + length => length($query), + accept => 'text/html', + ), + 'Post fingerprint' + ); + + my $id = expectCookie($res); + if ($notifydeleted) { + like( $res->[2]->[0], qr/sessionsDeleted/, "Show deleted sessions" ); + expectForm( $res, "auth.example.com", "/" ); + } + else { + expectRedirection( $res, 'http://auth.example.com/' ); + } + my $cid = expectCookie( $res, 'llngconnection' ); + return ( $id, $cid ); +} + +sub sessionid_not_valid { + my ( $client, $id ) = @_; + my $res; + ok( + $res = $client->_get( + '/', + cookie => "lemonldap=$id", + accept => 'text/html', + ), + 'Check session validity' + ); + is( getHeader( $res, 'Lm-Remote-User' ), + undef, "Session ID no longer valid" ); +} + +sub sessionid_still_valid { + my ( $client, $id ) = @_; + my $res; + ok( + $res = $client->_get( + '/', + cookie => "lemonldap=$id", + accept => 'text/html', + ), + 'Check session validity' + ); + expectAuthenticatedAs( $res, "dwho" ); +} + +sub try_connect_with_persistent_cookie { + my ( $client, $cid, $notifydeleted ) = @_; + my $res; + ok( + $res = $client->_get( + '/', + cookie => "llngconnection=$cid", + accept => 'text/html', + ), + 'Try to auth with persistent cookie' + ); + expectOK($res); + if ( $res->[2]->[0] =~ qr/Register browser/ ) { + my ( $host, $url, $query ) = + expectForm( $res, '#', undef, 'fg', 'token' ); + + # Push fingerprint + $query =~ s/fg=/fg=aaa/; + ok( + $res = $client->_post( + '/', + IO::String->new($query), + cookie => "llngconnection=$cid", + length => length($query), + accept => 'text/html', + ), + 'Post fingerprint' + ); + if ($notifydeleted) { + like( $res->[2]->[0], qr/sessionsDeleted/, + "Show deleted sessions" ); + expectForm( $res, "auth.example.com", "/" ); + } + else { + expectRedirection( $res, 'http://auth.example.com/' ); + } + return expectCookie($res); + } + else { + return; + } +} + +subtest "Login with stay connected, then with persistent cookie" + . ", user sees notification" => sub { + + clean_sessions(); + + # Create a persistent connection + my ( $id, $cid ) = login_create_persistent_cookie_from_scratch($client); + sessionid_still_valid( $client, $id ); + + # NotifyDeleted must be shown + my $id2 = try_connect_with_persistent_cookie( $client, $cid, 1 ); + sessionid_still_valid( $client, $id2 ); + sessionid_not_valid( $client, $id ); + }; + +subtest "Login with stay connected, then without persistent cookie" + . ", user sees notification" => sub { + + clean_sessions(); + + # Create a persistent connection + my ( $id, $cid ) = login_create_persistent_cookie_from_scratch($client); + sessionid_still_valid( $client, $id ); + + # NotifyDeleted must be shown + my ( $id2, $cid2 ) = + login_create_persistent_cookie_from_scratch( $client, 1 ); + sessionid_not_valid( $client, $id ); + }; + +clean_sessions(); +done_testing(); + -- GitLab