Group
Extension

WWW-Suffit-API/lib/WWW/Suffit/Server/API/Auth.pm

package WWW::Suffit::Server::API::Auth;
use strict;
use utf8;

=encoding utf8

=head1 NAME

WWW::Suffit::Server::API::Auth - The authentication and authorization Suffit API controller

=head1 SYNOPSIS

    use WWW::Suffit::Server::API::Auth;

=head1 DESCRIPTION

The authentication and authorization Suffit API controller

=head1 METHODS

List of authorization/authentication methods

=head2 authorize

    $c->routes->post('/authorize')->to('API::Auth#authorize'
      => {token_type => 'session'});
    $c->routes->post('/authorize')->to('API::Auth#authorize'
      => {token_type => 'access'});
    $c->routes->post('/authorize')->to('API::Auth#authorize'
      => {token_type => 'api'});

The authorization controller  by stashed parameters

Options:

=over 8

=item skip_authdb_connect

    skip_authdb_connect => 1

This option disables connection to authorization database

=item token_type

    token_type => 'access'

This option is required and sets the token type: C<access>, C<session>, C<refresh> or C<api>

=back

See L</"POST /authorize">

=head2 is_authorized

    my $authorized = $c->routes->under('/api')->to('API::Auth#is_authorized')
         ->name('api');

The API Authorization checker. If use `init_api_routes` startup option then
this route will be exists by default. To get access to this route use:

    my $authorized = $r->lookup('api');

=head1 API METHODS

List of API methods

=head2 POST /api/authorize

This method performs authentication and authorization on the Suffit API server,
then returns the access token

    # curl -v -X POST \
      -H "Accept: application/json" \
      -d '{
        "username": "test",
        "password": "test",
        "encrypted": false,
        "remember": false,
        "cachekey": ""
      }' \
      https://localhost:8695/api/authorize

    # curl -v -X POST \
      -H "Accept: application/json" \
      -F username=test -F password=test \
      https://localhost:8695/api/authorize

    > POST /authorize HTTP/1.1
    > Host: localhost:8695
    > User-Agent: curl/7.68.0
    > Accept: application/json
    > Content-Length: 248
    > Content-Type: multipart/form-data; boundary=-----6a21ca7cea8dc981
    >
    < HTTP/1.1 200 OK
    < Date: Tue, 13 Aug 2024 14:42:56 GMT
    < Content-Type: application/json;charset=UTF-8
    < Content-Length: 635
    < Server: WWW::Suffit/1.00
    <
    {
      "cachekey": "97vyZgPzskPG",
      "clientid": "f459f12619c961122450ae5883e44a60",
      "code": "E0000",
      "datetime": "2024-08-13T14:42:56Z",
      "elapsed": 0.230106,
      "encrypted": false,
      "expires": "2024-08-14T14:42:56Z",
      "jti": "oWwGYKT2MdKj-xVvF9s9",
      "message": "The user is successfully authorized",
      "referer": "",
      "status": true,
      "token": "ey...8o",
      "type": "access",
      "user": {
        "algorithm": "SHA256",
        "attributes": "",
        "comment": "Test user for internal testing only",
        "created": 1678741533,
        "email": "test@owl.localhost",
        "email_md5": "163e50783979333ebae6fd63b2d96d16",
        "expires": 1723560176,
        "flags": 31,
        "groups": [
          "user"
        ],
        "name": "Test User",
        "not_after": 0,
        "not_before": 1695334721,
        "public_key": "-----BEGIN RSA PUBLIC KEY-----...",
        "role": "Test user",
        "uid": 3,
        "username": "test"
      }
    }

=head1 ERROR CODES

List of authentication and authorization Suffit API error codes

    API   | HTTP  | DESCRIPTION
   -------+-------+-------------------------------------------------
    E1000   [403]   Access denied. No token/session exists
    E1001   [403]   Access denied. JWT error
    E1002   [403]   Access denied. The token has been revoked
    E1003   [ * ]   Access denied. Session is not authorized
    E1004   [ * ]   Access denied by realm restrictions
    E1005   [500]   The authorization database is not ready
    E1006   [500]   Can't connect to authorization database
    E1007   [---]   Reserved
    E1008   [---]   Reserved
    E1009   [---]   Reserved
    E1010   [---]   Reserved
    E1011   [---]   Reserved
    E1012   [---]   Reserved
    E1013   [---]   Reserved
    E1014   [---]   Reserved
    E1015   [---]   Reserved
    E1016   [---]   Reserved
    E1017   [---]   Reserved
    E1018   [---]   Reserved
    E1019   [---]   Reserved
    E1020   [400]   Incorrect token type
    E1021   [401]   No username specified
    E1022   [401]   No password specified
    E1023   [500]   RSA decode error
    E1024   [500]   Can't JWT generate
    E1025   [500]   Can't token store to database
    E1026   [---]   Reserved
    E1027   [---]   Reserved
    E1028   [---]   Reserved
    E1029   [---]   Reserved

