svc_hardware: better error messages for bad hw_addr when not validating as a MAC...
[freeside.git] / FS / FS / Password_Mixin.pm
index c4549c7..b29cf57 100644 (file)
@@ -6,13 +6,12 @@ use FS::password_history;
 use Authen::Passphrase;
 use Authen::Passphrase::BlowfishCrypt;
 # https://rt.cpan.org/Ticket/Display.html?id=72743
+use Data::Password qw(:all);
 
-our $DEBUG = 1;
+our $DEBUG = 0;
 our $conf;
 FS::UID->install_callback( sub {
-    $conf = FS::Conf->new;
-    # this is safe
-    #eval "use Authen::Passphrase::BlowfishCrypt;";
+  $conf = FS::Conf->new;
 });
 
 our $me = '[' . __PACKAGE__ . ']';
@@ -36,7 +35,7 @@ message on failure, an empty string on success.
 This MUST NOT be called from check(). It should be called by the office UI,
 self-service ClientAPI, or other I<user-interactive> code that processes a
 password change, and only if the user has taken some action with the intent
-of changing the password.
+of setting the password.
 
 =cut
 
@@ -44,11 +43,84 @@ sub is_password_allowed {
   my $self = shift;
   my $password = shift;
 
-  # check length and complexity here
+  # basic checks using Data::Password;
+  # options for Data::Password
+  $DICTIONARY = 0;   # minimum length of disallowed words, false value disables dictionary checking
+  $MINLEN = $conf->config('passwordmin') || 8;
+  $MAXLEN = $conf->config('passwordmax') || 12;
+  $GROUPS = 4;       # must have all 4 'character groups': numbers, symbols, uppercase, lowercase
+  # other options use the defaults listed below:
+  # $FOLLOWING = 3;    # disallows more than 3 chars in a row, by alphabet or keyboard (ie abcd or asdf)
+  # $SKIPCHAR = undef; # set to true to skip checking for bad characters
+  # # lists of disallowed words
+  # @DICTIONARIES = qw( /usr/share/dict/web2 /usr/share/dict/words /usr/share/dict/linux.words );
+
+  # first, no dictionary checking but require 4 char groups
+  my $error = IsBadPassword($password);
+
+  # but they can get away with 3 char groups, so long as they're not using a word
+  if ($error eq 'contains less than 4 character groups') {
+    $DICTIONARY = 4; # default from Data::Password is 5
+    $GROUPS = 3;
+    $error = IsBadPassword($password);
+    # take note--we never actually report dictionary word errors;
+    # 4 char groups is the rule, 3 char groups and no dictionary words is an acceptable exception
+    $error = 'should contain at least one each of numbers, symbols, lowercase and uppercase letters'
+      if $error;
+  }
+
+  # maybe also at some point add an exception for any passwords of sufficient length,
+  # see https://xkcd.com/936/
+
+  $error = 'Invalid password - ' . $error if $error;
+  return $error if $error;
 
-  if ( $conf->config('password-no_reuse') =~ /^(\d+)$/ ) {
+  #check against service fields
+  $error = $self->password_svc_check($password);
+  return $error if $error;
 
-    my $no_reuse = $1;
+  return '' unless $self->get($self->primary_key); # for validating new passwords pre-insert
+
+  #check against customer fields
+  my $cust_main = $self->table eq 'access_user'
+                    ? $self->user_cust_main
+                    : $self->cust_main;
+  if ($cust_main) {
+    my @words;
+    # words from cust_main
+    foreach my $field ( qw( last first daytime night fax mobile ) ) {
+        push @words, split(/\W/,$cust_main->get($field));
+    }
+    # words from cust_location
+    foreach my $loc ($cust_main->cust_location) {
+      foreach my $field ( qw(address1 address2 city county state zip) ) {
+        push @words, split(/\W/,$loc->get($field));
+      }
+    }
+    # words from cust_contact & contact_phone
+    foreach my $contact (map { $_->contact } $cust_main->cust_contact) {
+      foreach my $field ( qw(last first) ) {
+        push @words, split(/\W/,$contact->get($field));
+      }
+      # not hugely useful right now, hyphenless stored values longer than password max,
+      # but max will probably be increased eventually...
+      foreach my $phone ( qsearch('contact_phone', {'contactnum' => $contact->contactnum}) ) {
+        push @words, split(/\W/,$phone->get('phonenum'));
+      }
+    }
+    # do the actual checking
+    foreach my $word (@words) {
+      next unless length($word) > 2;
+      if ($password =~ /$word/i) {
+        return qq(Password contains account information '$word');
+      }
+    }
+  }
+
+  my $no_reuse = 3;
+  # allow override here if we really must
+
+  if ( $no_reuse > 0 ) {
 
     # "the last N" passwords includes the current password and the N-1
     # passwords before that.
@@ -80,6 +152,17 @@ sub is_password_allowed {
   '';
 }
 
+=item password_svc_check
+
+Override to run additional service-specific password checks.
+
+=cut
+
+sub password_svc_check {
+  my ($self, $password) = @_;
+  return '';
+}
+
 =item password_history_key
 
 Returns the name of the field in L<FS::password_history> that's the foreign
@@ -105,7 +188,16 @@ sub insert_password_history {
   my $password = $self->_password;
   my $auth;
 
-  if ( $encoding eq 'bcrypt' or $encoding eq 'crypt' ) {
+  if ( $encoding eq 'bcrypt' ) {
+    # our format, used for contact and access_user passwords
+    my ($cost, $salt, $hash) = split(',', $password);
+    $auth = Authen::Passphrase::BlowfishCrypt->new(
+      cost        => $cost,
+      salt_base64 => $salt,
+      hash_base64 => $hash,
+    );
+
+  } elsif ( $encoding eq 'crypt' ) {
 
     # it's smart enough to figure this out
     $auth = Authen::Passphrase->from_crypt($password);
@@ -119,7 +211,9 @@ sub insert_password_history {
       $auth = $self->_blowfishcrypt( $auth->passphrase );
     }
   
-  } elsif ( $encoding eq 'plain' ) {
+  } else {
+    warn "unrecognized password encoding '$encoding'; treating as plain text"
+      unless $encoding eq 'plain';
 
     $auth = $self->_blowfishcrypt( $password );
 
@@ -137,6 +231,26 @@ sub insert_password_history {
 
 }
 
+=item delete_password_history;
+
+Removes all password history records attached to this object, in preparation
+to delete the object.
+
+=cut
+
+sub delete_password_history {
+  my $self = shift;
+  my @records = qsearch('password_history', {
+      $self->password_history_key => $self->get($self->primary_key)
+  });
+  my $error = '';
+  foreach (@records) {
+    $error ||= $_->delete;
+  }
+  return $error . ' (clearing password history)' if $error;
+  '';
+}
+
 =item _blowfishcrypt PASSWORD
 
 For internal use: takes PASSWORD and returns a new
@@ -156,6 +270,29 @@ sub _blowfishcrypt {
 
 =back
 
+=head1 CLASS METHODS
+
+=over 4
+
+=item pw_set
+
+Returns the list of characters allowed in random passwords. This is now
+hardcoded.
+
+=cut
+
+sub pw_set {
+
+  # ASCII alphabet, minus easily confused stuff (l, o, O, 0, 1)
+  # and plus some "safe" punctuation
+  split('',
+    'abcdefghijkmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ23456789#.,[]-_=+'
+  );
+
+}
+
+=back
+
 =head1 SEE ALSO
 
 L<FS::password_history>