diff --git a/package.json b/package.json index dc62aff03d..a2d1bd93c2 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,6 @@ "@nguniversal/express-engine": "5.0.0-beta.5", "@ngx-translate/core": "9.1.1", "@ngx-translate/http-loader": "2.0.1", - "@types/js-cookie": "^2.1.0", "angular-idle-preload": "2.0.4", "body-parser": "1.18.2", "bootstrap": "4.0.0-beta", @@ -102,6 +101,7 @@ "js-cookie": "2.2.0", "js.clone": "0.0.3", "jsonschema": "1.2.2", + "jwt-decode": "^2.2.0", "methods": "1.1.2", "morgan": "1.9.0", "ngx-pagination": "3.0.3", @@ -124,6 +124,7 @@ "@types/express-serve-static-core": "4.11.1", "@types/hammerjs": "2.0.35", "@types/jasmine": "2.8.4", + "@types/js-cookie": "2.1.0", "@types/memory-cache": "0.2.0", "@types/mime": "2.0.0", "@types/node": "^9.3.0", diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 327da7d9f6..de9b121bff 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -30,6 +30,8 @@ import { NativeWindowRef, NativeWindowService } from './shared/services/window.s import { MockTranslateLoader } from './shared/mocks/mock-translate-loader'; 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 fixture: ComponentFixture; @@ -56,6 +58,7 @@ describe('App component', () => { { provide: GLOBAL_CONFIG, useValue: ENV_CONFIG }, { provide: NativeWindowService, useValue: new NativeWindowRef() }, { provide: MetadataService, useValue: new MockMetadataService() }, + { provide: PlatformService, useValue: new PlatformServiceStub() }, AppComponent ], schemas: [CUSTOM_ELEMENTS_SCHEMA] diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index f54aa9f0be..6791100d40 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -8,7 +8,7 @@ import { Observable } from 'rxjs/Observable'; import { isNotEmpty } from '../../shared/empty.util'; import { AuthGetRequest, AuthPostRequest, PostRequest, RestRequest } from '../data/request.models'; 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'; @Injectable() @@ -37,8 +37,8 @@ export class AuthRequestService extends HALEndpointService { errorResponse.flatMap((response: ErrorResponse) => Observable.throw(new Error(response.errorMessage))), successResponse - .filter((response: AuthSuccessResponse) => isNotEmpty(response)) - .map((response: AuthSuccessResponse) => response.response) + .filter((response: AuthStatusResponse) => isNotEmpty(response)) + .map((response: AuthStatusResponse) => response.response) .distinctUntilChanged()); } diff --git a/src/app/core/auth/auth-response-parsing.service.ts b/src/app/core/auth/auth-response-parsing.service.ts index 0215335f03..80c1b2eeca 100644 --- a/src/app/core/auth/auth-response-parsing.service.ts +++ b/src/app/core/auth/auth-response-parsing.service.ts @@ -2,25 +2,15 @@ import { Inject, Injectable } from '@angular/core'; import { AuthObjectFactory } from './auth-object-factory'; import { BaseResponseParsingService } from '../data/base-response-parsing.service'; -import { - AuthErrorResponse, - AuthStatusResponse, - AuthSuccessResponse, ConfigSuccessResponse, ErrorResponse, - RestResponse -} from '../cache/response-cache.models'; +import { AuthStatusResponse, RestResponse } from '../cache/response-cache.models'; 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 { 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 { ResponseParsingService } from '../data/parsing.service'; import { RestRequest } from '../data/request.models'; 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'; @Injectable() @@ -29,18 +19,15 @@ export class AuthResponseParsingService extends BaseResponseParsingService imple protected objectFactory = AuthObjectFactory; protected toCache = false; - constructor( - @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, - protected objectCache: ObjectCacheService, - ) { super(); + constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService,) { + super(); } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '200' || data.statusCode === 'OK')) { - const response = this.process(data.payload, request.href); + const response = this.process(data.payload, request.href); 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 { return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode); } diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index aedda593c4..5680c0017b 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -19,6 +19,9 @@ export const AuthActionTypes = { CHECK_AUTHENTICATION_TOKEN_ERROR: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_ERROR'), REDIRECT_TOKEN_EXPIRED: type('dspace/auth/REDIRECT_TOKEN_EXPIRED'), 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'), LOG_OUT: type('dspace/auth/LOG_OUT'), 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. * @class RegistrationAction diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index b066d5ca92..225e40255b 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -18,7 +18,7 @@ import { AuthenticationErrorAction, AuthenticationSuccessAction, CheckAuthenticationTokenErrorAction, LogOutErrorAction, - LogOutSuccessAction, RegistrationAction, + LogOutSuccessAction, RefreshTokenAction, RefreshTokenErrorAction, RefreshTokenSuccessAction, RegistrationAction, RegistrationErrorAction, RegistrationSuccessAction } from './auth.actions'; @@ -41,11 +41,11 @@ export class AuthEffects { .ofType(AuthActionTypes.AUTHENTICATE) .switchMap((action: AuthenticateAction) => { return this.authService.authenticate(action.payload.email, action.payload.password) + .first() .map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)) .catch((error) => Observable.of(new AuthenticationErrorAction(error))); }); - // It means "reacts to this action but don't send another" @Effect() public authenticateSuccess: Observable = this.actions$ .ofType(AuthActionTypes.AUTHENTICATE_SUCCESS) @@ -61,6 +61,7 @@ export class AuthEffects { .catch((error) => Observable.of(new AuthenticatedErrorAction(error))); }); + // It means "reacts to this action but don't send another" @Effect({dispatch: false}) public authenticatedError: Observable = this.actions$ .ofType(AuthActionTypes.AUTHENTICATED_ERROR) @@ -85,6 +86,21 @@ export class AuthEffects { .catch((error) => Observable.of(new RegistrationErrorAction(error))); }); + @Effect() + public refreshToken: Observable = 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 = this.actions$ + .ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS) + .do((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)); + /** * When the store is rehydrated in the browser, * clear a possible invalid token or authentication errors diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index a45e5ef694..e5d7afc72d 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -1,40 +1,55 @@ import { Injectable, Injector } from '@angular/core'; import { HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse, - HttpErrorResponse + HttpErrorResponse, HttpResponseBase } from '@angular/common/http'; import { Observable } from 'rxjs/Rx'; import 'rxjs/add/observable/throw' import 'rxjs/add/operator/catch'; +import { find } from 'lodash'; + import { AppState } from '../../app.reducer'; -import { AuthError } from './models/auth-error.model'; import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { isNotEmpty } from '../../shared/empty.util'; -import { RedirectWhenTokenExpiredAction } from './auth.actions'; +import { isNotEmpty, isUndefined } from '../../shared/empty.util'; +import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions'; import { Store } from '@ngrx/store'; @Injectable() 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) { } - private isUnauthorized(status: number): boolean { - return status === 401 || status === 403; + private isUnauthorized(response: HttpResponseBase): boolean { + // invalid_token The access token provided is expired, revoked, malformed, or invalid for other reasons + return response.status === 401; } - private isAuthRequest(url: string): boolean { - return url.endsWith('/authn/login') || url.endsWith('/authn/logout') || url.endsWith('/authn/status'); + private isSuccess(response: HttpResponseBase): boolean { + return response.status === 200; } - private isLoginResponse(url: string): boolean { - return url.endsWith('/authn/login'); + private isAuthRequest(http: HttpRequest | HttpResponseBase): boolean { + return http.url + && (http.url.endsWith('/authn/login') + || http.url.endsWith('/authn/logout') + || http.url.endsWith('/authn/status')); } - private isLogoutResponse(url: string): boolean { - return url.endsWith('/authn/logout'); + private isLoginResponse(http: HttpRequest | HttpResponseBase): boolean { + return http.url && http.url.endsWith('/authn/login'); + } + + private isLogoutResponse(http: HttpRequest | HttpResponseBase): boolean { + return http.url && http.url.endsWith('/authn/logout'); } private makeAuthStatusObject(authenticated:boolean, accessToken?: string, error?: string): AuthStatus { @@ -55,27 +70,45 @@ export class AuthInterceptor implements HttpInterceptor { const authService = this.inj.get(AuthService); - // Get the auth header from the service. - const Authorization = authService.getAuthHeader(); + const token = authService.getToken(); - let authReq; - if (!this.isAuthRequest(req.url) && isNotEmpty(Authorization)) { + let newReq; + // 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. - authReq = req.clone({headers: req.headers.set('authorization', Authorization)}); + newReq = req.clone({headers: req.headers.set('authorization', Authorization)}); } else { - authReq = req; + newReq = req; } - // Pass on the cloned request instead of the original request. - return next.handle(authReq) + // Pass on the new request instead of the original request. + return next.handle(newReq) .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; - if (this.isLoginResponse(response.url)) { - const token = response.headers.get('authorization'); - const expires = response.headers.get('expires'); - authRes = response.clone({body: this.makeAuthStatusObject(true, token)}); + if (this.isLoginResponse(response)) { + // login successfully + const newToken = response.headers.get('authorization'); + authRes = response.clone({body: this.makeAuthStatusObject(true, newToken)}); + + // clean eventually refresh Requests list + this.refreshTokenRequestUrls = []; } else { + // logout successfully authRes = response.clone({body: this.makeAuthStatusObject(false)}); } return authRes; @@ -84,10 +117,12 @@ export class AuthInterceptor implements HttpInterceptor { } }) .catch((error, caught) => { - // Intercept an unauthorized error response - if (error instanceof HttpErrorResponse && this.isUnauthorized(error.status)) { + // Intercept an error response + if (error instanceof HttpErrorResponse) { // 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. const authResponse = new HttpResponse({ body: this.makeAuthStatusObject(false, null, error.error), @@ -97,14 +132,15 @@ export class AuthInterceptor implements HttpInterceptor { url: error.url }); 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 this.store.dispatch(new RedirectWhenTokenExpiredAction('Your session has expired. Please log in again.')); } - } else { - // Return error response as is. - return Observable.throw(error); } + // Return error response as is. + return Observable.throw(error); }) as any; + } } diff --git a/src/app/core/auth/auth.reducers.ts b/src/app/core/auth/auth.reducers.ts index d56f0888fa..b752b8f77f 100644 --- a/src/app/core/auth/auth.reducers.ts +++ b/src/app/core/auth/auth.reducers.ts @@ -32,6 +32,9 @@ export interface AuthState { // redirect url after login redirectUrl?: string; + // true when refreshing token + refreshing?: boolean; + // the authenticated user user?: Eperson; } @@ -108,12 +111,14 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.LOG_OUT_SUCCESS: + case AuthActionTypes.REFRESH_TOKEN_ERROR: return Object.assign({}, state, { authenticated: false, error: undefined, loaded: false, loading: false, info: undefined, + refreshing: false, user: undefined }); @@ -138,6 +143,16 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.REGISTRATION_SUCCESS: 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: return Object.assign({}, state, { authenticated: state.authenticated, @@ -159,7 +174,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut /** * Returns true if the user is authenticated. - * @function isAuthenticated + * @function _isAuthenticated * @param {State} state * @returns {boolean} */ @@ -167,7 +182,7 @@ export const _isAuthenticated = (state: AuthState) => state.authenticated; /** * Returns true if the authenticated has loaded. - * @function isAuthenticatedLoaded + * @function _isAuthenticatedLoaded * @param {State} state * @returns {boolean} */ @@ -175,7 +190,7 @@ export const _isAuthenticatedLoaded = (state: AuthState) => state.loaded; /** * Return the users state - * @function getAuthenticatedUser + * @function _getAuthenticatedUser * @param {State} state * @returns {User} */ @@ -183,7 +198,7 @@ export const _getAuthenticatedUser = (state: AuthState) => state.user; /** * Returns the authentication error. - * @function getAuthenticationError + * @function _getAuthenticationError * @param {State} state * @returns {string} */ @@ -191,7 +206,7 @@ export const _getAuthenticationError = (state: AuthState) => state.error; /** * Returns the authentication info message. - * @function getAuthenticationInfo + * @function _getAuthenticationInfo * @param {State} state * @returns {string} */ @@ -199,15 +214,23 @@ export const _getAuthenticationInfo = (state: AuthState) => state.info; /** * Returns true if request is in progress. - * @function isLoading + * @function _isLoading * @param {State} state * @returns {boolean} */ 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. - * @function getLogOutError + * @function _getLogOutError * @param {State} state * @returns {string} */ @@ -215,7 +238,7 @@ export const _getLogOutError = (state: AuthState) => state.error; /** * Returns the sign up error. - * @function getRegistrationError + * @function _getRegistrationError * @param {State} state * @returns {string} */ @@ -223,7 +246,7 @@ export const _getRegistrationError = (state: AuthState) => state.error; /** * Returns the redirect url. - * @function getRedirectUrl + * @function _getRedirectUrl * @param {State} state * @returns {string} */ diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 26a7ff290f..4978212005 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -11,12 +11,13 @@ import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; import { isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; 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 { Store } from '@ngrx/store'; import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions'; import { RouterReducerState } from '@ngrx/router-store'; import { Router } from '@angular/router'; +import { CookieAttributes } from 'js-cookie'; 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 { 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 { + 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 * @returns {string} */ - public getAuthHeader(): string { - const token = this.getToken(); + public buildAuthHeader(token): string { 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 { + 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 * @@ -186,7 +230,9 @@ export class AuthService { * @returns {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); } + /** + * Replace authentication token info with a new one + */ + public replaceToken(token: AuthTokenInfo) { + this.removeToken(); + return this.storeToken(token); + } + /** * Redirect to the login route */ diff --git a/src/app/core/auth/models/auth-token-info.model.ts b/src/app/core/auth/models/auth-token-info.model.ts index e6f6afc872..7eb125a947 100644 --- a/src/app/core/auth/models/auth-token-info.model.ts +++ b/src/app/core/auth/models/auth-token-info.model.ts @@ -1,13 +1,15 @@ +import { default as decode } from 'jwt-decode'; + export const TOKENITEM = 'dsAuthInfo'; export class AuthTokenInfo { public accessToken: string; - public expires?: number; + public expires: number; - constructor(token: string, expiresIn?: number) { + constructor(token: string) { this.accessToken = token.replace('Bearer ', ''); - if (expiresIn) { - this.expires = expiresIn * 1000 + Date.now(); - } + const tokenClaims = decode(this.accessToken); + // exp claim is in seconds, convert it se to milliseconds + this.expires = tokenClaims.exp * 1000; } } diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index 6853a32b6f..a37f128c88 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -74,6 +74,15 @@ export const isAuthenticatedLoaded = createSelector(getAuthState, auth._isAuthen */ 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. * @function getLogOutError diff --git a/src/app/core/cache/response-cache.models.ts b/src/app/core/cache/response-cache.models.ts index fd41c2258b..f77168c80e 100644 --- a/src/app/core/cache/response-cache.models.ts +++ b/src/app/core/cache/response-cache.models.ts @@ -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 */ diff --git a/src/app/core/data/request.service.spec.ts b/src/app/core/data/request.service.spec.ts index 17d4a89d05..9f82f32c4e 100644 --- a/src/app/core/data/request.service.spec.ts +++ b/src/app/core/data/request.service.spec.ts @@ -236,7 +236,7 @@ describe('RequestService', () => { it('should dispatch the request', () => { service.configure(request); - expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(request); + expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(request, false); }); }); describe('and it is already cached or pending', () => { @@ -254,22 +254,22 @@ describe('RequestService', () => { describe('when the request isn\'t a GET request', () => { it('should dispatch the request', () => { service.configure(testPostRequest); - expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPostRequest); + expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPostRequest, false); service.configure(testPutRequest); - expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPutRequest); + expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPutRequest, false); service.configure(testDeleteRequest); - expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testDeleteRequest); + expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testDeleteRequest, false); service.configure(testOptionsRequest); - expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testOptionsRequest); + expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testOptionsRequest, false); service.configure(testHeadRequest); - expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testHeadRequest); + expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testHeadRequest, false); service.configure(testPatchRequest); - expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPatchRequest); + expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(testPatchRequest, false); }); }); }); diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 6d4393bade..7952c736d3 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -102,7 +102,7 @@ export class RequestService { 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 RequestExecuteAction(request.uuid)); if (request.method === RestRequestMethod.Get && !overrideRequest) { diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts index fe911e5635..17fb389707 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2-response.model.ts @@ -7,6 +7,6 @@ export interface DSpaceRESTV2Response { _links?: any; page?: any; }, - headers: HttpHeaders, + headers?: HttpHeaders, statusCode: string } diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts index 51efecf625..b57f17a76e 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -71,6 +71,7 @@ export class DSpaceRESTv2Service { console.log(res); return ({ payload: res.body, headers: res.headers, statusCode: res.statusText }) }) + .share() .catch((err) => { console.log('Error: ', err); return Observable.throw(err); diff --git a/src/app/header/header.component.spec.ts b/src/app/header/header.component.spec.ts index 7c1a9601ac..87fa2995d6 100644 --- a/src/app/header/header.component.spec.ts +++ b/src/app/header/header.component.spec.ts @@ -9,6 +9,16 @@ import { Observable } from 'rxjs/Observable'; import { HeaderComponent } from './header.component'; import { HeaderState } from './header.reducer'; 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 fixture: ComponentFixture; @@ -19,8 +29,17 @@ describe('HeaderComponent', () => { // async beforeEach beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [StoreModule.forRoot({}), TranslateModule.forRoot(), NgbCollapseModule.forRoot()], - declarations: [HeaderComponent] + imports: [ + 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 })); diff --git a/src/app/shared/log-in/log-in.component.html b/src/app/shared/log-in/log-in.component.html index 8c7d91a217..d8d91ca271 100644 --- a/src/app/shared/log-in/log-in.component.html +++ b/src/app/shared/log-in/log-in.component.html @@ -1,4 +1,4 @@ - +