LP#626157 sessionPoll; cleanup
authorBill Erickson <berickxx@gmail.com>
Tue, 26 Dec 2017 20:39:35 +0000 (15:39 -0500)
committerBill Erickson <berickxx@gmail.com>
Tue, 26 Dec 2017 20:39:35 +0000 (15:39 -0500)
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Open-ILS/eg2-src/src/app/core/auth.ts
Open-ILS/eg2-src/src/app/core/event.ts
Open-ILS/eg2-src/src/app/core/net.ts
Open-ILS/eg2-src/src/app/core/pcrud.ts
Open-ILS/eg2-src/src/app/staff/app.component.ts
Open-ILS/eg2-src/src/app/staff/app.module.ts
Open-ILS/eg2-src/src/app/staff/login.component.ts
Open-ILS/eg2-src/src/app/staff/nav.component.html
Open-ILS/eg2-src/src/app/staff/nav.component.ts

index aba9dea..ac21206 100644 (file)
@@ -1,13 +1,15 @@
 import {Injectable, EventEmitter} from '@angular/core';
-import {Observable} from 'rxjs/Rx';
 import {EgNetService} from './net';
 import {EgEventService, EgEvent} from './event';
 import {EgIdlService, EgIdlObject} from './idl';
 import {EgStoreService} from './store';
 
