Files
traxxx-web/src/web/auth.js

274 lines
6.6 KiB
JavaScript
Executable File

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);