Создание веб-приложения с двухфакторной аутентификацией на Angular 7
В этой статье мы узнаем, как интегрировать двухфакторную аутентификацию Google Authenticator в приложение Angular 7, используя Node JS на стороне сервера. Изучив это руководство, вы сможете создать приложение с простой формой для регистрации и авторизации с помощью двухфакторной аутентификации.
Репозиторий с исходным кодом на GitHub
Ресурсы
После установки перечисленных выше ресурсов займемся созданием API для приложения.
Шаг 1: серверное приложение
Для создания API-сервисов мы будем использовать небольшой фреймворк для Node.js, который называется Express.js. Создадим папку ‘back-end’ для нашего серверного приложения. Затем перейдём в неё в терминале командной строки и установим необходимые зависимости.
> mkdir back-end
> cd back-end
> npm init -y
> npm install --save express body-parser cors qrcode speakeasy
Мы создали папку ‘back-end’ и инициализировали проект Node.js, установив следующие зависимости:
- express — это небольшой настраиваемый фреймворк для создания API сервисов.
- body-parser — для анализа методов HTTP.
- cors — пакет используется для интеграции клиентской части веб-приложения с API сервисами.
- qrcode — отвечает за генерацию QR-код в виде изображений base64.
- speakeasy — генератор секретных ключей по алгоритму T-OTP, который использует Google Authenticator.
Теперь создадим несколько API-сервисов, в которых главным исполняемым файлом будет app.js. Для упрощения изучения материала мы опустим код, отвечающий за взаимодействие с базой данных.

