Added refresh token functionality

This commit is contained in:
Giuseppe Digilio
2018-02-26 15:41:07 +01:00
parent 2637f1c28e
commit 696b8b73f5
22 changed files with 301 additions and 114 deletions

View File

@@ -86,7 +86,6 @@
"@nguniversal/express-engine": "5.0.0-beta.5", "@nguniversal/express-engine": "5.0.0-beta.5",
"@ngx-translate/core": "9.1.1", "@ngx-translate/core": "9.1.1",
"@ngx-translate/http-loader": "2.0.1", "@ngx-translate/http-loader": "2.0.1",
"@types/js-cookie": "^2.1.0",
"angular-idle-preload": "2.0.4", "angular-idle-preload": "2.0.4",
"body-parser": "1.18.2", "body-parser": "1.18.2",
"bootstrap": "4.0.0-beta", "bootstrap": "4.0.0-beta",
@@ -102,6 +101,7 @@
"js-cookie": "2.2.0", "js-cookie": "2.2.0",
"js.clone": "0.0.3", "js.clone": "0.0.3",
"jsonschema": "1.2.2", "jsonschema": "1.2.2",
"jwt-decode": "^2.2.0",
"methods": "1.1.2", "methods": "1.1.2",
"morgan": "1.9.0", "morgan": "1.9.0",
"ngx-pagination": "3.0.3", "ngx-pagination": "3.0.3",
@@ -124,6 +124,7 @@
"@types/express-serve-static-core": "4.11.1", "@types/express-serve-static-core": "4.11.1",
"@types/hammerjs": "2.0.35", "@types/hammerjs": "2.0.35",
"@types/jasmine": "2.8.4", "@types/jasmine": "2.8.4",
"@types/js-cookie": "2.1.0",
"@types/memory-cache": "0.2.0", "@types/memory-cache": "0.2.0",
"@types/mime": "2.0.0", "@types/mime": "2.0.0",
"@types/node": "^9.3.0", "@types/node": "^9.3.0",

View File

@@ -30,6 +30,8 @@ import { NativeWindowRef, NativeWindowService } from './shared/services/window.s
import { MockTranslateLoader } from './shared/mocks/mock-translate-loader'; import { MockTranslateLoader } from './shared/mocks/mock-translate-loader';
import { MockMetadataService } from './shared/mocks/mock-metadata-service'; import { MockMetadataService } from './shared/mocks/mock-metadata-service';
import { PlatformServiceStub } from './shared/testing/platform-service-stub';
import { PlatformService } from './shared/services/platform.service';
let comp: AppComponent; let comp: AppComponent;
let fixture: ComponentFixture<AppComponent>; let fixture: ComponentFixture<AppComponent>;
@@ -56,6 +58,7 @@ describe('App component', () => {
{ provide: GLOBAL_CONFIG, useValue: ENV_CONFIG }, { provide: GLOBAL_CONFIG, useValue: ENV_CONFIG },
{ provide: NativeWindowService, useValue: new NativeWindowRef() }, { provide: NativeWindowService, useValue: new NativeWindowRef() },
{ provide: MetadataService, useValue: new MockMetadataService() }, { provide: MetadataService, useValue: new MockMetadataService() },
{ provide: PlatformService, useValue: new PlatformServiceStub() },
AppComponent AppComponent
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@@ -8,7 +8,7 @@ import { Observable } from 'rxjs/Observable';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
import { AuthGetRequest, AuthPostRequest, PostRequest, RestRequest } from '../data/request.models'; import { AuthGetRequest, AuthPostRequest, PostRequest, RestRequest } from '../data/request.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { AuthSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; import { AuthStatusResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
@Injectable() @Injectable()
@@ -37,8 +37,8 @@ export class AuthRequestService extends HALEndpointService {
errorResponse.flatMap((response: ErrorResponse) => errorResponse.flatMap((response: ErrorResponse) =>
Observable.throw(new Error(response.errorMessage))), Observable.throw(new Error(response.errorMessage))),
successResponse successResponse
.filter((response: AuthSuccessResponse) => isNotEmpty(response)) .filter((response: AuthStatusResponse) => isNotEmpty(response))
.map((response: AuthSuccessResponse) => response.response) .map((response: AuthStatusResponse) => response.response)
.distinctUntilChanged()); .distinctUntilChanged());
} }

View File

