import { Router } from 'express'; import IPCIDR from 'ip-cidr'; import runtimeId from '../../utils/runtime-id.js'; import argv from '../argv.js'; import { createKey, fetchUserKeys, flushUserKeys, login, removeUserKey, resetPassword, sendEmailVerification, sendPasswordReset, sendSignup, signup, updateSettings, updateUser, verifyEmail, } from '../auth.js'; import { fetchUser, } from '../users.js'; // spread into a new object rather than mutating the user we're about to hand back to the // client (loginApi/signupApi send this straight to the browser) — runtimeId/refreshedAt are // session bookkeeping only, they shouldn't leak into the API response function toSession(user) { if (!user) { return null; } return { ...user, runtimeId, refreshedAt: Date.now(), }; } // concurrent requests for the same user (e.g. the burst of asset/API requests a single page // load fires) share one in-flight refresh instead of each independently hitting the DB const pendingSessionRefreshes = new Map(); async function refreshSessionUser(sessionUser) { const pending = pendingSessionRefreshes.get(sessionUser.id); if (pending) { return pending; } const refresh = fetchUser(sessionUser.id, {}, sessionUser) .then((user) => toSession(user)) .finally(() => pendingSessionRefreshes.delete(sessionUser.id)); pendingSessionRefreshes.set(sessionUser.id, refresh); return refresh; } function getIp(req) { if (argv.ip) { return argv.ip; } const ip = req.headers['x-forwarded-for']?.split(',')[0] || req.connection.remoteAddress; const unmappedIp = ip?.includes('.') ? ip.slice(ip.lastIndexOf(':') + 1) : ip; if (!unmappedIp) { console.log('failed unmapped ip', ip, unmappedIp); return null; } // ensure IP is in expanded notation for consistency and matching const expandedIp = unmappedIp.includes(':') ? new IPCIDR(`${ip}/128`) // IPv6 : new IPCIDR(`${ip}/32`); // IPv4 if (!expandedIp.addressStart?.addressMinusSuffix) { throw new Error(`Could not determine user IP from ${ip}`); } return expandedIp.addressStart?.addressMinusSuffix || null; } // changes every time the process restarts, so a deploy invalidates all cached session users // even if they're within the staleness window below const sessionStaleThreshold = 5 * 60 * 1000; function isSessionStale(sessionUser) { return !!sessionUser && (sessionUser.runtimeId !== runtimeId || Date.now() - sessionUser.refreshedAt > sessionStaleThreshold); } export async function setUserApi(req, res, next) { const ip = getIp(req); req.userIp = ip; if (isSessionStale(req.session.user)) { req.session.user = await refreshSessionUser(req.session.user); } if (req.session.user) { req.user = req.session.user; req.user.ip = ip; } next(); } export async function updateSessionUser(req) { const user = toSession(await fetchUser(req.session.user.id, {}, req.session.user)); if (user) { req.session.user = user; req.user = user; req.user.ip = req.userIp; } } export async function loginApi(req, res) { const user = await login(req.body, req.userIp); req.session.user = toSession(user); res.send(user); } export async function logoutApi(req, res) { req.session.destroy((error) => { if (error) { res.status(500).send(); } res.status(204).send(); }); } async function sendSignupApi(req, res) { await sendSignup(req.body.email, req.userIp); res.status(204).send(); } export async function signupApi(req, res) { const user = await signup(req.body, req.userIp); req.session.user = toSession(user); 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 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); req.session.user.settings = updatedSettings; res.send(updatedSettings); } export async function fetchUserKeysApi(req, res) { const keys = await fetchUserKeys(req.user); res.send(keys); } export async function createKeyApi(req, res) { const key = await createKey(req.user); res.send(key); } export async function removeUserKeyApi(req, res) { await removeUserKey(req.user, req.params.keyIdentifier); res.status(204).send(); } export async function flushUserKeysApi(req, res) { await flushUserKeys(req.user); 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/signups', sendSignupApi); 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 authRouter.post('/api/passwords/request', sendPasswordResetApi); authRouter.post('/api/passwords/reset', resetPasswordApi); // 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);