File Coverage

blib/lib/Concierge/Auth.pm
Criterion Covered Total %
statement 191 216 88.4
branch 93 130 71.5
condition 38 65 58.4
subroutine 28 30 93.3
pod 19 24 79.1
total 369 465 79.3


line stmt bran cond sub pod time code
1             package Concierge::Auth v0.4.3;
2 6     6   1191690 use v5.36;
  6         21  
3              
4             # ABSTRACT: Concierge authorization using Crypt::Passphrase
5              
6 6     6   37 use Carp qw/carp croak/;
  6         19  
  6         328  
7 6     6   30 use Fcntl qw/:flock/;
  6         11  
  6         725  
8 6     6   2919 use Crypt::Passphrase;
  6         138655  
  6         40  
9 6     6   251 use parent qw/Concierge::Auth::Generators/;
  6         10  
  6         32  
10              
11             ## Constants for validation
12             use constant {
13 6         18578 MIN_ID_LENGTH => 2,
14             MAX_ID_LENGTH => 32,
15             MIN_PASSWORD_LENGTH => 8,
16             MAX_PASSWORD_LENGTH => 72, # bcrypt limit
17 6     6   402 };
  6         12  
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 12     12 1 643329 my ($class, $args) = @_;
37              
38 12         104 my $self = bless {
39             auth => Crypt::Passphrase->new(
40             encoder => 'Argon2',
41             validators => [ 'Bcrypt' ],
42             )
43             }, $class;
44              
45 12 100       70036 if ($args->{no_file}) {
46 4         811 carp "Utilities only; no ID and password checks";
47             # Still functional:
48 4         43 return $self;
49             }
50 8 100       36 unless ($args->{file}) {
51 1         114 carp "No auth file provided for ID and password checks";
52             # Still functional:
53 1         6 return $self;
54             }
55              
56 7 50       416 if (-e $args->{file}) {
57             open my $afh, "<", $args->{file} or
58 0 0       0 croak ("Can't read auth file ($args->{file}). $! ");
59 0         0 close $afh;
60             } else {
61             open my $afh, ">", $args->{file} or
62 7 100       896 croak ("Can't open/create auth file ($args->{file}). $! ");
63 6         79 close $afh;
64             }
65              
66 6 50       203 chmod 0600, $args->{file} or carp $!;
67 6         68 $self->{auth}->{file} = $args->{file};
68 6         36 $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 71   100 71 0 262 my $message = shift || "Auth confirmation";
87 71 100       370 wantarray ? (1, $message) : 1;
88             }
89             sub reject {
90 36   50 36 0 124 my $message = shift || "Auth rejection";
91 36 100       205 wantarray ? (0, $message) : 0;
92             }
93             ## First arg is 1|0 or other Perl true/false value
94             sub reply {
95 30   50 30 0 103 my $bool = shift // 0;
96 30   33     75 my $message = shift || ( $bool ? "Auth confirmation" : "Auth rejection" );
97 30 100       199 wantarray ? ($bool, $message) : $bool;
98             }
99              
100             ## Validations
101             sub validateID {
102 47     47 1 18225 my ($self, $id) = @_;
103              
104 47 100 100     247 return reject( "ID cannot be empty" )
105             unless (defined $id && length($id) > 0);
106              
107             # Check length constraints
108 40 100 100     171 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 37 100       378 return reject( "ID contains invalid characters" )
119             unless ($id =~ $ID_ALLOWED_CHARS);
120              
121 33         89 return confirm;
122             }
123              
124             sub validatePwd {
125 27     27 1 8255 my ($self, $password) = @_;
126              
127             # Check if password is defined and not empty
128 27 100 100     116 return reject( "Password cannot be empty" )
129             unless defined $password && length($password) > 0;
130              
131             # Check length constraints
132 24 100 100     90 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 19         35 return confirm;
142             }
143              
144             sub validateFile {
145 10     10 1 4485 my ($self, $file) = @_;
146 10   66     40 $file ||= $self->{auth}->{file};
147 10 100 66     308 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 10     10 1 10933 my $self = shift;
155 10         21 my $id = shift;
156              
157 10         71 my ($ok,$msg) = $self->validateID($id);
158 10 100       28 return reject( $msg ) unless $ok;
159              
160 9         28 my $pfile = $self->pfile();
161 9 50       212 -e $pfile
162             or return reject( "checkID: No auth file");
163 9 50       368 open my $pfh, "<", $pfile
164             or return reject( "checkID: Can't read auth file" );
165 9 50       96 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         48 my $sep = $FIELD_SEPARATOR;
170 9         277 while (<$pfh>) {
171 5 100       154 if (/^$id$sep/) {
172 4         90 close $pfh;
173 4         18 return confirm( "ID OK" );
174             }
175             }
176 5         88 close $pfh;
177 5         32 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 3     3 1 3247 my $self = shift;
183 3         8 my $id = shift;
184              
185 3         12 my @id = $self->validateID($id);
186 3 100       12 return reply( @id ) unless $id[0];
187              
188 2         5 my $sep = $FIELD_SEPARATOR;
189 2   50     10 my $pfile = $self->{auth}->{file} || '';
190 2 50 33     55 unless ( $pfile and -e $pfile ) {
191 0         0 return reject( "File $pfile no good" );
192             }
193 2 50       88 open my $fh, "+<", $pfile or return reject( "Cannot open file: $!" );
194 2 50       20 flock($fh, LOCK_EX) or do {
195 0         0 close $fh;
196 0         0 return reject( "Cannot lock file: $!" );
197             };
198 2         33 my @lines = <$fh>;
199              
200 2         5 my $success = 0;
201 2         4 my @output;
202 2         7 for my $line ( @lines ) {
203 1 50       24 if ( $line =~ /^$id$sep/) {
204 1         4 $success++;
205 1         2 next;
206             }
207 0         0 push @output, $line;
208             }
209 2 50 33     170 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       13 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 7     7 1 10926 my $self = shift;
226 7         29 my $id = shift;
227 7         15 my $passwd = shift;
228              
229 7         34 my @id = $self->validateID($id);
230 7 100       25 return reply( @id ) unless $id[0];
231 6         23 my @pwd = $self->validatePwd($passwd);
232 6 100       21 return reply( @pwd ) unless $pwd[0];
233              
234 5         14 my $sep = $FIELD_SEPARATOR;
235 5         20 my $pfile = $self->{auth}->{file};
236              
237 5 50       312 open my $pfh, "<", $pfile
238             or return reject( "checkPwd: Cannot open auth file: $!" );
239 5 50       77 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         141 while (<$pfh>) {
244 5 100       155 if (/^$id$sep([^$sep]+)$sep\|/) {
245 4         20 my $phash = $1;
246 4         62 close $pfh;
247             # uses Crypt::Passphrase::verify_password
248 4 100       31 if ($self->{auth}->verify_password($passwd,$phash)) {
249 2         2557597 return confirm;
250             } else {
251 2         2574548 return reject( "checkPwd: Invalid password" );
252             }
253             }
254             }
255 1         18 close $pfh;
256 1         7 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 5     5 1 14198 my $self = shift;
266 5   100     23 my $id = shift || '';
267 5   50     32 my $passwd = shift || '';
268              
269 5         20 my @id = $self->validateID($id);
270 5 100       16 return reply( @id ) unless $id[0];
271 4         16 my @pwd = $self->validatePwd($passwd);
272 4 100       12 return reply( @pwd ) unless $pwd[0];
273 3         11 my @chk = $self->checkID($id);
274 3 100       15 return reject( "ID $id previously used" ) if $chk[0];
275              
276 2         9 my $phash = $self->encryptPwd($passwd);
277 2         2941885 my $sep = $FIELD_SEPARATOR;
278 2         32 my $pfile = $self->{auth}->{file};
279              
280 2 50       165 open my $pfh, ">>", $pfile
281             or return reject( "setPwd: Cannot open auth file: $!" );
282 2 50       31 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       47 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       151 close $pfh or return reject( "setPwd: Cannot close file: $!" );
291 2         16 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 4     4 1 6398 my $self = shift;
300 4   100     20 my $id = shift || '';
301 4   50     14 my $passwd = shift || '';
302              
303 4         15 my @id = $self->validateID($id);
304 4 100       20 return reply( @id ) unless $id[0];
305 3         11 my @pwd = $self->validatePwd($passwd);
306 3 100       15 return reply( @pwd ) unless $pwd[0];
307              
308 2         10 my $phash = $self->encryptPwd($passwd);
309              
310 2         2489854 my $sep = $FIELD_SEPARATOR;
311 2   50     26 my $pfile = $self->{auth}->{file} || '';
312 2         18 my @f = $self->validateFile($pfile);
313 2 50       7 return reply( @f ) unless $f[0];
314              
315 2 50       173 open my $fh, "+<", $pfile or return reject( "resetPwd: Cannot open file: $!" );
316 2 50       29 flock($fh, LOCK_EX) or do {
317 0         0 close $fh;
318 0         0 return reject( "resetPwd: Cannot lock file: $!" );
319             };
320 2         73 my @lines = <$fh>;
321              
322 2         8 my $success = 0;
323 2         5 my @output;
324 2         6 for my $line ( @lines ) {
325 2 100       67 if ( $line =~ /^$id$sep/) {
326 1         5 push @output => join( $sep => $id, $phash, "|\n" );
327 1         3 $success++;
328 1         4 next;
329             }
330 1         5 push @output, $line;
331             }
332 2 50 33     451 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       20 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 3     3 1 2012 my $self = shift;
351 3         5 my $file = shift;
352              
353 3 50       19 return reject( "No filename" ) unless $file =~ /\S/;
354              
355 3 100       114 if (-e $file) {
356 1 50       31 open my $afh, "<", $file or
357             return reject( "Can't read auth file ($file). $!" );
358 1         12 close $afh;
359             } else {
360 2 50       268 open my $afh, ">", $file or
361             return reject( "Can't open/create auth file ($file). $!" );
362 2         31 close $afh;
363             }
364              
365 3 50       70 chmod 0600, $file or carp $!;
366              
367 3 50       13 if ( $self->validateFile($file) ) {
368 3         9 $self->{auth}->{file} = $file;
369 3         5 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 2     2 1 246 my $self = shift;
378              
379 2   50     10 my $pfile = $self->{auth}->{file} || '';
380 2 50 33     47 unless ( $pfile and -e $pfile ) {
381 0         0 return reject( "No valid file to remove" );
382             }
383              
384 2 50       335 unless (unlink $pfile) {
385 0         0 return reject( "Unable to unlink file: $! " );
386             }
387              
388 2         10 $self->{auth}->{file} = '';
389              
390 2         6 return reply( $pfile, "Password file removed" );
391             }
392              
393             sub clearFile {
394 1     1 1 543 my $self = shift;
395              
396 1         5 my ($pfile,$msg) = $self->rmFile();
397 1 50       6 return reply( 0, "No valid file to clear: $msg" ) unless $pfile;
398              
399 1         5 my ($ok,$setmsg) = $self->setFile($pfile);
400 1 50       6 return reject( "File not cleared: $setmsg" ) unless $ok;
401 1         3 return confirm( "File cleared" );
402             }
403              
404             ## Utilities
405              
406             ## encryptPwd: returns encrypted password
407             sub encryptPwd {
408 4     4 1 8 my $self = shift;
409 4         6 my $passwd = shift;
410              
411 4         11 my @vp = $self->validatePwd($passwd);
412 4 50       10 return reply( @vp ) unless $vp[0];
413              
414 4         29 return $self->{auth}->hash_password($passwd);
415             }
416              
417             ## pfile: returns the passwd file, if any
418             sub pfile {
419 12     12 1 5674 my $self = shift;
420             return defined $self->{auth}->{file}
421 12 100       59 ? 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 310 my $self = shift;
431 1         5 my ($uuid, $msg) = Concierge::Auth::Generators::gen_uuid(@_);
432              
433 1 50       19 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 3214 my $self = shift;
440 2         11 my ($id, $msg) = Concierge::Auth::Generators::gen_random_id(@_);
441              
442 2 50       9 return defined $id
443             ? reply($id, $msg)
444             : reject("gen_random_id: Failed to generate random ID");
445             }
446              
447             sub gen_token {
448 0     0 0 0 goto &gen_random_token;
449             }
450              
451             sub gen_crypt_token {
452 0     0 0 0 goto &gen_random_token;
453             }
454              
455             sub gen_random_token {
456 1     1 1 2512 my $self = shift;
457 1         7 my ($token, $msg) = Concierge::Auth::Generators::gen_random_token(@_);
458              
459 1 50       9 return defined $token
460             ? reply($token, $msg)
461             : reject("gen_random_token: Failed to generate random token");
462             }
463              
464             sub gen_random_string {
465 1     1 1 2593 my $self = shift;
466 1         6 my ($string, $msg) = Concierge::Auth::Generators::gen_random_string(@_);
467              
468 1 50       8 return defined $string
469             ? reply($string, $msg)
470             : reject("gen_random_string: Failed to generate random string");
471             }
472              
473             sub gen_word_phrase {
474 1     1 1 3355 my $self = shift;
475 1         7 my ($phrase, $msg) = Concierge::Auth::Generators::gen_word_phrase(@_);
476              
477 1 50       7 return defined $phrase
478             ? reply($phrase, $msg)
479             : reject("gen_word_phrase: Failed to generate word phrase");
480             }
481              
482             1;
483              
484             __END__