API call to get the age of a patron's password, this is displayed in the user/lew/lp-1979570-detect-password-age
authorLlewellyn Marshall <llewellyn.marshall@ncdcr.gov>
Thu, 9 Dec 2021 22:00:44 +0000 (17:00 -0500)
committerLlewellyn Marshall <llewellyn.marshall@ncdcr.gov>
Thu, 7 Jul 2022 21:30:13 +0000 (17:30 -0400)
user editor and myopac home page. Added a configurable variable in
config to set when the password reminder should show up. Patrons can not enter a duplicate password to bypass message. Passive Hook for
password update based on edit date. Example event
definition for password update notice.

16 files changed:
Open-ILS/examples/fm_IDL.xml
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/app.js
Open-ILS/web/js/ui/default/staff/circ/patron/regctl.js

index 674ace4..85cec9a 100644 (file)
@@ -2409,6 +2409,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 4298f28..03cac4a 100644 (file)
@@ -4987,6 +4987,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 5763cb8..be29646 100644 (file)
@@ -381,6 +381,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 f77c1e0..d26383f 100644 (file)
@@ -2760,6 +2760,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 ad3c3e5..c70474a 100644 (file)
@@ -21305,6 +21305,67 @@ INSERT INTO permission.grp_perm_map (grp, perm, depth, grantable)
         aout.name = 'Consortium' AND
         (perm.code = 'ADMIN_GEOLOCATION_SERVICES' OR perm.code = 'VIEW_GEOLOCATION_SERVICES');
 
+-- 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 820e346..25de4ef 100644 (file)
@@ -222,7 +222,14 @@ within the "form" by name for validation.
       [% l('Generate Password') %]</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 58afd06..8dd0c10 100644 (file)
@@ -157,10 +157,26 @@ function($routeProvider , $locationProvider) {
     function($scope, $window,egCore) {
                
     $scope.focus_search = true;
-       
+       $scope.can_self_update = false;
+    
        egCore.strings.setPageTitle(
         egCore.strings['PAGE_TITLE_SPLASH']);
-
+    egCore.org.settingsFromServer(['auth.password_expire_age','global.password_regex']).then(function(settings) {
+            $scope.password_reset_age = parseInt(settings['auth.password_expire_age']);
+        });
+    egCore.net.request(
+                'open-ils.actor', 
+                'open-ils.actor.get_password_age',
+                egCore.auth.token(),
+                egCore.auth.user().id()
+            ).then(function(age) {
+                $scope.password_age = parseInt(age);
+            });
+    egCore.perm.hasPermHere('EDIT_SELF_IN_CLIENT')
+    .then(function(bool){
+        $scope.can_self_update = bool;
+        $scope.self_update_link = './circ/patron/' + egCore.auth.user().id() + '/edit'
+        });
     $scope.catalog_search = function($event) {
         $scope.focus_search = true;
         if (!$scope.cat_query) return;
index ac1894c..de023c3 100644 (file)
@@ -30,6 +30,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()
         ];
@@ -638,6 +639,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);
 
@@ -1394,6 +1407,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 = {};