Создание веб-приложения с двухфакторной аутентификацией на Angular 7

В данной статье мы узнаем, как интегрировать двухфакторную аутентификацию Google Authenticator в приложение Angular 7, используя Node JS на стороне сервера. Изучив это руководство, вы сможете создать приложение с простой формой для регистрации и авторизации при помощи двухфакторной аутентификации.

Репозиторий с исходным кодом на GitHub

Ресурсы

  1. Node JS(LTS) — [ Скачать ].
  2. Google Authenticator — [ Скачать : Android] [ Скачать : iOS]

После установки перечисленных выше ресурсов займемся созданием 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, установив следующие зависимости:

  1. express — это небольшой настраиваемый фреймворк для создания API сервисов.
  2. body-parser — для анализа способов HTTP.
  3. cors — пакет используется для интеграции клиентской части веб-приложения с API сервисами.
  4. qrcode — отвечает за генерацию QR-код в виде картинок base64.
  5. speakeasy — генератор секретных ключей по алгоритму T-OTP, который использует Google Authenticator.

Сейчас создадим пару API-сервисов, в которых главным исполняемым файлом будет app.js. Для упрощения изучения материала мы опустим программный код, отвечающий за взаимодействие с базой данных.

Создание веб-приложения с двухфакторной аутентификацией на Angular 7
Структура папок для серверной части

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

Следующим шагом будет создание простого приложения, использующего данные сервисы на 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);        }      });  }}

Создание веб-приложения с двухфакторной аутентификацией на Angular 7
Страница входа и регистрации

Как только посетитель зарегистрировался и вошел в систему с логином и паролем, ему будет предоставлена функция включения и отключения двухфакторной аутентификации в 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();      }    });  }}

Создание веб-приложения с двухфакторной аутентификацией на Angular 7
Установка и текущие параметра TFA

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *