implement account and IP address lockouts for site and admin users
authorTony Cook <tony@develop-help.com>
Wed, 13 Mar 2013 00:45:14 +0000 (11:45 +1100)
committerTony Cook <tony@develop-help.com>
Sat, 16 Mar 2013 00:48:22 +0000 (11:48 +1100)
35 files changed:
MANIFEST
MANIFEST.SKIP
schema/bse.sql
schema/bse_sp.sql [new file with mode: 0644]
site/cgi-bin/admin/bse_modules.pl
site/cgi-bin/bse.cfg
site/cgi-bin/modules/BSE/AdminLogon.pm
site/cgi-bin/modules/BSE/AdminSiteUsers.pm
site/cgi-bin/modules/BSE/AdminUsers.pm
site/cgi-bin/modules/BSE/DB/Mysql.pm
site/cgi-bin/modules/BSE/Request/Base.pm
site/cgi-bin/modules/BSE/TB/AdminUser.pm
site/cgi-bin/modules/BSE/TB/AuditEntry.pm
site/cgi-bin/modules/BSE/TB/IPLockout.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/TB/IPLockouts.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/UI/AdminIPAddress.pm [new file with mode: 0644]
site/cgi-bin/modules/BSE/UserReg.pm
site/cgi-bin/modules/BSE/Util/Lockouts.pm [new file with mode: 0644]
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/data/db/sql_statements.data
site/docs/config.pod
site/htdocs/css/admin.css
site/templates/admin/include/activity.tmpl [new file with mode: 0644]
site/templates/admin/ip/detail.tmpl [new file with mode: 0644]
site/templates/admin/ip/list.tmpl [new file with mode: 0644]
site/templates/admin/showuser.tmpl
site/templates/admin/userlist.tmpl
site/templates/admin/users/inc_user_menu.tmpl
site/templates/admin/users/list.tmpl
site/templates/admin/users/list_low.tmpl
site/util/mysql.str
t/data/known_pod_issues.txt

index c6e3de6..1f5d99b 100644 (file)
--- a/MANIFEST
+++ b/MANIFEST
@@ -12,6 +12,7 @@ MANIFEST.SKIP
 README
 schema/article.txt
 schema/bse.sql
+schema/bse_sp.sql
 schema/mssql.sql
 schema/mysql_build.pl          # builds site/util/mysql.str
 schema/site_users_to_members.sql
@@ -187,6 +188,8 @@ site/cgi-bin/modules/BSE/TB/FileAccessLogEntry.pm
 site/cgi-bin/modules/BSE/TB/Files.pm
 site/cgi-bin/modules/BSE/TB/Image.pm
 site/cgi-bin/modules/BSE/TB/Images.pm
+site/cgi-bin/modules/BSE/TB/IPLockout.pm
+site/cgi-bin/modules/BSE/TB/IPLockouts.pm
 site/cgi-bin/modules/BSE/TB/Location.pm
 site/cgi-bin/modules/BSE/TB/Locations.pm
 site/cgi-bin/modules/BSE/TB/Order.pm
@@ -241,6 +244,7 @@ site/cgi-bin/modules/BSE/UI/AdminAudit.pm
 site/cgi-bin/modules/BSE/UI/AdminDispatch.pm
 site/cgi-bin/modules/BSE/UI/AdminImageClean.pm
 site/cgi-bin/modules/BSE/UI/AdminImporter.pm
+site/cgi-bin/modules/BSE/UI/AdminIPAddress.pm
 site/cgi-bin/modules/BSE/UI/AdminMessages.pm
 site/cgi-bin/modules/BSE/UI/AdminModules.pm
 site/cgi-bin/modules/BSE/UI/AdminNewsletter.pm
@@ -278,6 +282,7 @@ site/cgi-bin/modules/BSE/Util/DynSort.pm
 site/cgi-bin/modules/BSE/Util/Format.pm
 site/cgi-bin/modules/BSE/Util/HTML.pm
 site/cgi-bin/modules/BSE/Util/Iterate.pm
+site/cgi-bin/modules/BSE/Util/Lockouts.pm
 site/cgi-bin/modules/BSE/Util/PasswordValidate.pm
 site/cgi-bin/modules/BSE/Util/Prereq.pm
 site/cgi-bin/modules/BSE/Util/Secure.pm
@@ -571,6 +576,7 @@ site/templates/admin/imageclean/intro.tmpl
 site/templates/admin/imageclean/preview.tmpl
 site/templates/admin/import/import.tmpl
 site/templates/admin/import/start.tmpl
+site/templates/admin/include/activity.tmpl
 site/templates/admin/include/article_cfg_custom.tmpl
 site/templates/admin/include/article_menu.tmpl
 site/templates/admin/include/auditentry.tmpl
@@ -582,6 +588,8 @@ site/templates/admin/include/order_list_filter.tmpl
 site/templates/admin/include/order_list_pages.tmpl
 site/templates/admin/include/site_menu.tmpl
 site/templates/admin/interestemail.tmpl
+site/templates/admin/ip/detail.tmpl
+site/templates/admin/ip/list.tmpl
 site/templates/admin/locations/add.tmpl
 site/templates/admin/locations/delete.tmpl
 site/templates/admin/locations/edit.tmpl
index 838a1a6..29e4ea9 100644 (file)
@@ -23,3 +23,4 @@
 ^\.?\#
 /\.?\#
 ^cover_db/
