mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-08 02:24:11 +00:00
Added refresh token functionality
This commit is contained in:
@@ -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",
|
||||
|
@@ -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<AppComponent>;
|
||||
@@ -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]
|
||||
|
@@ -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());
|
||||
}
|
||||
|
||||
|
@@ -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<AuthStatus, AuthType>(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);
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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<Action> = 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<Action> = this.actions$
|
||||
.ofType(AuthActionTypes.AUTHENTICATED_ERROR)
|
||||
@@ -85,6 +86,21 @@ export class AuthEffects {
|
||||
.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,
|
||||
* clear a possible invalid token or authentication errors
|
||||
|
@@ -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<AppState>) { }
|
||||
|
||||
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<any> | 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<any> | HttpResponseBase): boolean {
|
||||
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 {
|
||||
@@ -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<any>;
|
||||
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);
|
||||
}
|
||||
}) as any;
|
||||
|
||||
}
|
||||
}
|
||||
|
@@ -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}
|
||||
*/
|
||||
|
@@ -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<AuthTokenInfo> {
|
||||
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
|
||||
* @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<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
|
||||
*
|
||||
@@ -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
|
||||
*/
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
19
src/app/core/cache/response-cache.models.ts
vendored
19
src/app/core/cache/response-cache.models.ts
vendored
@@ -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 */
|
||||
|
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -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) {
|
||||
|
@@ -7,6 +7,6 @@ export interface DSpaceRESTV2Response {
|
||||
_links?: any;
|
||||
page?: any;
|
||||
},
|
||||
headers: HttpHeaders,
|
||||
headers?: HttpHeaders,
|
||||
statusCode: string
|
||||
}
|
||||
|
@@ -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);
|
||||
|
@@ -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<HeaderComponent>;
|
||||
@@ -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
|
||||
}));
|
||||
|
@@ -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>
|
||||
<label for="inputEmail" class="sr-only">{{"login.form.email" | translate}}</label>
|
||||
<input id="inputEmail"
|
||||
|
@@ -1,18 +1,13 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
|
||||
// @ngrx
|
||||
import { Store } from '@ngrx/store';
|
||||
|
||||
// rxjs
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import 'rxjs/add/operator/filter';
|
||||
import 'rxjs/add/operator/takeWhile';
|
||||
|
||||
// actions
|
||||
import { AuthenticateAction, ResetAuthenticationMessagesAction } from '../../core/auth/auth.actions';
|
||||
|
||||
// reducers
|
||||
import {
|
||||
getAuthenticationError, getAuthenticationInfo,
|
||||
isAuthenticated,
|
||||
|
@@ -16,4 +16,8 @@ export class HostWindowServiceStub {
|
||||
isXs(): Observable<boolean> {
|
||||
return Observable.of(this.width < 576);
|
||||
}
|
||||
|
||||
isMobileView(): Observable<boolean> {
|
||||
return this.isXs();
|
||||
}
|
||||
}
|
||||
|
12
src/app/shared/testing/platform-service-stub.ts
Normal file
12
src/app/shared/testing/platform-service-stub.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
|
||||
// declare a stub service
|
||||
export class PlatformServiceStub {
|
||||
|
||||
public get isBrowser(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
public get isServer(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
@@ -193,7 +193,7 @@
|
||||
version "2.8.4"
|
||||
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"
|
||||
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"
|
||||
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:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-2.2.0.tgz#cf1b9d07136cc18fe239327d24654c3dbc368acf"
|
||||
|
Reference in New Issue
Block a user