diff --git a/src/app/app.reducer.ts b/src/app/app.reducer.ts index e69f29ae52..2b37ea6a79 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -28,3 +28,5 @@ export const appReducers: ActionReducerMap = { searchSidebar: sidebarReducer, searchFilter: filterReducer }; + +export const routerStateSelector = (state: AppState) => state.router; diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 0528607d55..b9a71b71a1 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -17,6 +17,7 @@ export const AuthActionTypes = { AUTHENTICATED_SUCCESS: type('dspace/auth/AUTHENTICATED_SUCCESS'), CHECK_AUTHENTICATION_TOKEN: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN'), CHECK_AUTHENTICATION_TOKEN_ERROR: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_ERROR'), + REDIRECT: type('dspace/auth/REDIRECT'), RESET_ERROR: type('dspace/auth/RESET_ERROR'), LOG_OUT: type('dspace/auth/LOG_OUT'), LOG_OUT_ERROR: type('dspace/auth/LOG_OUT_ERROR'), @@ -136,15 +137,6 @@ export class CheckAuthenticationTokenErrorAction implements Action { public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR; } -/** - * Reset error. - * @class ResetAuthenticationErrorAction - * @implements {Action} - */ -export class ResetAuthenticationErrorAction implements Action { - public type: string = AuthActionTypes.RESET_ERROR; -} - /** * Sign out. * @class LogOutAction @@ -179,6 +171,20 @@ export class LogOutSuccessAction implements Action { constructor(public payload?: any) {} } +/** + * Redirect to login page when token is expired. + * @class RedirectWhenTokenExpiredAction + * @implements {Action} + */ +export class RedirectWhenTokenExpiredAction implements Action { + public type: string = AuthActionTypes.REDIRECT; + payload: string; + + constructor(message: string) { + this.payload = message ; + } +} + /** * Sign up. * @class RegistrationAction @@ -221,6 +227,15 @@ export class RegistrationSuccessAction implements Action { } } +/** + * Reset error. + * @class ResetAuthenticationErrorAction + * @implements {Action} + */ +export class ResetAuthenticationErrorAction implements Action { + public type: string = AuthActionTypes.RESET_ERROR; +} + /* tslint:enable:max-classes-per-file */ /** @@ -237,6 +252,7 @@ export type AuthActions | AuthenticationSuccessAction | CheckAuthenticationTokenAction | CheckAuthenticationTokenErrorAction + | RedirectWhenTokenExpiredAction | RegistrationAction | RegistrationErrorAction | RegistrationSuccessAction; diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 3d1bd43854..2343862cbb 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -16,7 +16,7 @@ import { AuthenticatedErrorAction, AuthenticatedSuccessAction, AuthenticationErrorAction, - AuthenticationSuccessAction, CheckAuthenticationTokenAction, CheckAuthenticationTokenErrorAction, LogOutAction, + AuthenticationSuccessAction, CheckAuthenticationTokenErrorAction, LogOutErrorAction, LogOutSuccessAction, RegistrationAction, RegistrationErrorAction, @@ -87,7 +87,7 @@ export class AuthEffects { /** * When the store is rehydrated in the browser, - * clear a possible invalid token + * clear a possible invalid token or authentication errors */ @Effect({dispatch: false}) public clearInvalidTokenOnRehydrate = this.actions$ @@ -96,7 +96,8 @@ export class AuthEffects { return this.store.select(isAuthenticated) .take(1) .filter((authenticated) => !authenticated) - .do(() => this.authService.removeToken()); + .do(() => this.authService.removeToken()) + .do(() => this.authService.resetAuthenticationError()); }); @Effect() @@ -113,10 +114,16 @@ export class AuthEffects { .ofType(AuthActionTypes.LOG_OUT_SUCCESS) .do((action: LogOutSuccessAction) => this.authService.removeToken()); + @Effect({dispatch: false}) + public redirectToLogin: Observable = this.actions$ + .ofType(AuthActionTypes.REDIRECT) + .do(() => this.authService.redirectToLogin()); + /** * @constructor * @param {Actions} actions$ * @param {AuthService} authService + * @param {Store} store */ constructor(private actions$: Actions, private authService: AuthService, diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index e8406566bc..49a1213636 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -14,10 +14,13 @@ import { AuthType } from './auth-type'; import { ResourceType } from '../shared/resource-type'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { isNotEmpty } from '../../shared/empty.util'; +import { AppState } from '../../app.reducer'; +import { RedirectWhenTokenExpiredAction } from './auth.actions'; +import { Store } from '@ngrx/store'; @Injectable() export class AuthInterceptor implements HttpInterceptor { - constructor(private inj: Injector, private router: Router) { } + constructor(private inj: Injector, private store: Store) { } private isUnauthorized(status: number): boolean { return status === 401 || status === 403; @@ -84,15 +87,21 @@ export class AuthInterceptor implements HttpInterceptor { .catch((error, caught) => { // Intercept an unauthorized error response if (error instanceof HttpErrorResponse && this.isUnauthorized(error.status)) { - // 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), - headers: error.headers, - status: error.status, - statusText: error.statusText, - url: error.url - }); - return Observable.of(authResponse); + // Checks if is a response from a request to an authentication endpoint + if (this.isAuthRequest(error.url)) { + // 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), + headers: error.headers, + status: error.status, + statusText: error.statusText, + url: error.url + }); + return Observable.of(authResponse); + } else { + // 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); diff --git a/src/app/core/auth/auth.reducers.ts b/src/app/core/auth/auth.reducers.ts index 31079767e5..1aa800daed 100644 --- a/src/app/core/auth/auth.reducers.ts +++ b/src/app/core/auth/auth.reducers.ts @@ -1,7 +1,7 @@ // import actions import { AuthActions, AuthActionTypes, AuthenticatedSuccessAction, AuthenticationErrorAction, - AuthenticationSuccessAction, LogOutErrorAction + AuthenticationSuccessAction, LogOutErrorAction, RedirectWhenTokenExpiredAction } from './auth.actions'; // import models @@ -25,6 +25,9 @@ export interface AuthState { // true when loading loading: boolean; + // info message + message?: string; + // the authenticated user user?: Eperson; } @@ -33,7 +36,7 @@ export interface AuthState { * The initial state. */ const initialState: AuthState = { - authenticated: null, + authenticated: false, loaded: false, loading: false }; @@ -50,7 +53,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.AUTHENTICATE: return Object.assign({}, state, { error: undefined, - loading: true + loading: true, + message: undefined }); case AuthActionTypes.AUTHENTICATED_ERROR: @@ -67,6 +71,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loaded: true, error: undefined, loading: false, + message: undefined, user: (action as AuthenticatedSuccessAction).payload.user }); @@ -96,10 +101,11 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.RESET_ERROR: return Object.assign({}, state, { - authenticated: null, + authenticated: false, error: undefined, loaded: false, loading: false, + message: undefined, }); case AuthActionTypes.LOG_OUT_ERROR: @@ -115,6 +121,16 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut error: undefined, loaded: false, loading: false, + message: undefined, + user: undefined + }); + + case AuthActionTypes.REDIRECT: + return Object.assign({}, state, { + authenticated: false, + loaded: false, + loading: false, + message: (action as RedirectWhenTokenExpiredAction).payload, user: undefined }); @@ -122,7 +138,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { authenticated: false, error: undefined, - loading: true + loading: true, + message: undefined }); default: @@ -158,10 +175,18 @@ export const getAuthenticatedUser = (state: AuthState) => state.user; * Returns the authentication error. * @function getAuthenticationError * @param {State} state - * @returns {Error} + * @returns {String} */ export const getAuthenticationError = (state: AuthState) => state.error; +/** + * Returns the authentication info message. + * @function getAuthenticationError + * @param {State} state + * @returns {String} + */ +export const getAuthenticationMessage = (state: AuthState) => state.message; + /** * Returns true if request is in progress. * @function isLoading diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 62f5aa5ba7..3b76c0c1a6 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -8,9 +8,16 @@ import { HttpHeaders } from '@angular/common/http'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; -import { isNotEmpty, isNotNull } from '../../shared/empty.util'; +import { isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; import { CookieService } from '../../shared/services/cookie.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { isAuthenticated } from './selectors'; +import { AppState, routerStateSelector } from '../../app.reducer'; +import { Store } from '@ngrx/store'; +import { ResetAuthenticationErrorAction } from './auth.actions'; +import { RouterReducerState } from '@ngrx/router-store'; +export const LOGIN_ROUTE = '/login'; /** * The auth service. */ @@ -21,7 +28,7 @@ export class AuthService { * True if authenticated * @type boolean */ - private _authenticated = false; + private _authenticated: boolean; /** * The url to redirect after login @@ -29,7 +36,27 @@ export class AuthService { */ private _redirectUrl: string; - constructor(private authRequestService: AuthRequestService, private storage: CookieService) { + constructor(private route: ActivatedRoute, + private authRequestService: AuthRequestService, + private router: Router, + private storage: CookieService, + private store: Store) { + this.store.select(isAuthenticated) + .startWith(false) + .subscribe((authenticated: boolean) => this._authenticated = authenticated); + + // If current route is different from the one setted in authentication guard + // and is not the login route, clear it + this.store.select(routerStateSelector) + .filter((routerState: RouterReducerState) => isNotUndefined(routerState)) + .filter((routerState: RouterReducerState) => + (routerState.state.url !== LOGIN_ROUTE) + && isNotEmpty(this._redirectUrl) + && (routerState.state.url !== this._redirectUrl)) + .distinctUntilChanged() + .subscribe((routerState: RouterReducerState) => { + this._redirectUrl = ''; + }) } /** @@ -40,18 +67,12 @@ export class AuthService { * @returns {Observable} The authenticated user observable. */ public authenticate(user: string, password: string): Observable { - // Normally you would do an HTTP request to determine to - // attempt authenticating the user using the supplied credentials. - // const body = `user=${user}&password=${password}`; - // const body = encodeURI('password=test&user=vera.aloe@mailinator.com'); - // const body = [{user}, {password}]; - // const body = encodeURI('password=' + password.toString() + '&user=' + user.toString()); + // Attempt authenticating the user using the supplied credentials. const body = encodeURI(`password=${password}&user=${user}`); const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); options.headers = headers; - // options.responseType = 'text'; return this.authRequestService.postToEndpoint('login', body, options) .map((status: AuthStatus) => { if (status.authenticated) { @@ -67,8 +88,8 @@ export class AuthService { * Determines if the user is authenticated * @returns {Observable} */ - public authenticated(): Observable { - return Observable.of(this._authenticated); + public isAuthenticated(): Observable { + return this.store.select(isAuthenticated); } /** @@ -85,10 +106,8 @@ export class AuthService { return this.authRequestService.getRequest('status', options) .map((status: AuthStatus) => { if (status.authenticated) { - this._authenticated = true; return status.eperson[0]; } else { - this._authenticated = false; throw(new Error('Not authenticated')); } }); @@ -102,6 +121,13 @@ export class AuthService { return isNotEmpty(token) ? Observable.of(token) : Observable.throw(false); } + /** + * Clear authentication errors + */ + public resetAuthenticationError(): void { + this.store.dispatch(new ResetAuthenticationErrorAction()); + } + /** * Create a new user * @returns {User} @@ -119,15 +145,13 @@ export class AuthService { * @returns {Observable} */ public logout(): Observable { - // Normally you would do an HTTP request sign end the session - // but, let's just return an observable of true. + // Send a request that sign end the session let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); const options: HttpOptions = Object.create({headers, responseType: 'text'}); return this.authRequestService.getRequest('logout', options) .map((status: AuthStatus) => { if (!status.authenticated) { - this._authenticated = false; return true; } else { throw(new Error('Invalid email or password')); @@ -136,32 +160,77 @@ export class AuthService { } + /** + * Retrieve authentication token info and make authorization header + * @returns {string} + */ public getAuthHeader(): string { - // Retrieve authentication token info - const token = this.storage.get(TOKENITEM); - return (isNotNull(token) && this._authenticated) ? `Bearer ${token.accessToken}` : ''; + const token = this.getToken(); + return (this._authenticated && isNotNull(token)) ? `Bearer ${token.accessToken}` : ''; } + /** + * Get authentication token info + * @returns {AuthTokenInfo} + */ public getToken(): AuthTokenInfo { - // Retrieve authentication token info - return this.storage.get(TOKENITEM); + // Retrieve authentication token info and check if is valid + const token = this.storage.get(TOKENITEM); + if (isNotEmpty(token) && token.hasOwnProperty('accessToken') && isNotEmpty(token.accessToken)) { + return token; + } else { + return null; + } } + /** + * Save authentication token info + * + * @param {AuthTokenInfo} token The token to save + * @returns {AuthTokenInfo} + */ public storeToken(token: AuthTokenInfo) { - // Save authentication token info return this.storage.set(TOKENITEM, token); } + /** + * Remove authentication token info + */ public removeToken() { - // Remove authentication token info - console.log('REMOVE!!!!'); return this.storage.remove(TOKENITEM); } + /** + * Redirect to the login route + */ + public redirectToLogin() { + this.router.navigate(['/login']); + } + + /** + * Redirect to the route navigated before the login + */ + public redirectToPreviousUrl() { + if (isNotEmpty(this._redirectUrl)) { + const url = this._redirectUrl; + // Clear url + this._redirectUrl = null; + this.router.navigate([url]); + } else { + this.router.navigate(['/']); + } + } + + /** + * Get redirect url + */ get redirectUrl(): string { return this._redirectUrl; } + /** + * Set redirect url + */ set redirectUrl(value: string) { this._redirectUrl = value; } diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index cd39981a1f..29db8bc46d 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -6,7 +6,7 @@ import { Store } from '@ngrx/store'; // reducers import { CoreState } from '../core.reducers'; -import { isAuthenticated } from './selectors'; +import { isAuthenticated, isAuthenticationLoading } from './selectors'; import { AuthService } from './auth.service'; /** @@ -50,6 +50,7 @@ export class AuthenticatedGuard implements CanActivate, CanLoad { } private handleAuth(url: string): Observable { + console.log('handleAuth', url) // get observable const observable = this.store.select(isAuthenticated); @@ -57,7 +58,7 @@ export class AuthenticatedGuard implements CanActivate, CanLoad { observable.subscribe((authenticated) => { if (!authenticated) { this.authService.redirectUrl = url; - this.router.navigate(['/login']); + this.authService.redirectToLogin(); } }); diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index a260f84762..5cf4da58dc 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -38,6 +38,15 @@ export const getAuthenticatedUser = createSelector(getAuthState, auth.getAuthent */ export const getAuthenticationError = createSelector(getAuthState, auth.getAuthenticationError); +/** + * Returns the authentication info message. + * @function getAuthenticationError + * @param {AuthState} state + * @param {any} props + * @return {Error} + */ +export const getAuthenticationMessage = createSelector(getAuthState, auth.getAuthenticationMessage); + /** * Returns true if the user is authenticated * @function isAuthenticated diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html index 34db6a2130..e66e75be4c 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html @@ -2,7 +2,7 @@