password reset age 3.9
authorLlewellyn Marshall <llewellyn.marshall@ncdcr.gov>
Thu, 9 Dec 2021 22:00:44 +0000 (17:00 -0500)
committerLlewellyn Marshall <llewellyn.marshall@ncdcr.gov>
Wed, 27 Jul 2022 20:43:51 +0000 (16:43 -0400)
17 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/eg2/src/app/staff/splash.component.html
Open-ILS/src/eg2/src/app/staff/splash.component.ts
Open-ILS/src/perlmods/lib/OpenILS/Application/Actor.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Account.pm
Open-ILS/src/perlmods/live_t/33-password-age.t [new file with mode: 0644]
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/xxxx.data.password_age_reset.sql [new file with mode: 0644]
Open-ILS/src/templates-bootstrap/opac/myopac/main.tt2
Open-ILS/src/templates-bootstrap/opac/myopac/update_password.tt2
Open-ILS/src/templates-bootstrap/opac/parts/config.tt2
Open-ILS/src/templates/opac/myopac/update_password.tt2
Open-ILS/src/templates/opac/parts/config.tt2
Open-ILS/src/templates/staff/circ/patron/t_edit.tt2
Open-ILS/src/templates/staff/t_splash.tt2
Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js

index 92abe15..bf8a0f9 100644 (file)
@@ -2599,6 +2599,24 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
                        </actions>
                </permacrud>
        </class>
+       <class id="aupsd" controller="open-ils.cstore" oils_obj:fieldmapper="actor::usr_password_set_date" reporter:label="User Password Set Date" oils_persist:readonly="true">
+               <oils_persist:source_definition><![CDATA[
+                       SELECT ac.id, ac.usr as usr, au.home_ou as home_ou, ac.create_date as create_date, ac.edit_date as edit_date 
+            FROM actor.passwd ac 
+            JOIN actor.usr au on au.id = ac.usr 
+               ]]></oils_persist:source_definition>
+               <fields oils_persist:primary="id">
+            <field reporter:label="Password ID" name="id" reporter:datatype="id" />
+            <field reporter:label="User ID" name="usr" reporter:datatype="id" />
+            <field reporter:label="User Home OU" name="home_ou" reporter:datatype="id" />
+                       <field reporter:label="Create Date" name="create_date" reporter:datatype="timestamp"/>
+                       <field reporter:label="Last Edit Date" name="edit_date" reporter:datatype="timestamp"/>
+               </fields>
+               <links>
+                       <link field="usr" reltype="has_a" key="id" map="" class="au"/>
+                       <link field="home_ou" reltype="has_a" key="id" map="" class="aou"/>
+               </links>
+       </class>
        <class id="aupr" controller="open-ils.cstore" oils_obj:fieldmapper="actor::usr_password_reset" oils_persist:tablename="actor.usr_password_reset" reporter:label="User password reset requests">
                <fields oils_persist:primary="id" oils_persist:sequence="actor.usr_password_reset_id_seq">
                        <field reporter:label="Request ID" name="id" reporter:datatype="id"/>
index d9e6b76..9f13368 100644 (file)
     </div>
   </div>
 
+  <div *ngIf="passwordExpireAge && passwordAge >= (passwordExpireAge - 7)" class="alert alert-danger row">
+         <p i18n>Your password is <b>{{passwordAge}} days</b> old. It is recommended that passwords be updated every <b>{{passwordExpireAge}} days</b>.</p> 
+      <p *ngIf="!canSelfUpdate" i18n>
+        Please contact an administrator to have your password changed or change your password through the OPAC.
+      </p>
+      <div *ngIf="canSelfUpdate">
+      <br>
+        <a class="btn btn-warning btn-block" target="_top" href="{{selfUpdateLink}}" i18n> 
+        Update Password
+        </a>
+      </div>
+  </div>
+  
   <div class="row" id="splash-nav">
     <div class="col-lg-4" *ngFor="let header of portalHeaders; index as i">
       <div class="card">
index b310cd3..69626d7 100644 (file)
@@ -3,6 +3,8 @@ import {OrgService} from '@eg/core/org.service';
 import {AuthService} from '@eg/core/auth.service';
 import {PcrudService} from '@eg/core/pcrud.service';
 import {ToastService} from '@eg/share/toast/toast.service';
