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
+100 -18
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');