Group
Extension

WWW-Suffit-Plugin-SuffitAuth/lib/WWW/Suffit/Plugin/SuffitAuth.pm

package WWW::Suffit::Plugin::SuffitAuth;
use strict;
use warnings;
use utf8;

=encoding utf8

=head1 NAME

WWW::Suffit::Plugin::SuffitAuth - The Suffit plugin for Suffit API authentication and authorization providing

=head1 SYNOPSIS

    sub startup {
        my $self = shift->SUPER::startup();
        $self->plugin('SuffitAuth', {
            configsection    => 'SuffitAuth',
            expiration       => 3600, # 1h
            cache_expiration => 300, # 5m
            public_key_file  => 'suffitauth_pub.key',
            userfile_format  => 'user-%s.json',
        });

        # . . .
    }

... configuration:

    # SuffitAuth Client configuration
    <SuffitAuth>
        ServerURL       https://api.example.com/api
        Insecure        on
        AuthScheme      Bearer
        Token           "eyJhb...1Okw"
        ConnectTimeout  60
        RequestTimeout  60
    </SuffitAuth>

=head1 DESCRIPTION

The Suffit plugin for Suffit API authentication and authorization providing

=head1 OPTIONS

This plugin supports the following options

=head2 cache_expiration

    cache_expiration => 300
    cache_expiration => '5m'

This option sets default cache expiration time for keep user data in cache

Default: 300 (5 min)

=head2 configsection

    configsection => 'suffitauth'
    configsection => 'SuffitAuth'

This option sets a section name of the config file for define
namespace of configuration directives for this plugin

Default: 'suffitauth'

=head2 expiration

    expiration => 3600
    expiration => '1h'
    expiration => SESSION_EXPIRATION

This option performs set a default expiration time of session

Default: 3600 secs (1 hour)

See L<WWW::Suffit::Const/SESSION_EXPIRATION>

=head2 public_key_file

    public_key_file => 'auth_public.key'

This option sets default public key file location (relative to datadir)

Default: 'auth_public.key'

=head2 userfile_format

    userfile_format => 'u.%s.json'

This option sets default format of userdata authorization filename

Default: 'u.%s.json'

=head1 HELPERS

This plugin provides the following helpers

=head2 suffitauth.authenticate

    my $auth = $self->suffitauth->authenticate({
        address     => $self->remote_ip($self->app->trustedproxies),
        method      => $self->req->method, # 'ANY'
        base_url    => $self->base_url,
        referer     => $self->req->headers->header("Referer"),
        username    => $username,
        password    => $password,
        loginpage   => 'login', # -- To login-page!!
        expiration  => $remember ? SESSION_EXPIRE_MAX : SESSION_EXPIRATION,
        realm       => "Test zone", # Reserved. Now is not supported
        options     => {}, # Reserved. Now is not supported
    });
    if (my $err = $auth->get('/error')) {
        if (my $location = $auth->get('/location')) { # Redirect
            $self->flash(message => $err);
            $self->redirect_to($location); # 'login' -- To login-page!!
        } elsif ($auth->get('/status') >= 500) { # Fatal server errors
            $self->reply->error($auth->get('/status'), $auth->get('/code'), $err);
        } else { # User errors (show on login page)
            $self->stash(error => $err);
            return $self->render;
        }
        return;
    }

This helper performs authentication backend subprocess and returns
result object (L<Mojo::JSON::Pointer>) that contains data structure:

    {
        error       => '',          # Error message
        status      => 200,         # HTTP status code
        code        => 'E0000',     # The Suffit error code
        username    => $username,   # User name
        referer     => $referer,    # Referer
        loginpage   => $loginpage,  # Login page for redirects (location)
        location    => undef,       # Location URL for redirects
    }

This method is typically called in a handler responsible for the authentication and authorization
process, such as `login`. The call is typically made before a session is established, for eg.:

    # Set session
    $self->session(
            username => $username,
            remember => $remember ? 1 : 0,
            logged   => time,
            $remember ? (expiration => SESSION_EXPIRE_MAX) : (),
        );
    $self->flash(message => 'Thanks for logging in.');

    # Go to protected page (/root)
    $self->redirect_to('root');

=head2 suffitauth.authorize

    my $auth = $self->suffitauth->authorize({
        referer     => $referer,
        username    => $username,
        loginpage   => 'login', # -- To login-page!!
        options     => {}, # Reserved. Now is not supported
    });
    if (my $err = $auth->get('/error')) {
        if (my $location = $auth->get('/location')) {
            $self->flash(message => $err);
            $self->redirect_to($location); # 'login' -- To login-page!!
        } else {
            $self->reply->error($auth->get('/status'), $auth->get('/code'), $err);
        }
        return;
    }

