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
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
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
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
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
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
^\.?\#
/\.?\#
^cover_db/
+^bse-\d.\d
\ No newline at end of file
customWhen1 datetime,
+ -- when the account lock-out (if any) ends
+ lockout_end datetime,
+
primary key (id),
unique (userId),
index (affiliate_name),
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)
);
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
--- /dev/null
+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;;
'File::Slurp' => 0,
'Time::HiRes' => 0,
'WWW::Mechanize' => 0,
+ 'Net::IP' => 0,
}
},
{
[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
modules=BSE::UI::AdminModules
log=BSE::UI::AdminAudit
importer=BSE::UI::AdminImporter
+ipaddress=BSE::UI::AdminIPAddress
[includes]
00install=bse-install.cfg
use BSE::Util::HTML;
use BSE::CfgInfo 'admin_base_url';
-our $VERSION = "1.004";
+our $VERSION = "1.005";
my %actions =
(
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 =
);
}
else {
- return $self->req_logon_form($req, $errors);
+ return $self->req_logon_form($req, ref $errors ? $errors : { _ => $errors });
}
}
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;
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};
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 =
(
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',
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 =
(
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);
$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) = @_;
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 =
(
saveuserart=>1,
adduserform => 1,
adduser=>1,
+ unlock => 1,
groups=>1,
showgroupart=>1,
showgroup=>1,
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;
}
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),
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 =
(
'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;
use Carp 'confess';
@ISA = qw(BSE::DB);
-our $VERSION = "1.011";
+our $VERSION = "1.012";
use vars qw($VERSION $MAX_CONNECTION_AGE);
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.*
use BSE::Util::HTML;
use Carp qw(cluck confess);
-our $VERSION = "1.022";
+our $VERSION = "1.023";
=head1 NAME
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
(
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
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) = @_;
$self->SUPER::remove();
}
+=item groups()
+
+return the groups the user is a member of.
+
+=cut
+
sub groups {
my ($self) = @_;
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;
use strict;
use base qw(Squirrel::Row);
-our $VERSION = "1.006";
+our $VERSION = "1.007";
sub columns {
return qw/id
action => "showuser",
format => "Admin: %d",
class => "BSE::TB::AdminUsers",
+ idname => "userid",
},
);
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");
&& 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;
--- /dev/null
+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;
--- /dev/null
+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;
--- /dev/null
+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;
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;
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");
+ }
}
}
}
$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);
--- /dev/null
+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;
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;
customText1 customText2 customText3
customStr1 customStr2 customStr3
customInt1 customInt2 customWhen1
+ lockout_end
/;
}
customInt1 => "",
customInt2 => "",
customWhen1 => "",
+ lockout_end => undef,
);
}
$checkemail;
}
+=item subscriptions
+
+The subscriptions the user is subscribed to.
+
+=cut
+
sub subscriptions {
my ($self) = @_;
return 1;
}
+=item orders
+
+The shop orders made by the user.
+
+=cut
+
sub orders {
my ($self) = @_;
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) = @_;
@images;
}
+=item images
+
+Return images associated with the user.
+
+=cut
+
sub images {
my ($self) = @_;
BSE::DB->query(siteuserSubscriptions => $self->{id});
}
+=item is_disabled
+
+Return true if the user is disabled.
+
+=cut
+
sub is_disabled {
my ($self) = @_;
BSE::DB->query(bse_siteuserSeminarBookingsDetail => $self->{id});
}
+=item wishlist
+
+return the user's wishlist products.
+
+=cut
+
sub wishlist {
my $self = shift;
require Products;
);
}
-# 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) = @_;
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;
@ISA = qw(Squirrel::Table);
use SiteUser;
-our $VERSION = "1.002";
+our $VERSION = "1.003";
sub rowClass {
return 'SiteUser';
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;
--
-# VERSION=1.004
+# VERSION=1.005
id: bse/
description: BSE messages
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
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
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
---
-# VERSION=1.004
+# VERSION=1.005
# defaults for the following
language_code: en
priority: 0
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
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
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
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(?,?,?)
+
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
width: 100%;
}
+table tr.locked td {
+ background-color: #FFC0C0;
+}
+
table.siteusers td.col_name1,
table.siteusers td.col_name2 {
width: 33%;
--- /dev/null
+<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
--- /dev/null
+<: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 :>
--- /dev/null
+<: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
<: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&userid=<:user id:>&_t=glob">Global Permissions</a>
| <a href="<:script:>?a_showuserart=1&userid=<:user id:>&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:>" />
<: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> </th>
</tr>
- <:if Users:> <: iterator begin users :>
- <tr>
- <td> <a href="<:script:>?a_showuser=1&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&userid=<:iuser id:>&_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>
<a href="<:script:>?a_edit=1&id=<:siteuser id:>&_t=groups">Groups</a> |
<a href="/cgi-bin/admin/siteusers.pl?a_edit=1&id=<:siteuser id:>&_t=files">Files</a> |
<a href="/cgi-bin/admin/siteusers.pl?a_view=1&id=<:siteuser id:>&_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>
<: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>
<: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&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&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&id=<:siteuser id:>">Delete</a> <:or:><:eif:><a href="<:script:>?a_edit=1&id=<:siteuser id:>&_t=files">Files</a></td>
+ <td>
+<:ifUserCan bse_members_user_delete -:>
+<a href="<:script:>?a_deleteform=1&id=<:siteuser id:>">Delete</a>
+<:-eif:>
+<a href="<:script:>?a_edit=1&id=<:siteuser id:>&_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:>
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
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
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]
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
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
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