@@ -2,25 +2,15 @@ import { Inject, Injectable } from '@angular/core';
import { AuthObjectFactory } from './auth-object-factory'; import { AuthObjectFactory } from './auth-object-factory';
import { BaseResponseParsingService } from '../data/base-response-parsing.service'; import { BaseResponseParsingService } from '../data/base-response-parsing.service';
import { import { AuthStatusResponse, RestResponse } from '../cache/response-cache.models';
AuthErrorResponse,
AuthStatusResponse,
AuthSuccessResponse, ConfigSuccessResponse, ErrorResponse,
RestResponse
} from '../cache/response-cache.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { ConfigObject } from '../shared/config/config.model';
import { ConfigType } from '../shared/config/config-type';
import { GLOBAL_CONFIG } from '../../../config'; import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface'; import { GlobalConfig } from '../../../config/global-config.interface';
import { isEmpty, isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { ResponseParsingService } from '../data/parsing.service'; import { ResponseParsingService } from '../data/parsing.service';
import { RestRequest } from '../data/request.models'; import { RestRequest } from '../data/request.models';
import { AuthType } from './auth-type'; import { AuthType } from './auth-type';
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { NormalizedAuthStatus } from './models/normalized-auth-status.model';
import { AuthStatus } from './models/auth-status.model'; import { AuthStatus } from './models/auth-status.model';
@Injectable() @Injectable()
@@ -29,18 +19,15 @@ export class AuthResponseParsingService extends BaseResponseParsingService imple
protected objectFactory = AuthObjectFactory; protected objectFactory = AuthObjectFactory;
protected toCache = false; protected toCache = false;
constructor( constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, protected objectCache: ObjectCacheService,) {
protected objectCache: ObjectCacheService, super();
) { super();
} }
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '200' || data.statusCode === 'OK')) { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '200' || data.statusCode === 'OK')) {
const response = this.process<AuthStatus, AuthType>(data.payload, request.href); const response = this.process<AuthStatus, AuthType>(data.payload, request.href);
return new AuthStatusResponse(response[Object.keys(response)[0]][0], data.statusCode); return new AuthStatusResponse(response[Object.keys(response)[0]][0], data.statusCode);
} else if (isEmpty(data.payload) && isNotEmpty(data.headers.get('authorization')) && (data.statusCode === '200' || data.statusCode === 'OK')) {
return new AuthSuccessResponse(new AuthTokenInfo(data.headers.get('authorization')), data.statusCode);
} else { } else {
return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode); return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode);
} }

View File

