// otp-auth.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 'use strict'; // const striptags = require('striptags'); const mongoose = require('mongoose'); const OtpAccount = mongoose.model('OtpAccount'); const ONE_HOUR = 1000 * 60 * 60; const OTP_SESSION_DURATION = (process.env.NODE_ENV === 'local') ? (ONE_HOUR * 24) : (ONE_HOUR * 8); const { authenticator } = require('otplib'); const uuidv4 = require('uuid').v4; const { SiteService, SiteError } = require('../../lib/site-lib'); class OtpAuthService extends SiteService { constructor (dtp) { super(dtp, module.exports); authenticator.options = { algorithm: 'sha1', step: 30, digits: 6, }; } middleware (serviceName, options) { options = Object.assign({ otpRequired: false, otpRedirectURL: '/', adminRequired: false, }, options); return async (req, res, next) => { res.locals.otp = { }; // will decorate view model with OTP information if (!req.session) { return next(new SiteError(403, 'Request session is invalid')); } if (!req.user) { return next(new SiteError(403, 'Must be logged in')); } if (options.adminRequired && !req.user.flags.isAdmin) { return next(new SiteError(403, 'Admin privileges are required')); } req.session.otp = req.session.otp || { }; if (await this.checkOtpSession(req, serviceName)) { return next(); // user is OTP-authenticated on this service } res.locals.otpOptions = authenticator.options; res.locals.otpServiceName = serviceName; res.locals.otpAlgorithm = authenticator.options.algorithm.toUpperCase(); res.locals.otpDigits = authenticator.options.digits; res.locals.otpPeriod = authenticator.options.step; if (typeof options.otpRedirectURL === 'function') { // allows redirect to things like /user/:userId using current session's user res.locals.otpRedirectURL = await options.otpRedirectURL(req, res); } else { res.locals.otpRedirectURL = options.otpRedirectURL; } res.locals.otpAccount = await OtpAccount .findOne({ user: req.user._id, service: serviceName, }); if (!res.locals.otpAccount && !options.otpRequired) { return next(); // route not guarded (am I a joke to you?) } if (!res.locals.otpAccount) { let issuer; if (process.env.NODE_ENV === 'production') { issuer = `${this.dtp.config.site.name}: ${serviceName}`; } else { issuer = `${this.dtp.config.site.name}:${process.env.NODE_ENV}: ${serviceName}`; } res.locals.otpTempSecret = authenticator.generateSecret(); res.locals.otpKeyURI = authenticator.keyuri(req.user.username.trim(), issuer, res.locals.otpTempSecret); req.session.otp[serviceName] = req.session.otp[serviceName] || { }; req.session.otp[serviceName].secret = res.locals.otpTempSecret; req.session.otp[serviceName].redirectURL = res.locals.otpRedirectURL; return res.render('otp/welcome'); } res.locals.otpSession = req.session.otp[serviceName]; this.log.debug('request on OTP-required route with no authentication', { service: serviceName, session: res.locals.otpSession, }, req.user); req.session.otp[serviceName] = req.session.otp[serviceName] || { }; req.session.otp[serviceName].redirectURL = res.locals.otpRedirectURL; await this.saveSession(req); if (!res.locals.otpSession || !res.locals.otpSession.isAuthenticated) { return res.render('otp/authenticate'); } return next(); }; } async createOtpAccount (req, service, secret, passcode) { const NOW = new Date(); const { crypto: cryptoService } = this.dtp.services; try { this.log.info('verifying user passcode', { user: req.user._id, username: req.user.username, service, secret, passcode, }, req.user); if (authenticator.check(passcode, secret)) { throw new SiteError(403, 'Invalid passcode'); } const backupTokens = [ ]; for (let i = 0; i < 10; ++i) { backupTokens.push({ token: cryptoService.createHash(secret + uuidv4()), }); } const now = new Date(); const account = await OtpAccount.create({ created: NOW, user: req.user._id, service, secret, algorithm: authenticator.options.algorithm, step: authenticator.options.step, digits: authenticator.options.digits, backupTokens, lastVerification: now, lastVerificationIp: req.ip, }); return account; } catch (error) { this.log.error('failed to create OTP account', { service, secret, passcode, error, }, req.user); throw error; } } async startOtpSession (req, serviceName, passcode) { if (!passcode || (typeof passcode !== 'string')) { throw new SiteError(403, 'Invalid passcode'); } try { const account = await OtpAccount .findOne({ user: req.user._id, service: serviceName }) .select('+secret') .lean(); if (!account) { throw new SiteError(400, 'Two-Factor Authentication not enabled'); } const now = new Date(); if (!authenticator.check(passcode, account.secret)) { throw new SiteError(403, 'Invalid passcode'); } req.session.otp = req.session.otp || { }; req.session.otp[serviceName] = req.session.otp[serviceName] || { }; req.session.otp[serviceName].isAuthenticated = true; req.session.otp[serviceName].expiresAt = now.valueOf() + OTP_SESSION_DURATION; await this.saveSession(req); } catch (error) { this.log.error('failed to start OTP session', { serviceName, passcode, error, }); throw error; } } async checkOtpSession (req, serviceName) { if (!req.session || !req.session.otp) { return false; } const session = req.session.otp[serviceName]; if (!session) { return false; } if (!session.isAuthenticated) { return false; } const NOW = Date.now(); if (NOW >= session.expiresAt) { session.isAuthenticated = false; delete session.expiresAt; await this.saveSession(req); return false; } session.expiresAt = NOW + OTP_SESSION_DURATION; await this.saveSession(req); return true; } async destroyOtpSession (req, serviceName) { delete req.session.otp[serviceName]; await this.saveSession(req); } async isUserProtected (user, serviceName) { const account = await OtpAccount.findOne({ user: user._id, service: serviceName }); if (!account) { return false; } return true; } async removeForUser (user, serviceName) { return await OtpAccount.findOneAndDelete({ user: user, service: serviceName }); } async getBackupTokens (user, serviceName) { const tokens = await OtpAccount.findOne({ user: user._id, service: serviceName }) .select('+backupTokens') .lean(); return tokens.backupTokens; } } module.exports = { slug: 'otp-auth', name: 'otpAuth', create: (dtp) => { return new OtpAuthService(dtp); }, };