// 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('/'); }); }); } }