@@ -19,6 +19,9 @@ export const AuthActionTypes = {
CHECK_AUTHENTICATION_TOKEN_ERROR: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_ERROR'), CHECK_AUTHENTICATION_TOKEN_ERROR: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_ERROR'),
REDIRECT_TOKEN_EXPIRED: type('dspace/auth/REDIRECT_TOKEN_EXPIRED'), REDIRECT_TOKEN_EXPIRED: type('dspace/auth/REDIRECT_TOKEN_EXPIRED'),
REDIRECT_AUTHENTICATION_REQUIRED: type('dspace/auth/REDIRECT_AUTHENTICATION_REQUIRED'), REDIRECT_AUTHENTICATION_REQUIRED: type('dspace/auth/REDIRECT_AUTHENTICATION_REQUIRED'),
REFRESH_TOKEN: type('dspace/auth/REFRESH_TOKEN'),
REFRESH_TOKEN_SUCCESS: type('dspace/auth/REFRESH_TOKEN_SUCCESS'),
REFRESH_TOKEN_ERROR: type('dspace/auth/REFRESH_TOKEN_ERROR'),
RESET_MESSAGES: type('dspace/auth/RESET_MESSAGES'), RESET_MESSAGES: type('dspace/auth/RESET_MESSAGES'),
LOG_OUT: type('dspace/auth/LOG_OUT'), LOG_OUT: type('dspace/auth/LOG_OUT'),
LOG_OUT_ERROR: type('dspace/auth/LOG_OUT_ERROR'), LOG_OUT_ERROR: type('dspace/auth/LOG_OUT_ERROR'),
@@ -201,6 +204,43 @@ export class RedirectWhenTokenExpiredAction implements Action {
} }
} }
/**
* Refresh authentication token.
* @class RefreshTokenAction
* @implements {Action}
*/
export class RefreshTokenAction implements Action {
public type: string = AuthActionTypes.REFRESH_TOKEN;
payload: AuthTokenInfo;
constructor(token: AuthTokenInfo) {
this.payload = token;
}
}
/**
* Refresh authentication token success.
* @class RefreshTokenSuccessAction
* @implements {Action}
*/
export class RefreshTokenSuccessAction implements Action {
public type: string = AuthActionTypes.REFRESH_TOKEN_SUCCESS;
payload: AuthTokenInfo;
constructor(token: AuthTokenInfo) {
this.payload = token;
}
}
/**
* Refresh authentication token error.
* @class RefreshTokenErrorAction
* @implements {Action}
*/
export class RefreshTokenErrorAction implements Action {
public type: string = AuthActionTypes.REFRESH_TOKEN_ERROR;
}
/** /**
* Sign up. * Sign up.
* @class RegistrationAction * @class RegistrationAction

View File

@@ -18,7 +18,7 @@ import {
AuthenticationErrorAction, AuthenticationErrorAction,
AuthenticationSuccessAction, CheckAuthenticationTokenErrorAction, AuthenticationSuccessAction, CheckAuthenticationTokenErrorAction,
LogOutErrorAction, LogOutErrorAction,
LogOutSuccessAction, RegistrationAction, LogOutSuccessAction, RefreshTokenAction, RefreshTokenErrorAction, RefreshTokenSuccessAction, RegistrationAction,
RegistrationErrorAction, RegistrationErrorAction,
RegistrationSuccessAction RegistrationSuccessAction
} from './auth.actions'; } from './auth.actions';
@@ -41,11 +41,11 @@ export class AuthEffects {
.ofType(AuthActionTypes.AUTHENTICATE) .ofType(AuthActionTypes.AUTHENTICATE)
.switchMap((action: AuthenticateAction) => { .switchMap((action: AuthenticateAction) => {
return this.authService.authenticate(action.payload.email, action.payload.password) return this.authService.authenticate(action.payload.email, action.payload.password)
.first()
.map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)) .map((response: AuthStatus) => new AuthenticationSuccessAction(response.token))
.catch((error) => Observable.of(new AuthenticationErrorAction(error))); .catch((error) => Observable.of(new AuthenticationErrorAction(error)));
}); });
// It means "reacts to this action but don't send another"
@Effect() @Effect()
public authenticateSuccess: Observable<Action> = this.actions$ public authenticateSuccess: Observable<Action> = this.actions$
.ofType(AuthActionTypes.AUTHENTICATE_SUCCESS) .ofType(AuthActionTypes.AUTHENTICATE_SUCCESS)
@@ -61,6 +61,7 @@ export class AuthEffects {
.catch((error) => Observable.of(new AuthenticatedErrorAction(error))); .catch((error) => Observable.of(new AuthenticatedErrorAction(error)));
}); });
// It means "reacts to this action but don't send another"
@Effect({dispatch: false}) @Effect({dispatch: false})
public authenticatedError: Observable<Action> = this.actions$ public authenticatedError: Observable<Action> = this.actions$
.ofType(AuthActionTypes.AUTHENTICATED_ERROR) .ofType(AuthActionTypes.AUTHENTICATED_ERROR)
@@ -85,6 +86,21 @@ export class AuthEffects {
.catch((error) => Observable.of(new RegistrationErrorAction(error))); .catch((error) => Observable.of(new RegistrationErrorAction(error)));
}); });
@Effect()
public refreshToken: Observable<Action> = this.actions$
.ofType(AuthActionTypes.REFRESH_TOKEN)
.switchMap((action: RefreshTokenAction) => {
return this.authService.refreshAuthenticationToken(action.payload)
.map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token))
.catch((error) => Observable.of(new RefreshTokenErrorAction()));
});
// It means "reacts to this action but don't send another"
@Effect({dispatch: false})
public refreshTokenSuccess: Observable<Action> = this.actions$
.ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS)
.do((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload));
/** /**
* When the store is rehydrated in the browser, * When the store is rehydrated in the browser,
* clear a possible invalid token or authentication errors * clear a possible invalid token or authentication errors

View File

@@ -1,40 +1,55 @@
import { Injectable, Injector } from '@angular/core'; import { Injectable, Injector } from '@angular/core';
import { import {
HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse, HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse,
HttpErrorResponse HttpErrorResponse, HttpResponseBase
} from '@angular/common/http'; } from '@angular/common/http';
import { Observable } from 'rxjs/Rx'; import { Observable } from 'rxjs/Rx';
import 'rxjs/add/observable/throw' import 'rxjs/add/observable/throw'
import 'rxjs/add/operator/catch'; import 'rxjs/add/operator/catch';
import { find } from 'lodash';
import { AppState } from '../../app.reducer'; import { AppState } from '../../app.reducer';
import { AuthError } from './models/auth-error.model';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthStatus } from './models/auth-status.model'; import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthTokenInfo } from './models/auth-token-info.model';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty, isUndefined } from '../../shared/empty.util';
import { RedirectWhenTokenExpiredAction } from './auth.actions'; import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
@Injectable() @Injectable()
export class AuthInterceptor implements HttpInterceptor { export class AuthInterceptor implements HttpInterceptor {
// Intercetor is called twice per request,
// so to prevent RefreshTokenAction is dispatched twice
// we're creating a refresh token request list
protected refreshTokenRequestUrls = [];
constructor(private inj: Injector, private store: Store<AppState>) { } constructor(private inj: Injector, private store: Store<AppState>) { }
private isUnauthorized(status: number): boolean { private isUnauthorized(response: HttpResponseBase): boolean {
return status === 401 || status === 403; // invalid_token The access token provided is expired, revoked, malformed, or invalid for other reasons
return response.status === 401;
} }
private isAuthRequest(url: string): boolean { private isSuccess(response: HttpResponseBase): boolean {
return url.endsWith('/authn/login') || url.endsWith('/authn/logout') || url.endsWith('/authn/status'); return response.status === 200;
} }
private isLoginResponse(url: string): boolean { private isAuthRequest(http: HttpRequest<any> | HttpResponseBase): boolean {
return url.endsWith('/authn/login'); return http.url
&& (http.url.endsWith('/authn/login')
|| http.url.endsWith('/authn/logout')
|| http.url.endsWith('/authn/status'));
} }
private isLogoutResponse(url: string): boolean { private isLoginResponse(http: HttpRequest<any> | HttpResponseBase): boolean {
return url.endsWith('/authn/logout'); return http.url && http.url.endsWith('/authn/login');
}
private isLogoutResponse(http: HttpRequest<any> | HttpResponseBase): boolean {
return http.url && http.url.endsWith('/authn/logout');
} }
private makeAuthStatusObject(authenticated:boolean, accessToken?: string, error?: string): AuthStatus { private makeAuthStatusObject(authenticated:boolean, accessToken?: string, error?: string): AuthStatus {
@@ -55,27 +70,45 @@ export class AuthInterceptor implements HttpInterceptor {
const authService = this.inj.get(AuthService); const authService = this.inj.get(AuthService);
// Get the auth header from the service. const token = authService.getToken();
const Authorization = authService.getAuthHeader();
let authReq; let newReq;
if (!this.isAuthRequest(req.url) && isNotEmpty(Authorization)) { // Intercept a request that is not to the authentication endpoint
if (!this.isAuthRequest(req) && isNotEmpty(token)) {
authService.isTokenExpiring()
.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.
const Authorization = authService.buildAuthHeader(token);
// Clone the request to add the new header. // Clone the request to add the new header.
authReq = req.clone({headers: req.headers.set('authorization', Authorization)}); newReq = req.clone({headers: req.headers.set('authorization', Authorization)});
} else { } else {
authReq = req; newReq = req;
} }
// Pass on the cloned request instead of the original request. // Pass on the new request instead of the original request.
return next.handle(authReq) return next.handle(newReq)
.map((response) => { .map((response) => {
if (response instanceof HttpResponse && response.status === 200 && (this.isLoginResponse(response.url) || this.isLogoutResponse(response.url))) { // Intercept a Login/Logout response
if (response instanceof HttpResponse && this.isSuccess(response) && (this.isLoginResponse(response) || this.isLogoutResponse(response))) {
// It's a success Login/Logout response
let authRes: HttpResponse<any>; let authRes: HttpResponse<any>;
if (this.isLoginResponse(response.url)) { if (this.isLoginResponse(response)) {
const token = response.headers.get('authorization'); // login successfully
const expires = response.headers.get('expires'); const newToken = response.headers.get('authorization');
authRes = response.clone({body: this.makeAuthStatusObject(true, token)}); authRes = response.clone({body: this.makeAuthStatusObject(true, newToken)});
// clean eventually refresh Requests list
this.refreshTokenRequestUrls = [];
} else { } else {
// logout successfully
authRes = response.clone({body: this.makeAuthStatusObject(false)}); authRes = response.clone({body: this.makeAuthStatusObject(false)});
} }
return authRes; return authRes;
@@ -84,10 +117,12 @@ export class AuthInterceptor implements HttpInterceptor {
} }
}) })
.catch((error, caught) => { .catch((error, caught) => {
// Intercept an unauthorized error response // Intercept an error response
if (error instanceof HttpErrorResponse && this.isUnauthorized(error.status)) { if (error instanceof HttpErrorResponse) {
// Checks if is a response from a request to an authentication endpoint // Checks if is a response from a request to an authentication endpoint
if (this.isAuthRequest(error.url)) { if (this.isAuthRequest(error)) {
// clean eventually refresh Requests list
this.refreshTokenRequestUrls = [];
// Create a new HttpResponse and return it, so it can be handle properly by AuthService. // Create a new HttpResponse and return it, so it can be handle properly by AuthService.
const authResponse = new HttpResponse({ const authResponse = new HttpResponse({
body: this.makeAuthStatusObject(false, null, error.error), body: this.makeAuthStatusObject(false, null, error.error),
@@ -97,14 +132,15 @@ export class AuthInterceptor implements HttpInterceptor {
url: error.url url: error.url
}); });
return Observable.of(authResponse); return Observable.of(authResponse);
} else { } else if (this.isUnauthorized(error)) {
// The access token provided is expired, revoked, malformed, or invalid for other reasons
// Redirect to the login route // Redirect to the login route
this.store.dispatch(new RedirectWhenTokenExpiredAction('Your session has expired. Please log in again.')); this.store.dispatch(new RedirectWhenTokenExpiredAction('Your session has expired. Please log in again.'));
} }
} else { }
// Return error response as is. // Return error response as is.
return Observable.throw(error); return Observable.throw(error);
}
}) as any; }) as any;
} }
} }

View File

@@ -32,6 +32,9 @@ export interface AuthState {
// redirect url after login // redirect url after login
redirectUrl?: string; redirectUrl?: string;
// true when refreshing token
refreshing?: boolean;
// the authenticated user // the authenticated user
user?: Eperson; user?: Eperson;
} }
@@ -108,12 +111,14 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
}); });
case AuthActionTypes.LOG_OUT_SUCCESS: case AuthActionTypes.LOG_OUT_SUCCESS:
case AuthActionTypes.REFRESH_TOKEN_ERROR:
return Object.assign({}, state, { return Object.assign({}, state, {
authenticated: false, authenticated: false,
error: undefined, error: undefined,
loaded: false, loaded: false,
loading: false, loading: false,
info: undefined, info: undefined,
refreshing: false,
user: undefined user: undefined
}); });
@@ -138,6 +143,16 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
case AuthActionTypes.REGISTRATION_SUCCESS: case AuthActionTypes.REGISTRATION_SUCCESS:
return state; return state;
case AuthActionTypes.REFRESH_TOKEN:
return Object.assign({}, state, {
refreshing: true,
});
case AuthActionTypes.REFRESH_TOKEN_SUCCESS:
return Object.assign({}, state, {
refreshing: false,
});
case AuthActionTypes.RESET_MESSAGES: case AuthActionTypes.RESET_MESSAGES:
return Object.assign({}, state, { return Object.assign({}, state, {
authenticated: state.authenticated, authenticated: state.authenticated,
@@ -159,7 +174,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
/** /**
* Returns true if the user is authenticated. * Returns true if the user is authenticated.
* @function isAuthenticated * @function _isAuthenticated
* @param {State} state * @param {State} state
* @returns {boolean} * @returns {boolean}
*/ */
@@ -167,7 +182,7 @@ export const _isAuthenticated = (state: AuthState) => state.authenticated;
/** /**
* Returns true if the authenticated has loaded. * Returns true if the authenticated has loaded.
* @function isAuthenticatedLoaded * @function _isAuthenticatedLoaded
* @param {State} state * @param {State} state
* @returns {boolean} * @returns {boolean}
*/ */
@@ -175,7 +190,7 @@ export const _isAuthenticatedLoaded = (state: AuthState) => state.loaded;
/** /**
* Return the users state * Return the users state
* @function getAuthenticatedUser * @function _getAuthenticatedUser
* @param {State} state * @param {State} state
* @returns {User} * @returns {User}
*/ */
@@ -183,7 +198,7 @@ export const _getAuthenticatedUser = (state: AuthState) => state.user;
/** /**
* Returns the authentication error. * Returns the authentication error.
* @function getAuthenticationError * @function _getAuthenticationError
* @param {State} state * @param {State} state
* @returns {string} * @returns {string}
*/ */
@@ -191,7 +206,7 @@ export const _getAuthenticationError = (state: AuthState) => state.error;
/** /**
* Returns the authentication info message. * Returns the authentication info message.
* @function getAuthenticationInfo * @function _getAuthenticationInfo
* @param {State} state * @param {State} state
* @returns {string} * @returns {string}
*/ */
@@ -199,15 +214,23 @@ export const _getAuthenticationInfo = (state: AuthState) => state.info;
/** /**
* Returns true if request is in progress. * Returns true if request is in progress.
* @function isLoading * @function _isLoading
* @param {State} state * @param {State} state
* @returns {boolean} * @returns {boolean}
*/ */
export const _isLoading = (state: AuthState) => state.loading; export const _isLoading = (state: AuthState) => state.loading;
/**
* Returns true if a refresh token request is in progress.
* @function _isRefreshing
* @param {State} state
* @returns {boolean}
*/
export const _isRefreshing = (state: AuthState) => state.refreshing;
/** /**
* Returns the sign out error. * Returns the sign out error.
* @function getLogOutError * @function _getLogOutError
* @param {State} state * @param {State} state
* @returns {string} * @returns {string}
*/ */
@@ -215,7 +238,7 @@ export const _getLogOutError = (state: AuthState) => state.error;
/** /**
* Returns the sign up error. * Returns the sign up error.
* @function getRegistrationError * @function _getRegistrationError
* @param {State} state * @param {State} state
* @returns {string} * @returns {string}
*/ */
@@ -223,7 +246,7 @@ export const _getRegistrationError = (state: AuthState) => state.error;
/** /**
* Returns the redirect url. * Returns the redirect url.
* @function getRedirectUrl * @function _getRedirectUrl
* @param {State} state * @param {State} state
* @returns {string} * @returns {string}
*/ */