Структура папок для серверной части
API-сервисы реализуют функционал входа в систему, регистрации и TFA (двухфакторную аутентификацию):
Сервис входа в систему
Включает в себя базовую функциональность для входа с помощью логина, пароля и кода аутентификации.
const express = require('express');
const speakeasy = require('speakeasy');
const commons = require('./commons');
const router = express.Router();
router.post('/login', (req, res) => {
console.log(`DEBUG: Received login request`);
if (commons.userObject.uname && commons.userObject.upass) {
if (!commons.userObject.tfa || !commons.userObject.tfa.secret) {
if (req.body.uname == commons.userObject.uname && req.body.upass == commons.userObject.upass) {
console.log(`DEBUG: Login without TFA is successful`);
return res.send({
"status": 200,
"message": "success"
});
}
console.log(`ERROR: Login without TFA is not successful`);
return res.send({
"status": 403,
"message": "Invalid username or password"
});
} else {
if (req.body.uname != commons.userObject.uname || req.body.upass != commons.userObject.upass) {
console.log(`ERROR: Login with TFA is not successful`);
return res.send({
"status": 403,
"message": "Invalid username or password"
});
}
if (!req.headers['x-tfa']) {
console.log(`WARNING: Login was partial without TFA header`);
return res.send({
"status": 206,
"message": "Please enter the Auth Code"
});
}
let isVerified = speakeasy.totp.verify({
secret: commons.userObject.tfa.secret,
encoding: 'base32',
token: req.headers['x-tfa']
});
if (isVerified) {
console.log(`DEBUG: Login with TFA is verified to be successful`);
return res.send({
"status": 200,
"message": "success"
});
} else {
console.log(`ERROR: Invalid AUTH code`);
return res.send({
"status": 206,
"message": "Invalid Auth Code"
});
}
}
}
return res.send({
"status": 404,
"message": "Please register to login"
});
});
module.exports = router;
В этой статье мы не будем использовать базу данных для хранения данных пользователей. Поэтому реализуем это на стороне сервера.
let userObject = {};
module.exports = {
userObject
};
Сервис регистрации
Регистрация пользователя в приложении будет заключаться в добавлении логина и пароля в объект userObject. А также в удалении существующей в нем информации. Модули входа и регистрации создавались исключительно в демонстрационных целях, поэтому приложение будет поддерживать только одного пользователя.
const commons = require('./commons');
const router = express.Router();
router.post('/register', (req, res) => {
console.log(`DEBUG: Received request to register user`);
const result = req.body;
if ((!result.uname && !result.upass) || (result.uname.trim() == "" || result.upass.trim() == "")) {
return res.send({
"status": 400,
"message": "Username/ password is required"
});
}
commons.userObject.uname = result.uname;
commons.userObject.upass = result.upass;
delete commons.userObject.tfa;
return res.send({
"status": 200,
"message": "User is successfully registered"
});
});
module.exports = router;
Сервис TFA
Сервис предназначен для реализации двухфакторной аутентификации наряду с верификацией кода T-OTP, сгенерированного Google Authenticator. Он будет включать в себя функциональность для получения настроек TFA, а также включения или отключения TFA для userObject.
const express = require('express');
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
const commons = require('./commons');
const router = express.Router();
router.post('/tfa/setup', (req, res) => {
console.log(`DEBUG: Received TFA setup request`);
const secret = speakeasy.generateSecret({
length: 10,
name: commons.userObject.uname,
issuer: 'NarenAuth v0.0'
});
var url = speakeasy.otpauthURL({
secret: secret.base32,
label: commons.userObject.uname,
issuer: 'NarenAuth v0.0',
encoding: 'base32'
});
QRCode.toDataURL(url, (err, dataURL) => {
commons.userObject.tfa = {
secret: '',
tempSecret: secret.base32,
dataURL,
tfaURL: url
};
return res.json({
message: 'TFA Auth needs to be verified',
tempSecret: secret.base32,
dataURL,
tfaURL: secret.otpauth_url
});
});
});
router.get('/tfa/setup', (req, res) => {
console.log(`DEBUG: Received FETCH TFA request`);
res.json(commons.userObject.tfa ? commons.userObject.tfa : null);
});
router.delete('/tfa/setup', (req, res) => {
console.log(`DEBUG: Received DELETE TFA request`);
delete commons.userObject.tfa;
res.send({
"status": 200,
"message": "success"
});
});
router.post('/tfa/verify', (req, res) => {
console.log(`DEBUG: Received TFA Verify request`);
let isVerified = speakeasy.totp.verify({
secret: commons.userObject.tfa.tempSecret,
encoding: 'base32',
token: req.body.token
});
if (isVerified) {
console.log(`DEBUG: TFA is verified to be enabled`);
commons.userObject.tfa.secret = commons.userObject.tfa.tempSecret;
return res.send({
"status": 200,
"message": "Two-factor Auth is enabled successfully"
});
}
console.log(`ERROR: TFA is verified to be wrong`);
return res.send({
"status": 403,
"message": "Invalid Auth Code, verification failed. Please verify the system Date and Time"
});
});
module.exports = router;
Упомянутые выше сервисы включены в один исполняемый файл ‘app.js’, расположенный в корневой папке. Этот код запустит HTTP-сервер, созданный с помощью express.js на локальном хосте с портом 3000.
const express = require('express');
const app = express();
const bodyParser = require('body-parser');
const cors = require('cors');
const login = require('./routes/login');
const register = require('./routes/register');
const tfa = require('./routes/tfa');
app.use(bodyParser.json());
app.use(cors());
app.use(login);
app.use(register);
app.use(tfa);
app.listen('3000', () => {
console.log('The server started running on http://localhost:3000');
});
Мы реализовали серверную часть кода веб-приложения.

