# Session server plugin for REST requests
#
# This plugin adds the following entry points:
# - Sessions backend (if restSessionServer is on)
#   * GET /sessions/<type>/<session-id>          : get session data
#   * GET /sessions/<type>/<session-id>/<key>    : get a session key value
#   * GET /sessions/<type>/<session-id>/[k1,k2]  : get some session key value
#   * POST /sessions/<type>                      : create a session
#   * POST /sessions/<type>?all=1                : get all sessions (needs a token)
#   * POST /sessions/<type>?all=1&search=uid,dwho: search all sessions where uid=dwho (needs a token)
#   * PUT /sessions/<type>/<session-id>          : update some keys
#   * DELETE /sessions/<type>/<session-id>       : delete a session
#
# - Sessions for connected users (if restSessionServer is on):
#   * GET /session/my/<type>                     : get session data
#   * GET /session/my/<type>/key                 : get session key
#   * DELETE /session/my                         : ask for logout
#   * DELETE /sessions/my                        : ask for global logout
#
# - Authentication
#   * GET /renewcaptcha                          : get token and captcha image
#   * POST /sessions/<type>/<session-id>?auth    : authenticate with a fixed
#                                                  sessionId
#   * Note that the "getCookie" method (authentification via SOAP) exists for
#      REST requests directly by using '/' path : the portal recognize REST
#      calls and generate JSON response instead of web page.
#
# - Configuration (if restConfigServer is on)
#   * GET /config/latest                          : get the last config metadata
#   * GET /config/<cfgNum>                        : get the metadata for config
#                                                  n° <cfgNum>
#   * GET /config/<latest|cfgNum>/<key>           : get conf key value
#   * GET /config/<latest|cfgNum>?full            : get the full configuration
#   where <type> is the session type ("global" for SSO session or "persistent")
#   * GET /error/<lang>/<errNum>                  : get <errNum> message reference and errors file <lang>.json
#   Return 'en' error file if no <lang> specified
#
# - Endpoints for proxy auth/userdb/password
#   * POST /proxy/getUser                        : get user attributes (restAuthServer)
#   * POST /proxy/pwdReset                       : reset password (restPasswordServer)
#   * POST /proxy/pwdConfirm                     : check password (restAuthServer or restPasswordServer)
#
# - Authorizations for connected users (always):
#   * GET /mysession/?whoami                               : get "my" uid
#   * GET /mysession/?authorizationfor=<base64-encoded-url>: ask if url is
#                                                            authorized
#   * PUT /mysession/<type>                                : update some
#                                                            persistent data
#                                                            (restricted)
#   * DELETE /mysession/<type>/key                         : delete key in data
#                                                            (restricted)
#
# There is no conflict with SOAP server, they can be used together

package Lemonldap::NG::Portal::Plugins::RESTServer;

use strict;
use Mouse;
use JSON qw(from_json to_json);
use MIME::Base64;
use Lemonldap::NG::Portal::Main::Constants qw(
  PE_OK
  portalConsts
  PE_PASSWORD_OK
  URIRE
);

our $VERSION = '2.0.12';

extends qw(
  Lemonldap::NG::Portal::Main::Plugin
  Lemonldap::NG::Portal::Lib::Captcha
);

has configStorage => (
    is      => 'ro',
    lazy    => 1,
    default => sub {
        $_[0]->{p}->HANDLER->localConfig->{configStorage};
    }
);
has exportedAttr => (
    is      => 'rw',
    lazy    => 1,
    default => sub {
        my $conf = $_[0]->{conf};
        if ( $conf->{exportedAttr} and $conf->{exportedAttr} !~ /^\s*\+/ ) {
            return '[' . join( ',', split /\s+/, $conf->{exportedAttr} ) . ']';
        }
        else {
            my @attributes = (
                'authenticationLevel', 'groups',
                'ipAddr',              '_startTime',
                '_utime',              '_lastSeen',
                '_session_id',         '_session_kind',
            );
            if ( my $exportedAttr = $conf->{exportedAttr} ) {
                $exportedAttr =~ s/^\s*\+\s+//;
                @attributes = ( @attributes, split( /\s+/, $exportedAttr ) );

                # Convert @attributes into hash to remove duplicates
                my %attributes = map( { $_ => 1 } @attributes );
                return '[' . join( ',', keys %attributes ) . ']';
            }

            # Convert @attributes into hash to remove duplicates
            my %attributes = map( { $_ => 1 } @attributes );
            %attributes =
              ( %attributes, %{ $conf->{exportedVars} }, %{ $conf->{macros} },
              );
            return '[' . join( ',', keys %attributes ) . ']';
        }
    }
);
has captcha => ( is => 'rw' );
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;
    }
);

