File Coverage

blib/lib/WWW/Suffit/Plugin/FileAuth.pm
Criterion Covered Total %
statement 36 186 19.3
branch 0 76 0.0
condition 0 83 0.0
subroutine 12 21 57.1
pod 1 1 100.0
total 49 367 13.3


line stmt bran cond sub pod time code
1             package WWW::Suffit::Plugin::FileAuth;
2 1     1   154577 use strict;
  1         6  
  1         45  
3 1     1   6 use warnings;
  1         2  
  1         73  
4 1     1   868 use utf8;
  1         311  
  1         8  
5              
6             =encoding utf8
7              
8             =head1 NAME
9              
10             WWW::Suffit::Plugin::FileAuth - The Suffit plugin for authentication and authorization by password file
11              
12             =head1 SYNOPSIS
13              
14             sub startup {
15             my $self = shift->SUPER::startup();
16             $self->plugin('FileAuth', {
17             configsection => 'auth',
18             });
19              
20             # . . .
21             }
22              
23             ... configuration:
24              
25             # FileAuth configuration
26            
27             AuthUserFile /etc/myapp/passwd.db
28            
29              
30             =head1 DESCRIPTION
31              
32             This plugin provides authentication and authorization by looking up users in plain text password files
33              
34             The C configuration directive sets the path to the user file of a textual file containing the list of users and passwords
35             for user authentication.
36              
37             If it is not absolute, it is treated as relative to the project C directory.
38              
39             By default use C file name
40              
41             Each line of the user file contains a username followed by a colon, followed by the encrypted password.
42             If the same user ID is defined multiple times, plugin will use the first occurrence to verify the password.
43             Try to avoid such cases!
44              
45             The encrypted password format depends on which length of this encrypted-string and character-set:
46              
47             md5 32 hex digits and chars
48             sha1 40 hex digits and chars
49             sha224 56 hex digits and chars
50             sha256 64 hex digits and chars
51             sha384 96 hex digits and chars
52             sha512 128 hex digits and chars
53             unsafe plain text otherwise
54              
55             Also, each line of the user file can contain parameters in the C format
56             (L), which must be placed at the end of the line with
57             a leading colon character, which is the delimiter
58              
59             For example:
60              
61             admin:5f4dcc3b5aa765d61d8327deb882cf99
62             test:5f4dcc3b5aa765d61d8327deb882cf99:uid=1&name=Test%20user
63             anonymous:password
64              
65             =head1 OPTIONS
66              
67             This plugin supports the following options
68              
69             =head2 configsection
70              
71             configsection => 'auth'
72              
73             This option sets a section name of the config file for define
74             namespace of configuration directives for this plugin
75              
76             Default: none (without section)
77              
78             =head1 HELPERS
79              
80             This plugin provides the following helpers
81              
82             =head2 fileauth.init
83              
84             my $init = $self->fileauth->init;
85              
86             This method returns the init object (L)
87             that contains data of initialization:
88              
89             {
90             error => '...', # Error message
91             status => 500, # HTTP status code
92             code => 'E7000', # The Suffit error code
93             }
94              
95             For example (in your controller):
96              
97             # Check init status
98             my $init = $self->fileauth->init;
99             if (my $err = $init->get('/error')) {
100             $self->reply->error($init->get('/status'),
101             $init->get('/code'), $err);
102             return;
103             }
104              
105             =head2 fileauth.authenticate
106              
107             my $auth = $self->fileauth->authenticate({
108             username => $username,
109             password => $password,
110             loginpage => 'login', # -- To login-page!!
111             expiration => $remember ? SESSION_EXPIRE_MAX : SESSION_EXPIRATION,
112             realm => "Test zone",
113             });
114             if (my $err = $auth->get('/error')) {
115             if (my $location = $auth->get('/location')) { # Redirect
116             $self->flash(message => $err);
117             $self->redirect_to($location); # 'login' -- To login-page!!
118             } elsif ($auth->get('/status') >= 500) { # Fatal server errors
119             $self->reply->error($auth->get('/status'), $auth->get('/code'), $err);
120             } else { # User errors (show on login page)
121             $self->stash(error => $err);
122             return $self->render;
123             }
124             return;
125             }
126              
127             This helper performs authentication backend subprocess and returns
128             result object (L) that contains data structure:
129              
130             {
131             error => '', # Error message
132             status => 200, # HTTP status code
133             code => 'E0000', # The Suffit error code
134             username => $username, # User name
135             referer => $referer, # Referer
136             loginpage => $loginpage, # Login page for redirects (location)
137             location => undef, # Location URL for redirects
138             }
139              
140             =head2 fileauth.authorize
141              
142             my $auth = $self->fileauth->authorize({
143             referer => $referer,
144             username => $username,
145             loginpage => 'login', # -- To login-page!!
146             });
147             if (my $err = $auth->get('/error')) {
148             if (my $location = $auth->get('/location')) {
149             $self->flash(message => $err);
150             $self->redirect_to($location); # 'login' -- To login-page!!
151             } else {
152             $self->reply->error($auth->get('/status'), $auth->get('/code'), $err);
153             }
154             return;
155             }
156              
157             This helper performs authorization backend subprocess and returns
158             result object (L) that contains data structure:
159              
160             {
161             error => '', # Error message
162             status => 200, # HTTP status code
163             code => 'E0000', # The Suffit error code
164             username => $username, # User name
165             referer => $referer, # Referer
166             loginpage => $loginpage, # Login page for redirects (location)
167             location => undef, # Location URL for redirects
168             user => { # User data
169             address => "127.0.0.1", # User (client) IP address
170             base => "http://localhost:8080", # Base URL of request
171             comment => "No comments", # Comment
172             email => 'test@example.com', # Email address
173             email_md5 => "a84450...366", # MD5 hash of email address
174             method => "ANY", # Current method of request
175             name => "Bob Smith", # Full user name
176             path => "/", # Current query-path of request
177             role => "Regular user", # User role
178             status => true, # User status in JSON::PP::Boolean notation
179             uid => 1, # User ID
180             username => $username, # User name
181             },
182             }
183              
184             =head1 METHODS
185              
186             Internal methods
187              
188             =head2 register
189              
190             This method register the plugin and helpers in L application
191              
192             =head1 SEE ALSO
193              
194             L, L
195              
196             =head1 AUTHOR
197              
198             Serż Minus (Sergey Lepenkov) L Eabalama@cpan.orgE
199              
200             =head1 COPYRIGHT
201              
202             Copyright (C) 1998-2026 D&D Corporation
203              
204             =head1 LICENSE
205              
206             This program is distributed under the terms of the Artistic License Version 2.0
207              
208             See the C file or L for details
209              
210             =cut
211              
212 1     1   892 use Mojo::Base 'Mojolicious::Plugin';
  1         15562  
  1         11  