B<*> -- this code will be defined later on the interface side

See also list of common Suffit API error codes in L<WWW::Suffit::API/"ERROR CODES">

=head1 HISTORY

See C<Changes> file

=head1 TO DO

See C<TODO> file

=head1 SEE ALSO

L<WWW::Suffit::Server>

=head1 AUTHOR

Serż Minus (Sergey Lepenkov) L<https://www.serzik.com> E<lt>abalama@cpan.orgE<gt>

=head1 COPYRIGHT

Copyright (C) 1998-2025 D&D Corporation. All Rights Reserved

=head1 LICENSE

This program is free software; you can redistribute it and/or
modify it under the same terms as Perl itself.

See C<LICENSE> file and L<https://dev.perl.org/licenses/>

=cut

use Mojo::Base 'Mojolicious::Controller';

use Mojo::JSON qw / true false /;
use Mojo::Util qw/ md5_sum /;
use Mojo::Date;
use Mojo::URL;
use Mojo::JSON qw / true false /;

use Acrux::Util qw/ parse_time_offset randchars /;
use Acrux::RefUtil qw/ is_true_flag /;

use WWW::Suffit::Const qw/ TOKEN_EXPIRATION TOKEN_EXPIRE_MAX /;
use WWW::Suffit::RSA;

sub is_authorized {
    my $self = shift;
    my ($username, $expiration, $cachekey);

    # Get authdb instance
    my $authdb = $self->authdb;

    # Get authorization data from token (if specified)
    if (my $token = $self->token) {
        my $jwt = $self->jwt;

        # Get payload from JWT
        my $payload = $jwt->decode($token)->payload;
        if ($jwt->error) {
            $self->reply->json_error(403 => "E1001" => sprintf("Access denied. %s", $jwt->error));
            return;
        }
        $username = $payload->{'usr'};
        $expiration = $payload->{'exp'} || (time + TOKEN_EXPIRATION);
        $cachekey = $payload->{'key'};

        # Check the token by database
        if ($payload->{'jti'}) {
            unless ($authdb->token_check($username, $payload->{'jti'})) {
                if ($authdb->error) {
                    $self->reply->json_error($authdb->code, $authdb->error);
                    return;
                }
                $self->log->info(sprintf("Revoked token: \"%s\"", $payload->{'jti'}));
                $self->reply->json_error(403 => "E1002" => "Access denied. The token has been revoked");
                return;
            }
        }
    } else { # No token specified
        $self->reply->json_error(403 => "E1000" => "Access denied. No token exists");
        return;
    }

    # Set expiration and cachekey to stash if exists
    $self->stash(expiration => $expiration) if $expiration;
    $self->stash(cachekey => $cachekey) if $cachekey;

    # Authorization (username is optional)
    if ($username) {
        my $user = $authdb->authz(
            u => $username,
            's' => 0,
            k => $cachekey,
        );
        unless ($user) {
            $self->log->info(sprintf("Session is not authorized for \"%s\"", $username));
            $self->reply->json_error($authdb->code, $authdb->error || "E1003: Access denied. Session is not authorized");
            return;
        }

        # Stash user data
        $self->stash($user->to_hash);

        # Stah is_authorized flag
        $self->stash(is_authorized => $user->is_authorized ? 1 : 0) ;
    }

    # Access (username is optional; b,m,url,p,i,r will be got from controller)
    my $has_access = $authdb->access(
        c => $self,
        u => $username,
        k => $cachekey,
    );
    unless ($has_access) {
        $self->reply->json_error($authdb->code, $authdb->error || "E1004: Access denied by realm restrictions");
        return;
    }

    # Ok
    return 1;
}
sub authorize {
    my $self = shift;
       $self->timing->begin('suffit_authorize');
    my $token_type = $self->stash('token_type') || '';
    my $skip_authdb_connect = $self->stash('skip_authdb_connect') ? 1 : 0;
    my $username = $self->param('username') // $self->req->json('/username') // '';
    my $password = $self->param('password') // $self->req->json('/password') // '';
    my $encrypted = is_true_flag($self->param('encrypted') // $self->req->json('/encrypted')) || 0;
    my $remember = is_true_flag($self->param('remember') // $self->req->json('/remember')) || 0;
    my $cachekey = $self->param('cachekey') // $self->req->json('/cachekey') // $self->app->gen_cachekey;

    # md5(User-Agent . Remote-Address)
    my $ip = $self->client_ip($self->app->trustedproxies);
    my $clientid = $self->param('clientid') // $self->req->json('/clientid')
        || md5_sum(sprintf("%s%s", $self->req->headers->header('User-Agent') // 'unknown', $ip));

    # Get Referer from flash or header
    my $href = $self->req->headers->header("Referer") // '';
    my $referer = $self->flash("referer") // ($href ? Mojo::URL->new($href)->path->to_string // '' : '');
       $referer =~ s/\/authorize//;
    $self->stash(referer => $referer);

    # Get authdb instance
    my $authdb = $self->authdb;
    return $self->reply->json_error(500 => "E1005" => "The authorization database is not ready") unless $authdb;

    # Connect to AuthDB (optional)
    if (!$skip_authdb_connect) {
        $authdb->connect;
        if ($authdb->error) {
            return $self->reply->json_error(500 => $authdb->error);
        } elsif (!$authdb->initialized) {
            return $self->reply->json_error(500 => "E1307" => "The authorization database is not initialized");
        }
    }

    # Token type
    return $self->reply->json_error(400 => "E1020" => "Incorrect token type. Allowed: session, access, refresh, api")
        unless grep {$token_type eq $_} (qw/session access refresh api/);

    # Please provide username and password for authorization
    return $self->reply->json_error(401 => "E1021" => "No username specified") unless length($username);
    return $self->reply->json_error(401 => "E1022" => "No password specified") unless length($password);

    # Password decrypt
    if ($encrypted && length($password)) {
        my $rsa = WWW::Suffit::RSA->new(private_key => $self->app->private_key);
        $password = $rsa->decrypt($password);
        return $self->reply->json_error(500 => "E1023" => $rsa->error) if $rsa->error; # RSA decrypt error
    }

    # Authentication
    $authdb->authn(
        u => $username,
        p => $password,
        a => $ip, # For check by stats
        k => $cachekey,
    ) or return $self->reply->json_error($authdb->code, $authdb->error); # Unauthenticated (incorrect username or password)

    # Authorization
    my $user = $authdb->authz(
        u => $username,
        's' => 0,
        k => $cachekey,
    );
    return $self->reply->json_error($authdb->code, $authdb->error) unless $user; # Unauthorized (Access denied)

    # Generate token: access, session, api, etc.
    my $jwt = $self->jwt;
    my $jws_algorithm = $authdb->meta("jws_algorithm") || $self->conf->latest("/jws_algorithm") || '';
       $jwt->algorithm($jws_algorithm) if length $jws_algorithm;

    # Expires
    my $now = time(); # NOW
    my $tokenexpires = $authdb->meta("tokenexpires") || parse_time_offset($self->conf->latest("/tokenexpires")) || TOKEN_EXPIRATION;
    my $exp = $now + ($remember ? TOKEN_EXPIRE_MAX : $tokenexpires);
    if ($token_type eq 'api') {
        $exp = $user->forever ? undef : ($now + TOKEN_EXPIRATION * 365);
    }
    my $jti = sprintf("%s%s", randchars(8), $self->req->request_id);
       $jwt->expires($exp);
       $jwt->iat($now)->jti($jti)->payload({
            ver         => $self->app->VERSION,
            typ         => $token_type,
            usr         => $username,
            ip          => $ip,
            cid         => $clientid,
            $cachekey ? (key => $cachekey) : (),
        });

    # Issue (generate)
    my $token = $jwt->encode->token;
    return $self->reply->json_error(500 => "E1024" => $jwt->error || "Can't JWT generate") unless $token; # Can't JWT generation

    # Store token
    $authdb->token_set(
        type        => $token_type,
        jti         => $jti,
        username    => $username,
        clientid    => $clientid,
        iat         => $now,
        exp         => $exp || 0,
        address     => $ip,
    ) or return $self->reply->json_error($authdb->code, $authdb->error || "E1025: Can't token store to database"); # Can't store token data

    # Ok
    return $self->reply->json_ok({
        message     => "The user is successfully authorized",
        encrypted   => $encrypted ? true : false,
        clientid    => $clientid,
        datetime    => Mojo::Date->new($now)->to_datetime, # RFC 3339
        expires     => Mojo::Date->new($exp || 0)->to_datetime, # RFC 3339
        elapsed     => $self->timing->elapsed('suffit_authorize') // 0,
        referer     => $referer,
        jti         => $jti,
        type        => $token_type,
        token       => $token,
        cachekey    => $cachekey,
        user        => { ($user->to_hash(1)) },
    });
}

1;

__END__


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