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.
351 lines
11 KiB
351 lines
11 KiB
// auth.js
|
|
// Copyright (C) 2024 DTP Technologies, LLC
|
|
// All Rights Reserved
|
|
|
|
'use strict';
|
|
|
|
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 = this.createRouter('/auth');
|
|
|
|
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('/');
|
|
});
|
|
});
|
|
}
|
|
}
|