+import {PermService} from '@eg/core/perm.service';
+import {NetService} from '@eg/core/net.service';
 import {StringComponent} from '@eg/share/string/string.component';
 import {Router} from '@angular/router';
 
@@ -14,6 +16,10 @@ export class StaffSplashComponent implements OnInit {
 
     @ViewChild('noPermissionString', { static: true }) noPermissionString: StringComponent;
     catSearchQuery: string;
+    passwordExpireAge: number;
+    passwordAge: number;
+       canSelfUpdate: boolean;
+       selfUpdateLink: string;
     portalEntries: any[][] = [];
     portalHeaders: any[] = [];
 
@@ -23,7 +29,9 @@ export class StaffSplashComponent implements OnInit {
         private auth: AuthService,
         private org: OrgService,
         private router: Router,
-        private toast: ToastService
+        private toast: ToastService,
+               private net: NetService,
+               private perm: PermService
     ) {}
 
     ngOnInit() {
@@ -92,6 +100,36 @@ export class StaffSplashComponent implements OnInit {
             }
         );
 
+               //get the maximum password age
+               this.org.settings(['auth.password_expire_age'])
+               .then(settings => {
+                       this.passwordExpireAge = Number(settings['auth.password_expire_age']);
+                       console.log('password expire age: ', settings);
+               });
+               //get the age of the current user's password            
+        this.net.request(
+            'open-ils.actor',
+            'open-ils.actor.get_password_age',
+            this.auth.token(),
+            this.auth.user().id()).subscribe(
+            (res) => {
+                console.log('password age: ', res);
+                this.passwordAge = Number(res);
+            },
+            (err) => {
+                console.error('splash', err);
+            }
+        );
+               //check if user can change their own password
+               this.perm.hasWorkPermAt(
+                       ['EDIT_SELF_IN_CLIENT'], 
+                       true).then(perms => {
+                               if(perms['EDIT_SELF_IN_CLIENT']){
+                                       this.canSelfUpdate = true;
+                                       //create a link to the user's profile
+                                       this.selfUpdateLink = '/eg/staff/circ/patron/' + this.auth.user().id() + '/edit';
+                               }
+                       });
         if (this.router.url === '/staff/no_permission') {
             this.noPermissionString.current()
                 .then(str => {
index c0d9851..d9ee1a1 100644 (file)
@@ -5014,6 +5014,57 @@ sub get_barcodes {
         return $db_result;
     }
 }
+
+__PACKAGE__->register_method(
+    method   => "get_password_last_edit_age",
+    api_name => "open-ils.actor.get_password_age",
+    signature => {
+        desc => "Finds the number of days since a user's password was last updated.",
+        params => [
+            {desc => 'Authentication token',  type => 'string'},
+            {desc => 'Patron ID',             type => 'number'},
+            {desc => 'Reference Time',        type => 'string'},
+        ],
+        return => {desc => 'Number of days since password update'}
+    }
+);
+
+sub get_password_last_edit_age {
+    my( $self, $client, $auth, $patron_id, $ref_time ) = @_;
+    my $e = new_editor(authtoken => $auth);
+    return $e->event unless $e->checkauth;
+    my $patron = $e->retrieve_actor_user($patron_id);
+    
+    #return unless the requestor is either the patron in question, or can view users at that patron's home ou
+    unless($patron && ($patron_id == $e->requestor->id || $e->allowed('VIEW_USER', $patron->home_ou))) {
+         return $e->event;
+    }
+    
+    #get the password dates from the virtual table
+    my $aupsds = $e->json_query({
+        select => {aupsd => ['create_date','edit_date']},
+        from => 'aupsd',
+        where => {
+            usr => $patron_id
+        }
+    });
+
+    if(defined $aupsds){
+        my $pwd = $aupsds->[0];        
+        if($pwd){
+            #convert the dates with the DateTime module
+            my $edit_datetime = DateTime::Format::ISO8601->parse_datetime(clean_ISO8601($pwd->{'edit_date'}));
+            #get the time we are subtracting from, use ref_time if it's defined or the current datetime otherwise
+            my $now = defined($ref_time) ?  DateTime::Format::ISO8601->parse_datetime(clean_ISO8601($ref_time)) : DateTime->now();
+            
+            my $duration = $now->subtract_datetime_absolute($edit_datetime)->delta_seconds / (24*60*60);
+            return int($duration);            
+        }
+    }
+    #no password entry, return -1 days
+    return -1;    
+}
+
 __PACKAGE__->register_method(
     method   => 'address_alert_test',
     api_name => 'open-ils.actor.address_alert.test',
index 0f8557b..811829e 100644 (file)
@@ -383,6 +383,9 @@ sub load_common {
             $ctx->{authtoken} = $e->authtoken;
             $ctx->{authtime} = $e->authtime;
             $ctx->{user} = $e->requestor;
+            $ctx->{password_age} = int($U->simplereq(
+                'open-ils.actor',
+                'open-ils.actor.get_password_age', $e->authtoken, $ctx->{user}->id));
             my $card = $self->editor->retrieve_actor_card($ctx->{user}->card);
             $ctx->{active_card} = (ref $card) ? $card->barcode : undef;
             $ctx->{place_unfillable} = 1 if $e->requestor->wsid && $e->allowed('PLACE_UNFILLABLE_HOLD', $e->requestor->ws_ou);
index dcc2f14..3bc5b42 100644 (file)
@@ -2823,6 +2823,11 @@ sub load_myopac_update_password {
         return Apache2::Const::OK;
     }
 
+       if($current_pw eq $new_pw) {
+        $ctx->{password_duplicate} = 1;
+        return Apache2::Const::OK;             
+       }
+
     my $pw_regex = $ctx->{get_org_setting}->($e->requestor->home_ou, 'global.password_regex');
 
     if(!$pw_regex) {
diff --git a/Open-ILS/src/perlmods/live_t/33-password-age.t b/Open-ILS/src/perlmods/live_t/33-password-age.t
new file mode 100644 (file)
index 0000000..f2d2007
--- /dev/null
@@ -0,0 +1,87 @@
+#!perl
+use constant FUTURE_DAYS => 150;
+use strict; use warnings;
+use Test::More tests => 4;
+use OpenILS::Utils::TestUtils;
+use OpenILS::Const qw(:const);
+use OpenILS::Utils::CStoreEditor qw/:funcs/;
+use OpenILS::Utils::Fieldmapper;
+use DateTime;
+use DateTime::Format::ISO8601;
+
+diag("test password age");
+
+my $U = 'OpenILS::Application::AppUtils';
+
+my $script = OpenILS::Utils::TestUtils->new();
+$script->bootstrap;
+
+$script->authenticate({
+    username => 'admin',
+    password => 'demo123',
+    type => 'staff'
+});
+
+my $authtoken = $script->authtoken;
+ok($authtoken, 'was able to authenticate');
+
+my $new_user = Fieldmapper::actor::user->new();
+my $new_card = Fieldmapper::actor::card->new();
+
+$new_card->barcode("lew_$$");
+$new_card->id(-1); # virtual ID
+$new_card->usr(undef);
+$new_card->isnew(1);
+
+$new_user->cards([ $new_card ]);
+$new_user->card($new_card);
+$new_user->usrname("lew_$$");
+$new_user->passwd('lew_$$');
+$new_user->family_name('Marshall');
+$new_user->first_given_name('Llewellyn');
+$new_user->profile(2);
+$new_user->home_ou(4);
+$new_user->ident_type(1);
+$new_user->isnew(1);
+
+my $resp = $U->simplereq(
+    'open-ils.actor',
+    'open-ils.actor.patron.update',
+    $authtoken,
+    $new_user
+);
+
+isa_ok($resp, 'Fieldmapper::actor::user', 'new patron');
+
+my $new_id = $resp->id();
+
+$resp = $U->simplereq(
+    'open-ils.actor',
+    'open-ils.actor.get_password_age',
+    $authtoken,
+    $new_id
+);
+
+cmp_ok($resp, '==', 0, 'Password age on new user is 0 days');
+
+my $dt = DateTime->now();
+$dt->add( days => FUTURE_DAYS );
+
+$resp = $U->simplereq(
+    'open-ils.actor',
+    'open-ils.actor.get_password_age',
+    $authtoken,
+    $new_id,
+    $dt->iso8601()
+);
+
+cmp_ok($resp, '==', FUTURE_DAYS, FUTURE_DAYS." days from now, Password age on new user is ".FUTURE_DAYS." days");
+
+# clean up
+$U->simplereq(
+    'open-ils.actor',
+    'open-ils.actor.user.delete',
+    $authtoken,
+    $new_id
+);
\ No newline at end of file
index a0f937c..f4571ed 100644 (file)
@@ -21427,6 +21427,67 @@ VALUES (
     'integer'
 );
 
+-- Password age reset
+
+INSERT INTO config.org_unit_setting_type
+    (name, grp, label, description, datatype)
+    VALUES (
+        'auth.password_expire_age',
+        'sec',
+        oils_i18n_gettext(
+            'auth.password_expire_age',
+            'Password Reset Age',
+            'coust',
+            'label'
+        ),
+        oils_i18n_gettext(
+            'auth.password_expire_age',
+            'The number of days after a password has been changed before ' || 
+                       'users will be alerted that they should update it.',
+            'coust',
+            'description'
+        ),
+        'integer'
+    );
+
+INSERT INTO action_trigger.hook (key,core_type,description,passive) VALUES (
+    'aupsd.passwd_changed',
+    'aupsd',
+    oils_i18n_gettext(
+        'au.passwd_changed',
+        'An account password was updated',
+        'ath',
+        'description'
+    ),
+       true
+);
+
+-- Sample Password Update Notice --
+
+INSERT INTO action_trigger.event_definition (active, owner, name, delay_field, delay, max_delay, repeat_delay, hook, validator, reactor,  template) 
+    VALUES ('f', 1, 'Password Update Notice', 'edit_date','90 days', '91 days','90 days' 'aupsd.passwd_changed', 'NOOP_True', 'SendEmail',
+$$
+[%- USE date -%]
+[%- user = target.usr -%]
+To: [%- params.recipient_email || user.email %]
+From: [%- params.sender_email || default_sender || helpers.get_org_setting(user.home_ou, 'org.bounced_emails') %]
+Date: [%- date.format(date.now, '%a, %d %b %Y %T -0000', gmt => 1) %]
+Subject: Password Update Required
+Auto-Submitted: auto-generated
+
+Dear [% user.family_name %], [% user.first_given_name %]
+Regularly updating your password is an essential part of maintaining the security of your account. At the time of writing, your password is 90 days old. Please log in to the system or contact a system administrator to update your password. 
+
+$$);
+
+INSERT INTO action_trigger.environment (
+    event_def,
+    path
+) VALUES (
+    currval('action_trigger.event_definition_id_seq'),
+    'usr'
+);
+
 ------------------- Disabled example A/T defintions ------------------------------
 
 -- Create a "dummy" slot when applicable, and trigger the "offer curbside" events
diff --git a/Open-ILS/src/sql/Pg/upgrade/xxxx.data.password_age_reset.sql b/Open-ILS/src/sql/Pg/upgrade/xxxx.data.password_age_reset.sql
new file mode 100644 (file)
index 0000000..036ad0a
--- /dev/null
@@ -0,0 +1,66 @@
+BEGIN;
+
+--SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+-- password age display setting
+
+INSERT INTO config.org_unit_setting_type
+    (name, grp, label, description, datatype)
+    VALUES (
+        'auth.password_expire_age',
+        'sec',
+        oils_i18n_gettext(
+            'auth.password_expire_age',
+            'Password Reset Age',
+            'coust',
+            'label'
+        ),
+        oils_i18n_gettext(
+            'auth.password_expire_age',
+            'The number of days after a password has been changed before ' || 
+                       'users will be alerted that they should update it.',
+            'coust',
+            'description'
+        ),
+        'integer'
+    );
+
+INSERT INTO action_trigger.hook (key,core_type,description,passive) VALUES (
+    'aupsd.passwd_changed',
+    'aupsd',
+    oils_i18n_gettext(
+        'au.passwd_changed',
+        'An account password was updated',
+        'ath',
+        'description'
+    ),
+       true
+);
+
+-- Sample Password Update Notice --
+
+INSERT INTO action_trigger.event_definition (active, owner, name, delay_field, delay, max_delay, repeat_delay, hook, validator, reactor,  template) 
+    VALUES ('f', 1, 'Password Update Notice', 'edit_date','90 days', '91 days','90 days' 'aupsd.passwd_changed', 'NOOP_True', 'SendEmail',
+$$
+[%- USE date -%]
+[%- user = target.usr -%]
+To: [%- params.recipient_email || user.email %]
+From: [%- params.sender_email || default_sender || helpers.get_org_setting(user.home_ou, 'org.bounced_emails') %]
+Date: [%- date.format(date.now, '%a, %d %b %Y %T -0000', gmt => 1) %]
+Subject: Password Update Required
+Auto-Submitted: auto-generated
+
+Dear [% user.family_name %], [% user.first_given_name %]
+Regularly updating your password is an essential part of maintaining the security of your account. At the time of writing, your password is 90 days old. Please log in to the system or contact a system administrator to update your password. 
+
+$$);
+
+INSERT INTO action_trigger.environment (
+    event_def,
+    path
+) VALUES (
+    currval('action_trigger.event_definition_id_seq'),
+    'usr'
+);
+--ROLLBACK;
+COMMIT;
\ No newline at end of file
index 29b8198..2a11559 100755 (executable)
         <div id="acct_sum_block" class="container">
             <h3>[% l('My Account Summary') %]</h3>
                 <div class="row">
+                    <br>
+                    [% IF ctx.password_age && ctx.password_age_reminder && ctx.disable_password_change != 'true' %]
+                    [% need_password_change = ctx.password_age == -1 || ctx.password_age >= ctx.password_age_reminder %]
+                    <div class="col-12">
+                            [% IF ctx.password_age == -1 %]
+                            [% l('You have never changed your password. Please consider updating your password.') %]
+                            [% ELSIF ctx.password_age >= (ctx.password_age_reminder - 7) %]
+                            [% l('Your password is <b>[_1]</b> days old.',ctx.password_age) %][%- IF !need_password_change %] [% l('You will be asked to change your password soon.') %][%- ELSE %] [% l('It is recommended to update your password every <b>[_1]</b> days. Please consider updating your password.',ctx.password_age_reminder) %][% END %]
+                            [% END %]
+                            [% IF need_password_change %]
+                            <br>
+                            <a class="btn btn-sm btn-action" href='update_password'
+                            title="[% l('Change Password') %]"><i class="fas fa-user-cog"></i> [% l("Change Password") %]</a>
+                            <br>
+                            [% END %]
+                    </div>
+                    <br>
+                    [% END %]  
                     <div class="col-12">
                      <span [% IF ctx.expired_card %]class="danger"[% END %]>
                         [% l("Account Expiration Date - ") %]
@@ -29,7 +47,7 @@
                         </span>
                         [% END %]
                     </div>
-                    <br>
+                    <br>               
                     <div class="col-12">
                      <a href="[% mkurl(ctx.opac_root _ '/myopac/circs') %]"
                             title="[% l('View My Checked Out Items') %]">
index dcd8f4c..d4104d3 100755 (executable)
         [% |l %]Passwords do not match.[% END %]
     </div>
 
+[% ELSIF ctx.password_duplicate %]
+    <div id='account-update-email-error'>
+        [% |l %]New password can not be the same as current password.[% END %]
+    </div>
+
 [% ELSIF ctx.password_incorrect %]
     <div id='account-update-email-error'>
         [% |l %]Your current password was not correct.[% END %]
index 7409a4b..a93b76b 100755 (executable)
@@ -280,4 +280,11 @@ ctx.max_cart_size = 500;
 ##############################################################################
 ctx.show_reservations_tab = 'false';
 
+##############################################################################
+# Password Reminder Settings
+##############################################################################
+
+# days since last password change to start reminding a patron to change their password
+# commenting out this line will disable the reminder
+ctx.password_age_reminder = ctx.get_org_setting(ctx.physical_loc || 1, 'auth.password_expire_age');
 %]
index d86d8e1..93aed23 100644 (file)
         [% |l %]Passwords do not match.[% END %]
     </div>
 
+[% ELSIF ctx.password_duplicate %]
+    <div id='account-update-email-error'>
+        [% |l %]New password can not be the same as current password.[% END %]
+    </div>
+
 [% ELSIF ctx.password_incorrect %]
     <div id='account-update-email-error'>
         [% |l %]Your current password was not correct.[% END %]
index eda51ad..da7c15d 100644 (file)
@@ -285,4 +285,12 @@ contents_truncate_length = 50;
 # Edit parts/record/contents.tt2 to designate character length on a field-by-
 # field basis for notes.
 
+
+##############################################################################
+# Password Reminder Settings
+##############################################################################
+
+# days since last password change to start reminding a patron to change their password
+# commenting out this line will disable the reminder
+ctx.password_age_reminder = ctx.get_org_setting(ctx.physical_loc || 1, 'auth.password_expire_age');
 %]
index ebafd08..cf30d2d 100644 (file)
@@ -225,7 +225,14 @@ within the "form" by name for validation.
       [% l('Send Password Reset Link') %]</button>
   </div>
 </div>
-
+<div class="row reg-field-row" ng-show="!patron.isnew">
+    <div class="col-md-3">
+    </div>
+    <div class="col-md-9">
+        <div ng-show="password_age !== undefined && password_age !== '' && password_age !== '-1'"><i>[% l('Password last changed <b>[_1]</b> day(s) ago', '{{password_age}}') %]</i></div>
+        <div ng-show="password_age === '-1'"><i>[% l('User has never changed their password') %]</i></div>
+    </div>
+</div>
 <div class="row reg-field-row">
   <div class="col-md-6">
     <ul class="nav nav-pills nav-pills-like-tabs">
index 598f9ff..e25febd 100644 (file)
@@ -5,6 +5,17 @@
       <h1 class="sr-only" i18n>Evergreen Staff Client Home Page</h1>
     </div>
   </div>
+  <div ng-if="password_reset_age && password_age >= (password_reset_age - 7)" class="alert alert-danger row">[% l('Your password is <b>[_1] days</b> old. It is recommended that passwords be updated every <b>[_2] days</b>.','{{password_age}}','{{password_reset_age}}') %] 
+      <p ng-if="!can_self_update">
+        [% l('Please contact an administrator to have your password changed.') %]
+      </p>
+      <div ng-if="can_self_update">
+      <br>
+        <a class="btn btn-warning btn-block" target="_top" href="{{self_update_link}}">
+        [% l('Update Password') %]
+        </a>
+      </div>
+  </div>
   <br/>
   <div class="row" id="splash-nav">
 
index 31bc4fe..3da947c 100644 (file)
@@ -31,6 +31,7 @@ angular.module('egCoreMod')
         // These are fetched with every instance of the page.
         var page_data = [
             service.get_user_settings(),
+            service.get_user_password_age(),
             service.get_clone_user(),
             service.get_stage_user()
         ];
@@ -662,6 +663,18 @@ angular.module('egCoreMod')
         });
     }
 
+    service.get_user_password_age = function() {
+        return egCore.net.request(
+            'open-ils.actor', 
+            'open-ils.actor.get_password_age',
+            egCore.auth.token(),
+            service.patron_id
+        ).then(function(age) {
+            console.log("retrieved from AUSPD: "+age)
+            service.password_age = age;
+        });  
+    }
+
     service.invalidate_field = function(patron, field) {
         console.log('Invalidating patron field ' + field);
 
@@ -1423,6 +1436,7 @@ function($scope , $routeParams , $q , $uibModal , $window , egCore ,
         $scope.stat_cat_entry_maps = prs.stat_cat_entry_maps;
         $scope.stage_user = prs.stage_user;
         $scope.stage_user_requestor = prs.stage_user_requestor;
+        $scope.password_age = prs.password_age;
 
         $scope.user_settings = prs.user_settings;
         prs.user_settings = {};