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

@@ -0,0 +1,79 @@
<script setup>
import {
Body,
Button,
Container,
Heading,
Html,
Img,
Preview,
Text,
} from '@vue-email/components';
import config from 'config';
defineProps({
user: {
type: Object,
default: () => mockUser,
},
verifyUrl: {
type: String,
default: 'http://localhost:5100/recovery?userId=0&token=00000000000000000000000000000000',
},
address: {
type: String,
default: 'http://localhost:5100',
},
});
</script>
<script>
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
// <script setup> itself, since that block gets hoisted out of the setup() function.
const mockUser = {
id: 1,
username: 'DebuggingLibrarian',
email: 'debug@alerts.traxxx.me',
};
</script>
<template>
<Html>
<Body style="font-family: sans-serif;">
<Preview>Recovering your traxxx password.</Preview>
<Container>
<Img
:src="`${address}/img/logo.png`"
width="150"
alt="traxxx"
style="margin: 15px auto 25px auto;"
/>
<Heading
as="h3"
style="color: #f65596; margin: 0 auto 0 auto;"
>Hi, {{ user.username }}</Heading>
<Text>
<strong>You requested to reset the password for your traxxx account.</strong>
<br>
Please set a new password by clicking the button below:
</Text>
<Button
:href="verifyUrl"
target="_blank"
style="background: #f65596; color: #fff; font-size: 16px; padding: 1rem 1.5rem; border-radius: .5rem; margin: 1rem 0;"
><strong>Reset password</strong></Button>
<Text style="font-size: 16px; margin: 20px 0;">
The link will expire in {{ config.auth.passwordTokenExpiry }} minutes, or when you request another password reset.
If you did not request a password reset, you can safely ignore this e-mail.
</Text>
</Container>
</Body>
</Html>
</template>

View File

