Group
Extension

Lemonldap-NG-Manager/lib/Lemonldap/NG/Manager/Api/2F.pm

package Lemonldap::NG::Manager::Api::2F;

our $VERSION = '2.21.0';

package Lemonldap::NG::Manager::Api;

use strict;
use utf8;
use Mouse;
use JSON;

use Lemonldap::NG::Common::Session;
use Lemonldap::NG::Common::Util qw/genId2F display2F filterKey2F/;

sub getSecondFactors {
    my ( $self, $req ) = @_;
    my ( $uid, $res );

    $uid = $req->params('_uid')
      or return $self->searchSecondFactors($req);

    $self->logger->debug("[API] 2F for $uid requested");

    $res = $self->_get2F($uid);

    return $self->sendError( $req, $res->{msg}, $res->{code} )
      unless ( $res->{res} eq 'ok' );

    return $self->sendJSONresponse( $req, $res->{secondFactors} );
}

sub getSecondFactorsByType {
    my ( $self, $req ) = @_;
    my ( $uid, $type, $res );

    $uid = $req->params('_uid')
      or return $self->sendError( $req, 'Uid is missing', 400 );

    $type = $req->params('type')
      or return $self->sendError( $req, 'Type is missing', 400 );

    $self->logger->debug("[API] 2F for $uid with type $type requested");

    $res = $self->_get2F( $uid, uc $type );

    return $self->sendError( $req, $res->{msg}, $res->{code} )
      unless ( $res->{res} eq 'ok' );

    return $self->sendJSONresponse( $req, $res->{secondFactors} );
}

sub getSecondFactorsById {
    my ( $self, $req ) = @_;
    my ( $uid, $id, $res );

    $uid = $req->params('_uid')
      or return $self->sendError( $req, 'uid is missing', 400 );

    $id = $req->params('id')
      or return $self->sendError( $req, 'id is missing', 400 );

    $self->logger->debug("[API] 2F for $uid with id $id requested");

    $res = $self->_get2F( $uid, undef, $id );

    return $self->sendError( $req, $res->{msg}, $res->{code} )
      unless ( $res->{res} eq 'ok' );

    return $self->sendError( $req, "2F id '$id' not found for user '$uid'",
        404 )
      unless ( scalar @{ $res->{secondFactors} } > 0 );

    return $self->sendJSONresponse( $req, @{ $res->{secondFactors} }[0] );
}

sub deleteSecondFactors {
    my ( $self, $req, @path ) = @_;
    my ( $uid, $res );

    # Return an error on
    @path
      and return $self->sendError( $req, 'Invalid URI, provide /id/ or /type/',
        400 );

    $uid = $req->params('uid')
      or return $self->sendError( $req, 'uid is missing', 400 );

    $self->logger->debug("[API] Delete all 2F for $uid requested");

    $res = $self->_delete2F( $req, $uid );

    return $self->sendError( $req, $res->{msg}, $res->{code} )
      unless ( $res->{res} eq 'ok' );

    return $self->sendJSONresponse( $req, { message => $res->{msg} } );
}

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

    my $uid   = $req->param('uid') || '*';
    my @types = $req->parameters->get_all('type');
    use Hash::MultiValue;
    my $search_params =
      Hash::MultiValue->from_mixed( { _session_uid => $uid, type => \@types } );

    my $res = $self->search2F( $req, $self->_getPersistentMod, "Persistent",
        $search_params, 1 );

    if ( $res->{result} ) {
        my $api_result = [];

        my @sorted_results =
          sort { $a->{userId} cmp $b->{userId} } @{ $res->{values} || [] };
        for my $result (@sorted_results) {
            push @$api_result, {
                uid           => $result->{userId},
                secondFactors => [
                    map {
                        {
                            id   => genId2F($_),
                            type => $_->{type},
                            name => $_->{name}
                        }
                    } @{ $result->{_2fDevices} || [] }
                ]

            };
        }
        return $self->sendJSONresponse( $req, $api_result );
    }
    else {
        return $self->sendError( $req, $res->{msg}, $res->{code} );
    }
}

sub deleteSecondFactorsById {
    my ( $self, $req ) = @_;
    my ( $uid, $id, $res );

    $uid = $req->params('uid')
      or return $self->sendError( $req, 'uid is missing', 400 );

    $id = $req->params('id')
      or return $self->sendError( $req, 'id is missing', 400 );

    $self->logger->debug("[API] Delete 2F for $uid with id $id requested");

    $res = $self->_delete2F( $req, $uid, undef, $id );

    return $self->sendError( $req, $res->{msg}, $res->{code} )
      unless ( $res->{res} eq 'ok' );

    return $self->sendError( $req, "2F id '$id' not found for user '$uid'",
        404 )
      unless ( $res->{removed} > 0 );

    return $self->sendJSONresponse( $req, { message => $res->{msg} } );
}