+// Not universally available.
+declare var BroadcastChannel;
+
 // Models a login instance.
 class EgAuthUser {
-    user:        EgIdlObject;
+    user:        EgIdlObject; // actor.usr (au) object
     workstation: string; // workstation name
     token:       string;
     authtime:    number;
@@ -38,24 +40,36 @@ export enum EgAuthWsState {
 @Injectable()
 export class EgAuthService {
 
+    private authChannel: any;
+
     private activeUser: EgAuthUser;
 
     // opChangeUser refers to the user that has been superseded during
-    // an op-change event.  This use will become the activeUser once
-    // again, when the op-change cycle has completed.
+    // an op-change event.  opChangeUser resumes its status as the 
+    // activeUser once the op-change cycle has completed.
     private opChangeUser: EgAuthUser;
 
     workstationState: EgAuthWsState = EgAuthWsState.PENDING;
 
+    // Used by auth-checking resolvers
     redirectUrl: string;
 
+    // reference to active auth validity setTimeout handler.
+    pollTimeout: any;
+
     constructor(
         private egEvt: EgEventService,
         private net: EgNetService,
         private store: EgStoreService
-    ) {}
+    ) {
+
+        // BroadcastChannel is not yet defined in PhantomJS
+        this.authChannel = BroadcastChannel ? 
+            new BroadcastChannel('eg.auth') : {};
+    }
+
 
-    // - Accessor functions alway refer to the active user.
+    // - Accessor functions always refer to the active user.
 
     user(): EgIdlObject { 
         return this.activeUser ? this.activeUser.user : null;
@@ -63,15 +77,15 @@ export class EgAuthService {
 
     // Workstation name.
     workstation(): string { 
-        return this.activeUser.workstation;
+        return this.activeUser ? this.activeUser.workstation : null;
     };
 
     token(): string { 
         return this.activeUser ? this.activeUser.token : null;
     };
 
-    authtime(): Number { 
-        return this.activeUser.authtime 
+    authtime(): number { 
+        return this.activeUser ? this.activeUser.authtime : 0;
     };
 
     // NOTE: EgNetService emits an event if the auth session has expired.
@@ -84,40 +98,24 @@ export class EgAuthService {
 
         if (!this.token()) return Promise.reject('no authtoken');
 
-        return new Promise<any>( (resolve, reject) => {
-            this.net.request(
-                'open-ils.auth',
-                'open-ils.auth.session.retrieve', this.token()
-            ).subscribe(
-                user => {
-                    // EgNetService interceps NO_SESSION events.
-                    // We can only get here if the session is valid.
-                    this.activeUser.user = user;
-                    this.sessionPoll();
-                    resolve();
-                },
-                err => { reject(); }
-            );
+        return this.net.request(
+            'open-ils.auth',
+            'open-ils.auth.session.retrieve', this.token()).toPromise()
+        .then(user => {
+            // EgNetService interceps NO_SESSION events.
+            // We can only get here if the session is valid.
+            this.activeUser.user = user;
+            this.listenForLogout();
+            this.sessionPoll();
         });
     }
 
-    checkWorkstation(): void {
-        // TODO:
-        // Emits event on invalid workstation.
-    }
-
     login(args: EgAuthLoginArgs, isOpChange?: boolean): Promise<void> {
-
-        return new Promise<void>((resolve, reject) => {
-            this.net.request('open-ils.auth', 'open-ils.auth.login', args)
-            .subscribe(res => {
-                this.handleLoginResponse(args, this.egEvt.parse(res), isOpChange)
-                .then(
-                    ok => resolve(ok),
-                    notOk => reject(notOk)
-                );
-            });
-        });
+        return this.net.request('open-ils.auth', 'open-ils.auth.login', args)
+        .toPromise().then(res => {
+            return this.handleLoginResponse(
+                args, this.egEvt.parse(res), isOpChange)
+        })
     }
 
     handleLoginResponse(
@@ -172,42 +170,89 @@ export class EgAuthService {
         return this.testAuthToken();
     }
 
+    /**
+     * Listen for logout events initiated by other browser tabs.
+     */
+    listenForLogout(): void {
+        if (this.authChannel.onmessage) return;
+
+        this.authChannel.onmessage = (e) => {
+            console.debug(
+                `received eg.auth broadcast ${JSON.stringify(e.data)}`);
+
+            if (e.data.action == 'logout') {
+                // Logout will be handled by the originating tab.
+                // We just need to clear tab-local memory.
+                this.cleanup();
+                this.net.authExpired$.emit({viaExternal: true});
+            }
+        }
+    }
+
+    /**
+     * Force-check the validity of the authtoken on occasion. 
+     * This allows us to redirect an idle staff client back to the login
+     * page after the session times out.  Otherwise, the UI would stay
+     * open with potentially sensitive data visible.
+     * TODO: What is the practical difference (for a browser) between 
+     * checking auth validity and the ui.general.idle_timeout setting?
+     * Does that setting serve a purpose in a browser environment?
+     */
     sessionPoll(): void {
-        // TODO
+
+        // add a 5 second delay to give the token plenty of time
+        // to expire on the server.
+        let pollTime = this.authtime() * 1000 + 5000;
+
+        console.debug('EgAuth session poll at ' + pollTime);
+
+        this.pollTimeout = setTimeout(() => {
+            this.net.request(
+                'open-ils.auth',
+                'open-ils.auth.session.retrieve',
+                this.token(),
+                0, // return extra auth details, unneeded here.
+                1  // avoid extending the auth timeout
+
+            // EgNetService intercepts NO_SESSION events.
+            // If the promise resolves, the session is valid.
+            ).toPromise().then(user => this.sessionPoll())
+
+        }, pollTime);
     }
 
+
     // Resolves if login workstation matches a workstation known to this 
     // browser instance.
     verifyWorkstation(): Promise<void> {
-        return new Promise((resolve, reject) => {
 
-            if (!this.user()) {
-                this.workstationState = EgAuthWsState.PENDING;
-                reject();
-                return;
-            }
+        if (!this.user()) {
+            this.workstationState = EgAuthWsState.PENDING;
+            return Promise.reject('Cannot verify workstation without user');
+        }
 
-            if (!this.user().wsid()) {
-                this.workstationState = EgAuthWsState.NOT_USED;
-                reject();
-                return;
-            }
+        if (!this.user().wsid()) {
+            this.workstationState = EgAuthWsState.NOT_USED;
+            return Promise.reject('User has no workstation ID to verify');
+        }
 
+        return new Promise((resolve, reject) => {
             this.store.getItem('eg.workstation.all')
             .then(workstations => {
-                if (!workstations) workstations = [];
-
-                let ws = workstations.filter(
-                    w => {return w.id == this.user().wsid()})[0];
-
-                if (ws) {
-                    this.activeUser.workstation = ws.name;
-                    this.workstationState = EgAuthWsState.VALID;
-                    resolve();
-                } else {
-                    this.workstationState = EgAuthWsState.NOT_FOUND_LOCAL;
-                    reject();
+
+                if (workstations) {
+                    let ws = workstations.filter(
+                        w => {return w.id == this.user().wsid()})[0];
+
+                    if (ws) {
+                        this.activeUser.workstation = ws.name;
+                        this.workstationState = EgAuthWsState.VALID;
+                        return resolve();
+                    }
                 }
+
+                this.workstationState = EgAuthWsState.NOT_FOUND_LOCAL;
+                reject();
             });
         });
     }
@@ -221,17 +266,27 @@ export class EgAuthService {
         }
     }
 
-    logout(broadcast?: boolean) {
-        console.debug('logging out');
+    // Tell all listening browser tabs that it's time to logout.
+    // This should only be invoked by one tab.
+    broadcastLogout(): void {
+        console.debug('Notifying tabs of imminent auth token removal');
+        this.authChannel.postMessage({action : 'logout'});
+    }
 
-        if (broadcast) {
-            // TODO
-            //this.authChannel.postMessage({action : 'logout'});
+    // Remove/reset session data
+    cleanup(): void {
+        this.activeUser = null;
+        this.opChangeUser = null;
+        if (this.pollTimeout) {
+            clearTimeout(this.pollTimeout);
+            this.pollTimeout = null;
         }
+    }
 
+    // Invalidate server auth token and clean up.
+    logout(): void {
         this.deleteSession();
         this.store.clearLoginSessionItems();                                  
-        this.activeUser = null;
-        this.opChangeUser = null;
+        this.cleanup();
     }
 }
index 3f6afc7..33e3f84 100644 (file)
@@ -1,18 +1,18 @@
-import { Injectable } from '@angular/core';
+import {Injectable} from '@angular/core';
 
 export class EgEvent {
-    code        : Number;
-    textcode    : String;
+    code        : number;
+    textcode    : string;
     payload     : any;
-    desc        : String;
-    debug       : String;
-    note        : String;
-    servertime  : String;
-    ilsperm     : String;
-    ilspermloc  : Number;
+    desc        : string;
+    debug       : string;
+    note        : string;
+    servertime  : string;
+    ilsperm     : string;
+    ilspermloc  : number;
     success     : Boolean = false;
 
-    toString(): String {
+    toString(): string {
         let s = `Event: ${this.code}:${this.textcode} -> ${this.desc}`;
         if (this.ilsperm)
             s += `  ${this.ilsperm}@${this.ilspermloc}`;
@@ -39,8 +39,8 @@ export class EgEventService {
                 .forEach(field => { evt[field] = thing[field]; });
 
             evt.debug = thing.stacktrace;
-            evt.code = new Number(thing.code);
-            evt.ilspermloc = new Number(thing.ilspermloc);
+            evt.code = +(thing.ilsevent || -1);
+            evt.ilspermloc = +(thing.ilspermloc || -1);
             evt.success = thing.textcode == 'SUCCESS';
 
             return evt;
index 121601c..502ffbf 100644 (file)
@@ -15,9 +15,9 @@
  * Each response is relayed via Observable onNext().  The interface is 
  * the same for streaming and atomic requests.
  */
-import { Injectable, EventEmitter } from '@angular/core';
-import { Observable, Observer } from 'rxjs/Rx';
-import { EgEventService, EgEvent } from './event';
+import {Injectable, EventEmitter} from '@angular/core';
+import {Observable, Observer} from 'rxjs/Rx';
+import {EgEventService, EgEvent} from './event';
 
 // Global vars from opensrf.js
 // These are availavble at runtime, but are not exported.
@@ -31,6 +31,8 @@ export class EgNetRequest {
     superseded : Boolean = false;
     // If set, this will be used instead of a one-off OpenSRF.ClientSession.
     session?   : any;
+    // True if we're using a single-use local session
+    localSession: boolean = true;
 
     // Last EgEvent encountered by this request.
     // Most callers will not need to import EgEvent since the parsed
@@ -43,17 +45,28 @@ export class EgNetRequest {
         this.params = params;
         if (session) {
             this.session = session;
+            this.localSession = false;
         } else {
             this.session = new OpenSRF.ClientSession(service);
         }
     }
 }
 
+export interface EgAuthExpiredEvent {
+    // request is set when the auth expiration was determined as a 
+    // by-product of making an API call.
+    request?: EgNetRequest;
+
+    // True if this environment (e.g. browser tab) was notified of the
+    // expired auth token from an external source (e.g. another browser tab).
+    viaExternal?: boolean;
+}
+
 @Injectable()
 export class EgNetService {
 
     permFailed$: EventEmitter<EgNetRequest>;
-    authExpired$: EventEmitter<EgNetRequest>;
+    authExpired$: EventEmitter<EgAuthExpiredEvent>;
 
     // If true, permission failures are emitted via permFailed$ 
     // and the active request is marked as superseded.
@@ -63,7 +76,7 @@ export class EgNetService {
         private egEvt: EgEventService
     ) { 
         this.permFailed$ = new EventEmitter<EgNetRequest>();
-        this.authExpired$ = new EventEmitter<EgNetRequest>();
+        this.authExpired$ = new EventEmitter<EgAuthExpiredEvent>();
     }
 
     // Standard request call -- Variadic params version
@@ -97,6 +110,13 @@ export class EgNetService {
             method : request.method,
             params : request.params,
             oncomplete : () => {
+
+                // TODO: teach opensrf.js to call cleanup() inside
+                // disconnect() and teach EgPcrud to call cleanup() 
+                // as needed to avoid long-lived session data bloat.
+                if (request.localSession)
+                    request.session.cleanup();
+
                 // A superseded request will be complete()'ed by the 
                 // superseder at a later time.
                 if (!request.superseded)
@@ -117,7 +137,7 @@ export class EgNetService {
 
                 if (request.service == 'open-ils.pcrud' && statCode == 401) {
                     // 401 is the PCRUD equivalent of a NO_SESSION event
-                    this.authExpired$.emit(request);
+                    this.authExpired$.emit({request: request});
                 }
 
                 request.observer.error(msg);
@@ -126,8 +146,9 @@ export class EgNetService {
         }).send();
     }
 
-    // Relay response object to the caller for typical/successful responses.  
-    // Applies special handling to response events that require global attention.
+    // Relay response object to the caller for typical/successful
+    // responses.  Applies special handling to response events that
+    // require global attention.
     private dispatchResponse(request, response): void {
         request.evt = this.egEvt.parse(response);
 
@@ -137,7 +158,7 @@ export class EgNetService {
                 case 'NO_SESSION':
                     console.debug(`EgNet emitting event: ${request.evt}`);
                     request.observer.error(request.evt.toString());
-                    this.authExpired$.emit(request);
+                    this.authExpired$.emit({request: request});
                     return;
 
                 case 'PERM_FAILURE':
index 0cee7d3..643a515 100644 (file)
@@ -1,6 +1,5 @@
 import {Injectable} from '@angular/core';
 import {Observable, Observer} from 'rxjs/Rx';
-//import {toPromise} from 'rxjs/operators';
 import {EgIdlService, EgIdlObject} from './idl';
 import {EgNetService, EgNetRequest} from './net';
 import {EgAuthService} from './auth';
@@ -158,15 +157,13 @@ export class EgPcrudContext {
     // => xact_close(commit/rollback) 
     // => disconnect
     wrapXact(mainFunc: () => Observable<EgPcrudResponse>): Observable<EgPcrudResponse> {
-        let this_ = this;
-
         return Observable.create(observer => {
 
             // 1. connect
             this.connect()
 
             // 2. start the transaction
-            .then(() => {return this_.xactBegin().toPromise()})
+            .then(() => {return this.xactBegin().toPromise()})
 
             // 3. execute the main body 
             .then(() => {
@@ -175,9 +172,9 @@ export class EgPcrudContext {
                     res => observer.next(res),
                     err => observer.error(err),
                     ()  => {
-                        this_.xactClose().toPromise().then(() => {
+                        this.xactClose().toPromise().then(() => {
                             // 5. disconnect
-                            this_.disconnect();
+                            this.disconnect();
                             // 6. all done
                             observer.complete();
                         });
@@ -209,12 +206,10 @@ export class EgPcrudContext {
 
         if (!Array.isArray(list)) this.cudList = [list];
 
-        let this_ = this;
-
         return this.wrapXact(() => {
             return Observable.create(observer => {
-                this_.cudObserver = observer;
-                this_.nextCudRequest();
+                this.cudObserver = observer;
+                this.nextCudRequest();
             });
         });
     }
@@ -225,8 +220,6 @@ export class EgPcrudContext {
      * all are done, the cudObserver is resolved.
      */
     nextCudRequest(): void {
-        let this_ = this;
-
         if (this.cudIdx >= this.cudList.length) {
             this.cudObserver.complete();
             return;
@@ -250,9 +243,9 @@ export class EgPcrudContext {
             `open-ils.pcrud.${action}.${fmObj.classname}`,
             [this.token(), fmObj]
         ).subscribe(
-            res => this_.cudObserver.next(res),
-            err => this_.cudObserver.error(err),
-            ()  => this_.nextCudRequest()
+            res => this.cudObserver.next(res),
+            err => this.cudObserver.error(err),
+            ()  => this.nextCudRequest()
         );
     };
 }
index 6f1dbda..27a60f9 100644 (file)
@@ -33,7 +33,12 @@ export class EgStaffComponent implements OnInit {
         });
 
         // Redirect to the login page on any auth timeout events.
-        this.net.authExpired$.subscribe(uhOh => {
+        this.net.authExpired$.subscribe(expireEvent => {
+
+            // If the expired authtoken was identified locally (i.e. 
+            // in this browser tab) notify all tabs of imminent logout.
+            if (!expireEvent.viaExternal) this.auth.broadcastLogout();
+
             console.debug('Auth session has expired. Redirecting to login');
             this.auth.redirectUrl = this.router.url;
             this.router.navigate([this.loginPath]);
@@ -41,8 +46,7 @@ export class EgStaffComponent implements OnInit {
 
         this.route.data.subscribe((data: {staffResolver : any}) => {
             console.debug('EgStaff ngOnInit complete');
-     
-      });
+        });
     }
 
     /**
index 6cefff5..39561db 100644 (file)
@@ -32,6 +32,7 @@ import {EgConfirmDialogComponent} from '@eg/share/confirm-dialog.component';
     // Components available to all staff/sub modules
     EgOrgSelectComponent,
     EgConfirmDialogComponent,
+    CommonModule,
     FormsModule,
     NgbModule
   ]
index fadcfc2..85d0490 100644 (file)
@@ -27,8 +27,9 @@ export class EgStaffLoginComponent implements OnInit {
       private auth: EgAuthService,
       private store: EgStoreService 
     ) {}
-    
+
     ngOnInit() {
+        console.debug('login ngOnInit()');
 
         // clear out any stale auth data
         this.auth.logout();
index f325a74..f85a6e1 100644 (file)
           <i class="material-icons">more_vert</i>
         </a>
         <div class="dropdown-menu" ngbDropdownMenu>
-          <a i18n class="dropdown-item" routerLink="/staff/login">
+          <a i18n class="dropdown-item" (click)="logout()">
             <span class="material-icons">lock_outline</span>
             <span i18n>Logout</span>
           </a>
index 57dcfda..7b87841 100644 (file)
@@ -13,7 +13,10 @@ export class EgStaffNavComponent implements OnInit {
     user: string;
     workstation: string;
 
-    constructor(private auth: EgAuthService) {}
+    constructor(
+        private router: Router,
+        private auth: EgAuthService
+    ) {}
 
     ngOnInit() {
         if (this.auth.user()) {
@@ -21,6 +24,14 @@ export class EgStaffNavComponent implements OnInit {
             this.workstation = this.auth.workstation();
         }
     }
+
+    // Broadcast to all tabs that we're logging out.
+    // Redirect to the login page, which performs the remaining 
+    // logout duties.
+    logout(): void {
+        this.auth.broadcastLogout();
+        this.router.navigate(['/staff/login']);
+    }
 }