@@ -154,7 +154,13 @@ module.exports = {
siteKey: '10000000-ffff-ffff-ffff-000000000001', siteKey: '10000000-ffff-ffff-ffff-000000000001',
secretKey: '0x0000000000000000000000000000000000000000', secretKey: '0x0000000000000000000000000000000000000000',
}, },
loginCredentialAttempts: 5,
loginCredentialCooldown: 10, // minutes,
loginIpAttempts: 20, // shared IPs might result in more non-malicious failed attempt
loginIpCooldown: 10, // minutes
tokenCooldown: 10, // minutes, how often a token can be requested
emailTokenExpiry: 120, // minutes emailTokenExpiry: 120, // minutes
passwordTokenExpiry: 120, // minutes
}, },
bans: { bans: {
defaultExpiry: 60 * 24 * 3, // in minutes, 3 days defaultExpiry: 60 * 24 * 3, // in minutes, 3 days

View File

@@ -1,5 +1,11 @@
export default { export default {
meta: { meta: {
title: {
env: {
server: true,
client: true,
},
},
data: { data: {
env: { env: {
server: true, server: true,

View File

@@ -16,7 +16,6 @@ export async function onBeforeRender(pageContext) {
return { return {
pageContext: { pageContext: {
title: 'actors',
pageProps: { pageProps: {
actors, actors,
countries, countries,

1
pages/actors/+title.js Normal file
View File

@@ -0,0 +1 @@
export default 'Actors';

View File

@@ -9,10 +9,7 @@ import navigate from '#/src/navigate.js';
const pageContext = inject('pageContext'); const pageContext = inject('pageContext');
const user = pageContext.user; const user = pageContext.user;
const allowSignup = pageContext.env.allowSignup; const allowSignup = pageContext.env.allowSignup;
// const allowRecovery = pageContext.env.emailEnabled; const allowRecovery = pageContext.env.emailEnabled;
const allowRecovery = false; // TODO: implement
console.log(pageContext.env);
const username = ref(''); const username = ref('');
const password = ref(''); const password = ref('');

View File

@@ -0,0 +1 @@
export default 'Login';

View File

@@ -0,0 +1,218 @@
<script setup>
import { inject, ref } from 'vue';
import Password from '#/components/form/password.vue';
import Ellipsis from '#/components/loading/ellipsis.vue';
import { post } from '#/src/api.js';
const pageContext = inject('pageContext');
const { userId, token } = pageContext.urlParsed.search;
const allowRecovery = pageContext.env.emailEnabled;
const credential = ref('');
const submitted = ref(false);
const success = ref(false);
const errorMsg = ref(null);
const password = ref(null);
async function request() {
submitted.value = true;
errorMsg.value = null;
try {
await post('/passwords/request', {
credential: credential.value,
}, {
successFeedback: 'Password reset requested, please check your e-mail inbox',
appendErrorMessage: true,
});
success.value = true;
}
catch (error) {
errorMsg.value = error.message;
}
finally {
submitted.value = false;
}
}
async function reset() {
submitted.value = true;
errorMsg.value = null;
try {
await post('/passwords/reset', {
userId,
token,
password: password.value,
}, {
successFeedback: 'Password reset successfully',
appendErrorMessage: true,
});
success.value = true;
}
catch (error) {
errorMsg.value = error.message;
}
finally {
submitted.value = false;
}
}
</script>
<template>
<div class="recovery-container">
<h3 class="heading">
<template v-if="userId && token">Password reset</template>
<template v-else>Account recovery</template>
</h3>
<template v-if="allowRecovery">
<strong
v-if="errorMsg"
class="error"
>{{ errorMsg }}</strong>
<form
v-else-if="userId && token"
class="login-panel"
@submit.prevent="reset"
>
<div
v-if="success"
class="success"
>
<strong class="success-heading">Password reset successfully</strong>
<a
href="/login"
class="link"
>Go to login</a>
</div>
<template v-else>
<Password
:password="password"
@input="(newPassword) => password = newPassword"
/>
<Ellipsis v-if="submitted" />
<button
v-else
class="button button-submit"
>Reset password</button>
</template>
</form>
<form
v-else-if="allowRecovery"
class="login-panel"
@submit.prevent="request"
>
<div
v-if="success"
class="success"
>
<strong class="success-heading">Password reset requested</strong>
If the account exists, instructions have been sent to the associated e-mail address.
</div>
<template v-else>
<input
v-model="credential"
placeholder="Username or e-mail"
class="input"
>
<Ellipsis v-if="submitted" />
<button
v-else
class="button button-submit"
>Request password reset</button>
</template>
</form>
</template>
<span
v-else
class="error"
>Password recovery is currently unavailable</span>
</div>
</template>
<style scoped>
.input {
width: 100%;
}
.heading {
text-align: center;
margin: 1rem;
}
.recovery-container {
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
align-items: center;
background: var(--background-base-10);
}
.login-panel {
width: 20rem;
max-width: 100%;
display: flex;
flex-direction: column;
gap: .5rem;
padding: 1rem;
margin: 0 1rem 1rem 1rem;
border-radius: .5rem;
box-shadow: 0 0 3px var(--shadow-weak-30);
background: var(--background-base);
font-size: 1rem;
.button {
justify-content: center;
}
.link {
margin-top: .5rem;
text-align: center;
}
}
.error {
padding: 1rem;
color: var(--error);
}
.success {
justify-content: center;
line-height: 1.25;
text-align: center;
.link {
display: block;
margin-top: 1rem;
text-align: center;
font-weight: bold;
}
}
.success-heading {
display: block;
margin-bottom: .5rem;
}
.load-container {
justify-content: center;
padding: 1rem 1rem .5rem 1rem;
}
</style>

View File

@@ -0,0 +1 @@
export default '/recovery';

View File

@@ -0,0 +1 @@
export default `Account recovery`;

View File

@@ -0,0 +1 @@
export default 'Sign up';

1
pages/recovery/+title.js Normal file
View File

@@ -0,0 +1 @@
export default 'Account recovery';

View File

@@ -270,7 +270,7 @@ function scrollHorizontal(event) {
gap: .5rem; gap: .5rem;
box-sizing: border-box; box-sizing: border-box;
padding: .5rem 0; padding: .5rem 0;
margin-top: .5rem; margin-bottom: 1rem;
} }
.domain { .domain {

View File

@@ -5,11 +5,15 @@ import { dangerouslySkipEscape, escapeInject } from 'vike/server';
import { createApp } from './app.js'; import { createApp } from './app.js';
function getTitle(location) { function getTitle(location, pageContext) {
if (!location) { if (!location) {
return config.title; return config.title;
} }
if (typeof location === 'function') {
return getTitle(location(pageContext), pageContext);
}
return `${config.title} - ${location.slice(0, 1).toUpperCase()}${location.slice(1)}`; return `${config.title} - ${location.slice(0, 1).toUpperCase()}${location.slice(1)}`;
} }
@@ -44,8 +48,7 @@ async function onRenderHtml(pageContext) {
const appHtml = await renderToString(app); const appHtml = await renderToString(app);
// See https://vike.dev/head // See https://vike.dev/head
const { documentProps } = pageContext.exports; const title = getTitle(pageContext.title || pageContext.config.title, pageContext);
const title = getTitle(documentProps?.title || pageContext.title);
const documentHtml = escapeInject`<!DOCTYPE html> const documentHtml = escapeInject`<!DOCTYPE html>
<html lang="en"> <html lang="en">

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); builder.where('alias_for', 0);
} }

View File

@@ -46,19 +46,71 @@ async function generateAvatar(user) {
logger.verbose(`Generated avatar for '${user.username}' (${user.id})`); 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) { export async function login(credentials, userIp) {
if (!config.auth.login) { if (!config.auth.login) {
throw new HttpError('Logins are currently disabled', 503); 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(), { const { user } = await fetchUser(credentials.username.trim(), {
email: true, email: true,
raw: 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); 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') await knex('users')
.update('last_login', 'NOW()') .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) { export async function sendEmailVerification(user, customEmail, trigger) {
if (!config.email.enabled) { if (!config.email.enabled) {
throw new HttpError('E-mail verification is disabled', 503); 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); throw new HttpError('No e-mail address provided', 400);
} }
await tokenCooldown(email);
const tokenKey = `traxxx:token_email:${user.id}`; const tokenKey = `traxxx:token_email:${user.id}`;
const token = crypto.randomBytes(32).toString('hex'); 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) { export async function signup(credentials, userIp) {
if (!config.auth.signup) { if (!config.auth.signup) {
throw new HttpError('Sign-ups are currently disabled', 503); throw new HttpError('Sign-ups are currently disabled', 503);
@@ -256,43 +453,6 @@ async function updateEmail(email, reqUser) {
await sendEmailVerification(reqUser, email, 'change'); 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) { export async function updateUser({ credentials, password }, reqUser, reqSession) {
if (!reqUser) { if (!reqUser) {
throw new HttpError('You are not logged in', 401); throw new HttpError('You are not logged in', 401);

View File

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