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 <erik.tiekstra@arbetsformedlingen.se>
Date:   Wed Nov 10 14:45:48 2021 +0100

    Made changes after PR

commit a8a51ecdabf0d32aa67b814eee530c9a01d405a9
Merge: b1677aff 3b6ce438
Author: Erik Tiekstra <erik.tiekstra@arbetsformedlingen.se>
Date:   Wed Nov 10 12:47:54 2021 +0100

    Merge branch 'develop' into feature/TV-535-idle-functionality

commit b1677aff5210288f4a86ba235dd1acb5d415f71f
Author: Erik Tiekstra <erik.tiekstra@arbetsformedlingen.se>
Date:   Wed Nov 10 09:17:55 2021 +0100

    Added better check to avoid blank screens

commit 0129f3f6a1d4884d3f669b109bc9b8667fc6281c
Author: Erik Tiekstra <erik.tiekstra@arbetsformedlingen.se>
Date:   Wed Nov 10 09:12:55 2021 +0100

    Added idle functionality
This commit is contained in:
Erik Tiekstra
2021-11-10 14:47:06 +01:00
parent 3b6ce438a9
commit f332dd41e2
14 changed files with 266 additions and 48 deletions

View File

@@ -1,3 +1,19 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
<msfa-toast-list></msfa-toast-list> <msfa-toast-list></msfa-toast-list>
<digi-ng-dialog
[afActive]="userIsIdle$ | async"
(afOnPrimaryClick)="setUserAsActive()"
(afOnSecondaryClick)="logout()"
(afOnInactive)="setUserAsActive()"
afHeading="Du verkar inaktiv"
afHeadingLevel="h2"
afPrimaryButtonText="Fortsätt sessionen"
afSecondaryButtonText="Logga ut"
>
<p>Din session är på väg att löpa ut på grund av inaktivitet. Vill du fortsätta eller logga ut?</p>
<p *ngIf="timeLeftBeforeLogout$ | async as timeLeftBeforeLogout">
Du blir automatiskt utloggad om <time>{{timeLeftBeforeLogout}}</time>
</p>
</digi-ng-dialog>

View File