Следующим шагом будет создание простого приложения, использующего эти сервисы на Angular 7.
Шаг 2: приложение на базе Angular 7
Сначала нужно установить Angular. После этого мы создадим приложение с названием ‘front-end
’ и установим зависимость от ‘bootstrap
’ (ссылки на bootstrap.min.css
в styles.css
), перейдя в папку front-end
.
> npm install -g @angular/cli
> ng new front-end
> cd front-end
> npm install --save bootstrap
> ng serve
После этого создадим несколько компонентов и сервисов, которые требуются приложению.
Для целей демонстрации мы создадим LoginService и два guards — ‘Auth Guard’ и ‘Login Guard.’
> ng g s services/login-service/login-service --spec=false
> ng g g guards/AuthGuard
> ng g g guards/Login
Guards, которые мы создаем, относятся к типу CanActivate. Сервис входа в систему будет включать в себя HTTP-запросы к сервисам, созданным на стороне сервера.
import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Subject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class LoginServiceService {
headerOptions: any = null
_isLoggedIn: boolean = false
authSub = new Subject<any>();
constructor(private _http: HttpClient) {
}
loginAuth(userObj: any) {
if (userObj.authcode) {
console.log('Appending headers');
this.headerOptions = new HttpHeaders({
'x-tfa': userObj.authcode
});
}
return this._http.post("http://localhost:3000/login", { uname: userObj.uname, upass: userObj.upass }, { observe: 'response', headers: this.headerOptions });
}
setupAuth() {
return this._http.post("http://localhost:3000/tfa/setup", {}, { observe: 'response' })
}
registerUser(userObj: any) {
return this._http.post("http://localhost:3000/register", { uname: userObj.uname, upass: userObj.upass }, { observe: "response" });
}
updateAuthStatus(value: boolean) {
this._isLoggedIn = value
this.authSub.next(this._isLoggedIn);
localStorage.setItem('isLoggedIn', value ? "true" : "false");
}
getAuthStatus() {
this._isLoggedIn = localStorage.getItem('isLoggedIn') == "true" ? true : false;
return this._isLoggedIn
}
logoutUser() {
this._isLoggedIn = false;
this.authSub.next(this._isLoggedIn);
localStorage.setItem('isLoggedIn', "false")
}
getAuth() {
return this._http.get("http://localhost:3000/tfa/setup", { observe: 'response' });
}
deleteAuth() {
return this._http.delete("http://localhost:3000/tfa/setup", { observe: 'response' });
}
verifyAuth(token: any) {
return this._http.post("http://localhost:3000/tfa/verify", { token }, { observe: 'response' });
}
}
AuthGuard ограничит навигацию пользователя только домашней страницей, если тот не вошел в систему.
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
import { LoginServiceService } from 'src/app/services/login-service/login-service.service';
@Injectable({
providedIn: 'root'
})
export class AuthGuardGuard implements CanActivate {
constructor(private _loginService: LoginServiceService, private _router: Router) {
}
canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
if (this._loginService.getAuthStatus()) {
return true;
}
this._router.navigate(['/login'])
return false;
}
}
LoginGuard не позволит пользователю заходить на страницу авторизации, если пользователь уже вошел в систему.
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, Router } from '@angular/router';
import { LoginServiceService } from 'src/app/services/login-service/login-service.service';
@Injectable({
providedIn: 'root'
})
export class LoginGuard implements CanActivate {
constructor(private _loginService: LoginServiceService, private _router: Router) {
}
canActivate(
next: ActivatedRouteSnapshot,
state: RouterStateSnapshot) {
if (!this._loginService.getAuthStatus()) {
return true;
}
this._router.navigate(['/home'])
return false;
}
}
Мы завершили создание основы для нашего приложения, создав службы и guards. Теперь разработаем несколько компонентов.
> ng g c components/header --spec=false
> ng g c components/home --spec=false
> ng g c components/login --spec=false
> ng g c components/register --spec=false
После создания необходимых компонентов приложения мы настроим маршрутизацию для приложения, связав соответствующие guards для активации маршрутов.
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './components/home/home.component';
import { LoginComponent } from './components/login/login.component';
import { RegisterComponent } from './components/register/register.component';
import { AuthGuardGuard } from './guards/AuthGuard/auth-guard.guard';
import { LoginGuard } from './guards/Login/login.guard';
const routes: Routes = [
{ path: "", redirectTo: '/login', pathMatch: 'full', canActivate: [LoginGuard] },
{ path: "login", component: LoginComponent, canActivate: [LoginGuard] },
{ path: "home", component: HomeComponent, canActivate: [AuthGuardGuard] },
{ path: "register", component: RegisterComponent, canActivate: [LoginGuard] },
{ path: "**", redirectTo: '/login', pathMatch: 'full', canActivate: [LoginGuard] }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
Теперь удалим код, добавленный по умолчанию в app.component.html, и вставим компонент общего заголовка и вывод маршрутизатора.
<app-header></app-header>
<router-outlet></router-outlet>
Компонент заголовка
Это общий компонент для других компонентов, который включает в себя панель навигации приложения. Видимость ссылок в заголовке контролируется методом getAuthStatus() сервиса LoginService.
<nav class="navbar navbar-expand-sm navbar-dark bg-dark" style="z-index: 99999;">
<a class="navbar-brand" [routerLink]="['/login']">NarenAuth v0.0</a>
<button class="navbar-toggler d-lg-none" type="button" data-toggle="collapse" data-target="#collapsibleNavId"
aria-controls="collapsibleNavId" aria-expanded="false" aria-label="Toggle navigation" (click)="toggleMenuBar()">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="collapsibleNavId">
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
<li class="nav-item" [routerLinkActive]="['active']" *ngIf="!isLoggedIn">
<a class="nav-link" [routerLink]="['/login']">Login</a>
</li>
<li class="nav-item" [routerLinkActive]="['active']" *ngIf="!isLoggedIn">
<a class="nav-link" [routerLink]="['/register']">Register</a>
</li>
</ul>
<ul class="navbar-nav ml-auto">
<li class="nav-item" *ngIf="isLoggedIn">
<a class="nav-link" (click)="logout()">Logout</a>
</li>
</ul>
</div>
</nav>
В фоновом режиме мы также запросим файл *.ts для компонента заголовка.
import { Component, OnInit } from '@angular/core';
import { LoginServiceService } from 'src/app/services/login-service/login-service.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-header',
templateUrl: './header.component.html',
styleUrls: ['./header.component.css']
})
export class HeaderComponent implements OnInit {
isLoggedIn: boolean = false
constructor(private _loginService: LoginServiceService, private _router: Router) {
this._loginService.authSub.subscribe((data) => {
this.isLoggedIn = data
})
}
ngOnInit() {
this.isLoggedIn = this._loginService.getAuthStatus()
}
toggleMenuBar() {
if(document.getElementById("collapsibleNavId").style.display == "block") {
document.getElementById("collapsibleNavId").style.display = "none";
} else {
document.getElementById("collapsibleNavId").style.display = "block";
}
}
logout() {
this._loginService.logoutUser()
this._router.navigate(['/login'])
}
}
Компонент входа в систему
Предназначен для получения логина, пароля и кода AuthCode (если включен TFA) от пользователя и его проверки на стороне сервера. Если данные верны, то пользователь будет перемещен в HomeComponent.
<div class="container">
<div class="card card-container">
<img id="profile-img" class="profile-img-card" src="assets/images/avatar_2x.png" />
<form class="form-signin" (ngSubmit)="loginUser()" #loginForm="ngForm">
<input type="text" id="uname" class="form-control" name="uname" autocomplete="off" #uname="ngModel"
[(ngModel)]="userObject.uname" placeholder="Username" title="Please enter the username" required autofocus>
<input type="password" id="upass" class="form-control" name="upass" autocomplete="off" #upass="ngModel"
[(ngModel)]="userObject.upass" placeholder="Password" title="Please enter the password" required>
<input type="text" id="authcode" class="form-control" *ngIf="this.tfaFlag" name="authcode" autocomplete="off"
#authcode="ngModel" [(ngModel)]="userObject.authcode" placeholder="Two-Factor Auth code"
title="Please enter the code" required>
<button class="btn btn-lg btn-primary btn-block btn-signin" type="submit"
[disabled]="uname?.errors?.required || upass?.errors?.required || (this.tfaFlag && authcode?.errors?.required)">Sign
in</button>
<p style="text-align:center;">Want to reset login? <a [routerLink]="['/register']">Register
here</a></p>
<p class="text-danger" style="text-align:center;" *ngIf="errorMessage">{{errorMessage}}</p>
</form>
</div>
</div>
Мы также будем проверять статус, полученный от серверной части кода, для отображения сообщений пользователю.
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router'
import { LoginServiceService } from 'src/app/services/login-service/login-service.service';
@Component({
selector: 'app-login',
templateUrl: './login.component.html',
styleUrls: ['./login.component.css']
})
export class LoginComponent implements OnInit {
tfaFlag: boolean = false
userObject = {
uname: "",
upass: ""
}
errorMessage: string = null
constructor(private _loginService: LoginServiceService, private _router: Router) {
}
ngOnInit() {
}
loginUser() {
this._loginService.loginAuth(this.userObject).subscribe((data) => {
this.errorMessage = null;
if (data.body['status'] === 200) {
this._loginService.updateAuthStatus(true);
this._router.navigateByUrl('/home');
}
if (data.body['status'] === 206) {
this.tfaFlag = true;
}
if (data.body['status'] === 403) {
this.errorMessage = data.body['message'];
}
if (data.body['status'] === 404) {
this.errorMessage = data.body['message'];
}
})
}
}
Компонент регистрации
Мы зарегистрируем одного пользователя во всем приложении.
<div class="container">
<div class="card card-container">
<img id="profile-img" class="profile-img-card" src="assets/images/avatar_2x.png" />
<form class="form-signin" (ngSubmit)="registerUser()" #registerForm="ngForm">
<input type="text" id="uname" class="form-control" name="uname" #uname="ngModel" [(ngModel)]="userObject.uname"
placeholder="Username" title="Please enter the username" autocomplete="off" required autofocus>
<input type="password" id="upass" class="form-control" name="upass" placeholder="Password"
title="Please enter the password" #upass="ngModel" autocomplete="off" [(ngModel)]="userObject.upass" required>
<input type="password" id="confirmpass" class="form-control" name="confirmpass" placeholder="Confirm password"
title="Please re-enter the password" #uconfirmpass="ngModel" autocomplete="off" [(ngModel)]="confirmPass"
required>
<button class="btn btn-lg btn-primary btn-block btn-signin" type="submit"
[disabled]="(uname?.errors?.required || upass?.errors?.required || uconfirmpass?.errors?.required) || (upass.value !== uconfirmpass.value)">Sign
up</button>
<p style="text-align:center;">Remember credentials? <a [routerLink]="['/login']">Login
here</a></p>
<p class="text-success" style="text-align:center;" *ngIf="errorMessage">{{errorMessage}}</p>
</form>
</div>
</div>
Если вы забудете логин и пароль для приложения, или секретный ключ TFA, просто введите новое имя пользователя и пароль на странице.
import { Component, OnInit } from '@angular/core';
import { LoginServiceService } from 'src/app/services/login-service/login-service.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-register',
templateUrl: './register.component.html',
styleUrls: ['./register.component.css']
})
export class RegisterComponent implements OnInit {
errorMessage: string = null
userObject = {
uname: "",
upass: ""
}
confirmPass: string = ""
constructor(private _loginService: LoginServiceService, private _router: Router) { }
ngOnInit() {
}
registerUser() {
if (this.userObject.uname.trim() !== "" && this.userObject.upass.trim() !== "" && (this.userObject.upass.trim() === this.confirmPass))
this._loginService.registerUser(this.userObject).subscribe((data) => {
const result = data.body
if (result['status'] === 200) {
this.errorMessage = result['message'];
setTimeout(() => {
this._router.navigate(['/login']);
}, 2000);
}
});
}
}

