Files
video/opentogethertube/opentogethertube/usermanager.js
2021-09-14 06:30:17 -06:00

438 lines
11 KiB
JavaScript

const { getLogger } = require('./logger.js');
const _ = require("lodash");
const securePassword = require('secure-password');
const express = require('express');
const passport = require('passport');
const crypto = require('crypto');
const rateLimit = require("express-rate-limit");
const RateLimitStore = require('rate-limit-redis');
const { User } = require("./models");
const roommanager = require("./roommanager");
const { redisClient } = require('./redisclient.js');
const pwd = securePassword();
const log = getLogger("usermanager");
const router = express.Router();
router.get("/", (req, res) => {
if (req.user) {
let user = {
username: req.user.username,
loggedIn: true,
};
res.json(user);
}
else {
res.json({
username: req.session.username,
loggedIn: false,
});
}
});
router.post("/", async (req, res) => {
if (!req.body.username) {
res.status(400).json({
success: false,
error: {
message: "Missing argument (username)",
},
});
return;
}
let oldUsername;
if (req.user) {
oldUsername = req.user.username;
req.user.username = req.body.username;
try {
await req.user.save();
}
catch (err) {
if (err.name === "SequelizeUniqueConstraintError") {
await req.user.reload();
res.status(400).json({
success: false,
error: {
name: "UsernameTaken",
message: "Somebody else is already using that username.",
},
});
return;
}
else {
log.error(`Unknown error occurred when saving user to database ${err.message}`);
res.status(500).json({
success: false,
error: {
message: "An unknown error occurred.",
},
});
return;
}
}
res.json({
success: true,
});
}
else {
oldUsername = req.session.username;
req.session.username = req.body.username;
req.session.save();
res.json({
success: true,
});
}
log.info(`${oldUsername} changed username to ${req.body.username}`);
usermanager.onUserModified(req.session);
});
let logInLimiter = rateLimit({ store: new RateLimitStore({ client: redisClient, resetExpiryOnChange: true, prefix: "rl:UserRegister" }), windowMs: 10 * 1000, max: 5, message: "You are doing that too much." });
router.post("/login", process.env.NODE_ENV === "production" ? logInLimiter : (req, res, next) => next(), (req, res, next) => {
passport.authenticate("local", (err, user) => {
if (err) {
res.status(401).json({
success: false,
error: {
message: err.message,
},
});
return;
}
if (user) {
req.login(user, (err) => {
if (err) {
log.error("Unknown error when logging in");
res.status(500).json({
success: false,
error: {
message: "An unknown error occurred when logging in.",
},
});
return;
}
req.session.save();
try {
usermanager.onUserLogIn(user, req.session);
}
catch (err) {
log.error(`An unknown error occurred when running onUserLogIn: ${err} ${err.message}`);
}
res.json({
success: true,
user: _.pick(user, [
"email",
"username",
]),
});
});
}
else {
res.status(401).json({
success: false,
error: {
message: "Either the email or password was not provided.",
},
});
}
})(req, res, next);
});
router.post("/logout", (req, res) => {
if (req.user) {
let user = req.user;
req.logout();
usermanager.onUserLogOut(user, req.session);
res.json({
success: true,
});
}
else {
res.json({
success: false,
error: {
message: "Not logged in.",
},
});
}
});
let registerLimiter = rateLimit({ store: new RateLimitStore({ client: redisClient, resetExpiryOnChange: true, prefix: "rl:UserRegister" }), windowMs: 60 * 60 * 1000, max: 4, message: "You are doing that too much." });
router.post("/register", process.env.NODE_ENV === "production" ? registerLimiter : (req, res, next) => next(), (req, res) => {
usermanager.registerUser(req.body).then(result => {
req.login(result, () => {
try {
usermanager.onUserLogIn(result, req.session);
}
catch (err) {
log.error(`An unknown error occurred when running onUserLogIn: ${err} ${err.message}`);
}
res.json({
success: true,
user: _.pick(result, [
"email",
"username",
]),
});
});
}).catch(err => {
log.error(`Unable to register user ${err} ${err.message}`);
if (err.name === "SequelizeUniqueConstraintError") {
let fields = err.fields.join(", ");
fields = fields.charAt(0).toUpperCase() + fields.slice(1);
res.status(400).json({
success: false,
error: {
name: "AlreadyInUse",
fields: err.fields,
message: `${fields} ${err.fields.length > 1 ? "are" : "is"} already in use.`,
},
});
}
else if (err.name === "SequelizeValidationError" || err.name === "BadPasswordError") {
res.status(400).json({
success: false,
error: {
name: "ValidationError",
message: err.message,
},
});
}
else {
res.status(500).json({
success: false,
error: {
name: "Unknown",
message: "An unknown error occurred. Try again later.",
},
});
}
});
});
class BadPasswordError extends Error {
constructor() {
super("Password does not meet minimum requirements. Must be at least 8 characters long, and contain 2 of the following categories of characters: lowercase letters, uppercase letters, numbers, special characters.");
this.name = "BadPasswordError";
}
}
let usermanager = {
router,
/**
* Callback used by passport LocalStrategy to authenticate Users.
*/
async authCallback(email, password, done) {
// HACK: required to use usermanager inside passport callbacks that are inside usermanager. This is because `this` becomes `global` inside these callbacks for some fucking reason
let usermanager = require("./usermanager.js");
let user;
try {
user = await usermanager.getUser({ email });
}
catch (err) {
if (err.message === "User not found") {
done(new Error("Email or password is incorrect."));
}
else {
log.error(`Auth callback failed: ${err}`);
done(new Error("An unknown error occurred. This is a bug."));
}
return;
}
// eslint-disable-next-line array-bracket-newline
let result = await pwd.verify(Buffer.concat([user.salt, Buffer.from(password)]), Buffer.from(user.hash));
switch (result) {
case securePassword.INVALID_UNRECOGNIZED_HASH:
log.error(`${email}: Unrecognized hash. I don't think this should ever happen.`);
done(null, false);
break;
case securePassword.INVALID:
log.debug(`${email}: Hash is invalid`);
done(new Error("Email or password is incorrect."), false);
break;
case securePassword.VALID_NEEDS_REHASH:
log.debug(`${email}: Hash is valid, needs rehash`);
// eslint-disable-next-line array-bracket-newline
user.hash = await pwd.hash(Buffer.concat([user.salt, Buffer.from(password)]));
await user.save();
// eslint-disable-next-line no-fallthrough
case securePassword.VALID:
log.debug(`${email}: Hash is valid`);
done(null, user);
break;
default:
break;
}
},
/**
* Converts a User into their user id.
* Used for persistent session storage.
*/
serializeUser(user, done) {
done(null, user.id);
},
/**
* Converts a user id into a User.
* Used for persistent session storage.
*/
async deserializeUser(id, done) {
// HACK: required to use usermanager inside passport callbacks that are inside usermanager. This is because `this` becomes `global` inside these callbacks for some fucking reason
let usermanager = require("./usermanager.js");
try {
let user = await usermanager.getUser({ id });
done(null, user);
}
catch (err) {
log.error(`Unable to deserialize user id=${id} ${err}`);
done(err, false);
}
},
/**
* Middleware to handle errors in serialize and deserialize callbacks
*/
passportErrorHandler(err, req, res, next) {
if (err) {
log.error(`Error in middleware ${err}, logging user out.`);
req.logout();
req.session.save();
next();
}
else {
next();
}
},
async registerUser({ email, username, password }) {
if (!this.isPasswordValid(password)) {
return Promise.reject(new BadPasswordError());
}
let salt = crypto.randomBytes(128);
// eslint-disable-next-line array-bracket-newline
let hash = await pwd.hash(Buffer.concat([salt, Buffer.from(password)]));
return User.create({
email,
username,
salt,
hash,
}).then(user => {
return user;
}).catch(err => {
log.error(`Failed to create new user in the database: ${err} ${err.message}`);
throw err;
});
},
/**
* Gets a User based on either their email or id.
* @param {*} param0
* @returns Promise<User>
*/
async getUser({ email, id }) {
if (!email && !id) {
log.error("Invalid parameters to find user");
throw new Error("Invalid parameters to find user");
}
let where = {};
if (email) {
where = { email };
}
else if (id) {
where = { id };
}
return User.findOne({ where }).then(user => {
if (!user) {
log.error("User not found");
throw new Error("User not found");
}
return user;
});
},
isPasswordValid(password) {
if (process.env.NODE_ENV === "development" && password === "1") {
return true;
}
let conditions = [
!!/^(?=.*[a-z])/.exec(password),
!!/^(?=.*[A-Z])/.exec(password),
!!/^(?=.*[0-9])/.exec(password),
!!/^(?=.*[!@#$%^&*])/.exec(password),
];
return conditions.reduce((acc, curr) => acc + curr) >= 2 && !!/^(?=.{8,})/.exec(password);
},
onUserLogIn(user, session) {
log.info(`${user.username} (id: ${user.id}) has logged in.`);
for (let room of roommanager.rooms) {
for (let client of room.clients) {
if (client.session.id === session.id) {
client.user = user;
room._dirtyProps.push("users");
break;
}
}
}
},
onUserLogOut(user, session) {
log.info(`${user.username} (id: ${user.id}) has logged out.`);
for (let room of roommanager.rooms) {
for (let client of room.clients) {
if (client.session.id === session.id) {
client.user = null;
room._dirtyProps.push("users");
break;
}
}
}
},
onUserModified(session) {
for (let room of roommanager.rooms) {
for (let client of room.clients) {
if (client.session.id === session.id) {
if (client.isLoggedIn) {
client.user.reload();
}
room._dirtyProps.push("users");
break;
}
}
}
},
};
if (process.env.NODE_ENV === "test") {
usermanager.registerUser({
email: "forced@localhost",
username: "forced test user",
password: "test1234",
}).catch(err => {
log.warn(`failed to register test user ${err.message}`);
});
usermanager.registerUser({
email: "test@localhost",
username: "test user",
password: "test1234",
}).catch(err => {
log.warn(`failed to register test user ${err.message}`);
});
router.get("/test/forceLogin", async (req, res) => {
req.login(await usermanager.getUser({ email: "forced@localhost" }), (err) => {
res.json({
success: !!err,
});
});
});
}
module.exports = usermanager;