DTP Social Engine
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.
 
 
 
 
 

229 lines
6.8 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 removeForUser (user) {
return await OtpAccount.deleteMany({ user: user });
}
}
module.exports = {
slug: 'otp-auth',
name: 'otpAuth',
create: (dtp) => { return new OtpAuthService(dtp); },
};