1 package FS::Password_Mixin;
3 use FS::Record qw(qsearch);
5 use FS::password_history;
6 use Authen::Passphrase;
7 use Authen::Passphrase::BlowfishCrypt;
8 # https://rt.cpan.org/Ticket/Display.html?id=72743
9 use Data::Password qw(:all);
13 FS::UID->install_callback( sub {
14 $conf = FS::Conf->new;
16 #eval "use Authen::Passphrase::BlowfishCrypt;";
19 our $me = '[' . __PACKAGE__ . ']';
21 our $BLOWFISH_COST = 10;
25 FS::Password_Mixin - Object methods for accounts that have passwords governed
26 by the password policy.
32 =item is_password_allowed PASSWORD
34 Checks the password against the system password policy. Returns an error
35 message on failure, an empty string on success.
37 This MUST NOT be called from check(). It should be called by the office UI,
38 self-service ClientAPI, or other I<user-interactive> code that processes a
39 password change, and only if the user has taken some action with the intent
40 of setting the password.
44 sub is_password_allowed {
48 # basic checks using Data::Password;
49 # options for Data::Password
50 $DICTIONARY = 4; # minimum length of disallowed words
51 $MINLEN = $conf->config('passwordmin') || 6;
52 $MAXLEN = $conf->config('passwordmax') || 8;
53 $GROUPS = 4; # must have all 4 'character groups': numbers, symbols, uppercase, lowercase
54 # other options use the defaults listed below:
55 # $FOLLOWING = 3; # disallows more than 3 chars in a row, by alphabet or keyboard (ie abcd or asdf)
56 # $SKIPCHAR = undef; # set to true to skip checking for bad characters
57 # # lists of disallowed words
58 # @DICTIONARIES = qw( /usr/share/dict/web2 /usr/share/dict/words /usr/share/dict/linux.words );
60 my $error = IsBadPassword($password);
61 $error = 'must contain at least one each of numbers, symbols, and lowercase and uppercase letters'
62 if $error eq 'contains less than 4 character groups'; # avoid confusion
63 $error = 'Invalid password - ' . $error if $error;
64 return $error if $error;
66 #check against service fields
67 $error = $self->password_svc_check($password);
68 return $error if $error;
70 return '' unless $self->get($self->primary_key); # for validating new passwords pre-insert
72 #check against customer fields
73 my $cust_main = $self->cust_main;
76 # words from cust_main
77 foreach my $field ( qw( last first daytime night fax mobile ) ) {
78 push @words, split(/\W/,$cust_main->get($field));
80 # words from cust_location
81 foreach my $loc ($cust_main->cust_location) {
82 foreach my $field ( qw(address1 address2 city county state zip) ) {
83 push @words, split(/\W/,$loc->get($field));
86 # do the actual checking
87 foreach my $word (@words) {
88 next unless length($word) > 2;
89 if ($password =~ /$word/i) {
90 return qq(Password contains account information '$word');
95 if ( $conf->config('password-no_reuse') =~ /^(\d+)$/ ) {
99 # "the last N" passwords includes the current password and the N-1
100 # passwords before that.
101 warn "$me checking password reuse limit of $no_reuse\n" if $DEBUG;
102 my @latest = qsearch({
103 'table' => 'password_history',
104 'hashref' => { $self->password_history_key => $self->get($self->primary_key) },
105 'order_by' => " ORDER BY created DESC LIMIT $no_reuse",
108 # don't check the first one; reusing the current password is allowed.
111 foreach my $history (@latest) {
112 warn "$me previous password created ".$history->created."\n" if $DEBUG;
113 if ( $history->password_equals($password) ) {
115 if ( $no_reuse == 1 ) {
116 $message = "This password is the same as your previous password.";
118 $message = "This password was one of the last $no_reuse passwords on this account.";
124 } # end of no_reuse checking
129 =item password_svc_check
131 Override to run additional service-specific password checks.
135 sub password_svc_check {
136 my ($self, $password) = @_;
140 =item password_history_key
142 Returns the name of the field in L<FS::password_history> that's the foreign
147 sub password_history_key {
149 $self->table . '__' . $self->primary_key;
152 =item insert_password_history
154 Creates a L<FS::password_history> record linked to this object, with its
159 sub insert_password_history {
161 my $encoding = $self->_password_encoding;
162 my $password = $self->_password;
165 if ( $encoding eq 'bcrypt' ) {
166 # our format, used for contact and access_user passwords
167 my ($cost, $salt, $hash) = split(',', $password);
168 $auth = Authen::Passphrase::BlowfishCrypt->new(
170 salt_base64 => $salt,
171 hash_base64 => $hash,
174 } elsif ( $encoding eq 'crypt' ) {
176 # it's smart enough to figure this out
177 $auth = Authen::Passphrase->from_crypt($password);
179 } elsif ( $encoding eq 'ldap' ) {
181 $password =~ s/^{PLAIN}/{CLEARTEXT}/i; # normalize
182 $auth = Authen::Passphrase->from_rfc2307($password);
183 if ( $auth->isa('Authen::Passphrase::Clear') ) {
184 # then we've been given the password in cleartext
185 $auth = $self->_blowfishcrypt( $auth->passphrase );
189 warn "unrecognized password encoding '$encoding'; treating as plain text"
190 unless $encoding eq 'plain';
192 $auth = $self->_blowfishcrypt( $password );
196 my $password_history = FS::password_history->new({
197 _password => $auth->as_rfc2307,
199 $self->password_history_key => $self->get($self->primary_key),
202 my $error = $password_history->insert;
203 return "recording password history: $error" if $error;
208 =item _blowfishcrypt PASSWORD
210 For internal use: takes PASSWORD and returns a new
211 L<Authen::Passphrase::BlowfishCrypt> object representing it.
217 my $passphrase = shift;
218 return Authen::Passphrase::BlowfishCrypt->new(
219 cost => $BLOWFISH_COST,
221 passphrase => $passphrase,
229 L<FS::password_history>