This helper performs authorization backend subprocess and returns
result object (L<Mojo::JSON::Pointer>) that contains data structure:

    {
        error       => '',          # Error message
        status      => 200,         # HTTP status code
        code        => 'E0000',     # The Suffit error code
        username    => $username,   # User name
        referer     => $referer,    # Referer
        loginpage   => $loginpage,  # Login page for redirects (location)
        location    => undef,       # Location URL for redirects
        user    => {                # User data
            address     => "127.0.0.1", # User (client) IP address
            base        => "http://localhost:8080", # Base URL of request
            comment     => "No comments", # Comment
            email       => 'test@example.com', # Email address
            email_md5   => "a84450...366", # MD5 hash of email address
            method      => "ANY", # Current method of request
            name        => "Bob Smith", # Full user name
            path        => "/", # Current query-path of request
            role        => "Regular user", # User role
            status      => true, # User status in JSON::PP::Boolean notation
            uid         => 1, # User ID
            username    => $username, # User name
        },
    }

The 'user' is structure that describes found user. For eg.:

    {
        "address": "127.0.0.1",
        "base": "http://localhost:8473",
        "code": "E0000",
        "email": "foo@example.com",
        "email_md5": "b48def645758b95537d4424c84d1a9ff",
        "expires": 1700490101,
        "groups": [
            "wheel"
        ],
        "method": "ANY",
        "name": "Anon Anonymous",
        "path": "/",
        "role": "System Administratot",
        "status": true,
        "uid": 1,
        "username": "admin"
    }

Typically, this method is called in the handler responsible for session accounting (logged_in).
The call is accompanied by staking of the authorization data into the corresponding
project templates, for example:

    # Stash user data
    $self->stash(
        username => $username,
        name     => $auth->get('/user/name') // 'Anonymous',
        email_md5=> $auth->get('/user/email_md5') // '',
        role     => $auth->get('/user/role') // 'User',
        user     => $auth->get('/user') || {},
    );


=head2 suffitauth.client

    my $client = $self->suffitauth->client;

Returns authorization client

See L<WWW::Suffit::Client::V1>

=head2 suffitauth.init

    my $init = $self->suffitauth->init;

This method returns the init object (L<Mojo::JSON::Pointer>)
that contains data of initialization:

    {
        error       => '...',       # Error message
        status      => 500,         # HTTP status code
        code        => 'E7000',     # The Suffit error code
    }

For example (in your controller):

    # Check init status
    my $init = $self->suffitauth->init;
    if (my $err = $init->get('/error')) {
        $self->reply->error($init->get('/status'),
            $init->get('/code'), $err);
        return;
    }

=head2 suffitauth.options

    my $options = $self->suffitauth->options;

Returns authorization plugin options as hashref

=head2 suffitauth.unauthorize

    my $auth = $self->suffitauth->unauthorize(username => $username);
    if (my $err = $auth->get('/error')) {
        $self->reply->error($authdata->get('/status'), $authdata->get('/code'), $err);
    }

This helper performs unauthorize process - remove userdata file from disk and returns
result object (L<Mojo::JSON::Pointer>) that contains data structure:

    {
        error       => '',          # Error message
        status      => 200,         # HTTP status code
        code        => 'E0000',     # The Suffit error code
        username    => $username,   # User name
    }

This method is typically called in the handler responsible for the logout process.
The call usually occurs before the redirect to the `login` page, for example:

    # Remove session
    $self->session(expires => 1);

    # Unauthorize
    if (my $username = $self->session('username')) {
        my $authdata = $self->suffitauth->unauthorize(username => $username);
        if (my $err = $authdata->get('/error')) {
            $self->reply->error($authdata->get('/status'), $authdata->get('/code'), $err);
        }
    }

    # To login-page!
    $self->redirect_to('login');

=head1 METHODS

Internal methods

=head2 register

This method register the plugin and helpers in L<Mojolicious> application

=head1 ERROR CODES

=over 8

=item B<E0xxx> -- General errors

E01xx, E02xx, E03xx, E04xx and E05xx are reserved as HTTP errors

    E0000   Ok
    E0100   Continue
    E0200   OK
    E0300   Multiple Choices
    E0400   Bad Request
    E0500   Internal Server Error

=item B<E1xxx> -- API errors

See L<WWW::Suffit::API>

=item B<E2xxx> -- Database errors