213              
214             our $VERSION = '1.01';
215              
216 1     1   2919 use Digest::SHA qw/sha224_hex sha256_hex sha384_hex sha512_hex/;
  1         4824  
  1         388  
217 1     1   672 use Mojo::File qw/path/;
  1         318029  
  1         120  
218 1     1   14 use Mojo::Util qw/trim encode md5_sum sha1_sum hmac_sha1_sum secure_compare/;
  1         3  
  1         113  
219 1     1   877 use Mojo::JSON::Pointer;
  1         1129  
  1         11  
220 1     1   812 use Mojo::Parameters;
  1         3284  
  1         9  
221 1     1   884 use WWW::Suffit::Const qw/ :session /;
  1         3623  
  1         7429  
222 1     1   582 use WWW::Suffit::Util qw/json_load json_save/;
  1         71027  
  1         158  
223              
224 1     1   12 use constant PASSWD_FILENAME => 'passwd.db';
  1         3  
  1         3796  
225              
226             sub register {
227 0     0 1   my ($plugin, $app, $opts) = @_; # $self = $plugin
228 0   0       $opts //= {};
229 0           my $configsection = $opts->{configsection};
230 0           my %payload = ( # Ok by default
231             error => '', # Error message
232             status => 200, # HTTP status code
233             code => 'E0000', # The Suffit error code
234             );
235              
236             # Load pwdb file
237 0           my @users = ();
238 0 0         my $pwdb_file = $configsection
239             ? $app->conf->latest("/$configsection/authuserfile")
240             : $app->conf->latest("/authuserfile");
241 0   0       $pwdb_file ||= path($app->app->datadir, PASSWD_FILENAME)->to_string;
242 0 0         $pwdb_file = path($app->app->datadir, $pwdb_file)->to_string unless path($pwdb_file)->is_abs;
243 0 0         if (-e $pwdb_file) {
244 0 0         if (open my $records, '<', $pwdb_file) {
245 0           while(<$records>) {
246 0           chomp;
247 0 0         next unless $_;
248 0           my $l = trim($_);
249 0 0         next unless $l;
250 0 0         next if $l =~ /^[#;]/;
251 0           push @users, $l;
252             }
253 0           close $records;
254             } else {
255 0           $app->log->error(sprintf("[E7000] Error opening password file \"%s\: %s", $pwdb_file, $!));
256 0           $payload{error} = "Error opening password file: $!";
257 0           $payload{status} = 500;
258 0           $payload{code} = 'E7000';
259             }
260             } else {
261 0           $app->log->error(sprintf("[E7000] Password file \"%s\" not found", $pwdb_file));
262 0           $payload{error} = "Password file not found";
263 0           $payload{status} = 500;
264 0           $payload{code} = 'E7000';
265             }
266              
267             # List of users from config
268 0     0     $app->helper('fileauth.users' => sub { \@users });
  0            
269              
270             # Auth helpers (methods)
271 0           $app->helper('fileauth.authenticate'=> \&_authenticate);
272 0           $app->helper('fileauth.authorize' => \&_authorize);
273              
274             # Return with errors
275 0     0     return $app->helper('fileauth.init' => sub { Mojo::JSON::Pointer->new({%payload}) })
276 0 0         if $payload{error};
277              
278             # Check users
279 0 0         unless (scalar @users) {
280 0           $app->log->error(sprintf("[E7010] No any users found in password file \"%s\"", $pwdb_file));
281 0           $payload{error} = "No any users found in password file";
282 0           $payload{status} = 500;
283 0           $payload{code} = 'E7010';
284 0     0     return $app->helper('fileauth.init' => sub { Mojo::JSON::Pointer->new({%payload}) });
  0            
285             }
286             #$app->log->error(Mojo::Util::dumper($users));
287              
288             # Ok
289 0     0     return $app->helper('fileauth.init' => sub { Mojo::JSON::Pointer->new({%payload}) });
  0            
290             }
291             sub _authenticate {
292 0     0     my $self = shift;
293 0 0         my %args = scalar(@_) ? scalar(@_) % 2 ? ref($_[0]) eq 'HASH' ? (%{$_[0]}) : () : (@_) : ();
  0 0          
    0          
294 0           my $cache = $self->app->cache;
295 0           my $now = time();
296 0   0       my $username = $args{username} || '';
297 0   0       my $password = $args{password} // '';
298 0 0         $password = encode('UTF-8', $password) if length $password; # chars to bytes
299 0   0       my $referer = $args{referer} // $self->req->headers->header("Referer") // '';
      0        
300 0   0       my $loginpage = $args{loginpage} // '';
301 0   0       my $expiration = $args{expiration} || 0;
302 0           my %payload = ( # Ok by default
303             error => '', # Error message
304             status => 200, # HTTP status code
305             code => 'E0000', # The Suffit error code
306             username => $username, # User name
307             referer => $referer, # Referer
308             loginpage => $loginpage, # Login page for redirects (location)
309             location => undef, # Location URL for redirects
310             );
311 0           my $json_file = path($self->app->datadir, sprintf("u.%s.json", $username));
312 0           my $file = $json_file->to_string;
313              
314             # Check username
315 0 0         unless (length $username) {
316 0           $self->log->error("[E7001] Incorrect username");
317 0           $payload{error} = "Incorrect username";
318 0           $payload{status} = 400;
319 0           $payload{code} = 'E7001';
320 0           return Mojo::JSON::Pointer->new({%payload});
321             }
322              
323             # Get user key and file
324 0           my $ustat_key = sprintf("auth.ustat.%s", hmac_sha1_sum(sprintf("%s:%s", encode('UTF-8', $username), $password), $self->app->mysecret));
325 0   0       my $ustat_tm = $cache->get($ustat_key) || 0;
326 0 0 0       if ($expiration && (-e $file) && ($ustat_tm + $expiration) > $now) { # Ok!
      0        
327 0           $self->log->debug(sprintf("$$: User data is still valid. Expired at %s", scalar(localtime($ustat_tm + $expiration))));
328 0           return Mojo::JSON::Pointer->new({%payload});
329             }
330              
331             # Get password database from cache
332 0           my $pwdb = $cache->get('auth.pwdb');
333 0 0         unless ($pwdb) {
334 0   0       my $users = $self->fileauth->users || [];
335 0           $pwdb = { (_parse_pwdb_lines(@$users)) };
336 0           $cache->set('auth.pwdb' => $pwdb); # store whole password database to cache
337             #$self->log->error(Mojo::Util::dumper( $pwdb ));
338             }
339              
340             # Authentication: Check by password database
341 0   0       my $pw = encode('UTF-8', $pwdb->{$username}->{pwd} // '');
342 0   0       my $ar = Mojo::Parameters->new($pwdb->{$username}->{arg} // '')->charset('UTF-8');
343 0 0         unless (_check_pw($password, $pw)) { # Oops. Incorrect username/password
344 0           $self->log->error(sprintf("[%s] %s: %s", 401, 'E7005', 'Incorrect username/password'));
345 0           $payload{error} = 'Incorrect username/password';
346 0           $payload{status} = 401;
347 0           $payload{code} = 'E7005';
348 0           return Mojo::JSON::Pointer->new({%payload});
349             }
350             #$self->log->error(Mojo::Util::dumper( $ar ));
351              
352             # User data with required fields!
353 0   0       my $data = $ar->to_hash || {};
354 0           $data->{address} = $self->remote_ip($self->app->trustedproxies);
355 0   0       $data->{base} = $args{base_url} || $self->base_url;
356 0   0       $data->{method} = $args{method} || $self->req->method || "ANY";
357 0   0       $data->{path} = $self->req->url->path->to_string || "/";
358 0           $data->{referer} = $referer;
359             # required fields:
360 0 0         $data->{status} = $data->{status} ? \1 : \0;
361 0   0       $data->{uid} ||= 0;
362 0   0       $data->{username} //= $username;
363 0   0       $data->{name} //= $username;
364 0   0       $data->{role} //= '';
365 0   0       $data->{email} //= '';
366             $data->{email_md5} //= $data->{email} ? md5_sum($data->{email}) : '',
367 0 0 0       $data->{comment} //= '';
      0        
368              
369             # Save json file with user data
370 0           json_save($file, $data);
371 0 0         unless (-e $file) {
372 0           $self->log->error(sprintf("[E7007] Can't save file %s", $file));
373 0           $payload{error} = sprintf("Can't save file DATADIR/u.%s.json", $username);
374 0           $payload{status} = 500;
375 0           $payload{code} = 'E7007';
376 0           return Mojo::JSON::Pointer->new({%payload});
377             }
378              
379             # Fixed to cache
380 0           $cache->set($ustat_key, $now);
381              
382             # Ok
383 0           return Mojo::JSON::Pointer->new({%payload});
384             }
385             sub _authorize {
386 0     0     my $self = shift;
387 0 0         my %args = scalar(@_) ? scalar(@_) % 2 ? ref($_[0]) eq 'HASH' ? (%{$_[0]}) : () : (@_) : ();
  0 0          
    0          
388 0   0       my $username = $args{username} || '';
389 0   0       my $referer = $args{referer} // $self->req->headers->header("Referer") // '';
      0        
390 0   0       my $loginpage = $args{loginpage} // '';
391 0           my %payload = ( # Ok by default
392             error => '', # Error message
393             status => 200, # HTTP status code
394             code => 'E0000', # The Suffit error code
395             username => $username, # User name
396             referer => $referer, # Referer
397             loginpage => $loginpage, # Login page for redirects (location)
398             location => undef, # Location URL for redirects
399             user => { # User data with required fields (defaults)
400             status => \0, # User status
401             uid => 0, # User ID
402             username => $username, # User name
403             name => $username, # Full name
404             role => "", # User role
405             email => "", # Email address
406             email_md5 => "", # MD5 of email address
407             comment => "", # Comment
408             },
409             );
410              
411             # Check username
412 0 0         unless (length $username) {
413 0           $self->log->error("[E7009] Incorrect username");
414 0           $payload{error} = "Incorrect username";
415 0           $payload{status} = 400;
416 0           $payload{code} = 'E7009';
417 0           return Mojo::JSON::Pointer->new({%payload});
418             }
419              
420             # Get user file name
421 0           my $file = path($self->app->datadir, sprintf("u.%s.json", $username))->to_string;
422              
423             # Load user file with user data
424 0 0         my $user = -e $file ? json_load($file) : {};
425              
426             # Check user data
427 0 0         unless ($user->{username}) {
428 0           $self->log->error(sprintf("[E7008] File %s not found or incorrect", $file));
429 0           $payload{error} = sprintf("File DATADIR/u.%s.json not found or incorrect", $username);
430 0           $payload{status} = 500;
431 0           $payload{code} = 'E7008';
432 0           return Mojo::JSON::Pointer->new({%payload});
433             }
434              
435             # Ok
436 0           $payload{user} = {%{$user}}; # Set user data to pyload hash
  0            
437 0           return Mojo::JSON::Pointer->new({%payload});
438             }
439              
440             sub _parse_pwdb_lines {
441 0     0     my @lines = @_;
442 0           my %r = ();
443 0           for (@lines) {
444 0 0         next unless $_;
445 0           my @line = split ':', $_;
446 0   0       my ($usr, $pwd, $arg) = ($line[0] // '', $line[1] // '', $line[2] // '');
      0        
      0        
447 0 0 0       next unless length($usr) && length($pwd);
448 0 0         if (@line == 3) { # username:password:params
    0          
449 0           $r{$usr} = {
450             pwd => $pwd,
451             arg => $arg,
452             };
453             } elsif (@line == 2) { # username:password
454 0           $r{$usr} = {
455             pwd => $pwd
456             };
457             }
458             }
459 0           return %r;
460             }
461             sub _check_pw {
462 0   0 0     my $pwd = shift // '';
463 0   0       my $sum = shift // '';
464 0 0 0       return 0 unless length($pwd) && length($sum);
465 0 0         if ($sum =~ /^[0-9a-f]+$/i) {
466 0 0         if (length($sum) == 32) { # md5: acbd18db4cc2f85cedef654fccc4a4d8
    0          
    0          
    0          
    0          
    0          
467 0           return secure_compare(md5_sum($pwd), lc($sum));
468             } elsif(length($sum) == 40) { # sha1: 0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33
469 0           return secure_compare(sha1_sum($pwd), lc($sum));
470             } elsif(length($sum) == 56) { # sha224: d63dc919e201d7bc4c825630d2cf25fdc93d4b2f0d46706d29038d01
471 0           return secure_compare(sha224_hex($pwd), lc($sum));
472             } elsif(length($sum) == 64) { # sha224: 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
473 0           return secure_compare(sha256_hex($pwd), lc($sum));
474             } elsif(length($sum) == 96) { # sha384: a8b64babd0aca91a59bdbb7761b421d4f2bb38280d3a75ba0f21f2bebc45583d446c598660c94ce680c47d19c30783a7
475 0           return secure_compare(sha384_hex($pwd), lc($sum));
476             } elsif(length($sum) == 128) { # sha512: b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb980b1d7785e5976ec049b46df5f1326af5a2ea6d103fd07c95385ffab0cacbc86
477 0           return secure_compare(sha512_hex($pwd), lc($sum));
478             } else { # Plain text (unsafe)
479 0           return secure_compare($pwd, $sum);
480             }
481             } else { # Plain text (unsafe)
482 0           return secure_compare($pwd, $sum);
483             }
484 0           return 0;
485             }
486              
487             1;
488              
489             __END__