# INITIALIZATION

sub init {
    my ($self)  = @_;
    my @parents = ('Lemonldap::NG::Portal::Main::Plugin');
    my $add     = 0;
    if (   $self->conf->{captcha_mail_enabled}
        || $self->conf->{captcha_login_enabled}
        || $self->conf->{captcha_register_enabled} )
    {
        $self->captcha( $self->p->loadModule('::Lib::Captcha') ) or return 0;
        $self->addUnauthRoute( renewcaptcha => 'sendCaptcha', ['GET'] );
    }
    if ( $self->conf->{restConfigServer} ) {
        push @parents, 'Lemonldap::NG::Common::Conf::RESTServer';
        $add++;

        # Methods inherited from Lemonldap::NG::Common::Conf::RESTServer
        $self->addUnauthRoute(
            config => {
                ':cfgNum' => [
                    qw(virtualHosts samlIDPMetaDataNodes samlSPMetaDataNodes
                      applicationList oidcOPMetaDataNodes oidcRPMetaDataNodes
                      authChoiceModules grantSessionRules)
                ]
            },
            ['GET'],
          )

          ->addUnauthRoute(
            config => { ':cfgNum' => { '*' => 'getKey' } },
            ['GET']
          )

          ->addUnauthRoute( error => { '*' => 'getError' }, ['GET'] );
    }
    if ( $self->conf->{restSessionServer} ) {
        push @parents, 'Lemonldap::NG::Common::Session::REST';
        $add++;

        # Methods inherited from Lemonldap::NG::Common::Session::REST
        $self->addUnauthRoute(
            sessions => {
                ':sessionType' => (
                    $self->conf->{restExportSecretKeys}
                    ? 'rawSession'
                    : 'session'
                )
            },
            ['GET']
          )

          ->addUnauthRoute(
            sessions => { ':sessionType' => 'newSession' },
            ['POST']
          )

          # Methods written below
          ->addUnauthRoute(
            sessions => { ':sessionType' => 'updateSession' },
            ['PUT']
          )

          ->addUnauthRoute(
            sessions => { ':sessionType' => 'delSession' },
            ['DELETE']
          )

          ->addAuthRoute(
            session => { my => { ':sessionType' => 'getMyKey' } },
            [ 'GET', 'POST' ]
          )

          ->addAuthRoute(
            sessions => { my => { ':sessionType' => 'removeSessions' } },
            ['DELETE']
          );
    }

    if ( $self->conf->{restPasswordServer} ) {
        $self->addUnauthRoute(
            proxy => {
                'pwdReset' => 'pwdReset',
            },
            ['POST']
        );
    }

    if ( $self->conf->{restAuthServer} or $self->conf->{restPasswordServer} ) {
        $self->addUnauthRoute(
            proxy => {
                'pwdConfirm' => 'pwdConfirm',
            },
            ['POST']
        );
    }

    if ( $self->conf->{restAuthServer} ) {
        $self->addUnauthRoute(
            proxy => {
                'getUser' => 'getUser',
            },
            ['POST']
        );
    }

    # Methods always available
    $self->addAuthRoute(
        mysession => { '*' => 'mysession' },
        [ 'GET', 'POST' ]
      )

      ->addAuthRoute(
        mysession => {
            ':sessionType' =>
              { ':key' => 'delKeyInMySession', '*' => 'delMySession' }
        },
        ['DELETE']
      )

      ->addAuthRoute(
        mysession => { ':sessionType' => 'updateMySession' },
        ['PUT']
      );
    extends @parents               if ($add);
    $self->setTypes( $self->conf ) if ( $self->conf->{restSessionServer} );

    return 1;
}

