Auth module improvement

This commit is contained in:
Giuseppe Digilio
2018-02-12 19:13:42 +01:00
parent b7cff01dab
commit 8df514f39c
15 changed files with 238 additions and 89 deletions

View File

@@ -28,3 +28,5 @@ export const appReducers: ActionReducerMap<AppState> = {
searchSidebar: sidebarReducer,
searchFilter: filterReducer
};
export const routerStateSelector = (state: AppState) => state.router;

View File

@@ -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;

View File

@@ -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<Action> = 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,

View File

@@ -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<AppState>) { }
private isUnauthorized(status: number): boolean {
return status === 401 || status === 403;
@@ -84,6 +87,8 @@ export class AuthInterceptor implements HttpInterceptor {
.catch((error, caught) => {
// Intercept an unauthorized error response
if (error instanceof HttpErrorResponse && this.isUnauthorized(error.status)) {
// 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),
@@ -93,6 +98,10 @@ export class AuthInterceptor implements HttpInterceptor {
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);

View File

@@ -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

View File

@@ -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<AppState>) {
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<User>} The authenticated user observable.
*/
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 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<boolean>}
*/
public authenticated(): Observable<boolean> {
return Observable.of(this._authenticated);
public isAuthenticated(): Observable<boolean> {
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<boolean>}
*/
public logout(): Observable<boolean> {
// 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;
}

View File

@@ -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<boolean> {
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();
}
});

View File

@@ -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

View File

@@ -2,7 +2,7 @@
<li *ngIf="!(isAuthenticated | async) && !(windowService.isMobileView() | async) && showAuth" class="nav-item dropdown" (click)="$event.stopPropagation();">
<div ngbDropdown placement="bottom-right" class="d-inline-block float-right" @fadeInOut>
<a href="#" id="dropdownLogin" class="nav-link" (click)="$event.preventDefault()" ngbDropdownToggle><i class="fa fa-sign-in fa-fw" aria-hidden="true"></i> {{ 'nav.login' | translate }}<span class="caret"></span></a>
<div ngbDropdownMenu aria-labelledby="dropdownLogin">
<div id="loginDropdownMenu" ngbDropdownMenu aria-labelledby="dropdownLogin">
<ds-log-in></ds-log-in>
</div>
</div>
@@ -13,7 +13,7 @@
<li *ngIf="(isAuthenticated | async) && !(windowService.isMobileView() | async)" class="nav-item">
<div ngbDropdown placement="bottom-right" class="d-inline-block" [ngClass]="{'float-right': !(windowService.isMobileView() | async)}" @fadeInOut>
<a href="#" id="dropdownUser" class="nav-link" (click)="$event.preventDefault()" ngbDropdownToggle><i class="fa fa-user fa-fw" aria-hidden="true"></i>Hello {{(user | async).name}}<span class="caret"></span></a>
<div ngbDropdownMenu aria-labelledby="dropdownUser">
<div id="logoutDropdownMenu" ngbDropdownMenu aria-labelledby="dropdownUser">
<ds-log-out></ds-log-out>
</div>
</div>

View File

@@ -0,0 +1,7 @@
#loginDropdownMenu, #logoutDropdownMenu {
min-width: 330px;
}
#loginDropdownMenu {
min-height: 260px;
}

View File