Страница входа и регистрации
Как только пользователь зарегистрировался и вошел в систему с логином и паролем, ему будет предоставлена возможность включения и отключения двухфакторной аутентификации в HomeComponent.
Компонент Home
Позволят пользователю настраивать и проверять TFA. Как только пользователь попадет на эту страницу, он сможет отсканировать QR-код в приложении Google Authenticator. После сканирования T-OTP (элемент TFA), связанный с userObject, будет включен в приложение Google Authenticator. AuthCode будет отображаться в приложении на временной основе. Тот же код необходим для проверки и включения TFA для userObject.
<div class="container">
<div class="card card-container">
<div *ngIf="this.tfa.secret">
<h5 style="border-bottom: 1px solid #a8a8a8; padding-bottom: 5px;">Current Settings</h5>
<img [src]="tfa.dataURL" alt="" class="img-thumbnail" style="display:block;margin:auto">
<p>Secret Key - {{tfa.secret || tfa.tempSecret}}</p>
<p>Auth Type - Time Based - OTP</p>
<button class="btn btn-lg btn-danger btn-block btn-signin" (click)="disabledTfa()">Disable TFA</button>
</div>
<div *ngIf="!tfa.secret">
<h5 style="border-bottom: 1px solid #a8a8a8; padding-bottom: 5px;">Setup TFA</h5>
<span *ngIf="!!tfa.tempSecret">
<p>Scan the QR code or enter the secret key in Google Authenticator</p>
<img [src]="tfa.dataURL" alt="" class="img-thumbnail" style="display:block;margin:auto">
<p>Secret Key - {{tfa.tempSecret}}</p>
<p>Auth Type - Time Based - OTP</p>
<form class="form-group" (ngSubmit)="confirm()" #otpForm="ngForm">
<input name="authcode" type="number" #iauthcode="ngModel" class="form-control" maxlength="6"
placeholder="Enter the Auth Code" id="authcode" autocomplete="off" [(ngModel)]="authcode" required>
<br>
<button type="Submit" class="btn btn-lg btn-primary btn-block btn-signin"
[disabled]="iauthcode?.errors?.required">Enable TFA</button>
</form>
<p class="text-danger" style="text-align:center;" *ngIf="errorMessage">{{errorMessage}}</p>
</span>
</div>
</div>
</div>
Если пользователь включил TFA, то будут отображены текущие настройки с QR-кодом и секретным ключом. А также опция отключения TFA, связанного с userObject.
import { Component, OnInit } from '@angular/core';
import { LoginServiceService } from 'src/app/services/login-service/login-service.service';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {
tfa: any = {};
authcode: string = "";
errorMessage: string = null;
constructor(private _loginService: LoginServiceService) {
this.getAuthDetails();
}
ngOnInit() {
}
getAuthDetails() {
this._loginService.getAuth().subscribe((data) => {
const result = data.body
if (data['status'] === 200) {
console.log(result);
if (result == null) {
this.setup();
} else {
this.tfa = result;
}
}
});
}
setup() {
this._loginService.setupAuth().subscribe((data) => {
const result = data.body
if (data['status'] === 200) {
console.log(result);
this.tfa = result;
}
});
}
confirm() {
this._loginService.verifyAuth(this.authcode).subscribe((data) => {
const result = data.body
if (result['status'] === 200) {
console.log(result);
this.errorMessage = null;
this.tfa.secret = this.tfa.tempSecret;
this.tfa.tempSecret = "";
} else {
this.errorMessage = result['message'];
}
});
}
disabledTfa() {
this._loginService.deleteAuth().subscribe((data) => {
const result = data.body
if (data['status'] === 200) {
console.log(result);
this.authcode = "";
this.getAuthDetails();
}
});
}
}

Установка и текущие настройки TFA
Теперь вы знаете, как легко интегрировать двухфакторную аутентификацию в приложение, созданное на основе Angular 7.