account recovery for hashed passwords
authorTony Cook <tony@develop-help.com>
Fri, 10 Jun 2011 10:18:39 +0000 (20:18 +1000)
committerTony Cook <tony@develop-help.com>
Fri, 10 Jun 2011 10:18:39 +0000 (20:18 +1000)
14 files changed:
MANIFEST
schema/bse.sql
site/cgi-bin/bse.cfg
site/cgi-bin/modules/BSE/UserReg.pm
site/cgi-bin/modules/SiteUser.pm
site/cgi-bin/modules/SiteUsers.pm
site/data/db/bse_msg_base.data
site/data/db/bse_msg_defaults.data
site/docs/config.pod
site/templates/user/base_lost_prompt.tmpl [new file with mode: 0644]
site/templates/user/lostemailsent_base.tmpl
site/templates/user/lostpassword_base.tmpl
site/templates/user/lostpwdemail.tmpl
site/util/mysql.str

index 46911d0..4c71953 100644 (file)
--- a/MANIFEST
+++ b/MANIFEST
@@ -706,6 +706,7 @@ site/templates/user/base_bookinglist.tmpl
 site/templates/user/base_bookseminar.tmpl
 site/templates/user/base_cancelbooking.tmpl
 site/templates/user/base_editbooking.tmpl
+site/templates/user/base_lost_prompt.tmpl
 site/templates/user/base_orderdetail.tmpl
 site/templates/user/base_orderdetaila.tmpl
 site/templates/user/base_redirect.tmpl
