From f332dd41e26ba79822f71845c588b384b5e7f1fd Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Wed, 10 Nov 2021 14:47:06 +0100 Subject: [PATCH] feat(authentication): Added idle-functionality to logout inactive users after a certain time of inactivity. (TV-535) Squashed commit of the following: commit ca11dd079dca884634ead2696899cfedbfc826f1 Author: Erik Tiekstra Date: Wed Nov 10 14:45:48 2021 +0100 Made changes after PR commit a8a51ecdabf0d32aa67b814eee530c9a01d405a9 Merge: b1677aff 3b6ce438 Author: Erik Tiekstra Date: Wed Nov 10 12:47:54 2021 +0100 Merge branch 'develop' into feature/TV-535-idle-functionality commit b1677aff5210288f4a86ba235dd1acb5d415f71f Author: Erik Tiekstra Date: Wed Nov 10 09:17:55 2021 +0100 Added better check to avoid blank screens commit 0129f3f6a1d4884d3f669b109bc9b8667fc6281c Author: Erik Tiekstra Date: Wed Nov 10 09:12:55 2021 +0100 Added idle functionality --- apps/mina-sidor-fa/src/app/app.component.html | 16 +++ apps/mina-sidor-fa/src/app/app.component.ts | 18 ++- apps/mina-sidor-fa/src/app/app.module.ts | 15 ++- .../shared/constants/local-storage-keys.ts | 6 +- .../src/app/shared/guards/auth.guard.ts | 31 ++++-- .../src/app/shared/models/ciam.model.ts | 1 + .../app/shared/models/environment.model.ts | 1 + .../services/api/authentication.service.ts | 105 +++++++++++++----- .../app/shared/services/api/idle.service.ts | 93 ++++++++++++++++ apps/mina-sidor-fa/src/environments/ciam.ts | 3 + config/proxy.conf.json | 7 ++ nginx/nginx-start/nginx.template | 13 ++- nginx/nginx-start/template.sh | 3 +- openshift/{utv => playground}/Jenkinsfile | 2 +- 14 files changed, 266 insertions(+), 48 deletions(-) create mode 100644 apps/mina-sidor-fa/src/app/shared/services/api/idle.service.ts rename openshift/{utv => playground}/Jenkinsfile (97%) diff --git a/apps/mina-sidor-fa/src/app/app.component.html b/apps/mina-sidor-fa/src/app/app.component.html index f3649be..fdd5936 100644 --- a/apps/mina-sidor-fa/src/app/app.component.html +++ b/apps/mina-sidor-fa/src/app/app.component.html @@ -1,3 +1,19 @@ + + +

Din session är på väg att löpa ut på grund av inaktivitet. Vill du fortsätta eller logga ut?

+

+ Du blir automatiskt utloggad om +

