DTP Base provides a scalable and secure Node.js application development harness ready for production service.
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.
 
 
 
 

353 lines
11 KiB

// auth.js
// Copyright (C) 2024 DTP Technologies, LLC
// All Rights Reserved
'use strict';
import express from 'express';
import multer from 'multer';
import mongoose from 'mongoose';
const ConnectToken = mongoose.model('ConnectToken');
import { v4 as uuidv4 } from 'uuid';
import SvgCaptcha from 'svg-captcha';
import { SiteController, SiteError } from '../../lib/site-lib.js';
export default class AuthController extends SiteController {
static get name ( ) { return 'AuthController'; }
static get slug ( ) { return 'auth'; }
constructor (dtp) {
super(dtp, AuthController);
}
async start ( ) {
const {
csrfToken: csrfTokenService,
limiter: limiterService,
session: sessionService,
} = this.dtp.services;
this.templates = {
passwordResetComplete: this.loadViewTemplate('auth/password-reset-complete.pug'),
};
const upload = multer({ });
const authRequired = sessionService.authCheckMiddleware({ requireLogin: true });
const router = express.Router();
this.dtp.app.use('/auth', router);
router.use(async (req, res, next) => {
res.locals.currentView = 'auth';
return next();
});
router.post(
'/otp/enable',
limiterService.create(limiterService.config.auth.postOtpEnable),
this.postOtpEnable.bind(this),
);
router.post(
'/otp/auth',
limiterService.create(limiterService.config.auth.postOtpAuthenticate),
this.postOtpAuthenticate.bind(this),
);
router.post(
'/forgot-password',
limiterService.create(limiterService.config.auth.postForgotPassword),
csrfTokenService.middleware({ name: 'forgot-password' }),
this.postForgotPassword.bind(this),
);
router.post(
'/password-reset',
limiterService.create(limiterService.config.auth.postPasswordReset),
upload.none(),
csrfTokenService.middleware({ name: 'password-reset' }),
this.postPasswordReset.bind(this),
);
router.post(
'/login',
limiterService.create(limiterService.config.auth.postLogin),
upload.none(),
csrfTokenService.middleware({ name: 'login' }),
this.postLogin.bind(this),
);
router.get(
'/api-token/personal',
authRequired,
limiterService.create(limiterService.config.auth.getPersonalApiToken),
this.getPersonalApiToken.bind(this),
);
router.get(
'/socket-token',
authRequired,
limiterService.create(limiterService.config.auth.getSocketToken),
this.getSocketToken.bind(this),
);
router.get(
'/forgot-password',
limiterService.create(limiterService.config.auth.getForgotPasswordForm),
this.getForgotPasswordForm.bind(this),
);
router.get(
'/password-reset',
limiterService.create(limiterService.config.auth.getResetPasswordForm),
this.getResetPasswordForm.bind(this),
);
router.get(
'/logout',
authRequired,
limiterService.create(limiterService.config.auth.getLogout),
this.getLogout.bind(this),
);
return router;
}
async postOtpEnable (req, res, next) {
const {
actionAudit: actionAuditService,
otpAuth: otpAuthService,
} = this.dtp.services;
const service = req.body['otp-service'];
const secret = req.body['otp-secret'];
const token = req.body['otp-token'];
const otpRedirectURL = req.body['otp-redirect'] || '/';
try {
this.log.info('enabling OTP protections', { service, secret, token });
res.locals.otpAccount = await otpAuthService.createOtpAccount(req, service, secret, token);
res.locals.otpRedirectURL = otpRedirectURL;
await actionAuditService.auditRequest(req, 'Enabled 2FA');
res.render('otp/new-account');
} catch (error) {
this.log.error('failed to enable OTP protections', {
service, error,
});
return next(error);
}
}
async postOtpAuthenticate (req, res, next) {
const {
actionAudit: actionAuditService,
otpAuth: otpAuthService,
} = this.dtp.services;
if (!req.user) {
return res.status(403).json({
success: false,
message: 'Must be logged in',
});
}
const service = req.body['otp-service'];
if (!service) {
return res.status(400).json({
success: false,
message: 'Must specify OTP service name',
});
}
const passcode = req.body['otp-passcode'];
if (!passcode || (typeof passcode !== 'string') || (passcode.length !== 6)) {
return res.status(400).json({
success: false,
message: 'Must include a valid passcode',
});
}
try {
await otpAuthService.startOtpSession(req, service, passcode);
await actionAuditService.auditRequest(req, '2FA credentials provided successfully');
return res.redirect(req.body['otp-redirect']);
} catch (error) {
this.log.error('failed to verify one-time password for 2FA', {
service, error,
}, req.user);
return next(error);
}
}
async postForgotPassword (req, res, next) {
const { user: userService } = this.dtp.services;
try {
res.locals.account = await userService.requestPasswordReset(req.body.username);
res.render('auth/password-reset-sent');
} catch (error) {
this.log.error("failed to request password reset", { error });
return next(error);
}
}
async postPasswordReset (req, res) {
const { authToken: authTokenService, user: userService } = this.dtp.services;
try {
if (!req.body.password) {
return res.status(200).json({ success: false, message: 'Please enter a new password' });
}
if ((typeof req.body.password !== 'string') || (req.body.length < 8)) {
return res.status(200).json({ success: false, message: 'The password is invalid or too short' });
}
if (!req.body.passwordVerify) {
return res.status(200).json({ success: false, message: 'Please also verify your new password' });
}
if (req.body.password !== req.body.passwordVerify) {
return res.status(200).json({ success: false, message: 'The password and password verify do not match.' });
}
res.locals.authToken = await authTokenService.claim(req.body.authToken);
res.locals.accountId = mongoose.Types.ObjectId.createFromHexString(req.body.accountId);
res.locals.account = await userService.getUserAccount(res.locals.accountId);
this.log.alert('updating user password', {
userId: res.locals.account._id,
username: res.locals.account.username_lc,
ip: req.ip,
});
await userService.updatePassword(res.locals.account, req.body.password);
const displayList = this.createDisplayList('password-reset');
displayList.replaceElement('section#view-content', await this.renderTemplate(this.templates.passwordResetComplete, res.locals));
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error("failed to update account password", { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async postLogin (req, res, next) {
const { user: userService } = this.dtp.services;
await userService.login(req, res, next, { redirectUrl: '/' });
}
async getPersonalApiToken (req, res, next) {
try {
const { apiGuard: apiGuardService } = this.dtp.platform.services;
res.locals.apiToken = await apiGuardService.createApiToken(req.user, [
'account-read',
// additional scopes go here
]);
res.render('api-token/view');
} catch (error) {
this.log.error('failed to generate API token', { error });
return next(error);
}
}
async getSocketToken (req, res, next) {
const { actionAudit: actionAuditService } = this.dtp.services;
try {
const token = await ConnectToken.create({
created: new Date(),
userType: 'User',
user: req.user._id,
consumerType: 'User',
consumer: req.user._id,
token: uuidv4(),
});
await actionAuditService.auditRequest(req, 'Received socket connect token');
res.status(200).json({
success: true,
token: token.token
});
} catch (error) {
this.log.error('failed to create Socket.io connect token', { error });
return next(error);
}
}
async getForgotPasswordForm (req, res) {
const { csrfToken: csrfTokenService } = this.dtp.services;
res.locals.csrfForgotPassword = await csrfTokenService.create(req, {
name: 'forgot-password',
expiresMinutes: 15,
});
res.locals.captcha = SvgCaptcha.create({
size: Math.round(Math.random() * 2) + 4,
width: 280,
height: 80,
noise: Math.floor(Math.random() * 2) + 1,
// background: '#d8d8d8',
color: false,
});
req.session.dtp = req.session.dtp || { };
req.session.dtp.captcha = req.session.dtp.captcha || { };
req.session.dtp.captcha.forgotPassword = res.locals.captcha.text;
res.render('auth/forgot-password');
}
async getResetPasswordForm (req, res, next) {
const {
csrfToken: csrfTokenService,
authToken: authTokenService,
user: userService,
} = this.dtp.services;
try {
// tokens are UUIDv4: efcfc5e3-700c-4d21-9b00-0d1a14a1ab6d
if (!req.query || !req.query.t) {
throw new SiteError(400, 'Must include authentication token');
}
res.locals.authToken = await authTokenService.getByValue(req.query.t);
res.locals.account = await userService.getUserAccount(res.locals.authToken.data._id);
delete res.locals.account.passwordSalt;
delete res.locals.account.password;
delete res.locals.account.favoriteStickers;
res.locals.csrfPasswordReset = await csrfTokenService.create(req, {
name: 'password-reset',
expiresMinutes: 15,
});
this.log.info('presenting password reset form', {
tokenId: res.locals.authToken._id,
token: res.locals.authToken.token,
userId: res.locals.account._id,
username: res.locals.account.username_lc,
});
res.render('auth/password-reset.pug');
} catch (error) {
this.log.error('failed to present password reset form', { error });
return next(error);
}
}
async getLogout (req, res, next) {
const { actionAudit: actionAuditService } = this.dtp.services;
if (!req.user) {
return next(new SiteError(403, 'You are not signed in'));
}
await actionAuditService.auditRequest(req, 'Logged out');
req.logout((err) => {
if (err) {
this.log.error('failed to destroy browser session', { err });
return next(err);
}
req.session.destroy((err) => {
if (err) {
this.log.error('failed to destroy browser session', { err });
return next(err);
}
res.redirect('/');
});
});
}
}