You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
245 lines
7.3 KiB
245 lines
7.3 KiB
// 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 * 2);
|
|
|
|
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 isUserProtected (user, serviceName) {
|
|
const account = await OtpAccount.findOne({ user: user._id, service: serviceName });
|
|
if (!account) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async destroyOtpSession (req, serviceName) {
|
|
delete req.session.otp[serviceName];
|
|
await this.saveSession(req);
|
|
}
|
|
|
|
async removeForUser (user, serviceName) {
|
|
if (serviceName) {
|
|
return OtpAccount.findOneAndDelete({ user: user._id, service: serviceName });
|
|
}
|
|
await OtpAccount.deleteMany({ user: user._id });
|
|
}
|
|
|
|
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',
|
|
className: 'OtpAuthService',
|
|
create: (dtp) => { return new OtpAuthService(dtp); },
|
|
};
|
|
|