@@ -3,6 +3,9 @@ import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { environment } from '@msfa-environment'; 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'; import { filter, map, switchMap } from 'rxjs/operators';
@Component({ @Component({
@@ -12,11 +15,16 @@ import { filter, map, switchMap } from 'rxjs/operators';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AppComponent { export class AppComponent {
userIsIdle$: Observable<boolean> = this.idleService.isIdle$;
timeLeftBeforeLogout$: Observable<string> = this.idleService.timeLeftBeforeLogout$;
constructor( constructor(
@Inject(DOCUMENT) private document: Document, @Inject(DOCUMENT) private document: Document,
private router: Router, private router: Router,
private activatedRoute: ActivatedRoute, private activatedRoute: ActivatedRoute,
private titleService: Title private titleService: Title,
private idleService: IdleService,
private authenticationService: AuthenticationService
) { ) {
this.document.body.dataset.version = environment.version; 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`); 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 { function traverseUntilNoChildRoute(route: ActivatedRoute): ActivatedRoute {

View File

@@ -1,17 +1,18 @@
import { DigiNgDialogModule } from '@af/digi-ng/_dialog/dialog';
import { registerLocaleData } from '@angular/common'; 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 localeSe from '@angular/common/locales/sv';
import { ErrorHandler, LOCALE_ID, NgModule, Provider } from '@angular/core'; import { ErrorHandler, LOCALE_ID, NgModule, Provider } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { ApmErrorHandler } from '@elastic/apm-rum-angular'; import { ApmErrorHandler } from '@elastic/apm-rum-angular';
import { environment } from '@msfa-environment'; import { environment } from '@msfa-environment';
import { AuthInterceptor } from '@msfa-interceptors/auth.interceptor'; import { AuthInterceptor } from '@msfa-interceptors/auth.interceptor';
import { CustomErrorHandler } from '@msfa-interceptors/custom-error-handler';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { ToastListModule } from './components/toast-list/toast-list.module'; import { ToastListModule } from './components/toast-list/toast-list.module';
import { LoggingModule } from './logging.module'; import { LoggingModule } from './logging.module';
import { AvropModule } from './pages/avrop/avrop.module'; import { AvropModule } from './pages/avrop/avrop.module';
import { CustomErrorHandler } from '@msfa-interceptors/custom-error-handler';
registerLocaleData(localeSe); registerLocaleData(localeSe);
const providers: Provider[] = [ const providers: Provider[] = [
@@ -30,7 +31,15 @@ if (environment.production) {
@NgModule({ @NgModule({
declarations: [AppComponent], declarations: [AppComponent],
imports: [LoggingModule, BrowserModule, HttpClientModule, AppRoutingModule, ToastListModule, AvropModule], imports: [
LoggingModule,
BrowserModule,
HttpClientModule,
AppRoutingModule,
ToastListModule,
AvropModule,
DigiNgDialogModule,
],
providers, providers,
bootstrap: [AppComponent], bootstrap: [AppComponent],
}) })

View File

@@ -1,9 +1,11 @@
export const AUTH_TOKEN_KEY = 'id_token'; export const AUTH_TOKEN_KEY = 'id_token';
export const AUTH_TOKEN_EXPIRE_KEY = 'expires_at'; 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 SELECTED_ORGANIZATION_NUMBER_KEY = 'selected_orgnr';
export const ALL_LOCAL_STORAGE_KEYS = { export const ALL_LOCAL_STORAGE_KEYS = [
AUTH_TOKEN_KEY, AUTH_TOKEN_KEY,
AUTH_TOKEN_EXPIRE_KEY, AUTH_TOKEN_EXPIRE_KEY,
AUTH_TOKEN_EXPIRES_IN_KEY,
SELECTED_ORGANIZATION_NUMBER_KEY, SELECTED_ORGANIZATION_NUMBER_KEY,
}; ];

View File

@@ -15,20 +15,35 @@ export class AuthGuard implements CanActivate {
return this.authenticationService.isLoggedIn$.pipe( return this.authenticationService.isLoggedIn$.pipe(
switchMap(loggedIn => { switchMap(loggedIn => {
if (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) { } else if (route.queryParams.code) {
return this.authenticationService.login$(route.queryParams.code).pipe(map(result => !!result)); return this.authenticationService.login$(route.queryParams.code).pipe(map(result => !!result));
} }
void this.authenticationService.removeLocalStorageData(); this.redirectToLoginPage();
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);
}
return of(false); 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);
}
}
} }

View File

@@ -2,4 +2,5 @@ export interface Ciam {
clientId: string; clientId: string;
loginUrl: string; loginUrl: string;
logoutUrl: string; logoutUrl: string;
refreshUrl: string;
} }

View File

@@ -12,6 +12,7 @@ export interface Environment {
clientId?: string; clientId?: string;
loginUrl: string; loginUrl: string;
logoutUrl: string; logoutUrl: string;
refreshUrl: string;
}; };
activeFeatures: Feature[]; activeFeatures: Feature[];
elastic?: { elastic?: {

View File

@@ -1,28 +1,47 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Router } from '@angular/router'; 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 { environment } from '@msfa-environment';
import { AuthenticationResponse } from '@msfa-models/api/authentication.response.model'; import { AuthenticationResponse } from '@msfa-models/api/authentication.response.model';
import { Authentication, mapAuthApiResponseToAuthenticationResult } from '@msfa-models/authentication.model'; import { Authentication, mapAuthApiResponseToAuthenticationResult } from '@msfa-models/authentication.model';
import { add, isBefore } from 'date-fns'; import { add, isBefore, sub } from 'date-fns';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs';
import { map, tap } from 'rxjs/operators'; import { catchError, distinctUntilChanged, filter, map, tap } from 'rxjs/operators';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class AuthenticationService { export class AuthenticationService {
private readonly _refreshBaseUrl = environment.ciam.refreshUrl;
private _token$ = new BehaviorSubject<string>(null); private _token$ = new BehaviorSubject<string>(null);
private _expiration$ = new BehaviorSubject<number>(null); private _expiresAt$ = new BehaviorSubject<number>(null);
private _expiresIn$ = new BehaviorSubject<number>(null);
isLoggedIn$: Observable<boolean> = combineLatest([this._token$, this._expiration$]).pipe( isLoggedIn$: Observable<boolean> = this._token$.pipe(
map(([token, expiration]) => { distinctUntilChanged(),
if (token && expiration) { map(token => !!token)
return isBefore(new Date(), expiration); );
}
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}`; return `${environment.api.url}/auth/token?accessCode=${code}`;
} }
private _setSession(authenticationResult: Authentication): void { public get expiresAtDate(): number {
const expiresAt = add(new Date(), { seconds: authenticationResult.expiresIn }); return this._expiresAt$.getValue();
}
this._token$.next(authenticationResult.idToken); refreshToken$(): Observable<boolean> {
localStorage.setItem(AUTH_TOKEN_KEY, authenticationResult.idToken); return this.httpClient.get(`${this._refreshBaseUrl}`, { responseType: 'blob' }).pipe(
this._expiration$.next(expiresAt.valueOf()); tap(() => {
localStorage.setItem(AUTH_TOKEN_EXPIRE_KEY, JSON.stringify(expiresAt.valueOf())); 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<Authentication> { login$(authorizationCodeFromCiam: string): Observable<Authentication> {
@@ -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 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, id_token,
expires_at: +JSON.parse(expiration), expires_at: +JSON.parse(expiresAt),
expires_in: +expiresIn,
} }
: null; : null;
} }
public removeLocalStorageData(): void { public removeLocalStorageData(): void {
Object.values(ALL_LOCAL_STORAGE_KEYS).forEach(value => { ALL_LOCAL_STORAGE_KEYS.forEach(key => {
localStorage.removeItem(value); localStorage.removeItem(key);
}); });
} }
@@ -75,11 +127,12 @@ export class AuthenticationService {
} }
constructor(private httpClient: HttpClient, private router: Router) { constructor(private httpClient: HttpClient, private router: Router) {
const localStorageData = this._getLocalStorageData(); const localStorageData = this._localStorageData;
if (localStorageData) { if (localStorageData) {
this._token$.next(localStorageData.id_token); this._token$.next(localStorageData.id_token);
this._expiration$.next(localStorageData.expires_at); this._expiresAt$.next(localStorageData.expires_at);
this._expiresIn$.next(localStorageData.expires_in);
} }
} }

View File

@@ -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<Event>[] = [
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<boolean>(false);
public isIdle$: Observable<boolean> = this._isIdle$.asObservable();
private _isActive$ = new BehaviorSubject<boolean>(true);
public isActive$: Observable<boolean> = this._isActive$.asObservable();
private _timeLeftBeforeLogoutInSeconds$ = new BehaviorSubject<number>(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<string> {
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')}`;
})
);
}
}

View File

@@ -4,16 +4,19 @@ export const CIAM_TEST: Ciam = {
clientId: '5d08c2e4-763e-42f6-b858-24e4773bb83d', clientId: '5d08c2e4-763e-42f6-b858-24e4773bb83d',
loginUrl: 'https://ciam-test.arbetsformedlingen.se:8443/uas/oauth2/authorization?response_type=code&scope=openid', loginUrl: 'https://ciam-test.arbetsformedlingen.se:8443/uas/oauth2/authorization?response_type=code&scope=openid',
logoutUrl: 'https://ciam-test.arbetsformedlingen.se:8443/uas/logout', logoutUrl: 'https://ciam-test.arbetsformedlingen.se:8443/uas/logout',
refreshUrl: '/refreshToken',
}; };
export const CIAM_PROD: Ciam = { export const CIAM_PROD: Ciam = {
clientId: '71010833-e445-4bbc-926a-775247b7a6e3', clientId: '71010833-e445-4bbc-926a-775247b7a6e3',
loginUrl: 'https://ciam.arbetsformedlingen.se/uas/oauth2/authorization?response_type=code&scope=openid', loginUrl: 'https://ciam.arbetsformedlingen.se/uas/oauth2/authorization?response_type=code&scope=openid',
logoutUrl: 'https://ciam.arbetsformedlingen.se/uas/logout', logoutUrl: 'https://ciam.arbetsformedlingen.se/uas/logout',
refreshUrl: '/refreshToken',
}; };
export const CIAM_MOCK: Ciam = { export const CIAM_MOCK: Ciam = {
clientId: '', clientId: '',
loginUrl: '/mock-login', loginUrl: '/mock-login',
logoutUrl: '/mock-login', logoutUrl: '/mock-login',
refreshUrl: '/refreshToken',
}; };

View File

@@ -11,5 +11,12 @@
"pathRewrite": { "pathRewrite": {
"^/logging": "" "^/logging": ""
} }
},
"/refreshToken": {
"target": "https://ciam-test.arbetsformedlingen.se:8443/uas/refresh",
"secure": false,
"changeOrigin": true,
"logLevel": "debug",
"pathRewrite": { "^/refreshToken": "" }
} }
} }

View File

@@ -72,15 +72,16 @@ http {
proxy_pass $ELASTIC_SERVER_URL; 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; error_page 404 /404.html;
location = /40x.html { location = /40x.html {}
}
error_page 500 502 503 504 /50x.html; error_page 500 502 503 504 /50x.html;
location = /50x.html { location = /50x.html {}
}
} }
} }

View File

@@ -1,5 +1,6 @@
#!/bin/bash #!/bin/bash
export CIAM_REFRESH_URL
export ELASTIC_SERVER_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

View File

@@ -23,7 +23,7 @@ pipeline {
echo '### Generating build tag... ###' echo '### Generating build tag... ###'
script { script {
def packageJson = readJSON file: 'package.json' 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 '### Build tag ###'
echo "This is the build tag: ${BUILD_TAG}" echo "This is the build tag: ${BUILD_TAG}"
} }