Authentication and Authorization with Angular 2+
In this tutorial, I'll show you how you can secure your Angular 2+ application. Please, take a look at my previous post (Authorization and Authentication with AngularJS + Jersey) for the backend implementation.
1. Authentication mechanism
We use JWT (JSON Web Token) authentication mechanism in our application. We have decided to store our JWT in a cookie with
In order to prevent CSRF attack we'll use the double submit cookie. This pattern is defined as sending a random value in both a cookie and as a request header. When a user authenticates to our application via the login page, the API responds with 2 cookies:
HttpOnly
(prevent XSS attacks) and Secure
(sent over HTTPS only).In order to prevent CSRF attack we'll use the double submit cookie. This pattern is defined as sending a random value in both a cookie and as a request header. When a user authenticates to our application via the login page, the API responds with 2 cookies:
- A cookie (XSRF-TOKEN) set with a strong random value with
Secure
flag only. We will instruct AngularHttpClient
to read this value and set it as an HTTP header (X-XSRF-TOKEN) for each subsequent request. Since only JavaScript that runs on your domain can read the cookie, your server can be assured that the XHR came from JavaScript running on your domain.
- A cookie set with our JWT which includes the strong random value (xsrfToken) as a claim. This cookie is not accessible through JavaScript as explained above. On the server side, a filter checks if the X-XSRF-TOKEN and the
xsrfToken
match.
2. Authentication Service
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {Injectable} from '@angular/core'; | |
import {BehaviorSubject} from 'rxjs/BehaviorSubject'; | |
import 'rxjs/add/operator/map'; | |
import {Observable} from 'rxjs/Observable'; | |
import {CookieService} from 'ngx-cookie'; | |
import {LoggerService} from './logger.service'; | |
import {ApiService} from './api.service'; | |
import {User, LogoutInfo} from '../../model/user'; | |
@Injectable() | |
export class AuthService { | |
private currentUserSubject = new BehaviorSubject<User>(new User()); | |
private isAuthenticatedSubject = new BehaviorSubject<boolean>(false); | |
private userUrl: string = 'user'; | |
constructor( | |
private cookieService: CookieService, | |
private apiService: ApiService, | |
private loggerService: LoggerService | |
) {} | |
currentUser(): Observable<User> { | |
return this.currentUserSubject.asObservable(); | |
} | |
isAuthenticated(): Observable<boolean> { | |
return this.isAuthenticatedSubject.asObservable(); | |
} | |
setAuthentication(user: User): void { | |
this.currentUserSubject.next(user); | |
this.isAuthenticatedSubject.next(true); | |
this.loggerService.log(`Session set for ${user.contactName}.`); | |
} | |
purgeAuthentication(): void { | |
this.currentUserSubject.next(new User()); | |
this.isAuthenticatedSubject.next(false); | |
this.loggerService.log('Session removed.'); | |
} | |
populate() { | |
return new Promise((resolve, reject) => { | |
if (this.cookieService.get('vedrax-index-ui-csrf')) { | |
let url = `${this.userUrl}/current`; | |
this.apiService.get<User>(url) | |
.subscribe( | |
res => { | |
this.setAuthentication(res); | |
resolve(); | |
}, | |
err => { | |
this.loggerService.logObject(err); | |
this.purgeAuthentication(); | |
resolve(); | |
}); | |
} else { | |
this.purgeAuthentication(); | |
resolve(); | |
} | |
}); | |
} | |
login(username: string, password: string): Observable<User> { | |
let url = `${this.userUrl}/login`; | |
return this.apiService.post<User>(url, {userName: username, password: password}) | |
.map(data => { | |
this.setAuthentication(data); | |
return data; | |
}); | |
} | |
logout(): Observable<LogoutInfo> { | |
let url = `${this.userUrl}/logout`; | |
return this.apiService.get<LogoutInfo>(url) | |
.map(data => { | |
this.purgeAuthentication(); | |
return data; | |
}); | |
} | |
} |
2.1 Session Data
Our session data will be stored in our authentication service. We use
We change the session data using the
We create a provider, which will return a
We need a factory in order to hook into app init process and load the session data when csrf cookie is available.
We instruct Angular to use it in the init process with
Now, we can attach this guard to our route:
The last task is to redirect the user after login, which is handled in the login component.
So, it's done. We have a pretty good foundation for implementing a reliable authentication process in our Angular application.
Don't hesitate to leave your comments or maybe to improve my solution.
Thanks
BehaviorSubject
from RxJS. The whole point of using RxJS is to asynchronously update and share session data across our application. This is done by subscribing to the Observable in our components as follow :
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
this.authService.currentUser() | |
.takeUntil(this.destroy$) // avoid memory leak in conjunction with ngDestroy | |
.subscribe(user => { | |
//do something with session data | |
}); |
We change the session data using the
next
method of the BehaviorSubject
inside our helper methods setAuthentication
and purgeAuthentication
.2.2 Populate Method
Because we cannot access our JWT cookie from JavaScript, we need to load the session data from an API during initialization of the application.We create a provider, which will return a
Promise
, which will be resolved when the request completed. Our populate
function will be executed at application initialization process. This function will retrieve and set our session data.We need a factory in order to hook into app init process and load the session data when csrf cookie is available.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export function authProviderFactory(provider: AuthService) { | |
return () => provider.populate(); | |
} |
We instruct Angular to use it in the init process with
APP_INITIALIZER
inside the providers section of your @NgModule
.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@NgModule({ | |
declarations: [ | |
AppComponent | |
], | |
imports: [ | |
BrowserModule, | |
HttpModule | |
], | |
providers: [ | |
ApiService, | |
AuthService, | |
{ | |
provide: APP_INITIALIZER, | |
useFactory: authProviderFactory, | |
deps: [AuthService], | |
multi: true | |
} | |
], | |
bootstrap: [AppComponent] | |
}) | |
export class AppModule { } |
2.3 Login
When no session data are available (no cookies, or expired cookies), the application prompts the user to enter his credentials. The server returns the user data along with the authentication cookies when the credentials match.2.4 Logout
When the user logout, the server invalidate the authentication cookies.3. Guards
In order to protect a route, we use thecanActivate
type, which is run before you navigate to a route. Our authentication guard will check if the user is valid otherwise it will navigate to the login route. It also grabs the current URL which will be set as a query parameter.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {Injectable} from '@angular/core'; | |
import {Router, CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot} from '@angular/router'; | |
import {Observable} from 'rxjs/Observable'; | |
import 'rxjs/add/operator/map'; | |
import {AuthService} from '../services/auth.service'; | |
import {LoggerService} from '../services/logger.service'; | |
import {User} from '../../model/user'; | |
@Injectable() | |
export class AccessGuard implements CanActivate { | |
constructor( | |
private router: Router, | |
private authService: AuthService, | |
private loggerService: LoggerService | |
) {} | |
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> { | |
// this will be passed from the route config | |
// on the data property | |
const expectedRole = route.data.expectedRole; | |
return this.authService.currentUser() | |
.map((user: User) => { | |
if (!user.id) { | |
this.loggerService.log(`User not authenticated.`); | |
this.router.navigate(['/login'], { | |
queryParams: { | |
return: state.url | |
} | |
}); | |
return false; | |
} | |
if (expectedRole) { | |
if (user.role !== expectedRole) { | |
this.loggerService.log(`User with role ${user.role} cannot access this resource.`); | |
this.router.navigate(['/home']); | |
return false; | |
} | |
} | |
return true; | |
}); | |
} | |
} |
Now, we can attach this guard to our route:
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
path: 'account', | |
component: AccountComponent, | |
canActivate: [AccessGuard] | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import {Component, OnInit, OnDestroy} from '@angular/core'; | |
import {Subject} from 'rxjs/Subject'; | |
import 'rxjs/add/operator/takeUntil'; | |
import {Router, ActivatedRoute} from '@angular/router'; | |
import {FormGroup, FormBuilder, Validators} from '@angular/forms'; | |
import {NotificationService} from '../../services/notification.service'; | |
import {AuthService} from '../../services/auth.service'; | |
@Component({ | |
selector: 'app-login', | |
templateUrl: './login.component.html' | |
}) | |
export class LoginComponent implements OnInit, OnDestroy { | |
//Manage Subscription Declaratively | |
destroy$: Subject<boolean> = new Subject<boolean>(); | |
form: FormGroup; | |
loading: boolean = false; | |
return: string = ''; | |
constructor( | |
private fb: FormBuilder, | |
private router: Router, | |
private route: ActivatedRoute, | |
private authService: AuthService, | |
private notificationService: NotificationService | |
) {} | |
ngOnInit(): void { | |
// Get the query params for redirecting the user after login | |
this.route.queryParamMap | |
.takeUntil(this.destroy$) | |
.subscribe(params => this.return = params.get('return') || '/home'); | |
this.form = this.fb.group({ | |
email: ['', [Validators.required, Validators.email]], | |
password: ['', [Validators.required, Validators.minLength(4)]] | |
}); | |
} | |
ngOnDestroy(): void { | |
this.destroy$.next(true); | |
// Now let's also unsubscribe from the subject itself: | |
this.destroy$.unsubscribe(); | |
} | |
onSubmit(): void { | |
if (this.form.valid) { | |
this.loading = true; | |
const formModel = this.form.value; | |
let email = formModel.email; | |
let password = formModel.password; | |
this.authService.login(email, password) | |
.takeUntil(this.destroy$) | |
.subscribe(data => { | |
this.loading = false; | |
this.router.navigateByUrl(this.return); | |
this.notificationService.notify(`Hi ${data.contactName}`); | |
}); | |
} | |
} | |
} |
Don't hesitate to leave your comments or maybe to improve my solution.
Thanks
Comments
Post a Comment