File Coverage

blib/lib/Data/Transpose/PasswordPolicy.pm
Criterion Covered Total %
statement 177 179 98.8
branch 73 80 91.2
condition 14 21 66.6
subroutine 24 24 100.0
pod 14 14 100.0
total 302 318 94.9


line stmt bran cond sub pod time code
1             package Data::Transpose::PasswordPolicy;
2              
3 5     5   29028 use 5.010001;
  5         18  
4 5     5   69 use strict;
  5         11  
  5         157  
5 5     5   26 use warnings;
  5         16  
  5         185  
6             # use Data::Dumper;
7              
8 5     5   824 use Moo;
  5         17902  
  5         37  
9             extends 'Data::Transpose::Validator::Base';
10 5     5   5393 use MooX::Types::MooseLike::Base qw(:all);
  5         9007  
  5         2367  
11 5     5   1068 use namespace::clean;
  5         14538  
  5         39  
12              
13             our $VERSION = '0.02';
14              
15             =head1 NAME
16              
17             Data::Transpose::PasswordPolicy - Perl extension to enforce password policy
18              
19             =head1 SYNOPSIS
20              
21             use Data::Transpose::PasswordPolicy;
22              
23             my %credentials = (username => "marco",
24             password => "My.very.very.5strong.pzwd"
25             );
26              
27             my $pv = Data::Transpose::PasswordPolicy->new(\%credentials)
28            
29             if (my $password = $pv->is_valid) {
30             print "$password is OK";
31             }
32             else {
33             die $pv->error
34             }
35              
36              
37              
38             =head1 DESCRIPTION
39              
40             This module enforces the password policy, doing a number of checking.
41             The author reccomends to use passphrases instead of password, using
42             some special character (like punctuation) as separator, with 4-5
43             words in mixed case and with numbers as a good measure.
44              
45             You can add the policy to the constructor, where C is the
46             minimum password length, C is the maximum password and
47             C is the minimum number of different characters in the
48             password. Read below for C
49              
50             By default all checkings are enabled. If you want to configure the
51             policy, pass an hashref assigning to the disabled checking a true
52             value. This will leave only the length checks in place, which you can
53             tweak with the accessors. For example:
54              
55              
56              
57              
58             my %validate = ( username => "marco",
59             password => "ciao",
60             minlength => 10,
61             maxlength => 50,
62             patternlength => 4,
63             mindiffchars => 5,
64             disabled => {
65             digits => 1,
66             mixed => 1,
67             }
68             my $pv = Data::Transpose::PasswordPolicy->new(\%validate)
69             $pv->is_valid ? "OK" : "not OK";
70              
71              
72             See below for the list of the available checkings.
73              
74             B: the purpose of this module is not to try to crack the
75             password provided, but to set a policy for the passwords, which should
76             have some minimum standards, and could be used on web services to stop
77             users to set trivial password (without keeping the server busy for
78             seconds while we check it). Nothing more.
79              
80             =cut
81              
82             =head1 METHODS
83              
84             =cut
85              
86             =head2 new(%credentials)
87              
88             Create a new Data::Transpose::PasswordPolicy object using the
89             credentials provided to the constructor.
90              
91             =cut
92              
93             has username => (is => 'rw',
94             isa => Str);
95              
96             has password => (is => 'rw',
97             isa => Str);
98              
99             around password => \&_strip_space_on_around;
100             around username => \&_strip_space_on_around;
101              
102             sub _strip_space_on_around {
103 3550     3550   74422 my $orig = shift;
104 3550         54977 my $ret = $orig->(@_);
105 3550 50       18635 if (not defined $ret) {
106 0         0 return '';
107             }
108             else {
109 3550         8758 $ret =~ s/^\s*//s;
110 3550         18371 $ret =~ s/\s*$//s;
111 3550         23588 return $ret;
112             }
113             }
114              
115              
116             has maxlength => (is => 'rw',
117             isa => Int,
118             default => sub { 255 },
119             );
120              
121             has minlength => (is => 'rw',
122             isa => Int,
123             default => sub { 12 },
124             );
125              
126              
127             has mindiffchars => (is => 'rw',
128             isa => Int,
129             default => sub { 6 },
130             );
131              
132             has patternlength => (is => 'rw',
133             isa => Int,
134             default => sub { 3 },
135             );
136              
137             has disabled => (is => 'rw',
138             isa => HashRef,
139             default => sub { {} });
140              
141              
142             =head1 ACCESSORS
143              
144             =head2 $obj->password($newpassword)
145              
146             Set and return the new password. If no argument is provided, returns
147             the current. It will strip leading and trailing spaces.
148              
149             =head2 $obj->username($username)
150              
151             Set and return the new username. If no argument is provided, returns
152             the current. It will strip leading and trailing spaces.
153              
154             =head2 $obj->password_length
155              
156             It returns the length of the password;
157              
158             =cut
159              
160             sub password_length {
161 737     737 1 2828 my $self = shift;
162 737         14237 return length($self->password);
163             }
164              
165             =head2 $obj->minlength
166              
167             Returns the minimum length required. If a numeric argument is
168             provided, set that limit. Defaults to 255;
169              
170             =head2 $obj->maxlength
171              
172             As above, but for the maximum. Defaults to 12;
173              
174             =head2 $obj->mindiffchars
175              
176             As above, but set the minimum of different characters (to avoid things like
177             00000000000000000ciao00000000000.
178              
179             Defaults to 6;
180              
181             =head2 $obj->patternlength
182              
183             As above, but set the length of the common patterns we will search in
184             the password, like "abcd", or "1234", or "asdf". By default it's 3, so
185             a password which merely contains "abc" will be discarded.
186              
187             This option can also be set in the constructor.
188              
189             =head1 Internal algorithms
190              
191             All the following methods operate on $obj->password and return the
192             message of the error if something if not OK, while returning false if
193             nothing suspicious was found.
194              
195             =head2 password_length_ok
196              
197             Check if the password is in the range of permitted lengths. Return
198             undef if the validation passes, otherwise the arrayref with the error
199             code and the error string.
200              
201             =cut
202              
203              
204             sub password_length_ok {
205 253     253 1 318 my $self = shift;
206 253 100 100     393 if (($self->password_length >= $self->minlength) and
207             ($self->password_length <= $self->maxlength)) {
208 245         2825 return undef;
209             } else {
210 8   50     2135 my $min = $self->minlength || 0;
211 8   50     131 my $max = $self->maxlength || 0;
212 8   50     1869 my $cur = $self->password_length || 0;
213 8 100       25 if ($cur < $min) {
214 7         62 return ["length" => "Wrong length (it should be long at least $min characters)"];
215             }
216             else {
217 1         6 return ["length" => "Password too long (max allowed $max)"];
218             }
219             }
220             }
221              
222              
223              
224              
225             my %leetperms = (
226             'a' => qr{[4a]},
227             'b' => qr{[8b]},
228             'c' => "c",
229             'd' => "d",
230             'e' => qr{[3e]},
231             'f' => "f",
232             'g' => "g",
233             'h' => "h",
234             'i' => qr{[1i]},
235             'j' => "j",
236             'k' => "k",
237             'l' => qr{[l1]},
238             'm' => "m",
239             'n' => "n",
240             'o' => qr{[0o]},
241             'p' => "p",
242             'q' => "q",
243             'r' => "r",
244             's' => qr{[5s\$]},
245             't' => "t",
246             'u' => "u",
247             'v' => "v",
248             'w' => "w",
249             'x' => "x",
250             'y' => "y",
251             'z' => "z",
252             '0' => qr{[o0]},
253             '1' => qr{[l1]},
254             '2' => "2",
255             '3' => qr{[e3]},
256             '4' => qr{[4a]},
257             '5' => qr{[5s]},
258             '6' => "6",
259             '7' => qr{[7t]},
260             '8' => qr{[8b]},
261             '9' => "9",
262             );
263              
264             my @toppassword = ( 'password', 'link', '1234', 'work', 'god', 'job',
265             'angel', 'ilove', 'sex', 'jesus', 'connect',
266             'f*ck', 'fu*k', 'monkey', 'master', 'bitch', 'dick',
267             'micheal', 'jordan', 'dragon', 'soccer', 'killer',
268             '4321', 'pepper', 'career', 'princess' );
269              
270              
271             =head2 password_has_username
272              
273             Check if the password contains the username, even if obfuscated.
274              
275             Disable keyword: C
276              
277             =cut
278              
279              
280             # check if the password doesn't contain the username
281             sub password_has_username {
282 220     220 1 231 my $self = shift;
283 220 50       4296 return [ username => "Missing username" ] unless $self->username;
284              
285 220         4335 my $match = _leet_string_match($self->password, $self->username);
286 220 100       422 if ($match) {
287 2         11 return [ username => "Found username $match in password" ];
288             } else {
289             return undef
290 218         676 }
291             }
292              
293             =head2 password_has_common_password
294              
295             Check if the password contains, even obfuscated, common password like
296             "password" et similia.
297              
298             Disable keyword: C
299              
300             =cut
301              
302              
303             # check if the password is in the top ten :-)
304             sub password_has_common_password {
305 234     234 1 243 my $self = shift;
306 234         193 my @found;
307 234         4515 my $password = $self->password;
308 234         484 foreach my $common (@toppassword) {
309 6084 100       7886 if (_leet_string_match($password, $common)) {
310 7         18 push @found, $common;
311             }
312             }
313 234 100       454 if (@found) {
314             # warn join(" ", @found) . "\n";
315 6         35 return [ common => "Found common password" ];
316             }
317             else {
318 228         879 return undef;
319             }
320             }
321              
322             sub _leet_string_match {
323 6304     6304   7193 my ($string, $match) = @_;
324 6304 50 33     20423 return "Missing parameter" unless ($string and $match);
325              
326 6304         6660 my $lcstring = lc($string); # the password
327 6304         5307 my $lcmatch = lc($match); # the check
328 6304         11966 my @chars = split(//, $lcmatch); # split the match
329              
330             # for each character we look up the regexp or .
331 6304         4618 my @regexps;
332 6304         6688 foreach my $c (@chars) {
333 32694 100       37750 if (exists $leetperms{$c}) {
334 32226         38104 push @regexps, $leetperms{$c};
335             } else {
336 468         505 push @regexps, "."; # unknown character
337             }
338             }
339             # then we join it
340 6304         9826 my $re = join("", @regexps);
341             # and use it as re against the provided string
342             # warn "checking $lcstring against $re\n";
343 6304 100       69261 if ($lcstring =~ m/$re/i) {
344             # warn $re . "\n";
345             # return false if the re is present in the string
346 9         42 return $lcmatch
347             } else {
348 6295         20994 return undef;
349             }
350             }
351              
352              
353              
354             =head2 password_has_enough_different_char
355              
356             Check if the password has enough different characters.
357              
358             Disable keyword: C
359              
360             =cut
361              
362              
363             sub password_has_enough_different_char {
364 234     234 1 281 my $self = shift;
365 234         226 my %found;
366 234         4489 my @chars = split //, $self->password;
367 234         280 my %consecutives;
368 234         261 my $previous = "";
369 234         347 foreach my $c (@chars) {
370 9079         8652 $found{$c}++;
371            
372             # check previous char
373 9079 100       12151 if ($previous eq $c) {
374 305         383 $consecutives{$c}++;
375             }
376 9079         7665 $previous = $c;
377             }
378             # print Dumper(\%found);
379              
380             # check the number of chars
381 234         372 my $totalchar = scalar(keys(%found));
382 234 100       4657 if ($totalchar <= $self->mindiffchars) {
383 8         2039 return [ varchars => "Not enough different characters" ];
384             }
385              
386 226         2247 my %reportconsec;
387             # check the consecutive chars;
388 226         734 while (my ($k, $v) = each %consecutives) {
389 221 100       1001 if ($v > 2) {
390 7         33 $reportconsec{$k} = $v + 1;
391             }
392             }
393              
394 226 100       419 if (%reportconsec) {
395             # we see if subtracting the number of total repetition, we are
396             # still above the minimum chars.
397 6         17 my $passwdlen = $self->password_length;
398 6         22 foreach my $rep (values %reportconsec) {
399 7         16 $passwdlen = $passwdlen - $rep;
400             }
401 6 100       159 if ($passwdlen < $self->minlength) {
402 3         32 my $errstring = "Found too many repetitions, "
403             . "lowering the effectivelength: "
404             . (join(", ", (keys %reportconsec)));
405 3         27 return [ varchars => $errstring ];
406             }
407             }
408              
409             # given we have enough different characters, we check also there
410             # are not some characters which are repeated too many times;
411             # max dimension is 1/3 of the password
412 223         448 my $maxrepeat = int($self->password_length / 3);
413             # now get the hightest value;
414 223         278 my $max = 0;
415 223         647 foreach my $v (values %found) {
416 4726 100       6949 $max = $v if ($v > $max);
417             }
418 223 100       373 if ($max > $maxrepeat) {
419 3         62 return [ varchars => "Found too many repetitions" ];
420             }
421 220         1831 return undef;
422             }
423              
424             =head2 password_has_mixed_chars
425              
426             Check if the password has mixed cases
427              
428             Disable keyword: C
429              
430             =cut
431              
432              
433             sub password_has_mixed_chars {
434 224     224 1 229 my $self = shift;
435 224         4242 my $pass = $self->password;
436 224 100 100     1414 if (($pass =~ m/[a-z]/) and ($pass =~ m/[A-Z]/)) {
437             return undef
438 212         643 } else {
439 12         60 return [ mixed => "No mixed case"];
440             }
441             }
442              
443             =head2 password_has_specials
444              
445             Check if the password has non-word characters
446              
447             Disable keyword: C
448              
449             =cut
450              
451              
452             sub password_has_specials {
453 234     234 1 254 my $self = shift;
454 234 100       4557 if ($self->password =~ m/[\W_]/) {
455             return undef
456 224         682 } else {
457 10         46 return [ specials => "No special characters" ];
458             }
459             }
460              
461             =head2 password_has_digits
462              
463             Check if the password has digits
464              
465             Disable keyword: C
466              
467             =cut
468              
469              
470             sub password_has_digits {
471 224     224 1 229 my $self = shift;
472 224 100       4179 if ($self->password =~ m/\d/) {
473             return undef
474 215         640 } else {
475 9         44 return [ digits => "No digits in the password" ];
476             }
477             }
478              
479             =head2 password_has_letters
480              
481             Check if the password has letters
482              
483             Disable keyword: C
484              
485             =cut
486              
487             sub password_has_letters {
488 234     234 1 251 my $self = shift;
489 234 100       4352 if ($self->password =~ m/[a-zA-Z]/) {
490             return undef
491 231         650 } else {
492 3         13 return [letters => "No letters in the password" ];
493             }
494             }
495              
496             =head2 password_has_patterns
497              
498             Check if the password contains usual patterns like 12345, abcd, or
499             asdf (like in the qwerty keyboard).
500              
501             Disable keyword: C
502              
503             =cut
504              
505             my @patterns = (
506             [ qw/1 2 3 4 5 6 7 8 9 0/ ],
507             [ ("a" .. "z") ],
508             [ qw/q w e r t y u i o p/ ],
509             [ qw/a s d f g h j k l/ ],
510             [ qw/z x c v b n m/ ]);
511              
512             sub password_has_patterns {
513 237     237 1 266 my $self = shift;
514 237         4412 my $password = lc($self->password);
515 237         281 my @found;
516 237         4092 my $range = $self->patternlength - 1;
517 237         4112 foreach my $row (@patterns) {
518 1185         3095 my @pat = @$row;
519             # we search a pattern of 3 consecutive keys, maybe 4 is reasonable enough
520 1185         2272 for (my $i = 0; $i <= ($#pat - $range); $i++) {
521 12269         9658 my $to = $i + $range;
522 12269         13943 my $substring = join("", @pat[$i..$to]);
523 12269 100       31379 if (index($password, $substring) >= 0) {
524 11         30 push @found, $substring;
525             }
526             }
527             }
528 237 100       433 if (@found) {
529 8         25 my $errstring = "Found common patterns: " . join(", ", @found);
530 8         43 return [ patterns => $errstring ];
531             } else {
532 229         839 return undef;
533             }
534             }
535              
536              
537             =head1 Main methods
538              
539             =head2 $obj->is_valid
540              
541             Return the password if matches the policy or a false value if not.
542              
543             For convenience, this method can accept the password to validate as
544             argument, which will overwrite the one provided with the C
545             method (if it was set).
546              
547             =cut
548              
549              
550              
551             sub is_valid {
552 254     254 1 14550 my $self = shift;
553 254         317 my $password = shift;
554 254 100 66     764 if (defined $password and $password ne "") {
555 15         340 $self->password($password);
556             }
557 254 100       5143 unless ($self->password) {
558 1         7 $self->error([missing => "Password is missing"]);
559 1         5 return undef;
560             }
561             # reset the errors, we are going to do the checks anew;
562 253         840 $self->reset_errors;
563              
564              
565              
566             # To disable this, set the minimum to 1 and the max
567             # to 255, but it makes no sense.
568 253         9994 $self->error($self->password_length_ok);
569              
570 253 100       687 unless ($self->is_disabled("specials")) {
571 234         3629 $self->error($self->password_has_specials);
572             }
573              
574 253 100       626 unless ($self->is_disabled("digits")) {
575 224         1461 $self->error($self->password_has_digits);
576             }
577              
578 253 100       693 unless ($self->is_disabled("letters")) {
579 234         1600 $self->error($self->password_has_letters);
580             }
581              
582 253 100       554 unless ($self->is_disabled("username")) {
583 220         1381 $self->error($self->password_has_username);
584             }
585              
586 253 100       698 unless ($self->is_disabled("common")) {
587 234         1567 $self->error($self->password_has_common_password);
588             }
589              
590 253 100       592 unless ($self->is_disabled("varchars")) {
591 234         1646 $self->error($self->password_has_enough_different_char);
592             }
593              
594 253 100       639 unless ($self->is_disabled("mixed")) {
595 224         1597 $self->error($self->password_has_mixed_chars);
596             }
597              
598 253 100       647 unless ($self->is_disabled("patterns")) {
599 237         1557 $self->error($self->password_has_patterns)
600             }
601            
602 253 100       729 if ($self->error) {
603 28         158 return undef;
604             } else {
605 225         4211 return $self->password;
606             }
607             }
608              
609              
610             =head2 $obj->error
611              
612             With argument, set the error. Without, return the errors found in the
613             password.
614              
615             In list context, we pass the array with the error codes and the strings.
616             In scalar context, we return the concatenated error strings.
617              
618             Inherited from Data::Transpose::Validator::Base;
619              
620             =cut
621              
622             =head2 error_codes
623              
624             Return a list of the error codes found in the password. The error
625             codes match the options. (e.g. C, C).
626              
627             If you want the verbose string, you need the C method.
628              
629             =cut
630              
631              
632              
633              
634             =head2 $obj->reset_errors
635              
636             Clear the object from previous errors, in case you want to reuse it.
637              
638             =cut
639              
640             =head2 $obj->disable("mixed", "letters", "digits", [...])
641              
642             Disable the checking(s) passed as list of strings.
643              
644             =cut
645              
646             sub disable {
647 10     10 1 4227 my $self = shift;
648 10         29 $self->_enable_or_disable_check("disable", @_);
649             }
650              
651             =head2 $obj->enable("mixed", "letters", [...])
652              
653             Same as above, but enable the checking
654              
655             =cut
656              
657              
658             sub enable {
659 10     10 1 27 my $self = shift;
660 10         21 $self->_enable_or_disable_check("enable", @_);
661             }
662              
663             sub _enable_or_disable_check {
664 20     20   23 my $self = shift;
665 20         19 my $action = shift;
666 20         45 my @args = @_;
667 20         24 my $set = 0;
668 20 50 66     101 die "Wrong usage! internal only!\n" unless (($action eq 'enable') or
669             ($action eq 'disable'));
670              
671 20 50       51 if (@args) {
672 20         30 foreach my $what (@args) {
673 20         41 $self->_get_or_set_disable($what, $action);
674             }
675             }
676             }
677              
678             =head2 $obj->is_disabled("checking")
679              
680             Return true if the checking is disable.
681              
682             =cut
683              
684             sub is_disabled {
685 2024     2024 1 2640 my $self = shift;
686 2024         1900 my $check = shift;
687 2024         2804 return $self->_get_or_set_disable($check);
688             }
689              
690             sub _get_or_set_disable {
691 2044     2044   2044 my ($self, $what, $action) = @_;
692 2044 50       3281 return undef unless $what;
693 2044 100       2942 unless ($action) {
694 2024         34429 return $self->disabled->{$what}
695             }
696 20 100       48 if ($action eq 'enable') {
    50          
697 10         214 $self->disabled->{$what} = 0;
698             }
699             elsif ($action eq 'disable') {
700 10         234 $self->disabled->{$what} = 1;
701             }
702             else {
703 0         0 die "Wrong action!\n"
704             }
705 20         864 return $self->disabled->{$what};
706             }
707              
708              
709             1;
710              
711             __END__