View File

@@ -11,12 +11,13 @@ import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model';
import { isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; import { isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util';
import { CookieService } from '../../shared/services/cookie.service'; import { CookieService } from '../../shared/services/cookie.service';
import { getRedirectUrl, isAuthenticated } from './selectors'; import { getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors';
import { AppState, routerStateSelector } from '../../app.reducer'; import { AppState, routerStateSelector } from '../../app.reducer';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions'; import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions';
import { RouterReducerState } from '@ngrx/router-store'; import { RouterReducerState } from '@ngrx/router-store';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { CookieAttributes } from 'js-cookie';
export const LOGIN_ROUTE = '/login'; export const LOGIN_ROUTE = '/login';
/** /**
@@ -110,11 +111,30 @@ export class AuthService {
} }
/** /**
* Checks if token is present into storage * Checks if token is present into storage and is not expired
*/ */
public checkAuthenticationToken(): Observable<AuthTokenInfo> { public checkAuthenticationToken(): Observable<AuthTokenInfo> {
const token = this.getToken(); const token = this.getToken();
return isNotEmpty(token) ? Observable.of(token) : Observable.throw(false); return isNotEmpty(token) && !this.isTokenExpired() ? Observable.of(token) : Observable.throw(false);
}
/**
* Checks if token is present into storage
*/
public refreshAuthenticationToken(token: AuthTokenInfo): Observable<AuthTokenInfo> {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Accept', 'application/json');
headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
options.headers = headers;
return this.authRequestService.postToEndpoint('login', {}, options)
.map((status: AuthStatus) => {
if (status.authenticated) {
return status.token;
} else {
throw(new Error('Not authenticated'));
}
});
} }
/** /**
@@ -160,8 +180,7 @@ export class AuthService {
* Retrieve authentication token info and make authorization header * Retrieve authentication token info and make authorization header
* @returns {string} * @returns {string}
*/ */
public getAuthHeader(): string { public buildAuthHeader(token): string {
const token = this.getToken();
return (this._authenticated && isNotNull(token)) ? `Bearer ${token.accessToken}` : ''; return (this._authenticated && isNotNull(token)) ? `Bearer ${token.accessToken}` : '';
} }
@@ -179,6 +198,31 @@ export class AuthService {
} }
} }
/**
* Check if a token is about to expire
* @returns {boolean}
*/
public isTokenExpiring(): Observable<boolean> {
return this.store.select(isTokenRefreshing)
.first()
.map((isRefreshing: boolean) => {
if (this.isTokenExpired() || isRefreshing) {
return false;
} else {
const token = this.getToken();
return token.expires - (60 * 5 * 1000) < Date.now();
}
})
}
/**
* Check if a token is expired
* @returns {boolean}
*/
public isTokenExpired(): boolean {
const token = this.getToken();
return token && token.expires < Date.now();
}
/** /**
* Save authentication token info * Save authentication token info
* *
@@ -186,7 +230,9 @@ export class AuthService {
* @returns {AuthTokenInfo} * @returns {AuthTokenInfo}
*/ */
public storeToken(token: AuthTokenInfo) { public storeToken(token: AuthTokenInfo) {
return this.storage.set(TOKENITEM, token); const expires = new Date(token.expires);
const options: CookieAttributes = { expires: expires };
return this.storage.set(TOKENITEM, token, options);
} }
/** /**
@@ -196,6 +242,14 @@ export class AuthService {
return this.storage.remove(TOKENITEM); return this.storage.remove(TOKENITEM);
} }
/**
* Replace authentication token info with a new one
*/
public replaceToken(token: AuthTokenInfo) {
this.removeToken();
return this.storeToken(token);
}
/** /**
* Redirect to the login route * Redirect to the login route
*/ */

