diff --git a/components/emails/recovery.vue b/components/emails/recovery.vue new file mode 100644 index 0000000..b4ac193 --- /dev/null +++ b/components/emails/recovery.vue @@ -0,0 +1,79 @@ + + + + + + + + Recovering your traxxx password. + + + + + Hi, {{ user.username }} + + + You requested to reset the password for your traxxx account. + + Please set a new password by clicking the button below: + + + Reset password + + + 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. + + + + + diff --git a/config/default.cjs b/config/default.cjs index 78d8132..52f3930 100755 --- a/config/default.cjs +++ b/config/default.cjs @@ -154,7 +154,13 @@ module.exports = { siteKey: '10000000-ffff-ffff-ffff-000000000001', 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 + passwordTokenExpiry: 120, // minutes }, bans: { defaultExpiry: 60 * 24 * 3, // in minutes, 3 days diff --git a/pages/+config.js b/pages/+config.js index 8f749ed..23630e5 100644 --- a/pages/+config.js +++ b/pages/+config.js @@ -1,5 +1,11 @@ export default { meta: { + title: { + env: { + server: true, + client: true, + }, + }, data: { env: { server: true, diff --git a/pages/actors/+onBeforeRender.js b/pages/actors/+onBeforeRender.js index 80d7be9..721f51d 100644 --- a/pages/actors/+onBeforeRender.js +++ b/pages/actors/+onBeforeRender.js @@ -16,7 +16,6 @@ export async function onBeforeRender(pageContext) { return { pageContext: { - title: 'actors', pageProps: { actors, countries, diff --git a/pages/actors/+title.js b/pages/actors/+title.js new file mode 100644 index 0000000..da11368 --- /dev/null +++ b/pages/actors/+title.js @@ -0,0 +1 @@ +export default 'Actors'; diff --git a/pages/auth/login/+Page.vue b/pages/auth/login/+Page.vue index 8170c82..67c8ccc 100644 --- a/pages/auth/login/+Page.vue +++ b/pages/auth/login/+Page.vue @@ -9,10 +9,7 @@ import navigate from '#/src/navigate.js'; const pageContext = inject('pageContext'); const user = pageContext.user; const allowSignup = pageContext.env.allowSignup; -// const allowRecovery = pageContext.env.emailEnabled; -const allowRecovery = false; // TODO: implement - -console.log(pageContext.env); +const allowRecovery = pageContext.env.emailEnabled; const username = ref(''); const password = ref(''); diff --git a/pages/auth/login/+title.js b/pages/auth/login/+title.js new file mode 100644 index 0000000..cc8de55 --- /dev/null +++ b/pages/auth/login/+title.js @@ -0,0 +1 @@ +export default 'Login'; diff --git a/pages/auth/recovery/+Page.vue b/pages/auth/recovery/+Page.vue new file mode 100644 index 0000000..7c40406 --- /dev/null +++ b/pages/auth/recovery/+Page.vue @@ -0,0 +1,218 @@ + + + + + + Password reset + Account recovery + + + + {{ errorMsg }} + + + + Password reset successfully + + Go to login + + + + password = newPassword" + /> + + + + Reset password + + + + + + Password reset requested + If the account exists, instructions have been sent to the associated e-mail address. + + + + + + + + Request password reset + + + + + Password recovery is currently unavailable + + + + diff --git a/pages/auth/recovery/+route.js b/pages/auth/recovery/+route.js new file mode 100644 index 0000000..93cce91 --- /dev/null +++ b/pages/auth/recovery/+route.js @@ -0,0 +1 @@ +export default '/recovery'; diff --git a/pages/auth/recovery/+title.js b/pages/auth/recovery/+title.js new file mode 100644 index 0000000..a50d8c7 --- /dev/null +++ b/pages/auth/recovery/+title.js @@ -0,0 +1 @@ +export default `Account recovery`; diff --git a/pages/auth/signup/+title.js b/pages/auth/signup/+title.js new file mode 100644 index 0000000..1381ff1 --- /dev/null +++ b/pages/auth/signup/+title.js @@ -0,0 +1 @@ +export default 'Sign up'; diff --git a/pages/recovery/+title.js b/pages/recovery/+title.js new file mode 100644 index 0000000..588992b --- /dev/null +++ b/pages/recovery/+title.js @@ -0,0 +1 @@ +export default 'Account recovery'; diff --git a/pages/users/@username/+Page.vue b/pages/users/@username/+Page.vue index fd74e00..474de47 100644 --- a/pages/users/@username/+Page.vue +++ b/pages/users/@username/+Page.vue @@ -270,7 +270,7 @@ function scrollHorizontal(event) { gap: .5rem; box-sizing: border-box; padding: .5rem 0; - margin-top: .5rem; + margin-bottom: 1rem; } .domain { diff --git a/renderer/+onRenderHtml.js b/renderer/+onRenderHtml.js index c54cb99..e8881c8 100644 --- a/renderer/+onRenderHtml.js +++ b/renderer/+onRenderHtml.js @@ -5,11 +5,15 @@ import { dangerouslySkipEscape, escapeInject } from 'vike/server'; import { createApp } from './app.js'; -function getTitle(location) { +function getTitle(location, pageContext) { if (!location) { return config.title; } + if (typeof location === 'function') { + return getTitle(location(pageContext), pageContext); + } + return `${config.title} - ${location.slice(0, 1).toUpperCase()}${location.slice(1)}`; } @@ -44,8 +48,7 @@ async function onRenderHtml(pageContext) { const appHtml = await renderToString(app); // See https://vike.dev/head - const { documentProps } = pageContext.exports; - const title = getTitle(documentProps?.title || pageContext.title); + const title = getTitle(pageContext.title || pageContext.config.title, pageContext); const documentHtml = escapeInject` diff --git a/src/actors.js b/src/actors.js index 4065c1a..a03df63 100644 --- a/src/actors.js +++ b/src/actors.js @@ -413,7 +413,7 @@ async function queryManticoreSql(filters, options, _reqUser) { } } - if (!filters.includeAliased) { + if (!filters.includeAliased && !filters.stashId) { builder.where('alias_for', 0); } diff --git a/src/auth.js b/src/auth.js index ba02630..7e4c5fd 100755 --- a/src/auth.js +++ b/src/auth.js @@ -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); diff --git a/src/web/auth.js b/src/web/auth.js index 78ca863..5383eb2 100755 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -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);