Added account update form and e-mail verification.
This commit is contained in:
+211
-6
@@ -1,3 +1,4 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import util from 'node:util';
|
||||
@@ -8,6 +9,7 @@ import config from 'config';
|
||||
import { verify } from 'hcaptcha';
|
||||
|
||||
import slugify from '../utils/slugify.js';
|
||||
import { sendEmails } from './email.js';
|
||||
import { HttpError } from './errors.js';
|
||||
import { knexOwner as knex } from './knex.js';
|
||||
import initLogger, { initAccessLogger } from './logger.js';
|
||||
@@ -46,7 +48,7 @@ async function generateAvatar(user) {
|
||||
|
||||
export async function login(credentials, userIp) {
|
||||
if (!config.auth.login) {
|
||||
throw new HttpError('Logins are currently disabled', 405);
|
||||
throw new HttpError('Logins are currently disabled', 503);
|
||||
}
|
||||
|
||||
const { user } = await fetchUser(credentials.username.trim(), {
|
||||
@@ -76,9 +78,62 @@ export async function login(credentials, userIp) {
|
||||
return curateUser(user, user);
|
||||
}
|
||||
|
||||
async function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hashedPassword = (await scrypt(password, salt, 64)).toString('hex');
|
||||
const storedPassword = `${salt}/${hashedPassword}`;
|
||||
|
||||
return {
|
||||
storedPassword,
|
||||
hashedPassword,
|
||||
salt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendEmailVerification(user, customEmail, trigger) {
|
||||
if (!user) {
|
||||
throw new HttpError('No user provided', 401);
|
||||
}
|
||||
|
||||
const email = customEmail || user.email;
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError('No e-mail address provided', 400);
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_email:${user.id}`;
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const tokenData = {
|
||||
email,
|
||||
token,
|
||||
};
|
||||
|
||||
await redis.set(tokenKey, JSON.stringify(tokenData));
|
||||
await redis.expire(tokenKey, config.auth.emailTokenExpiry * 60);
|
||||
|
||||
const verifyUrl = `${config.web.address}/verify/email?${new URLSearchParams({
|
||||
userId: user.id,
|
||||
token,
|
||||
}).toString()}`;
|
||||
|
||||
logger.info(`E-mail verification sent to ${user.username} (${user.id}): ${verifyUrl}`);
|
||||
|
||||
await sendEmails([{
|
||||
template: 'verify',
|
||||
to: email,
|
||||
subject: 'Please verify your traxxx e-mail address',
|
||||
props: {
|
||||
user,
|
||||
verifyUrl,
|
||||
trigger,
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
export async function signup(credentials, userIp) {
|
||||
if (!config.auth.signup) {
|
||||
throw new HttpError('Sign-ups are currently disabled', 405);
|
||||
throw new HttpError('Sign-ups are currently disabled', 503);
|
||||
}
|
||||
|
||||
const curatedUsername = credentials.username.trim();
|
||||
@@ -125,9 +180,7 @@ export async function signup(credentials, userIp) {
|
||||
throw new HttpError('Username or e-mail already in use', 409);
|
||||
}
|
||||
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hashedPassword = (await scrypt(credentials.password, salt, 64)).toString('hex');
|
||||
const storedPassword = `${salt}/${hashedPassword}`;
|
||||
const { storedPassword } = await hashPassword(credentials.password);
|
||||
|
||||
const [{ id: userId }] = await knex('users')
|
||||
.insert({
|
||||
@@ -152,7 +205,159 @@ export async function signup(credentials, userIp) {
|
||||
username: curatedUsername,
|
||||
});
|
||||
|
||||
return fetchUser(userId);
|
||||
const user = await fetchUser(userId, {
|
||||
includePrivate: true,
|
||||
});
|
||||
|
||||
await sendEmailVerification(user, null, 'signup');
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async function updatePassword(password, reqUser, reqSession) {
|
||||
const now = new Date();
|
||||
const { storedPassword } = await hashPassword(password);
|
||||
|
||||
await knex('users')
|
||||
.where('id', reqUser.id)
|
||||
.update('password', storedPassword);
|
||||
|
||||
const country = reqSession.country && await knex('countries').where('alpha2', reqSession.country).first();
|
||||
|
||||
await sendEmails([{
|
||||
template: 'password',
|
||||
to: reqUser.email,
|
||||
subject: 'Your traxxx password has been changed',
|
||||
props: {
|
||||
user: reqUser,
|
||||
ip: reqUser.ip,
|
||||
country: country?.name,
|
||||
address: config.web.address,
|
||||
timestamp: now,
|
||||
},
|
||||
}]);
|
||||
|
||||
logger.info(`Password changed by ${reqUser.username} (${reqUser.id}, ${reqUser.ip})`);
|
||||
}
|
||||
|
||||
async function updateEmail(email, reqUser) {
|
||||
const user = await fetchUser(email, {
|
||||
email: true,
|
||||
throwError: false,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
throw new HttpError('This e-mail address is already in use', 409);
|
||||
}
|
||||
|
||||
await sendEmailVerification(reqUser, email, 'change');
|
||||
}
|
||||
|
||||
export async function verifyEmail(userId, emailToken) {
|
||||
if (!userId) {
|
||||
throw new HttpError('User ID not provided', 400);
|
||||
}
|
||||
|
||||
if (!emailToken) {
|
||||
throw new HttpError('Token not provided', 400);
|
||||
}
|
||||
|
||||
const tokenKey = `traxxx:token_email:${userId}`;
|
||||
const tokenEntry = await redis.getDel(tokenKey);
|
||||
|
||||
if (!tokenEntry) {
|
||||
throw new HttpError('Invalid verification link', 401);
|
||||
}
|
||||
|
||||
const { token, email } = JSON.parse(tokenEntry);
|
||||
|
||||
if (!token
|
||||
|| !email
|
||||
|| emailToken.length !== token.length
|
||||
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
|
||||
throw new HttpError('Invalid verification link', 401);
|
||||
}
|
||||
|
||||
const updated = await knex('users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
email,
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
if (updated === 0) {
|
||||
throw new HttpError('E-mail verification failed because the user could not be found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUser({ credentials, password }, reqUser, reqSession) {
|
||||
if (!reqUser) {
|
||||
throw new HttpError('You are not logged in', 401);
|
||||
}
|
||||
|
||||
if (!credentials?.email && !credentials.password) {
|
||||
throw new HttpError('No changeable credentials provided', 400);
|
||||
}
|
||||
|
||||
if (credentials.email && !config.email.enabled) {
|
||||
throw new HttpError('E-mail is currently disabled', 503);
|
||||
}
|
||||
|
||||
if (credentials.password && credentials.password?.length < 3) {
|
||||
throw new HttpError('Password must be 3 characters or longer', 400);
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
throw new HttpError('No current password provided', 401);
|
||||
}
|
||||
|
||||
const { user } = await fetchUser(reqUser.id, {
|
||||
email: true,
|
||||
raw: true,
|
||||
}).catch(() => {
|
||||
throw new HttpError('Username or password incorrect', 401);
|
||||
});
|
||||
|
||||
await verifyPassword(password, user.password);
|
||||
|
||||
if (credentials.password) {
|
||||
await updatePassword(credentials.password, reqUser, reqSession);
|
||||
}
|
||||
|
||||
if (credentials.email) {
|
||||
await updateEmail(credentials.email, reqUser);
|
||||
}
|
||||
|
||||
return curateUser(user, reqUser);
|
||||
}
|
||||
|
||||
const validSettingKeys = [
|
||||
'alertNotificationsEnabled',
|
||||
'alertEmailsEnabled',
|
||||
'alertEmailsFrequency',
|
||||
];
|
||||
|
||||
export async function updateSettings(settings, reqUser) {
|
||||
if (!reqUser) {
|
||||
throw new HttpError('You need to be authenticated to change your settings', 401);
|
||||
}
|
||||
|
||||
const invalidKeys = Object.keys(settings).filter((key) => !validSettingKeys.includes(key));
|
||||
|
||||
if (invalidKeys.length > 0) {
|
||||
throw new HttpError(`Invalid settings: ${invalidKeys.join()}`, 400);
|
||||
}
|
||||
|
||||
if (settings.alertEmailsFrequency && !['instant', 'daily', 'weekly', 'monthly'].includes(settings.alertEmailsFrequency)) {
|
||||
throw new HttpError('Invalid email frequency setting', 400);
|
||||
}
|
||||
|
||||
const [{ settings: updatedSettings }] = await knex('users')
|
||||
.where('id', reqUser.id)
|
||||
.update('settings', knex.raw('coalesce(settings, \'{}\'::jsonb) || ?::jsonb', [JSON.stringify(settings)]))
|
||||
.returning('settings');
|
||||
|
||||
return updatedSettings;
|
||||
}
|
||||
|
||||
function curateKey(key) {
|
||||
|
||||
Reference in New Issue
Block a user