View File

@@ -1,13 +1,15 @@
import { default as decode } from 'jwt-decode';
export const TOKENITEM = 'dsAuthInfo'; export const TOKENITEM = 'dsAuthInfo';
export class AuthTokenInfo { export class AuthTokenInfo {
public accessToken: string; public accessToken: string;
public expires?: number; public expires: number;
constructor(token: string, expiresIn?: number) { constructor(token: string) {
this.accessToken = token.replace('Bearer ', ''); this.accessToken = token.replace('Bearer ', '');
if (expiresIn) { const tokenClaims = decode(this.accessToken);
this.expires = expiresIn * 1000 + Date.now(); // exp claim is in seconds, convert it se to milliseconds
} this.expires = tokenClaims.exp * 1000;
} }
} }

View File

@@ -74,6 +74,15 @@ export const isAuthenticatedLoaded = createSelector(getAuthState, auth._isAuthen
*/ */
export const isAuthenticationLoading = createSelector(getAuthState, auth._isLoading); export const isAuthenticationLoading = createSelector(getAuthState, auth._isLoading);
/**
* Returns true if the refresh token request is loading.
* @function isTokenRefreshing
* @param {AuthState} state
* @param {any} props
* @return {boolean}
*/
export const isTokenRefreshing = createSelector(getAuthState, auth._isRefreshing);
/** /**
* Returns the log out error. * Returns the log out error.
* @function getLogOutError * @function getLogOutError

View File

@@ -77,23 +77,4 @@ export class AuthStatusResponse extends RestResponse {
} }
} }
export class AuthSuccessResponse extends RestResponse {
public toCache = false;
constructor(
public response: AuthTokenInfo,
public statusCode: string
) {
super(true, statusCode);
}
}
export class AuthErrorResponse extends RestResponse {
public toCache = false;
constructor(
public response: any,
public statusCode: string,
) {
super(true, statusCode);
}
}
/* tslint:enable:max-classes-per-file */ /* tslint:enable:max-classes-per-file */

View File

@@ -236,7 +236,7 @@ describe('RequestService', () => {
it('should dispatch the request', () => { it('should dispatch the request', () => {
service.configure(request); service.configure(request);
expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(request); expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(request, false);
}); });
}); });
describe('and it is already cached or pending', () => { describe('and it is already cached or pending', () => {
@@ -254,22 +254,22 @@ describe('RequestService', () => {
describe('when the request isn\'t a GET request', () => { describe('when the request isn\'t a GET request', () => {
it('should dispatch the request', () => { it('should dispatch the request', () => {
service.configure(testPostRequest); service.configure(testPostRequest);
expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPostRequest); expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPostRequest, false);
service.configure(testPutRequest); service.configure(testPutRequest);
expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPutRequest); expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPutRequest, false);
service.configure(testDeleteRequest); service.configure(testDeleteRequest);
expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testDeleteRequest); expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testDeleteRequest, false);
service.configure(testOptionsRequest); service.configure(testOptionsRequest);
expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testOptionsRequest); expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testOptionsRequest, false);
service.configure(testHeadRequest); service.configure(testHeadRequest);
expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testHeadRequest); expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testHeadRequest, false);
service.configure(testPatchRequest); service.configure(testPatchRequest);
expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPatchRequest); expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPatchRequest, false);
}); });
}); });
}); });

