Changed sign-up flow to require e-mail verification up front, preventing account existence leakage.

This commit is contained in:
2026-07-23 22:12:33 +02:00
parent 18c30b6f98
commit 6b3866a99f
12 changed files with 387 additions and 79 deletions

View File

@@ -6,7 +6,7 @@ import { shapes } from '@dicebear/collection';
import { createAvatar } from '@dicebear/core';
import { faker } from '@faker-js/faker';
import config from 'config';
import { verify } from 'hcaptcha';
import { verify as verifyCaptcha } from 'hcaptcha';
import slugify from '../utils/slugify.js';
import { sendEmails } from './email.js';
@@ -332,11 +332,78 @@ export async function resetPassword(userId, emailToken, newPassword) {
}
}
export async function sendSignup(email) {
if (!config.auth.signup || !config.email.enabled) {
throw new HttpError('Sign-ups are disabled', 503);
}
if (!email) {
throw new HttpError('No e-mail address provided', 400);
}
await tokenCooldown(email);
const user = await fetchUser(email, {
email: true,
includePrivate: true,
throwError: false,
});
if (user) {
await sendEmails([{
template: 'unavailable',
to: email,
subject: 'Someone tried to use your e-mail address for traxxx',
props: {
user,
},
}]);
logger.info(`Duplicate signup with '${email}' attempted, user e-mailed`);
// we don't throw an error because the requesting user shouldn't know whether the account exists or not
return;
}
const token = crypto.randomBytes(32).toString('hex');
const tokenKey = `traxxx:token_signup:${token}`;
const tokenData = {
email,
};
await redis.set(tokenKey, JSON.stringify(tokenData));
await redis.expire(tokenKey, config.auth.signupTokenExpiry * 60);
const verifyUrl = `${config.web.address}/signup?${new URLSearchParams({
token,
}).toString()}`;
logger.info(`Signup e-mail sent to ${email}: ${verifyUrl}`);
await sendEmails([{
template: 'signup',
to: email,
subject: 'Sign up for traxxx',
props: {
verifyUrl,
},
}]);
}
export async function signup(credentials, userIp) {
if (!config.auth.signup) {
throw new HttpError('Sign-ups are currently disabled', 503);
}
if (!credentials.token) {
throw new HttpError('Token required', 400);
}
if (credentials.password?.length < 3) {
throw new HttpError('Password must be 3 characters or longer', 400);
}
const curatedUsername = credentials.username.trim();
if (!curatedUsername) {
@@ -355,30 +422,34 @@ export async function signup(credentials, userIp) {
throw new HttpError('Username contains invalid characters', 400);
}
if (!credentials.email) {
throw new HttpError('E-mail required', 400);
}
const existingUser = await knex('users')
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
.first();
if (credentials.password?.length < 3) {
throw new HttpError('Password must be 3 characters or longer', 400);
if (existingUser) {
throw new HttpError('Username already in use', 409);
}
if (config.auth.captcha.enabled) {
const captchaVerification = await verify(config.auth.captcha.secretKey, credentials.captcha);
const captchaVerification = await verifyCaptcha(config.auth.captcha.secretKey, credentials.captcha);
if (!captchaVerification.success) {
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${credentials.email}, ${userIp})`);
logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${userIp})`);
throw new HttpError('Invalid CAPTCHA', 400);
}
}
const existingUser = await knex('users')
.where(knex.raw('lower(username)'), curatedUsername.toLowerCase())
.orWhere(knex.raw('lower(email)'), credentials.email.toLowerCase())
.first();
const tokenKey = `traxxx:token_signup:${credentials.token}`;
const tokenEntry = await redis.getDel(tokenKey);
if (existingUser) {
throw new HttpError('Username or e-mail already in use', 409);
if (!tokenEntry) {
throw new HttpError('Invalid sign-up link', 401);
}
const { email } = JSON.parse(tokenEntry);
if (!email) {
throw new HttpError('Invalid sign-up link', 401);
}
const { storedPassword } = await hashPassword(credentials.password);
@@ -386,8 +457,9 @@ export async function signup(credentials, userIp) {
const [{ id: userId }] = await knex('users')
.insert({
username: curatedUsername,
email: credentials.email,
email,
password: storedPassword,
email_verified: true,
})
.returning('id');
@@ -399,7 +471,7 @@ export async function signup(credentials, userIp) {
primary: true,
});
logger.info(`Signup from '${curatedUsername}' (${userId}, ${credentials.email}, ${userIp})`);
logger.info(`Signup from '${curatedUsername}' (${userId}, ${email}, ${userIp})`);
await generateAvatar({
id: userId,
@@ -410,7 +482,8 @@ export async function signup(credentials, userIp) {
includePrivate: true,
});
await sendEmailVerification(user, null, 'signup');
// no longer needed with token-first signup flow
// await sendEmailVerification(user, null, 'signup');
return user;
}
@@ -447,7 +520,16 @@ async function updateEmail(email, reqUser) {
});
if (user) {
throw new HttpError('This e-mail address is already in use', 409);
await sendEmails([{
template: 'unavailable',
to: email,
subject: 'Someone tried to use your e-mail address for traxxx',
props: {
user, // not reqUser, we don't want to leak details about the requester
},
}]);
return;
}
await sendEmailVerification(reqUser, email, 'change');

View File

@@ -126,6 +126,10 @@ export async function fetchStashByUsernameAndSlug(usernameOrId, stashSlug, sessi
}
export async function fetchUserStashes(usernameOrId, reqUser) {
if (!usernameOrId) {
throw new HttpError(`No username or user ID provided`, 400);
}
const userId = typeof usernameOrId === 'number'
? usernameOrId
: await knex('users')
@@ -134,7 +138,7 @@ export async function fetchUserStashes(usernameOrId, reqUser) {
.then((user) => user?.id);
if (!userId) {
throw new HttpError(`Could not find user '${usernameOrId}'`);
throw new HttpError(`Could not find user '${usernameOrId}'`, 404);
}
const stashes = await knex('stashes')

View File

@@ -56,6 +56,14 @@ function whereUser(builder, userId, options = {}) {
}
export async function fetchUser(userId, options = {}, reqUser) {
if (!userId) {
if (options.raw) {
return { user: null };
}
return null;
}
const user = await knex('users')
.select(knex.raw('users.*, users_roles.abilities as role_abilities'))
.modify((builder) => whereUser(builder, userId, options))
@@ -119,8 +127,6 @@ export async function removeTemplate(templateId, reqUser) {
}
export async function createBan(ban, reqUser) {
console.log(ban);
if (reqUser.role !== 'admin') {
throw new HttpError('You do not have sufficient privileges to set a ban', 403);
}

View File

@@ -13,6 +13,7 @@ import {
resetPassword,
sendEmailVerification,
sendPasswordReset,
sendSignup,
signup,
updateSettings,
updateUser,
@@ -23,25 +24,20 @@ import {
fetchUser,
} from '../users.js';
// changes every time the process restarts, so a deploy invalidates all cached session users
// even if they're within the staleness window below
const sessionStaleThreshold = 5 * 60 * 1000;
// spread into a new object rather than mutating the user we're about to hand back to the
// client (loginApi/signupApi send this straight to the browser) — runtimeId/refreshedAt are
// session bookkeeping only, they shouldn't leak into the API response
function toSession(user) {
if (!user) {
return null;
}
return {
...user,
runtimeId,
refreshedAt: Date.now(),
};
}
function isSessionStale(sessionUser) {
return sessionUser.runtimeId !== runtimeId || Date.now() - sessionUser.refreshedAt > sessionStaleThreshold;
}
// concurrent requests for the same user (e.g. the burst of asset/API requests a single page
// load fires) share one in-flight refresh instead of each independently hitting the DB
const pendingSessionRefreshes = new Map();
@@ -90,16 +86,24 @@ function getIp(req) {
return expandedIp.addressStart?.addressMinusSuffix || null;
}
// changes every time the process restarts, so a deploy invalidates all cached session users
// even if they're within the staleness window below
const sessionStaleThreshold = 5 * 60 * 1000;
function isSessionStale(sessionUser) {
return !!sessionUser && (sessionUser.runtimeId !== runtimeId || Date.now() - sessionUser.refreshedAt > sessionStaleThreshold);
}
export async function setUserApi(req, res, next) {
const ip = getIp(req);
req.userIp = ip;
if (req.session.user) {
if (isSessionStale(req.session.user)) {
req.session.user = await refreshSessionUser(req.session.user);
}
if (isSessionStale(req.session.user)) {
req.session.user = await refreshSessionUser(req.session.user);
}
if (req.session.user) {
req.user = req.session.user;
req.user.ip = ip;
}
@@ -110,10 +114,12 @@ export async function setUserApi(req, res, next) {
export async function updateSessionUser(req) {
const user = toSession(await fetchUser(req.session.user.id, {}, req.session.user));
req.session.user = user;
if (user) {
req.session.user = user;
req.user = user;
req.user.ip = req.userIp;
req.user = user;
req.user.ip = req.userIp;
}
}
export async function loginApi(req, res) {
@@ -133,6 +139,12 @@ export async function logoutApi(req, res) {
});
}
async function sendSignupApi(req, res) {
await sendSignup(req.body.email, req.userIp);
res.status(204).send();
}
export async function signupApi(req, res) {
const user = await signup(req.body, req.userIp);
@@ -241,6 +253,7 @@ authRouter.post('/api/session', loginApi);
authRouter.delete('/api/session', logoutApi);
// USERS
authRouter.post('/api/signups', sendSignupApi);
authRouter.post('/api/users', signupApi);
// SETTINGS