Added password recovery and login attempt guards.

This commit is contained in:
2026-07-23 04:40:25 +02:00
parent ef9be8ef9a
commit 6f68a50eb7
17 changed files with 541 additions and 49 deletions

View File

@@ -413,7 +413,7 @@ async function queryManticoreSql(filters, options, _reqUser) {
}
}
if (!filters.includeAliased) {
if (!filters.includeAliased && !filters.stashId) {
builder.where('alias_for', 0);
}

View File

@@ -46,19 +46,71 @@ async function generateAvatar(user) {
logger.verbose(`Generated avatar for '${user.username}' (${user.id})`);
}
async function checkLoginCooldown(identifier, attemptThreshold) {
if (!identifier) {
return false;
}
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
const attempts = JSON.parse(await redis.get(cooldownKey) || '0');
if (attempts >= attemptThreshold) {
throw new HttpError(`Please wait a few minutes before attempting another login`, 409);
}
}
async function setLoginCooldown(identifier, attemptCooldown) {
if (!identifier) {
return false;
}
const cooldownKey = `traxxx:login_cooldown:${slugify(identifier, '_')}`;
await redis.incr(cooldownKey);
await redis.expire(cooldownKey, attemptCooldown * 60);
}
export async function login(credentials, userIp) {
if (!config.auth.login) {
throw new HttpError('Logins are currently disabled', 503);
}
if (!credentials.username) {
throw new HttpError('Please provide a username or e-mail', 400);
}
if (!credentials.password) {
throw new HttpError('Please provide a password', 400);
}
await Promise.all([
checkLoginCooldown(credentials.username, config.auth.loginCredentialAttempts),
checkLoginCooldown(userIp, config.auth.loginIpAttempts),
]);
const { user } = await fetchUser(credentials.username.trim(), {
email: true,
raw: true,
}).catch(() => {
}).catch(async () => {
await Promise.all([
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
setLoginCooldown(userIp, config.auth.loginIpCooldown),
]);
throw new HttpError('Username or password incorrect', 401);
});
await verifyPassword(credentials.password, user.password);
try {
await verifyPassword(credentials.password, user.password);
}
catch (error) {
await Promise.all([
setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown),
setLoginCooldown(userIp, config.auth.loginIpCooldown),
]);
throw error;
}
await knex('users')
.update('last_login', 'NOW()')
@@ -90,6 +142,17 @@ async function hashPassword(password) {
};
}
async function tokenCooldown(identifier) {
const cooldownKey = `traxxx:token_cooldown:${slugify(identifier, '_')}`;
if (await redis.exists(cooldownKey)) {
throw new HttpError(`Please wait a few minutes before requesting another token`, 409);
}
await redis.set(cooldownKey, JSON.stringify(true));
await redis.expire(cooldownKey, config.auth.tokenCooldown * 60);
}
export async function sendEmailVerification(user, customEmail, trigger) {
if (!config.email.enabled) {
throw new HttpError('E-mail verification is disabled', 503);
@@ -105,6 +168,8 @@ export async function sendEmailVerification(user, customEmail, trigger) {
throw new HttpError('No e-mail address provided', 400);
}
await tokenCooldown(email);
const tokenKey = `traxxx:token_email:${user.id}`;
const token = crypto.randomBytes(32).toString('hex');
@@ -135,6 +200,138 @@ export async function sendEmailVerification(user, customEmail, trigger) {
}]);
}
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 sendPasswordReset(credential) {
if (!config.email.enabled) {
throw new HttpError('Password reset is disabled', 503);
}
if (!credential) {
throw new HttpError('No credential provided', 400);
}
await tokenCooldown(credential);
const user = await fetchUser(credential, {
email: true,
includePrivate: true,
throwError: false,
});
// we don't throw errors because the requesting user shouldn't know whether the account exists or not
if (!user) {
logger.info(`Password reset requested for unknown user '${credential}'`);
return;
}
if (!user.email) {
logger.info(`Password reset requested for '${credential}', but user '${user.username}' (${user.id}) has no e-mail address known`);
return;
}
const tokenKey = `traxxx:token_password:${user.id}`;
const token = crypto.randomBytes(32).toString('hex');
const tokenData = {
token,
};
await redis.set(tokenKey, JSON.stringify(tokenData));
await redis.expire(tokenKey, config.auth.passwordTokenExpiry * 60);
const verifyUrl = `${config.web.address}/recovery?${new URLSearchParams({
userId: user.id,
token,
}).toString()}`;
logger.info(`Password reset sent to ${user.username} (${user.id}): ${verifyUrl}`);
await sendEmails([{
template: 'recovery',
to: user.email,
subject: 'Reset your traxxx password',
props: {
user,
verifyUrl,
},
}]);
}
export async function resetPassword(userId, emailToken, newPassword) {
if (!userId) {
throw new HttpError('User ID not provided', 400);
}
if (!emailToken) {
throw new HttpError('Token not provided', 400);
}
if (!newPassword) {
throw new HttpError('New password not provided', 400);
}
const tokenKey = `traxxx:token_password:${userId}`;
const tokenEntry = await redis.getDel(tokenKey);
if (!tokenEntry) {
throw new HttpError('Invalid recovery link', 401);
}
const { token } = JSON.parse(tokenEntry);
if (!token
|| emailToken.length !== token.length
|| !crypto.timingSafeEqual(Buffer.from(emailToken, 'hex'), Buffer.from(token, 'hex'))) {
throw new HttpError('Invalid recovery link', 401);
}
const { storedPassword } = await hashPassword(newPassword);
const updated = await knex('users')
.where('id', userId)
.update('password', storedPassword);
if (updated === 0) {
throw new HttpError('Password reset failed because the user could not be found', 404);
}
}
export async function signup(credentials, userIp) {
if (!config.auth.signup) {
throw new HttpError('Sign-ups are currently disabled', 503);
@@ -256,43 +453,6 @@ async function updateEmail(email, reqUser) {
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);

View File

@@ -9,7 +9,9 @@ import {
flushUserKeys,
login,
removeUserKey,
resetPassword,
sendEmailVerification,
sendPasswordReset,
signup,
updateSettings,
updateUser,
@@ -184,6 +186,20 @@ async function verifyEmailApi(req, res) {
res.status(204).send();
}
async function sendPasswordResetApi(req, res) {
await sendPasswordReset(req.body.credential);
res.status(204).send();
}
async function resetPasswordApi(req, res) {
const userId = Number(req.body.userId) || null;
await resetPassword(userId, req.body.token, req.body.password);
res.status(204).send();
}
async function updateSettingsApi(req, res) {
const updatedSettings = await updateSettings(req.body, req.user);
@@ -234,6 +250,8 @@ authRouter.patch('/api/me/settings', updateSettingsApi);
// EMAIL VERIFICATION
authRouter.post('/api/me/email_verification', sendEmailVerificationApi);
authRouter.post('/api/emails/verify', verifyEmailApi); // usable without session
authRouter.post('/api/passwords/request', sendPasswordResetApi);
authRouter.post('/api/passwords/reset', resetPasswordApi);
// API KEYS
authRouter.get('/api/me/keys', fetchUserKeysApi);