File Coverage

blib/lib/Concierge/Auth/Pwd.pm
Criterion Covered Total %
statement 184 205 89.7
branch 93 122 76.2
condition 51 81 62.9
subroutine 21 21 100.0
pod 12 15 80.0
total 361 444 81.3


line stmt bran cond sub pod time code
1             package Concierge::Auth::Pwd v0.5.0;
2 6     6   1558 use v5.36;
  6         20  
3              
4             # ABSTRACT: Password-file Concierge::Auth backend using Crypt::Passphrase
5              
6 6     6   29 use Carp qw/carp croak/;
  6         7  
  6         295  
7 6     6   23 use Fcntl qw/:flock/;
  6         7  
  6         647  
8 6     6   2699 use Crypt::Passphrase;
  6         66255  
  6         32  
9 6     6   231 use parent qw/Concierge::Auth::Base/;
  6         9  
  6         34  
10              
11             ## Constants for validation
12             use constant {
13 6         12644 MIN_ID_LENGTH => 2,
14             MAX_ID_LENGTH => 32,
15             MIN_PASSWORD_LENGTH => 8,
16             MAX_PASSWORD_LENGTH => 72, # bcrypt limit
17 6     6   406 };
  6         9  
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 20     20 1 50 my ($class, %args) = @_;
37              
38 20         134 my $self = bless {
39             auth => Crypt::Passphrase->new(
40             encoder => 'Argon2',
41             validators => [ 'Bcrypt' ],
42             )
43             }, $class;
44              
45 20 100       62172 if ($args{no_file}) {
46 11         1852 carp "Utilities only; no ID and password checks";
47             # Still functional:
48 11         78 return $self;
49             }
50 9 100       21 unless ($args{file}) {
51 1         93 carp "No auth file provided for ID and password checks";
52             # Still functional:
53 1         7 return $self;
54             }
55              
56 8 100       403 if (-e $args{file}) {
57             open my $afh, "<", $args{file} or
58 1 50       31 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       986 croak ("Can't open/create auth file ($args{file}). $! ");
63 6         81 close $afh;
64             }
65              
66 7 50       242 chmod 0600, $args{file} or carp $!;
67 7         59 $self->{auth}->{file} = $args{file};
68 7         33 $self;
69             }
70              
71             # =============================================================================
72             # CONTRACT METHODS (Concierge::Auth::Base)
73             # These are the methods Concierge calls, common to every Concierge::Auth
74             # backend. Each is self-contained: password-file I/O is written directly
75             # inline here, with no intermediate backend-primitive methods to hop
76             # through. ID/credential validation is inlined too, except where the same
77             # check is needed by more than one contract method (see validatePwd below).
78             #
79             # Full ID format policy (length, character set) is only enforced in
80             # enroll(), since that's the only place a *new* ID is established and
81             # needs to conform to storage policy going forward. The other four methods
82             # operate on an ID that either does or doesn't already exist on file, so a
83             # malformed-but-nonempty ID simply fails to match -- no separate rejection
84             # message is needed for it.
85             # =============================================================================
86              
87             ## validatePwd: checks password format constraints (length). Needed by
88             ## both enroll and change_credentials (each establishes a new credential
89             ## value), so kept as a shared utility rather than duplicated. Not used by
90             ## authenticate: a wrong-length submitted password simply fails to match
91             ## the stored hash, so a separate format check there would be redundant.
92 29     29 1 12415 sub validatePwd ($self, $password) {
  29         46  
  29         45  
  29         29  
93 29 100 100     121 return { success => 0, message => "Password cannot be empty" }
94             unless defined $password && length($password) > 0;
95 25 100 100     112 return { success => 0, message => sprintf(
96             "Password must be between %d and %d characters",
97             MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH
98             ) } unless length($password) >= MIN_PASSWORD_LENGTH
99             && length($password) <= MAX_PASSWORD_LENGTH;
100 19         47 return { success => 1 };
101             }
102              
103             ## authenticate: verifies a credential (password) for a user_id.
104             ## Pure check, no side effects.
105 8     8 1 2956608 sub authenticate ($self, $user_id, $credential) {
  8         13  
  8         11  
  8         10  
  8         9  
106 8 100 66     41 return { success => 0, message => "ID cannot be empty" }
107             unless defined $user_id && length($user_id) > 0;
108 7 50 33     27 return { success => 0, message => "Password cannot be empty" }
109             unless defined $credential && length($credential) > 0;
110              
111 7         12 my $sep = $FIELD_SEPARATOR;
112 7         21 my $pfile = $self->{auth}->{file};
113              
114 7 100       351 open my $pfh, "<", $pfile
115             or return { success => 0, message => "authenticate: Cannot open auth file: $!" };
116 6 50       74 flock($pfh, LOCK_SH) or do {
117 0         0 close $pfh;
118 0         0 return { success => 0, message => "authenticate: Cannot lock file for reading: $!" };
119             };
120 6         133 while (<$pfh>) {
121 5 100       157 if (/^\Q$user_id\E$sep([^$sep]+)$sep\|/) {
122 4         30 my $phash = $1;
123 4         59 close $pfh;
124 4 100       22 return $self->{auth}->verify_password($credential, $phash)
125             ? { success => 1 }
126             : { success => 0, message => "authenticate: Invalid password" };
127             }
128             }
129 2         21 close $pfh;
130 2         16 return { success => 0, message => "authenticate: User ID not found" };
131             }
132              
133             ## is_id_known: is user_id a known identity in the password file?
134             ## Empty/missing IDs and missing records/files are all simply "not known"
135             ## -- there is no failure branch for this backend, short of a genuine I/O
136             ## error on a file that does exist.
137 9     9 1 7083 sub is_id_known ($self, $user_id) {
  9         15  
  9         15  
  9         11  
138 9 50 33     66 return { success => 1, known => 0 }
139             unless defined $user_id && length($user_id) > 0;
140              
141 9         45 my $pfile = $self->{auth}->{file};
142 9 100 66     148 return { success => 1, known => 0 } unless $pfile && -e $pfile;
143              
144 7 50       204 open my $pfh, "<", $pfile
145             or return { success => 0, message => "is_id_known: Cannot open auth file: $!" };
146 7 50       46 flock($pfh, LOCK_SH) or do {
147 0         0 close $pfh;
148 0         0 return { success => 0, message => "is_id_known: Cannot lock file for reading: $!" };
149             };
150 7         13 my $sep = $FIELD_SEPARATOR;
151 7         166 while (<$pfh>) {
152 4 100       100 if (/^\Q$user_id\E$sep/) {
153 3         30 close $pfh;
154 3         27 return { success => 1, known => 1 };
155             }
156             }
157 4         36 close $pfh;
158 4         29 return { success => 1, known => 0 };
159             }
160              
161             ## enroll: establishes user_id as a known identity with the given
162             ## credential. $opts is accepted for interface compatibility with other
163             ## backends but unused here. Fails if the ID is already on file.
164 20     20 1 26446 sub enroll ($self, $user_id, $credential, $opts = undef) {
  20         59  
  20         31  
  20         31  
  20         32  
  20         26  
165 20 100 66     118 return { success => 0, message => "ID cannot be empty" }
166             unless defined $user_id && length($user_id) > 0;
167 18 100 100     84 return { success => 0, message => sprintf(
168             "ID must be between %d and %d characters",
169             MIN_ID_LENGTH, MAX_ID_LENGTH
170             ) } unless length($user_id) >= MIN_ID_LENGTH
171             && length($user_id) <= MAX_ID_LENGTH;
172 16 100       189 return { success => 0, message => "ID contains invalid characters" }
173             unless $user_id =~ $ID_ALLOWED_CHARS;
174              
175 12         56 my $vp = $self->validatePwd($credential);
176 12 100       36 return $vp unless $vp->{success};
177              
178 10         59 my $sep = $FIELD_SEPARATOR;
179 10         33 my $pfile = $self->{auth}->{file};
180              
181 10 50 33     228 if ($pfile && -e $pfile) {
182 10 50       356 open my $chfh, "<", $pfile
183             or return { success => 0, message => "enroll: Cannot open auth file: $!" };
184 10 50       99 flock($chfh, LOCK_SH) or do {
185 0         0 close $chfh;
186 0         0 return { success => 0, message => "enroll: Cannot lock file for reading: $!" };
187             };
188 10         288 while (<$chfh>) {
189 22 100       348 if (/^\Q$user_id\E$sep/) {
190 1         12 close $chfh;
191 1         10 return { success => 0, message => "ID $user_id previously used" };
192             }
193             }
194 9         127 close $chfh;
195             }
196              
197 9         63 my $phash = $self->{auth}->hash_password($credential);
198              
199 9 50       9240648 open my $pfh, ">>", $pfile
200             or return { success => 0, message => "enroll: Cannot open auth file: $!" };
201 9 50       142 flock($pfh, LOCK_EX) or do {
202 0         0 close $pfh;
203 0         0 return { success => 0, message => "enroll: Cannot lock file for writing: $!" };
204             };
205 9 50       175 print $pfh join( $sep => $user_id, $phash, "|\n") or do {
206 0         0 close $pfh;
207 0         0 return { success => 0, message => "enroll: Cannot write to file: $!" };
208             };
209 9 50       536 close $pfh or return { success => 0, message => "enroll: Cannot close file: $!" };
210              
211 9         173 return { success => 1, user_id => $user_id, status => 'created' };
212             }
213              
214             ## change_credentials: replaces the credential on file for an existing
215             ## user_id. Fails if the ID is not known.
216 7     7 1 976641 sub change_credentials ($self, $user_id, $new_credential) {
  7         24  
  7         9  
  7         9  
  7         10  
217 7 100 66     36 return { success => 0, message => "ID cannot be empty" }
218             unless defined $user_id && length($user_id) > 0;
219              
220 6         16 my $vp = $self->validatePwd($new_credential);
221 6 100       18 return $vp unless $vp->{success};
222              
223 4         7 my $sep = $FIELD_SEPARATOR;
224 4   100     15 my $pfile = $self->{auth}->{file} || '';
225              
226 4 50 66     95 return { success => 0, message => "Auth file not OK" }
      66        
227             unless $pfile && -e $pfile && -r $pfile;
228              
229 2         11 my $phash = $self->{auth}->hash_password($new_credential);
230              
231 2 50       1949216 open my $fh, "+<", $pfile
232             or return { success => 0, message => "change_credentials: Cannot open file: $!" };
233 2 50       43 flock($fh, LOCK_EX) or do {
234 0         0 close $fh;
235 0         0 return { success => 0, message => "change_credentials: Cannot lock file: $!" };
236             };
237 2         53 my @lines = <$fh>;
238              
239 2         5 my $success = 0;
240 2         5 my @output;
241 2         9 for my $line ( @lines ) {
242 2 100       83 if ( $line =~ /^\Q$user_id\E$sep/) {
243 1         6 push @output => join( $sep => $user_id, $phash, "|\n" );
244 1         2 $success++;
245 1         4 next;
246             }
247 1         5 push @output, $line;
248             }
249 2 50 33     330 unless (
      33        
      33        
250             seek($fh, 0, 0)
251             and truncate($fh, 0)
252             and print $fh @output
253             and close $fh
254             ) {
255 0         0 close $fh;
256 0         0 return { success => 0, message => "change_credentials: File update failed: $!" };
257             }
258              
259 2 100       39 return $success
260             ? { success => 1, user_id => $user_id }
261             : { success => 0, message => "ID $user_id not found to reset password" };
262             }
263              
264             ## revoke: removes user_id as a known identity. Symmetric with enroll.
265             ## No ID format policy check here -- revoke operates on an existing ID, so
266             ## a malformed-but-nonempty ID just fails to match any record on file.
267 4     4 1 2109 sub revoke ($self, $user_id) {
  4         5  
  4         5  
  4         5  
268 4 100 66     28 return { success => 0, message => "ID cannot be empty" }
269             unless defined $user_id && length($user_id) > 0;
270              
271 3         4 my $sep = $FIELD_SEPARATOR;
272 3   100     12 my $pfile = $self->{auth}->{file} || '';
273              
274 3 100 66     44 return { success => 0, message => "File $pfile no good" }
275             unless $pfile && -e $pfile;
276              
277 2 50       67 open my $fh, "+<", $pfile
278             or return { success => 0, message => "revoke: Cannot open file: $!" };
279 2 50       15 flock($fh, LOCK_EX) or do {
280 0         0 close $fh;
281 0         0 return { success => 0, message => "revoke: Cannot lock file: $!" };
282             };
283 2         32 my @lines = <$fh>;
284              
285 2         4 my $success = 0;
286 2         28 my @output;
287 2         6 for my $line ( @lines ) {
288 1 50       17 if ( $line =~ /^\Q$user_id\E$sep/) {
289 1         2 $success++;
290 1         2 next;
291             }
292 0         0 push @output, $line;
293             }
294 2 50 33     104 unless (
      33        
      33        
295             seek($fh, 0, 0)
296             and truncate($fh, 0)
297             and print $fh @output
298             and close $fh
299             ) {
300 0         0 close $fh;
301 0         0 return { success => 0, message => "revoke: File update failed: $!" };
302             }
303              
304 2 100       22 return $success
305             ? { success => 1, user_id => $user_id }
306             : { success => 0, message => "ID $user_id not found to delete" };
307             }
308              
309             # =============================================================================
310             # BACKEND-SPECIFIC METHODS
311             # Everything below is specific to how the password-file backend satisfies
312             # the contract above. These are not part of Concierge::Auth::Base and other
313             # backends (e.g. an LDAP backend) are not expected to implement them.
314             # =============================================================================
315              
316             ## Class Methods for Responses
317             ## confirm, reject, reply
318             ## NOT called with object arrow notation:
319             ## $self->reject # !WRONG
320             ## Once instantiated, the auth object will not die/croak;
321             ## Instead, all methods that check or validate respond with
322             ## `confirm ($msg)` # wantarray ? (1, $msg) : 1;
323             ## or
324             ## `reject ($msg)` # wantarray ? (0, $msg) : 0;
325             ## or the more general
326             ## `reply ($bool, $msg)` # wantarray ? ($bool, $msg) : $bool
327             ## Use explicit `return` to assure correct contrl flow:
328             ## `return confirm($msg);`
329             ## `return reply( $result, $msg);`
330             sub confirm {
331 7   100 7 0 1574 my $message = shift || "Auth confirmation";
332 7 100       31 wantarray ? (1, $message) : 1;
333             }
334             sub reject {
335 8   100 8 0 1632 my $message = shift || "Auth rejection";
336 8 100       31 wantarray ? (0, $message) : 0;
337             }
338             ## First arg is 1|0 or other Perl true/false value
339             sub reply {
340 8   100 8 0 2432 my $bool = shift // 0;
341 8   66     27 my $message = shift || ( $bool ? "Auth confirmation" : "Auth rejection" );
342 8 50       30 wantarray ? ($bool, $message) : $bool;
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 8088 my $self = shift;
351 7         10 my $file = shift;
352              
353 7 100       35 return reject( "No filename" ) unless $file =~ /\S/;
354              
355 5 100       114 if (-e $file) {
356 3 50       106 open my $afh, "<", $file or
357             return reject( "Can't read auth file ($file). $!" );
358 3         32 close $afh;
359             } else {
360 2 50       276 open my $afh, ">", $file or
361             return reject( "Can't open/create auth file ($file). $!" );
362 2         26 close $afh;
363             }
364              
365 5 50       103 chmod 0600, $file or carp $!;
366              
367 5 50 33     84 if ( -e $file && -r $file ) {
368 5         15 $self->{auth}->{file} = $file;
369 5         12 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 246 my $self = shift;
378              
379 4   100     15 my $pfile = $self->{auth}->{file} || '';
380 4 100 66     44 unless ( $pfile and -e $pfile ) {
381 2         3 return reject( "No valid file to remove" );
382             }
383              
384 2 50       203 unless (unlink $pfile) {
385 0         0 return reject( "Unable to unlink file: $! " );
386             }
387              
388 2         14 $self->{auth}->{file} = '';
389              
390 2         6 return reply( $pfile, "Password file removed" );
391             }
392              
393             sub clearFile {
394 2     2 1 422 my $self = shift;
395              
396 2         6 my ($pfile,$msg) = $self->rmFile();
397 2 100       12 return reply( 0, "No valid file to clear: $msg" ) unless $pfile;
398              
399 1         4 my ($ok,$setmsg) = $self->setFile($pfile);
400 1 50       5 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 3     3 1 964368 my $self = shift;
409 3         6 my $passwd = shift;
410              
411 3         10 my $vp = $self->validatePwd($passwd);
412 3 100       9 return reject( $vp->{message} ) unless $vp->{success};
413              
414 1         5 return $self->{auth}->hash_password($passwd);
415             }
416              
417             ## pfile: returns the passwd file, if any
418             sub pfile {
419 3     3 1 5708 my $self = shift;
420             return defined $self->{auth}->{file}
421 3 100       11 ? reply($self->{auth}->{file}, "Auth file" )
422             : reject( "No auth file" );
423             }
424              
425             # Generator methods (gen_uuid, gen_random_id, gen_random_token,
426             # gen_random_string, gen_word_phrase, gen_token, gen_crypt_token) are
427             # NOT defined here -- they are inherited as working defaults from
428             # Concierge::Auth::Base, which delegates to Concierge::Auth::Generators.
429             # See L.
430              
431             1;
432              
433             __END__