From 2f19f32d91b118abf0083d358960b5c0245d5491 Mon Sep 17 00:00:00 2001 From: Giuseppe Digilio <giuseppe.digilio@4science.it> Date: Fri, 9 Feb 2018 09:55:55 +0100 Subject: [PATCH] Improvement for authentication module --- src/app/+home-page/home-page.component.ts | 4 +- src/app/core/auth/auth-object-factory.ts | 14 ++- src/app/core/auth/auth-request.service.ts | 23 +++- .../auth/auth-response-parsing.service.ts | 28 ++--- src/app/core/auth/auth-storage.service.ts | 34 ++++++ src/app/core/auth/auth-type.ts | 1 + src/app/core/auth/auth.actions.ts | 11 +- src/app/core/auth/auth.effects.ts | 37 +++--- src/app/core/auth/auth.interceptor.ts | 97 ++++++++++++++++ src/app/core/auth/auth.reducers.ts | 31 ++++-- src/app/core/auth/auth.service.ts | 105 ++++++++++++++---- src/app/core/auth/authenticated.guard.ts | 32 ++++-- src/app/core/auth/models/auth-error.model.ts | 7 ++ src/app/core/auth/models/auth-info.model.ts | 5 - src/app/core/auth/models/auth-status.model.ts | 8 ++ .../core/auth/models/auth-token-info.model.ts | 11 ++ .../models/normalized-auth-status.model.ts | 26 +++++ src/app/core/cache/response-cache.models.ts | 30 ++++- src/app/core/core.module.ts | 14 ++- src/app/core/data/request.models.ts | 4 +- src/app/core/data/request.service.ts | 11 +- .../dspace-rest-v2-response.model.ts | 3 + .../dspace-rest-v2/dspace-rest-v2.service.ts | 17 +-- 23 files changed, 430 insertions(+), 123 deletions(-) create mode 100644 src/app/core/auth/auth-storage.service.ts create mode 100644 src/app/core/auth/auth.interceptor.ts create mode 100644 src/app/core/auth/models/auth-error.model.ts delete mode 100644 src/app/core/auth/models/auth-info.model.ts create mode 100644 src/app/core/auth/models/auth-token-info.model.ts create mode 100644 src/app/core/auth/models/normalized-auth-status.model.ts diff --git a/src/app/+home-page/home-page.component.ts b/src/app/+home-page/home-page.component.ts index 7736d874d9..edaa032d42 100644 --- a/src/app/+home-page/home-page.component.ts +++ b/src/app/+home-page/home-page.component.ts @@ -12,7 +12,9 @@ import { Store } from '@ngrx/store'; export class HomePageComponent implements OnInit { public isAuthenticated: Observable<boolean>; - constructor(private store: Store<AppState>) {} + constructor(private store: Store<AppState>) { + } + ngOnInit() { // set loading this.isAuthenticated = this.store.select(isAuthenticated); diff --git a/src/app/core/auth/auth-object-factory.ts b/src/app/core/auth/auth-object-factory.ts index e1960e8111..c3e70eaaac 100644 --- a/src/app/core/auth/auth-object-factory.ts +++ b/src/app/core/auth/auth-object-factory.ts @@ -1,14 +1,18 @@ - import { AuthType } from './auth-type'; -import { AuthStatus } from './models/auth-status.model'; import { GenericConstructor } from '../shared/generic-constructor'; -import { DSpaceObject } from '../shared/dspace-object.model'; +import { NormalizedAuthStatus } from './models/normalized-auth-status.model'; +import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; +import { NormalizedEpersonModel } from '../eperson/models/NormalizedEperson.model'; export class AuthObjectFactory { - public static getConstructor(type): GenericConstructor<DSpaceObject> { + public static getConstructor(type): GenericConstructor<NormalizedDSpaceObject> { switch (type) { + case AuthType.Eperson: { + return NormalizedEpersonModel + } + case AuthType.Status: { - return AuthStatus + return NormalizedAuthStatus } default: { diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 24bdc16135..6202b6784d 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -6,7 +6,7 @@ import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; import { Observable } from 'rxjs/Observable'; import { isNotEmpty } from '../../shared/empty.util'; -import { AuthPostRequest, PostRequest, RestRequest } from '../data/request.models'; +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 { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; @@ -28,16 +28,16 @@ export class AuthRequestService extends HALEndpointService { super(); } - protected submitRequest(request: RestRequest): Observable<any> { + protected fetchRequest(request: RestRequest): Observable<any> { const [successResponse, errorResponse] = this.responseCache.get(request.href) .map((entry: ResponseCacheEntry) => entry.response) .partition((response: RestResponse) => response.isSuccessful); return Observable.merge( errorResponse.flatMap((response: ErrorResponse) => - Observable.throw(new Error(`Couldn't send data to server`))), + Observable.throw(new Error(response.errorMessage))), successResponse .filter((response: AuthSuccessResponse) => isNotEmpty(response)) - .map((response: AuthSuccessResponse) => response.authResponse) + .map((response: AuthSuccessResponse) => response.response) .distinctUntilChanged()); } @@ -51,8 +51,19 @@ export class AuthRequestService extends HALEndpointService { .map((endpointURL) => this.getEndpointByMethod(endpointURL, method)) .distinctUntilChanged() .map((endpointURL: string) => new AuthPostRequest(this.requestService.generateRequestId(), endpointURL, body, options)) - .do((request: PostRequest) => this.requestService.configure(request)) - .flatMap((request: PostRequest) => this.submitRequest(request)) + .do((request: PostRequest) => this.requestService.configure(request, true)) + .flatMap((request: PostRequest) => this.fetchRequest(request)) + .distinctUntilChanged(); + } + + public getRequest(method: string, options?: HttpOptions): Observable<any> { + return this.getEndpoint() + .filter((href: string) => isNotEmpty(href)) + .map((endpointURL) => this.getEndpointByMethod(endpointURL, method)) + .distinctUntilChanged() + .map((endpointURL: string) => new AuthGetRequest(this.requestService.generateRequestId(), endpointURL, options)) + .do((request: PostRequest) => this.requestService.configure(request, true)) + .flatMap((request: PostRequest) => this.fetchRequest(request)) .distinctUntilChanged(); } } diff --git a/src/app/core/auth/auth-response-parsing.service.ts b/src/app/core/auth/auth-response-parsing.service.ts index 21a4ae94eb..1075cfe059 100644 --- a/src/app/core/auth/auth-response-parsing.service.ts +++ b/src/app/core/auth/auth-response-parsing.service.ts @@ -3,6 +3,8 @@ 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'; @@ -11,10 +13,15 @@ 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 { isNotEmpty } from '../../shared/empty.util'; +import { isEmpty, 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() export class AuthResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { @@ -29,19 +36,14 @@ export class AuthResponseParsingService extends BaseResponseParsingService imple } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - /*if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && data.statusCode === '200') { - const configDefinition = this.process<ConfigObject,ConfigType>(data.payload, request.href); - return new ConfigSuccessResponse(configDefinition[Object.keys(configDefinition)[0]], data.statusCode, this.processPageInfo(data.payload.page)); + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && data.statusCode === '200') { + 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') { + return new AuthSuccessResponse(new AuthTokenInfo(data.headers.get('authorization')), data.statusCode); } else { - return new ErrorResponse( - Object.assign( - new Error('Unexpected response from config endpoint'), - {statusText: data.statusCode} - ) - ); - }*/ - console.log(data); - return new AuthSuccessResponse(data.payload, data.statusCode) + return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode); + } } } diff --git a/src/app/core/auth/auth-storage.service.ts b/src/app/core/auth/auth-storage.service.ts new file mode 100644 index 0000000000..01e41d850b --- /dev/null +++ b/src/app/core/auth/auth-storage.service.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +/** + * The auth service. + */ +@Injectable() +export class AuthStorageService { + + constructor(@Inject(PLATFORM_ID) private platformId: string) {} + + public get(key: string): any { + let item = null; + if (isPlatformBrowser(this.platformId)) { + item = JSON.parse(localStorage.getItem(key)); + } + return item; + } + + public store(key: string, item: any) { + if (isPlatformBrowser(this.platformId)) { + localStorage.setItem(key, JSON.stringify(item)); + } + return true; + } + + public remove(key: string) { + if (isPlatformBrowser(this.platformId)) { + localStorage.removeItem(key); + } + return true; + } + +} diff --git a/src/app/core/auth/auth-type.ts b/src/app/core/auth/auth-type.ts index 793c9869a0..b8879ae445 100644 --- a/src/app/core/auth/auth-type.ts +++ b/src/app/core/auth/auth-type.ts @@ -1,3 +1,4 @@ export enum AuthType { + Eperson = 'eperson', Status = 'status' } diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 694d59b9ee..cf8ce68bd6 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -6,6 +6,7 @@ import { type } from '../../shared/ngrx/type'; // import models import { Eperson } from '../eperson/models/eperson.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; export const AuthActionTypes = { AUTHENTICATE: type('dspace/auth/AUTHENTICATE'), @@ -49,9 +50,9 @@ export class AuthenticateAction implements Action { */ export class AuthenticatedAction implements Action { public type: string = AuthActionTypes.AUTHENTICATED; - payload: string; + payload: AuthTokenInfo; - constructor(token: string) { + constructor(token: AuthTokenInfo) { this.payload = token; } } @@ -108,10 +109,10 @@ export class AuthenticationErrorAction implements Action { */ export class AuthenticationSuccessAction implements Action { public type: string = AuthActionTypes.AUTHENTICATE_SUCCESS; - payload: Eperson; + payload: AuthTokenInfo; - constructor(user: Eperson) { - this.payload = user; + constructor(token: AuthTokenInfo) { + this.payload = token; } } diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 75ca333b5a..aa7503fe4c 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -23,23 +23,7 @@ import { RegistrationSuccessAction } from './auth.actions'; import { Eperson } from '../eperson/models/eperson.model'; - -/** - * Effects offer a way to isolate and easily test side-effects within your - * application. - * The `toPayload` helper function returns just - * the payload of the currently dispatched action, useful in - * instances where the current state is not necessary. - * - * Documentation on `toPayload` can be found here: - * https://github.com/ngrx/effects/blob/master/docs/api.md#topayload - * - * If you are unfamiliar with the operators being used in these examples, please - * check out the sources below: - * - * Official Docs: http://reactivex.io/rxjs/manual/overview.html#categories-of-operators - * RxJS 5 Operators By Example: https://gist.github.com/btroncone/d6cf141d6f2c00dc6b35 - */ +import { AuthStatus } from './models/auth-status.model'; @Injectable() export class AuthEffects { @@ -51,18 +35,29 @@ export class AuthEffects { @Effect() public authenticate: Observable<Action> = this.actions$ .ofType(AuthActionTypes.AUTHENTICATE) - .debounceTime(500) .switchMap((action: AuthenticateAction) => { return this.authService.authenticate(action.payload.email, action.payload.password) - .map((user: Eperson) => new AuthenticationSuccessAction(user)) + .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) + .do((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)) + .map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)) + + @Effect({dispatch: false}) + public logOutSuccess: Observable<Action> = this.actions$ + .ofType(AuthActionTypes.LOG_OUT_SUCCESS) + .do((action: LogOutSuccessAction) => this.authService.removeToken()); + @Effect() public authenticated: Observable<Action> = this.actions$ .ofType(AuthActionTypes.AUTHENTICATED) .switchMap((action: AuthenticatedAction) => { - return this.authService.authenticatedUser() + return this.authService.authenticatedUser(action.payload) .map((user: Eperson) => new AuthenticatedSuccessAction((user !== null), user)) .catch((error) => Observable.of(new AuthenticatedErrorAction(error))); }); @@ -70,7 +65,7 @@ export class AuthEffects { @Effect() public createUser: Observable<Action> = this.actions$ .ofType(AuthActionTypes.REGISTRATION) - .debounceTime(500) + .debounceTime(500) // to remove when functionality is implemented .switchMap((action: RegistrationAction) => { return this.authService.create(action.payload) .map((user: Eperson) => new RegistrationSuccessAction(user)) diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts new file mode 100644 index 0000000000..40fd0d3836 --- /dev/null +++ b/src/app/core/auth/auth.interceptor.ts @@ -0,0 +1,97 @@ +import { Injectable, Injector } from '@angular/core'; +import { Router } from '@angular/router'; +import { + HttpClient, HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse, + HttpErrorResponse +} from '@angular/common/http'; +import { Observable } from 'rxjs/Rx'; +import 'rxjs/add/observable/throw' +import 'rxjs/add/operator/catch'; + +import { AuthService } from './auth.service'; +import { AuthStatus } from './models/auth-status.model'; +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'; + +@Injectable() +export class AuthInterceptor implements HttpInterceptor { + constructor(private inj: Injector, private router: Router) { } + + private isUnauthorized(status: number): boolean { + return status === 401 || status === 403; + } + + private isLoginResponse(url: string): boolean { + return url.endsWith('/authn/login'); + } + + private isLogoutResponse(url: string): boolean { + return url.endsWith('/authn/logout'); + } + + private makeAuthStatusObject(authenticated:boolean, accessToken?: string, error?: string): AuthStatus { + const authStatus = new AuthStatus(); + authStatus.id = null; + authStatus.okay = true; + if (authenticated) { + authStatus.authenticated = true; + authStatus.token = new AuthTokenInfo(accessToken); + } else { + authStatus.authenticated = false; + authStatus.error = JSON.parse(error); + } + return authStatus; + } + + intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> { + + const authService = this.inj.get(AuthService); + + // Get the auth header from the service. + const Authorization = authService.getAuthHeader(); + + let authReq; + if (isNotEmpty(Authorization)) { + // Clone the request to add the new header. + authReq = req.clone({headers: req.headers.set('authorization', Authorization)}); + } else { + authReq = req.clone(); + } + + // Pass on the cloned request instead of the original request. + return next.handle(authReq) + .map((response) => { + if (response instanceof HttpResponse && response.status === 200 && (this.isLoginResponse(response.url) || this.isLogoutResponse(response.url))) { + let authRes: HttpResponse<any>; + if (this.isLoginResponse(response.url)) { + const token = response.headers.get('authorization'); + authRes = response.clone({body: this.makeAuthStatusObject(true, token)}); + } else { + authRes = response.clone({body: this.makeAuthStatusObject(false)}); + } + return authRes; + } else { + return response; + } + }) + .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); + } else { + // 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 293e7b1527..645541aa8e 100644 --- a/src/app/core/auth/auth.reducers.ts +++ b/src/app/core/auth/auth.reducers.ts @@ -6,6 +6,7 @@ import { // import models import { Eperson } from '../eperson/models/eperson.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; /** * The auth state. @@ -25,6 +26,9 @@ export interface AuthState { // true when loading loading: boolean; + // access token + token?: AuthTokenInfo; + // the authenticated user user?: Eperson; } @@ -62,8 +66,10 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.AUTHENTICATED_SUCCESS: return Object.assign({}, state, { - authenticated: (action as AuthenticatedSuccessAction).payload.authenticated, + authenticated: true, loaded: true, + error: undefined, + loading: false, user: (action as AuthenticatedSuccessAction).payload.user }); @@ -76,26 +82,26 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.AUTHENTICATE_SUCCESS: - case AuthActionTypes.REGISTRATION_SUCCESS: - const user: Eperson = (action as AuthenticationSuccessAction).payload; + const token: AuthTokenInfo = (action as AuthenticationSuccessAction).payload; - // verify user is not null - if (user === null) { + // verify token is not null + if (token === null) { return state; } return Object.assign({}, state, { - authenticated: true, - error: undefined, - loading: false, - user: user + token: token }); + case AuthActionTypes.REGISTRATION_SUCCESS: + return state; + case AuthActionTypes.RESET_ERROR: return Object.assign({}, state, { authenticated: null, + error: undefined, loaded: false, - loading: false + loading: false, }); case AuthActionTypes.LOG_OUT_ERROR: @@ -109,7 +115,10 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut return Object.assign({}, state, { authenticated: false, error: undefined, - user: undefined + loaded: false, + loading: false, + user: undefined, + token: undefined }); case AuthActionTypes.REGISTRATION: diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index c59c5d069e..a5373e1afd 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -6,6 +6,10 @@ import { Eperson } from '../eperson/models/eperson.model'; import { AuthRequestService } from './auth-request.service'; 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 } from './models/auth-token-info.model'; +import { isNotEmpty, isNotNull } from '../../shared/empty.util'; +import { AuthStorageService } from './auth-storage.service'; export const MOCK_USER = new Eperson(); MOCK_USER.id = '92a59227-ccf7-46da-9776-86c3fc64147f'; @@ -30,21 +34,28 @@ MOCK_USER.metadata = [ } ]; -export const TOKENITEM = 'ds-token'; +export const TOKENITEM = 'dsAuthInfo'; /** - * The user service. + * The auth service. */ @Injectable() export class AuthService { /** * True if authenticated - * @type + * @type boolean */ private _authenticated = false; - constructor(private authRequestService: AuthRequestService) {} + /** + * The url to redirect after login + * @type string + */ + private _redirectUrl: string; + + constructor(private authRequestService: AuthRequestService, private storage: AuthStorageService) { + } /** * Authenticate the user @@ -53,32 +64,28 @@ export class AuthService { * @param {string} password The user's password * @returns {Observable<User>} The authenticated user observable. */ - public authenticate(user: string, password: string): Observable<Eperson> { + public authenticate(user: string, password: string): Observable<AuthStatus> { // 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 formData: FormData = new FormData(); - formData.append('user', user); - formData.append('password', password); - const body = 'password=' + password.toString() + '&user=' + user.toString(); + // const body = encodeURI('password=' + password.toString() + '&user=' + user.toString()); + 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'); - headers = headers.append('Accept', 'application/json'); options.headers = headers; options.responseType = 'text'; - this.authRequestService.postToEndpoint('login', body, options) - .subscribe((r) => { - console.log(r); + return this.authRequestService.postToEndpoint('login', body, options) + .map((status: AuthStatus) => { + if (status.authenticated) { + return status; + } else { + throw(new Error('Invalid email or password')); + } }) - if (user === 'test' && password === 'password') { - this._authenticated = true; - return Observable.of(MOCK_USER); - } - return Observable.throw(new Error('Invalid email or password')); } /** @@ -93,11 +100,25 @@ export class AuthService { * Returns the authenticated user * @returns {User} */ - public authenticatedUser(): Observable<Eperson> { + public authenticatedUser(token: AuthTokenInfo): Observable<Eperson> { // Normally you would do an HTTP request to determine if // the user has an existing auth session on the server // but, let's just return the mock user for this example. - return Observable.of(MOCK_USER); + 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.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')); + } + }); } /** @@ -119,7 +140,47 @@ export class AuthService { public signout(): Observable<boolean> { // Normally you would do an HTTP request sign end the session // but, let's just return an observable of true. - this._authenticated = false; - return Observable.of(true); + 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')); + } + }) + + } + + public getAuthHeader(): string { + // Retrieve authentication token info + const token = this.storage.get(TOKENITEM); + return (isNotNull(token) && this._authenticated) ? `Bearer ${token.accessToken}` : ''; + } + + public getToken(): AuthTokenInfo { + // Retrieve authentication token info + return this.storage.get(TOKENITEM); + } + + public storeToken(token: AuthTokenInfo) { + // Save authentication token info + return this.storage.store(TOKENITEM, JSON.stringify(token)); + } + + public removeToken() { + // Remove authentication token info + return this.storage.remove(TOKENITEM); + } + + get redirectUrl(): string { + return this._redirectUrl; + } + + 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 38e0b5e24d..cd39981a1f 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -7,6 +7,7 @@ import { Store } from '@ngrx/store'; // reducers import { CoreState } from '../core.reducers'; import { isAuthenticated } from './selectors'; +import { AuthService } from './auth.service'; /** * Prevent unauthorized activating and loading of routes @@ -18,37 +19,44 @@ export class AuthenticatedGuard implements CanActivate, CanLoad { /** * @constructor */ - constructor(private router: Router, private store: Store<CoreState>) {} + constructor(private authService: AuthService, private router: Router, private store: Store<CoreState>) {} /** * True when user is authenticated * @method canActivate */ - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> | boolean { - // get observable - const observable = this.store.select(isAuthenticated); + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> { + const url = state.url; - // redirect to sign in page if user is not authenticated - observable.subscribe((authenticated) => { - if (!authenticated) { - this.router.navigate(['/login']); - } - }); + return this.handleAuth(url); + } - return observable; + /** + * True when user is authenticated + * @method canActivateChild + */ + canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> { + return this.canActivate(route, state); } /** * True when user is authenticated * @method canLoad */ - canLoad(route: Route): Observable<boolean> | Promise<boolean> | boolean { + canLoad(route: Route): Observable<boolean> { + const url = `/${route.path}`; + + return this.handleAuth(url); + } + + private handleAuth(url: string): Observable<boolean> { // get observable const observable = this.store.select(isAuthenticated); // redirect to sign in page if user is not authenticated observable.subscribe((authenticated) => { if (!authenticated) { + this.authService.redirectUrl = url; this.router.navigate(['/login']); } }); diff --git a/src/app/core/auth/models/auth-error.model.ts b/src/app/core/auth/models/auth-error.model.ts new file mode 100644 index 0000000000..d68d04748e --- /dev/null +++ b/src/app/core/auth/models/auth-error.model.ts @@ -0,0 +1,7 @@ +export interface AuthError { + error: string, + message: string, + path: string, + status: number + timestamp: number +} diff --git a/src/app/core/auth/models/auth-info.model.ts b/src/app/core/auth/models/auth-info.model.ts deleted file mode 100644 index 8995f82235..0000000000 --- a/src/app/core/auth/models/auth-info.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface AuthInfo { - access_token?: string, - expires?: number, - expires_in?: number -} diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index 388405e87a..98a07c0e9d 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -1,4 +1,7 @@ +import { AuthError } from './auth-error.model'; +import { AuthTokenInfo } from './auth-token-info.model'; import { DSpaceObject } from '../../shared/dspace-object.model'; +import { Eperson } from '../../eperson/models/eperson.model'; export class AuthStatus extends DSpaceObject { @@ -6,4 +9,9 @@ export class AuthStatus extends DSpaceObject { authenticated: boolean; + error?: AuthError; + + eperson: Eperson[]; + + token?: AuthTokenInfo } diff --git a/src/app/core/auth/models/auth-token-info.model.ts b/src/app/core/auth/models/auth-token-info.model.ts new file mode 100644 index 0000000000..1e798f1630 --- /dev/null +++ b/src/app/core/auth/models/auth-token-info.model.ts @@ -0,0 +1,11 @@ +export class AuthTokenInfo { + public accessToken: string; + public expires?: number; + + constructor(token: string, expiresIn?: number) { + this.accessToken = token.replace('Bearer ', ''); + if (expiresIn) { + this.expires = expiresIn * 1000 + Date.now(); + } + } +} diff --git a/src/app/core/auth/models/normalized-auth-status.model.ts b/src/app/core/auth/models/normalized-auth-status.model.ts new file mode 100644 index 0000000000..19952f7c70 --- /dev/null +++ b/src/app/core/auth/models/normalized-auth-status.model.ts @@ -0,0 +1,26 @@ +import { AuthStatus } from './auth-status.model'; +import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; +import { mapsTo } from '../../cache/builders/build-decorators'; +import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; +import { Eperson } from '../../eperson/models/eperson.model'; + +@mapsTo(AuthStatus) +@inheritSerialization(NormalizedDSpaceObject) +export class NormalizedAuthStatus extends NormalizedDSpaceObject { + + /** + * True if REST API is up and running, should never return false + */ + @autoserialize + okay: boolean; + + /** + * True if the token is valid, false if there was no token or the token wasn't valid + */ + @autoserialize + authenticated: boolean; + + @autoserializeAs(Eperson) + eperson: Eperson[]; + +} diff --git a/src/app/core/cache/response-cache.models.ts b/src/app/core/cache/response-cache.models.ts index 0a61a53df7..fd41c2258b 100644 --- a/src/app/core/cache/response-cache.models.ts +++ b/src/app/core/cache/response-cache.models.ts @@ -2,12 +2,16 @@ import { RequestError } from '../data/request.models'; import { PageInfo } from '../shared/page-info.model'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { ConfigObject } from '../shared/config/config.model'; +import { AuthTokenInfo } from '../auth/models/auth-token-info.model'; +import { NormalizedAuthStatus } from '../auth/models/normalized-auth-status.model'; +import { AuthStatus } from '../auth/models/auth-status.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { + public toCache = true; constructor( public isSuccessful: boolean, - public statusCode: string + public statusCode: string, ) { } } @@ -63,11 +67,31 @@ export class ConfigSuccessResponse extends RestResponse { } } +export class AuthStatusResponse extends RestResponse { + public toCache = false; + constructor( + public response: AuthStatus, + public statusCode: string + ) { + super(true, statusCode); + } +} + export class AuthSuccessResponse extends RestResponse { + public toCache = false; constructor( - public authResponse: any, + public response: AuthTokenInfo, + public statusCode: string + ) { + super(true, statusCode); + } +} + +export class AuthErrorResponse extends RestResponse { + public toCache = false; + constructor( + public response: any, public statusCode: string, - public pageInfo?: PageInfo ) { super(true, statusCode); } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 7ea4ef45d4..63f0165b06 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -41,6 +41,10 @@ import { UUIDService } from './shared/uuid.service'; import { AuthService } from './auth/auth.service'; import { AuthenticatedGuard } from './auth/authenticated.guard'; import { AuthRequestService } from './auth/auth-request.service'; +import { AuthResponseParsingService } from './auth/auth-response-parsing.service'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { AuthInterceptor } from './auth/auth.interceptor'; +import { AuthStorageService } from './auth/auth-storage.service'; const IMPORTS = [ CommonModule, @@ -60,7 +64,9 @@ const PROVIDERS = [ ApiService, AuthenticatedGuard, AuthRequestService, + AuthResponseParsingService, AuthService, + AuthStorageService, CommunityDataService, CollectionDataService, DSOResponseParsingService, @@ -83,7 +89,13 @@ const PROVIDERS = [ SubmissionFormsConfigService, SubmissionSectionsConfigService, UUIDService, - { provide: NativeWindowService, useFactory: NativeWindowFactory } + { provide: NativeWindowService, useFactory: NativeWindowFactory }, + // register TokenInterceptor as HttpInterceptor + { + provide: HTTP_INTERCEPTORS, + useClass: AuthInterceptor, + multi: true + } ]; @NgModule({ diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index b7c81eaa8e..c986c4b653 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -193,8 +193,8 @@ export class AuthPostRequest extends PostRequest { } export class AuthGetRequest extends GetRequest { - constructor(uuid: string, href: string) { - super(uuid, href); + constructor(uuid: string, href: string, public options?: HttpOptions) { + super(uuid, href, null, options); } getResponseParser(): GenericConstructor<ResponseParsingService> { diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index f589221e63..6d4393bade 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -17,6 +17,7 @@ import { RequestConfigureAction, RequestExecuteAction } from './request.actions' import { GetRequest, RestRequest, RestRequestMethod } from './request.models'; import { RequestEntry, RequestState } from './request.reducer'; +import { ResponseCacheRemoveAction } from '../cache/response-cache.actions'; @Injectable() export class RequestService { @@ -66,9 +67,9 @@ export class RequestService { .flatMap((uuid: string) => this.getByUUID(uuid)); } - configure<T extends CacheableObject>(request: RestRequest): void { - if (request.method !== RestRequestMethod.Get || !this.isCachedOrPending(request)) { - this.dispatchRequest(request); + configure<T extends CacheableObject>(request: RestRequest, overrideRequest: boolean = false): void { + if (request.method !== RestRequestMethod.Get || !this.isCachedOrPending(request) || overrideRequest) { + this.dispatchRequest(request, overrideRequest); } } @@ -101,10 +102,10 @@ export class RequestService { return isCached || isPending; } - private dispatchRequest(request: RestRequest) { + private dispatchRequest(request: RestRequest, overrideRequest: boolean) { this.store.dispatch(new RequestConfigureAction(request)); this.store.dispatch(new RequestExecuteAction(request.uuid)); - if (request.method === RestRequestMethod.Get) { + if (request.method === RestRequestMethod.Get && !overrideRequest) { this.trackRequestsOnTheirWayToTheStore(request); } } 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 d225eadcc4..fe911e5635 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 @@ -1,3 +1,5 @@ +import { HttpHeaders } from '@angular/common/http'; + export interface DSpaceRESTV2Response { payload: { [name: string]: any; @@ -5,5 +7,6 @@ export interface DSpaceRESTV2Response { _links?: any; page?: any; }, + 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 1a5287d4b0..51efecf625 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 @@ -61,21 +61,16 @@ export class DSpaceRESTv2Service { requestOptions.body = body; requestOptions.observe = 'response'; if (options && options.headers) { - let headers = new HttpHeaders(); - headers = headers.append('Accept', 'application/json'); - headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); - // requestOptions.headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded'); - requestOptions.headers = headers; - /* const keys = options.headers.getAll(''); - keys.forEach((key) => { - requestOptions.headers.append(key, options.headers.get(key)); - })*/ + requestOptions.headers = Object.assign(new HttpHeaders(), options.headers); } if (options && options.responseType) { - // requestOptions.responseType = options.responseType; + requestOptions.responseType = options.responseType; } return this.http.request(method, url, requestOptions) - .map((res) => ({ payload: res.body, statusCode: res.statusText })) + .map((res) => { + console.log(res); + return ({ payload: res.body, headers: res.headers, statusCode: res.statusText }) + }) .catch((err) => { console.log('Error: ', err); return Observable.throw(err); -- GitLab