See L<WWW::Suffit::API>

=item B<E7xxx> -- SuffitAuth (application) errors

B<Auth: E70xx>

    E7000   [403]   Access denied
    E7001   [400]   Incorrect username
    E7002   [503]   Can't connect to authorization server
    E7003   [500]   Can't get public key from authorization server
    E7004   [500]   Can't save public key file %s
    E7005   [*]     Authentication error
    E7006   [*]     Authorization error
    E7007   [500]   Can't save file <USERNAME>.json
    E7008   [500]   File <USERNAME>.json not found or incorrect
    E7009   [400]   Incorrect username (while authorize)
    E7010   [400]   Incorrect username (while unauthorize)

B<*> -- this code defines on server side

=back

=head1 SEE ALSO

L<Mojolicious>, L<WWW::Suffit::Client::V1>, L<WWW::Suffit::Server>, bundled examples

=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::Plugin';

our $VERSION = '1.04';

use File::stat;
use Mojo::File qw/path/;
use Mojo::Util qw/encode md5_sum hmac_sha1_sum/;
use Mojo::JSON::Pointer;

use Acrux::Util qw/parse_time_offset/;

use WWW::Suffit::Client::V1;
use WWW::Suffit::Const qw/ :session /;
use WWW::Suffit::Util qw/json_load json_save/;

use constant {
    SUFFITAUTH_PREFIX   => 'suffitauth',
    PUBLIC_KEY_FILE     => 'auth_public.key',
    USERFILE_FORMAT     => 'u.%s.json',
    CACHE_EXPIRATION    => 300, # 5 min
};

has _options => sub { {} };

