274 lines
6.6 KiB
JavaScript
Executable File
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);
|