ng new angular-twilio-authy --style css --routing true cd angular-twilio-authy/ ng g c loginPage --spec false ng g c protectedPage --spec false
LoginPageComponent
a form for collecting the user ID and password andProtectedPageComponent
a page which will be the target of the home
path. ProtectedPageComponent
.AuthService
by entering the following at the command line:ng g s auth --spec false
import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; @Injectable({ providedIn: 'root' }) export class AuthService { private authenticated = false; private redirectUrl: string; constructor(private router: Router) { } public setRedirectUrl(url: string) { this.redirectUrl = url; } public auth(login: string, password: string): void { if (login === 'foo' && password === 'bar') { this.authenticated = true; this.redirectUrl = this.redirectUrl === undefined ? '/' : this.redirectUrl; this.router.navigate([this.redirectUrl]); } } public isAuthenticated(): boolean { return this.authenticated; } }
auth
method the values for the user ID, login
, and the password
have been hard-coded for the sake of simplicity. In a production application the values collected on the /login page and passed to the method would be validated against data retrieved from a persistent data store, like a database.authenticated
is set to true and the user is redirected to the URL passed by the AuthGuardService
.AuthService
is not true
. Create the AuthGuardComponent
with this command:ng g s authGuard --spec false
import { Injectable } from '@angular/core'; import { CanActivate, Router } from '@angular/router'; import { AuthService } from './auth.service'; @Injectable({ providedIn: 'root' }) export class AuthGuardService implements CanActivate { constructor(private authService: AuthService, private router: Router) { } public canActivate(): boolean { if (!this.authService.isAuthenticated()) { this.authService.setRedirectUrl(this.router.url); this.router.navigate(['login']); return false; } return true; } }
AuthGuardService
class implements the CanActivate
interface, which specifies the public canActivate(): boolean
method. The method uses the AuthService
to determine if the user is authenticated. If not, the method passes the route URL that invoked the AuthGuardService
to the AuthService
and returns false
. When the user logs in successfully the AuthService
will automatically route them to the page they had intended to reach before logging in. If the user is authenticated the method returns true
, enabling the user to navigate to the component.LoginPageComponent
, ProtectedPageComponent
, and AuthGuardService
need to be added to the application routing. Replace the contents of src/app/app-routing.module.ts with the following:import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { ProtectedPageComponent } from './protected-page/protected-page.component'; import { LoginPageComponent } from './login-page/login-page.component'; import { AuthGuardService } from './auth-guard.service'; const routes: Routes = [ { path: '', redirectTo: 'home', pathMatch: 'full' }, { path: 'login', component: LoginPageComponent }, { path: 'home', component: ProtectedPageComponent, canActivate: [AuthGuardService] }, ]; @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule] }) export class AppRoutingModule { }
ProtectedPageComponent
, which is protected by the AuthGuardService
service, as follows:{ path: 'home', component: ProtectedPageComponent, canActivate: [AuthGuardService] },
canActivate
method, which determines if the user is allowed to open given resource.<router-outlet></router-outlet>
<form [formGroup]="loginForm" (ngSubmit)="onSubmit()"> <label>login: </label><input type="text" formControlName="login" /><br/> <label>password: </label><input type="password" formControlName="password"/><br/> <input type="submit" value="log in" /> </form>
AuthService
. Replace the contents of src/app/login-page/login-page.component.ts with the following:import { Component } from '@angular/core'; import { FormGroup, FormControl } from '@angular/forms'; import { AuthService } from '../auth.service'; @Component({ selector: 'app-login-page', templateUrl: './login-page.component.html', styleUrls: ['./login-page.component.css'] }) export class LoginPageComponent { public loginForm: FormGroup = new FormGroup({ login: new FormControl(''), password: new FormControl('') }); constructor(private authService: AuthService) { } public onSubmit(): void { this.authService.auth( this.loginForm.get('login').value, this.loginForm.get('password').value ); } }
ReactiveFormsModule
needs to be included in the app entry point. This requires two changes to the src/app/app.module.ts file.import { ReactiveFormsModule } from '@angular/forms';
imports: [ BrowserModule, AppRoutingModule, ReactiveFormsModule ],
ng serve
AuthGuardService
. Because you have not yet been authorized you were redirected to the login path.ProtectedPageComponent
is supplied as the target of the /home route in app-routing.module.ts and that the login credentials are hard-coded in server.ts.git clone https://github.com/maciejtreder/angular-twilio-authy.git cd angular-twilio-authy git co step1 npm install ng serve
ng add @ng-toolkit/universal
import 'zone.js/dist/zone-node'; import 'reflect-metadata'; import {enableProdMode} from '@angular/core'; import {ngExpressEngine} from '@nguniversal/express-engine'; import {provideModuleMap} from '@nguniversal/module-map-ngfactory-loader'; import * as express from 'express'; import * as bodyParser from 'body-parser'; import * as cors from 'cors'; import * as compression from 'compression'; enableProdMode(); export const app = express(); app.use(compression()); app.use(cors()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); const {AppServerModuleNgFactory, LAZY_MODULE_MAP} = require('./dist/server/main'); app.engine('html', ngExpressEngine({ bootstrap: AppServerModuleNgFactory, providers: [ provideModuleMap(LAZY_MODULE_MAP) ] })); app.set('view engine', 'html'); app.set('views', './dist/browser'); app.post('/auth/login', (req, res) => { if (req.body.login === 'foo' && req.body.password === 'bar') { res.status(200).send({login: 'foo'}); } else { res.status(401).send('Bad credentials'); } }); app.get('*.*', express.static('./dist/browser', { maxAge: '1y' })); app.get('/*', (req, res) => { res.render('index', {req, res}, (err, html) => { if (html) { res.send(html); } else { console.error(err); res.send(err); } }); });
HttpPost
endpoint, /auth/login, that validates the credentials supplied by the user against the values known by the application, as shown below. Although the login credentials are hard-coded here, in a production application they’d typically be validated against a persistent data store using a query.app.post('/auth/login', (req, res) => { if (req.body.login === 'foo' && req.body.password === 'bar') { res.status(200).send({login: 'foo'}); } else { res.status(401).send('Bad credentials'); } });
AuthService
in the application will consume the server’s /auth/login end point. To change implementation of the AuthService
, replace the code in the src/app/auth.service.ts file with the following:import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { HttpClient } from '@angular/common/http'; import { tap } from 'rxjs/operators'; import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class AuthService { private authenticated = false; private redirectUrl: string; constructor(private router: Router, private http: HttpClient) { } public setRedirectUrl(url: string) { this.redirectUrl = url; } public auth(login: string, password: string): Observable<any> { return this.http.post<any>('/auth/login', {login: login, password: password}).pipe( tap( () => { this.authenticated = true; this.redirectUrl = this.redirectUrl === undefined ? '/' : this.redirectUrl; this.router.navigate([this.redirectUrl]); }) ); } public isAuthenticated(): boolean { return this.authenticated; } }
auth
method no longer returns void
and now returns an Observable
the LoginComponent
needs to subscribe to it. Change the implementation of the onSubmit()
method in the src/app/login-page/login-page.component.ts file as follows:...
”) in a code block represents a section of code redacted for brevity.)... public onSubmit(): void { this.authService.auth( this.loginForm.get('login').value, this.loginForm.get('password').value ).subscribe(); }
npm run build:prod npm run server
200 (OK)
.git clone https://github.com/maciejtreder/angular-twilio-authy.git cd angular-twilio-authy git co step2 npm install npm run build:prod npm run server
npm install authy npm install cookie-parser
approved
, rejected
, no response).import 'zone.js/dist/zone-node'; import 'reflect-metadata'; import {enableProdMode} from '@angular/core'; import {ngExpressEngine} from '@nguniversal/express-engine'; import {provideModuleMap} from '@nguniversal/module-map-ngfactory-loader'; import * as express from 'express'; import * as bodyParser from 'body-parser'; import * as cors from 'cors'; import * as compression from 'compression'; import * as cookieParser from 'cookie-parser'; const API_KEY = 'Production API Key'; const authy = require('authy')(API_KEY); enableProdMode(); export const app = express(); app.use(compression()); app.use(cors()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); app.use(cookieParser()); const {AppServerModuleNgFactory, LAZY_MODULE_MAP} = require('./dist/server/main'); app.engine('html', ngExpressEngine({ bootstrap: AppServerModuleNgFactory, providers: [ provideModuleMap(LAZY_MODULE_MAP) ] })); app.set('view engine', 'html'); app.set('views', './dist/browser'); app.post('/auth/login', (req, res) => { if (req.body.login === 'foo' && req.body.password === 'bar') { authy.send_approval_request('Authy ID', { message: 'Request to login to Angular two factor authentication with Twilio' }, null, null, function(err, authResponse) { if (err) { res.status(400).send('Bad Request'); } else { res.status(200).send({token: authResponse.approval_request.uuid}); } }); } else { res.status(401).send('Bad credentials'); } }); app.get('/auth/status', (req, res) => { authy.check_approval_status(req.headers.token, (err, authResponse) => { if (err) { res.status(400).send('Bad Request.'); } else { if (authResponse.approval_request.status === 'approved') { res.cookie('authentication', 'super-encrypted-value-indicating-that-user-is-authenticated!', { maxAge: 5 * 60 * 60 * 60, httpOnly: true }); } res.status(200).send({status: authResponse.approval_request.status}); } }); }); app.get('/auth/isLogged', (req, res) => { res.status(200).send({authenticated: req.cookies.authentication === 'super-encrypted-value-indicating-that-user-is-authenticated!'}); }); app.get('*.*', express.static('./dist/browser', { maxAge: '1y' })); app.get('/*', (req, res) => { res.render('index', {req, res}, (err, html) => { if (html) { res.send(html); } else { console.error(err); res.send(err); } }); });
const API_KEY = 'Production API Key'; const authy = require('authy')(API_KEY);
login
(user ID) and password
. In a production application you would typically check the supplied values against a persistent data store.app.post('/auth/login', (req, res) => { if (req.body.login === 'foo' && req.body.password === 'bar') { authy.send_approval_request('Authy ID', { message: 'Request to login to Angular two factor authentication with Twilio' }, null, null, function(err, authResponse) { if (err) { res.status(400).send('Bad Request'); } else { res.status(200).send({token: authResponse.approval_request.uuid}); } }); } else { res.status(401).send('Bad credentials'); } });
app.get('/auth/isLogged', (req, res) => { res.status(200).send({authenticated: req.cookies.authentication === 'super-encrypted-value-indicating-that-user-is-authenticated!'}); });
httpOnly
attribute it’s inaccessible in the browser. The cookie is a great place to use a JSON Web Token (JWT) containing the user’s authorization scope or other sensitive data. The encrypted cookie also provides protection against the token data being stolen in a cross-site scripting (XSS) attack.import { Injectable, Inject, PLATFORM_ID, Optional } from '@angular/core'; import { Router } from '@angular/router'; import { HttpClient, HttpHeaders } from '@angular/common/http'; import { map, flatMap } from 'rxjs/operators'; import { Observable, timer, of, Subscription, Subject } from 'rxjs'; import { isPlatformServer } from '@angular/common'; import { REQUEST } from '@nguniversal/express-engine/tokens'; @Injectable({ providedIn: 'root' }) export class AuthService { private redirectUrl: string; constructor( private router: Router, private http: HttpClient, @Inject(PLATFORM_ID) private platformId: any, @Optional() @Inject(REQUEST) private request: any ) { } public setRedirectUrl(url: string) { this.redirectUrl = url; } public auth(login: string, password: string): Observable<any> { return this.http.post<any>('/auth/login', {login: login, password: password}).pipe( flatMap(response => this.secondFactor(response.token) ) ); } private secondFactor(token: string): Observable<any> { const httpOptions = { headers: new HttpHeaders({'Token': token}) }; const tick: Observable<number> = timer(1000, 1000); return Observable.create(subject => { let tock = 0; const timerSubscription = tick.subscribe(() => { tock++; this.http.get<any>('/auth/status', httpOptions).subscribe( response => { if (response.status === 'approved') { this.redirectUrl = this.redirectUrl === undefined ? '/' : this.redirectUrl; this.router.navigate([this.redirectUrl]); this.closeSecondFactorObservables(subject, true, timerSubscription); } else if (response.status === 'denied') { this.closeSecondFactorObservables(subject, false, timerSubscription); } }); if (tock === 60) { this.closeSecondFactorObservables(subject, false, timerSubscription); } }); }); } public isAuthenticated(): Observable<boolean> { if (isPlatformServer(this.platformId)) { return of(this.request.cookies.authentication === 'super-encrypted-value-indicating-that-user-is-authenticated!'); } return this.http.get<any>('/auth/isLogged').pipe(map(response => response.authenticated)); } private closeSecondFactorObservables(subject: Subject<any>, result: boolean, timerSubscription: Subscription): void { subject.next(result); subject.complete(); timerSubscription.unsubscribe(); } }
secondFactor()
method is called using the token from the Authy API as an argument:public auth(login: string, password: string): Observable<any> { return this.http.post<any>('/auth/login', {login: login, password: password}).pipe( flatMap(response => this.secondFactor(response.token) ) ); }
secondFactor
function uses the timer
method from the rxjs
library to emit a number every second while it waits for a response from the /auth/status endpoint on the server. If the response is approved
, the user is redirected to the URL provided to the auth
function. This URL is the path to which the user originally tried to navigate before being required to sign in.denied
, or there is no response after 60 seconds, the observable returns false
and closes.private secondFactor(token: string): Observable<any> { const httpOptions = { headers: new HttpHeaders({ 'Token': token }) }; const tick: Observable<number> = timer(1000, 1000); return Observable.create(subject => { let tock = 0; const timerSubscription = tick.subscribe(() => { tock++; this.http.get<any>('/auth/status', httpOptions).subscribe( response => { if (response.status === 'approved') { this.redirectUrl = this.redirectUrl === undefined ? '/' : this.redirectUrl; this.router.navigate([this.redirectUrl]); this.closeSecondFactorObservables(subject, true, timerSubscription); } else if (response.status === 'denied') { this.closeSecondFactorObservables(subject, false, timerSubscription); } }); if (tock === 60) { this.closeSecondFactorObservables(subject, false, timerSubscription); } }); }); }
canActivate
method of AuthGuardService
needs to consume the AuthService
through an observable and return the redirect URL instead of a boolean. Make these changes by copying the following code into the src/app/auth-guard.service.ts file:import { Injectable } from '@angular/core'; import { CanActivate, Router } from '@angular/router'; import { AuthService } from './auth.service'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @Injectable({ providedIn: 'root' }) export class AuthGuardService implements CanActivate { constructor(private authService: AuthService, private router: Router) { } public canActivate(): Observable<boolean> { return this.authService.isAuthenticated().pipe(map(isAuth => { if (!isAuth) { this.authService.setRedirectUrl(this.router.url); this.router.navigate(['login']); } return isAuth; })); } }
LoginPageComponent
should keep the user informed about the status of the login process. This can be done with a rxjs BehaviorSubject, which is a type of observable that enables the LoginPageComponent
to send the values of login
and password
to the auth
method and receive the status of the authentication attempt.import { Component } from '@angular/core'; import { FormGroup, FormControl } from '@angular/forms'; import { AuthService } from '../auth.service'; import { BehaviorSubject, Subject, throwError } from 'rxjs'; import { catchError } from 'rxjs/operators'; @Component({ selector: 'app-login-page', templateUrl: './login-page.component.html', styleUrls: ['./login-page.component.css'] }) export class LoginPageComponent { public message: Subject<string> = new BehaviorSubject(''); public loginForm: FormGroup = new FormGroup({ login: new FormControl(''), password: new FormControl('') }); constructor(private authService: AuthService) { } public onSubmit(): void { this.message.next('Waiting for second factor.'); this.authService.auth( this.loginForm.get('login').value, this.loginForm.get('password').value ).pipe( catchError(() => { this.message.next('Bad credentials.'); return throwError('Not logged in!'); }) ) .subscribe(response => { if (!response) { this.message.next('Request timed out or not authorized'); } }); } }
message
can then be displayed on the login page. Add the following code at the bottom of the existing code in the src/app/login-page/login-page.component.html file:... <h1>{{message | async}}</h1>
npm run build:prod npm run server
LoginPageComponent
receiving the once-per-second push notification from the observable secondFactor
in the AuthService
. These events will continue to occur until Authy receives a response from the app on your phone or 60 seconds have elapsed.isLogged
response and the cookie:git clone https://github.com/maciejtreder/angular-twilio-authy.git cd angular-twilio-authy git co step3 npm install npm run build:prod npm run server
// generated by @ng-toolkit/universal const port = process.env.PORT || 8080; const serverApp = require('./dist/server'); const https = require('https'); const fs = require('fs'); const options = { key: fs.readFileSync( './localhost.key' ), cert: fs.readFileSync( './localhost.cert' ), requestCert: false, rejectUnauthorized: false }; const httpsServer = https.createServer( options, serverApp.app ); httpsServer.listen(port, () => { console.log("Listening on: https://localhost:" + port ); });