View | Details | Raw Unified | Return to bug 12617
Collapse All | Expand All

(-)a/Koha/AuthUtils.pm (-20 / +87 lines)
Lines 22-30 use Crypt::Eksblowfish::Bcrypt qw(bcrypt en_base64); Link Here
22
use Encode qw( encode is_utf8 );
22
use Encode qw( encode is_utf8 );
23
use Fcntl qw/O_RDONLY/; # O_RDONLY is used in generate_salt
23
use Fcntl qw/O_RDONLY/; # O_RDONLY is used in generate_salt
24
use List::MoreUtils qw/ any /;
24
use List::MoreUtils qw/ any /;
25
use String::Random qw( random_string );
25
use String::Random qw( random_string random_regex );
26
26
27
use C4::Context;
27
use C4::Context;
28
use Koha::Patron::Categories;
28
29
29
use base 'Exporter';
30
use base 'Exporter';
30
31
Lines 139-186 sub generate_salt { Link Here
139
140
140
=head2 is_password_valid
141
=head2 is_password_valid
141
142
142
my ( $is_valid, $error ) = is_password_valid( $password );
143
    my ( $is_valid, $error ) = is_password_valid( $password, $categorycode );
143
144
144
return $is_valid == 1 if the password match minPasswordLength and RequireStrongPassword conditions
145
Validates a member's password based on category password policy and/or minPasswordLength
145
otherwise return $is_valid == 0 and $error will contain the error ('too_short' or 'too_weak')
146
146
147
=cut
147
=cut
148
148
149
sub is_password_valid {
149
sub is_password_valid {
150
    my ($password) = @_;
150
    my ( $password, $categorycode ) = @_;
151
    my $minPasswordLength = C4::Context->preference('minPasswordLength');
151
152
    my $categoryinfo = $categorycode ? Koha::Patron::Categories->find($categorycode) : undef;
153
    my $passwordpolicy = $categoryinfo->passwordpolicy;
154
    my $minPasswordLength = min_password_length($categorycode);
152
    $minPasswordLength = 3 if not $minPasswordLength or $minPasswordLength < 3;
155
    $minPasswordLength = 3 if not $minPasswordLength or $minPasswordLength < 3;
153
    if ( length($password) < $minPasswordLength ) {
156
154
        return ( 0, 'too_short' );
157
    if ($password =~ m|^\s+| or $password =~ m|\s+$|) {
155
    }
158
        return (0, 'has_whitespaces');
156
    elsif ( C4::Context->preference('RequireStrongPassword') ) {
159
    } else {
157
        return ( 0, 'too_weak' )
160
        if ($passwordpolicy) {
158
          if $password !~ m|(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{$minPasswordLength,}|;
161
            if ($passwordpolicy eq "complex") {
162
                unless ($password =~ /[0-9]/
163
                        && $password =~ /[a-zåäö]/
164
                        && $password =~ /[A-ZÅÄÖ]/
165
                        && $password =~ /[\|\[\]\{\}!@#\$%\^&\*\(\)_\-\+\?]/
166
                        && length($password) >= $minPasswordLength ) {
167
                    return (0, "complex_policy_mismatch");
168
                }
169
170
            }
171
            elsif ($passwordpolicy eq "alphanumeric") {
172
                unless ($password =~ /[0-9]/
173
                        && $password =~ /[a-zA-ZöäåÖÄÅ]/
174
                        && $password !~ /\W/
175
                        && $password !~ /[_-]/
176
                        && length($password) >= $minPasswordLength ) {
177
                    return (0, "alpha_policy_mismatch");
178
                }
179
            }
180
            else {
181
                if ($password !~ /^[0-9]+$/ || length($password) < $minPasswordLength) {
182
                    return (0, "simple_policy_mismatch");
183
                }
184
            }
185
        } elsif (defined $minPasswordLength && length($password) < $minPasswordLength) {
186
            return (0, 'too_short');
187
        } elsif ( C4::Context->preference('RequireStrongPassword') ) {
188
            return ( 0, 'too_weak' )
189
                if $password !~ m|(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{$minPasswordLength,}|;
190
        }
159
    }
191
    }
160
    return ( 0, 'has_whitespaces' ) if $password =~ m[^\s|\s$];
192
161
    return ( 1, undef );
193
    return 1;
162
}
194
}
163
195
164
=head2 generate_password
196
=head2 generate_password
165
197
166
my password = generate_password();
198
my password = generate_password($categorycode);
167
199
168
Generate a password according to the minPasswordLength and RequireStrongPassword.
200
Returns a new patron password suggestion based on borrowers password policy from categories
169
201
170
=cut
202
=cut
171
203
172
sub generate_password {
204
sub generate_password {
173
    my $minPasswordLength = C4::Context->preference('minPasswordLength');
205
    my ( $categorycode ) = @_;
174
    $minPasswordLength = 8 if not $minPasswordLength or $minPasswordLength < 8;
206
    my $categoryinfo = Koha::Patron::Categories->find($categorycode) if $categorycode;
207
    my $passwordpolicy = $categoryinfo->passwordpolicy;
208
    my $minpasslength = min_password_length($categorycode);
209
    $minpasslength = 8 if not $minpasslength or ( $minpasslength < 8 and !$passwordpolicy);
175
210
176
    my ( $password, $is_valid );
211
    my ( $password, $is_valid );
177
    do {
212
    do {
178
        $password = random_string('.' x $minPasswordLength );
213
        if (!$passwordpolicy || $passwordpolicy eq "complex") {
179
        ( $is_valid, undef ) = is_password_valid( $password );
214
            $password = random_string('.' x $minpasslength );
215
        } else {
216
            if ($passwordpolicy eq "alphanumeric") {
217
                $password = random_regex('[a-zA-Z0-9]' x $minpasslength );
218
            } else {
219
                $password = random_regex('[0-9]' x $minpasslength );
220
            }
221
        }
222
        ( $is_valid, undef ) = is_password_valid( $password, $categorycode );
180
    } while not $is_valid;
223
    } while not $is_valid;
181
    return $password;
224
    return $password;
182
}
225
}
183
226
227
=head2 minPasswordLength
228
229
    $minpasslength = minPasswordLength($categorycode);
230
231
Returns correct minPasswordLength
232
233
=cut
234
235
sub min_password_length {
236
    my $categorycode = shift;
237
238
    my $categoryinfo = Koha::Patron::Categories->find($categorycode);
239
    my $passwordpolicy = $categoryinfo ? $categoryinfo->passwordpolicy : undef;
240
    my $minpasslen;
241
    if ($passwordpolicy eq "complex") {
242
        $minpasslen = C4::Context->preference("minComplexPasswordLength");
243
    }elsif ($passwordpolicy eq "alphanumeric") {
244
        $minpasslen = C4::Context->preference("minAlnumPasswordLength");
245
    }else {
246
        $minpasslen = C4::Context->preference("minPasswordLength");
247
    }
248
    return $minpasslen;
249
}
250
184
251
185
=head2 get_script_name
252
=head2 get_script_name
186
253
(-)a/Koha/Exceptions/Password.pm (+12 lines)
Lines 43-48 use Exception::Class ( Link Here
43
        isa => 'Koha::Exceptions::Password',
43
        isa => 'Koha::Exceptions::Password',
44
        description => 'The password was rejected by a plugin'
44
        description => 'The password was rejected by a plugin'
45
    },
45
    },
46
    'Koha::Exceptions::Password::SimplePolicy' => {
47
        isa => 'Koha::Exceptions::Password',
48
        description => 'Password does not match simplenumeric passwordpolicy',
49
    },
50
    'Koha::Exceptions::Password::AlphaPolicy' => {
51
        isa => 'Koha::Exceptions::Password',
52
        description => 'Password does not match alphanumeric passwordpolicy',
53
    },
54
    'Koha::Exceptions::Password::ComplexPolicy' => {
55
        isa => 'Koha::Exceptions::Password',
56
        description => 'Password does not match complex passwordpolicy',
57
    },
46
);
58
);
47
59
48
sub full_message {
60
sub full_message {
(-)a/Koha/Patron.pm (-2 / +18 lines)
Lines 734-739 Exceptions are thrown if the password is not good enough. Link Here
734
734
735
=item Koha::Exceptions::Password::Plugin (if a "check password" plugin is enabled)
735
=item Koha::Exceptions::Password::Plugin (if a "check password" plugin is enabled)
736
736
737
=item Koha::Exceptions::Password::SimplePolicy
738
739
=item Koha::Exceptions::Password::AlphaPolicy
740
741
=item Koha::Exceptions::Password::ComplexPolicy
742
737
=back
743
=back
738
744
739
=cut
745
=cut
Lines 744-750 sub set_password { Link Here
744
    my $password = $args->{password};
750
    my $password = $args->{password};
745
751
746
    unless ( $args->{skip_validation} ) {
752
    unless ( $args->{skip_validation} ) {
747
        my ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( $password );
753
754
        my $categorycode = $self->category->categorycode;
755
        my ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( $password, $categorycode );
748
756
749
        if ( !$is_valid ) {
757
        if ( !$is_valid ) {
750
            if ( $error eq 'too_short' ) {
758
            if ( $error eq 'too_short' ) {
Lines 761-766 sub set_password { Link Here
761
            elsif ( $error eq 'too_weak' ) {
769
            elsif ( $error eq 'too_weak' ) {
762
                Koha::Exceptions::Password::TooWeak->throw();
770
                Koha::Exceptions::Password::TooWeak->throw();
763
            }
771
            }
772
            elsif ( $error eq 'simple_policy_mismatch' ) {
773
                Koha::Exceptions::Password::SimplePolicy->throw();
774
            }
775
            elsif ( $error eq 'alpha_policy_mismatch' ) {
776
                Koha::Exceptions::Password::AlphaPolicy->throw();
777
            }
778
            elsif ( $error eq 'complex_policy_mismatch' ) {
779
                Koha::Exceptions::Password::ComplexPolicy->throw();
780
            }
764
        }
781
        }
765
    }
782
    }
766
783
Lines 801-807 sub set_password { Link Here
801
    return $self;
818
    return $self;
802
}
819
}
803
820
804
805
=head3 renew_account
821
=head3 renew_account
806
822
807
my $new_expiry_date = $patron->renew_account
823
my $new_expiry_date = $patron->renew_account
(-)a/Koha/Schema/Result/Category.pm (+8 lines)
Lines 133-138 __PACKAGE__->table("categories"); Link Here
133
  data_type: 'tinyint'
133
  data_type: 'tinyint'
134
  is_nullable: 1
134
  is_nullable: 1
135
135
136
=head2 passwordpolicy
137
138
  data_type: 'varchar'
139
  is_nullable: 1
140
  size: 40
141
136
=cut
142
=cut
137
143
138
__PACKAGE__->add_columns(
144
__PACKAGE__->add_columns(
Lines 189-194 __PACKAGE__->add_columns( Link Here
189
  { data_type => "tinyint", is_nullable => 1 },
195
  { data_type => "tinyint", is_nullable => 1 },
190
  "change_password",
196
  "change_password",
191
  { data_type => "tinyint", is_nullable => 1 },
197
  { data_type => "tinyint", is_nullable => 1 },
198
  "passwordpolicy",
199
  { data_type => "varchar", is_nullable => 1, size => 40 },
192
);
200
);
193
201
194
=head1 PRIMARY KEY
202
=head1 PRIMARY KEY
(-)a/admin/categories.pl (+3 lines)
Lines 94-99 elsif ( $op eq 'add_validate' ) { Link Here
94
    my $default_privacy = $input->param('default_privacy');
94
    my $default_privacy = $input->param('default_privacy');
95
    my $reset_password = $input->param('reset_password');
95
    my $reset_password = $input->param('reset_password');
96
    my $change_password = $input->param('change_password');
96
    my $change_password = $input->param('change_password');
97
    my $selectedpasswordpolicy  = $input->param('passwordpolicy');
97
    my @branches = grep { $_ ne q{} } $input->multi_param('branches');
98
    my @branches = grep { $_ ne q{} } $input->multi_param('branches');
98
99
99
    $reset_password = undef if $reset_password eq -1;
100
    $reset_password = undef if $reset_password eq -1;
Lines 129-134 elsif ( $op eq 'add_validate' ) { Link Here
129
        $category->default_privacy($default_privacy);
130
        $category->default_privacy($default_privacy);
130
        $category->reset_password($reset_password);
131
        $category->reset_password($reset_password);
131
        $category->change_password($change_password);
132
        $category->change_password($change_password);
133
        $category->passwordpolicy($selectedpasswordpolicy);
132
        eval {
134
        eval {
133
            $category->store;
135
            $category->store;
134
            $category->replace_branch_limitations( \@branches );
136
            $category->replace_branch_limitations( \@branches );
Lines 157-162 elsif ( $op eq 'add_validate' ) { Link Here
157
            default_privacy => $default_privacy,
159
            default_privacy => $default_privacy,
158
            reset_password => $reset_password,
160
            reset_password => $reset_password,
159
            change_password => $change_password,
161
            change_password => $change_password,
162
            passwordpolicy => $selectedpasswordpolicy,
160
        });
163
        });
161
        eval {
164
        eval {
162
            $category->store;
165
            $category->store;
(-)a/installer/data/mysql/atomicupdate/Bug-12617-Koha-should-let-admins-to-configure-automatically-generated-password.perl (+18 lines)
Line 0 Link Here
1
$DBversion = 'XXX';  # will be replaced by the RM
2
if( CheckVersion( $DBversion ) ) {
3
    $dbh->do("ALTER TABLE categories ADD COLUMN passwordpolicy VARCHAR(40) DEFAULT NULL AFTER change_password");
4
5
    $dbh->do(
6
        "INSERT IGNORE INTO systempreferences (variable, value, options, explanation, type) VALUES ('minAlnumPasswordLength', '10', null, 'Specify the minimum length for alphanumeric passwords', 'free')"
7
    );
8
    $dbh->do(
9
        "INSERT IGNORE INTO systempreferences (variable, value, options, explanation, type) VALUES ('minComplexPasswordLength', '10', null, 'Specify the minimum length for complex passwords', 'free')"
10
    );
11
    $dbh->do(
12
        "UPDATE systempreferences set explanation='Specify the minimum length for simplenumeric passwords' where variable='minPasswordLength'"
13
    );
14
15
    # Always end with this (adjust the bug info)
16
    SetVersion( $DBversion );
17
    print "Upgrade to $DBversion done (Bug 12617 - Koha should let admins to configure automatically generated password complexity/difficulty)\n";
18
}
(-)a/installer/data/mysql/kohastructure.sql (+1 lines)
Lines 327-332 CREATE TABLE `categories` ( -- this table shows information related to Koha patr Link Here
327
  `checkprevcheckout` varchar(7) NOT NULL default 'inherit', -- produce a warning for this patron category if this item has previously been checked out to this patron if 'yes', not if 'no', defer to syspref setting if 'inherit'.
327
  `checkprevcheckout` varchar(7) NOT NULL default 'inherit', -- produce a warning for this patron category if this item has previously been checked out to this patron if 'yes', not if 'no', defer to syspref setting if 'inherit'.
328
  `reset_password` TINYINT(1) NULL DEFAULT NULL, -- if patrons of this category can do the password reset flow,
328
  `reset_password` TINYINT(1) NULL DEFAULT NULL, -- if patrons of this category can do the password reset flow,
329
  `change_password` TINYINT(1) NULL DEFAULT NULL, -- if patrons of this category can change their passwords in the OAPC
329
  `change_password` TINYINT(1) NULL DEFAULT NULL, -- if patrons of this category can change their passwords in the OAPC
330
  `passwordpolicy` varchar(40) default NULL, -- which password policy patron category uses
330
  PRIMARY KEY  (`categorycode`),
331
  PRIMARY KEY  (`categorycode`),
331
  UNIQUE KEY `categorycode` (`categorycode`)
332
  UNIQUE KEY `categorycode` (`categorycode`)
332
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
333
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
(-)a/installer/data/mysql/sysprefs.sql (+2 lines)
Lines 327-332 INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` Link Here
327
('MaxTotalSuggestions','',NULL,'Number of total suggestions used for time limit with NumberOfSuggestionDays','Free'),
327
('MaxTotalSuggestions','',NULL,'Number of total suggestions used for time limit with NumberOfSuggestionDays','Free'),
328
('MembershipExpiryDaysNotice','',NULL,'Send an account expiration notice that a patron\'s card is about to expire after','Integer'),
328
('MembershipExpiryDaysNotice','',NULL,'Send an account expiration notice that a patron\'s card is about to expire after','Integer'),
329
('MergeReportFields','',NULL,'Displayed fields for deleted MARC records after merge','Free'),
329
('MergeReportFields','',NULL,'Displayed fields for deleted MARC records after merge','Free'),
330
('minAlnumPasswordLength', '10', null, 'Specify the minimum length for alphanumeric passwords', 'free')
331
('minComplexPasswordLength', '10', null, 'Specify the minimum length for complex passwords', 'free')
330
('minPasswordLength','8',NULL,'Specify the minimum length of a patron/staff password','free'),
332
('minPasswordLength','8',NULL,'Specify the minimum length of a patron/staff password','free'),
331
('NewItemsDefaultLocation','','','If set, all new items will have a location of the given Location Code ( Authorized Value type LOC )',''),
333
('NewItemsDefaultLocation','','','If set, all new items will have a location of the given Location Code ( Authorized Value type LOC )',''),
332
('NewsAuthorDisplay','none','none|opac|staff|both','Display the author name for news items.','Choice'),
334
('NewsAuthorDisplay','none','none|opac|staff|both','Display the author name for news items.','Choice'),
(-)a/installer/onboarding.pl (-1 / +1 lines)
Lines 145-151 if ( $step == 3 ) { Link Here
145
        my $cardnumber     = $input->param('cardnumber');
145
        my $cardnumber     = $input->param('cardnumber');
146
        my $userid         = $input->param('userid');
146
        my $userid         = $input->param('userid');
147
147
148
        my ( $is_valid, $passworderror) = Koha::AuthUtils::is_password_valid( $firstpassword );
148
        my ( $is_valid, $passworderror) = Koha::AuthUtils::is_password_valid( $firstpassword, undef );
149
149
150
        if ( my $error_code = checkcardnumber($cardnumber) ) {
150
        if ( my $error_code = checkcardnumber($cardnumber) ) {
151
            if ( $error_code == 1 ) {
151
            if ( $error_code == 1 ) {
(-)a/koha-tmpl/intranet-tmpl/prog/en/includes/password_check.inc (-1 / +1 lines)
Lines 1-6 Link Here
1
[% USE Koha %]
1
[% USE Koha %]
2
[% BLOCK add_password_check %]
2
[% BLOCK add_password_check %]
3
<!-- password_check.inc -->
3
<!-- password_check.inc-->
4
<script>
4
<script>
5
    var pwd_title = "";
5
    var pwd_title = "";
6
    var pattern_title = "";
6
    var pattern_title = "";
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/categories.tt (+15 lines)
Lines 229-234 Link Here
229
                      [% END %]
229
                      [% END %]
230
                    </select>
230
                    </select>
231
                </li>
231
                </li>
232
                <li>
233
                    <label for="password-policy">Category password policy</label>
234
                    <select name="passwordpolicy" id="password-policy">
235
                        [% UNLESS category %]<option value="" selected="selected"></option>[% ELSE %]<option value=""></option>[% END %]
236
                        [% IF category and category.passwordpolicy == 'complex' %]<option value="complex" selected="selected">Complex</option>[% ELSE %]<option value="complex">Complex</option>[% END %]
237
                        [% IF category and category.passwordpolicy == 'alphanumeric' %]<option value="alphanumeric" selected="selected">Alphanumeric</option>[% ELSE %]<option value="alphanumeric">Alphanumeric</option>[% END %]
238
                        [% IF category and category.passwordpolicy == 'simplenumeric' %]<option value="simplenumeric" selected="selected">Numbers only</option>[% ELSE %]<option value="simplenumeric">Numbers only</option>[% END %]
239
                    </select>
240
                    <span>
241
                        Selecting a password policy for a category affects both automatically created suggested passwords and enfo$
242
                        of rules.
243
                    </span>
244
                </li>
232
                <li><label for="block_expired">Block expired patrons:</label>
245
                <li><label for="block_expired">Block expired patrons:</label>
233
                    <select name="BlockExpiredPatronOpacActions" id="block_expired">
246
                    <select name="BlockExpiredPatronOpacActions" id="block_expired">
234
                        [% IF not category or category.BlockExpiredPatronOpacActions == -1%]
247
                        [% IF not category or category.BlockExpiredPatronOpacActions == -1%]
Lines 419-424 Link Here
419
                    <th scope="col">Messaging</th>
432
                    <th scope="col">Messaging</th>
420
                    [% END %]
433
                    [% END %]
421
                    <th scope="col">Library limitations</th>
434
                    <th scope="col">Library limitations</th>
435
                    <th scope="col">Password policy</th>
422
                    [% IF ( Koha.Preference('CheckPrevCheckout') == 'softyes' || Koha.Preference('CheckPrevCheckout') == 'softno' ) %]
436
                    [% IF ( Koha.Preference('CheckPrevCheckout') == 'softyes' || Koha.Preference('CheckPrevCheckout') == 'softno' ) %]
423
                    <th scope="col">Check previous checkout?</th>
437
                    <th scope="col">Check previous checkout?</th>
424
                    [% END %]
438
                    [% END %]
Lines 514-519 Link Here
514
                                No limitation
528
                                No limitation
515
                            [% END %]
529
                            [% END %]
516
                        </td>
530
                        </td>
531
                        <td>[% category.passwordpolicy %]</td>
517
                        [% IF ( Koha.Preference('CheckPrevCheckout') == 'softyes' || Koha.Preference('CheckPrevCheckout') == 'softno' ) %]
532
                        [% IF ( Koha.Preference('CheckPrevCheckout') == 'softyes' || Koha.Preference('CheckPrevCheckout') == 'softno' ) %]
518
                          <td>
533
                          <td>
519
                              [% SWITCH category.checkprevcheckout %]
534
                              [% SWITCH category.checkprevcheckout %]
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/patrons.pref (-1 / +11 lines)
Lines 307-316 Patrons: Link Here
307
         - "days.<br>IMPORTANT: No action is performed when these delays are empty (no text). But a zero value ('0') is interpreted as no delay (do it now)! The actions are performed by the cleanup database cron job."
307
         - "days.<br>IMPORTANT: No action is performed when these delays are empty (no text). But a zero value ('0') is interpreted as no delay (do it now)! The actions are performed by the cleanup database cron job."
308
    Security:
308
    Security:
309
     -
309
     -
310
         - Login passwords for staff and patrons must be at least
310
         - Login passwords for simplenumeric policy must be at least
311
         - pref: minPasswordLength
311
         - pref: minPasswordLength
312
           class: integer
312
           class: integer
313
         - characters long.
313
         - characters long.
314
     -
315
         - Login passwords for alphanumeric policy must be at least
316
         - pref: minAlnumPasswordLength
317
           class: integer
318
         - characters long.
319
     -
320
         - Login passwords for complex policy must be at least
321
         - pref: minComplexPasswordLength
322
           class: integer
323
         - characters long.
314
     -
324
     -
315
         - pref: RequireStrongPassword
325
         - pref: RequireStrongPassword
316
           choices:
326
           choices:
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/members/member-password.tt (-8 / +28 lines)
Lines 51-59 Link Here
51
		[% IF ( NOPERMISSION ) %]
51
		[% IF ( NOPERMISSION ) %]
52
		<li>You do not have permission to edit this patron's login information.</li>
52
		<li>You do not have permission to edit this patron's login information.</li>
53
		[% END %]
53
		[% END %]
54
		[% IF ( NOMATCH ) %]
54
		[% IF ( ERROR_password_mismatch )%]
55
		<li><strong>The passwords entered do not match</strong>. Please re-enter the new password.</li>
55
		<li id="ERROR_password_mismatch"><strong>The passwords entered do not match</strong>. Please re-enter the new password.</li>
56
		[% END %]
56
		[% END %]
57
        [% IF ( ERROR_complex_policy_mismatch ) %]
58
        <li id="ERROR_policy_mismatch"><strong>Password policy: password must contain numbers, lower and uppercase characters and special characters and must be at least [% minPasswordLength | html %] characters long.</strong> Please re-enter the new password.</li>
59
        [% END %]
60
        [% IF ( ERROR_alpha_policy_mismatch ) %]
61
        <li id="ERROR_policy_mismatch"><strong>Password policy: password must contain both numbers and non-special characters and must be at least [% minPasswordLength | html %] characters long.</strong> Please re-enter the new password.</li>
62
        [% END %]
63
        [% IF ( ERROR_simple_policy_mismatch ) %]
64
        <li id="ERROR_policy_mismatch"><strong>Password policy: password can only contain digits 0-9 and must be at least [% minPasswordLength | html %] characters long.</strong> Please re-enter the new password.</li>
65
        [% END %]
57
		</ul>
66
		</ul>
58
		</div>
67
		</div>
59
	[% END %]
68
	[% END %]
Lines 102-112 Link Here
102
    [% INCLUDE 'str/members-menu.inc' %]
111
    [% INCLUDE 'str/members-menu.inc' %]
103
    [% Asset.js("js/members-menu.js") | $raw %]
112
    [% Asset.js("js/members-menu.js") | $raw %]
104
    <script>
113
    <script>
105
        function generate_password() {
114
        function generate_password(password_policy) {
106
            // Always generate a strong password
115
            // Follow password policy when generating password
107
            var chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
116
            var chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
108
            var length = [% Koha.Preference('minPasswordLength') | html %];
117
            if ( password_policy == 'complex' ){
109
            if ( length < 8 ) length = 8;
118
                chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ|[]{}!@#$%^&*()_-+?';
119
            } else if ( password_policy == 'simplenumeric'){
120
                chars = '0123456789';
121
            }
122
123
            var length = [% minPasswordLength | html %];
124
            if ( !password_policy && length < 8 ) length = 8;
110
            var password='';
125
            var password='';
111
            for ( var i = 0 ; i < length ; i++){
126
            for ( var i = 0 ; i < length ; i++){
112
                password += chars.charAt(Math.floor(Math.random()*chars.length));
127
                password += chars.charAt(Math.floor(Math.random()*chars.length));
Lines 117-125 Link Here
117
            $("body").on('click', "#fillrandom",function(e) {
132
            $("body").on('click', "#fillrandom",function(e) {
118
                e.preventDefault();
133
                e.preventDefault();
119
                var password = '';
134
                var password = '';
120
                var pattern_regex = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{[% Koha.Preference('minPasswordLength') | html %],}/;
135
                var password_policy = '[% password_policy | html %]';
136
137
                // Change password pattern to match password policy
138
                var pattern_regex = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{[% minPasswordLength | html %],}/;
139
                if (password_policy == 'simplenumeric') pattern_regex = /(?=.*\d).{[% minPasswordLength | html %],}/;
140
                if (password_policy == 'complex') pattern_regex = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[|[]{}!@#$%^&*()_-+?]).{[% minPasswordLength | html %],}/;
121
                while ( ! pattern_regex.test( password ) ) {
141
                while ( ! pattern_regex.test( password ) ) {
122
                    password = generate_password();
142
                    password = generate_password(password_policy);
123
                }
143
                }
124
                $("#newpassword").val(password);
144
                $("#newpassword").val(password);
125
                $("#newpassword").attr('type', 'text');
145
                $("#newpassword").attr('type', 'text');
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/members/memberentrygen.tt (+9 lines)
Lines 172-177 legend:hover { Link Here
172
                                    [% IF ERROR_bad_email_alternative %]
172
                                    [% IF ERROR_bad_email_alternative %]
173
                                        <li id="ERROR_bad_email_alternative">The alternative email is invalid.</li>
173
                                        <li id="ERROR_bad_email_alternative">The alternative email is invalid.</li>
174
                                    [% END %]
174
                                    [% END %]
175
                                    [% IF ( ERROR_complex_policy_mismatch ) %]
176
                                    <li id="ERROR_policy_mismatch">Password policy: password must contain numbers, lower and uppercase characters and special characters and must be at least [% minPasswordLength | html %] characters long.</li>
177
                                    [% END %]
178
                                    [% IF ( ERROR_alpha_policy_mismatch ) %]
179
                                    <li id="ERROR_policy_mismatch">Password policy: password must contain both numbers and non-special characters and must be at least [% minPasswordLength | html %] characters long.</li>
180
                                    [% END %]
181
                                    [% IF ( ERROR_simple_policy_mismatch ) %]
182
                                    <li id="ERROR_policy_mismatch">Password policy: password can only contain digits 0-9 and must be at least [% minPasswordLength | html %] characters long.</li>
183
                                    [% END %]
175
                                </ul>
184
                                </ul>
176
                            </div>
185
                            </div>
177
                        [% END %]
186
                        [% END %]
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-memberentry.tt (-2 / +17 lines)
Lines 90-95 Link Here
90
                                [% IF field == "password_has_whitespaces" %]
90
                                [% IF field == "password_has_whitespaces" %]
91
                                    <li>Password must not contain leading or trailing whitespaces.</li>
91
                                    <li>Password must not contain leading or trailing whitespaces.</li>
92
                                [% END %]
92
                                [% END %]
93
                                [% IF field == "complex_policy_mismatch" %]
94
                                    <li>Password policy: password must contain numbers, lower and uppercase characters and special characters and must be at least [% minPasswordLength | html %] characters long.</li>
95
                                [% END %]
96
                                [% IF field == "alpha_policy_mismatch" %]
97
                                    <li>Password policy: password must contain both numbers and non-special characters and must be at least [% minPasswordLength | html %] characters long.</li>
98
                                [% END %]
99
                                [% IF field == "simple_policy_mismatch" %]
100
                                    <li>Password policy: password can only contain digits 0-9 and must be at least [% minPasswordLength | html %] characters long.</li>
101
                                [% END %]
93
                                [% IF field == "duplicate_email" %]
102
                                [% IF field == "duplicate_email" %]
94
                                    <li>This email address already exists in our database.</li>
103
                                    <li>This email address already exists in our database.</li>
95
                                [% END %]
104
                                [% END %]
Lines 859-867 Link Here
859
                        <legend id="contact_legend">Password</legend>
868
                        <legend id="contact_legend">Password</legend>
860
                        <div class="alert alert-info">
869
                        <div class="alert alert-info">
861
                            [% IF ( Koha.Preference('RequireStrongPassword') ) %]
870
                            [% IF ( Koha.Preference('RequireStrongPassword') ) %]
862
                                <p>Your password must contain at least [% Koha.Preference('minPasswordLength') | html %] characters, including UPPERCASE, lowercase and numbers.</p>
871
                                <p>Your password must contain at least [% minPasswordLength | html %] characters, including UPPERCASE, lowercase and numbers.</p>
872
                            [% ELSIF ( password_policy == 'complex') %]
873
                                <p>Your password must contain numbers, lower and uppercase characters and special characters and must be at least [% minPasswordLength | html %] characters long.</p>
874
                            [% ELSIF ( password_policy == 'alphanumeric') %]
875
                                <p>Your password must contain both numbers and non-special characters and must be at least [% minPasswordLength | html %] characters long.</p>
876
                            [% ELSIF ( password_policy == 'simplenumeric') %]
877
                                <p>Your password can only contain digits 0-9 and must be at least [% minPasswordLength | html %] characters long.</p>
863
                            [% ELSE %]
878
                            [% ELSE %]
864
                                <p>Your password must be at least [% Koha.Preference('minPasswordLength') | html %] characters long.</p>
879
                                <p>Your password must be at least [% minPasswordLength | html %] characters long.</p>
865
                            [% END %]
880
                            [% END %]
866
                            [% UNLESS mandatory.defined('password') %]
881
                            [% UNLESS mandatory.defined('password') %]
867
                                <p>If you do not enter a password a system generated password will be created.</p>
882
                                <p>If you do not enter a password a system generated password will be created.</p>
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-passwd.tt (-1 / +15 lines)
Lines 43-49 Link Here
43
                                [% IF password_has_whitespaces %]
43
                                [% IF password_has_whitespaces %]
44
                                    Password must not contain leading or trailing whitespaces.
44
                                    Password must not contain leading or trailing whitespaces.
45
                                [% END %]
45
                                [% END %]
46
46
                                [% IF ( complex_policy_mismatch ) %]
47
                                    Password policy: password must contain numbers, lower and uppercase characters and special characters and must be at least [% minPasswordLength | html %] characters long.
48
                                [% END %]
49
                                [% IF ( alpha_policy_mismatch ) %]
50
                                    Password policy: password must contain both numbers and non-special characters and must be at least [% minPasswordLength | html %] characters long.
51
                                [% END %]
52
                                [% IF ( simple_policy_mismatch ) %]
53
                                    Password policy: password can only contain digits 0-9 and must be at least [% minPasswordLength | html %] characters long.
54
                                [% END %]
47
                                [% IF ( WrongPass ) %]
55
                                [% IF ( WrongPass ) %]
48
                                Your current password was entered incorrectly.  If this problem persists, please ask a librarian to reset your password for you.
56
                                Your current password was entered incorrectly.  If this problem persists, please ask a librarian to reset your password for you.
49
                                [% END %]
57
                                [% END %]
Lines 59-64 Link Here
59
                                <fieldset>
67
                                <fieldset>
60
                                    [% IF ( Koha.Preference('RequireStrongPassword') ) %]
68
                                    [% IF ( Koha.Preference('RequireStrongPassword') ) %]
61
                                        <div class="alert alert-info">Your password must contain at least [% Koha.Preference('minPasswordLength') | html %] characters, including UPPERCASE, lowercase and numbers.</div>
69
                                        <div class="alert alert-info">Your password must contain at least [% Koha.Preference('minPasswordLength') | html %] characters, including UPPERCASE, lowercase and numbers.</div>
70
                                    [% ELSIF ( password_policy == 'complex') %]
71
                                        <div class="alert alert-info">Your password must contain numbers, lower and uppercase characters and special characters and must be at least [% minPasswordLength | html %] characters long.</div>
72
                                    [% ELSIF ( password_policy == 'alphanumeric') %]
73
                                       <div class="alert alert-info">Your password must contain both numbers and non-special characters and must be at least [% minPasswordLength | html %] characters long.</div>
74
                                    [% ELSIF ( password_policy == 'simplenumeric') %]
75
                                        <div class="alert alert-info">Your password can only contain digits 0-9 and must be at least [% minPasswordLength | html %] characters long.</div>
62
                                    [% ELSE %]
76
                                    [% ELSE %]
63
                                        <div class="alert alert-info">Your password must be at least [% Koha.Preference('minPasswordLength') | html %] characters long.</div>
77
                                        <div class="alert alert-info">Your password must be at least [% Koha.Preference('minPasswordLength') | html %] characters long.</div>
64
                                    [% END %]
78
                                    [% END %]
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-password-recovery.tt (+12 lines)
Lines 80-85 Link Here
80
                    [% ELSIF (errLinkNotValid) %]
80
                    [% ELSIF (errLinkNotValid) %]
81
                        The link you clicked is either invalid, or expired.
81
                        The link you clicked is either invalid, or expired.
82
                        <br/>Be sure you used the link from the email, or contact library staff for assistance.
82
                        <br/>Be sure you used the link from the email, or contact library staff for assistance.
83
                    [% ELSIF ( complex_policy_mismatch ) %]
84
                        <li>Password policy: password must contain numbers, lower and uppercase characters and special characters and must be at least [% minPasswordLength | html %] characters long.</li>
85
                    [% ELSIF ( alpha_policy_mismatch ) %]
86
                        <li>Password policy: password must contain both numbers and non-special characters and must be at least [% minPasswordLength | html %] characters long.</li>
87
                    [% ELSIF ( simple_policy_mismatch ) %]
88
                        <li>Password policy: password can only contain digits 0-9 and must be at least [% minPasswordLength | html %] characters long.</li>
83
                    [% END %]
89
                    [% END %]
84
                    </p>
90
                    </p>
85
                    <p>Please contact the library if you need further assistance.</p>
91
                    <p>Please contact the library if you need further assistance.</p>
Lines 109-114 Link Here
109
                        <fieldset>
115
                        <fieldset>
110
                            [% IF ( Koha.Preference('RequireStrongPassword') ) %]
116
                            [% IF ( Koha.Preference('RequireStrongPassword') ) %]
111
                                <div class="alert alert-info">Your password must contain at least [% Koha.Preference('minPasswordLength') | html %] characters, including UPPERCASE, lowercase and numbers.</div>
117
                                <div class="alert alert-info">Your password must contain at least [% Koha.Preference('minPasswordLength') | html %] characters, including UPPERCASE, lowercase and numbers.</div>
118
                            [% ELSIF ( password_policy == 'complex') %]
119
                                <div class="alert alert-info">Your password must contain numbers, lower and uppercase characters and special characters and must be at least [% minPasswordLength | html %] characters long.</div>
120
                            [% ELSIF ( password_policy == 'alphanumeric') %]
121
                                <div class="alert alert-info">Your password must contain both numbers and non-special characters and must be at least [% minPasswordLength | html %] characters long.</div>
122
                            [% ELSIF ( password_policy == 'simplenumeric') %]
123
                                <div class="alert alert-info">Your password can only contain digits 0-9 and must be at least [% minPasswordLength | html %] characters long.</div>
112
                            [% ELSE %]
124
                            [% ELSE %]
113
                                <div class="alert alert-info">Your password must be at least [% Koha.Preference('minPasswordLength') | html %] characters long.</div>
125
                                <div class="alert alert-info">Your password must be at least [% Koha.Preference('minPasswordLength') | html %] characters long.</div>
114
                            [% END %]
126
                            [% END %]
(-)a/members/member-password.pl (-1 / +14 lines)
Lines 51-61 my $patron = Koha::Patrons->find( $patron_id ); Link Here
51
output_and_exit_if_error( $input, $cookie, $template, { module => 'members', logged_in_user => $logged_in_user, current_patron => $patron } );
51
output_and_exit_if_error( $input, $cookie, $template, { module => 'members', logged_in_user => $logged_in_user, current_patron => $patron } );
52
52
53
my $category_type = $patron->category->category_type;
53
my $category_type = $patron->category->category_type;
54
my $categorycode = $patron->category->categorycode;
55
my $passwordpolicy = $patron->category->passwordpolicy;
56
my $minpasslength = Koha::AuthUtils::min_password_length($categorycode);
54
57
55
if ( ( $patron_id ne $loggedinuser ) && ( $category_type eq 'S' ) ) {
58
if ( ( $patron_id ne $loggedinuser ) && ( $category_type eq 'S' ) ) {
56
    push( @errors, 'NOPERMISSION' )
59
    push( @errors, 'NOPERMISSION' )
57
      unless ( $staffflags->{'superlibrarian'} || $staffflags->{'staffaccess'} );
60
      unless ( $staffflags->{'superlibrarian'} || $staffflags->{'staffaccess'} );
58
59
    # need superlibrarian for koha-conf.xml fakeuser.
61
    # need superlibrarian for koha-conf.xml fakeuser.
60
}
62
}
61
63
Lines 94-99 if ( $newpassword and not @errors) { Link Here
94
        elsif ( $_->isa('Koha::Exceptions::Password::Plugin') ) {
96
        elsif ( $_->isa('Koha::Exceptions::Password::Plugin') ) {
95
            push @errors, 'ERROR_from_plugin';
97
            push @errors, 'ERROR_from_plugin';
96
        }
98
        }
99
        elsif ( $_->isa('Koha::Exceptions::Password::SimplePolicy') ) {
100
            push @errors, 'ERROR_simple_policy_mismatch';
101
        }
102
        elsif ( $_->isa('Koha::Exceptions::Password::AlphaPolicy') ) {
103
            push @errors, 'ERROR_alpha_policy_mismatch';
104
        }
105
        elsif ( $_->isa('Koha::Exceptions::Password::ComplexPolicy') ) {
106
            push @errors, 'ERROR_complex_policy_mismatch';
107
        }
97
        else {
108
        else {
98
            push( @errors, 'BADUSERID' );
109
            push( @errors, 'BADUSERID' );
99
        }
110
        }
Lines 104-109 $template->param( Link Here
104
    patron      => $patron,
115
    patron      => $patron,
105
    destination => $destination,
116
    destination => $destination,
106
    csrf_token  => Koha::Token->new->generate_csrf({ session_id => scalar $input->cookie('CGISESSID'), }),
117
    csrf_token  => Koha::Token->new->generate_csrf({ session_id => scalar $input->cookie('CGISESSID'), }),
118
    password_policy => $passwordpolicy,
119
    minPasswordLength => $minpasslength,
107
);
120
);
108
121
109
if ( scalar(@errors) ) {
122
if ( scalar(@errors) ) {
(-)a/members/memberentry.pl (-1 / +7 lines)
Lines 184-189 unless ($category_type or !($categorycode)){ Link Here
184
}
184
}
185
$category_type="A" unless $category_type; # FIXME we should display a error message instead of a 500 error !
185
$category_type="A" unless $category_type; # FIXME we should display a error message instead of a 500 error !
186
186
187
my $minpasslength = Koha::AuthUtils::min_password_length($categorycode);
188
$template->param("minPasswordLength" => $minpasslength);
189
187
# if a add or modify is requested => check validity of data.
190
# if a add or modify is requested => check validity of data.
188
%data = %$borrower_data if ($borrower_data);
191
%data = %$borrower_data if ($borrower_data);
189
192
Lines 379-389 if ($op eq 'save' || $op eq 'insert'){ Link Here
379
  push @errors, "ERROR_password_mismatch" if ( $password ne $password2 );
382
  push @errors, "ERROR_password_mismatch" if ( $password ne $password2 );
380
383
381
  if ( $password and $password ne '****' ) {
384
  if ( $password and $password ne '****' ) {
382
      my ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( $password );
385
      my ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( $password, $categorycode );
383
      unless ( $is_valid ) {
386
      unless ( $is_valid ) {
384
          push @errors, 'ERROR_password_too_short' if $error eq 'too_short';
387
          push @errors, 'ERROR_password_too_short' if $error eq 'too_short';
385
          push @errors, 'ERROR_password_too_weak' if $error eq 'too_weak';
388
          push @errors, 'ERROR_password_too_weak' if $error eq 'too_weak';
386
          push @errors, 'ERROR_password_has_whitespaces' if $error eq 'has_whitespaces';
389
          push @errors, 'ERROR_password_has_whitespaces' if $error eq 'has_whitespaces';
390
          push @errors, 'ERROR_complex_policy_mismatch' if $error eq 'complex_policy_mismatch';
391
          push @errors, 'ERROR_alpha_policy_mismatch' if $error eq 'alpha_policy_mismatch';
392
          push @errors, 'ERROR_simple_policy_mismatch' if $error eq 'simple_policy_mismatch';
387
      }
393
      }
388
  }
394
  }
389
395
(-)a/opac/opac-memberentry.pl (-2 / +17 lines)
Lines 38-43 use Koha::DateUtils; Link Here
38
use Koha::Libraries;
38
use Koha::Libraries;
39
use Koha::Patron::Attribute::Types;
39
use Koha::Patron::Attribute::Types;
40
use Koha::Patron::Attributes;
40
use Koha::Patron::Attributes;
41
use Koha::Patron::Categories;
41
use Koha::Patron::Images;
42
use Koha::Patron::Images;
42
use Koha::Patron::Modification;
43
use Koha::Patron::Modification;
43
use Koha::Patron::Modifications;
44
use Koha::Patron::Modifications;
Lines 117-122 foreach my $attr (@$attributes) { Link Here
117
    }
118
    }
118
}
119
}
119
120
121
my $categorycode = C4::Context->preference('PatronSelfRegistrationDefaultCategory');
122
my $categoryinfo = Koha::Patron::Categories->find($categorycode);
123
my $passwordpolicy = $categoryinfo->passwordpolicy;
124
my $minpasslength = Koha::AuthUtils::min_password_length($categorycode);
125
$template->param(
126
    password_policy => $passwordpolicy,
127
    minPasswordLength => $minpasslength
128
);
129
120
if ( $action eq 'create' ) {
130
if ( $action eq 'create' ) {
121
131
122
    my %borrower = ParseCgiForBorrower($cgi);
132
    my %borrower = ParseCgiForBorrower($cgi);
Lines 125-130 if ( $action eq 'create' ) { Link Here
125
135
126
    my @empty_mandatory_fields = (CheckMandatoryFields( \%borrower, $action ), CheckMandatoryAttributes( \%borrower, $attributes ) );
136
    my @empty_mandatory_fields = (CheckMandatoryFields( \%borrower, $action ), CheckMandatoryAttributes( \%borrower, $attributes ) );
127
    my $invalidformfields = CheckForInvalidFields(\%borrower);
137
    my $invalidformfields = CheckForInvalidFields(\%borrower);
138
128
    delete $borrower{'password2'};
139
    delete $borrower{'password2'};
129
    my $cardnumber_error_code;
140
    my $cardnumber_error_code;
130
    if ( !grep { $_ eq 'cardnumber' } @empty_mandatory_fields ) {
141
    if ( !grep { $_ eq 'cardnumber' } @empty_mandatory_fields ) {
Lines 216-222 if ( $action eq 'create' ) { Link Here
216
            );
227
            );
217
228
218
            $borrower{categorycode}     ||= C4::Context->preference('PatronSelfRegistrationDefaultCategory');
229
            $borrower{categorycode}     ||= C4::Context->preference('PatronSelfRegistrationDefaultCategory');
219
            $borrower{password}         ||= Koha::AuthUtils::generate_password;
230
            $borrower{password}         ||= Koha::AuthUtils::generate_password($borrower{categorycode});
231
220
            my $consent_dt = delete $borrower{gdpr_proc_consent};
232
            my $consent_dt = delete $borrower{gdpr_proc_consent};
221
            my $patron = Koha::Patron->new( \%borrower )->store;
233
            my $patron = Koha::Patron->new( \%borrower )->store;
222
            Koha::Patron::Consent->new({ borrowernumber => $patron->borrowernumber, type => 'GDPR_PROCESSING', given_on => $consent_dt })->store if $consent_dt;
234
            Koha::Patron::Consent->new({ borrowernumber => $patron->borrowernumber, type => 'GDPR_PROCESSING', given_on => $consent_dt })->store if $consent_dt;
Lines 467-477 sub CheckForInvalidFields { Link Here
467
        push( @invalidFields, "password_match" );
479
        push( @invalidFields, "password_match" );
468
    }
480
    }
469
    if ( $borrower->{'password'} ) {
481
    if ( $borrower->{'password'} ) {
470
        my ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( $borrower->{password} );
482
        my ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( $borrower->{password}, $borrower->{categorycode} );
471
          unless ( $is_valid ) {
483
          unless ( $is_valid ) {
472
              push @invalidFields, 'password_too_short' if $error eq 'too_short';
484
              push @invalidFields, 'password_too_short' if $error eq 'too_short';
473
              push @invalidFields, 'password_too_weak' if $error eq 'too_weak';
485
              push @invalidFields, 'password_too_weak' if $error eq 'too_weak';
474
              push @invalidFields, 'password_has_whitespaces' if $error eq 'has_whitespaces';
486
              push @invalidFields, 'password_has_whitespaces' if $error eq 'has_whitespaces';
487
              push @invalidFields, 'complex_policy_mismatch' if $error eq 'complex_policy_mismatch';
488
              push @invalidFields, 'alpha_policy_mismatch' if $error eq 'alpha_policy_mismatch';
489
              push @invalidFields, 'simple_policy_mismatch' if $error eq 'simple_policy_mismatch';
475
          }
490
          }
476
    }
491
    }
477
492
(-)a/opac/opac-passwd.pl (+13 lines)
Lines 27-32 use C4::Context; Link Here
27
use C4::Circulation;
27
use C4::Circulation;
28
use C4::Members;
28
use C4::Members;
29
use C4::Output;
29
use C4::Output;
30
use Koha::AuthUtils;
30
use Koha::Patrons;
31
use Koha::Patrons;
31
32
32
use Try::Tiny;
33
use Try::Tiny;
Lines 44-49 my ( $template, $borrowernumber, $cookie ) = get_template_and_user( Link Here
44
);
45
);
45
46
46
my $patron = Koha::Patrons->find( $borrowernumber );
47
my $patron = Koha::Patrons->find( $borrowernumber );
48
my $categorycode = $patron->category->categorycode;
49
my $passwordpolicy = $patron->category->passwordpolicy;
50
my $minpasslength = Koha::AuthUtils::min_password_length($categorycode);
47
if ( $patron->category->effective_change_password ) {
51
if ( $patron->category->effective_change_password ) {
48
    if (   $query->param('Oldkey')
52
    if (   $query->param('Oldkey')
49
        && $query->param('Newkey')
53
        && $query->param('Newkey')
Lines 60-65 if ( $patron->category->effective_change_password ) { Link Here
60
                $template->param( 'passwords_mismatch'   => '1' );
64
                $template->param( 'passwords_mismatch'   => '1' );
61
            } else {
65
            } else {
62
                try {
66
                try {
67
                    Koha::AuthUtils::is_password_valid( $new_password, $categorycode );
63
                    $patron->set_password({ password => $new_password });
68
                    $patron->set_password({ password => $new_password });
64
                    $template->param( 'password_updated' => '1' );
69
                    $template->param( 'password_updated' => '1' );
65
                    $template->param( 'borrowernumber'   => $borrowernumber );
70
                    $template->param( 'borrowernumber'   => $borrowernumber );
Lines 71-76 if ( $patron->category->effective_change_password ) { Link Here
71
                        if $_->isa('Koha::Exceptions::Password::TooWeak');
76
                        if $_->isa('Koha::Exceptions::Password::TooWeak');
72
                    $error = 'password_has_whitespaces'
77
                    $error = 'password_has_whitespaces'
73
                        if $_->isa('Koha::Exceptions::Password::WhitespaceCharacters');
78
                        if $_->isa('Koha::Exceptions::Password::WhitespaceCharacters');
79
                    $error = 'simple_policy_mismatch'
80
                        if $_->isa('Koha::Exceptions::Password::SimplePolicy');
81
                    $error = 'alpha_policy_mismatch'
82
                        if $_->isa('Koha::Exceptions::Password::AlphaPolicy');
83
                    $error = 'complex_policy_mismatch'
84
                        if $_->isa('Koha::Exceptions::Password::ComplexPolicy');
74
                };
85
                };
75
            }
86
            }
76
        }
87
        }
Lines 106-111 $template->param( Link Here
106
    firstname  => $patron->firstname,
117
    firstname  => $patron->firstname,
107
    surname    => $patron->surname,
118
    surname    => $patron->surname,
108
    passwdview => 1,
119
    passwdview => 1,
120
    password_policy => $passwordpolicy,
121
    minPasswordLength => $minpasslength,
109
);
122
);
110
123
111
124
(-)a/opac/opac-password-recovery.pl (+26 lines)
Lines 7-12 use C4::Auth; Link Here
7
use C4::Koha;
7
use C4::Koha;
8
use C4::Output;
8
use C4::Output;
9
use C4::Context;
9
use C4::Context;
10
use Koha::AuthUtils;
10
use Koha::Patron::Password::Recovery
11
use Koha::Patron::Password::Recovery
11
  qw(SendPasswordRecoveryEmail ValidateBorrowernumber GetValidLinkInfo CompletePasswordRecovery DeleteExpiredPasswordRecovery);
12
  qw(SendPasswordRecoveryEmail ValidateBorrowernumber GetValidLinkInfo CompletePasswordRecovery DeleteExpiredPasswordRecovery);
12
use Koha::Patrons;
13
use Koha::Patrons;
Lines 153-158 if ( $query->param('sendEmail') || $query->param('resendEmail') ) { Link Here
153
elsif ( $query->param('passwordReset') ) {
154
elsif ( $query->param('passwordReset') ) {
154
    ( $borrower_number, $username ) = GetValidLinkInfo($uniqueKey);
155
    ( $borrower_number, $username ) = GetValidLinkInfo($uniqueKey);
155
156
157
    my $patron = Koha::Patrons->find($borrower_number);
158
    my $categorycode = $patron->category->categorycode;
159
    my $passwordpolicy = $patron->category->passwordpolicy;
160
    my $minpasslength = Koha::AuthUtils::min_password_length($categorycode);
161
162
    $template->param(
163
        password_policy => $passwordpolicy,
164
        minPasswordLength => $minpasslength,
165
    );
166
156
    my $error;
167
    my $error;
157
    if ( not $borrower_number ) {
168
    if ( not $borrower_number ) {
158
        $error = 'errLinkNotValid';
169
        $error = 'errLinkNotValid';
Lines 178-183 elsif ( $query->param('passwordReset') ) { Link Here
178
            elsif ( $_->isa('Koha::Exceptions::Password::TooWeak') ) {
189
            elsif ( $_->isa('Koha::Exceptions::Password::TooWeak') ) {
179
                $error = 'password_too_weak';
190
                $error = 'password_too_weak';
180
            }
191
            }
192
            elsif ( $_->isa('Koha::Exceptions::Password::SimplePolicy') ) {
193
                $error = 'simple_policy_mismatch';
194
            }
195
            elsif ( $_->isa('Koha::Exceptions::Password::AlphaPolicy') ) {
196
                $error = 'alpha_policy_mismatch';
197
            }
198
            elsif ( $_->isa('Koha::Exceptions::Password::ComplexPolicy') ) {
199
                $error = 'complex_policy_mismatch';
200
            }
181
        };
201
        };
182
    }
202
    }
183
    if ( $error ) {
203
    if ( $error ) {
Lines 193-198 elsif ( $query->param('passwordReset') ) { Link Here
193
elsif ($uniqueKey) {    #reset password form
213
elsif ($uniqueKey) {    #reset password form
194
                        #check if the link is valid
214
                        #check if the link is valid
195
    ( $borrower_number, $username ) = GetValidLinkInfo($uniqueKey);
215
    ( $borrower_number, $username ) = GetValidLinkInfo($uniqueKey);
216
    my $patron = Koha::Patrons->find($borrower_number);
217
    my $categorycode = $patron->category->categorycode;
218
    my $passwordpolicy = $patron->category->passwordpolicy;
219
    my $minpasslength = Koha::AuthUtils::min_password_length($categorycode);
196
220
197
    if ( !$borrower_number ) {
221
    if ( !$borrower_number ) {
198
        $errLinkNotValid = 1;
222
        $errLinkNotValid = 1;
Lines 205-210 elsif ($uniqueKey) { #reset password form Link Here
205
        username        => $username,
229
        username        => $username,
206
        errLinkNotValid => $errLinkNotValid,
230
        errLinkNotValid => $errLinkNotValid,
207
        hasError        => ( $errLinkNotValid ? 1 : 0 ),
231
        hasError        => ( $errLinkNotValid ? 1 : 0 ),
232
        password_policy => $passwordpolicy,
233
        minPasswordLength => $minpasslength,
208
    );
234
    );
209
}
235
}
210
else {    #password recovery form (to send email)
236
else {    #password recovery form (to send email)
(-)a/t/AuthUtils.t (-11 / +125 lines)
Lines 20-25 use Modern::Perl; Link Here
20
use Test::More tests => 3;
20
use Test::More tests => 3;
21
21
22
use t::lib::Mocks;
22
use t::lib::Mocks;
23
use t::lib::TestBuilder;
23
use Koha::AuthUtils qw/hash_password/;
24
use Koha::AuthUtils qw/hash_password/;
24
25
25
my $hash1 = hash_password('password');
26
my $hash1 = hash_password('password');
Lines 27-73 my $hash2 = hash_password('password'); Link Here
27
28
28
ok($hash1 ne $hash2, 'random salts used when generating password hash');
29
ok($hash1 ne $hash2, 'random salts used when generating password hash');
29
30
31
my $schema = Koha::Database->schema;
32
my $dbh = C4::Context->dbh;
33
$schema->storage->txn_begin;
34
35
my $builder = t::lib::TestBuilder->new;
36
37
# Password policy blank
38
my $category = $builder->build({
39
    source => 'Category',
40
    value  => {
41
        categorycode => 'XYZ4',
42
        passwordpolicy => ''
43
    },
44
});
45
46
# Password policy simplenumeric
47
my $category_simple = $builder->build({
48
    source => 'Category',
49
    value  => {
50
        categorycode => 'XYZ1',
51
        passwordpolicy => 'simplenumeric'
52
    },
53
});
54
55
# Password policy alphanumeric
56
my $category_alpha = $builder->build({
57
    source => 'Category',
58
    value  => {
59
        categorycode => 'XYZ2',
60
        passwordpolicy => 'alphanumeric'
61
    },
62
});
63
64
# Password policy complex
65
my $category_complex = $builder->build({
66
    source => 'Category',
67
    value  => {
68
        categorycode => 'XYZ3',
69
        passwordpolicy => 'complex'
70
    },
71
});
72
73
t::lib::Mocks::mock_preference('minAlnumPasswordLength', 5);
74
t::lib::Mocks::mock_preference('minComplexPasswordLength', 6);
75
30
subtest 'is_password_valid' => sub {
76
subtest 'is_password_valid' => sub {
31
    plan tests => 12;
77
    plan tests => 13;
32
78
33
    my ( $is_valid, $error );
79
    my ( $is_valid, $error );
34
80
35
    t::lib::Mocks::mock_preference('RequireStrongPassword', 0);
81
    t::lib::Mocks::mock_preference('RequireStrongPassword', 0);
36
    t::lib::Mocks::mock_preference('minPasswordLength', 0);
82
    t::lib::Mocks::mock_preference('minPasswordLength', 0);
37
    ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( '12' );
83
    ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( '12', $category->{categorycode} );
38
    is( $is_valid, 0, 'min password size should be 3' );
84
    is( $is_valid, 0, 'min password size should be 3' );
39
    is( $error, 'too_short', 'min password size should be 3' );
85
    is( $error, 'too_short', 'min password size should be 3' );
40
    ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( ' 123' );
86
    ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( ' 123', $category->{categorycode} );
41
    is( $is_valid, 0, 'password should not contain leading spaces' );
87
    is( $is_valid, 0, 'password should not contain leading spaces' );
42
    is( $error, 'has_whitespaces', 'password should not contain leading spaces' );
88
    is( $error, 'has_whitespaces', 'password should not contain leading spaces' );
43
    ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( '123 ' );
89
    ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( '123 ', $category->{categorycode} );
44
    is( $is_valid, 0, 'password should not contain trailing spaces' );
90
    is( $is_valid, 0, 'password should not contain trailing spaces' );
45
    is( $error, 'has_whitespaces', 'password should not contain trailing spaces' );
91
    is( $error, 'has_whitespaces', 'password should not contain trailing spaces' );
46
    ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( '123' );
92
    ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( '123', $category->{categorycode} );
47
    is( $is_valid, 1, 'min password size should be 3' );
93
    is( $is_valid, 1, 'min password size should be 3' );
48
94
49
    t::lib::Mocks::mock_preference('RequireStrongPassword', 1);
95
    t::lib::Mocks::mock_preference('RequireStrongPassword', 1);
50
    t::lib::Mocks::mock_preference('minPasswordLength', 8);
96
    t::lib::Mocks::mock_preference('minPasswordLength', 8);
51
    ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( '12345678' );
97
    ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( '12345678', $category->{categorycode} );
52
    is( $is_valid, 0, 'password should be strong' );
98
    is( $is_valid, 0, 'password should be strong' );
53
    is( $error, 'too_weak', 'password should be strong' );
99
    is( $error, 'too_weak', 'password should be strong' );
54
    ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( 'abcd1234' );
100
    ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( 'abcd1234', $category->{categorycode} );
55
    is( $is_valid, 0, 'strong password should contain uppercase' );
101
    is( $is_valid, 0, 'strong password should contain uppercase' );
56
    is( $error, 'too_weak', 'strong password should contain uppercase' );
102
    is( $error, 'too_weak', 'strong password should contain uppercase' );
57
103
58
    ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( 'abcD1234' );
104
    ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( 'abcD1234', $category->{categorycode} );
59
    is( $is_valid, 1, 'strong password should contain uppercase' );
105
    is( $is_valid, 1, 'strong password should contain uppercase' );
106
107
    subtest 'password policies' => sub {
108
109
        t::lib::Mocks::mock_preference('RequireStrongPassword', 0);
110
        t::lib::Mocks::mock_preference('minPasswordLength', 4);
111
112
        #test simplenumeric password policy
113
        ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( '1234', $category_simple->{categorycode} );
114
        is ( $is_valid, 1, 'simplenumeric password should contain only numbers' );
115
        ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( 'A123', $category_simple->{categorycode} );
116
        is ( $is_valid, 0, 'simplenumeric password should not contain alphabets' );
117
        is($error, 'simple_policy_mismatch', 'error "simple_policy_mismatch" raised');
118
        ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( '!234', $category_simple->{categorycode} );
119
        is ( $is_valid, 0, 'simplenumeric password should not contain non-special characters' );
120
        ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( '123', $category_simple->{categorycode} );
121
        is ( $is_valid, 0, 'simplenumeric password follows minPasswordLength syspref' );
122
123
        #test alphanumeric
124
        ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( 'A1234', $category_alpha->{categorycode} );
125
        is ( $is_valid, 1, 'alphanumeric password should contain both numbers and non-special characters' );
126
        ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( '12345', $category_alpha->{categorycode} );
127
        is ( $is_valid, 0, 'alphanumeric password must contain at least one uppercase character' );
128
        is($error, 'alpha_policy_mismatch', 'error "alpha_policy_mismatch" raised');
129
        ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( 'A123', $category_alpha->{categorycode} );
130
        is ( $is_valid, 0, 'alphanumeric password follows minAlnumPasswordLength syspref');
131
132
        #test complex
133
        ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( 'As!123', $category_complex->{categorycode} );
134
        is ( $is_valid, 1, 'complex password should contain numbers, lower and uppercase characters and special characters' );
135
        ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( 'A12345', $category_complex->{categorycode});
136
        is ( $is_valid, 0, 'complex password must contain numbers, lower and uppercase characters and special characters' );
137
        is($error, 'complex_policy_mismatch', 'error "complex_policy_mismatch" raised');
138
        ( $is_valid, $error ) = Koha::AuthUtils::is_password_valid( 'As!12', $category_complex->{categorycode});
139
        is ( $is_valid, 0, 'complex password follows minComplexPasswordLength syspref' );
140
    }
60
};
141
};
61
142
62
subtest 'generate_password' => sub {
143
subtest 'generate_password' => sub {
63
    plan tests => 1;
144
    plan tests => 2;
64
    t::lib::Mocks::mock_preference('RequireStrongPassword', 1);
145
    t::lib::Mocks::mock_preference('RequireStrongPassword', 1);
65
    t::lib::Mocks::mock_preference('minPasswordLength', 8);
146
    t::lib::Mocks::mock_preference('minPasswordLength', 8);
66
    my $all_valid = 1;
147
    my $all_valid = 1;
67
    for ( 1 .. 10 ) {
148
    for ( 1 .. 10 ) {
68
        my $password = Koha::AuthUtils::generate_password;
149
        my $password = Koha::AuthUtils::generate_password( $category->{categorycode} );
69
        my ( $is_valid, undef ) = Koha::AuthUtils::is_password_valid( $password );
150
        my ( $is_valid, undef ) = Koha::AuthUtils::is_password_valid( $password, $category->{categorycode} );
70
        $all_valid = 0 unless $is_valid;
151
        $all_valid = 0 unless $is_valid;
71
    }
152
    }
72
    is ( $all_valid, 1, 'generate_password should generate valid passwords' );
153
    is ( $all_valid, 1, 'generate_password should generate valid passwords' );
154
155
    subtest 'generate_password with password policies' => sub {
156
157
        t::lib::Mocks::mock_preference('RequireStrongPassword', 0);
158
        t::lib::Mocks::mock_preference('minPasswordLength', 4);
159
160
        #simplenumeric
161
        for ( 1 .. 10 ) {
162
            my $password = Koha::AuthUtils::generate_password( $category_simple->{categorycode} );;
163
            my ( $is_valid, undef ) = Koha::AuthUtils::is_password_valid( $password, $category_simple->{categorycode} );
164
            $all_valid = 0 unless $is_valid;
165
        }
166
        is ( $all_valid, 1, 'generate_password should generate valid passwords with simplenumeric policy' );
167
168
        #alphanumeric
169
        for ( 1 .. 10 ) {
170
            my $password = Koha::AuthUtils::generate_password( $category_alpha->{categorycode} );
171
            my ( $is_valid, undef ) = Koha::AuthUtils::is_password_valid( $password, $category_alpha->{categorycode} );
172
            $all_valid = 0 unless $is_valid;
173
        }
174
        is ( $all_valid, 1, 'generate_password should generate valid passwords with alphanumeric policy' );
175
176
        #complex
177
        for ( 1 .. 10 ) {
178
            my $password = Koha::AuthUtils::generate_password( $category_complex->{categorycode} );
179
            my ( $is_valid, undef ) = Koha::AuthUtils::is_password_valid( $password, $category_complex->{categorycode} );
180
            $all_valid = 0 unless $is_valid;
181
        }
182
        is ( $all_valid, 1, 'generate_password should generate valid passwords with complex policy' );
183
    }
184
73
};
185
};
186
187
$schema->storage->txn_rollback;
(-)a/t/db_dependent/api/v1/patrons_password.t (-2 / +82 lines)
Lines 39-49 my $t = Test::Mojo->new('Koha::REST::V1'); Link Here
39
39
40
subtest 'set() (authorized user tests)' => sub {
40
subtest 'set() (authorized user tests)' => sub {
41
41
42
    plan tests => 21;
42
    plan tests => 22;
43
43
44
    $schema->storage->txn_begin;
44
    $schema->storage->txn_begin;
45
45
46
    my ( $patron, $session ) = create_user_and_session({ authorized => 1 });
46
    my ( $patron, $session ) = create_user_and_session({ authorized => 1 });
47
    $patron->category->update({ passwordpolicy => ''});
47
48
48
    t::lib::Mocks::mock_preference( 'minPasswordLength',     3 );
49
    t::lib::Mocks::mock_preference( 'minPasswordLength',     3 );
49
    t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
50
    t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0 );
Lines 118-123 subtest 'set() (authorized user tests)' => sub { Link Here
118
    $tx->req->env( { REMOTE_ADDR => '127.0.0.1' } );
119
    $tx->req->env( { REMOTE_ADDR => '127.0.0.1' } );
119
    $t->request_ok($tx)->status_is(200)->json_is('');
120
    $t->request_ok($tx)->status_is(200)->json_is('');
120
121
122
    subtest 'password policies' => sub {
123
124
      plan tests => 18;
125
126
      t::lib::Mocks::mock_preference( 'RequireStrongPassword', 0);
127
      t::lib::Mocks::mock_preference( 'minPasswordLength', 4 );
128
      t::lib::Mocks::mock_preference( 'minAlnumPasswordLength', 5 );
129
      t::lib::Mocks::mock_preference( 'minComplexPasswordLength', 6 );
130
131
      # simple policy
132
      $patron->category->update({ passwordpolicy => 'simple'});
133
      $new_password = '1234';
134
      $tx
135
            = $t->ua->build_tx( POST => "/api/v1/patrons/"
136
              . $patron->id
137
              . "/password" => json => { password => $new_password, password_2 => $new_password } );
138
139
      $tx->req->cookies( { name => 'CGISESSID', value => $session->id } );
140
      $tx->req->env( { REMOTE_ADDR => '127.0.0.1' } );
141
      $t->request_ok($tx)->status_is(200)->json_is('');
142
143
      $new_password = '123A';
144
      $tx
145
            = $t->ua->build_tx( POST => "/api/v1/patrons/"
146
              . $patron->id
147
              . "/password" => json => { password => $new_password, password_2 => $new_password } );
148
149
      $tx->req->cookies( { name => 'CGISESSID', value => $session->id } );
150
      $tx->req->env( { REMOTE_ADDR => '127.0.0.1' } );
151
      $t->request_ok($tx)->status_is(400)->json_is({ error => '[Password does not match simplenumeric passwordpolicy]' });
152
153
      # alphanumeric policy
154
      $patron->category->update({ passwordpolicy => 'alphanumeric'});
155
      $new_password = '123A5';
156
      $tx
157
            = $t->ua->build_tx( POST => "/api/v1/patrons/"
158
              . $patron->id
159
              . "/password" => json => { password => $new_password, password_2 => $new_password } );
160
161
      $tx->req->cookies( { name => 'CGISESSID', value => $session->id } );
162
      $tx->req->env( { REMOTE_ADDR => '127.0.0.1' } );
163
      $t->request_ok($tx)->status_is(200)->json_is('');
164
165
      $new_password = '12345';
166
      $tx
167
            = $t->ua->build_tx( POST => "/api/v1/patrons/"
168
              . $patron->id
169
              . "/password" => json => { password => $new_password, password_2 => $new_password } );
170
171
      $tx->req->cookies( { name => 'CGISESSID', value => $session->id } );
172
      $tx->req->env( { REMOTE_ADDR => '127.0.0.1' } );
173
      $t->request_ok($tx)->status_is(400)->json_is({ error => '[Password does not match alphanumeric passwordpolicy]' });
174
175
      # complex policy
176
      $patron->category->update({ passwordpolicy => 'complex'});
177
      $new_password = 'As!123';
178
      $tx
179
            = $t->ua->build_tx( POST => "/api/v1/patrons/"
180
              . $patron->id
181
              . "/password" => json => { password => $new_password, password_2 => $new_password } );
182
183
      $tx->req->cookies( { name => 'CGISESSID', value => $session->id } );
184
      $tx->req->env( { REMOTE_ADDR => '127.0.0.1' } );
185
      $t->request_ok($tx)->status_is(200)->json_is('');
186
187
      $new_password = '123A5';
188
      $tx
189
            = $t->ua->build_tx( POST => "/api/v1/patrons/"
190
              . $patron->id
191
              . "/password" => json => { password => $new_password, password_2 => $new_password } );
192
193
      $tx->req->cookies( { name => 'CGISESSID', value => $session->id } );
194
      $tx->req->env( { REMOTE_ADDR => '127.0.0.1' } );
195
      $t->request_ok($tx)->status_is(400)->json_is({ error => '[Password does not match complex passwordpolicy]' });
196
197
198
    };
199
121
    $schema->storage->txn_rollback;
200
    $schema->storage->txn_rollback;
122
};
201
};
123
202
Lines 128-133 subtest 'set_public() (unprivileged user tests)' => sub { Link Here
128
    $schema->storage->txn_begin;
207
    $schema->storage->txn_begin;
129
208
130
    my ( $patron, $session ) = create_user_and_session({ authorized => 0 });
209
    my ( $patron, $session ) = create_user_and_session({ authorized => 0 });
210
    $patron->category->update({ passwordpolicy => ''});
211
131
    my $other_patron = $builder->build_object({ class => 'Koha::Patrons' });
212
    my $other_patron = $builder->build_object({ class => 'Koha::Patrons' });
132
213
133
    # Enable the public API
214
    # Enable the public API
134
- 

Return to bug 12617