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   23559 use 5.010001;
  5         21  
4 5     5   26 use strict;
  5         10  
  5         138  
5 5     5   26 use warnings;
  5         10  
  5         160  
6             # use Data::Dumper;
7              
8 5     5   856 use Moo;
  5         15416  
  5         41  
9             extends 'Data::Transpose::Validator::Base';
10 5     5   5327 use MooX::Types::MooseLike::Base qw(:all);
  5         6989  
  5         2605  
11 5     5   861 use namespace::clean;
  5         13529  
  5         57  
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   90994 my $orig = shift;
104 3550         70606 my $ret = $orig->(@_);
105 3550 50       22049 if (not defined $ret) {
106 0         0 return '';
107             }
108             else {
109 3550         10969 $ret =~ s/^\s*//s;
110 3550         24248 $ret =~ s/\s*$//s;
111 3550         27663 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 3182 my $self = shift;
162 737         17321 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 376 my $self = shift;
206 253 100 100     513 if (($self->password_length >= $self->minlength) and
207             ($self->password_length <= $self->maxlength)) {
208 245         3001 return undef;
209             } else {
210 8   50     2747 my $min = $self->minlength || 0;
211 8   50     174 my $max = $self->maxlength || 0;
212 8   50     2523 my $cur = $self->password_length || 0;
213 8 100       34 if ($cur < $min) {
214 7         75 return ["length" => "Wrong length (it should be long at least $min characters)"];
215             }
216             else {
217 1         8 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 268 my $self = shift;
283 220 50       4963 return [ username => "Missing username" ] unless $self->username;
284              
285 220         5112 my $match = _leet_string_match($self->password, $self->username);
286 220 100       521 if ($match) {
287 2         12 return [ username => "Found username $match in password" ];
288             } else {
289             return undef
290 218         772 }
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 330 my $self = shift;
306 234         295 my @found;
307 234         5461 my $password = $self->password;
308 234         568 foreach my $common (@toppassword) {
309 6084 100       11438 if (_leet_string_match($password, $common)) {
310 7         20 push @found, $common;
311             }
312             }
313 234 100       524 if (@found) {
314             # warn join(" ", @found) . "\n";
315 6         40 return [ common => "Found common password" ];
316             }
317             else {
318 228         943 return undef;
319             }
320             }
321              
322             sub _leet_string_match {
323 6304     6304   9741 my ($string, $match) = @_;
324 6304 50 33     24869 return "Missing parameter" unless ($string and $match);
325              
326 6304         10334 my $lcstring = lc($string); # the password
327 6304         8676 my $lcmatch = lc($match); # the check
328 6304         16618 my @chars = split(//, $lcmatch); # split the match
329              
330             # for each character we look up the regexp or .
331 6304         7454 my @regexps;
332 6304         8936 foreach my $c (@chars) {
333 32694 100       60021 if (exists $leetperms{$c}) {
334 32226         57808 push @regexps, $leetperms{$c};
335             } else {
336 468         761 push @regexps, "."; # unknown character
337             }
338             }
339             # then we join it
340 6304         14557 my $re = join("", @regexps);
341             # and use it as re against the provided string
342             # warn "checking $lcstring against $re\n";
343 6304 100       79741 if ($lcstring =~ m/$re/i) {
344             # warn $re . "\n";
345             # return false if the re is present in the string
346 9         47 return $lcmatch
347             } else {
348 6295         28633 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 318 my $self = shift;
365 234         317 my %found;
366 234         5588 my @chars = split //, $self->password;
367 234         354 my %consecutives;
368 234         333 my $previous = "";
369 234         393 foreach my $c (@chars) {
370 9079         12928 $found{$c}++;
371            
372             # check previous char
373 9079 100       16111 if ($previous eq $c) {
374 305         533 $consecutives{$c}++;
375             }
376 9079         12666 $previous = $c;
377             }
378             # print Dumper(\%found);
379              
380             # check the number of chars
381 234         375 my $totalchar = scalar(keys(%found));
382 234 100       5133 if ($totalchar <= $self->mindiffchars) {
383 8         2775 return [ varchars => "Not enough different characters" ];
384             }
385              
386 226         2105 my %reportconsec;
387             # check the consecutive chars;
388 226         806 while (my ($k, $v) = each %consecutives) {
389 221 100       961 if ($v > 2) {
390 7         41 $reportconsec{$k} = $v + 1;
391             }
392             }
393              
394 226 100       522 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         18 foreach my $rep (values %reportconsec) {
399 7         14 $passwdlen = $passwdlen - $rep;
400             }
401 6 100       151 if ($passwdlen < $self->minlength) {
402 3         28 my $errstring = "Found too many repetitions, "
403             . "lowering the effectivelength: "
404             . (join(", ", (keys %reportconsec)));
405 3         28 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         517 my $maxrepeat = int($self->password_length / 3);
413             # now get the hightest value;
414 223         383 my $max = 0;
415 223         749 foreach my $v (values %found) {
416 4726 100       8853 $max = $v if ($v > $max);
417             }
418 223 100       412 if ($max > $maxrepeat) {
419 3         35 return [ varchars => "Found too many repetitions" ];
420             }
421 220         2065 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 332 my $self = shift;
435 224         5112 my $pass = $self->password;
436 224 100 100     1529 if (($pass =~ m/[a-z]/) and ($pass =~ m/[A-Z]/)) {
437             return undef
438 212         739 } else {
439 12         59 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 353 my $self = shift;
454 234 100       5335 if ($self->password =~ m/[\W_]/) {
455             return undef
456 224         813 } else {
457 10         56 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 285 my $self = shift;
472 224 100       5198 if ($self->password =~ m/\d/) {
473             return undef
474 215         762 } 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 322 my $self = shift;
489 234 100       5353 if ($self->password =~ m/[a-zA-Z]/) {
490             return undef
491 231         826 } else {
492 3         16 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 311 my $self = shift;
514 237         5362 my $password = lc($self->password);
515 237         318 my @found;
516 237         4760 my $range = $self->patternlength - 1;
517 237         4616 foreach my $row (@patterns) {
518 1185         3888 my @pat = @$row;
519             # we search a pattern of 3 consecutive keys, maybe 4 is reasonable enough
520 1185         2975 for (my $i = 0; $i <= ($#pat - $range); $i++) {
521 12269         15604 my $to = $i + $range;
522 12269         21181 my $substring = join("", @pat[$i..$to]);
523 12269 100       45576 if (index($password, $substring) >= 0) {
524 11         45 push @found, $substring;
525             }
526             }
527             }
528 237 100       546 if (@found) {
529 8         26 my $errstring = "Found common patterns: " . join(", ", @found);
530 8         57 return [ patterns => $errstring ];
531             } else {
532 229         959 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 16468 my $self = shift;
553 254         364 my $password = shift;
554 254 100 66     778 if (defined $password and $password ne "") {
555 15         443 $self->password($password);
556             }
557 254 100       6155 unless ($self->password) {
558 1         5 $self->error([missing => "Password is missing"]);
559 1         11 return undef;
560             }
561             # reset the errors, we are going to do the checks anew;
562 253         880 $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         11549 $self->error($self->password_length_ok);
569              
570 253 100       870 unless ($self->is_disabled("specials")) {
571 234         4178 $self->error($self->password_has_specials);
572             }
573              
574 253 100       782 unless ($self->is_disabled("digits")) {
575 224         1675 $self->error($self->password_has_digits);
576             }
577              
578 253 100       803 unless ($self->is_disabled("letters")) {
579 234         1903 $self->error($self->password_has_letters);
580             }
581              
582 253 100       720 unless ($self->is_disabled("username")) {
583 220         1627 $self->error($self->password_has_username);
584             }
585              
586 253 100       825 unless ($self->is_disabled("common")) {
587 234         1815 $self->error($self->password_has_common_password);
588             }
589              
590 253 100       759 unless ($self->is_disabled("varchars")) {
591 234         1844 $self->error($self->password_has_enough_different_char);
592             }
593              
594 253 100       771 unless ($self->is_disabled("mixed")) {
595 224         1815 $self->error($self->password_has_mixed_chars);
596             }
597              
598 253 100       799 unless ($self->is_disabled("patterns")) {
599 237         1824 $self->error($self->password_has_patterns)
600             }
601            
602 253 100       907 if ($self->error) {
603 28         157 return undef;
604             } else {
605 225         5330 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 3593 my $self = shift;
648 10         34 $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 26 my $self = shift;
660 10         27 $self->_enable_or_disable_check("enable", @_);
661             }
662              
663             sub _enable_or_disable_check {
664 20     20   25 my $self = shift;
665 20         28 my $action = shift;
666 20         39 my @args = @_;
667 20         26 my $set = 0;
668 20 50 66     90 die "Wrong usage! internal only!\n" unless (($action eq 'enable') or
669             ($action eq 'disable'));
670              
671 20 50       56 if (@args) {
672 20         35 foreach my $what (@args) {
673 20         48 $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 2813 my $self = shift;
686 2024         2791 my $check = shift;
687 2024         4025 return $self->_get_or_set_disable($check);
688             }
689              
690             sub _get_or_set_disable {
691 2044     2044   3135 my ($self, $what, $action) = @_;
692 2044 50       3883 return undef unless $what;
693 2044 100       3847 unless ($action) {
694 2024         41525 return $self->disabled->{$what}
695             }
696 20 100       51 if ($action eq 'enable') {
    50          
697 10         226 $self->disabled->{$what} = 0;
698             }
699             elsif ($action eq 'disable') {
700 10         217 $self->disabled->{$what} = 1;
701             }
702             else {
703 0         0 die "Wrong action!\n"
704             }
705 20         1248 return $self->disabled->{$what};
706             }
707              
708              
709             1;
710              
711             __END__