sub deleteSecondFactorsByType {
    my ( $self, $req ) = @_;
    my ( $uid, $type, $res );

    $uid = $req->params('uid')
      or return $self->sendError( $req, 'uid is missing', 400 );

    $type = $req->params('type')
      or return $self->sendError( $req, 'type is missing', 400 );

    $self->logger->debug(
        "[API] Delete all 2F for $uid with type $type requested");

    $res = $self->_delete2F( $req, $uid, uc $type );

    return $self->sendError( $req, $res->{msg}, $res->{code} )
      unless ( $res->{res} eq 'ok' );

    return $self->sendJSONresponse( $req, { message => $res->{msg} } );
}

sub _get2F {
    my ( $self, $uid, $type, $id ) = @_;
    my ( $res, $psessions, @secondFactors );

    $psessions = $self->_getSessions2F( $self->_getPersistentMod, 'Persistent',
        '_session_uid', $uid );

    foreach ( keys %$psessions ) {
        my $devices = $self->_getDevicesFromSessionData( $psessions->{$_} );
        foreach my $device ( @{$devices} ) {
            $self->logger->debug(
"Check device [epoch=$device->{epoch}, type=$device->{type}, name=$device->{name}]"
            );
            if (    ( !defined $type or uc($type) eq uc( $device->{type} ) )
                and ( !defined $id or $id eq genId2F($device) ) )
            {
                push @secondFactors,
                  { %{ filterKey2F($device) }, id => genId2F($device), };
            }
        }
    }
    $self->logger->debug(
        "Found " . scalar @secondFactors . " 2F devices for uid $uid." );
    return { res => 'ok', secondFactors => [@secondFactors] };
}

sub _getSessions2F {
    my ( $self, $mod, $kind, $key, $uid ) = @_;
    $self->logger->debug("Looking for sessions for uid $uid ...");
    my $sessions =
      Lemonldap::NG::Common::Apache::Session->searchOn( $mod->{options}, $key,
        $uid,
        ( '_session_kind', '_session_uid', '_session_id', '_2fDevices' ) );
    foreach ( keys %$sessions ) {
        delete $sessions->{$_}
          unless ( $sessions->{$_}->{_session_kind} eq $kind );
    }
    $self->logger->debug( "Found "
          . scalar( keys %$sessions )
          . " $kind sessions for uid $uid." );

    return $sessions;
}

sub _getSession2F {
    my ( $self, $sessionId, $mod ) = @_;
    $self->logger->debug("Looking for session with sessionId $sessionId ...");
    my $session = $self->getApacheSession( $mod, $sessionId );
    $self->logger->debug(
        defined $session
        ? "Session $sessionId found."
        : " No session found for sessionId $sessionId"
    );

    return $session;
}

sub _delete2FFromSessions {
    my ( $self, $uid, $type, $id, $mod, $kind, $key ) = @_;
    my ( $sessions, $session, $devices, @keep, $removed,
        $total, $module, $localStorage );
    $sessions = $self->_getSessions2F( $mod, $kind, $key, $uid );
    foreach ( keys %$sessions ) {

        $session = $self->_getSession2F( $_, $mod )
          or return { res => 'ko', code => 500, msg => $@ };

        $self->logger->debug(
            "Looking for 2F Device(s) attached to sessionId $_");

        if ( $session->data->{_2fDevices} ) {
            $devices = $self->_getDevicesFromSessionData( $session->data );
            $total   = scalar @$devices;

            $self->logger->debug(
                "Found $total 2F devices attached to sessionId $_");

            @keep = ();
            while (@$devices) {
                my $element = shift @$devices;
                if (
                    ( defined $type or defined $id )
                    and ( (
                            defined $type
                            and uc($type) ne uc( $element->{type} )
                        )
                        or ( defined $id and $id ne genId2F($element) )
                    )
                  )
                {
                    push @keep, $element;
                }
                else {
                    $removed->{ genId2F($element) } = $element;
                }
            }
            if ( ( $total - scalar @keep ) > 0 ) {

                # Update session
                $self->logger->debug( "Removing "
                      . ( $total - scalar @keep )
                      . " 2F device(s) attached to sessionId $_ ..." );
                $session->data->{_2fDevices} = to_json( \@keep );
                $session->update( $session->data );

                # Delete from local cache
                if ( $session->{options}->{localStorage} ) {
                    $module = $session->{options}->{localStorage};
                    eval "use $module;";
                    $localStorage =
                      $module->new(
                        $session->{options}->{localStorageOptions} );
                    if ( $localStorage->get($_) ) {
                        $self->logger->debug(
                            "Delete local cache for session $_");
                        $localStorage->remove($_);
                    }
                }
            }
            else {
                $self->logger->debug(
"No matching 2F devices attached to sessionId $_ were selected for removal."
                );
            }
        }
        else {
            $self->logger->debug(
                "No 2F devices attached to sessionId $_ were found.");
        }
    }

    return { res => 'ok', removed => $removed };
}

