diff --git a/assets/css/inputs.css b/assets/css/inputs.css index b8f29ea..d4b9d96 100644 --- a/assets/css/inputs.css +++ b/assets/css/inputs.css @@ -1,4 +1,5 @@ .input { + width: 15rem; box-sizing: border-box; padding: .5rem .75rem; font-size: 1rem; diff --git a/components/emails/password.vue b/components/emails/password.vue new file mode 100644 index 0000000..9da8f29 --- /dev/null +++ b/components/emails/password.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/components/emails/verify.vue b/components/emails/verify.vue new file mode 100644 index 0000000..0f1bd26 --- /dev/null +++ b/components/emails/verify.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/components/form/password.vue b/components/form/password.vue new file mode 100644 index 0000000..7083b5a --- /dev/null +++ b/components/form/password.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/components/user/account.vue b/components/user/account.vue new file mode 100644 index 0000000..88a6795 --- /dev/null +++ b/components/user/account.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/config/default.cjs b/config/default.cjs index 8846dd8..78d8132 100755 --- a/config/default.cjs +++ b/config/default.cjs @@ -154,6 +154,7 @@ module.exports = { siteKey: '10000000-ffff-ffff-ffff-000000000001', secretKey: '0x0000000000000000000000000000000000000000', }, + emailTokenExpiry: 120, // minutes }, bans: { defaultExpiry: 60 * 24 * 3, // in minutes, 3 days diff --git a/pages/auth/login/+Page.vue b/pages/auth/login/+Page.vue index dee085d..776053a 100644 --- a/pages/auth/login/+Page.vue +++ b/pages/auth/login/+Page.vue @@ -1,6 +1,8 @@ + + + + diff --git a/src/alerts.js b/src/alerts.js index 57ba7c7..23e5ac2 100755 --- a/src/alerts.js +++ b/src/alerts.js @@ -3,11 +3,10 @@ import { CronJob } from 'cron'; import { add } from 'date-fns'; import escapeRegexp from 'escape-string-regexp'; import markdownIt from 'markdown-it'; -import { Resend } from 'resend'; import promiseProps from '../utils/promise-props.js'; import { getIdsBySlug } from './cache.js'; -import { emailTemplates } from './email-templates.js'; +import { sendEmails } from './email.js'; import { HttpError } from './errors.js'; import { knexOwner as knex } from './knex.js'; import initLogger from './logger.js'; @@ -15,18 +14,6 @@ import { indexApi } from './manticore.js'; import { fetchScenesById } from './scenes.js'; const logger = initLogger(); -const resend = (() => { - if (config.email.enabled) { - try { - return new Resend(config.email.apiKey); - } - catch (error) { - logger.error(`Failed to initialize Resend: ${error.message}`); - } - } - - return null; -})(); function getImagePath(media) { if (!media) { @@ -452,11 +439,6 @@ async function sendEmailAlerts() { return; } - if (!resend) { - logger.warn('E-mails are enabled, but Resend is not initialized'); - return; - } - const qualifyingUsers = await knex('notifications') .select('users.id as user_id', 'users.settings', knex.raw('max(emailed_at) as last_email')) .where('notifications.email', true) @@ -546,7 +528,7 @@ async function sendEmailAlerts() { && !!notification.scene && notification.user.settings?.alertEmailsEnabled !== false); // keep the e-mails marked as 'sent', if emails get re-enabled there shouldn't be a barrage of old notifications - const emails = await Promise.all(Object.values(usersByUserId).map(async (user) => { + const emailItems = Object.values(usersByUserId).map((user) => { const userTriggers = Object.values(Object.fromEntries(triggers .filter((trigger) => trigger.user.id === user.id) .map((trigger) => [trigger.scene.id, trigger]))) @@ -556,42 +538,24 @@ async function sendEmailAlerts() { return null; } - try { - const { html, text } = await emailTemplates.render('alert.vue', { - props: { - user, - triggers: userTriggers, - address: config.web.address, - }, - }); + return { + template: 'alert', + to: user.email, + subject: `Notification for ${userTriggers.length} scenes on traxxx`, + props: { + user, + triggers: userTriggers, + address: config.web.address, + }, + }; + }).filter(Boolean); - return { - from: config.email.from, - to: user.email, - subject: `Notification for ${userTriggers.length} scenes on traxxx`, - html, - text, - }; - } - catch (error) { - logger.warn(`Failed to render e-mail for '${user.username}' with scenes ${userTriggers.map((trigger) => `${trigger.notificationId}: ${trigger.scene.id}`)}: ${error.message}`); - return null; - } - })).then((allEmails) => allEmails.filter(Boolean)); - - if (emails.length === 0) { + if (emailItems.length === 0) { logger.info(`No alert e-mails sent`); return; } - const { data, error } = await resend.batch.send(emails); - - if (error) { - logger.error(`Failed to send ${emails.length} alert e-mails (${error.statusCode} ${error.name}): ${error.message}`); - } - else { - logger.info(`Sent ${data.data.length}/${emails.length} alert e-mails`); - } + await sendEmails(emailItems); } export async function notify(sceneIds, options = {}) { diff --git a/src/auth.js b/src/auth.js index 20c1599..c89ccd7 100755 --- a/src/auth.js +++ b/src/auth.js @@ -1,3 +1,4 @@ +import { Buffer } from 'node:buffer'; import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import util from 'node:util'; @@ -8,6 +9,7 @@ import config from 'config'; import { verify } 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'; @@ -46,7 +48,7 @@ async function generateAvatar(user) { export async function login(credentials, userIp) { if (!config.auth.login) { - throw new HttpError('Logins are currently disabled', 405); + throw new HttpError('Logins are currently disabled', 503); } const { user } = await fetchUser(credentials.username.trim(), { @@ -76,9 +78,62 @@ export async function login(credentials, userIp) { 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, + }; +} + +export async function sendEmailVerification(user, customEmail, trigger) { + 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); + } + + 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 signup(credentials, userIp) { if (!config.auth.signup) { - throw new HttpError('Sign-ups are currently disabled', 405); + throw new HttpError('Sign-ups are currently disabled', 503); } const curatedUsername = credentials.username.trim(); @@ -125,9 +180,7 @@ export async function signup(credentials, userIp) { throw new HttpError('Username or e-mail already in use', 409); } - const salt = crypto.randomBytes(16).toString('hex'); - const hashedPassword = (await scrypt(credentials.password, salt, 64)).toString('hex'); - const storedPassword = `${salt}/${hashedPassword}`; + const { storedPassword } = await hashPassword(credentials.password); const [{ id: userId }] = await knex('users') .insert({ @@ -152,7 +205,159 @@ export async function signup(credentials, userIp) { username: curatedUsername, }); - return fetchUser(userId); + const user = await fetchUser(userId, { + includePrivate: true, + }); + + 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.country && await knex('countries').where('alpha2', reqSession.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, + address: config.web.address, + 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) { + throw new HttpError('This e-mail address is already in use', 409); + } + + 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); + } + + 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) { diff --git a/src/email.js b/src/email.js new file mode 100644 index 0000000..30b3163 --- /dev/null +++ b/src/email.js @@ -0,0 +1,79 @@ +import config from 'config'; +import { Resend } from 'resend'; + +import { emailTemplates } from './email-templates.js'; +import initLogger from './logger.js'; + +const logger = initLogger(); + +const resend = (() => { + if (config.email.enabled) { + try { + return new Resend(config.email.apiKey); + } + catch (error) { + logger.error(`Failed to initialize Resend: ${error.message}`); + } + } + + return null; +})(); + +export async function renderEmail(template, { to, subject, props }) { + const { html, text } = await emailTemplates.render(`${template}.vue`, { props }); + + return { + from: config.email.from, + to, + subject, + html, + text, + }; +} + +// always batches, even for a single item: Resend's batch endpoint accepts a batch of one, +// and passing batchValidation: 'permissive' means one bad recipient among many doesn't +// sink the rest of the batch (the default 'strict' mode fails the whole call instead) +export async function sendEmails(items) { + if (!config.email.enabled) { + return null; + } + + if (!resend) { + logger.warn('E-mails are enabled, but Resend is not initialized'); + return null; + } + + const rendered = await Promise.all(items.map(async (item) => { + try { + return await renderEmail(item.template, item); + } + catch (error) { + logger.error(`Failed to render '${item.template}' e-mail for '${item.to}': ${error.message} — ${JSON.stringify(item.props)}`); + return null; + } + })); + + const sentItems = items.filter((item, index) => rendered[index]); + const emails = rendered.filter(Boolean); + + if (emails.length === 0) { + return null; + } + + const { data, error } = await resend.batch.send(emails, { batchValidation: 'permissive' }); + + if (error) { + logger.error(`Failed to send ${emails.length} e-mail(s) (${error.statusCode} ${error.name}): ${error.message}`); + return null; + } + + data.errors?.forEach(({ index, message }) => { + const item = sentItems[index]; + logger.error(`Failed to send '${item.template}' e-mail to '${item.to}': ${message} — ${JSON.stringify(item.props)}`); + }); + + logger.info(`Sent ${emails.length - (data.errors?.length || 0)}/${items.length} e-mail(s)`); + + return data; +} diff --git a/src/users.js b/src/users.js index e2f9cc7..90734a5 100755 --- a/src/users.js +++ b/src/users.js @@ -14,7 +14,7 @@ function curateTemplate(template) { }; } -export function curateUser(user, reqUser) { +export function curateUser(user, reqUser, options = {}) { if (!user) { return null; } @@ -35,7 +35,7 @@ export function curateUser(user, reqUser) { role: user.role, abilities: [...user.role_abilities || [], ...user.abilities || []], createdAt: user.created_at, - ...(reqUser?.id === user.id ? privateUser : null), + ...(reqUser?.id === user.id || options.includePrivate ? privateUser : null), }; return curatedUser; @@ -63,7 +63,7 @@ export async function fetchUser(userId, options = {}, reqUser) { .groupBy('users.id', 'users_roles.role') .first(); - if (!user) { + if (!user && options.throwError !== false) { throw new HttpError(`User '${userId}' not found`, 404); } @@ -71,7 +71,7 @@ export async function fetchUser(userId, options = {}, reqUser) { return { user }; } - return curateUser(user, reqUser); + return curateUser(user, reqUser, options); } export async function fetchUserTemplates(reqUser) { @@ -136,32 +136,3 @@ export async function createBan(ban, reqUser) { await knex('bans').insert(curatedBan); } - -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; -} diff --git a/src/web/alerts.js b/src/web/alerts.js index 4a59508..f66baf9 100755 --- a/src/web/alerts.js +++ b/src/web/alerts.js @@ -219,12 +219,12 @@ export async function updateNotificationGraphql(query, req) { return updatedNotification; } -export const router = Router(); +export const alertsRouter = Router(); -router.get('/api/alerts', fetchAlertsApi); -router.post('/api/alerts', createAlertApi); -router.delete('/api/alerts/:alertId', removeAlertApi); +alertsRouter.get('/api/alerts', fetchAlertsApi); +alertsRouter.post('/api/alerts', createAlertApi); +alertsRouter.delete('/api/alerts/:alertId', removeAlertApi); -router.get('/api/users/:userId/notifications', fetchNotificationsApi); -router.patch('/api/users/:userId/notifications', updateNotificationsApi); -router.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi); +alertsRouter.get('/api/users/:userId/notifications', fetchNotificationsApi); +alertsRouter.patch('/api/users/:userId/notifications', updateNotificationsApi); +alertsRouter.patch('/api/users/:userId/notifications/:notificationId', updateNotificationApi); diff --git a/src/web/auth.js b/src/web/auth.js index a965136..78ca863 100755 --- a/src/web/auth.js +++ b/src/web/auth.js @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import { Router } from 'express'; import IPCIDR from 'ip-cidr'; import argv from '../argv.js'; @@ -8,10 +9,16 @@ import { flushUserKeys, login, removeUserKey, + sendEmailVerification, signup, + updateSettings, + updateUser, + verifyEmail, } from '../auth.js'; -import { fetchUser } from '../users.js'; +import { + fetchUser, +} from '../users.js'; // changes every time the process restarts, so a deploy invalidates all cached session users // even if they're within the staleness window below @@ -131,6 +138,60 @@ export async function signupApi(req, res) { res.send(user); } +async function updateUserApi(req, res) { + const updatedUser = await updateUser(req.body, req.user, req.session); + const sessions = await req.sessionStore.all(); + + req.session.user.refreshedAt = 0; + + await Promise.all(sessions.map(async (session) => { + if (session.user?.id === req.user.id && session.id !== req.sessionID) { + if (req.body.credentials?.password) { + // we changed the password, destroy all user sessions except our own (we already provided all the credentials) + await req.sessionStore.destroy(session.id); + } + else { + // mark our sessions as stale + session.user.refreshedAt = 0; // eslint-disable-line no-param-reassign + await req.sessionStore.set(session.id, session); + } + } + })); + + res.send(updatedUser); +} + +async function sendEmailVerificationApi(req, res) { + await sendEmailVerification(req.user, null, 'verify'); + + res.status(204).send(); +} + +async function verifyEmailApi(req, res) { + const userId = Number(req.body.userId) || null; + + await verifyEmail(userId, req.body.token); + + const sessions = await req.sessionStore.all(); + + await Promise.all(sessions.map(async (session) => { + if (session.user?.id === userId) { + session.user.refreshedAt = 0; // eslint-disable-line no-param-reassign + await req.sessionStore.set(session.id, session); + } + })); + + res.status(204).send(); +} + +async function updateSettingsApi(req, res) { + const updatedSettings = await updateSettings(req.body, req.user); + + req.session.user.settings = updatedSettings; + + res.send(updatedSettings); +} + export async function fetchUserKeysApi(req, res) { const keys = await fetchUserKeys(req.user); @@ -154,3 +215,28 @@ export async function flushUserKeysApi(req, res) { res.status(204).send(); } + +export const authRouter = Router(); + +// SESSION +// authRouter.use(setUserApi); + +authRouter.post('/api/session', loginApi); +authRouter.delete('/api/session', logoutApi); + +// USERS +authRouter.post('/api/users', signupApi); + +// SETTINGS +authRouter.patch('/api/me', updateUserApi); +authRouter.patch('/api/me/settings', updateSettingsApi); + +// EMAIL VERIFICATION +authRouter.post('/api/me/email_verification', sendEmailVerificationApi); +authRouter.post('/api/emails/verify', verifyEmailApi); // usable without session + +// API KEYS +authRouter.get('/api/me/keys', fetchUserKeysApi); +authRouter.post('/api/me/keys', createKeyApi); +authRouter.delete('/api/me/keys/:keyIdentifier', removeUserKeyApi); +authRouter.delete('/api/me/keys', flushUserKeysApi); diff --git a/src/web/main.js b/src/web/main.js index 9c809e1..4ed1961 100644 --- a/src/web/main.js +++ b/src/web/main.js @@ -30,6 +30,7 @@ export default async function mainHandler(req, res, next) { role: req.user.role, abilities: req.user.abilities, avatar: req.user.avatar, + isEmailVerified: req.user.isEmailVerified, }, assets: req.user ? { diff --git a/src/web/server.js b/src/web/server.js index 14376a8..a08c6ad 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -12,19 +12,11 @@ import initLogger from '../logger.js'; import redis from '../redis.js'; import queryBoolean from '../utils/query-boolean.js'; -import { actorsRouter } from './actors.js'; -import { router as alertsRouter } from './alerts.js'; -import { - createKeyApi, - fetchUserKeysApi, - flushUserKeysApi, - loginApi, - logoutApi, - removeUserKeyApi, - setUserApi, - signupApi, -} from './auth.js'; +import { actorsRouter } from './actors.js'; +import { alertsRouter } from './alerts.js'; +import { authRouter, setUserApi } from './auth.js'; + import consentHandler from './consent.js'; import { fetchEntitiesApi } from './entities.js'; @@ -91,8 +83,6 @@ export default async function initServer() { store: redisStore, })); - router.use(setUserApi); - // Vite integration if (isProduction) { app.enable('trust proxy'); @@ -118,6 +108,7 @@ export default async function initServer() { router.use(viteDevMiddleware); } + router.use(setUserApi); // we need to set the IP before the restriction handler router.use(restrictionHandler); router.get('/consent', (_req, res) => { @@ -134,22 +125,10 @@ export default async function initServer() { next(); }); - // SESSION - router.post('/api/session', loginApi); - router.delete('/api/session', logoutApi); - - // USERS - router.post('/api/users', signupApi); - - // API KEYS - router.get('/api/me/keys', fetchUserKeysApi); - router.post('/api/me/keys', createKeyApi); - router.delete('/api/me/keys/:keyIdentifier', removeUserKeyApi); - router.delete('/api/me/keys', flushUserKeysApi); - router.use(userRouter); router.use(stashesRouter); router.use(alertsRouter); + router.use(authRouter); router.use(scenesRouter); router.use(actorsRouter); router.use(syncRouter); diff --git a/src/web/users.js b/src/web/users.js index 5af37a9..642064b 100644 --- a/src/web/users.js +++ b/src/web/users.js @@ -6,7 +6,6 @@ import { fetchUser, fetchUserTemplates, removeTemplate, - updateSettings, } from '../users.js'; async function fetchUserApi(req, res) { @@ -15,14 +14,6 @@ async function fetchUserApi(req, res) { res.send(user); } -async function updateSettingsApi(req, res) { - const updatedSettings = await updateSettings(req.body, req.user); - - req.session.user.settings = updatedSettings; - - res.send(updatedSettings); -} - async function fetchUserTemplatesApi(req, res) { const templates = await fetchUserTemplates(req.user); @@ -52,8 +43,6 @@ export const router = Router(); router.get('/api/users/:userId', fetchUserApi); router.get('/api/users/:userId/templates', fetchUserTemplatesApi); -router.patch('/api/me/settings', updateSettingsApi); - router.post('/api/templates', createTemplateApi); router.delete('/api/templates/:templateId', removeTemplateApi);