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   14892 use 5.010001;
  5         11  
4 5     5   15 use strict;
  5         4  
  5         85  
5 5     5   39 use warnings;
  5         3  
  5         105  
6             # use Data::Dumper;
7              
8 5     5   435 use Moo;
  5         9673  
  5         22  
9             extends 'Data::Transpose::Validator::Base';
10 5     5   3121 use MooX::Types::MooseLike::Base qw(:all);
  5         4570  
  5         1401  
11 5     5   439 use namespace::clean;
  5         9131  
  5         23  
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   54419 my $orig = shift;
104 3550         41447 my $ret = $orig->(@_);
105 3550 50       13897 if (not defined $ret) {
106 0         0 return '';
107             }
108             else {
109 3550         6591 $ret =~ s/^\s*//s;
110 3550         13798 $ret =~ s/\s*$//s;
111 3550         16950 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 2099 my $self = shift;
162 737         10430 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 220 my $self = shift;
206 253 100 100     284 if (($self->password_length >= $self->minlength) and
207             ($self->password_length <= $self->maxlength)) {
208 245         2034 return undef;
209             } else {
210 8   50     1649 my $min = $self->minlength || 0;
211 8   50     121 my $max = $self->maxlength || 0;
212 8   50     1478 my $cur = $self->password_length || 0;
213 8 100       20 if ($cur < $min) {
214 7         53 return ["length" => "Wrong length (it should be long at least $min characters)"];
215             }
216             else {
217 1         5 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 155 my $self = shift;
283 220 50       3070 return [ username => "Missing username" ] unless $self->username;
284              
285 220         3148 my $match = _leet_string_match($self->password, $self->username);
286 220 100       299 if ($match) {
287 2         9 return [ username => "Found username $match in password" ];
288             } else {
289             return undef
290 218         525 }
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 177 my $self = shift;
306 234         153 my @found;
307 234         3386 my $password = $self->password;
308 234         305 foreach my $common (@toppassword) {
309 6084 100       6042 if (_leet_string_match($password, $common)) {
310 7         14 push @found, $common;
311             }
312             }
313 234 100       308 if (@found) {
314             # warn join(" ", @found) . "\n";
315 6         23 return [ common => "Found common password" ];
316             }
317             else {
318 228         639 return undef;
319             }
320             }
321              
322             sub _leet_string_match {
323 6304     6304   5151 my ($string, $match) = @_;
324 6304 50 33     15507 return "Missing parameter" unless ($string and $match);
325              
326 6304         5252 my $lcstring = lc($string); # the password
327 6304         3965 my $lcmatch = lc($match); # the check
328 6304         8665 my @chars = split(//, $lcmatch); # split the match
329              
330             # for each character we look up the regexp or .
331 6304         3636 my @regexps;
332 6304         5132 foreach my $c (@chars) {
333 32694 100       29834 if (exists $leetperms{$c}) {
334 32226         28851 push @regexps, $leetperms{$c};
335             } else {
336 468         424 push @regexps, "."; # unknown character
337             }
338             }
339             # then we join it
340 6304         7234 my $re = join("", @regexps);
341             # and use it as re against the provided string
342             # warn "checking $lcstring against $re\n";
343 6304 100       49660 if ($lcstring =~ m/$re/i) {
344             # warn $re . "\n";
345             # return false if the re is present in the string
346 9         27 return $lcmatch
347             } else {
348 6295         15247 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 178 my $self = shift;
365 234         197 my %found;
366 234         3372 my @chars = split //, $self->password;
367 234         191 my %consecutives;
368 234         194 my $previous = "";
369 234         261 foreach my $c (@chars) {
370 9079         6487 $found{$c}++;
371            
372             # check previous char
373 9079 100       9304 if ($previous eq $c) {
374 305         287 $consecutives{$c}++;
375             }
376 9079         6144 $previous = $c;
377             }
378             # print Dumper(\%found);
379              
380             # check the number of chars
381 234         245 my $totalchar = scalar(keys(%found));
382 234 100       3414 if ($totalchar <= $self->mindiffchars) {
383 8         1632 return [ varchars => "Not enough different characters" ];
384             }
385              
386 226         1440 my %reportconsec;
387             # check the consecutive chars;
388 226         539 while (my ($k, $v) = each %consecutives) {
389 221 100       626 if ($v > 2) {
390 7         16 $reportconsec{$k} = $v + 1;
391             }
392             }
393              
394 226 100       371 if (%reportconsec) {
395             # we see if subtracting the number of total repetition, we are
396             # still above the minimum chars.
397 6         10 my $passwdlen = $self->password_length;
398 6         8 foreach my $rep (values %reportconsec) {
399 7         8 $passwdlen = $passwdlen - $rep;
400             }
401 6 100       78 if ($passwdlen < $self->minlength) {
402 3         17 my $errstring = "Found too many repetitions, "
403             . "lowering the effectivelength: "
404             . (join(", ", (keys %reportconsec)));
405 3         17 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         301 my $maxrepeat = int($self->password_length / 3);
413             # now get the hightest value;
414 223         180 my $max = 0;
415 223         484 foreach my $v (values %found) {
416 4726 100       5277 $max = $v if ($v > $max);
417             }
418 223 100       267 if ($max > $maxrepeat) {
419 3         19 return [ varchars => "Found too many repetitions" ];
420             }
421 220         1344 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 174 my $self = shift;
435 224         3155 my $pass = $self->password;
436 224 100 100     978 if (($pass =~ m/[a-z]/) and ($pass =~ m/[A-Z]/)) {
437             return undef
438 212         473 } else {
439 12         38 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 195 my $self = shift;
454 234 100       3201 if ($self->password =~ m/[\W_]/) {
455             return undef
456 224         504 } else {
457 10         36 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 165 my $self = shift;
472 224 100       3174 if ($self->password =~ m/\d/) {
473             return undef
474 215         445 } else {
475 9         25 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 288 my $self = shift;
489 234 100       3315 if ($self->password =~ m/[a-zA-Z]/) {
490             return undef
491 231         477 } else {
492 3         10 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 172 my $self = shift;
514 237         3255 my $password = lc($self->password);
515 237         216 my @found;
516 237         2921 my $range = $self->patternlength - 1;
517 237         2864 foreach my $row (@patterns) {
518 1185         2179 my @pat = @$row;
519             # we search a pattern of 3 consecutive keys, maybe 4 is reasonable enough
520 1185         1600 for (my $i = 0; $i <= ($#pat - $range); $i++) {
521 12269         7620 my $to = $i + $range;
522 12269         10515 my $substring = join("", @pat[$i..$to]);
523 12269 100       23838 if (index($password, $substring) >= 0) {
524 11         43 push @found, $substring;
525             }
526             }
527             }
528 237 100       380 if (@found) {
529 8         17 my $errstring = "Found common patterns: " . join(", ", @found);
530 8         34 return [ patterns => $errstring ];
531             } else {
532 229         587 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 10188 my $self = shift;
553 254         208 my $password = shift;
554 254 100 66     580 if (defined $password and $password ne "") {
555 15         280 $self->password($password);
556             }
557 254 100       3766 unless ($self->password) {
558 1         4 $self->error([missing => "Password is missing"]);
559 1         4 return undef;
560             }
561             # reset the errors, we are going to do the checks anew;
562 253         630 $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         7825 $self->error($self->password_length_ok);
569              
570 253 100       495 unless ($self->is_disabled("specials")) {
571 234         2638 $self->error($self->password_has_specials);
572             }
573              
574 253 100       438 unless ($self->is_disabled("digits")) {
575 224         1077 $self->error($self->password_has_digits);
576             }
577              
578 253 100       464 unless ($self->is_disabled("letters")) {
579 234         1090 $self->error($self->password_has_letters);
580             }
581              
582 253 100       445 unless ($self->is_disabled("username")) {
583 220         1043 $self->error($self->password_has_username);
584             }
585              
586 253 100       507 unless ($self->is_disabled("common")) {
587 234         1233 $self->error($self->password_has_common_password);
588             }
589              
590 253 100       488 unless ($self->is_disabled("varchars")) {
591 234         1179 $self->error($self->password_has_enough_different_char);
592             }
593              
594 253 100       496 unless ($self->is_disabled("mixed")) {
595 224         1126 $self->error($self->password_has_mixed_chars);
596             }
597              
598 253 100       444 unless ($self->is_disabled("patterns")) {
599 237         1171 $self->error($self->password_has_patterns)
600             }
601            
602 253 100       516 if ($self->error) {
603 28         97 return undef;
604             } else {
605 225         3242 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 2380 my $self = shift;
648 10         19 $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 16 my $self = shift;
660 10         14 $self->_enable_or_disable_check("enable", @_);
661             }
662              
663             sub _enable_or_disable_check {
664 20     20   17 my $self = shift;
665 20         21 my $action = shift;
666 20         26 my @args = @_;
667 20         13 my $set = 0;
668 20 50 66     73 die "Wrong usage! internal only!\n" unless (($action eq 'enable') or
669             ($action eq 'disable'));
670              
671 20 50       38 if (@args) {
672 20         22 foreach my $what (@args) {
673 20         30 $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 1491 my $self = shift;
686 2024         1456 my $check = shift;
687 2024         2073 return $self->_get_or_set_disable($check);
688             }
689              
690             sub _get_or_set_disable {
691 2044     2044   1644 my ($self, $what, $action) = @_;
692 2044 50       2425 return undef unless $what;
693 2044 100       2292 unless ($action) {
694 2024         25810 return $self->disabled->{$what}
695             }
696 20 100       32 if ($action eq 'enable') {
    50          
697 10         168 $self->disabled->{$what} = 0;
698             }
699             elsif ($action eq 'disable') {
700 10         168 $self->disabled->{$what} = 1;
701             }
702             else {
703 0         0 die "Wrong action!\n"
704             }
705 20         678 return $self->disabled->{$what};
706             }
707              
708              
709             1;
710              
711             __END__