Merge pull request #1240 from atmire/w2p-79700_Improve-session-timeout-UX

Improve session timeout UX
This commit is contained in:
Art Lowel
2021-07-08 13:27:41 +02:00
committed by GitHub
24 changed files with 807 additions and 107 deletions

View File

@@ -167,7 +167,6 @@ export class BrowseByMetadataPageComponent implements OnInit {
* @param value The value of the browse-entry to display items for
*/
updatePageWithItems(searchOptions: BrowseEntrySearchOptions, value: string) {
console.log('updatePAge', searchOptions);
this.items$ = this.browseService.getBrowseItemsFor(value, searchOptions);
}

View File

@@ -1,4 +1,4 @@
import { delay, distinctUntilChanged, filter, take } from 'rxjs/operators';
import { delay, distinctUntilChanged, filter, take, withLatestFrom } from 'rxjs/operators';
import {
AfterViewInit,
ChangeDetectionStrategy,
@@ -38,6 +38,8 @@ import { ThemeService } from './shared/theme-support/theme.service';
import { BASE_THEME_NAME } from './shared/theme-support/theme.constants';
import { DEFAULT_THEME_CONFIG } from './shared/theme-support/theme.effects';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
@Component({
selector: 'ds-app',
@@ -70,6 +72,11 @@ export class AppComponent implements OnInit, AfterViewInit {
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false);
/**
* Whether or not the idle modal is is currently open
*/
idleModalOpen: boolean;
constructor(
@Inject(NativeWindowService) private _window: NativeWindowRef,
@Inject(DOCUMENT) private document: any,
@@ -87,6 +94,7 @@ export class AppComponent implements OnInit, AfterViewInit {
private windowService: HostWindowService,
private localeService: LocaleService,
private breadcrumbsService: BreadcrumbsService,
private modalService: NgbModal,
@Optional() private cookiesService: KlaroService,
@Optional() private googleAnalyticsService: GoogleAnalyticsService,
) {
@@ -108,6 +116,11 @@ export class AppComponent implements OnInit, AfterViewInit {
}
});
if (isPlatformBrowser(this.platformId)) {
this.authService.trackTokenExpiration();
this.trackIdleModal();
}
// Load all the languages that are defined as active from the config file
translate.addLangs(environment.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code));
@@ -130,7 +143,6 @@ export class AppComponent implements OnInit, AfterViewInit {
console.info(environment);
}
this.storeCSSVariables();
}
ngOnInit() {
@@ -229,4 +241,23 @@ export class AppComponent implements OnInit, AfterViewInit {
};
head.appendChild(link);
}
private trackIdleModal() {
const isIdle$ = this.authService.isUserIdle();
const isAuthenticated$ = this.authService.isAuthenticated();
isIdle$.pipe(withLatestFrom(isAuthenticated$))
.subscribe(([userIdle, authenticated]) => {
if (userIdle && authenticated) {
if (!this.idleModalOpen) {
const modalRef = this.modalService.open(IdleModalComponent, { ariaLabelledBy: 'idle-modal.header' });
this.idleModalOpen = true;
modalRef.componentInstance.response.pipe(take(1)).subscribe((closed: boolean) => {
if (closed) {
this.idleModalOpen = false;
}
});
}
}
});
}
}

View File

@@ -47,6 +47,7 @@ import { ThemedHeaderComponent } from './header/themed-header.component';
import { ThemedFooterComponent } from './footer/themed-footer.component';
import { ThemedBreadcrumbsComponent } from './breadcrumbs/themed-breadcrumbs.component';
import { ThemedHeaderNavbarWrapperComponent } from './header-nav-wrapper/themed-header-navbar-wrapper.component';
import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
export function getBase() {
return environment.ui.nameSpace;
@@ -144,6 +145,7 @@ const DECLARATIONS = [
ThemedBreadcrumbsComponent,
ForbiddenComponent,
ThemedForbiddenComponent,
IdleModalComponent
];
const EXPORTS = [

View File

@@ -34,7 +34,9 @@ export const AuthActionTypes = {
RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'),
RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'),
RETRIEVE_AUTHENTICATED_EPERSON_ERROR: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_ERROR'),
REDIRECT_AFTER_LOGIN_SUCCESS: type('dspace/auth/REDIRECT_AFTER_LOGIN_SUCCESS')
REDIRECT_AFTER_LOGIN_SUCCESS: type('dspace/auth/REDIRECT_AFTER_LOGIN_SUCCESS'),
SET_USER_AS_IDLE: type('dspace/auth/SET_USER_AS_IDLE'),
UNSET_USER_AS_IDLE: type('dspace/auth/UNSET_USER_AS_IDLE')
};
/* tslint:disable:max-classes-per-file */
@@ -404,6 +406,24 @@ export class RetrieveAuthenticatedEpersonErrorAction implements Action {
this.payload = payload ;
}
}
/**
* Set the current user as being idle.
* @class SetUserAsIdleAction
* @implements {Action}
*/
export class SetUserAsIdleAction implements Action {
public type: string = AuthActionTypes.SET_USER_AS_IDLE;
}
/**
* Unset the current user as being idle.
* @class UnsetUserAsIdleAction
* @implements {Action}
*/
export class UnsetUserAsIdleAction implements Action {
public type: string = AuthActionTypes.UNSET_USER_AS_IDLE;
}
/* tslint:enable:max-classes-per-file */
/**
@@ -434,4 +454,7 @@ export type AuthActions
| RetrieveAuthenticatedEpersonErrorAction
| RetrieveAuthenticatedEpersonSuccessAction
| SetRedirectUrlAction
| RedirectAfterLoginSuccessAction;
| RedirectAfterLoginSuccessAction
| SetUserAsIdleAction
| UnsetUserAsIdleAction;

View File

@@ -1,7 +1,13 @@
import { Injectable } from '@angular/core';
import { Injectable, NgZone } from '@angular/core';
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators';
import {
combineLatest as observableCombineLatest,
Observable,
of as observableOf,
timer,
asyncScheduler, queueScheduler
} from 'rxjs';
import { catchError, filter, map, switchMap, take, tap, observeOn } from 'rxjs/operators';
// import @ngrx
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Action, select, Store } from '@ngrx/store';
@@ -37,9 +43,19 @@ import {
RetrieveAuthMethodsAction,
RetrieveAuthMethodsErrorAction,
RetrieveAuthMethodsSuccessAction,
RetrieveTokenAction
RetrieveTokenAction, SetUserAsIdleAction
} from './auth.actions';
import { hasValue } from '../../shared/empty.util';
import { environment } from '../../../environments/environment';
import { RequestActionTypes } from '../data/request.actions';
import { NotificationsActionTypes } from '../../shared/notifications/notifications.actions';
import { LeaveZoneScheduler } from '../utilities/leave-zone.scheduler';
import { EnterZoneScheduler } from '../utilities/enter-zone.scheduler';
// Action Types that do not break/prevent the user from an idle state
const IDLE_TIMER_IGNORE_TYPES: string[]
= [...Object.values(AuthActionTypes).filter((t: string) => t !== AuthActionTypes.UNSET_USER_AS_IDLE),
...Object.values(RequestActionTypes), ...Object.values(NotificationsActionTypes)];
@Injectable()
export class AuthEffects {
@@ -242,13 +258,35 @@ export class AuthEffects {
})
);
/**
* For any action that is not in {@link IDLE_TIMER_IGNORE_TYPES} that comes in => Start the idleness timer
* If the idleness timer runs out (so no un-ignored action come through for that amount of time)
* => Return the action to set the user as idle ({@link SetUserAsIdleAction})
* @method trackIdleness
*/
@Effect()
public trackIdleness$: Observable<Action> = this.actions$.pipe(
filter((action: Action) => !IDLE_TIMER_IGNORE_TYPES.includes(action.type)),
// Using switchMap the effect will stop subscribing to the previous timer if a new action comes
// in, and start a new timer
switchMap(() =>
// Start a timer outside of Angular's zone
timer(environment.auth.ui.timeUntilIdle, new LeaveZoneScheduler(this.zone, asyncScheduler))
),
// Re-enter the zone to dispatch the action
observeOn(new EnterZoneScheduler(this.zone, queueScheduler)),
map(() => new SetUserAsIdleAction()),
);
/**
* @constructor
* @param {Actions} actions$
* @param {NgZone} zone
* @param {AuthService} authService
* @param {Store} store
*/
constructor(private actions$: Actions,
private zone: NgZone,
private authService: AuthService,
private store: Store<AppState>) {
}

View File

@@ -1,6 +1,6 @@
import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs';
import { catchError, filter, map } from 'rxjs/operators';
import { catchError, map } from 'rxjs/operators';
import { Injectable, Injector } from '@angular/core';
import {
HttpErrorResponse,
@@ -12,14 +12,13 @@ import {
HttpResponse,
HttpResponseBase
} from '@angular/common/http';
import { find } from 'lodash';
import { AppState } from '../../app.reducer';
import { AuthService } from './auth.service';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { hasValue, isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util';
import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions';
import { hasValue, isNotEmpty, isNotNull } from '../../shared/empty.util';
import { RedirectWhenTokenExpiredAction } from './auth.actions';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
import { AuthMethod } from './models/auth.method';
@@ -28,7 +27,7 @@ import { AuthMethodType } from './models/auth.method-type';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
// Intercetor is called twice per request,
// Interceptor is called twice per request,
// so to prevent RefreshTokenAction is dispatched twice
// we're creating a refresh token request list
protected refreshTokenRequestUrls = [];
@@ -216,23 +215,8 @@ export class AuthInterceptor implements HttpInterceptor {
let authorization: string;
if (authService.isTokenExpired()) {
authService.setRedirectUrl(this.router.url);
// The access token is expired
// Redirect to the login route
this.store.dispatch(new RedirectWhenTokenExpiredAction('auth.messages.expired'));
return observableOf(null);
} else if ((!this.isAuthRequest(req) || this.isLogoutResponse(req)) && isNotEmpty(token)) {
// Intercept a request that is not to the authentication endpoint
authService.isTokenExpiring().pipe(
filter((isExpiring) => isExpiring))
.subscribe(() => {
// If the current request url is already in the refresh token request list, skip it
if (isUndefined(find(this.refreshTokenRequestUrls, req.url))) {
// When a token is about to expire, refresh it
this.store.dispatch(new RefreshTokenAction(token));
this.refreshTokenRequestUrls.push(req.url);
}
});
// Get the auth header from the service.
authorization = authService.buildAuthHeader(token);
let newHeaders = req.headers.set('authorization', authorization);

View File

@@ -23,7 +23,7 @@ import {
RetrieveAuthMethodsAction,
RetrieveAuthMethodsErrorAction,
RetrieveAuthMethodsSuccessAction,
SetRedirectUrlAction
SetRedirectUrlAction, SetUserAsIdleAction, UnsetUserAsIdleAction
} from './auth.actions';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { EPersonMock } from '../../shared/testing/eperson.mock';
@@ -44,6 +44,7 @@ describe('authReducer', () => {
loaded: false,
blocking: true,
loading: false,
idle: false
};
const action = new AuthenticateAction('user', 'password');
const newState = authReducer(initialState, action);
@@ -53,7 +54,8 @@ describe('authReducer', () => {
blocking: true,
error: undefined,
loading: true,
info: undefined
info: undefined,
idle: false
};
expect(newState).toEqual(state);
@@ -66,7 +68,8 @@ describe('authReducer', () => {
error: undefined,
blocking: true,
loading: true,
info: undefined
info: undefined,
idle: false
};
const action = new AuthenticationSuccessAction(mockTokenInfo);
const newState = authReducer(initialState, action);
@@ -81,7 +84,8 @@ describe('authReducer', () => {
error: undefined,
blocking: true,
loading: true,
info: undefined
info: undefined,
idle: false
};
const action = new AuthenticationErrorAction(mockError);
const newState = authReducer(initialState, action);
@@ -92,7 +96,8 @@ describe('authReducer', () => {
loading: false,
info: undefined,
authToken: undefined,
error: 'Test error message'
error: 'Test error message',
idle: false
};
expect(newState).toEqual(state);
@@ -105,7 +110,8 @@ describe('authReducer', () => {
loaded: false,
error: undefined,
loading: true,
info: undefined
info: undefined,
idle: false
};
const action = new AuthenticatedAction(mockTokenInfo);
const newState = authReducer(initialState, action);
@@ -115,7 +121,8 @@ describe('authReducer', () => {
loaded: false,
error: undefined,
loading: true,
info: undefined
info: undefined,
idle: false
};
expect(newState).toEqual(state);
});
@@ -127,7 +134,8 @@ describe('authReducer', () => {
error: undefined,
blocking: true,
loading: true,
info: undefined
info: undefined,
idle: false
};
const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EPersonMock._links.self.href);
const newState = authReducer(initialState, action);
@@ -138,7 +146,8 @@ describe('authReducer', () => {
error: undefined,
blocking: true,
loading: true,
info: undefined
info: undefined,
idle: false
};
expect(newState).toEqual(state);
});
@@ -150,7 +159,8 @@ describe('authReducer', () => {
error: undefined,
blocking: true,
loading: true,
info: undefined
info: undefined,
idle: false
};
const action = new AuthenticatedErrorAction(mockError);
const newState = authReducer(initialState, action);
@@ -161,7 +171,8 @@ describe('authReducer', () => {
loaded: true,
blocking: false,
loading: false,
info: undefined
info: undefined,
idle: false
};
expect(newState).toEqual(state);
});
@@ -172,6 +183,7 @@ describe('authReducer', () => {
loaded: false,
blocking: false,
loading: false,
idle: false
};
const action = new CheckAuthenticationTokenAction();
const newState = authReducer(initialState, action);
@@ -180,6 +192,7 @@ describe('authReducer', () => {
loaded: false,
blocking: true,
loading: true,
idle: false
};
expect(newState).toEqual(state);
});
@@ -190,6 +203,7 @@ describe('authReducer', () => {
loaded: false,
blocking: false,
loading: true,
idle: false
};
const action = new CheckAuthenticationTokenCookieAction();
const newState = authReducer(initialState, action);
@@ -198,6 +212,7 @@ describe('authReducer', () => {
loaded: false,
blocking: true,
loading: true,
idle: false
};
expect(newState).toEqual(state);
});
@@ -211,7 +226,8 @@ describe('authReducer', () => {
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
userId: EPersonMock.id,
idle: false
};
const action = new LogOutAction();
@@ -229,7 +245,8 @@ describe('authReducer', () => {
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
userId: EPersonMock.id,
idle: false
};
const action = new LogOutSuccessAction();
@@ -243,7 +260,8 @@ describe('authReducer', () => {
loading: true,
info: undefined,
refreshing: false,
userId: undefined
userId: undefined,
idle: false
};
expect(newState).toEqual(state);
});
@@ -257,7 +275,8 @@ describe('authReducer', () => {
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
userId: EPersonMock.id,
idle: false
};
const action = new LogOutErrorAction(mockError);
@@ -270,7 +289,8 @@ describe('authReducer', () => {
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
userId: EPersonMock.id,
idle: false
};
expect(newState).toEqual(state);
});
@@ -283,7 +303,8 @@ describe('authReducer', () => {
error: undefined,
blocking: true,
loading: true,
info: undefined
info: undefined,
idle: false
};
const action = new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock.id);
const newState = authReducer(initialState, action);
@@ -295,7 +316,8 @@ describe('authReducer', () => {
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
userId: EPersonMock.id,
idle: false
};
expect(newState).toEqual(state);
});
@@ -307,7 +329,8 @@ describe('authReducer', () => {
error: undefined,
blocking: true,
loading: true,
info: undefined
info: undefined,
idle: false
};
const action = new RetrieveAuthenticatedEpersonErrorAction(mockError);
const newState = authReducer(initialState, action);
@@ -318,7 +341,8 @@ describe('authReducer', () => {
loaded: true,
blocking: false,
loading: false,
info: undefined
info: undefined,
idle: false
};
expect(newState).toEqual(state);
});
@@ -332,7 +356,8 @@ describe('authReducer', () => {
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
userId: EPersonMock.id,
idle: false
};
const newTokenInfo = new AuthTokenInfo('Refreshed token');
const action = new RefreshTokenAction(newTokenInfo);
@@ -346,7 +371,8 @@ describe('authReducer', () => {
loading: false,
info: undefined,
userId: EPersonMock.id,
refreshing: true
refreshing: true,
idle: false
};
expect(newState).toEqual(state);
});
@@ -361,7 +387,8 @@ describe('authReducer', () => {
loading: false,
info: undefined,
userId: EPersonMock.id,
refreshing: true
refreshing: true,
idle: false
};
const newTokenInfo = new AuthTokenInfo('Refreshed token');
const action = new RefreshTokenSuccessAction(newTokenInfo);
@@ -375,7 +402,8 @@ describe('authReducer', () => {
loading: false,
info: undefined,
userId: EPersonMock.id,
refreshing: false
refreshing: false,
idle: false
};
expect(newState).toEqual(state);
});
@@ -390,7 +418,8 @@ describe('authReducer', () => {
loading: false,
info: undefined,
userId: EPersonMock.id,
refreshing: true
refreshing: true,
idle: false
};
const action = new RefreshTokenErrorAction();
const newState = authReducer(initialState, action);
@@ -403,7 +432,8 @@ describe('authReducer', () => {
loading: false,
info: undefined,
refreshing: false,
userId: undefined
userId: undefined,
idle: false
};
expect(newState).toEqual(state);
});
@@ -417,7 +447,8 @@ describe('authReducer', () => {
blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
userId: EPersonMock.id,
idle: false
};
state = {
@@ -428,7 +459,8 @@ describe('authReducer', () => {
loading: false,
error: undefined,
info: 'Message',
userId: undefined
userId: undefined,
idle: false
};
});
@@ -450,6 +482,7 @@ describe('authReducer', () => {
loaded: false,
blocking: false,
loading: false,
idle: false
};
const action = new AddAuthenticationMessageAction('Message');
const newState = authReducer(initialState, action);
@@ -458,7 +491,8 @@ describe('authReducer', () => {
loaded: false,
blocking: false,
loading: false,
info: 'Message'
info: 'Message',
idle: false
};
expect(newState).toEqual(state);
});
@@ -470,7 +504,8 @@ describe('authReducer', () => {
blocking: false,
loading: false,
error: 'Error',
info: 'Message'
info: 'Message',
idle: false
};
const action = new ResetAuthenticationMessagesAction();
const newState = authReducer(initialState, action);
@@ -480,7 +515,8 @@ describe('authReducer', () => {
blocking: false,
loading: false,
error: undefined,
info: undefined
info: undefined,
idle: false
};
expect(newState).toEqual(state);
});
@@ -490,7 +526,8 @@ describe('authReducer', () => {
authenticated: false,
loaded: false,
blocking: false,
loading: false
loading: false,
idle: false
};
const action = new SetRedirectUrlAction('redirect.url');
const newState = authReducer(initialState, action);
@@ -499,7 +536,8 @@ describe('authReducer', () => {
loaded: false,
blocking: false,
loading: false,
redirectUrl: 'redirect.url'
redirectUrl: 'redirect.url',
idle: false
};
expect(newState).toEqual(state);
});
@@ -510,7 +548,8 @@ describe('authReducer', () => {
loaded: false,
blocking: false,
loading: false,
authMethods: []
authMethods: [],
idle: false
};
const action = new RetrieveAuthMethodsAction(new AuthStatus(), true);
const newState = authReducer(initialState, action);
@@ -519,7 +558,8 @@ describe('authReducer', () => {
loaded: false,
blocking: true,
loading: true,
authMethods: []
authMethods: [],
idle: false
};
expect(newState).toEqual(state);
});
@@ -530,7 +570,8 @@ describe('authReducer', () => {
loaded: false,
blocking: true,
loading: true,
authMethods: []
authMethods: [],
idle: false
};
const authMethods = [
new AuthMethod(AuthMethodType.Password),
@@ -543,7 +584,8 @@ describe('authReducer', () => {
loaded: false,
blocking: false,
loading: false,
authMethods: authMethods
authMethods: authMethods,
idle: false
};
expect(newState).toEqual(state);
});
@@ -554,7 +596,8 @@ describe('authReducer', () => {
loaded: false,
blocking: true,
loading: true,
authMethods: []
authMethods: [],
idle: false
};
const authMethods = [
new AuthMethod(AuthMethodType.Password),
@@ -567,7 +610,8 @@ describe('authReducer', () => {
loaded: false,
blocking: true,
loading: false,
authMethods: authMethods
authMethods: authMethods,
idle: false
};
expect(newState).toEqual(state);
});
@@ -578,7 +622,8 @@ describe('authReducer', () => {
loaded: false,
blocking: true,
loading: true,
authMethods: []
authMethods: [],
idle: false
};
const action = new RetrieveAuthMethodsErrorAction(false);
@@ -588,7 +633,50 @@ describe('authReducer', () => {
loaded: false,
blocking: false,
loading: false,
authMethods: [new AuthMethod(AuthMethodType.Password)]
authMethods: [new AuthMethod(AuthMethodType.Password)],
idle: false
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a SET_USER_AS_IDLE action', () => {
initialState = {
authenticated: true,
loaded: true,
blocking: false,
loading: false,
idle: false
};
const action = new SetUserAsIdleAction();
const newState = authReducer(initialState, action);
state = {
authenticated: true,
loaded: true,
blocking: false,
loading: false,
idle: true
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a UNSET_USER_AS_IDLE action', () => {
initialState = {
authenticated: true,
loaded: true,
blocking: false,
loading: false,
idle: true
};
const action = new UnsetUserAsIdleAction();
const newState = authReducer(initialState, action);
state = {
authenticated: true,
loaded: true,
blocking: false,
loading: false,
idle: false
};
expect(newState).toEqual(state);
});
@@ -599,7 +687,8 @@ describe('authReducer', () => {
loaded: false,
blocking: true,
loading: true,
authMethods: []
authMethods: [],
idle: false
};
const action = new RetrieveAuthMethodsErrorAction(true);
@@ -609,7 +698,8 @@ describe('authReducer', () => {
loaded: false,
blocking: true,
loading: false,
authMethods: [new AuthMethod(AuthMethodType.Password)]
authMethods: [new AuthMethod(AuthMethodType.Password)],
idle: false
};
expect(newState).toEqual(state);
});

View File

@@ -59,6 +59,9 @@ export interface AuthState {
// all authentication Methods enabled at the backend
authMethods?: AuthMethod[];
// true when the current user is idle
idle: boolean;
}
/**
@@ -69,7 +72,8 @@ const initialState: AuthState = {
loaded: false,
blocking: true,
loading: false,
authMethods: []
authMethods: [],
idle: false
};
/**
@@ -189,6 +193,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
return Object.assign({}, state, {
authToken: (action as RefreshTokenSuccessAction).payload,
refreshing: false,
blocking: false
});
case AuthActionTypes.ADD_MESSAGE:
@@ -234,6 +239,16 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
blocking: true,
});
case AuthActionTypes.SET_USER_AS_IDLE:
return Object.assign({}, state, {
idle: true,
});
case AuthActionTypes.UNSET_USER_AS_IDLE:
return Object.assign({}, state, {
idle: false,
});
default:
return state;
}

View File

@@ -27,6 +27,11 @@ import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.util
import { authMethodsMock } from '../../shared/testing/auth-service.stub';
import { AuthMethod } from './models/auth.method';
import { HardRedirectService } from '../services/hard-redirect.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions';
describe('AuthService test', () => {
@@ -47,6 +52,7 @@ describe('AuthService test', () => {
let token: AuthTokenInfo;
let authenticatedState;
let unAuthenticatedState;
let idleState;
let linkService;
let hardRedirectService;
@@ -64,14 +70,24 @@ describe('AuthService test', () => {
loaded: true,
loading: false,
authToken: token,
user: EPersonMock
user: EPersonMock,
idle: false
};
unAuthenticatedState = {
authenticated: false,
loaded: true,
loading: false,
authToken: undefined,
user: undefined
user: undefined,
idle: false
};
idleState = {
authenticated: true,
loaded: true,
loading: false,
authToken: token,
user: EPersonMock,
idle: true
};
authRequest = new AuthRequestServiceStub();
routeStub = new ActivatedRouteStub();
@@ -107,6 +123,8 @@ describe('AuthService test', () => {
{ provide: Store, useValue: mockStore },
{ provide: EPersonDataService, useValue: mockEpersonDataService },
{ provide: HardRedirectService, useValue: hardRedirectService },
{ provide: NotificationsService, useValue: NotificationsServiceStub },
{ provide: TranslateService, useValue: getMockTranslateService() },
CookieService,
AuthService
],
@@ -180,6 +198,26 @@ describe('AuthService test', () => {
expect(authMethods.length).toBe(2);
});
});
describe('setIdle true', () => {
beforeEach(() => {
authService.setIdle(true);
});
it('store should dispatch SetUserAsIdleAction', () => {
expect(mockStore.dispatch).toHaveBeenCalledWith(new SetUserAsIdleAction());
});
});
describe('setIdle false', () => {
beforeEach(() => {
authService.setIdle(false);
});
it('store should dispatch UnsetUserAsIdleAction', () => {
expect(mockStore.dispatch).toHaveBeenCalledWith(new UnsetUserAsIdleAction());
});
});
});
describe('', () => {
@@ -207,13 +245,13 @@ describe('AuthService test', () => {
}).compileComponents();
}));
beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService) => {
beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => {
store
.subscribe((state) => {
(state as any).core = Object.create({});
(state as any).core.auth = authenticatedState;
});
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService);
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
}));
it('should return true when user is logged in', () => {
@@ -250,6 +288,12 @@ describe('AuthService test', () => {
});
});
it('isUserIdle should return false when user is not yet idle', () => {
authService.isUserIdle().subscribe((status: boolean) => {
expect(status).toBe(false);
});
});
});
describe('', () => {
@@ -277,7 +321,7 @@ describe('AuthService test', () => {
}).compileComponents();
}));
beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router, RouteService], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService) => {
beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router, RouteService], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => {
const expiredToken: AuthTokenInfo = new AuthTokenInfo('test_token');
expiredToken.expires = Date.now() - (1000 * 60 * 60);
authenticatedState = {
@@ -292,7 +336,7 @@ describe('AuthService test', () => {
(state as any).core = Object.create({});
(state as any).core.auth = authenticatedState;
});
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService);
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
storage = (authService as any).storage;
routeServiceMock = TestBed.inject(RouteService);
routerStub = TestBed.inject(Router);
@@ -493,13 +537,13 @@ describe('AuthService test', () => {
}).compileComponents();
}));
beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService) => {
beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => {
store
.subscribe((state) => {
(state as any).core = Object.create({});
(state as any).core.auth = unAuthenticatedState;
});
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService);
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
}));
it('should return null for the shortlived token', () => {
@@ -508,4 +552,44 @@ describe('AuthService test', () => {
});
});
});
describe('when user is idle', () => {
beforeEach(waitForAsync(() => {
init();
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot({ authReducer }, {
runtimeChecks: {
strictStateImmutability: false,
strictActionImmutability: false
}
})
],
providers: [
{ provide: AuthRequestService, useValue: authRequest },
{ provide: REQUEST, useValue: {} },
{ provide: Router, useValue: routerStub },
{ provide: RouteService, useValue: routeServiceStub },
{ provide: RemoteDataBuildService, useValue: linkService },
CookieService,
AuthService
]
}).compileComponents();
}));
beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store<AppState>, router: Router, routeService: RouteService, notificationsService: NotificationsService, translateService: TranslateService) => {
store
.subscribe((state) => {
(state as any).core = Object.create({});
(state as any).core.auth = idleState;
});
authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService, notificationsService, translateService);
}));
it('isUserIdle should return true when user is not idle', () => {
authService.isUserIdle().subscribe((status: boolean) => {
expect(status).toBe(true);
});
});
});
});

View File

@@ -29,14 +29,17 @@ import {
getRedirectUrl,
isAuthenticated,
isAuthenticatedLoaded,
isIdle,
isTokenRefreshing
} from './selectors';
import { AppState } from '../../app.reducer';
import {
CheckAuthenticationTokenAction,
CheckAuthenticationTokenAction, RefreshTokenAction,
ResetAuthenticationMessagesAction,
RetrieveAuthMethodsAction,
SetRedirectUrlAction
SetRedirectUrlAction,
SetUserAsIdleAction,
UnsetUserAsIdleAction
} from './auth.actions';
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
@@ -46,6 +49,9 @@ import { getAllSucceededRemoteDataPayload } from '../shared/operators';
import { AuthMethod } from './models/auth.method';
import { HardRedirectService } from '../services/hard-redirect.service';
import { RemoteData } from '../data/remote-data';
import { environment } from '../../../environments/environment';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
export const LOGIN_ROUTE = '/login';
export const LOGOUT_ROUTE = '/logout';
@@ -64,6 +70,11 @@ export class AuthService {
*/
protected _authenticated: boolean;
/**
* Timer to track time until token refresh
*/
private tokenRefreshTimer;
constructor(@Inject(REQUEST) protected req: any,
@Inject(NativeWindowService) protected _window: NativeWindowRef,
@Optional() @Inject(RESPONSE) private response: any,
@@ -73,7 +84,9 @@ export class AuthService {
protected routeService: RouteService,
protected storage: CookieService,
protected store: Store<AppState>,
protected hardRedirectService: HardRedirectService
protected hardRedirectService: HardRedirectService,
private notificationService: NotificationsService,
private translateService: TranslateService
) {
this.store.pipe(
select(isAuthenticated),
@@ -187,7 +200,7 @@ export class AuthService {
return this.store.pipe(
select(getAuthenticatedUserId),
hasValueOperator(),
switchMap((id: string) => this.epersonService.findById(id) ),
switchMap((id: string) => this.epersonService.findById(id)),
getAllSucceededRemoteDataPayload()
);
}
@@ -298,7 +311,7 @@ export class AuthService {
*/
public getToken(): AuthTokenInfo {
let token: AuthTokenInfo;
this.store.pipe(select(getAuthenticationToken))
this.store.pipe(take(1), select(getAuthenticationToken))
.subscribe((authTokenInfo: AuthTokenInfo) => {
// Retrieve authentication token info and check if is valid
token = authTokenInfo || null;
@@ -306,6 +319,44 @@ export class AuthService {
return token;
}
/**
* Method that checks when the session token from store expires and refreshes it when needed
*/
public trackTokenExpiration(): void {
let token: AuthTokenInfo;
let currentlyRefreshingToken = false;
this.store.pipe(select(getAuthenticationToken)).subscribe((authTokenInfo: AuthTokenInfo) => {
// If new token is undefined an it wasn't previously => Refresh failed
if (currentlyRefreshingToken && token !== undefined && authTokenInfo === undefined) {
// Token refresh failed => Error notification => 10 second wait => Page reloads & user logged out
this.notificationService.error(this.translateService.get('auth.messages.token-refresh-failed'));
setTimeout(() => this.navigateToRedirectUrl(this.hardRedirectService.getCurrentRoute()), 10000);
currentlyRefreshingToken = false;
}
// If new token.expires is different => Refresh succeeded
if (currentlyRefreshingToken && authTokenInfo !== undefined && token.expires !== authTokenInfo.expires) {
currentlyRefreshingToken = false;
}
// Check if/when token needs to be refreshed
if (!currentlyRefreshingToken) {
token = authTokenInfo || null;
if (token !== undefined && token !== null) {
let timeLeftBeforeRefresh = token.expires - new Date().getTime() - environment.auth.rest.timeLeftBeforeTokenRefresh;
if (timeLeftBeforeRefresh < 0) {
timeLeftBeforeRefresh = 0;
}
if (hasValue(this.tokenRefreshTimer)) {
clearTimeout(this.tokenRefreshTimer);
}
this.tokenRefreshTimer = setTimeout(() => {
this.store.dispatch(new RefreshTokenAction(token));
currentlyRefreshingToken = true;
}, timeLeftBeforeRefresh);
}
}
});
}
/**
* Check if a token is next to be expired
* @returns {boolean}
@@ -346,7 +397,7 @@ export class AuthService {
// Set the cookie expire date
const expires = new Date(expireDate);
const options: CookieAttributes = { expires: expires };
const options: CookieAttributes = {expires: expires};
// Save cookie with the token
return this.storage.set(TOKENITEM, token, options);
@@ -396,12 +447,15 @@ export class AuthService {
* @param redirectUrl
*/
public navigateToRedirectUrl(redirectUrl: string) {
// Don't do redirect if already on reload url
if (!hasValue(redirectUrl) || !redirectUrl.includes('/reload/')) {
let url = `/reload/${new Date().getTime()}`;
if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) {
url += `?redirect=${encodeURIComponent(redirectUrl)}`;
}
this.hardRedirectService.redirect(url);
}
}
/**
* Refresh route navigated
@@ -435,7 +489,7 @@ export class AuthService {
// Set the cookie expire date
const expires = new Date(expireDate);
const options: CookieAttributes = { expires: expires };
const options: CookieAttributes = {expires: expires};
this.storage.set(REDIRECT_COOKIE, url, options);
this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : ''));
}
@@ -528,4 +582,24 @@ export class AuthService {
return new RetrieveAuthMethodsAction(authStatus, false);
}
/**
* Determines if current user is idle
* @returns {Observable<boolean>}
*/
public isUserIdle(): Observable<boolean> {
return this.store.pipe(select(isIdle));
}
/**
* Set idle of auth state
* @returns {Observable<boolean>}
*/
public setIdle(idle: boolean): void {
if (idle) {
this.store.dispatch(new SetUserAsIdleAction());
} else {
this.store.dispatch(new UnsetUserAsIdleAction());
}
}
}

View File

@@ -115,6 +115,14 @@ const _getRedirectUrl = (state: AuthState) => state.redirectUrl;
const _getAuthenticationMethods = (state: AuthState) => state.authMethods;
/**
* Returns true if the user is idle.
* @function _isIdle
* @param {State} state
* @returns {boolean}
*/
const _isIdle = (state: AuthState) => state.idle;
/**
* Returns the authentication methods enabled at the backend
* @function getAuthenticationMethods
@@ -231,3 +239,12 @@ export const getRegistrationError = createSelector(getAuthState, _getRegistratio
* @return {string}
*/
export const getRedirectUrl = createSelector(getAuthState, _getRedirectUrl);
/**
* Returns true if the user is idle
* @function isIdle
* @param {AuthState} state
* @param {any} props
* @return {boolean}
*/
export const isIdle = createSelector(getAuthState, _isIdle);

View File

@@ -0,0 +1,19 @@
import { SchedulerLike, Subscription } from 'rxjs';
import { NgZone } from '@angular/core';
/**
* An RXJS scheduler that will re-enter the Angular zone to run what's scheduled
*/
export class EnterZoneScheduler implements SchedulerLike {
constructor(private zone: NgZone, private scheduler: SchedulerLike) { }
schedule(...args: any[]): Subscription {
return this.zone.run(() =>
this.scheduler.schedule.apply(this.scheduler, args)
);
}
now (): number {
return this.scheduler.now();
}
}

View File

@@ -0,0 +1,19 @@
import { SchedulerLike, Subscription } from 'rxjs';
import { NgZone } from '@angular/core';
/**
* An RXJS scheduler that will run what's scheduled outside of the Angular zone
*/
export class LeaveZoneScheduler implements SchedulerLike {
constructor(private zone: NgZone, private scheduler: SchedulerLike) { }
schedule(...args: any[]): Subscription {
return this.zone.runOutsideAngular(() =>
this.scheduler.schedule.apply(this.scheduler, args)
);
}
now (): number {
return this.scheduler.now();
}
}

View File

@@ -1,5 +1,5 @@
import { map } from 'rxjs/operators';
import { Component, Inject, OnInit, Optional, Input } from '@angular/core';
import { Component, Inject, OnInit, Input } from '@angular/core';
import { Router } from '@angular/router';
import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs';
@@ -18,8 +18,6 @@ import { HostWindowService } from '../shared/host-window.service';
import { ThemeConfig } from '../../config/theme.model';
import { Angulartics2DSpace } from '../statistics/angulartics/dspace-provider';
import { environment } from '../../environments/environment';
import { LocaleService } from '../core/locale/locale.service';
import { KlaroService } from '../shared/cookies/klaro.service';
import { slideSidebarPadding } from '../shared/animations/slide';
@Component({
@@ -58,9 +56,7 @@ export class RootComponent implements OnInit {
private router: Router,
private cssService: CSSVariableService,
private menuService: MenuService,
private windowService: HostWindowService,
private localeService: LocaleService,
@Optional() private cookiesService: KlaroService
private windowService: HostWindowService
) {
}

View File

@@ -44,7 +44,8 @@ describe('AuthNavMenuComponent', () => {
authenticated: false,
loaded: false,
blocking: false,
loading: false
loading: false,
idle: false
};
authState = {
authenticated: true,
@@ -52,7 +53,8 @@ describe('AuthNavMenuComponent', () => {
blocking: false,
loading: false,
authToken: new AuthTokenInfo('test_token'),
userId: EPersonMock.id
userId: EPersonMock.id,
idle: false
};
}

View File

@@ -37,7 +37,8 @@ describe('UserMenuComponent', () => {
blocking: false,
loading: false,
authToken: new AuthTokenInfo('test_token'),
userId: EPersonMock.id
userId: EPersonMock.id,
idle: false
};
authStateLoading = {
authenticated: true,
@@ -45,7 +46,8 @@ describe('UserMenuComponent', () => {
blocking: false,
loading: true,
authToken: null,
userId: EPersonMock.id
userId: EPersonMock.id,
idle: false
};
}

View File

@@ -0,0 +1,18 @@
<div>
<div class="modal-header" id="idle-modal.header">{{ "idle-modal.header" | translate }}
<button type="button" class="close" (click)="closePressed()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<p>{{ "idle-modal.info" | translate:{timeToExpire: timeToExpire} }}</p>
</div>
<div class="modal-footer">
<button type="button" class="cancel btn btn-danger" (click)="logOutPressed()" aria-label="Log out">
<i class="fas fa-sign-out-alt"></i> {{ "idle-modal.log-out" | translate }}
</button>
<button type="button" class="confirm btn btn-primary" (click)="extendSessionPressed()" aria-label="Extend session" ngbAutofocus>
<i class="fas fa-redo-alt"></i> {{ "idle-modal.extend-session" | translate }}
</button>
</div>
</div>

View File

@@ -0,0 +1,135 @@
import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';
import { IdleModalComponent } from './idle-modal.component';
import { AuthService } from '../../core/auth/auth.service';
import { By } from '@angular/platform-browser';
import { Store } from '@ngrx/store';
import { LogOutAction } from '../../core/auth/auth.actions';
describe('IdleModalComponent', () => {
let component: IdleModalComponent;
let fixture: ComponentFixture<IdleModalComponent>;
let debugElement: DebugElement;
let modalStub;
let authServiceStub;
let storeStub;
beforeEach(waitForAsync(() => {
modalStub = jasmine.createSpyObj('modalStub', ['close']);
authServiceStub = jasmine.createSpyObj('authService', ['setIdle']);
storeStub = jasmine.createSpyObj('store', ['dispatch']);
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [IdleModalComponent],
providers: [
{ provide: NgbActiveModal, useValue: modalStub },
{ provide: AuthService, useValue: authServiceStub },
{ provide: Store, useValue: storeStub }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(IdleModalComponent);
component = fixture.componentInstance;
debugElement = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('extendSessionPressed', () => {
beforeEach(fakeAsync(() => {
spyOn(component.response, 'next');
component.extendSessionPressed();
}));
it('should set idle to false', () => {
expect(authServiceStub.setIdle).toHaveBeenCalledWith(false);
});
it('should close the modal', () => {
expect(modalStub.close).toHaveBeenCalled();
});
it('response \'closed\' should have true as next', () => {
expect(component.response.next).toHaveBeenCalledWith(true);
});
});
describe('logOutPressed', () => {
beforeEach(() => {
component.logOutPressed();
});
it('should close the modal', () => {
expect(modalStub.close).toHaveBeenCalled();
});
it('should send logout action', () => {
expect(storeStub.dispatch).toHaveBeenCalledWith(new LogOutAction());
});
});
describe('closePressed', () => {
beforeEach(fakeAsync(() => {
spyOn(component.response, 'next');
component.closePressed();
}));
it('should set idle to false', () => {
expect(authServiceStub.setIdle).toHaveBeenCalledWith(false);
});
it('should close the modal', () => {
expect(modalStub.close).toHaveBeenCalled();
});
it('response \'closed\' should have true as next', () => {
expect(component.response.next).toHaveBeenCalledWith(true);
});
});
describe('when the click method emits on extend session button', () => {
beforeEach(fakeAsync(() => {
spyOn(component, 'extendSessionPressed');
debugElement.query(By.css('button.confirm')).triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
tick();
fixture.detectChanges();
}));
it('should call the extendSessionPressed method on the component', () => {
expect(component.extendSessionPressed).toHaveBeenCalled();
});
});
describe('when the click method emits on log out button', () => {
beforeEach(fakeAsync(() => {
spyOn(component, 'logOutPressed');
debugElement.query(By.css('button.cancel')).triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
tick();
fixture.detectChanges();
}));
it('should call the logOutPressed method on the component', () => {
expect(component.logOutPressed).toHaveBeenCalled();
});
});
describe('when the click method emits on close button', () => {
beforeEach(fakeAsync(() => {
spyOn(component, 'closePressed');
debugElement.query(By.css('.close')).triggerEventHandler('click', {
preventDefault: () => {/**/
}
});
tick();
fixture.detectChanges();
}));
it('should call the closePressed method on the component', () => {
expect(component.closePressed).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,89 @@
import { Component, OnInit, Output } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { environment } from '../../../environments/environment';
import { AuthService } from '../../core/auth/auth.service';
import { Subject } from 'rxjs';
import { hasValue } from '../empty.util';
import { Store } from '@ngrx/store';
import { AppState } from '../../app.reducer';
import { LogOutAction } from '../../core/auth/auth.actions';
@Component({
selector: 'ds-idle-modal',
templateUrl: 'idle-modal.component.html',
})
export class IdleModalComponent implements OnInit {
/**
* Total time of idleness before session expires (in minutes)
* (environment.auth.ui.timeUntilIdle + environment.auth.ui.idleGracePeriod / 1000 / 60)
*/
timeToExpire: number;
/**
* Timer to track time grace period
*/
private graceTimer;
/**
* An event fired when the modal is closed
*/
@Output()
response: Subject<boolean> = new Subject();
constructor(private activeModal: NgbActiveModal,
private authService: AuthService,
private store: Store<AppState>) {
this.timeToExpire = (environment.auth.ui.timeUntilIdle + environment.auth.ui.idleGracePeriod) / 1000 / 60; // ms => min
}
ngOnInit() {
if (hasValue(this.graceTimer)) {
clearTimeout(this.graceTimer);
}
this.graceTimer = setTimeout(() => {
this.logOutPressed();
}, environment.auth.ui.idleGracePeriod);
}
/**
* When extend session is pressed
*/
extendSessionPressed() {
this.extendSessionAndCloseModal();
}
/**
* Close modal and logout
*/
logOutPressed() {
this.closeModal();
this.store.dispatch(new LogOutAction());
}
/**
* When close is pressed
*/
closePressed() {
this.extendSessionAndCloseModal();
}
/**
* Close the modal and extend session
*/
extendSessionAndCloseModal() {
if (hasValue(this.graceTimer)) {
clearTimeout(this.graceTimer);
}
this.authService.setIdle(false);
this.closeModal();
}
/**
* Close the modal and set the response to true so RootComponent knows the modal was closed
*/
closeModal() {
this.activeModal.close();
this.response.next(true);
}
}

View File

@@ -19,4 +19,11 @@ export class AuthServiceMock {
public setRedirectUrl(url: string) {
}
public trackTokenExpiration(): void {
}
public isUserIdle(): Observable<boolean> {
return observableOf(false);
}
}

View File

@@ -526,6 +526,8 @@
"auth.messages.expired": "Your session has expired. Please log in again.",
"auth.messages.token-refresh-failed": "Refreshing your session token failed. Please log in again.",
"bitstream.download.page": "Now downloading {{bitstream}}..." ,
@@ -3697,5 +3699,15 @@
"workflow-item.send-back.button.cancel": "Cancel",
"workflow-item.send-back.button.confirm": "Send back"
"workflow-item.send-back.button.confirm": "Send back",
"idle-modal.header": "Session will expire soon",
"idle-modal.info": "For security reasons, user sessions expire after {{ timeToExpire }} minutes of inactivity. Your session will expire soon. Would you like to extend it or log out?",
"idle-modal.log-out": "Log out",
"idle-modal.extend-session": "Extend session"
}

View File

@@ -6,5 +6,18 @@ export interface AuthTarget {
}
export interface AuthConfig extends Config {
target: AuthTarget;
target?: AuthTarget;
ui: {
// The amount of time before the idle warning is shown
timeUntilIdle: number;
// The amount of time the user has to react after the idle warning is shown before they are logged out.
idleGracePeriod: number;
};
rest: {
// If the rest token expires in less than this amount of time, it will be refreshed automatically.
// This is independent from the idle warning.
timeLeftBeforeTokenRefresh: number;
};
}

View File

@@ -2,7 +2,6 @@ import { GlobalConfig } from '../config/global-config.interface';
import { NotificationAnimationsType } from '../app/shared/notifications/models/notification-animations-type';
import { BrowseByType } from '../app/+browse-by/+browse-by-switcher/browse-by-decorator';
import { RestRequestMethod } from '../app/core/data/rest-request-method';
import { BASE_THEME_NAME } from '../app/shared/theme-support/theme.constants';
export const environment: GlobalConfig = {
production: true,
@@ -43,6 +42,22 @@ export const environment: GlobalConfig = {
timePerMethod: {[RestRequestMethod.PATCH]: 3} as any // time in seconds
}
},
// Authentication settings
auth: {
// Authentication UI settings
ui: {
// the amount of time before the idle warning is shown
timeUntilIdle: 15 * 60 * 1000, // 15 minutes
// the amount of time the user has to react after the idle warning is shown before they are logged out.
idleGracePeriod: 5 * 60 * 1000, // 5 minutes
},
// Authentication REST settings
rest: {
// If the rest token expires in less than this amount of time, it will be refreshed automatically.
// This is independent from the idle warning.
timeLeftBeforeTokenRefresh: 2 * 60 * 1000, // 2 minutes
},
},
// Form settings
form: {
// NOTE: Map server-side validators to comparative Angular form validators

View File

@@ -35,6 +35,22 @@ export const environment: Partial<GlobalConfig> = {
timePerMethod: {[RestRequestMethod.PATCH]: 3} as any // time in seconds
}
},
// Authentication settings
auth: {
// Authentication UI settings
ui: {
// the amount of time before the idle warning is shown
timeUntilIdle: 20000, // 20 sec
// the amount of time the user has to react after the idle warning is shown before they are logged out.
idleGracePeriod: 20000, // 20 sec
},
// Authentication REST settings
rest: {
// If the rest token expires in less than this amount of time, it will be refreshed automatically.
// This is independent from the idle warning.
timeLeftBeforeTokenRefresh: 20000, // 20 sec
},
},
// Form settings
form: {
// NOTE: Map server-side validators to comparative Angular form validators