View File

@@ -102,7 +102,7 @@ export class RequestService {
return isCached || isPending; return isCached || isPending;
} }
private dispatchRequest(request: RestRequest, overrideRequest: boolean) { private dispatchRequest(request: RestRequest, overrideRequest: boolean = false) {
this.store.dispatch(new RequestConfigureAction(request)); this.store.dispatch(new RequestConfigureAction(request));
this.store.dispatch(new RequestExecuteAction(request.uuid)); this.store.dispatch(new RequestExecuteAction(request.uuid));
if (request.method === RestRequestMethod.Get && !overrideRequest) { if (request.method === RestRequestMethod.Get && !overrideRequest) {

View File

@@ -7,6 +7,6 @@ export interface DSpaceRESTV2Response {
_links?: any; _links?: any;
page?: any; page?: any;
}, },
headers: HttpHeaders, headers?: HttpHeaders,
statusCode: string statusCode: string
} }

View File

@@ -71,6 +71,7 @@ export class DSpaceRESTv2Service {
console.log(res); console.log(res);
return ({ payload: res.body, headers: res.headers, statusCode: res.statusText }) return ({ payload: res.body, headers: res.headers, statusCode: res.statusText })
}) })
.share()
.catch((err) => { .catch((err) => {
console.log('Error: ', err); console.log('Error: ', err);
return Observable.throw(err); return Observable.throw(err);

View File

@@ -9,6 +9,16 @@ import { Observable } from 'rxjs/Observable';
import { HeaderComponent } from './header.component'; import { HeaderComponent } from './header.component';
import { HeaderState } from './header.reducer'; import { HeaderState } from './header.reducer';
import { HeaderToggleAction } from './header.actions'; import { HeaderToggleAction } from './header.actions';
import { AuthNavMenuComponent } from '../shared/auth-nav-menu/auth-nav-menu.component';
import { LogInComponent } from '../shared/log-in/log-in.component';
import { LogOutComponent } from '../shared/log-out/log-out.component';
import { LoadingComponent } from '../shared/loading/loading.component';
import { ReactiveFormsModule } from '@angular/forms';
import { HostWindowService } from '../shared/host-window.service';
import { HostWindowServiceStub } from '../shared/testing/host-window-service-stub';
import { RouterStub } from '../shared/testing/router-stub';
import { Router } from '@angular/router';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
let comp: HeaderComponent; let comp: HeaderComponent;
let fixture: ComponentFixture<HeaderComponent>; let fixture: ComponentFixture<HeaderComponent>;
@@ -19,8 +29,17 @@ describe('HeaderComponent', () => {
// async beforeEach // async beforeEach
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [StoreModule.forRoot({}), TranslateModule.forRoot(), NgbCollapseModule.forRoot()], imports: [
declarations: [HeaderComponent] StoreModule.forRoot({}),
TranslateModule.forRoot(),
NgbCollapseModule.forRoot(),
NoopAnimationsModule,
ReactiveFormsModule],
declarations: [HeaderComponent, AuthNavMenuComponent, LoadingComponent, LogInComponent, LogOutComponent],
providers: [
{ provide: HostWindowService, useValue: new HostWindowServiceStub(800) },
{ provide: Router, useClass: RouterStub },
]
}) })
.compileComponents(); // compile template and css .compileComponents(); // compile template and css
})); }));

