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;";
21 our $me = '[' . __PACKAGE__ . ']';
23 our $BLOWFISH_COST = 10;
27 FS::Password_Mixin - Object methods for accounts that have passwords governed
28 by the password policy.
34 =item is_password_allowed PASSWORD
36 Checks the password against the system password policy. Returns an error
37 message on failure, an empty string on success.
39 This MUST NOT be called from check(). It should be called by the office UI,
40 self-service ClientAPI, or other I<user-interactive> code that processes a
41 password change, and only if the user has taken some action with the intent
42 of setting the password.
46 sub is_password_allowed {
50 my $cust_main = $self->table eq 'access_user'
51 ? $self->user_cust_main
54 # workaround for non-inserted services
55 if ( !$cust_main and $self->get('pkgnum') ) {
56 my $cust_pkg = FS::cust_pkg->by_key($self->get('pkgnum'));
57 $cust_main = $cust_pkg->cust_main if $cust_pkg;
59 # selfservice signup invokes this without customer, but it checks this conf separately
60 warn "is_password_allowed: no customer could be identified" if !$cust_main;
61 return '' if $cust_main && $conf->config_bool('password-insecure', $cust_main->agentnum);
63 # basic checks using Data::Password;
64 # options for Data::Password
65 $DICTIONARY = 0; # minimum length of disallowed words, false value disables dictionary checking
66 $MINLEN = $conf->config('passwordmin') || 6;
67 $MAXLEN = $conf->config('passwordmax') || 8;
68 $GROUPS = 4; # must have all 4 'character groups': numbers, symbols, uppercase, lowercase
69 # other options use the defaults listed below:
70 # $FOLLOWING = 3; # disallows more than 3 chars in a row, by alphabet or keyboard (ie abcd or asdf)
71 # $SKIPCHAR = undef; # set to true to skip checking for bad characters
72 # # lists of disallowed words
73 # @DICTIONARIES = qw( /usr/share/dict/web2 /usr/share/dict/words /usr/share/dict/linux.words );
75 # first, no dictionary checking but require 4 char groups
76 my $error = IsBadPassword($password);
78 # but they can get away with 3 char groups, so long as they're not using a word
79 if ($error eq 'contains less than 4 character groups') {
80 $DICTIONARY = 4; # default from Data::Password is 5
82 $error = IsBadPassword($password);
83 # take note--we never actually report dictionary word errors;
84 # 4 char groups is the rule, 3 char groups and no dictionary words is an acceptable exception
85 $error = 'should contain at least one each of numbers, symbols, lowercase and uppercase letters'
89 # maybe also at some point add an exception for any passwords of sufficient length,
90 # see https://xkcd.com/936/
92 $error = 'Invalid password - ' . $error if $error;
93 return $error if $error;
95 #check against service fields
96 $error = $self->password_svc_check($password);
97 return $error if $error;
99 return '' unless $self->get($self->primary_key); # for validating new passwords pre-insert
101 #check against customer fields
104 # words from cust_main
105 foreach my $field ( qw( last first daytime night fax mobile ) ) {
106 push @words, split(/\W/,$cust_main->get($field));
108 # words from cust_location
109 foreach my $loc ($cust_main->cust_location) {
110 foreach my $field ( qw(address1 address2 city county state zip) ) {
111 push @words, split(/\W/,$loc->get($field));
114 # do the actual checking
115 foreach my $word (@words) {
116 next unless length($word) > 2;
117 if ($password =~ /$word/i) {
118 return qq(Password contains account information '$word');
123 if ( $conf->config('password-no_reuse') =~ /^(\d+)$/ ) {
127 # "the last N" passwords includes the current password and the N-1
128 # passwords before that.
129 warn "$me checking password reuse limit of $no_reuse\n" if $DEBUG;
130 my @latest = qsearch({
131 'table' => 'password_history',
132 'hashref' => { $self->password_history_key => $self->get($self->primary_key) },
133 'order_by' => " ORDER BY created DESC LIMIT $no_reuse",
136 # don't check the first one; reusing the current password is allowed.
139 foreach my $history (@latest) {
140 warn "$me previous password created ".$history->created."\n" if $DEBUG;
141 if ( $history->password_equals($password) ) {
143 if ( $no_reuse == 1 ) {
144 $message = "This password is the same as your previous password.";
146 $message = "This password was one of the last $no_reuse passwords on this account.";
152 } # end of no_reuse checking
157 =item password_svc_check
159 Override to run additional service-specific password checks.
163 sub password_svc_check {
164 my ($self, $password) = @_;
168 =item password_history_key
170 Returns the name of the field in L<FS::password_history> that's the foreign
175 sub password_history_key {
177 $self->table . '__' . $self->primary_key;
180 =item insert_password_history
182 Creates a L<FS::password_history> record linked to this object, with its
187 sub insert_password_history {
189 my $encoding = $self->_password_encoding;
190 my $password = $self->_password;
193 if ( $encoding eq 'bcrypt' ) {
194 # our format, used for contact and access_user passwords
195 my ($cost, $salt, $hash) = split(',', $password);
196 $auth = Authen::Passphrase::BlowfishCrypt->new(
198 salt_base64 => $salt,
199 hash_base64 => $hash,
202 } elsif ( $encoding eq 'crypt' ) {
204 # it's smart enough to figure this out
205 $auth = Authen::Passphrase->from_crypt($password);
207 } elsif ( $encoding eq 'ldap' ) {
209 $password =~ s/^{PLAIN}/{CLEARTEXT}/i; # normalize
210 $auth = Authen::Passphrase->from_rfc2307($password);
211 if ( $auth->isa('Authen::Passphrase::Clear') ) {
212 # then we've been given the password in cleartext
213 $auth = $self->_blowfishcrypt( $auth->passphrase );
217 if ($encoding and $encoding ne 'plain') {
218 warn "unrecognized password encoding '$encoding'; treating as plain text";
221 $auth = $self->_blowfishcrypt( $password );
225 my $password_history = FS::password_history->new({
226 _password => $auth->as_rfc2307,
228 $self->password_history_key => $self->get($self->primary_key),
231 my $error = $password_history->insert;
232 return "recording password history: $error" if $error;
237 =item delete_password_history;
239 Removes all password history records attached to this object, in preparation
240 to delete the object.
244 sub delete_password_history {
246 my @records = qsearch('password_history', {
247 $self->password_history_key => $self->get($self->primary_key)
251 $error ||= $_->delete;
253 return $error . ' (clearing password history)' if $error;
257 =item _blowfishcrypt PASSWORD
259 For internal use: takes PASSWORD and returns a new
260 L<Authen::Passphrase::BlowfishCrypt> object representing it.
266 my $passphrase = shift;
267 return Authen::Passphrase::BlowfishCrypt->new(
268 cost => $BLOWFISH_COST,
270 passphrase => $passphrase,
282 Returns the list of characters allowed in random passwords (from the
283 C<password-generated-characters> config).
290 my $pw_set = $conf->config('password-generated-characters');
291 $pw_set =~ s/\s//g; # don't ever allow whitespace
292 if ( $pw_set =~ /[[:lower:]]/
293 && $pw_set =~ /[[:upper:]]/
294 && $pw_set =~ /[[:digit:]]/
295 && $pw_set =~ /[[:punct:]]/ ) {
296 @pw_set = split('', $pw_set);
298 warn "password-generated-characters set is insufficient; using default.";
299 @pw_set = split('', 'abcdefghijkmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ23456789()#.,');
309 L<FS::password_history>