+
diff --git a/apps/mina-sidor-fa/src/app/app.component.ts b/apps/mina-sidor-fa/src/app/app.component.ts index 4ea91e8..8131cef 100644 --- a/apps/mina-sidor-fa/src/app/app.component.ts +++ b/apps/mina-sidor-fa/src/app/app.component.ts @@ -3,6 +3,9 @@ import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { environment } from '@msfa-environment'; +import { AuthenticationService } from '@msfa-services/api/authentication.service'; +import { IdleService } from '@msfa-services/api/idle.service'; +import { Observable } from 'rxjs'; import { filter, map, switchMap } from 'rxjs/operators'; @Component({ @@ -12,11 +15,16 @@ import { filter, map, switchMap } from 'rxjs/operators'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent { + userIsIdle$: Observable = this.idleService.isIdle$; + timeLeftBeforeLogout$: Observable = this.idleService.timeLeftBeforeLogout$; + constructor( @Inject(DOCUMENT) private document: Document, private router: Router, private activatedRoute: ActivatedRoute, - private titleService: Title + private titleService: Title, + private idleService: IdleService, + private authenticationService: AuthenticationService ) { this.document.body.dataset.version = environment.version; @@ -31,6 +39,14 @@ export class AppComponent { this.titleService.setTitle(`${pageTitle ? `${pageTitle} - ` : ''}Mina sidor för fristående aktörer`); }); } + + logout(): void { + this.authenticationService.logout(); + } + + setUserAsActive(): void { + this.idleService.setActive(); + } } function traverseUntilNoChildRoute(route: ActivatedRoute): ActivatedRoute { diff --git a/apps/mina-sidor-fa/src/app/app.module.ts b/apps/mina-sidor-fa/src/app/app.module.ts index eebefba..249b4e2 100644 --- a/apps/mina-sidor-fa/src/app/app.module.ts +++ b/apps/mina-sidor-fa/src/app/app.module.ts @@ -1,17 +1,18 @@ +import { DigiNgDialogModule } from '@af/digi-ng/_dialog/dialog'; import { registerLocaleData } from '@angular/common'; -import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; +import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; import localeSe from '@angular/common/locales/sv'; import { ErrorHandler, LOCALE_ID, NgModule, Provider } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { ApmErrorHandler } from '@elastic/apm-rum-angular'; import { environment } from '@msfa-environment'; import { AuthInterceptor } from '@msfa-interceptors/auth.interceptor'; +import { CustomErrorHandler } from '@msfa-interceptors/custom-error-handler'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { ToastListModule } from './components/toast-list/toast-list.module'; import { LoggingModule } from './logging.module'; import { AvropModule } from './pages/avrop/avrop.module'; -import { CustomErrorHandler } from '@msfa-interceptors/custom-error-handler'; registerLocaleData(localeSe); const providers: Provider[] = [ @@ -30,7 +31,15 @@ if (environment.production) { @NgModule({ declarations: [AppComponent], - imports: [LoggingModule, BrowserModule, HttpClientModule, AppRoutingModule, ToastListModule, AvropModule], + imports: [ + LoggingModule, + BrowserModule, + HttpClientModule, + AppRoutingModule, + ToastListModule, + AvropModule, + DigiNgDialogModule, + ], providers, bootstrap: [AppComponent], }) diff --git a/apps/mina-sidor-fa/src/app/shared/constants/local-storage-keys.ts b/apps/mina-sidor-fa/src/app/shared/constants/local-storage-keys.ts index e79912c..1d1229f 100644 --- a/apps/mina-sidor-fa/src/app/shared/constants/local-storage-keys.ts +++ b/apps/mina-sidor-fa/src/app/shared/constants/local-storage-keys.ts @@ -1,9 +1,11 @@ export const AUTH_TOKEN_KEY = 'id_token'; export const AUTH_TOKEN_EXPIRE_KEY = 'expires_at'; +export const AUTH_TOKEN_EXPIRES_IN_KEY = 'expires_in'; export const SELECTED_ORGANIZATION_NUMBER_KEY = 'selected_orgnr'; -export const ALL_LOCAL_STORAGE_KEYS = { +export const ALL_LOCAL_STORAGE_KEYS = [ AUTH_TOKEN_KEY, AUTH_TOKEN_EXPIRE_KEY, + AUTH_TOKEN_EXPIRES_IN_KEY, SELECTED_ORGANIZATION_NUMBER_KEY, -}; +]; diff --git a/apps/mina-sidor-fa/src/app/shared/guards/auth.guard.ts b/apps/mina-sidor-fa/src/app/shared/guards/auth.guard.ts index a5763e1..404cd84 100644 --- a/apps/mina-sidor-fa/src/app/shared/guards/auth.guard.ts +++ b/apps/mina-sidor-fa/src/app/shared/guards/auth.guard.ts @@ -15,20 +15,35 @@ export class AuthGuard implements CanActivate { return this.authenticationService.isLoggedIn$.pipe( switchMap(loggedIn => { if (loggedIn) { - return of(true); + return this.authenticationService.isTokenValid$.pipe( + switchMap(({ isValid, isRefreshable }) => { + if (isValid) { + return of(true); + } + if (isRefreshable) { + return this.authenticationService.refreshToken$(); + } + this.redirectToLoginPage(); + return of(false); + }) + ); } else if (route.queryParams.code) { return this.authenticationService.login$(route.queryParams.code).pipe(map(result => !!result)); } - void this.authenticationService.removeLocalStorageData(); - - if (environment.ciam.clientId) { - document.location.href = `${environment.ciam.loginUrl}&client_id=${environment.ciam.clientId}&redirect_uri=${window.location.origin}`; - } else { - void this.router.navigateByUrl(environment.ciam.loginUrl); - } + this.redirectToLoginPage(); return of(false); }) ); } + + redirectToLoginPage(): void { + this.authenticationService.removeLocalStorageData(); + + if (environment.ciam.clientId) { + document.location.href = `${environment.ciam.loginUrl}&client_id=${environment.ciam.clientId}&redirect_uri=${window.location.origin}`; + } else { + void this.router.navigateByUrl(environment.ciam.loginUrl); + } + } } diff --git a/apps/mina-sidor-fa/src/app/shared/models/ciam.model.ts b/apps/mina-sidor-fa/src/app/shared/models/ciam.model.ts index 2950807..5a76f4d 100644 --- a/apps/mina-sidor-fa/src/app/shared/models/ciam.model.ts +++ b/apps/mina-sidor-fa/src/app/shared/models/ciam.model.ts @@ -2,4 +2,5 @@ export interface Ciam { clientId: string; loginUrl: string; logoutUrl: string; + refreshUrl: string; } diff --git a/apps/mina-sidor-fa/src/app/shared/models/environment.model.ts b/apps/mina-sidor-fa/src/app/shared/models/environment.model.ts index c04c08c..64f1359 100644 --- a/apps/mina-sidor-fa/src/app/shared/models/environment.model.ts +++ b/apps/mina-sidor-fa/src/app/shared/models/environment.model.ts @@ -12,6 +12,7 @@ export interface Environment { clientId?: string; loginUrl: string; logoutUrl: string; + refreshUrl: string; }; activeFeatures: Feature[]; elastic?: { diff --git a/apps/mina-sidor-fa/src/app/shared/services/api/authentication.service.ts b/apps/mina-sidor-fa/src/app/shared/services/api/authentication.service.ts index fa03829..cd5537d 100644 --- a/apps/mina-sidor-fa/src/app/shared/services/api/authentication.service.ts +++ b/apps/mina-sidor-fa/src/app/shared/services/api/authentication.service.ts @@ -1,28 +1,47 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; -import { ALL_LOCAL_STORAGE_KEYS, AUTH_TOKEN_EXPIRE_KEY, AUTH_TOKEN_KEY } from '@msfa-constants/local-storage-keys'; +import { + ALL_LOCAL_STORAGE_KEYS, + AUTH_TOKEN_EXPIRES_IN_KEY, + AUTH_TOKEN_EXPIRE_KEY, + AUTH_TOKEN_KEY, +} from '@msfa-constants/local-storage-keys'; import { environment } from '@msfa-environment'; import { AuthenticationResponse } from '@msfa-models/api/authentication.response.model'; import { Authentication, mapAuthApiResponseToAuthenticationResult } from '@msfa-models/authentication.model'; -import { add, isBefore } from 'date-fns'; -import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; -import { map, tap } from 'rxjs/operators'; +import { add, isBefore, sub } from 'date-fns'; +import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs'; +import { catchError, distinctUntilChanged, filter, map, tap } from 'rxjs/operators'; @Injectable({ providedIn: 'root', }) export class AuthenticationService { + private readonly _refreshBaseUrl = environment.ciam.refreshUrl; private _token$ = new BehaviorSubject(null); - private _expiration$ = new BehaviorSubject(null); + private _expiresAt$ = new BehaviorSubject(null); + private _expiresIn$ = new BehaviorSubject(null); - isLoggedIn$: Observable = combineLatest([this._token$, this._expiration$]).pipe( - map(([token, expiration]) => { - if (token && expiration) { - return isBefore(new Date(), expiration); - } + isLoggedIn$: Observable = this._token$.pipe( + distinctUntilChanged(), + map(token => !!token) + ); - return false; + // We set token validity at 1 minute prior expiration time. + // Refresh will be possible until expiration time. + // TODO: Refesh solution + isTokenValid$: Observable<{ isValid: boolean; isRefreshable: boolean }> = combineLatest([ + this._token$, + this._expiresAt$, + ]).pipe( + filter(([token, expiresAt]) => !!(token && expiresAt)), + map(([, expiresAt]) => { + return { + isValid: isBefore(new Date(), sub(expiresAt, { minutes: 1 })), + isRefreshable: false, + // isRefreshable: isBefore(new Date(), expiresAt), + }; }) ); @@ -30,13 +49,44 @@ export class AuthenticationService { return `${environment.api.url}/auth/token?accessCode=${code}`; } - private _setSession(authenticationResult: Authentication): void { - const expiresAt = add(new Date(), { seconds: authenticationResult.expiresIn }); + public get expiresAtDate(): number { + return this._expiresAt$.getValue(); + } - this._token$.next(authenticationResult.idToken); - localStorage.setItem(AUTH_TOKEN_KEY, authenticationResult.idToken); - this._expiration$.next(expiresAt.valueOf()); - localStorage.setItem(AUTH_TOKEN_EXPIRE_KEY, JSON.stringify(expiresAt.valueOf())); + refreshToken$(): Observable { + return this.httpClient.get(`${this._refreshBaseUrl}`, { responseType: 'blob' }).pipe( + tap(() => { + this._setSession({ + idToken: this._token$.getValue(), + expiresIn: 3600, + }); + }), + map(() => true), + catchError((error: HttpErrorResponse) => { + // TODO: Information dialog that the user is logged out? + console.error('Could not refresh token... User needs to login again: ', error); + this._setSession(null); + return of(false); + }) + ); + } + + private _setSession(authenticationResult: Authentication | null): void { + if (!authenticationResult) { + this.removeLocalStorageData(); + this._token$.next(null); + this._expiresAt$.next(null); + this._expiresIn$.next(null); + } else { + const expiresAt = add(new Date(), { seconds: authenticationResult.expiresIn }); + + this._expiresIn$.next(authenticationResult.expiresIn); + localStorage.setItem(AUTH_TOKEN_EXPIRES_IN_KEY, authenticationResult.expiresIn.toString()); + this._token$.next(authenticationResult.idToken); + localStorage.setItem(AUTH_TOKEN_KEY, authenticationResult.idToken); + this._expiresAt$.next(expiresAt.valueOf()); + localStorage.setItem(AUTH_TOKEN_EXPIRE_KEY, JSON.stringify(expiresAt.valueOf())); + } } login$(authorizationCodeFromCiam: string): Observable { @@ -52,21 +102,23 @@ export class AuthenticationService { ); } - private _getLocalStorageData(): { id_token: string; expires_at: number } { + private get _localStorageData(): { id_token: string; expires_at: number; expires_in: number } { const id_token = localStorage.getItem(AUTH_TOKEN_KEY); - const expiration = localStorage.getItem(AUTH_TOKEN_EXPIRE_KEY); + const expiresAt = localStorage.getItem(AUTH_TOKEN_EXPIRE_KEY); + const expiresIn = localStorage.getItem(AUTH_TOKEN_EXPIRES_IN_KEY); - return id_token && expiration + return id_token && expiresAt && expiresIn ? { id_token, - expires_at: +JSON.parse(expiration), + expires_at: +JSON.parse(expiresAt), + expires_in: +expiresIn, } : null; } public removeLocalStorageData(): void { - Object.values(ALL_LOCAL_STORAGE_KEYS).forEach(value => { - localStorage.removeItem(value); + ALL_LOCAL_STORAGE_KEYS.forEach(key => { + localStorage.removeItem(key); }); } @@ -75,11 +127,12 @@ export class AuthenticationService { } constructor(private httpClient: HttpClient, private router: Router) { - const localStorageData = this._getLocalStorageData(); + const localStorageData = this._localStorageData; if (localStorageData) { this._token$.next(localStorageData.id_token); - this._expiration$.next(localStorageData.expires_at); + this._expiresAt$.next(localStorageData.expires_at); + this._expiresIn$.next(localStorageData.expires_in); } } diff --git a/apps/mina-sidor-fa/src/app/shared/services/api/idle.service.ts b/apps/mina-sidor-fa/src/app/shared/services/api/idle.service.ts new file mode 100644 index 0000000..527a43b --- /dev/null +++ b/apps/mina-sidor-fa/src/app/shared/services/api/idle.service.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@angular/core'; +import { UnsubscribeDirective } from '@msfa-directives/unsubscribe.directive'; +import { BehaviorSubject, fromEvent, merge, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { AuthenticationService } from './authentication.service'; + +@Injectable({ + providedIn: 'root', +}) +export class IdleService extends UnsubscribeDirective { + private _eventsArray$: Observable[] = [ + fromEvent(document, 'click'), + fromEvent(document, 'wheel'), + fromEvent(document, 'mousemove'), + fromEvent(document, 'touchstart'), + fromEvent(document, 'keyup'), + fromEvent(window, 'resize'), + fromEvent(window, 'scroll'), + ]; + private _allEvents$ = merge(...this._eventsArray$); + private _isIdle$ = new BehaviorSubject(false); + public isIdle$: Observable = this._isIdle$.asObservable(); + private _isActive$ = new BehaviorSubject(true); + public isActive$: Observable = this._isActive$.asObservable(); + private _timeLeftBeforeLogoutInSeconds$ = new BehaviorSubject(2 * 60); + + private _idleAfterMS = 10 * 60 * 1000; // 10 minutes + private _autoLogoutAfterMS = 2 * 60 * 1000; // 2 minutes + private _idleTimeout; + private _autoLogoutTimeout; + private _autoLogoutInterval; + + constructor(private authenticationService: AuthenticationService) { + super(); + super.unsubscribeOnDestroy( + this._allEvents$.subscribe(() => { + this._setNewTimeoutIfActive(); + }) + ); + this._setNewTimeoutIfActive(); + } + + private _setNewTimeoutIfActive(): void { + if (this._isActive$.getValue()) { + this._clearTimeouts(); + this._setIdleTimeout(); + } + } + + private _setIdleTimeout(): void { + this._idleTimeout = setTimeout(() => { + this._isActive$.next(false); + this._isIdle$.next(true); + this._setAutoLogoutTimeout(); + }, this._idleAfterMS); + } + + private _setAutoLogoutTimeout(): void { + this._autoLogoutTimeout = setTimeout(() => { + this.authenticationService.logout(); + }, this._autoLogoutAfterMS); + + this._autoLogoutInterval = setInterval(() => { + const currentTimeLeft = this._timeLeftBeforeLogoutInSeconds$.getValue(); + this._timeLeftBeforeLogoutInSeconds$.next(currentTimeLeft - 1); + }, 1000); + } + + private _clearTimeouts(): void { + clearTimeout(this._autoLogoutTimeout); + clearTimeout(this._idleTimeout); + clearInterval(this._autoLogoutInterval); + this._timeLeftBeforeLogoutInSeconds$.next(2 * 60); + } + + public setActive(): void { + this._isIdle$.next(false); + this._isActive$.next(true); + this._clearTimeouts(); + this._setIdleTimeout(); + } + + public get timeLeftBeforeLogout$(): Observable { + return this._timeLeftBeforeLogoutInSeconds$.pipe( + map(timeLeftInSeconds => { + const minutes = Math.floor(timeLeftInSeconds / 60); + const seconds = timeLeftInSeconds - minutes * 60; + + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + }) + ); + } +} diff --git a/apps/mina-sidor-fa/src/environments/ciam.ts b/apps/mina-sidor-fa/src/environments/ciam.ts index e158ef6..da76aee 100644 --- a/apps/mina-sidor-fa/src/environments/ciam.ts +++ b/apps/mina-sidor-fa/src/environments/ciam.ts @@ -4,16 +4,19 @@ export const CIAM_TEST: Ciam = { clientId: '5d08c2e4-763e-42f6-b858-24e4773bb83d', loginUrl: 'https://ciam-test.arbetsformedlingen.se:8443/uas/oauth2/authorization?response_type=code&scope=openid', logoutUrl: 'https://ciam-test.arbetsformedlingen.se:8443/uas/logout', + refreshUrl: '/refreshToken', }; export const CIAM_PROD: Ciam = { clientId: '71010833-e445-4bbc-926a-775247b7a6e3', loginUrl: 'https://ciam.arbetsformedlingen.se/uas/oauth2/authorization?response_type=code&scope=openid', logoutUrl: 'https://ciam.arbetsformedlingen.se/uas/logout', + refreshUrl: '/refreshToken', }; export const CIAM_MOCK: Ciam = { clientId: '', loginUrl: '/mock-login', logoutUrl: '/mock-login', + refreshUrl: '/refreshToken', }; diff --git a/config/proxy.conf.json b/config/proxy.conf.json index 4b6b641..422402f 100644 --- a/config/proxy.conf.json +++ b/config/proxy.conf.json @@ -11,5 +11,12 @@ "pathRewrite": { "^/logging": "" } + }, + "/refreshToken": { + "target": "https://ciam-test.arbetsformedlingen.se:8443/uas/refresh", + "secure": false, + "changeOrigin": true, + "logLevel": "debug", + "pathRewrite": { "^/refreshToken": "" } } } diff --git a/nginx/nginx-start/nginx.template b/nginx/nginx-start/nginx.template index 1f86ccd..a1f987d 100644 --- a/nginx/nginx-start/nginx.template +++ b/nginx/nginx-start/nginx.template @@ -72,15 +72,16 @@ http { proxy_pass $ELASTIC_SERVER_URL; } + # Ciam Refresh token + location /refreshToken { + #proxy_http_version 1.1; + proxy_pass $CIAM_REFRESH_URL; + } error_page 404 /404.html; - location = /40x.html { - - } + location = /40x.html {} error_page 500 502 503 504 /50x.html; - location = /50x.html { - - } + location = /50x.html {} } } diff --git a/nginx/nginx-start/template.sh b/nginx/nginx-start/template.sh index b871d24..c77c296 100644 --- a/nginx/nginx-start/template.sh +++ b/nginx/nginx-start/template.sh @@ -1,5 +1,6 @@ #!/bin/bash +export CIAM_REFRESH_URL export ELASTIC_SERVER_URL -envsubst '${ELASTIC_SERVER_URL} ' < /usr/share/container-scripts/nginx/nginx-start/nginx.template > /etc/nginx/nginx.conf +envsubst '${ELASTIC_SERVER_URL} ${CIAM_REFRESH_URL}' < /usr/share/container-scripts/nginx/nginx-start/nginx.template > /etc/nginx/nginx.conf diff --git a/openshift/utv/Jenkinsfile b/openshift/playground/Jenkinsfile similarity index 97% rename from openshift/utv/Jenkinsfile rename to openshift/playground/Jenkinsfile index 59ef146..8c2b23d 100644 --- a/openshift/utv/Jenkinsfile +++ b/openshift/playground/Jenkinsfile @@ -23,7 +23,7 @@ pipeline { echo '### Generating build tag... ###' script { def packageJson = readJSON file: 'package.json' - BUILD_TAG = "dev_v${packageJson.version}_${env.BUILD_NUMBER}_${CURRENT_COMMIT}" + BUILD_TAG = "playground_v${packageJson.version}_${env.BUILD_NUMBER}_${CURRENT_COMMIT}" echo '### Build tag ###' echo "This is the build tag: ${BUILD_TAG}" }