sub _delete2F {
    my ( $self, $req, $uid, $type, $id ) = @_;
    my ( $res, $removed, $count );

    $res =
      $self->_delete2FFromSessions( $uid, $type, $id, $self->_getPersistentMod,
        'Persistent', '_session_uid' );
    return $res if ( $res->{res} ne 'ok' );
    $removed = $res->{removed} || {};

    my $whatToTrace = Lemonldap::NG::Handler::PSGI::Main->tsv->{whatToTrace};
    $res =
      $self->_delete2FFromSessions( $uid, $type, $id, $self->_getSSOMod, 'SSO',
        $whatToTrace );
    return $res if ( $res->{res} ne 'ok' );
    $res->{removed} ||= {};

    # merge results
    $removed = { %$removed, %{ $res->{removed} } };
    $count   = scalar( keys %$removed );
    if ($count) {
        my @list_log = map { display2F( $removed->{$_} ) } keys %$removed;
        my $list_log = join( ",", @list_log );
        $self->auditLog(
            $req,
            code        => "API_2FA_DELETED",
            message     => ("[API] 2FA deletion for $uid: $list_log"),
            user        => $uid,
            removed_2fa => \@list_log,
        );
    }

    return {
        res     => 'ok',
        removed => $count,
        msg     => $count > 0
        ? "Successful operation: " . $count . " 2F were removed"
        : "No operation performed"
    };
}

sub _getDevicesFromSessionData {
    my ( $self, $sessiondata ) = @_;
    if ( $sessiondata->{_2fDevices} ) {
        my $devices;
        eval { $devices = from_json( $sessiondata->{_2fDevices} ); };
        if ($@) {
            $self->logger->warn("Error deserializing _2fDevices: $@");
        }
        else {
            if ( ref($devices) eq "ARRAY" ) {
                return $devices;
            }
            else {
                $self->logger->warn(
                    "Error deserializing _2fDevices: not a JSON Array");
            }
        }
    }
    return [];
}

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

    my $uid = $req->params('uid')
      or return $self->sendError( $req, 'uid is missing', 400 );

    $self->logger->debug("[API] Add 2F for $uid");

    my $add = $req->jsonBodyToObj;
    return $self->sendError( $req, "Invalid input: " . $req->error, 400 )
      unless ( $add and ref($add) eq "HASH" );

    return $self->sendError( $req, 'Invalid input: type is missing', 400 )
      unless ( defined $add->{type} );

    return $self->sendError( $req, 'Invalid input: epoch is forbidden', 400 )
      if ( defined $add->{epoch} );

    my $allow_create =
      (      $req->params('create')
          && $req->params('create') ne "false"
          && $req->params('create') ne "0" );

    my $res = $self->_add2F( $uid, undef, $add, $allow_create );

    return $self->sendError( $req, $res->{msg}, $res->{code} )
      if ( $res->{res} ne 'ok' );
    return $self->sendJSONresponse(
        $req,
        { message => "Successful operation" },
        code => 201
    );
}

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

    my $uid = $req->params('uid')
      or return $self->sendError( $req, 'uid is missing', 400 );

    my $type = $req->params('type')
      or return $self->sendError( $req, 'type is missing', 400 );

    my $add = $req->jsonBodyToObj;
    return $self->sendError( $req, "Invalid input: " . $req->error, 400 )
      unless ( $add and ref($add) eq "HASH" );

    $self->logger->debug("[API] Add $type 2F for $uid");

    my $allow_create =
      (      $req->params('create')
          && $req->params('create') ne "false"
          && $req->params('create') ne "0" );

    my $res = $self->_add2F( $uid, $type, $add, $allow_create );

    return $self->sendError( $req, $res->{msg}, $res->{code} )
      unless ( $res->{res} eq 'ok' );

    return $self->sendJSONresponse(
        $req,
        { message => "Successful operation" },
        code => 201
    );
}