@@ -7,14 +7,12 @@ import { Store } from '@ngrx/store';
import { fadeInOut, fadeOut } from '../animations/fade';
import { CoreState } from '../../core/core.reducers';
import { HostWindowService } from '../host-window.service';
import { AppState } from '../../app.reducer';
import { AppState, routerStateSelector } from '../../app.reducer';
import { hasValue, isNotUndefined } from '../empty.util';
import { getAuthenticatedUser, isAuthenticated } from '../../core/auth/selectors';
import { Subscription } from 'rxjs/Subscription';
import { Eperson } from '../../core/eperson/models/eperson.model';
const routerStateSelector = (state: AppState) => state.router;
@Component({
selector: 'ds-auth-nav-menu',
templateUrl: './auth-nav-menu.component.html',

View File

@@ -9,7 +9,7 @@
placeholder="{{'login.form.email' | translate}}"
required
type="email"
(input)="resetError($event)">
(input)="resetErrorOrMessage($event)">
<label for="inputPassword" class="sr-only">{{"login.form.password" | translate}}</label>
<input id="inputPassword"
autocomplete="off"
@@ -18,8 +18,9 @@
formControlName="password"
required
type="password"
(input)="resetError($event)">
(input)="resetErrorOrMessage($event)">
<div *ngIf="(error | async) && hasError" class="alert alert-danger" role="alert" @fadeOut>{{ error | async }}</div>
<div *ngIf="(message | async) && hasMessage" class="alert alert-info" role="alert" @fadeOut>{{ message | async }}</div>
<button class="btn btn-lg btn-primary btn-block mt-3" type="submit" [disabled]="!form.valid">{{"login.form.submit" | translate}}</button>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#">{{"login.form.new-user" | translate}}</a>

View File

@@ -14,15 +14,15 @@ import { AuthenticateAction, ResetAuthenticationErrorAction } from '../../core/a
// reducers
import {
getAuthenticationError,
getAuthenticationError, getAuthenticationMessage,
isAuthenticated,
isAuthenticationLoading,
} from '../../core/auth/selectors';
import { Router } from '@angular/router';
import { CoreState } from '../../core/core.reducers';
import { isNotEmpty, isNotNull } from '../empty.util';
import { isNotEmpty } from '../empty.util';
import { fadeOut } from '../animations/fade';
import { AuthService } from '../../core/auth/auth.service';
/**
* /users/sign-in
@@ -48,6 +48,18 @@ export class LogInComponent implements OnDestroy, OnInit {
*/
public hasError = false;
/**
* The authentication info message.
* @type {Observable<string>}
*/
public message: Observable<string>;
/**
* Has authentication message.
* @type {boolean}
*/
public hasMessage = false;
/**
* True if the authentication is loading.
* @type {boolean}
@@ -68,12 +80,13 @@ export class LogInComponent implements OnDestroy, OnInit {
/**
* @constructor
* @param {AuthService} authService
* @param {FormBuilder} formBuilder
* @param {Store<State>} store
*/
constructor(
private authService: AuthService,
private formBuilder: FormBuilder,
private router: Router,
private store: Store<CoreState>
) { }
@@ -95,6 +108,13 @@ export class LogInComponent implements OnDestroy, OnInit {
return error;
});
// set error
this.message = this.store.select(getAuthenticationMessage)
.map((message) => {
this.hasMessage = (isNotEmpty(message));
return message;
});
// set loading
this.loading = this.store.select(isAuthenticationLoading);
@@ -103,7 +123,7 @@ export class LogInComponent implements OnDestroy, OnInit {
.takeWhile(() => this.alive)
.filter((authenticated) => authenticated)
.subscribe(() => {
this.router.navigate(['/']);
this.authService.redirectToPreviousUrl();
});
}
@@ -116,20 +136,13 @@ export class LogInComponent implements OnDestroy, OnInit {
}
/**
* Go to the home page.
* @method home
* Reset error or message.
*/
public home() {
this.router.navigate(['/home']);
}
/**
* Reset error.
*/
public resetError() {
if (this.hasError) {
public resetErrorOrMessage() {
if (this.hasError || this.hasMessage) {
this.store.dispatch(new ResetAuthenticationErrorAction());
this.hasError = false;
this.hasMessage = false;
}
}
@@ -138,7 +151,8 @@ export class LogInComponent implements OnDestroy, OnInit {
* @method register
*/
public register() {
this.router.navigate(['/register']);
// TODO enable after registration process is done
// this.router.navigate(['/register']);
}
/**

View File

@@ -2,8 +2,6 @@
<div *ngIf="!(loading | async)" class="form-login px-4 py-3">
<div *ngIf="(error | async) && hasError" class="alert alert-danger" role="alert" @fadeOut>{{ error | async }}</div>
<button class="btn btn-lg btn-primary btn-block mt-3" (click)="logOut()">{{"logout.form.submit" | translate}}</button>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#">{{"login.form.new-user" | translate}}</a>
<a class="dropdown-item" href="#">{{"login.form.forgot-password" | translate}}</a>
<button class="btn btn-lg btn-primary btn-block mt-3" (click)="logOut()">{{"logout.form.submit" | translate}}</button>
</div>

View File

@@ -75,13 +75,6 @@ export class LogOutComponent implements OnDestroy, OnInit {
this.router.navigate(['/home']);
}
/**
* To to the log in page.
*/
public logIn() {
this.router.navigate(['/login']);
}
public logOut() {
this.store.dispatch(new LogOutAction());
}