sub newSession {
    my ( $self, $req, $id ) = @_;

    # If id is defined
    return $self->newAuthSession( $req, $id )
      if ( $id and exists $req->parameters->{auth} );
    my $mod = $self->getMod($req)
      or return $self->p->sendError( $req, undef, 400 );
    my $infos = $req->jsonBodyToObj
      or return $self->p->sendError( $req, undef, 400 );
    $infos->{_utime} = time();

    my $secret = delete $infos->{__secret};
    my $force  = $self->_checkSecret($secret);

    if ( $req->param('all') and not $id ) {
        return $self->p->sendError( $req,
            'Bad key, refuse to send all sessions', 400 )
          unless ($force);
        my $data = $infos->{data};
        my $opts = $self->conf->{globalStorageOptions} || {};
        $opts->{backend} = $self->conf->{globalStorage};
        my $sessions;
        if ( my $query = $req->param('search') ) {
            my ( $field, @values ) = split /,/, $query;
            $sessions = Lemonldap::NG::Common::Apache::Session->searchOn(
                $opts, $field,
                join( ',', @values ),
                ( $data ? @$data : () )
            );
        }
        else {
            $sessions =
              Lemonldap::NG::Common::Apache::Session
              ->get_key_from_all_sessions( $opts, $data );
        }
        return $self->p->sendJSONresponse( $req, $sessions );
    }
    my $session = $self->getApacheSession( $mod, $id, $infos, $force );
    return $self->p->sendError( $req, 'Unable to create session', 500 )
      unless ($session);

    $self->logger->debug(
        "SOAP request create a new session (" . $session->id . ")" );

    return $self->p->sendJSONresponse( $req,
        { result => 1, session => $session->data } );
}

sub newAuthSession {
    my ( $self, $req, $id ) = @_;

    # Check secret
    my $secret = $req->param('secret');
    unless ( $self->_checkSecret($secret) ) {
        return $self->p->sendError( $req, 'Bad secret', 403 );
    }

    $req->{id}    = $id;
    $req->{force} = 1;
    $req->user( $req->param('user') );
    $req->data->{password} = $req->param('password');
    $req->steps( [
            @{ $self->p->beforeAuth },
            qw(getUser extractFormInfo authenticate setAuthSessionInfo),
            @{ $self->p->betweenAuthAndData },
            $self->p->sessionData,
            @{ $self->p->afterData },
            $self->p->validSession,
            @{ $self->p->endAuth },
        ]
    );
    $req->{error} = $self->p->process($req);
    $self->logger->debug(
        "REST authentication result for $req->{user}: code $req->{error}");

    return $self->p->sendError( $req, 'Bad credentials', 401 )
      if ( $req->error > 0 );
    return $self->session( $req, $id );
}

sub updateSession {
    my ( $self, $req, $id ) = @_;
    $self->logger->debug("REST request to update session $id");
    my $mod = $self->getMod($req)
      or return $self->p->sendError( $req, undef, 400 );
    return $self->p->sendError( $req, 'ID is required', 400 ) unless ($id);

    # Get new info
    my $infos = $req->jsonBodyToObj
      or return $self->p->sendError( $req, undef, 400 );

    # Get secret if given
    my $secret = delete $infos->{__secret};
    my $force  = $self->_checkSecret($secret);

    # Get session and store info
    my $session = $self->getApacheSession( $mod, $id, $infos, $force )
      or return $self->p->sendError( $req, 'Session id does not exists', 400 );

    return $self->p->sendJSONresponse( $req, { result => 1 } );
}

sub delSession {
    my ( $self, $req, $id ) = @_;
    my $mod = $self->getMod($req)
      or return $self->p->sendError( $req, undef, 400 );
    return $self->p->sendError( $req, 'ID is required', 400 ) unless ($id);

    # Get session
    my $session = $self->getApacheSession( $mod, $id )
      or return $self->p->sendError( $req, 'Session id does not exists', 400 );

    # Delete it
    $self->logger->debug("REST request to delete session $id");
    my $res = $self->p->_deleteSession( $req, $session );
    $self->logger->debug(" Result is $res");

    return $self->p->sendJSONresponse( $req, { result => $res } );
}

sub delMySession {
    my ( $self, $req, $id ) = @_;

    return $self->delSession( $req, $req->userData->{_session_id} );
}