View File

@@ -1,4 +1,4 @@
<ds-loading *ngIf="(loading | async)"></ds-loading> <ds-loading *ngIf="(loading | async)" class="m-5"></ds-loading>
<form *ngIf="!(loading | async)" class="form-login px-4 py-3" (ngSubmit)="submit()" [formGroup]="form" novalidate> <form *ngIf="!(loading | async)" class="form-login px-4 py-3" (ngSubmit)="submit()" [formGroup]="form" novalidate>
<label for="inputEmail" class="sr-only">{{"login.form.email" | translate}}</label> <label for="inputEmail" class="sr-only">{{"login.form.email" | translate}}</label>
<input id="inputEmail" <input id="inputEmail"

View File

@@ -1,18 +1,13 @@
import { Component, OnDestroy, OnInit } from '@angular/core'; import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
// @ngrx
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
// rxjs
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/filter'; import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/takeWhile'; import 'rxjs/add/operator/takeWhile';
// actions
import { AuthenticateAction, ResetAuthenticationMessagesAction } from '../../core/auth/auth.actions'; import { AuthenticateAction, ResetAuthenticationMessagesAction } from '../../core/auth/auth.actions';
// reducers
import { import {
getAuthenticationError, getAuthenticationInfo, getAuthenticationError, getAuthenticationInfo,
isAuthenticated, isAuthenticated,

View File

@@ -16,4 +16,8 @@ export class HostWindowServiceStub {
isXs(): Observable<boolean> { isXs(): Observable<boolean> {
return Observable.of(this.width < 576); return Observable.of(this.width < 576);
} }
isMobileView(): Observable<boolean> {
return this.isXs();
}
} }

View File

@@ -0,0 +1,12 @@
// declare a stub service
export class PlatformServiceStub {
public get isBrowser(): boolean {
return true;
}
public get isServer(): boolean {
return false;
}
}

View File

@@ -193,7 +193,7 @@
version "2.8.4" version "2.8.4"
resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-2.8.4.tgz#5528fb5e53f1b27594f81f18debb7eab8dc532cb" resolved "https://registry.yarnpkg.com/@types/jasmine/-/jasmine-2.8.4.tgz#5528fb5e53f1b27594f81f18debb7eab8dc532cb"
"@types/js-cookie@^2.1.0": "@types/js-cookie@2.1.0":
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.1.0.tgz#a8916246aa994db646c66d54c854916213300a51" resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.1.0.tgz#a8916246aa994db646c66d54c854916213300a51"
@@ -4505,6 +4505,10 @@ jszip@^3.1.3:
pako "~1.0.2" pako "~1.0.2"
readable-stream "~2.0.6" readable-stream "~2.0.6"
jwt-decode@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79"
karma-chrome-launcher@2.2.0: karma-chrome-launcher@2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-2.2.0.tgz#cf1b9d07136cc18fe239327d24654c3dbc368acf" resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-2.2.0.tgz#cf1b9d07136cc18fe239327d24654c3dbc368acf"