sub _transformNew2f {
    my ( $self, $type, $add ) = @_;

    # Generic API, no transformation is done
    if ( !defined($type) ) {
        return {
            res    => "ok",
            device => $add,
        };
    }

    if ( uc($type) eq "TOTP" ) {
        return $self->_transformNew2fTotp( $type, $add );
    }

    else {
        return {
            res  => "ko",
            code => 400,
            msg  => "Invalid type: $type",
        };
    }
}

sub _transformNew2fTotp {
    my ( $self, $type, $add ) = @_;

    if ( !$add->{key} ) {
        return {
            res  => "ko",
            code => 400,
            msg  => "Invalid input: missing \"key\" parameter",
        };
    }

    my $secret = $self->_totpKeyToSecret( $add->{key} );
    if ( !$secret ) {
        return {
            res  => "ko",
            code => 400,
            msg  => "Invalid secret: you must provide a base32-encoded key",
        };
    }

    my $newdevice = {
        type => "TOTP",
        ( $add->{name} ? ( name => $add->{name} ) : () ),
        _secret => $secret,
    };

    return {
        res    => "ok",
        device => $newdevice,
    };
}

sub _totpKeyToSecret {
    my ( $self, $key ) = @_;

    return unless $key;

    # Make sure only BASE32 characters are present
    if ( uc($key) =~ /^[ABCDEFGHIJKLMNOPQRSTUVWXYZ234567\s]*$/ ) {

        # Trim spaces and normalize to lowercase
        my $newKey = $key =~ s/\s//gr;
        return $self->totp_encrypt->get_storable_secret( lc($newKey) );
    }

    return undef;
}

sub _get_epoch {
    my ($self) = @_;
    return time();
}

sub _add2F {
    my ( $self, $uid, $type, $add, $allow_create ) = @_;

    my $res = $self->_transformNew2f( $type, $add );
    return $res if ( $res->{res} ne 'ok' );

    my $device = $res->{device};

    $device->{epoch} = $self->_get_epoch();

    $res = $self->_check_duplicates( $uid, $device );
    return $res if ( $res->{res} ne 'ok' );

    $res =
      $self->_add2FToSessions( $uid, $device, $self->_getPersistentMod,
        'Persistent', '_session_uid', $allow_create );
    return $res if ( $res->{res} ne 'ok' );

    # Add to existing sessions
    my $whatToTrace = Lemonldap::NG::Handler::PSGI::Main->tsv->{whatToTrace};
    $res =
      $self->_add2FToSessions( $uid, $device, $self->_getSSOMod, 'SSO',
        $whatToTrace );

    return { res => "ok" };
}

sub _check_duplicates {
    my ( $self, $uid, $device ) = @_;
    my $type   = $device->{type};
    my $new_id = genId2F($device);

    my $res = $self->_get2F( $uid, uc $type );
    if ( $res->{res} eq 'ok' ) {
        my @devices = @{ $res->{secondFactors} // [] };
        if ( grep { $_->{id} and $_->{id} eq $new_id } @devices ) {
            return {
                res  => 'ko',
                code => 409,
                msg  => "ID $new_id is already used by an existing device"
            };
        }
        else {
            return { res => 'ok' };
        }
    }
    else {
        return { res => 'ok' };
    }
}

sub _add2FToSessions {
    my ( $self, $uid, $add, $mod, $kind, $key, $allow_create ) = @_;

    my ( $sessions, $session, $devices, @keep, $removed,
        $total, $module, $localStorage );
    $sessions = $self->_getSessions2F( $mod, $kind, $key, $uid );
    my $found = keys %$sessions;

    if ( !$found and $allow_create ) {
        my $ps = $self->getPersistentSession( $mod, $uid );
        $sessions = { $ps->id => { _2fDevices => undef } };
        $found    = 1;
    }

    foreach ( keys %$sessions ) {
        $session = $self->_getSession2F( $_, $mod )
          or return { res => 'ko', code => 500, msg => $@ };

        $devices = $self->_getDevicesFromSessionData( $session->data );
        push @$devices, $add;

        # Update session
        $self->logger->debug(
            "Adding " . display2F($add) . " to sessionId $_" );
        $session->data->{_2fDevices} = to_json($devices);
        $session->update( $session->data );
    }
    return {
        res     => "notfound",
        message => "User $uid was not found",
        code    => 404
      }
      if !$found;

    return { res => 'ok' };
}

1;


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.