# 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} or {} };
        $self->logger->debug(
            "Loading extra 2F module $extraKey of type $moduleType");
        my $m =
          $self->loadPlugin( "::2F::$moduleType",
            { sfPrefix => $extraKey, %over } )
          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 $tpl = $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($tpl);
    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, $tpl ) = @_;

    # After verifying rule:
    #  - display template if $tpl
    #  - else display choice template
    if ($tpl) {
        my ($m) =
          grep { $_->{m}->prefix eq $tpl } @{ $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},
                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, $tpl, @args ) = @_;

    # After verifying rule:
    #   - call register run method if $tpl
    #   - else give JSON list of available registers for this user
    if ($tpl) {
        my ($m) =
          grep { $_->{m}->prefix eq $tpl } @{ $self->sfRModules };
        unless ($m) {
            return $self->p->sendError( $req,
                'Inexistent register module', 400 );
        }
        unless ( $m->{r}->( $req, $req->userData ) ) {
            $self->userLogger->error("$tpl 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;