+^bse-\d.\d
\ No newline at end of file
index da48ac0..eceb3c1 100644 (file)
@@ -648,6 +648,9 @@ create table bse_siteusers (
 
   customWhen1 datetime,
 
+  -- when the account lock-out (if any) ends
+  lockout_end datetime,
+
   primary key (id),
   unique (userId),
   index (affiliate_name),
@@ -723,6 +726,10 @@ create table admin_users (
   password varchar(255) not null,
   perm_map varchar(255) not null,
   password_type varchar(20) not null default 'plain',
+
+  -- when the account lock-out (if any) ends
+  lockout_end datetime,
+
   primary key (base_id),
   unique (logon)
 );
@@ -1316,3 +1323,17 @@ create table bse_tag_category_deps (
 
   unique cat_dep(cat_id, depname)
 );
+
+drop table if exists bse_ip_lockouts;
+create table bse_ip_lockouts (
+  id integer not null auto_increment primary key,
+
+  ip_address varchar(20) not null,
+
+  -- S or A for site user or admin user lockouts
+  type char not null,
+
+  expires datetime not null,
+
+  unique ip_address(ip_address, type)
+) type=innodb;
\ No newline at end of file
diff --git a/schema/bse_sp.sql b/schema/bse_sp.sql
new file mode 100644 (file)
index 0000000..7c413ee
--- /dev/null
@@ -0,0 +1,11 @@
+delimiter ;;
+drop procedure if exists bse_ip_lockout;
+create procedure bse_ip_lockout(
+  pip_address varchar(20),
+  ptype char,
+  plockout_end datetime)
+begin
+  insert bse_ip_lockouts(ip_address, type, expires)
+    values(pip_address, ptype, plockout_end)
+    on duplicate key update expires = plockout_end;
+end;;
index 5913245..89cc6dc 100755 (executable)
@@ -45,6 +45,7 @@ my @base_check =
       'File::Slurp' => 0,
       'Time::HiRes' => 0,
       'WWW::Mechanize' => 0,
+      'Net::IP' => 0,
      }
    },
    {
index b250961..52e4746 100644 (file)
@@ -211,7 +211,7 @@ articles=not(3)
 [permission full_access]
 brief=Full access (Site)
 help=Full access to the article.  The user can modify all modifiable fields, delete and add articles at will.
-permissions=edit_*,regen_*,bse_*
+permissions=edit_*,regen_*,bse_*,admin_*
 descendants=1
 articles=-1
 
@@ -443,6 +443,7 @@ shopadmin=BSE::UI::AdminShop
 modules=BSE::UI::AdminModules
 log=BSE::UI::AdminAudit
 importer=BSE::UI::AdminImporter
+ipaddress=BSE::UI::AdminIPAddress
 
 [includes]
 00install=bse-install.cfg
index 4becb89..30e13ed 100644 (file)
@@ -4,7 +4,7 @@ use BSE::Util::Tags qw(tag_error_img);
 use BSE::Util::HTML;
 use BSE::CfgInfo 'admin_base_url';
 
-our $VERSION = "1.004";
+our $VERSION = "1.005";
 
 my %actions =
   (
@@ -33,13 +33,7 @@ sub dispatch {
 sub req_logon_form {
   my ($class, $req, $errors) = @_;
 
-  my $msg = (!$errors || ref $errors) ? $req->message($errors) : escape_html($errors);
-
-  $errors ||= {};
-  $errors = ref $errors ? $errors : { _ => $errors };
-
-  $req->set_variable(errors => $errors);
-  $req->set_variable(message => $msg);
+  my $msg = $req->message($errors);
 
   my %acts;
   %acts =
@@ -95,7 +89,7 @@ sub _service_error {
       );
   }
   else {
-    return $self->req_logon_form($req, $errors);
+    return $self->req_logon_form($req, ref $errors ? $errors : { _ => $errors });
   }
 }
 
@@ -129,6 +123,14 @@ sub req_logon {
     and return $class->_service_error($req, \%errors, "FIELD");
   require BSE::TB::AdminUsers;
   my $user = BSE::TB::AdminUsers->getBy(logon=>$logon);
+
+  if ($req->ip_locked_out("A")) {
+    return $class->_service_error($req, "IP Address blocked due to excessive logon failures", "IPBLOCKED");
+  }
+  elsif ($user && $user->locked_out) {
+    return $class->_service_error($req, "User account locked due to excessive logon failures", "USERLOCKED");
+  }
+
   my $match;
   if ($user) {
     my $error;
@@ -141,13 +143,19 @@ sub req_logon {
   unless ($user && $match) {
     $req->audit
       (
-       component => "adminlogon:logon:failure",
+       component => "adminlogon:logon:invalid",
        level => "error",
        msg => "Failed logon attempt",
        actor => "U",
        object => $user,
        dump => "Logon: $logon",
       );
+    BSE::TB::AdminUser->check_lockouts
+       (
+        request => $req,
+        user => $user,
+       );
+
     return $class->_service_error($req, "Invalid logon or password", "INVALID");
   }
   $req->session->{adminuserid} = $user->{id};
index c1c8799..81d3158 100644 (file)
@@ -13,7 +13,7 @@ use constant SITEUSER_GROUP_SECT => 'BSE Siteuser groups validation';
 use BSE::Template;
 use DevHelp::Date qw(dh_parse_date_sql dh_parse_time_sql);
 
-our $VERSION = "1.009";
+our $VERSION = "1.010";
 
 my %actions =
   (
@@ -25,6 +25,7 @@ my %actions =
    deleteform        => 'bse_members_user_delete',
    delete            => 'bse_members_user_delete',
    view              => 'bse_members_user_view',
+   unlock            => 'bse_members_user_unlock',
    grouplist        => 'bse_members_group_list',
    addgroupform             => 'bse_members_group_add',
    addgroup         => 'bse_members_group_add',
@@ -118,11 +119,13 @@ sub req_list {
   my ($sortby, $reverse) =
     sorter(data=>\@users, cgi=>$cgi, sortby=>'userId', session=>$req->session,
           name=>'siteusers', fields=> { id => {numeric => 1 } });
-  my $it = BSE::Util::Iterate->new;
+  my $it = BSE::Util::Iterate::Objects->new;
 
   my $search_param =
     join('&', map { "$_=".escape_uri($search_fields{$_}) } keys %search_fields);
 
+  $req->set_variable(siteusers => \@users);
+
   my %acts;
   %acts =
     (
@@ -252,13 +255,9 @@ sub _display_user {
 
   my $it = BSE::Util::Iterate->new;
 
-  $errors ||= {};
-  if ($msg) {
-    $msg = escape_html($msg);
-  }
-  else {
-    $msg = $req->message($errors);
-  }
+  $msg = $req->message($msg || $errors);
+
+  $req->set_variable(siteuser => $siteuser);
 
   require BSE::TB::OwnedFiles;
   my @file_cats = BSE::TB::OwnedFiles->categories($req->cfg);
@@ -828,6 +827,27 @@ sub save_subs {
   $found;
 }
 
+sub req_unlock {
+  my ($class, $req) = @_;
+
+  my $cgi = $req->cgi;
+  my $cfg = $req->cfg;
+
+  my $id = $cgi->param('id');
+  $id && $id =~ /^\d+$/
+    or return $class->req_list($req, "No user id supplied");
+
+  my $user = SiteUsers->getByPkey($id)
+    or return $class->req_list($req, "No user $id found");
+
+  $user->unlock(request => $req);
+  $req->flash_notice("msg:bse/user/unlocked", [ $user ]);
+
+  my $uri = $cgi->param("r") || $cfg->admin_url2("siteusers", "list");
+
+  return $req->get_refresh($uri);
+}
+
 sub _validate_affiliate_name {
   my ($req, $aff_name, $errors, $user) = @_;
 
index 71d360e..18d6bf1 100644 (file)
@@ -5,8 +5,11 @@ use BSE::Permissions;
 use BSE::Util::HTML qw(:default popup_menu);
 use BSE::CfgInfo qw(admin_base_url);
 use BSE::Template;
+use BSE::TB::AdminUsers;
+use BSE::TB::AdminGroups;
+use BSE::Util::Iterate;
 
-our $VERSION = "1.004";
+our $VERSION = "1.005";
 
 my %actions =
   (
@@ -17,6 +20,7 @@ my %actions =
    saveuserart=>1,
    adduserform => 1,
    adduser=>1,
+   unlock => 1,
    groups=>1,
    showgroupart=>1,
    showgroup=>1,
@@ -51,14 +55,12 @@ sub dispatch {
 sub iter_get_users {
   my ($req) = @_;
 
-  require BSE::TB::AdminUsers;
   return BSE::TB::AdminUsers->all;
 }
 
 sub iter_get_groups {
   my ($req) = @_;
 
-  require BSE::TB::AdminGroups;
   return BSE::TB::AdminGroups->all;
 }
 
@@ -141,12 +143,21 @@ sub common_tags {
   my $user_index;
   my @groups;
   my $group_index;
+  $req->set_variable_class(users => "BSE::TB::AdminUsers");
+  $req->set_variable_class(groups => "BSE::TB::AdminGroups");
+  my $ito = BSE::Util::Iterate::Objects->new;
   return
     (
      $req->admin_tags,
      message => $msg,
-     DevHelp::Tags->make_iterator2
-     ([ \&iter_get_users, $req ], 'iuser', 'users', \@users, \$user_index),
+     $ito->make
+     (
+      code => [ all => "BSE::TB::AdminUsers" ],
+      single => "iuser",
+      plural => "users",
+      data => \@users,
+      index => \$user_index,
+     ),
      DevHelp::Tags->make_iterator2
      ([ \&iter_get_groups, $req ], 'igroup', 'groups', \@groups, 
       \$group_index),
@@ -406,6 +417,7 @@ sub req_showuser {
   require BSE::TB::AdminUsers;
   my $user = BSE::TB::AdminUsers->getByPkey($userid)
     or return $class->req_users($req, "User id $userid not found");
+  $req->set_variable("user", $user);
   my %acts;
   %acts =
     (
@@ -955,4 +967,28 @@ sub req_delgroup {
                         'm' => "Group '$name' deleted");
 }
 
+sub req_unlock {
+  my ($class, $req) = @_;
+
+ $req->user_can("bse_admin_user_unlock")
+    or return $class->req_users($req, "You don't have bse_admin_user_unlock access");
+  
+  my $cgi = $req->cgi;
+  my $cfg = $req->cfg;
+  my $userid = $cgi->param('userid');
+  $userid
+    or return $class->req_users($req, 'No userid supplied');
+  require BSE::TB::AdminUsers;
+  my $user = BSE::TB::AdminUsers->getByPkey($userid)
+    or return $class->req_users($req, "User id $userid not found");
+
+  $user->unlock(request => $req);
+  $req->flash_notice("msg:bse/admin/user/unlocked", [ $user ]);
+
+  my $uri = $cgi->param("r") || $cfg->admin_url2("adminusers");
+
+  return $req->get_refresh($uri);
+}
+
+
 1;
index 6ba9b48..a25abef 100644 (file)
@@ -5,7 +5,7 @@ use vars qw/@ISA/;
 use Carp 'confess';
 @ISA = qw(BSE::DB);
 
-our $VERSION = "1.011";
+our $VERSION = "1.012";
 
 use vars qw($VERSION $MAX_CONNECTION_AGE);
 
@@ -288,8 +288,8 @@ SQL
 select bs.*, us.* from admin_base bs, admin_users us
   where bs.id = us.base_id and bs.id = ?
 SQL
-   addAdminUser => 'insert into admin_users values(?,?,?,?,?,?)',
-   replaceAdminUser => 'replace into admin_users values(?,?,?,?,?,?)',
+   addAdminUser => 'insert into admin_users values(?,?,?,?,?,?,?)',
+   replaceAdminUser => 'replace into admin_users values(?,?,?,?,?,?,?)',
    deleteAdminUser => 'delete from admin_users where base_id = ?',
    "AdminUsers.group_members" => <<SQL,
 select bs.*, us.*
index 32b9ca7..92812bb 100644 (file)
@@ -5,7 +5,7 @@ use BSE::Cfg;
 use BSE::Util::HTML;
 use Carp qw(cluck confess);
 
-our $VERSION = "1.022";
+our $VERSION = "1.023";
 
 =head1 NAME
 
@@ -1560,6 +1560,14 @@ Return the standard admin page tags.
 sub admin_tags {
   my ($req) = @_;
 
+  $req->set_variable
+    (
+     auditlog =>
+     sub {
+       require BSE::TB::AuditLog;
+       Squirrel::Template::Expr::WrapClass->new("BSE::TB::AuditLog")
+     });
+
   require BSE::Util::Tags;
   return
     (
@@ -1799,6 +1807,20 @@ sub cgi_fields {
   return \%values;
 }
 
+=item ip_locked_out
+
+Return true if there's an active IP address lockout of the current IP
+address.
+
+=cut
+
+sub ip_locked_out {
+  my ($self, $type) = @_;
+
+  require BSE::TB::IPLockouts;
+  return BSE::TB::IPLockouts->active($self->ip_address, $type);
+}
+
 1;
 
 =back
index bfc8d4d..51aa67e 100644 (file)
@@ -2,17 +2,34 @@ package BSE::TB::AdminUser;
 use strict;
 use base qw(BSE::TB::AdminBase);
 
-our $VERSION = "1.005";
+our $VERSION = "1.006";
+
+=head1 NAME
+
+BSE::TB::AdminUser - represents an admin user.
+
+=head1 METHODS
+
+=over
+
+=cut
 
 sub columns {
   return ($_[0]->SUPER::columns,
-         qw/base_id logon name password perm_map password_type/);
+         qw/base_id logon name password perm_map password_type lockout_end/);
 }
 
 sub bases {
   return { base_id=>{ class=>'BSE::TB::AdminBase' } };
 }
 
+sub defaults {
+  return
+    (
+     lockout_end => undef,
+    );
+}
+
 sub remove {
   my ($self) = @_;
 
@@ -21,6 +38,12 @@ sub remove {
   $self->SUPER::remove();
 }
 
+=item groups()
+
+return the groups the user is a member of.
+
+=cut
+
 sub groups {
   my ($self) = @_;
 
@@ -95,5 +118,68 @@ sub password_check_fields {
   return qw(name);
 }
 
+=item locked_out
+
+Return true if logons are disabled due to too many authentication
+failures.
+
+=cut
+
+sub locked_out {
+  my ($self) = @_;
+
+  require BSE::Util::SQL;
+  return $self->lockout_end
+    && $self->lockout_end gt BSE::Util::SQL::now_datetime();
+}
+
+sub check_lockouts {
+  my ($class, %opts) = @_;
+
+  require BSE::Util::Lockouts;
+  BSE::Util::Lockouts->check_lockouts
+      (
+       %opts,
+       section => "admin user lockouts",
+       component => "adminlogon",
+       module => "logon",
+       type => $class->lockout_type,
+      );
+}
+
+sub unlock {
+  my ($self, %opts) = @_;
+
+  require BSE::Util::Lockouts;
+  BSE::Util::Lockouts->unlock_user
+      (
+       %opts,
+       user => $self,
+       component => "adminlogon",
+       module => "logon",
+      );
+}
+
+sub unlock_ip_address {
+  my ($class, %opts) = @_;
+
+  require BSE::Util::Lockouts;
+  BSE::Util::Lockouts->unlock_ip_address
+      (
+       %opts,
+       component => "adminlogon",
+       module => "logon",
+       type => $class->lockout_type,
+      );
+}
+
+sub lockout_type {
+  "A";
+}
+
+=back
+
+=cut
+
 1;
 
index 01dbcdf..1589ef2 100644 (file)
@@ -2,7 +2,7 @@ package BSE::TB::AuditEntry;
 use strict;
 use base qw(Squirrel::Row);
 
-our $VERSION = "1.006";
+our $VERSION = "1.007";
 
 sub columns {
   return qw/id 
@@ -120,6 +120,7 @@ my %types =
     action => "showuser",
     format => "Admin: %d",
     class => "BSE::TB::AdminUsers",
+    idname => "userid",
    },
   );
 
@@ -131,8 +132,9 @@ sub object_link {
   my $entry = $types{$type};
     my $cfg = BSE::Cfg->single;
   if ($entry) {
+    my $idname = $entry->{idname} || "id";
     return $cfg->admin_url2($entry->{target}, $entry->{action},
-                           { id => $id });
+                           { $idname => $id });
   }
   else {
     my $link_action = $cfg->entry("type $type", "link_action");
@@ -163,8 +165,7 @@ sub object_name {
       && eval "use $class; 1"
       && ($obj = $class->getByPkey($self->object_id))
       && $obj->can($method)) {
-    $format ||= "%s";
-    return sprintf($format, $obj->$method());
+    return $obj->$method();
   }
   elsif ($format) {
     return sprintf $format, $self->object_id;
diff --git a/site/cgi-bin/modules/BSE/TB/IPLockout.pm b/site/cgi-bin/modules/BSE/TB/IPLockout.pm
new file mode 100644 (file)
index 0000000..48539aa
--- /dev/null
@@ -0,0 +1,15 @@
+package BSE::TB::IPLockout;
+use strict;
+use base 'Squirrel::Row';
+
+our $VERSION = "1.000";
+
+sub columns {
+  qw(id ip_address type expires);
+}
+
+sub table {
+  "bse_ip_lockouts";
+}
+
+1;
diff --git a/site/cgi-bin/modules/BSE/TB/IPLockouts.pm b/site/cgi-bin/modules/BSE/TB/IPLockouts.pm
new file mode 100644 (file)
index 0000000..ea99a43
--- /dev/null
@@ -0,0 +1,27 @@
+package BSE::TB::IPLockouts;
+use strict;
+use base 'Squirrel::Table';
+use BSE::TB::IPLockout;
+use BSE::Util::SQL;
+
+our $VERSION = "1.000";
+
+sub rowClass { "BSE::TB::IPLockout" }
+
+sub active {
+  my ($self, $ip_address, $type) = @_;
+
+  my ($entry) = $self->getColumnBy
+    (
+     "id",
+     [
+      [ ip_address => $ip_address ],
+      [ type => $type ],
+      [ '>', expires => BSE::Util::SQL::now_datetime() ],
+     ],
+    );
+
+  return defined $entry;
+}
+
+1;
diff --git a/site/cgi-bin/modules/BSE/UI/AdminIPAddress.pm b/site/cgi-bin/modules/BSE/UI/AdminIPAddress.pm
new file mode 100644 (file)
index 0000000..19549bc
--- /dev/null
@@ -0,0 +1,159 @@
+package BSE::UI::AdminIPAddress;
+use strict;
+use base "BSE::UI::AdminDispatch";
+use BSE::TB::IPLockouts;
+use Net::IP;
+use BSE::Util::SQL qw(now_datetime);
+
+our $VERSION = "1.000";
+
+my %actions =
+  (
+   list => "bse_ipaddress_list",
+   detail => "bse_ipaddress_detail",
+   unlock => "bse_ipaddress_unlock",
+  );
+
+sub actions { \%actions }
+
+sub default_action { "list" }
+
+sub rights { \%actions }
+
+sub req_list {
+  my ($self, $req, $errors) = @_;
+
+  my %ips;
+  for my $lockout (BSE::TB::IPLockouts->getBy2
+                  (
+                   [
+                    [ '>', expires => now_datetime() ],
+                   ]
+                  )) {
+    $ips{$lockout->ip_address}{"lockout_" . $lockout->type} =  $lockout->expires;
+  }
+  my @ips =
+    map $_->[0],
+    sort { $a->[1]->hexip cmp $b->[1]->hexip }
+      map [ $_, Net::IP->new($_->{ip_address}) ],
+       map
+         {
+           my $ip = $ips{$_};
+           $ip->{ip_address} = $_;
+           $ip;
+         } keys %ips;
+
+  $req->set_variable(ips => \@ips);
+  my $mesage = $req->message($errors);
+
+  my %acts =
+    (
+     $req->admin_tags,
+    );
+
+  return $req->dyn_response("admin/ip/list", \%acts);
+}
+
+sub _ip_check {
+  my ($ip) = @_;
+
+  # currently this only acceps IPv4 addresses and should be updated
+  # later
+
+  $ip =~ /\A(?:[0-9]+\.){3}[0-9]+\z/
+    or return;
+
+  my @nums = split /\./, $ip;
+  @nums == 4
+    or return;
+  grep $_ > 255, @nums
+    and return;
+
+  return 1;
+}
+
+sub req_detail {
+  my ($self, $req, $errors) = @_;
+
+  my $ip = $req->cgi->param("ip");
+  $ip
+    or return $self->req_list($req, { ip => "Missing ip parameter" });
+
+  _ip_check($ip)
+    or return $self->req_list($req, { ip => "Invalid ip parameter" });
+
+  my %ip = ( ip_address => $ip );
+  for my $entry (BSE::TB::IPLockouts->getBy2
+    (
+     [
+      [ '>', expires => now_datetime() ],
+      [ ip_address => $ip ],
+     ]
+    )) {
+    $ip{"lockout_".$entry->type} = $entry->expires;
+  }
+
+  $req->set_variable(ip => \%ip);
+  my $mesage = $req->message($errors);
+
+  my %acts =
+    (
+     $req->admin_tags,
+    );
+
+  return $req->dyn_response("admin/ip/detail", \%acts);
+}
+
+sub req_unlock {
+  my ($self, $req) = @_;
+
+  my $cgi = $req->cgi;
+  my $ip = $cgi->param("ip");
+  $ip
+    or return $self->req_list($req, { ip => "Missing ip parameter" });
+
+  _ip_check($ip)
+    or return $self->req_list($req, { ip => "Invalid ip parameter" });
+
+  my $type = $cgi->param("type");
+
+  my ($entry) = BSE::TB::IPLockouts->getBy
+    (
+     ip_address => $ip,
+     type => $type,
+    );
+
+  if ($entry) {
+    if ($type eq "S") {
+      require SiteUsers;
+      SiteUser->unlock_ip_address
+       (
+        ip_address => $ip,
+        request => $req,
+       );
+      $entry->remove;
+      $req->flash_notice("msg:bse/admin/ipaddress/siteunlock", [ $ip ]);
+    }
+    elsif ($type eq "A") {
+      require BSE::TB::AdminUsers;
+      BSE::TB::AdminUser->unlock_ip_address
+         (
+          ip_address => $ip,
+          request => $req,
+         );
+      $entry->remove;
+      $req->flash_notice("msg:bse/admin/ipaddress/adminunlock", [ $ip ]);
+    }
+    else {
+      $req->flash_notice("Unknown lock type '$type'");
+    }
+  }
+  else {
+    $req->flash_notice("No lock to remove");
+  }
+
+  my $url = $cgi->param("r") || $req->cfg->admin_url2("ipaddress", "list");
+  return $req->get_refresh($url);
+}
+
+1;
index 45b6a28..d9c616f 100644 (file)
@@ -18,7 +18,7 @@ use BSE::Util::Iterate;
 use base 'BSE::UI::UserCommon';
 use Carp qw(confess);
 
-our $VERSION = "1.025";
+our $VERSION = "1.026";
 
 use constant MAX_UNACKED_CONF_MSGS => 3;
 use constant MIN_UNACKED_CONF_GAP => 2 * 24 * 60 * 60;
@@ -231,13 +231,34 @@ sub req_logon {
   my $password = $cgi->param("password");
   unless (keys %errors) {
     $user = SiteUsers->getBy(userId => $userid);
-    my $error = "INVALID";
-    unless ($user && $user->check_password($password, \$error)) {
-      if ($error eq "INVALID") {
-       $errors{_} = $msgs->(baduserpass=>"Invalid username or password");
-      }
-      else {
-       $errors{_} = $msgs->(passwordload => "Error loading password module");
+    if ($req->ip_locked_out("S")) {
+      $errors{_} = "msg:bse/user/iplockout:".$req->ip_address;
+    }
+    elsif ($user && $user->locked_out) {
+      $errors{_} = "msg:bse/user/userlockout";
+    }
+    else {
+      my $error = "INVALID";
+      unless ($user && $user->check_password($password, \$error)) {
+       if ($error eq "INVALID") {
+         $errors{_} = $msgs->(baduserpass=>"Invalid username or password");
+         $req->audit
+           (
+            object => $user,
+            component => "siteuser:logon:invalid",
+            actor => "S",
+            level => "notice",
+            msg => "Invalid username or password",
+           );
+         SiteUser->check_lockouts
+           (
+            request => $req,
+            user => $user,
+           );
+       }
+       else {
+         $errors{_} = $msgs->(passwordload => "Error loading password module");
+       }
       }
     }
   }
@@ -280,6 +301,15 @@ sub req_logon {
   $session->{cart} = $cart;
   $session->{userid} = $user->id;
 
+  $req->audit
+    (
+     object => $user,
+     component => "siteuser:logon:success",
+     actor => "S",
+     level => "info",
+     msg => "Invalid username or password",
+    );
+
   if ($custom->can('siteuser_login')) {
     $custom->siteuser_login($session->{_session_id}, $session->{userid}, 
                            $cfg, $session);
diff --git a/site/cgi-bin/modules/BSE/Util/Lockouts.pm b/site/cgi-bin/modules/BSE/Util/Lockouts.pm
new file mode 100644 (file)
index 0000000..fc429b7
--- /dev/null
@@ -0,0 +1,186 @@
+package BSE::Util::Lockouts;
+use strict;
+use Carp qw(confess);
+use BSE::TB::AuditLog;
+use Scalar::Util qw(blessed);
+use POSIX qw(strftime);
+
+our $VERSION = "1.000";
+
+sub check_lockouts {
+  my ($class, %opts) = @_;
+
+  exists $opts{user}
+    or confess "Missing user parameter";
+  my $user = $opts{user};
+  my $section = $opts{section}
+    or confess "Missing section parameter";
+  my $component = $opts{component}
+    or confess "Missing component parameter";
+  my $module = $opts{module}
+    or confess "Missing module parameter";
+  my $req = $opts{request}
+    or confess "Missing request parameter";
+  my $type = $opts{type}
+    or confess "Missing type parameter";
+
+  my $cfg = BSE::Cfg->single;
+
+  if ($user) {
+    # user lockout check
+    my $time_limit = $cfg->entry($section, "account_time_period", 10);
+    my $fail_limit = $cfg->entry($section, "account_maximum_failures", 3);
+    my @entries = BSE::TB::AuditLog->getSpecial
+      (
+       logonRecords => $user->id, blessed($user), $component, $module,
+       $time_limit
+      );
+    my $bad_count = 0;
+    for my $entry (@entries) {
+      if ($entry->function eq 'success' || $entry->function eq 'unlock') {
+       $bad_count = 0;
+      }
+      else {
+       ++$bad_count;
+      }
+    }
+
+    if ($bad_count >= $fail_limit) {
+      my $penalty = $cfg->entry($section, "account_lockout_time", 60);
+      my $end = strftime("%Y-%m-%d %H:%M:%S", localtime(time() + $penalty * 60));
+      $user->set_lockout_end($end);
+      $user->save;
+
+      $req->audit
+       (
+        object => $user,
+        component => $component,
+        module => $module,
+        function => "lockout",
+        level => "warning",
+        actor => "S",
+        msg => "Account locked out until $end",
+        ip_address => $req->ip_address,
+       );
+    }
+  }
+
+  {
+    # IP lockout check
+    my $time_limit = $cfg->entry($section, "ip_time_period", 30);
+    my $fail_limit = $cfg->entry($section, "ip_maximum_failures", 10);
+    my $fail2_limit = $cfg->entry($section, "ip_maximum_failures2", 50);
+    my @entries = BSE::TB::AuditLog->getSpecial
+      (
+       ipLogonRecords => $req->ip_address, $component, $module,
+       $time_limit
+      );
+    my $bad_count = 0;
+    my $bad2_count = 0;
+    for my $entry (@entries) {
+      if ($entry->function eq 'success') {
+       $bad_count = 0;
+      }
+      elsif ($entry->function eq 'unlock') {
+       $bad_count = 0;
+       $bad2_count = 0;
+      }
+      else {
+       ++$bad_count;
+       ++$bad2_count;
+      }
+    }
+
+    my $penalty;
+    my $lock;
+    if ($bad_count >= $fail_limit) {
+      $penalty = $cfg->entry($section, "ip_lockout_time", 120);
+      $lock = "";
+    }
+    elsif ($bad2_count >= $fail2_limit) {
+      $penalty = $cfg->entry($section, "ip_lockout_time2", 120);
+      $lock = "super-"
+    }
+    if ($penalty) {
+      my $end = strftime("%Y-%m-%d %H:%M:%S", localtime(time() + $penalty * 60));
+      my $db = BSE::DB->single;
+      $db->run(bse_lockout_ip => $req->ip_address, $user->lockout_type, $end);
+      $req->audit
+       (
+        component => $component,
+        module => $module,
+        function => "lockout",
+        level => "warning",
+        actor => "S",
+        msg => "IP address " . $req->ip_address . " ${lock}locked out until $end",
+        object => $user,
+        ip_address => $req->ip_address,
+       );
+    }
+  }
+}
+
+sub unlock_user {
+  my ($class, %opts) = @_;
+
+  my $user = $opts{user}
+    or confess "Missing user parameter";
+  my $component = $opts{component}
+    or confess "Missing component parameter";
+  my $module = $opts{module}
+    or confess "Missing module parameter";
+  my $req = $opts{request}
+    or confess "Missing request parameter";
+
+  $req->audit
+    (
+     object => $user,
+     component => $component,
+     module => $module,
+     function => "unlock",
+     level => "info",
+     msg => "Account unlocked",
+     ip_address => $req->ip_address,
+    );
+
+  $user->set_lockout_end(undef);
+  $user->save;
+}
+
+my %types =
+  (
+   "S" => "Site users",
+   "A" => "Admin users",
+  );
+
+sub unlock_ip_address {
+  my ($class, %opts) = @_;
+
+  my $component = $opts{component}
+    or confess "Missing component parameter";
+  my $module = $opts{module}
+    or confess "Missing module parameter";
+  my $address = $opts{ip_address}
+    or confess "Missing ip_address parameter";
+  my $type = $opts{type}
+    or confess "Missing type parameter";
+  my $req = $opts{request}
+    or confess "Missing request parameter";
+
+  $types{$type}
+    or confess "Unknown type $type\n";
+
+  $req->audit
+    (
+     component => $component,
+     module => $module,
+     function => "unlock",
+     level => "info",
+     msg => "IP Address '$address' unlocked for $types{$type} by ".$req->ip_address,
+     ip_address => $address,
+    );
+}
+
+
+
+1;
index 9e5fb92..9253455 100644 (file)
@@ -8,7 +8,17 @@ 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.008";
+=head1 NAME
+
+SiteUser - represent a site user (or member)
+
+=head1 METHODS
+
+=over
+
+=cut
+
+our $VERSION = "1.009";
 
 use constant MAX_UNACKED_CONF_MSGS => 3;
 use constant MIN_UNACKED_CONF_GAP => 2 * 24 * 60 * 60;
@@ -30,6 +40,7 @@ sub columns {
             customText1 customText2 customText3
             customStr1 customStr2 customStr3
             customInt1 customInt2 customWhen1
+           lockout_end
             /;
 }
 
@@ -97,6 +108,7 @@ sub defaults {
      customInt1 => "",
      customInt2 => "",
      customWhen1 => "",
+     lockout_end => undef,
     );
 }
 
@@ -209,6 +221,12 @@ sub generic_email {
   $checkemail;
 }
 
+=item subscriptions
+
+The subscriptions the user is subscribed to.
+
+=cut
+
 sub subscriptions {
   my ($self) = @_;
 
@@ -317,6 +335,12 @@ sub send_conf_request {
   return 1;
 }
 
+=item orders
+
+The shop orders made by the user.
+
+=cut
+
 sub orders {
   my ($self) = @_;
 
@@ -335,6 +359,12 @@ sub _user_sub_entry {
   return $entry;
 }
 
+=item subscribed_to
+
+return true if the user is subcribed to the given subscription.
+
+=cut
+
 # check if the user is subscribed to the given subscription
 sub subscribed_to {
   my ($self, $sub) = @_;
@@ -388,6 +418,12 @@ sub images_cfg {
   @images;
 }
 
+=item images
+
+Return images associated with the user.
+
+=cut
+
 sub images {
   my ($self) = @_;
 
@@ -454,6 +490,12 @@ sub subscribed_services {
   BSE::DB->query(siteuserSubscriptions => $self->{id});
 }
 
+=item is_disabled
+
+Return true if the user is disabled.
+
+=cut
+
 sub is_disabled {
   my ($self) = @_;
 
@@ -495,6 +537,12 @@ sub seminar_bookings_detail {
   BSE::DB->query(bse_siteuserSeminarBookingsDetail => $self->{id});
 }
 
+=item wishlist
+
+return the user's wishlist products.
+
+=cut
+
 sub wishlist {
   my $self = shift;
   require Products;
@@ -627,7 +675,12 @@ sub query_group_files {
     );
 }
 
-# files the user can see, both owned and owned by groups
+=item visible_files
+
+files the user can see, both owned and owned by groups
+
+=cut
+
 sub visible_files {
   my ($self, $cfg) = @_;
 
@@ -914,4 +967,65 @@ sub password_check_fields {
   return qw(name1 name2);
 }
 
+=item locked_out
+
+Return true if logons are disabled due to too many authentication
+failures.
+
+=cut
+
+sub locked_out {
+  my ($self) = @_;
+
+  return $self->lockout_end && $self->lockout_end gt now_datetime();
+}
+
+sub check_lockouts {
+  my ($class, %opts) = @_;
+
+  require BSE::Util::Lockouts;
+  BSE::Util::Lockouts->check_lockouts
+      (
+       %opts,
+       section => "site user lockouts",
+       component => "siteuser",
+       module => "logon",
+       type => $class->lockout_type,
+      );
+}
+
+sub unlock {
+  my ($self, %opts) = @_;
+
+  require BSE::Util::Lockouts;
+  BSE::Util::Lockouts->unlock_user
+      (
+       %opts,
+       user => $self,
+       component => "siteuser",
+       module => "logon",
+      );
+}
+
+sub unlock_ip_address {
+  my ($class, %opts) = @_;
+
+  require BSE::Util::Lockouts;
+  BSE::Util::Lockouts->unlock_ip_address
+      (
+       %opts,
+       component => "siteuser",
+       module => "logon",
+       type => $class->lockout_type,
+      );
+}
+
+sub lockout_type {
+  "S";
+}
+
+=back
+
+=cut
+
 1;
index 03045ef..035d790 100644 (file)
@@ -5,7 +5,7 @@ use vars qw(@ISA $VERSION);
 @ISA = qw(Squirrel::Table);
 use SiteUser;
 
-our $VERSION = "1.002";
+our $VERSION = "1.003";
 
 sub rowClass {
   return 'SiteUser';
@@ -78,6 +78,15 @@ sub lost_password_save {
                  component => "siteusers:lost:changepw",
                  msg => "Account recovered");
   $user->set_lost_id("");
+  $user->set_lockout_end(undef);
+  BSE::TB::AuditLog->log
+      (
+       object => $user,
+       component => "siteuser:logon:recover",
+       actor => "S",
+       level => "notice",
+       msg => "Account recovered",
+      );
   $user->save;
 
   return 1;
index cb3564e..e620dea 100644 (file)
@@ -1,5 +1,5 @@
 --
-# VERSION=1.004
+# VERSION=1.005
 id: bse/
 description: BSE messages
 
@@ -38,6 +38,15 @@ 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/userlockout
+description: Displayed to a site user logging onto a locked account.
+
+id: bse/user/unlocked
+description: Displayed to an admin when unlocking s site user account.
+
+id: bse/user/iplockout
+description: Displayed to a user attempting to logon from a locked out IP address (%1 - ip address)
+
 id: bse/user/lostsaved
 description: Displayed when a new password is saved during account recovery
 
@@ -164,6 +173,12 @@ description: General messages
 id: bse/admin/generic/accessdenied
 description: Displayed when the user doesn't have access due to permissions (%1 - required access)
 
+id: bse/admin/user/
+description: Admin user administration messages
+
+id: bse/admin/user/unlocked
+description: Flashed when an admin user is unlocked (%1 - admin user)
+
 id: bse/admin/makeindex/
 description: makeIndex messages
 
@@ -242,6 +257,15 @@ description: User email confirmed (%1 is the user object)
 id: bse/admin/siteusers/membershipsaved
 description: Group membership saved (%1 is the group object)
 
+id: bse/admin/ipaddress/
+description: messages for IP address management
+
+id: bse/admin/ipaddress/siteunlock
+description: Flashed when an IP address is unlocked for site users (%1 - IP address)
+
+id: bse/admin/ipaddress/adminunlock
+description: Flashed when an IP address is unlocked for admin users (%1 - IP address)
+
 id: test/
 description: <<TEXT
 Test category
index dfb1ae2..e0ca785 100644 (file)
@@ -1,5 +1,5 @@
 ---
-# VERSION=1.004
+# VERSION=1.005
 # defaults for the following
 language_code: en
 priority: 0
@@ -19,6 +19,15 @@ message: No id supplied for password recovery
 id: bse/user/passwordlen
 message: New password must be at least %1:s characters
 
+id: bse/user/userlockout
+message: Your account is locked out due to too many logon failures, please try again later, or recover your account below.
+
+id: bse/user/unlocked
+message: Unlocked user account '%1:{userId}s'
+
+id: bse/user/iplockout
+message: Your IP address '%1:s' is locked out due to too many failed logon attempts.
+
 id: bse/user/lostsaved
 message: Your new password has been saved, please logon
 
@@ -187,6 +196,9 @@ message: Membership saved for group '%1:{name}s'
 id: bse/admin/generic/accessdenied
 message: You don't have access to this function (%1:s)
 
+id: bse/admin/user/unlocked
+message: User '%1:{logon}s' unlocked
+
 id: bse/admin/makeindex/complete
 message: Search index rebuild complete
 
@@ -316,6 +328,12 @@ message: Password and your user name may not contain the other
 id: bse/util/password/notu5er
 message: Password and your user name may not contain the other (even with substitution)
 
+id: bse/admin/ipaddress/siteunlock
+message: IP address '%1:s' unlocked for site users
+
+id: bse/admin/ipaddress/adminunlock
+message: IP address '%1:s' unlocked for admin users
+
 id: test/test/multiline
 message: <<TEXT
 This message has
index 2c410ae..cfd2894 100644 (file)
@@ -622,3 +622,48 @@ sql_statement: <<SQL
 delete from admin_perms
 where object_id = ?
 SQL
+
+name: AuditLog.logonRecords
+sql_statement: <<SQL
+select * from bse_audit_log
+where object_id = ?
+  and object_type = ?
+  and facility = 'bse'
+  and component = ?
+  and module = ?
+  and
+  (
+    `function` = 'success'
+   or
+    `function` = 'invalid'
+   or
+    `function` = 'unlock'
+   or
+    `function` = 'recover'
+  )
+  and when_at > date_sub(now(), interval ? minute)
+order by when_at asc
+SQL
+
+name: AuditLog.ipLogonRecords
+sql_statement: <<SQL
+select * from bse_audit_log
+where ip_address = ?
+  and facility = 'bse'
+  and component = ?
+  and module = ?
+  and
+  (
+    `function` = 'success'
+   or
+    `function` = 'invalid'
+   or
+    `function` = 'unlock'
+  )
+  and when_at > date_sub(now(), interval ? minute)
+order by when_at asc
+SQL
+
+name: bse_lockout_ip
+sql_statement: call bse_ip_lockout(?,?,?)
+
index 6e0491c..070d374 100644 (file)
@@ -2804,6 +2804,72 @@ case-insensitively, even with symbol replacement (e.g. "5" for "S".
 Rules for validating (not verifying) administrative user passwords.
 See L</[siteuser passwords]> on the keys that can be set.
 
+=head2 [site user lockouts]
+
+Controls when accounts and IP addresses are locked out due to repeated
+site user authentication failures.
+
+=over
+
+=item *
+
+I<account maximum failures> - the maximum number of login failures for
+a given account in I<account time period>.  A successful login of the
+account resets the count.  Default: 3.
+
+=item *
+
+C<account_time_period> - the number of minutes in which
+C<account_maximum_failures> login failures occurring will cause an
+account lock-out. Default: 10.
+
+=item *
+
+C<account_lockout_time> - the number of minutes to lock out an account
+that fails too many logins. Default: 60.
+
+=item *
+
+C<ip_maximum_failures> - the maximum number of login failures for a
+given IP address with-in C<ip_time_period>.  A successful login of any
+account at the IP address resets the count.  Default: 10.
+
+=item *
+
+C<ip_time_period> - the number of minutes in which
+C<ip_maximum_failures> login failures occurring causes an IP address
+lock-out. Default: 30.
+
+=item *
+
+C<ip_lockout_time> - the number of minutes to lock out an IP
+address that fails too many logins. Default: 120.
+
+=item *
+
+C<ip_maximum_failures2> - the maximum number of login failures for a
+given IP address with-in C<ip_time_period>.  A successful login of any
+account at the IP address I<does not reset> the count. Default: 50.
+
+=item *
+
+C<ip_lockout_time2> - the number of minutes to lock out an IP address
+that fails too many logins against C<ip_maximum_failure2>. Default:
+120.
+
+=back
+
+Note: an administrator unlocking an account resets the account lock
+count but has no effect on the IP address lock count.  An
+administrator unlocking an IP address resets both IP address lock
+counts, but has no effect on account lock counts.
+
+=head2 [admin user lockouts]
+
+Control account and IP address lockouts for admin user authentication.
+
+See L</[site user lockouts]> for details of the configuration
+possible.
 
 =head1 AUTHOR
 
index 46d36e1..185a992 100644 (file)
@@ -566,6 +566,10 @@ table.siteusers {
   width: 100%;
 }
 
+table tr.locked td {
+  background-color: #FFC0C0;
+}
+
 table.siteusers td.col_name1,
 table.siteusers td.col_name2 { 
   width: 33%;
diff --git a/site/templates/admin/include/activity.tmpl b/site/templates/admin/include/activity.tmpl
new file mode 100644 (file)
index 0000000..4c70ff4
--- /dev/null
@@ -0,0 +1,18 @@
+<table>
+<tr>
+  <th>When</th>
+  <th>Level</th>
+  <th>Who</th>
+  <th>What</th>
+  <th>Message</th>
+</tr>
+<:.for entry in activity -:>
+<tr class="audit<:= entry.level :>">
+  <td class="col_when_at"><:= bse.date("%H:%M %d/%m/%Y", entry.when_at) :></td>
+  <td class="col_level"><:= entry.level_name:></td>
+  <td class="col_actor"><:= entry.actor_name:></td>
+   <td class="col_what"><:.if entry.facility ne "bse":><:= entry.facility :>: <:.end if:><:= entry.component:>/<:= entry.module:>/<:= entry.function :></td>
+   <td class="col_msg"><:= entry.msg:></td>
+</tr>
+<:.end for -:>
+</table>
\ No newline at end of file
diff --git a/site/templates/admin/ip/detail.tmpl b/site/templates/admin/ip/detail.tmpl
new file mode 100644 (file)
index 0000000..208ae3c
--- /dev/null
@@ -0,0 +1,32 @@
+<:wrap admin/base.tmpl title => "IP Addresses", bodyid => "ipaddresses":>
+<h1>IP Address: <:= ip.ip_address -:></h1>
+<div class="menu">| <a href="<:= cfg.admin_url("menu") :>">Admin Menu</a> |
+<a href="<:= cfg.admin_url2("ipaddress", "list") :>">Return to list</a>
+</div>
+
+<:.call "messages" -:>
+
+<:.if ip.lockout_S -:>
+<div class="warning">Site users are locked out from this address
+<a href="<:= cfg.admin_url2("ipaddress", "unlock", { "ip":ip.ip_address, "type":"S" }) :>">Unlock</a>
+</div>
+<:.end if:>
+
+<:.if ip.lockout_A -:>
+<div class="warning">Admin users are locked out from this address
+<a href="<:= cfg.admin_url2("ipaddress", "unlock", { "ip":ip.ip_address, "type":"A", "r":cfg.admin_url2("ipaddress", "detail", { "ip":ip.ip_address }) }) :>">Unlock</a>
+</div>
+<:.end if:>
+
+<h2>Activity from <:= ip.ip_address :></h2>
+
+<:.set activity =
+    [
+      auditlog().getBy2
+        ( [
+            [ "ip_address", ip.ip_address ]
+          ],
+          { "order":"id desc", "limit":50 }
+        )
+    ] -:>
+<:.call "admin/include/activity.tmpl", "activity":activity :>
diff --git a/site/templates/admin/ip/list.tmpl b/site/templates/admin/ip/list.tmpl
new file mode 100644 (file)
index 0000000..8758252
--- /dev/null
@@ -0,0 +1,25 @@
+<:wrap admin/base.tmpl title => "IP Addresses", bodyid => "ipaddresses":>
+<h1>IP Addresses</h1>
+<div class="menu">| <a href="<:= cfg.admin_url("menu") :>">Admin Menu</a> |</div>
+
+<:.call "messages" -:>
+
+<table>
+  <tr>
+    <th>IP Address</th>
+    <th>Site users</th>
+    <th>Admin users</th>
+  </tr>
+<:.if ips.size -:>
+<:.for ip in ips -:>
+  <tr>
+    <td><a href="<:= cfg.admin_url2("ipaddress", "detail", { "ip":ip.ip_address }) :>"><:= ip.ip_address -:></a></td>
+    <td><:.if ip.lockout_S:>Locked<:.end if:></td>
+    <td><:.if ip.lockout_A:>Locked<:.end if:></td>
+  </tr>
+<:.end for -:>
+<:.else -:>
+<tr>
+  <td colspan="2">No IP addresses being tracked</td>
+<:.end if -:>
+</table>
\ No newline at end of file
index 0871069..fa2683d 100644 (file)
@@ -1,17 +1,22 @@
 <:wrap admin/base.tmpl title => "Edit User":>
 <h1>Edit User</h1>
-<p>
+<div class="menu">
 | <a href="/cgi-bin/admin/menu.pl">Admin menu</a>
 | <a href="<:script:>">User list</a> |
 <a href="<:script:>?a_groups=1">Group list</a> |
-</p>
-<p>
+
+</menu>
+<div class="menu">
 | User Details | <a href="<:script:>?a_showuser=1&amp;userid=<:user id:>&amp;_t=glob">Global Permissions</a>
 | <a href="<:script:>?a_showuserart=1&amp;userid=<:user id:>&amp;id=-1">Article Permissions</a> |
-</p>
-<:ifMessage:>
-<p><b><:message:></b></p>
-<:or:><:eif:> 
+<:.if user.locked_out and request.user_can("bse_admin_user_unlock") -:>
+<a href="<:= cfg.admin_url2("adminusers", "unlock",
+    { "userid": user.id,
+      "r":cfg.admin_url2("adminusers", "showuser", { "userid":user.id })
+    }) :>">Unlock</a> |
+<:.end if -:>
+</div>
+<:.call "messages" -:>
 
 <form method="post" action="<:script:>">
 <input type="hidden" name="userid" value="<:user id:>" />
index 1dba4bb..989871c 100644 (file)
@@ -1,16 +1,15 @@
 <:wrap admin/base.tmpl title => "Admin Users" :>
 <h1>Admin Users</h1>
-<p>
-| <a href="/cgi-bin/admin/menu.pl">Admin menu</a> 
-| <a href="<:script:>?a_groups=1">Group list</a> |
-<:if UserCan admin_user_add :>
-<a href="<:script:>?a_adduserform=1">Add New User</a> |
-<:or UserCan:><:eif UserCan:></p>
-<:ifMessage:>
-<p><b><:message:></b></p>
-<:or:><:eif:> 
+<div class="menu">
+| <a href="<:= cfg.admin_url("menu") :>">Admin menu</a> 
+| <a href="<:= cfg.admin_url2("adminusers", "groups") :>">Group list</a> |
+<:.if request.user_can("admin_user_add") :>
+<a href="<:= cfg.admin_url2("adminusers", "adduserform") :>">Add New User</a> |
+<:.end if:></div>
+<:.call "messages" :>
 
-<form method="post" action="<:script:>">
+<:.set allusers = [ users.all ] -:>
+<form method="post" action="<:= cfg.admin_url("adminusers") :>">
         <table >
           <tr> 
             <th> Logon</th>
             <th> In Groups</th>
             <th>&nbsp;</th>
           </tr>
-          <:if Users:> <: iterator begin users :> 
-          <tr> 
-            <td> <a href="<:script:>?a_showuser=1&amp;userid=<:iuser id:>"><:iuser logon:></a></td>
-            <td><:iuser name:></td>
-            <td><:ifUser_groups iuser:><:iterator begin user_groups iuser:><:user_group name:><:iterator separator user_groups:>, <:iterator end user_groups:><:or:>(none)<:eif:>
+          <:.if allusers.size:>
+         <:.for user in allusers :>
+          <tr<:.if user.locked_out :> class="locked"<:.end if:>> 
+            <td> <a href="<:= cfg.admin_url2("adminusers", "showuser", { "userid":user.id }) :>"><:= user.logon :></a></td>
+            <td><:= user.name:></td>
+<:.set usergroups = [ user.groups ] -:>
+            <td>
+             <:-.if usergroups.size -:>
+               <:.for group in usergroups -:>
+                 <:= group.name :><:= loop.is_last ? "" : ", " -:>
+               <:.end for:>
+             <:.else -:>
+               (none)
+             <:-.end if-:>
             </td>
            <td>
-             <:ifUserCan admin_user_del:><a href="<:script:>?a_showuser=1&amp;userid=<:iuser id:>&amp;_t=del">Delete</a><:or:><:eif:>
+             <:.if request.user_can("admin_user_del")-:>
+                <a href="<:= cfg.admin_url2("adminusers", "showuser", { "userid": user.id, "_t":"del" })  :>">Delete</a>
+              <:-.end if:>
+             <:.if user.locked_out -:>
+               <:.if request.user_can("bse_admin_user_unlock") -:>
+                 <a href="<:= cfg.admin_url2("adminusers", "unlock", { "userid": user.id })  :>">Unlock</a>
+               <:.else -:>
+                 LOCKED
+               <:.end if -:>
+             <:.end if -:>
            </td>
           </tr>
-          <: iterator end users :> 
-          <:or Users:> 
+          <:.end for :>
+          <:.else:> 
           <tr> 
             <td colspan="4">Your system has no users.</td>
           </tr>
-          <:eif Users:> 
+          <:.end if:> 
         </table>
 </form>
 
index c0088a4..ae9f092 100644 (file)
@@ -8,5 +8,8 @@
 <a href="<:script:>?a_edit=1&amp;id=<:siteuser id:>&amp;_t=groups">Groups</a> |
 <a href="/cgi-bin/admin/siteusers.pl?a_edit=1&amp;id=<:siteuser id:>&amp;_t=files">Files</a> |
 <a href="/cgi-bin/admin/siteusers.pl?a_view=1&amp;id=<:siteuser id:>&amp;_t=activity">Activity</a> |
+<:.if siteuser.locked_out and request.user_can("bse_member_user_unlock") :>
+<a href="<:= cfg.admin_url2("siteusers", "unlock", { "id":siteuser.id, "r":cfg.admin_url2("siteusers", "edit", { "id":siteuser.id })}) :>">Unlock</a> |
+<:.end if:>
 </p>
 
index 1618a5c..60a9b2c 100644 (file)
@@ -1,9 +1,11 @@
 <:wrap admin/base.tmpl title=>"Admin Site Members" js => "admin_siteusers.js":>
 <h1>Admin Site Members</h1>
 <p>
-| <a href="/cgi-bin/admin/menu.pl">Admin menu</a> | <a href="<:script:>?a_grouplist=1">Groups</a> |
+| <a href="<:= cfg.admin_url("menu") :>">Admin menu</a>
+| <a href="<:= cfg.admin_url2("siteusers", "grouplist") :>">Groups</a> |
 <:if UserCan bse_members_user_add :>
-<a href="<:script:>?a_addform=1">Add Member</a> |<:or UserCan:><:eif UserCan:>
+<a href="<:= cfg.admin_url2("siteusers", "addform") :>">Add Member</a> |
+<:- eif UserCan:>
 </p>
 <:ifMessage:>
 <p><b><:message:></b></p>
index 2d6736b..ec23b45 100644 (file)
@@ -1,11 +1,19 @@
 <:ifAjax:><table class="siteusers"><:or:><:eif:>
           <:if Siteusers:> <: iterator begin siteusers :> 
-          <tr> 
+          <tr<:if Siteuser locked_out:> class="locked"<:eif:>
            <td class="col_id">  <:if Or [ifUserCan bse_members_user_edit] [ifUserCan bse_members_user_view]:><a href="<:script:>?<:ifUserCan bse_members_user_edit:>a_edit<:or:>a_view<:eif:>=1&amp;id=<:siteuser id:>"><:siteuser id:></a><:or Or:><:siteuser id:><:eif Or:></td>
             <td class="col_userid"> <:if Or [ifUserCan bse_members_user_edit] [ifUserCan bse_members_user_view]:><a href="<:script:>?<:ifUserCan bse_members_user_edit:>a_edit<:or:>a_view<:eif:>=1&amp;id=<:siteuser id:>"><:siteuser userId:></a><:or Or:><:siteuser userId:><:eif Or:></td>
             <td class="col_name1"><:siteuser name1:></td>
             <td class="col_name2"><:siteuser name2:> </td>
-           <td><:ifUserCan bse_members_user_delete:><a href="<:script:>?a_deleteform=1&amp;id=<:siteuser id:>">Delete</a> <:or:><:eif:><a href="<:script:>?a_edit=1&amp;id=<:siteuser id:>&amp;_t=files">Files</a></td>
+           <td>
+<:ifUserCan bse_members_user_delete -:>
+<a href="<:script:>?a_deleteform=1&amp;id=<:siteuser id:>">Delete</a>
+<:-eif:>
+<a href="<:script:>?a_edit=1&amp;id=<:siteuser id:>&amp;_t=files">Files</a>
+<:ifAnd [ifUserCan bse_member_user_unlock] [siteuser locked_out]:>
+<a href="<:adminurl2 siteusers unlock id [siteuser id]:>">Unlock</a>
+<:eif:>
+</td>
           </tr>
           <: iterator end siteusers :> 
           <:or Siteusers:> 
index 6ba0214..cd71037 100644 (file)
@@ -31,6 +31,7 @@ Column name;varchar(255);NO;NULL;
 Column password;varchar(255);NO;NULL;
 Column perm_map;varchar(255);NO;NULL;
 Column password_type;varchar(20);NO;plain;
+Column lockout_end;datetime;YES;NULL;
 Index PRIMARY;1;[base_id]
 Index logon;1;[logon]
 Table article
@@ -227,6 +228,14 @@ Column description;text;NO;NULL;
 Column ftype;varchar(20);NO;img;
 Index PRIMARY;1;[id]
 Index owner;0;[file_type;owner_id]
+Table bse_ip_lockouts
+Engine InnoDB
+Column id;int(11);NO;NULL;auto_increment
+Column ip_address;varchar(20);NO;NULL;
+Column type;char(1);NO;NULL;
+Column expires;datetime;NO;NULL;
+Index PRIMARY;1;[id]
+Index ip_address;1;[ip_address;type]
 Table bse_locations
 Engine MyISAM
 Column id;int(11);NO;NULL;auto_increment
@@ -452,6 +461,7 @@ Column customStr3;varchar(255);YES;NULL;
 Column customInt1;int(11);YES;NULL;
 Column customInt2;int(11);YES;NULL;
 Column customWhen1;datetime;YES;NULL;
+Column lockout_end;datetime;YES;NULL;
 Index PRIMARY;1;[id]
 Index affiliate_name;0;[affiliate_name]
 Index idUUID;1;[idUUID]
index ac0d4ae..d3b6795 100644 (file)
@@ -69,9 +69,7 @@ site/cgi-bin/modules/DevHelp/Validate.pm      multiple occurrence of link target 'int
 site/cgi-bin/modules/DevHelp/Validate.pm       multiple occurrence of link target 'required'   1
 site/cgi-bin/modules/DevHelp/Validate.pm       multiple occurrence of link target 'rules'      1
 site/cgi-bin/modules/DevHelp/Validate.pm       multiple occurrence of link target 'time'       1
-site/cgi-bin/modules/Generate.pm       =item without previous =over    1
 site/cgi-bin/modules/Generate.pm       empty section in previous paragraph     1
-site/cgi-bin/modules/Generate/Article.pm       =item without previous =over    1
 site/cgi-bin/modules/Generate/Article.pm       Verbatim paragraph in NAME section      1
 site/cgi-bin/modules/Generate/Article.pm       empty section in previous paragraph     2
 site/cgi-bin/modules/Generate/Article.pm       multiple occurrence of link target 'admin'      1
@@ -79,13 +77,11 @@ site/cgi-bin/modules/Generate/Article.pm    multiple occurrence of link target 'bod
 site/cgi-bin/modules/Generate/Article.pm       multiple occurrence of link target 'file field' 1
 site/cgi-bin/modules/Generate/Article.pm       multiple occurrence of link target 'ifParent'   1
 site/cgi-bin/modules/Generate/Article.pm       multiple occurrence of link target 'image'      1
-site/cgi-bin/modules/Generate/Article.pm       preceding non-item paragraph(s) 1
 site/cgi-bin/modules/Generate/Article.pm       unresolved internal link 'item child'   1
 site/cgi-bin/modules/Generate/Article.pm       unresolved internal link 'item crumbs'  1
 site/cgi-bin/modules/Generate/Catalog.pm       Verbatim paragraph in NAME section      1
 site/cgi-bin/modules/Generate/Product.pm       Verbatim paragraph in NAME section      1
 site/cgi-bin/modules/Product.pm        =item without previous =over    1
-site/cgi-bin/modules/SiteUser.pm       =item without previous =over    1
 site/cgi-bin/modules/Squirrel/GPG.pm   Verbatim paragraph in NAME section      1
 site/cgi-bin/modules/Squirrel/PGP5.pm  Verbatim paragraph in NAME section      1
 site/cgi-bin/modules/Squirrel/PGP6.pm  Verbatim paragraph in NAME section      1
@@ -118,6 +114,7 @@ site/docs/config.pod        multiple occurrence of link target 'extra_templates'    1
 site/docs/config.pod   multiple occurrence of link target 'from'       1
 site/docs/config.pod   multiple occurrence of link target 'images'     1
 site/docs/config.pod   multiple occurrence of link target 'no_cache_dynamic'   1
+site/docs/config.pod   multiple occurrence of link target 'public_files'       1
 site/docs/config.pod   multiple occurrence of link target 'require_logon'      1
 site/docs/config.pod   multiple occurrence of link target 'scalecache' 1
 site/docs/config.pod   multiple occurrence of link target 'template'   1