import { Buffer } from 'node:buffer'; import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import util from 'node:util'; import { shapes } from '@dicebear/collection'; import { createAvatar } from '@dicebear/core'; import { faker } from '@faker-js/faker'; import config from 'config'; import { verify as verifyCaptcha } from 'hcaptcha'; import slugify from '../utils/slugify.js'; import { sendEmails } from './email.js'; import { HttpError } from './errors.js'; import { knexOwner as knex } from './knex.js'; import initLogger, { initAccessLogger } from './logger.js'; import redis from './redis.js'; import { curateUser, fetchUser } from './users.js'; const logger = initLogger(); const accessLogger = initAccessLogger(); const scrypt = util.promisify(crypto.scrypt); async function verifyPassword(password, storedPassword) { const [salt, hash] = storedPassword.split('/'); const hashedPassword = (await scrypt(password, salt, 64)).toString('hex'); if (hashedPassword === hash) { return true; } throw new HttpError('Username or password incorrect', 401); } async function generateAvatar(user) { const avatar = createAvatar(shapes, { seed: user.username, backgroundColor: ['f65596', '9b004b', '006b68', '5abab6'], shape1Color: ['f65596', 'ff6d7e', 'ff8d69', 'ffb15b', 'ffd55c', 'f9f871'], shape2Color: ['c162c6', '6074dd', '007dd2', '007ba9', '007170'], shape3Color: ['f65596', 'ff6d7e', 'ff8d69', 'ffb15b', 'ffd55c', 'f9f871'], }); await fs.mkdir('media/avatars', { recursive: true }); await avatar.png().toFile(`media/avatars/${user.id}_${user.username}.png`); 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(async () => { await Promise.all([ setLoginCooldown(credentials.username, config.auth.loginCredentialCooldown), setLoginCooldown(userIp, config.auth.loginIpCooldown), ]); throw new HttpError('Username or password incorrect', 401); }); 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()') .update('last_ip', userIp) .where('id', user.id); logger.verbose(`Login from '${user.username}' (${user.id}, ${userIp})`); try { await fs.access(`media/avatars/${user.id}_${user.username}.png`); } catch (error) { await generateAvatar(user); } // fetched the raw user for password verification, don't return directly to user return curateUser(user, user); } async function hashPassword(password) { const salt = crypto.randomBytes(16).toString('hex'); const hashedPassword = (await scrypt(password, salt, 64)).toString('hex'); const storedPassword = `${salt}/${hashedPassword}`; return { storedPassword, hashedPassword, salt, }; } 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); } if (!user) { throw new HttpError('No user provided', 401); } const email = customEmail || user.email; if (!email) { 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'); const tokenData = { email, token, }; await redis.set(tokenKey, JSON.stringify(tokenData)); await redis.expire(tokenKey, config.auth.emailTokenExpiry * 60); const verifyUrl = `${config.web.address}/verify/email?${new URLSearchParams({ userId: user.id, token, }).toString()}`; logger.info(`E-mail verification sent to ${user.username} (${user.id}): ${verifyUrl}`); await sendEmails([{ template: 'verify', to: email, subject: 'Please verify your traxxx e-mail address', props: { user, verifyUrl, 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 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) { throw new HttpError('Username required', 400); } if (curatedUsername.length < config.auth.usernameLength[0]) { throw new HttpError('Username is too short', 400); } if (curatedUsername.length > config.auth.usernameLength[1]) { throw new HttpError('Username is too long', 400); } if (!config.auth.usernamePattern.test(curatedUsername)) { throw new HttpError('Username contains invalid characters', 400); } const existingUser = await knex('users') .where(knex.raw('lower(username)'), curatedUsername.toLowerCase()) .first(); if (existingUser) { throw new HttpError('Username already in use', 409); } if (config.auth.captcha.enabled) { const captchaVerification = await verifyCaptcha(config.auth.captcha.secretKey, credentials.captcha); if (!captchaVerification.success) { logger.warn(`Invalid sign-up CAPTCHA from '${curatedUsername}' (${userIp})`); throw new HttpError('Invalid CAPTCHA', 400); } } const tokenKey = `traxxx:token_signup:${credentials.token}`; const tokenEntry = await redis.getDel(tokenKey); 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); const [{ id: userId }] = await knex('users') .insert({ username: curatedUsername, email, password: storedPassword, email_verified: true, }) .returning('id'); await knex('stashes').insert({ user_id: userId, name: 'Favorites', slug: 'favorites', public: false, primary: true, }); logger.info(`Signup from '${curatedUsername}' (${userId}, ${email}, ${userIp})`); await generateAvatar({ id: userId, username: curatedUsername, }); const user = await fetchUser(userId, { includePrivate: true, }); // no longer needed with token-first signup flow // await sendEmailVerification(user, null, 'signup'); return user; } async function updatePassword(password, reqUser, reqSession) { const now = new Date(); const { storedPassword } = await hashPassword(password); await knex('users') .where('id', reqUser.id) .update('password', storedPassword); const country = reqSession.geo?.country && await knex('countries').where('alpha2', reqSession.geo.country).first(); await sendEmails([{ template: 'password', to: reqUser.email, subject: 'Your traxxx password has been changed', props: { user: reqUser, ip: reqUser.ip, country: country?.name, timestamp: now, }, }]); logger.info(`Password changed by ${reqUser.username} (${reqUser.id}, ${reqUser.ip})`); } async function updateEmail(email, reqUser) { const user = await fetchUser(email, { email: true, throwError: false, }); if (user) { 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'); } export async function updateUser({ credentials, password }, reqUser, reqSession) { if (!reqUser) { throw new HttpError('You are not logged in', 401); } if (!credentials?.email && !credentials.password) { throw new HttpError('No changeable credentials provided', 400); } if (credentials.email && !config.email.enabled) { throw new HttpError('E-mail is currently disabled', 503); } if (credentials.password && credentials.password?.length < 3) { throw new HttpError('Password must be 3 characters or longer', 400); } if (!password) { throw new HttpError('No current password provided', 401); } const { user } = await fetchUser(reqUser.id, { email: true, raw: true, }).catch(() => { throw new HttpError('Username or password incorrect', 401); }); await verifyPassword(password, user.password); if (credentials.password) { await updatePassword(credentials.password, reqUser, reqSession); } if (credentials.email) { await updateEmail(credentials.email, reqUser); } return curateUser(user, reqUser); } const validSettingKeys = [ 'alertNotificationsEnabled', 'alertEmailsEnabled', 'alertEmailsFrequency', ]; export async function updateSettings(settings, reqUser) { if (!reqUser) { throw new HttpError('You need to be authenticated to change your settings', 401); } const invalidKeys = Object.keys(settings).filter((key) => !validSettingKeys.includes(key)); if (invalidKeys.length > 0) { throw new HttpError(`Invalid settings: ${invalidKeys.join()}`, 400); } if (settings.alertEmailsFrequency && !['instant', 'daily', 'weekly', 'monthly'].includes(settings.alertEmailsFrequency)) { throw new HttpError('Invalid email frequency setting', 400); } const [{ settings: updatedSettings }] = await knex('users') .where('id', reqUser.id) .update('settings', knex.raw('coalesce(settings, \'{}\'::jsonb) || ?::jsonb', [JSON.stringify(settings)])) .returning('settings'); return updatedSettings; } function curateKey(key) { return { id: key.id, identifier: key.identifier, lastUsedAt: key.last_used_at, lastUsedIp: key.last_used_ip, createdAt: key.created_at, }; } export async function fetchUserKeys(reqUser) { const keys = await knex('users_keys') .where('user_id', reqUser.id) .orderBy('created_at', 'asc'); return keys.map((key) => curateKey(key)); } export async function verifyKey(userId, key, req) { if (!key || !userId) { throw new HttpError('The API credentials are not provided.', 401); } const hashedKey = (await scrypt(key, '', 64)).toString('hex'); // salt redundant for randomly generated key const storedKey = await knex('users_keys') .where('user_id', userId) .where('key', hashedKey) .first(); if (!storedKey) { throw new HttpError('The API credentials are invalid.', 401); } accessLogger.access({ userId, identifier: storedKey.identifier, ip: req.userIp, path: req.path, userAgent: req.headers['user-agent'], }); knex('users_keys') .where('id', storedKey.id) .update('last_used_at', knex.raw('now()')) .update('last_used_ip', req.userIp) .then(() => { // no need to wait for this }); return fetchUser(storedKey.user_id); } export async function createKey(reqUser) { const cooldownKey = `traxxx:key_create_cooldown:${reqUser.id}`; if (reqUser.role !== 'admin' && await redis.exists(cooldownKey)) { throw new HttpError(`You can only create a new API key once every ${config.apiAccess.keyCooldown} minutes.`, 429); } const keys = await fetchUserKeys(reqUser); if (keys.length >= config.apiAccess.keyLimit) { throw new HttpError(`You can only hold ${config.apiAccess.keyLimit} API keys at one time. Please remove a key.`, 400); } const key = crypto.randomBytes(config.apiAccess.keySize).toString('base64url'); const hashedKey = (await scrypt(key, '', 64)).toString('hex'); // salt redundant for randomly generated key const identifier = slugify([faker.word.adjective(), faker.animal[faker.animal.type()]()]); const [newKey] = await knex('users_keys') .insert({ user_id: reqUser.id, key: hashedKey, identifier, }) .returning('*'); await redis.set(cooldownKey, identifier); await redis.expire(cooldownKey, config.apiAccess.keyCooldown * 60); return { ...curateKey(newKey), key, }; } export async function removeUserKey(reqUser, identifier) { await knex('users_keys') .where('user_id', reqUser.id) .where('identifier', identifier) .delete(); } export async function flushUserKeys(reqUser) { await knex('users_keys') .where('user_id', reqUser.id) .delete(); }