sub mysession {
    my ( $self, $req ) = @_;

    # 1. whoami
    return $self->p->sendJSONresponse( $req,
        { result => $req->userData->{ $self->conf->{whatToTrace} } } )
      if defined $req->param('whoami');

    if ( defined $req->param('gettoken') ) {
        return $self->p->sendJSONresponse( $req,
            { token => $self->ott->createToken() } );
    }

    # Verify authorizationfor arg
    elsif ( my $url = $req->param('authorizationfor') ) {

        # Verify that value is base64 encoded
        return $self->p->sendError( $req, "Value must be in BASE64", 400 )
          if ( $url =~ m#[^A-Za-z0-9\+/=]# );
        $req->urldc( decode_base64($url) );

        # Check for XSS problems
        return $self->p->sendError( $req, 'XSS attack detected', 400 )
          if ( $self->p->checkXSSAttack( 'authorizationfor', $req->urldc ) );

        # Split URL
        $req->urldc =~ URIRE;
        my ( $host, $uri ) = ( $3 . ( $4 ? ":$4" : '' ), $5 );
        $uri ||= '/';
        return $self->p->sendError( $req, "Bad URL $req->{urldc}", 400 )
          unless ($host);

        $self->logger->debug("Looking for authorization for $url");

        # Now check for authorization
        my $res =
          $self->p->HANDLER->grant( $req, $req->userData, $uri, undef, $host );
        $self->logger->debug(" Result is $res");
        return $self->p->sendJSONresponse( $req, { result => $res } );
    }

    return $self->p->sendError( $req,
        'whoami or authorizationfor is required', 400 );
}

sub getMyKey {
    my ( $self, $req, $key ) = @_;
    $key ||= '';
    if ($key) {
        $self->logger->debug(
            "Request to get personal session info -> Key: $key");
    }
    else {
        my $keys = $self->exportedAttr;
        $keys =~ s/(?:\[|\])//g;
        $keys =~ s/,/, /g;
        $self->logger->debug(
            "Request to get exported attributes -> Keys: $keys");
    }

    return $self->session(
        $req,
        $req->userData->{_session_id},
        $key || $self->exportedAttr
    );
}

sub updateMySession {
    my ( $self, $req ) = @_;
    my $res   = 0;
    my $mKeys = [];

    if ( my $token = $req->param('token') ) {
        if ( $self->ott->getToken($token) ) {
            if ( $req->param('sessionType') eq 'persistent' ) {
                foreach
                  my $key ( @{ $self->conf->{mySessionAuthorizedRWKeys} } )
                {
                    my $v;
                    if ( $key =~ /\*/ ) {
                        $key =~ s/\*/\.\*/g;
                        if ( my ($k) = grep( /$key/, $req->params ) ) {
                            $v = $req->param($k);
                        }
                    }
                    else {
                        $v = $req->param($key);
                    }
                    if ( defined $v ) {
                        $res++;
                        push @$mKeys, $key;
                        $self->p->updatePersistentSession( $req,
                            { $key => $v } );
                        $self->logger->debug(
                            "Request to update session -> Key : $key");
                    }
                }
            }
        }
        else {
            $self->logger->error('Update session request with invalid token');
        }
    }
    else {
        $self->logger->error('Update session request without token');
    }

    return $self->p->sendError( $req, 'Modification refused', 403 ) unless $res;
    return $self->p->sendJSONresponse( $req,
        { result => 1, count => $res, modifiedKeys => $mKeys } );
}

sub delKeyInMySession {
    my ( $self, $req ) = @_;
    my $res   = 0;
    my $mKeys = [];
    my $dkey  = $req->param('key');
    my $sub   = $req->param('sub');

    if ( my $token = $req->param('token') ) {
        if ( $self->ott->getToken($token) ) {
            if ( $req->param('sessionType') eq 'persistent' ) {
                foreach
                  my $key ( @{ $self->conf->{mySessionAuthorizedRWKeys} } )
                {
                    if ( $key =~ /\*/ ) {
                        $key =~ s/\*/\.\*/g;
                        if ( $dkey =~ /^$key$/ ) {
                            $res++;
                        }
                    }
                    elsif ( $dkey eq $key ) {
                        $res++;
                    }
                }
                if ($res) {
                    if ( $dkey !~ /^_oidcConsents$/ ) {
                        $self->p->updatePersistentSession( $req,
                            { $dkey => undef } );
                        $self->logger->debug(
                            "Update session -> delete Key : $dkey");
                    }
                    elsif ( $dkey =~ /^_oidcConsents$/ and defined $sub ) {

                        # Read existing oidcConsents
                        $self->logger->debug("Looking for OIDC Consents ...");
                        my $_oidcConsents;
                        if ( $req->userData->{_oidcConsents} ) {
                            $_oidcConsents = eval {
                                from_json( $req->userData->{_oidcConsents},
                                    { allow_nonref => 1 } );
                            };
                            if ($@) {
                                $self->logger->error(
                                    "Corrupted session (_oidcConsents): $@");
                                return $self->p->sendError( $req,
                                    "Corrupted session", 500 );
                            }
                        }
                        else {
                            $self->logger->debug("No OIDC Consent found");
                            $_oidcConsents = [];
                        }
                        my @keep = ();
                        while (@$_oidcConsents) {
                            my $element = shift @$_oidcConsents;
                            $self->logger->debug(
                                "Looking for OIDC Consent to delete ...");
                            push @keep, $element
                              unless ( $element->{rp} eq $sub );
                        }
                        $self->p->updatePersistentSession( $req,
                            { _oidcConsents => to_json( \@keep ) } );
                        $self->logger->debug(
"Update session -> delete Key : $dkey with Sub : $sub"
                        );
                    }
                    else {
                        $self->logger->error(
                            'Update session request with invalid Key or Sub');
                    }
                }
            }
        }
        else {
            $self->logger->error('Update session request with invalid token');
        }
    }
    else {
        $self->logger->error('Update session request without token');
    }

    return $self->p->sendError( $req, 'Modification refused', 403 ) unless $res;
    return $self->p->sendJSONresponse( $req,
        { result => 1, count => $res, modifiedKeys => $dkey } );
}

sub getError {
    my ( $self, $req, $lang, $errNum ) = @_;
    $lang ||= 'en';
    $errNum
      ? $self->logger->debug("Send $lang error file path with error: $errNum")
      : $self->logger->debug("Send $lang error file path");

    return $self->p->sendJSONresponse(
        $req,
        {
            result   => 1,
            lang     => $lang,
            errorNum => $errNum ? $errNum : 'all',
            errorsFileURL =>
              "$self->{conf}->{staticPrefix}/languages/$lang.json",
            ( $errNum ? ( errorMsgRef => "PE$errNum" ) : () ),
        }
    );
}

sub removeSessions {
    my ( $self, $req ) = @_;
    my $glPlugin =
      $self->p->loadedModules->{'Lemonldap::NG::Portal::Plugins::GlobalLogout'};
    my $sessions = $glPlugin->activeSessions($req);
    my $nbr      = $glPlugin->removeOtherActiveSessions( $req, $sessions );

    return $self->p->sendJSONresponse( $req, { result => $nbr } );
}

sub sendCaptcha {
    my ( $self, $req ) = @_;
    $self->logger->info("User request for captcha renew");
    my ( $token, $image ) = $self->captcha->getCaptcha($req);

    return $self->p->sendJSONresponse( $req,
        { newtoken => $token, newimage => $image } );
}

sub pwdReset {
    my ( $self, $req ) = @_;

    $self->logger->debug("Entering REST pwdReset method");

    unless ( $self->p->_passwordDB ) {
        $self->logger->error(
                "No Password module configured on this server, "
              . "cannot execute password change" );
        return $self->p->sendJSONresponse( $req, { 'result' => JSON::false } );
    }

    my $jsonBody = eval { from_json( $req->content ) };
    if ($@) {
        $self->logger->error("Received invalid JSON $@");
        return $self->p->sendError( $req, "Invalid JSON", 400 );
    }

    my $user     = $jsonBody->{user};
    my $mail     = $jsonBody->{mail};
    my $password = $jsonBody->{password};

    unless ( $user or $mail ) {
        $self->logger->error("Missing user or mail argument");
        return $self->p->sendError( $req, "Missing user or mail argument",
            400 );
    }

    unless ($password) {
        $self->logger->error("Missing password argument");
        return $self->p->sendError( $req, "Missing password argument", 400 );
    }

    my $tmp = $self->conf->{portalRequireOldPassword};
    $self->conf->{portalRequireOldPassword} = 0;
    $req->user( $user || $mail );
    $req->steps( ['getUser'] );
    my $result = $self->p->process( $req, ( $mail ? ( useMail => 1 ) : () ) );
    if ($result) {
        $self->logger->error( "Error while looking up user: $result ("
              . portalConsts->{$result}
              . ")" );
        return $self->p->sendError( $req, "User not found", 400 );
    }
    $result =
      $self->p->_passwordDB->setNewPassword( $req, $password, $mail ? 1 : 0 );
    $req->{user} = undef;
    $self->conf->{portalRequireOldPassword} = $tmp;

    if ( $result == PE_PASSWORD_OK or $result == PE_OK ) {
        return $self->p->sendJSONresponse( $req, { 'result' => JSON::true } );
    }
    else {
        $self->logger->error( "Error while changing user password: $result ("
              . portalConsts->{$result}
              . ")" );
        return $self->p->sendJSONresponse( $req, { 'result' => JSON::false } );
    }

}

sub pwdConfirm {
    my ( $self, $req ) = @_;

    $self->logger->debug("Entering REST pwdConfirm method");

    my $jsonBody = eval { from_json( $req->content ) };
    if ($@) {
        $self->logger->error("Received invalid JSON $@");
        return $self->p->sendError( $req, "Invalid JSON", 400 );
    }

    my $user     = $jsonBody->{user};
    my $password = $jsonBody->{password};

    unless ( $user and $password ) {
        $self->logger->error("Missing required arguments");
        return $self->p->sendError( $req, "Missing arguments user or password",
            400 );
    }

    $req->user($user);
    $req->data->{password}  = $password;
    $req->data->{_pwdCheck} = 1;

    if ( $self->p->_userDB ) {
        $req->steps( [ 'getUser', 'authenticate' ] );
        my $result = $self->p->process($req);
        if ( $result == PE_PASSWORD_OK or $result == PE_OK ) {
            return $self->p->sendJSONresponse( $req,
                { 'result' => JSON::true } );
        }
        else {
            $self->logger->error(
                "Process returned $result (" . portalConsts->{$result} . ")" );
            return $self->p->sendJSONresponse( $req,
                { 'result' => JSON::false } );
        }
    }
}

sub getUser {
    my ( $self, $req ) = @_;

    $self->logger->debug("Entering REST getUser method");

    my $jsonBody = eval { from_json( $req->content ) };
    if ($@) {
        $self->logger->error("Received invalid JSON $@");
        return $self->p->sendError( $req, "Invalid JSON", 400 );
    }

    my $user = $jsonBody->{user};
    my $mail = $jsonBody->{mail};

    unless ( $user or $mail ) {
        $self->logger->error("Missing user or mail argument");
        return $self->p->sendError( $req, "Missing user or mail argument",
            400 );
    }

    $req->user( $user || $mail );
    $req->data->{_pwdCheck} = 1;

    # Search user in database
    $req->steps( [
            'getUser',   'setSessionInfo',
            'setMacros', 'setGroups',
            'setLocalGroups'
        ]
    );
    my $error = $self->p->process( $req, ( $mail ? ( useMail => 1 ) : () ) );
    if ( $error == PE_OK ) {
        return $self->p->sendJSONresponse(
            $req,
            {
                'result' => JSON::true,
                'info'   => $req->sessionInfo,
            }
        );
    }
    else {
        return $self->p->sendJSONresponse( $req, { 'result' => JSON::false } );
    }
}

sub _checkSecret {
    my ( $self, $secret ) = @_;
    my $isValid = 0;

    if ($secret) {
        my $t;
        if ( $t = $self->conf->{cipher}->decrypt($secret) ) {
            if (    $t <= time + $self->conf->{restClockTolerance}
                and $t > time - $self->conf->{restClockTolerance} )
            {
                $isValid = 1;
            }
            else {
                $self->logger->error( 'Clock drift between servers is'
                      . ' beyond tolerance, force denied.' );
            }
        }
        else {
            $self->logger->error('Bad key, force denied');
        }
    }

    return $isValid;
}

1;
