File Coverage

blib/lib/Concierge/Auth.pm
Criterion Covered Total %
statement 197 216 91.2
branch 103 130 79.2
condition 48 65 73.8
subroutine 30 30 100.0
pod 19 24 79.1
total 397 465 85.3


line stmt bran cond sub pod time code
1             package Concierge::Auth v0.4.5;
2 6     6   915141 use v5.36;
  6         18  
3              
4             # ABSTRACT: Concierge authorization using Crypt::Passphrase
5              
6 6     6   31 use Carp qw/carp croak/;
  6         24  
  6         302  
7 6     6   69 use Fcntl qw/:flock/;
  6         10  
  6         611  
8 6     6   2559 use Crypt::Passphrase;
  6         100742  
  6         35  
9 6     6   210 use parent qw/Concierge::Auth::Generators/;
  6         8  
  6         24  
10              
11             ## Constants for validation
12             use constant {
13 6         13439 MIN_ID_LENGTH => 2,
14             MAX_ID_LENGTH => 32,
15             MIN_PASSWORD_LENGTH => 8,
16             MAX_PASSWORD_LENGTH => 72, # bcrypt limit
17 6     6   291 };
  6         7  
18              
19             ## Pre-compiled regex for ID validation - accepts email addresses
20             my $ID_ALLOWED_CHARS = qr/^[a-zA-Z0-9._@-]+$/;
21             ## Password file field separator
22             my $FIELD_SEPARATOR = "\t";
23              
24             ## new: instantiate the auth object with a passwd file
25             ## Complains if no file is provided unless the argument
26             ## no_file => 1 is provided, but still instantiates
27             ## the auth object; without a passwd file, the auth object
28             ## can only provide the utility methods:
29             ## encryptPwd(), gen_random_token(), gen_random_string(),
30             ## gen_word_phrase(), gen_uuid()
31             ## A file may be designated after instantiation with
32             ## the method setFile().
33             ## Dies if it can't open/create a designated file.
34             ## Complains if it can't set permissions on the file.
35             sub new {
36 19     19 1 569051 my ($class, $args) = @_;
37              
38 19         142 my $self = bless {
39             auth => Crypt::Passphrase->new(
40             encoder => 'Argon2',
41             validators => [ 'Bcrypt' ],
42             )
43             }, $class;
44              
45 19 100       75777 if ($args->{no_file}) {
46 10         1527 carp "Utilities only; no ID and password checks";
47             # Still functional:
48 10         79 return $self;
49             }
50 9 100       25 unless ($args->{file}) {
51 1         101 carp "No auth file provided for ID and password checks";
52             # Still functional:
53 1         6 return $self;
54             }
55              
56 8 100       370 if (-e $args->{file}) {
57             open my $afh, "<", $args->{file} or
58 1 50       29 croak ("Can't read auth file ($args->{file}). $! ");
59 1         10 close $afh;
60             } else {
61             open my $afh, ">", $args->{file} or
62 7 100       833 croak ("Can't open/create auth file ($args->{file}). $! ");
63 6         72 close $afh;
64             }
65              
66 7 50       165 chmod 0600, $args->{file} or carp $!;
67 7         69 $self->{auth}->{file} = $args->{file};
68 7         50 $self;
69             }
70              
71             ## Class Methods for Responses
72             ## confirm, reject, reply
73             ## NOT called with object arrow notation:
74             ## $self->reject # !WRONG
75             ## Once instantiated, the auth object will not die/croak;
76             ## Instead, all methods that check or validate respond with
77             ## `confirm ($msg)` # wantarray ? (1, $msg) : 1;
78             ## or
79             ## `reject ($msg)` # wantarray ? (0, $msg) : 0;
80             ## or the more general
81             ## `reply ($bool, $msg)` # wantarray ? ($bool, $msg) : $bool
82             ## Use explicit `return` to assure correct contrl flow:
83             ## `return confirm($msg);`
84             ## `return reply( $result, $msg);`
85             sub confirm {
86 86   100 86 0 1783 my $message = shift || "Auth confirmation";
87 86 100       296 wantarray ? (1, $message) : 1;
88             }
89             sub reject {
90 50   100 50 0 1534 my $message = shift || "Auth rejection";
91 50 100       202 wantarray ? (0, $message) : 0;
92             }
93             ## First arg is 1|0 or other Perl true/false value
94             sub reply {
95 42   100 42 0 2406 my $bool = shift // 0;
96 42   66     75 my $message = shift || ( $bool ? "Auth confirmation" : "Auth rejection" );
97 42 100       178 wantarray ? ($bool, $message) : $bool;
98             }
99              
100             ## Validations
101             sub validateID {
102 53     53 1 17238 my ($self, $id) = @_;
103              
104 53 100 100     220 return reject( "ID cannot be empty" )
105             unless (defined $id && length($id) > 0);
106              
107             # Check length constraints
108 46 100 100     146 return reject( sprintf(
109             "ID must be between %d and %d characters",
110             MIN_ID_LENGTH, MAX_ID_LENGTH
111             ) ) unless (
112             length($id) >= MIN_ID_LENGTH
113             &&
114             length($id) <= MAX_ID_LENGTH
115             );
116              
117             # Check pattern
118 43 100       312 return reject( "ID contains invalid characters" )
119             unless ($id =~ $ID_ALLOWED_CHARS);
120              
121 39         70 return confirm;
122             }
123              
124             sub validatePwd {
125 35     35 1 7902 my ($self, $password) = @_;
126              
127             # Check if password is defined and not empty
128 35 100 100     110 return reject( "Password cannot be empty" )
129             unless defined $password && length($password) > 0;
130              
131             # Check length constraints
132 30 100 100     89 return reject( sprintf(
133             "Password must be between %d and %d characters",
134             MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH
135             ) ) unless (
136             length($password) >= MIN_PASSWORD_LENGTH
137             &&
138             length($password) <= MAX_PASSWORD_LENGTH
139             );
140              
141 23         38 return confirm;
142             }
143              
144             sub validateFile {
145 13     13 1 4030 my ($self, $file) = @_;
146 13   66     34 $file ||= $self->{auth}->{file};
147 13 100 66     314 return ($file && -e $file && -r $file) ?
148             confirm( "Auth file OK" )
149             : reject( "Auth file Not OK" );
150             }
151              
152             ## checkID: checks for a record with a specified user_id
153             sub checkID {
154 11     11 1 6202 my $self = shift;
155 11         16 my $id = shift;
156              
157 11         24 my ($ok,$msg) = $self->validateID($id);
158 11 100       22 return reject( $msg ) unless $ok;
159              
160 10         24 my $pfile = $self->pfile();
161 10 100       207 -e $pfile
162             or return reject( "checkID: No auth file");
163 9 50       277 open my $pfh, "<", $pfile
164             or return reject( "checkID: Can't read auth file" );
165 9 50       59 flock($pfh, LOCK_SH) or do {
166 0         0 close $pfh;
167 0         0 return reject( "checkID: Can't lock file for reading: $!" );
168             };
169 9         17 my $sep = $FIELD_SEPARATOR;
170 9         172 while (<$pfh>) {
171 5 100       89 if (/^$id$sep/) {
172 4         38 close $pfh;
173 4         16 return confirm( "ID OK" );
174             }
175             }
176 5         40 close $pfh;
177 5         20 return reject( "checkID: ID $id not confirmed" );
178             }
179              
180             ## deleteID: deletes a passwd file record with a specified user_id
181             sub deleteID {
182 4     4 1 1675 my $self = shift;
183 4         5 my $id = shift;
184              
185 4         10 my @id = $self->validateID($id);
186 4 100       9 return reply( @id ) unless $id[0];
187              
188 3         4 my $sep = $FIELD_SEPARATOR;
189 3   100     11 my $pfile = $self->{auth}->{file} || '';
190 3 100 66     37 unless ( $pfile and -e $pfile ) {
191 1         2 return reject( "File $pfile no good" );
192             }
193 2 50       76 open my $fh, "+<", $pfile or return reject( "Cannot open file: $!" );
194 2 50       14 flock($fh, LOCK_EX) or do {
195 0         0 close $fh;
196 0         0 return reject( "Cannot lock file: $!" );
197             };
198 2         48 my @lines = <$fh>;
199              
200 2         4 my $success = 0;
201 2         2 my @output;
202 2         6 for my $line ( @lines ) {
203 1 50       14 if ( $line =~ /^$id$sep/) {
204 1         2 $success++;
205 1         3 next;
206             }
207 0         0 push @output, $line;
208             }
209 2 50 33     107 unless (
      33        
      33        
210             seek($fh, 0, 0)
211             and truncate($fh, 0)
212             and print $fh @output
213             and close $fh
214             ) {
215 0         0 close $fh;
216 0         0 return reject( "deleteID: File update failed: $!" );
217             }
218              
219 2 100       9 return reply($success, ($success ? $id : "ID $id not found to delete") );
220             }
221              
222             ## checkPwd: checks for a record with specified user_id & password
223             ## returns boolean 1|0 = True|False
224             sub checkPwd {
225 8     8 1 6646 my $self = shift;
226 8         13 my $id = shift;
227 8         10 my $passwd = shift;
228              
229 8         24 my @id = $self->validateID($id);
230 8 100       17 return reply( @id ) unless $id[0];
231 7         20 my @pwd = $self->validatePwd($passwd);
232 7 100       17 return reply( @pwd ) unless $pwd[0];
233              
234 6         11 my $sep = $FIELD_SEPARATOR;
235 6         13 my $pfile = $self->{auth}->{file};
236              
237 6 100       337 open my $pfh, "<", $pfile
238             or return reject( "checkPwd: Cannot open auth file: $!" );
239 5 50       42 flock($pfh, LOCK_SH) or do {
240 0         0 close $pfh;
241 0         0 return reject( "checkPwd: Cannot lock file for reading: $!" );
242             };
243 5         126 while (<$pfh>) {
244 5 100       136 if (/^$id$sep([^$sep]+)$sep\|/) {
245 4         13 my $phash = $1;
246 4         45 close $pfh;
247             # uses Crypt::Passphrase::verify_password
248 4 100       19 if ($self->{auth}->verify_password($passwd,$phash)) {
249 2         1988243 return confirm;
250             } else {
251 2         2025673 return reject( "checkPwd: Invalid password" );
252             }
253             }
254             }
255 1         30 close $pfh;
256 1         5 return reject( "checkPwd: User ID not found" );
257             }
258              
259             ## setPwd: records a hashed password for a specified user_id
260             ## Returns with a failure message if user_id already exists.
261             ## Returns boolean 1|0 = True|False in scalar context
262             ## Returns duple ( 1|0, 'string' ) in list context
263             ## = (success|failure, ID or failure message)
264             sub setPwd {
265 6     6 1 11582 my $self = shift;
266 6   100     18 my $id = shift || '';
267 6   100     51 my $passwd = shift || '';
268              
269 6         18 my @id = $self->validateID($id);
270 6 100       16 return reply( @id ) unless $id[0];
271 5         33 my @pwd = $self->validatePwd($passwd);
272 5 100       11 return reply( @pwd ) unless $pwd[0];
273 3         8 my @chk = $self->checkID($id);
274 3 100       11 return reject( "ID $id previously used" ) if $chk[0];
275              
276 2         8 my $phash = $self->encryptPwd($passwd);
277 2         2006620 my $sep = $FIELD_SEPARATOR;
278 2         26 my $pfile = $self->{auth}->{file};
279              
280 2 50       164 open my $pfh, ">>", $pfile
281             or return reject( "setPwd: Cannot open auth file: $!" );
282 2 50       21 flock($pfh, LOCK_EX) or do {
283 0         0 close $pfh;
284 0         0 return reject( "setPwd: Cannot lock file for writing: $!" );
285             };
286 2 50       34 print $pfh join( $sep => $id, $phash, "|\n") or do {
287 0         0 close $pfh;
288 0         0 return reject( "setPwd: Cannot write to file: $!" );
289             };
290 2 50       127 close $pfh or return reject( "setPwd: Cannot close file: $!" );
291 2         12 return confirm( $id );
292             }
293              
294             ## resetPwd: records a new hashed password for a specified user_id
295             ## returns boolean 1|0 = True|False in scalar context
296             ## returns duple ( 1|0, 'string' ) in list context
297             ## = (success|failure, ID or failure message)
298             sub resetPwd {
299 6     6 1 4207 my $self = shift;
300 6   100     20 my $id = shift || '';
301 6   100     16 my $passwd = shift || '';
302              
303 6         16 my @id = $self->validateID($id);
304 6 100       21 return reply( @id ) unless $id[0];
305 5         11 my @pwd = $self->validatePwd($passwd);
306 5 100       30 return reply( @pwd ) unless $pwd[0];
307              
308 3         11 my $phash = $self->encryptPwd($passwd);
309              
310 3         2982867 my $sep = $FIELD_SEPARATOR;
311 3   100     30 my $pfile = $self->{auth}->{file} || '';
312 3         17 my @f = $self->validateFile($pfile);
313 3 100       13 return reply( @f ) unless $f[0];
314              
315 2 50       83 open my $fh, "+<", $pfile or return reject( "resetPwd: Cannot open file: $!" );
316 2 50       18 flock($fh, LOCK_EX) or do {
317 0         0 close $fh;
318 0         0 return reject( "resetPwd: Cannot lock file: $!" );
319             };
320 2         52 my @lines = <$fh>;
321              
322 2         4 my $success = 0;
323 2         4 my @output;
324 2         4 for my $line ( @lines ) {
325 2 100       49 if ( $line =~ /^$id$sep/) {
326 1         6 push @output => join( $sep => $id, $phash, "|\n" );
327 1         1 $success++;
328 1         3 next;
329             }
330 1         4 push @output, $line;
331             }
332 2 50 33     296 unless (
      33        
      33        
333             seek($fh, 0, 0)
334             and truncate($fh, 0)
335             and print $fh @output
336             and close $fh
337             ) {
338 0         0 close $fh;
339 0         0 return reject( "resetPwd: File update failed: $!" );
340             }
341              
342 2 100       14 return reply($success, ($success ? $id : "ID $id not found to reset password") );
343             }
344              
345             ## Password file handling
346              
347             ## setFile: sets or changes the passwd file
348             ## creates the file if necessary
349             sub setFile {
350 7     7 1 9631 my $self = shift;
351 7         11 my $file = shift;
352              
353 7 100       36 return reject( "No filename" ) unless $file =~ /\S/;
354              
355 5 100       109 if (-e $file) {
356 3 50       94 open my $afh, "<", $file or
357             return reject( "Can't read auth file ($file). $!" );
358 3         27 close $afh;
359             } else {
360 2 50       222 open my $afh, ">", $file or
361             return reject( "Can't open/create auth file ($file). $!" );
362 2         25 close $afh;
363             }
364              
365 5 50       85 chmod 0600, $file or carp $!;
366              
367 5 50       19 if ( $self->validateFile($file) ) {
368 5         13 $self->{auth}->{file} = $file;
369 5         7 return confirm( "Valid file" );
370             }
371              
372 0         0 return reject( "Invalid file" );
373             }
374              
375             ## rmFile: deletes the passwd file
376             sub rmFile {
377 4     4 1 244 my $self = shift;
378              
379 4   100     16 my $pfile = $self->{auth}->{file} || '';
380 4 100 66     37 unless ( $pfile and -e $pfile ) {
381 2         6 return reject( "No valid file to remove" );
382             }
383              
384 2 50       300 unless (unlink $pfile) {
385 0         0 return reject( "Unable to unlink file: $! " );
386             }
387              
388 2         9 $self->{auth}->{file} = '';
389              
390 2         7 return reply( $pfile, "Password file removed" );
391             }
392              
393             sub clearFile {
394 2     2 1 1455 my $self = shift;
395              
396 2         9 my ($pfile,$msg) = $self->rmFile();
397 2 100       9 return reply( 0, "No valid file to clear: $msg" ) unless $pfile;
398              
399 1         5 my ($ok,$setmsg) = $self->setFile($pfile);
400 1 50       4 return reject( "File not cleared: $setmsg" ) unless $ok;
401 1         2 return confirm( "File cleared" );
402             }
403              
404             ## Utilities
405              
406             ## encryptPwd: returns encrypted password
407             sub encryptPwd {
408 8     8 1 986810 my $self = shift;
409 8         11 my $passwd = shift;
410              
411 8         19 my @vp = $self->validatePwd($passwd);
412 8 100       30 return reply( @vp ) unless $vp[0];
413              
414 6         42 return $self->{auth}->hash_password($passwd);
415             }
416              
417             ## pfile: returns the passwd file, if any
418             sub pfile {
419 13     13 1 5378 my $self = shift;
420             return defined $self->{auth}->{file}
421 13 100       50 ? reply($self->{auth}->{file}, "Auth file" )
422             : reject( "No auth file" );
423             }
424              
425             ## Generator method wrappers
426             ## These methods wrap the plain subroutines from Concierge::Auth::Generators
427             ## and return results using Auth.pm's reply response pattern
428              
429             sub gen_uuid {
430 1     1 1 258 my $self = shift;
431 1         6 my ($uuid, $msg) = Concierge::Auth::Generators::gen_uuid(@_);
432              
433 1 50       5 return defined $uuid
434             ? reply($uuid, $msg)
435             : reject("gen_uuid: Failed to generate UUID");
436             }
437              
438             sub gen_random_id {
439 2     2 1 2386 my $self = shift;
440 2         7 my ($id, $msg) = Concierge::Auth::Generators::gen_random_id(@_);
441              
442 2 50       6 return defined $id
443             ? reply($id, $msg)
444             : reject("gen_random_id: Failed to generate random ID");
445             }
446              
447             # Deprecated
448             sub gen_token {
449 1     1 0 1500 goto &gen_random_token;
450             }
451              
452             # Deprecated
453             sub gen_crypt_token {
454 1     1 0 1441 goto &gen_random_token;
455             }
456              
457             sub gen_random_token {
458 3     3 1 1416 my $self = shift;
459 3         11 my ($token, $msg) = Concierge::Auth::Generators::gen_random_token(@_);
460              
461 3 50       10 return defined $token
462             ? reply($token, $msg)
463             : reject("gen_random_token: Failed to generate random token");
464             }
465              
466             sub gen_random_string {
467 1     1 1 1469 my $self = shift;
468 1         5 my ($string, $msg) = Concierge::Auth::Generators::gen_random_string(@_);
469              
470 1 50       5 return defined $string
471             ? reply($string, $msg)
472             : reject("gen_random_string: Failed to generate random string");
473             }
474              
475             sub gen_word_phrase {
476 2     2 1 3211 my $self = shift;
477 2         7 my ($phrase, $msg) = Concierge::Auth::Generators::gen_word_phrase(@_);
478              
479 2 50       8 return defined $phrase
480             ? reply($phrase, $msg)
481             : reject("gen_word_phrase: Failed to generate word phrase");
482             }
483              
484             1;
485              
486             __END__