index a8fc474..307309c 100644 (file)
@@ -639,6 +639,14 @@ create table site_users (
 
   password_type varchar(20) not null default 'plain',
 
+  -- for password recovery
+  -- number of attempts today
+  lost_today integer not null default 0,
+  -- what today refers to
+  lost_date date null,
+  -- the hash the customer needs to supply to change their password
+  lost_id varchar(32) null,
+
   primary key (id),
   unique (userId),
   index (affiliate_name)
index a427074..5c17327 100644 (file)
@@ -37,6 +37,7 @@ user/email_conferror.tmpl = user,user/email_conferror_base.tmpl
 user/logon.tmpl = user,user/logon_base.tmpl
 user/lostemailsent.tmpl = user,user/lostemailsent_base.tmpl
 user/lostpassword.tmpl = user,user/lostpassword_base.tmpl
+user/lost_prompt.tmpl = user,user/base_lost_prompt.tmpl
 user/nopassword.tmpl = user,user/nopassword_base.tmpl
 user/options.tmpl = user,user/options_base.tmpl
 user/options_saved.tmpl = user,user/options_saved_base.tmpl
index d548114..61cab8d 100644 (file)
@@ -18,7 +18,7 @@ use BSE::Util::Iterate;
 use base 'BSE::UI::UserCommon';
 use Carp qw(confess);
 
-our $VERSION = "1.014";
+our $VERSION = "1.015";
 
 use constant MAX_UNACKED_CONF_MSGS => 3;
 use constant MIN_UNACKED_CONF_GAP => 2 * 24 * 60 * 60;
@@ -37,6 +37,8 @@ my %actions =
    download_file=>'download_file',
    show_lost_password => 'show_lost_password',
    lost_password => 'lost_password',
+   lost => 1,
+   lost_save => 1,
    subinfo => 'subinfo',
    blacklist => 'blacklist',
    confirm => 'confirm',
@@ -115,6 +117,9 @@ sub req_show_logon {
     $message = escape_html($message);
     $errors = {};
   }
+  else {
+    $message = $req->message();
+  }
   my %acts;
   %acts =
     (
@@ -672,12 +677,10 @@ sub req_saveopts {
          $errors{old_password} = $msgs->(optsbadold=>"You need to enter your old password to change your password")
        }
        else {
-         my $min_pass_length = $cfg->entry('basic', 'minpassword') || 4;
          my $error;
-         if (length $newpass < $min_pass_length) {
-           $errors{password} = $msgs->(optspasslen=>
-                                       "The password must be at least $min_pass_length characters",
-                                       $min_pass_length);
+         if (!SiteUser->check_password_rules($newpass, \$error)) {
+           my ($code, @more) = @$error;
+           $errors{password} = $req->catmsg("msg:bse/user/$code", \@more)
          }
          elsif (!defined $confirm || length $confirm == 0) {
            $errors{confirm_password} = $msgs->(optsconfpass=>"Please enter a confirmation password");
@@ -1753,39 +1756,10 @@ sub req_lost_password {
   keys %errors
     and return $self->req_show_lost_password($req, \%errors);
 
-  my $custom = custom_class($cfg);
-  my $email_user = $user;
-  if ($custom->can('send_user_email_to')) {
-    eval {
-      $email_user = $custom->send_user_email_to($user, $cfg);
-    };
-  }
-  require BSE::ComposeMail;
-  my $mail = BSE::ComposeMail->new(cfg => $cfg);
-
-  my %mailacts;
-  %mailacts =
-    (
-     BSE::Util::Tags->static(\%mailacts, $cfg),
-     user => sub { $user->{$_[0]} },
-     host => sub { $ENV{REMOTE_ADDR} },
-     site => sub { $cfg->entryErr('site', 'url') },
-     emailuser => [ \&tag_hash_plain, $email_user ],
-    );
-  my $from = $cfg->entry('confirmations', 'from') || 
-    $cfg->entry('basic', 'emailfrom') || $SHOP_FROM;
-  my $nopassword = $cfg->entryBool('site users', 'nopassword', 0);
-  my $subject = $cfg->entry('basic', 'lostpasswordsubject') 
-    || ($nopassword ? "Your options" : "Your password");
-  $mail->send(template => 'user/lostpwdemail',
-               acts => \%mailacts,
-               from=>$from,
-               to=>$email_user->{email},
-               subject=>$subject)
-    or return $self->req_show_lost_password($req,
-                                       $msgs->(lostmailerror=>
-                                               "Email error:".$mail->errstr,
-                                               $mail->errstr));
+  my $error;
+  my $email_user = $user->lost_password(\$error)
+    or return $self->req_show_lost_password
+      ($req, $msgs->(lostmailerror=> "Email error: .$error", $error));
   $message = $message ? escape_html($message) : $req->message;
   my %acts;
   %acts = 
@@ -2356,4 +2330,79 @@ sub req_downufile {
       or return $self->error($req, $msg);
 }
 
+sub req_lost {
+  my ($self, $req, $errors) = @_;
+
+  my ($id) = $self->rest;
+  $id ||= $req->cgi->param("id");
+  $id
+    or return $self->req_show_logon($req, $req->catmsg("msg:bse/user/nolostid"));
+
+  my $error;
+  my $user = SiteUsers->lost_password_next($id, \$error)
+    or return $self->req_show_logon($req, { _ => "msg:bse/user/lost/$error" });
+
+  my $message = $req->message($errors);
+
+  my %acts =
+    (
+     $req->dyn_user_tags,
+     lostid => $id,
+     error_img => [ \&tag_error_img, $req->cfg, $errors ],
+     message => $message,
+    );
+
+  return $req->response("user/lost_prompt", \%acts);
+}
+
+my %lost_fields =
+  (
+   password =>
+   {
+    description => "New Password",
+    required => 1,
+   },
+   confirm =>
+   {
+    description => "Confirm Password",
+    rules => "confirm",
+    required => 1,
+   },
+  );
+
+sub req_lost_save {
+  my ($self, $req) = @_;
+
+  my ($id) = $self->rest;
+  $id ||= $req->cgi->param("id");
+  $id
+    or return $self->req_show_logon($req, $req->catmsg("msg:bse/user/nolostid"));
+
+  my %errors;
+  $req->validate(fields => \%lost_fields,
+                errors => \%errors);
+  my $password = $req->cgi->param("password");
+  unless ($errors{password}) {
+    my $error;
+    unless (SiteUser->check_password_rules($password, \$error)) {
+      my ($errorid, @more) = @$error;
+      $errors{password} = $req->catmsg("msg:bse/user/$errorid", \@more)
+    }
+  }
+
+  keys %errors
+    and return $self->req_lost($req, \%errors);
+
+  my $error;
+
+  my $user = SiteUsers->lost_password_save($id, $password, \$error)
+    or return $self->req_show_logon($req, "msg:bse/user/lost/$error");
+
+  $req->flash("msg:bse/user/lostsaved");
+
+  return $req->get_refresh($req->cfg->user_url("user", "show_logon"));
+}
+
+
+
 1;
index 69c0fe2..ec90644 100644 (file)
@@ -8,7 +8,7 @@ use Constants qw($SHOP_FROM);
 use Carp qw(confess);
 use BSE::Util::SQL qw/now_datetime now_sqldate sql_normal_date sql_add_date_days/;
 
-our $VERSION = "1.005";
+our $VERSION = "1.006";
 
 use constant MAX_UNACKED_CONF_MSGS => 3;
 use constant MIN_UNACKED_CONF_GAP => 2 * 24 * 60 * 60;
@@ -28,7 +28,8 @@ sub columns {
             affiliate_name delivMobile billMobile
             delivStreet2 billStreet2
             billOrganization
-            customInt1 customInt2 password_type/;
+            customInt1 customInt2 password_type
+            lost_today lost_date lost_id/;
 }
 
 sub table {
@@ -93,7 +94,10 @@ sub defaults {
      billOrganization => "",
      customInt1 => "",
      customInt2 => "",
-     #password_type
+     #password_type,
+     lost_today => 0,
+     lost_date => undef,
+     lost_id => undef,
     );
 }
 
@@ -791,7 +795,7 @@ sub send_registration_notify {
 }
 
 sub changepw {
-  my ($self, $password, $who) = @_;
+  my ($self, $password, $who, %log) = @_;
 
   require BSE::Passwords;
 
@@ -808,6 +812,7 @@ sub changepw {
        actor => $who,
        level => "info",
        msg => "Change password",
+       %log,
       );
 
   1;
@@ -820,4 +825,92 @@ sub check_password {
   return BSE::Passwords->check_password_hash($self->password, $self->password_type, $password, $error);
 }
 
+=item lost_password
+
+Call to send a lost password email.
+
+=cut
+
+sub lost_password {
+  my ($self, $error) = @_;
+
+  my $cfg = BSE::Cfg->single;
+  require BSE::CfgInfo;
+  my $custom = BSE::CfgInfo::custom_class($cfg);
+  my $email_user = $self;
+  my $to = $self;
+  if ($custom->can('send_user_email_to')) {
+    eval {
+      $email_user = $custom->send_user_email_to($self, $cfg);
+    };
+    $to = $email_user->{email};
+  }
+  else {
+    require BSE::Util::SQL;
+    my $lost_limit = $cfg->entry("lost password", "daily_limit", 3);
+    my $today = BSE::Util::SQL::now_sqldate();
+    my $lost_today = 0;
+    if ($self->lost_date
+       && $self->lost_date eq $today) {
+      $lost_today = $self->lost_today;
+    }
+    if ($lost_today+1 > $lost_limit) {
+      $$error = "Too many password recovery attempts today, please try again tomorrow";
+      return;
+    }
+    $self->set_lost_date($today);
+    $self->set_lost_today($lost_today+1);
+    $self->set_lost_id(BSE::Util::Secure::make_secret($cfg));
+  }
+
+  require BSE::ComposeMail;
+  my $mail = BSE::ComposeMail->new(cfg => $cfg);
+
+  require BSE::Util::Tags;
+  my %mailacts;
+  %mailacts =
+    (
+     BSE::Util::Tags->mail_tags(),
+     user => [ \&BSE::Util::Tags::tag_object_plain, $self ],
+     host => $ENV{REMOTE_ADDR},
+     site => $cfg->entryErr('site', 'url'),
+     emailuser => [ \&BSE::Util::Tags::tag_hash_plain, $email_user ],
+    );
+  my $from = $cfg->entry('confirmations', 'from') || 
+    $cfg->entry('basic', 'emailfrom') || $SHOP_FROM;
+  my $nopassword = $cfg->entryBool('site users', 'nopassword', 0);
+  my $subject = $cfg->entry('basic', 'lostpasswordsubject') 
+    || ($nopassword ? "Your options" : "Your password");
+  unless ($mail->send
+         (
+          template => 'user/lostpwdemail',
+          acts => \%mailacts,
+          from=>$from,
+          to => $to,
+          subject=>$subject,
+          log_msg => "Sending lost password recovery email",
+          log_component => "siteusers:lost:send",
+          log_object => $self,
+         )) {
+    $$error = $mail->errstr;
+    return;
+  }
+  $self->save;
+
+  return $email_user;
+}
+
+sub check_password_rules {
+  my ($class, $password, $error) = @_;
+
+  my $cfg = BSE::Cfg->single;
+  my $min_pass_length = $cfg->entry('basic', 'minpassword') || 4;
+  if (length $password < $min_pass_length) {
+    $$error = [ "passwordlen", $min_pass_length ];
+    return;
+  }
+
+  return 1;
+}
+
 1;
index 5bc6545..03045ef 100644 (file)
@@ -5,7 +5,7 @@ use vars qw(@ISA $VERSION);
 @ISA = qw(Squirrel::Table);
 use SiteUser;
 
-our $VERSION = "1.001";
+our $VERSION = "1.002";
 
 sub rowClass {
   return 'SiteUser';
@@ -36,4 +36,51 @@ sub make {
   return $self->SUPER::make(%opts);
 }
 
+sub _lost_user {
+  my ($self, $id, $error) = @_;
+
+  my ($user) = SiteUsers->getBy(lost_id => $id);
+  unless ($user) {
+    $$error = "unknownid";
+    return;
+  }
+
+  require BSE::Util::SQL;
+  my $lost_limit_days = BSE::Cfg->single->entry("lost password", "age_limit", 7);
+  my $check_date = BSE::Util::SQL::sql_add_date_days($user->lost_date, $lost_limit_days);
+
+  my $today = BSE::Util::SQL::now_sqldate();
+
+  if ($check_date lt $today) {
+    $$error = "expired";
+    return;
+  }
+
+  return $user;
+}
+
+sub lost_password_next {
+  my ($self, $id, $error) = @_;
+
+  my $user = $self->_lost_user($id, $error)
+    or return;
+
+  return $user;
+}
+
+sub lost_password_save {
+  my ($self, $id, $password, $error) = @_;
+
+  my $user = $self->_lost_user($id, $error)
+    or return;
+
+  $user->changepw($password, $user,
+                 component => "siteusers:lost:changepw",
+                 msg => "Account recovered");
+  $user->set_lost_id("");
+  $user->save;
+
+  return 1;
+}
+
 1;
index 459e799..81ab544 100644 (file)
@@ -32,6 +32,24 @@ description: Message displayed for an invalid affiliate name
 id: bse/user/optsrequired
 description: Message displayed for a required field during registration or when saving user details
 
+id: bse/user/nolostid
+description: Displayed if the lost handler doesn't receive an id.
+
+id: bse/user/passwordlen
+description: Displayed when a new password is too short, $1 is the min length
+
+id: bse/user/lostsaved
+description: Displayed when a new password is saved during account recovery
+
+id: bse/user/lost/
+description: Lost password errors
+
+id: bse/user/lost/unknownid
+description: Displayed if the supplied lost password id isn't known
+
+id: bse/user/lost/expired
+description: Displayed if the supplied lost password has expired
+
 id: bse/admin/
 description: BSE Administration
 
index 5aa6e75..e0e4677 100644 (file)
@@ -13,6 +13,21 @@ message: Please enter your password
 id: bse/user/baduserpass
 message: Invalid username or password
 
+id: bse/user/nolostid
+message: No id supplied for password recovery
+
+id: bse/user/passwordlen
+message: New password must be at least %1:s characters
+
+id: bse/user/lostsaved
+message: Your new password has been saved, please logon
+
+id: bse/user/lost/unknownid
+message: Unknown identifier for account recovery.  Please try again.
+
+id: bse/user/lost/expired
+message: Your account recovery URL is too old.  Please try again.
+
 id: bse/admin/edit/uplabelsect
 message: -- move up a level -- become a section
 
index cf19c8d..710ecb2 100644 (file)
@@ -2502,6 +2502,22 @@ based on the user's settings.
 
 =back
 
+=head2 [lost password]
+
+=over
+
+=item *
+
+daily_limit - the number of recovery attempts permitted per day.
+Default: 3.
+
+=item *
+
+age_limit - the id included in the email is valid for this many days.
+Default: 7.
+
+=back
+
 =head1 AUTHOR
 
 Tony Cook <tony@develop-help.com>
diff --git a/site/templates/user/base_lost_prompt.tmpl b/site/templates/user/base_lost_prompt.tmpl
new file mode 100644 (file)
index 0000000..814cca0
--- /dev/null
@@ -0,0 +1,37 @@
+<:wrap base.tmpl:>
+<div align="center">
+  <table>
+    <tr> 
+      <th colspan="2" align="center"> 
+        <p><b><font face="Verdana, Arial, Helvetica, sans-serif" size="3"><:if Cfg "site users" nopassword:><:or Cfg:>Account Recovery<:eif Cfg:></font></b></p>
+      </th>
+    </tr>
+    <form action="/cgi-bin/user.pl" method="post">
+<input type="hidden" name="id" value="<:lostid:>" />
+<:ifMessage:>
+      <tr> 
+        <td colspan="2" align="center"><font face="Verdana, Arial, Helvetica, sans-serif" size="2"> 
+          <b><:message:></b> </font></td>
+      </tr><:or:><:eif:>
+      <tr> 
+        <th>New Password:</th>
+        <td> 
+          <input type="password" name="password" value="" size="40" id="password" accesskey="p" tabindex="10" />
+       </td>
+       <td><:error_img password:></td>
+      </tr>
+      <tr> 
+        <th>Confirm Password:</th>
+        <td> 
+          <input type="password" name="confirm" value="" size="40" id="confirm" accesskey="c" tabindex="20" />
+       </td>
+       <td><:error_img confirm:></td>
+      </tr>
+      <tr> 
+        <td colspan="3" align="right"> 
+          <input type="submit" name="a_lost_save" value="Save Password" tabindex="30" />
+        </td>
+      </tr>
+    </form>
+  </table>
+</div>
\ No newline at end of file
index d33a15b..868db04 100644 (file)
@@ -15,7 +15,7 @@
 <:or Cfg:>
     <tr> 
       <td align="center"><font face="Verdana, Arial, Helvetica, sans-serif" size="2"> 
-        Your password has been emailed to <b><:emailuser email:></b>. </font></td>
+        A link to the next step in password recovery has been sent to <b><:emailuser email:></b>. </font></td>
     </tr>
     <tr> 
       <td align="center"> <br>
index db90ee1..c8084f6 100644 (file)
@@ -16,8 +16,7 @@
 <:or Cfg:>
       <tr> 
         <td colspan="2"><font face="Verdana, Arial, Helvetica, sans-serif" size="2"> 
-          Please enter your Logon Name. Your password will be emailed to the address 
-          you entered when you registered. </font></td>
+          Please enter your Logon Name.  You will be sent a URL you can use to change your password. </font></td>
       </tr>
 <:eif Cfg:>
 <:ifMessage:>
@@ -35,7 +34,7 @@
       </tr>
       <tr> 
         <td colspan="2" align="right"> 
-          <input type="submit" name="lost_password" value="<:if Cfg "site users" nopassword:>Mail Options Link<:or Cfg:>Mail Password<:eif Cfg:>" />
+          <input type="submit" name="lost_password" value="<:if Cfg "site users" nopassword:>Mail Options Link<:or Cfg:>Next<:eif Cfg:>" />
         </td>
       </tr>
     </form>
index 36bacc2..252c112 100644 (file)
@@ -13,16 +13,12 @@ The link to access your options is:
 
   <:cfg site url:>/cgi-bin/user.pl?show_opts=<:user password:>&u=<:user id:>
 
-<:or Cfg:>We have received a request from <:host:> asking 
-<:site:> to send you a reminder of your password.
+<:or Cfg:>We have received a request from <:host:> indicating that
+you've forgotten or lost your password.
 
-If you did not make this request, please be reassured that your password is 
-only forwarded to your registered email address for security reasons.  
-No-one else can access it.
-
-Your password is:
+To take the next step in recovering your account, visit:
 
-  <:user password:>
+<:cfg site secureurl:>/cgi-bin/user.pl/lost/<:user lost_id:>
 
 <:eif Cfg:>Any questions should be directed to: <:adminEmail:>
 
index d32d37b..6dfcfc3 100644 (file)
@@ -656,6 +656,9 @@ Column billOrganization;varchar(127);NO;;
 Column customInt1;int(11);YES;NULL;
 Column customInt2;int(11);YES;NULL;
 Column password_type;varchar(20);NO;plain;
+Column lost_today;int(11);NO;0;
+Column lost_date;date;YES;NULL;
+Column lost_id;varchar(32);YES;NULL;
 Index PRIMARY;1;[id]
 Index affiliate_name;0;[affiliate_name]
 Index userId;1;[userId]