sub register {
    my ($plugin, $app, $opts) = @_; # $self = $plugin
    $opts //= {};
    my $configsection = lc($opts->{configsection} || SUFFITAUTH_PREFIX);
    my $expiration = parse_time_offset($opts->{expiration} // SESSION_EXPIRATION);
    my $cache_expiration = parse_time_offset($opts->{cache_expiration} // CACHE_EXPIRATION);
    my $public_key_file = $opts->{public_key_file} || PUBLIC_KEY_FILE;
       $public_key_file = path($app->app->datadir, $public_key_file)->to_string
            unless path($public_key_file)->is_abs;
    my $now = time();

    # Options
    $plugin->_options->{ 'configsection' } = $configsection;
    $plugin->_options->{ 'expiration' } = $expiration; # Session & Userdata expiration (3600)
    $plugin->_options->{ 'cache_expiration' } = $cache_expiration; # Cache expiration (300)
    $plugin->_options->{ 'public_key_file' } = $public_key_file;
    $plugin->_options->{ 'userfile_format' } = $opts->{userfile_format} || USERFILE_FORMAT;
    $app->helper( 'suffitauth.options' => sub { state $opts = $plugin->_options } );

    # Auth client (V1)
    $app->helper('suffitauth.client' => sub {
        my $c = shift;
        state $client = WWW::Suffit::Client::V1->new(
            url                 => $c->conf->latest("/$configsection/serverurl"),
            insecure            => $c->conf->latest("/$configsection/insecure"),
            max_redirects       => $c->conf->latest("/$configsection/maxredirects"),
            connect_timeout     => $c->conf->latest("/$configsection/connecttimeout"),
            inactivity_timeout  => $c->conf->latest("/$configsection/inactivitytimeout"),
            request_timeout     => $c->conf->latest("/$configsection/requesttimeout"),
            proxy               => $c->conf->latest("/$configsection/proxy"),
            token               => $c->conf->latest("/$configsection/token"),
            username            => $c->conf->latest("/$configsection/username"),
            password            => $c->conf->latest("/$configsection/password"),
            auth_scheme         => $c->conf->latest("/$configsection/authscheme"),
        );
    });

    # Auth helpers (methods)
    $app->helper('suffitauth.authenticate'=> \&_authenticate);
    $app->helper('suffitauth.authorize'   => \&_authorize);
    $app->helper('suffitauth.unauthorize' => \&_unauthorize);

    # Initialize auth client
    my %payload = ( # Ok by default
        error       => '',          # Error message
        status      => 200,         # HTTP status code
        code        => 'E0000',     # The Suffit error code
    );

    # Check auth client
    my $client = $app->suffitauth->client;
    unless ($client->check) {
        my $code = $client->res->json("/code") || 'E7002';
        $app->log->error(sprintf("[%s] %s: Can't connect to authorization server: %s",
            $code, $client->code, $client->apierr // 'unknown error'));
        $payload{error}     = "Can't connect to authorization server";
        $payload{status}    = 503;
        $payload{code}      = $code;
        return $app->helper('suffitauth.init' => sub { Mojo::JSON::Pointer->new({%payload}) });
    }

    # Get public_key and set it
    my $pkfile = path($public_key_file);
    $pkfile->remove if $expiration && (-e $public_key_file) && (stat($public_key_file)->mtime + $expiration) < $now; # Remove expired public key file
    if (-e $public_key_file) { # Set public key
        $client->public_key($pkfile->slurp);
    } else { # No file found - try get from auth server
        unless ($client->pubkey(1)) {
            my $code = $client->res->json("/code") || 'E7003';
            $app->log->error(sprintf("[%s] %s: Can't get public key from authorization server: %s",
                $code, $client->code, $client->apierr // 'unknown error'));
            $payload{error}     = "Can't get public key from authorization server";
            $payload{status}    = 500;
            $payload{code}      = $code;
            return $app->helper('suffitauth.init' => sub { Mojo::JSON::Pointer->new({%payload}) });
        }

        # Save file
        $pkfile->spew($client->public_key);
        unless (-e $public_key_file) {
            $app->log->error(sprintf("[E7004] Can't save public key file %s", $public_key_file));
            $payload{error}     = sprintf("Can't save public key file %s", $public_key_file);
            $payload{status}    = 500;
            $payload{code}      = 'E7004';
            return $app->helper('suffitauth.init' => sub { Mojo::JSON::Pointer->new({%payload}) });
        }
    }

    # Ok
    return $app->helper('suffitauth.init' => sub { Mojo::JSON::Pointer->new({%payload}) });
}
sub _authenticate {
    my $self = shift;
    my %args = scalar(@_) ? scalar(@_) % 2 ? ref($_[0]) eq 'HASH' ? (%{$_[0]}) : () : (@_) : ();
    my $cache = $self->app->cache;
    my $now = time();
    my $username = $args{username} || '';
    my $password = $args{password} // '';
       $password = encode('UTF-8', $password) if length $password; # chars to bytes
    my $referer = $args{referer} // $self->req->headers->header("Referer") // '';
    my $loginpage = $args{loginpage} // '';
    my $expiration = parse_time_offset($args{expiration} || 0) || $self->suffitauth->options->{expiration} || 0;
    my $address = $args{address} || $self->remote_ip($self->app->trustedproxies);
    my %payload = ( # Ok by default
        error       => '',          # Error message
        status      => 200,         # HTTP status code
        code        => 'E0000',     # The Suffit error code
        username    => $username,   # User name
        referer     => $referer,    # Referer
        loginpage   => $loginpage,  # Login page for redirects (location)
        location    => undef,       # Location URL for redirects
    );
    my $fileformat = $self->suffitauth->options->{userfile_format};
    my $json_file = path($self->app->datadir, sprintf($fileformat, $username));
    my $file = $json_file->to_string;

    # Check username
    unless (length $username) {
        $self->log->error("[E7001] Incorrect username");
        $payload{error}     = "Incorrect username";
        $payload{status}    = 400;
        $payload{code}      = 'E7001';
        return Mojo::JSON::Pointer->new({%payload});
    }

    # Get user key and file
    my $ustat_key = sprintf("auth.ustat.%s", hmac_sha1_sum(sprintf("%s:%s", encode('UTF-8', $username), $password), $self->app->mysecret));
    my $ustat_tm = $cache->get($ustat_key) || 0;
    if ($expiration && (-e $file) && ($ustat_tm + $expiration) > $now) { # Ok!
        $self->log->debug(sprintf("$$: User data is still valid. Expired at %s", scalar(localtime($ustat_tm + $expiration))));
        return Mojo::JSON::Pointer->new({%payload});
    }

    # Get auth client
    my $client = $self->suffitauth->client;

    # Authentication
    unless ($client->authn($username, $password, $address)) { # Error
        my $code = $client->res->json("/code") || 'E7005';
        $self->log->error(sprintf("[%s] %s: %s", $code, $client->code, $client->apierr));
        $payload{error}     = $client->apierr;
        $payload{status}    = $client->code;
        $payload{code}      = $code;
        return Mojo::JSON::Pointer->new({%payload});
    }

    # Authorization
    unless ($client->authz(
            $args{method} || $self->req->method || "ANY",
            $args{base_url} || $self->base_url,
            { # Error
                username    => $username,
                address     => $address,
                verbose     => 1,
            })
    ) {
        my $code = $client->res->json("/code") || 'E7006';
        $self->log->error(sprintf("[%s] %s: %s", $code, $client->code, $client->apierr));
        $payload{error}     = $client->apierr;
        $payload{status}    = $client->code;
        $payload{code}      = $code;
        return Mojo::JSON::Pointer->new({%payload});
    }

    # Save json file
    json_save($file, $client->res->json);
    unless (-e $file) {
        $self->log->error(sprintf("[E7007] Can't save file %s", $file));
        $payload{error}     = sprintf("Can't save file DATADIR/$fileformat", $username);
        $payload{status}    = 500;
        $payload{code}      = 'E7007';
        return Mojo::JSON::Pointer->new({%payload});
    }

    # Fixed to cache
    $cache->set($ustat_key, $now);

    # Ok
    return Mojo::JSON::Pointer->new({%payload});
}
sub _authorize {
    my $self = shift;
    my %args = scalar(@_) ? scalar(@_) % 2 ? ref($_[0]) eq 'HASH' ? (%{$_[0]}) : () : (@_) : ();
    my $username = $args{username} || '';
    my $referer = $args{referer} // $self->req->headers->header("Referer") // '';
    my $loginpage = $args{loginpage} // '';
    my %payload = ( # Ok by default
        error       => '',          # Error message
        status      => 200,         # HTTP status code
        code        => 'E0000',     # The Suffit error code
        username    => $username,   # User name
        referer     => $referer,    # Referer
        loginpage   => $loginpage,  # Login page for redirects (location)
        location    => undef,       # Location URL for redirects
        user        => {            # User data with required fields (defaults)
            status      => \0,          # User status
            uid         => 0,           # User ID
            username    => $username,   # User name
            name        => $username,   # Full name
            role        => "",          # User role
            email       => "",          # Email address
            email_md5   => "",          # MD5 of email address
            comment     => "",          # Comment
        },
    );

    # Check username
    unless (length $username) {
        $self->log->error("[E7009] Incorrect username");
        $payload{error}     = "Incorrect username";
        $payload{status}    = 400;
        $payload{code}      = 'E7009';
        return Mojo::JSON::Pointer->new({%payload});
    }

    # Get user file name
    my $fileformat = $self->suffitauth->options->{userfile_format};
    my $file = path($self->app->datadir, sprintf($fileformat, $username))->to_string;

    # Get user data from cache first
    my $cache = $self->app->cache;
    my $user_cache_key = sprintf("auth.userdata.%s", $username);
    my $user_data = $cache->get($user_cache_key);

    # Load user data from file
    unless ($user_data) {
        $user_data = -e $file ? json_load($file) : {};

        # Set loaded user data to cache
        $cache->set($user_cache_key, $user_data, $self->suffitauth->options->{cache_expiration});
    }

    # Check user data
    unless ($user_data->{uid}) {
        $self->log->error(sprintf("[E7008] File %s not found or incorrect", $file));
        $payload{error}     = sprintf("File DATADIR/$fileformat not found or incorrect", $username);
        $payload{status}    = 500;
        $payload{code}      = 'E7008';
        return Mojo::JSON::Pointer->new({%payload});
    }

    # Ok
    $payload{user} = {%{$user_data}}; # Set user data to pyload hash
    return Mojo::JSON::Pointer->new({%payload});
}
sub _unauthorize {
    my $self = shift;
    my %args = scalar(@_) ? scalar(@_) % 2 ? ref($_[0]) eq 'HASH' ? (%{$_[0]}) : () : (@_) : ();
    my $username = $args{username} || '';

    # Initialize auth client
    my %payload = ( # Ok by default
        error       => '',          # Error message
        status      => 200,         # HTTP status code
        code        => 'E0000',     # The Suffit error code
        username    => $username,
    );

    # Check username
    unless (length $username) {
        $self->log->error("[E7010] Incorrect username");
        $payload{error}     = "Incorrect username";
        $payload{status}    = 400;
        $payload{code}      = 'E7010';
        return Mojo::JSON::Pointer->new({%payload});
    }

    # Remove user data from cache first
    my $cache = $self->app->cache;
    my $user_cache_key = sprintf("auth.userdata.%s", $username);
    $cache->del($user_cache_key);

    # Get user file name
    my $fileformat = $self->suffitauth->options->{userfile_format};
    my $file = path($self->app->datadir, sprintf($fileformat, $username))->to_string;

    # Remove userdata file
    path($file)->remove if -e $file;

    # Ok
    return Mojo::JSON::Pointer->new({%payload});
}

1;

__END__


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