const User = require("../models/User"); const AuditLog = require("../models/AuditLog"); const { signAccessToken, signRefreshToken, verifyRefreshToken } = require("../utils/jwt"); const logger = require("../utils/logger"); const MAX_FAILED_ATTEMPTS = 5; const LOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes const REFRESH_TOKEN_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000; // -------------------------------------------------------------------- // Refresh tokens are returned in the JSON response body (and stored by // the client in localStorage) rather than as an httpOnly cookie. // // Why: this API and its frontend are deployed on different domains // (e.g. a Hugging Face Space + a separate Vercel domain), and some // hosting proxies in that kind of cross-site setup intercept CORS // preflight requests before they reach the app and strip the // Access-Control-Allow-Credentials header — which silently breaks any // flow that depends on browsers sending a cross-site cookie. Avoiding // `credentials: include` entirely sidesteps that class of problem. // // The trade-off: an httpOnly cookie can never be read by JavaScript, // even from an XSS payload; a token sitting in localStorage can be. // We narrow that risk with what's still in our control — access // tokens stay short-lived (15 min) and refresh tokens rotate on every // use with reuse detection (see `refresh` below) — but this is // genuinely a lower security bar than the cookie-based approach, and // worth knowing if you migrate this API to a same-site deployment // later (at which point switching back to an httpOnly cookie is // recommended). // -------------------------------------------------------------------- async function audit(action, req, extra = {}) { try { await AuditLog.create({ actor: req.user ? req.user._id : null, actorEmail: req.user ? req.user.email : extra.email || null, action, ip: req.ip, userAgent: req.headers["user-agent"], metadata: extra.metadata || {}, }); } catch (err) { logger.error("Failed to write audit log", { action, error: err.message }); } } function issueSession(user, req) { const accessToken = signAccessToken(user); const { token: refreshToken, jti } = signRefreshToken(user); user.activeRefreshTokens = (user.activeRefreshTokens || []) .filter((rt) => rt.expiresAt > new Date()) // prune expired sessions as we go .concat({ jti, expiresAt: new Date(Date.now() + REFRESH_TOKEN_LIFETIME_MS), userAgent: req.headers["user-agent"], ip: req.ip, }); return { accessToken, refreshToken }; } async function signup(req, res, next) { try { const { name, email, password } = req.body; const existing = await User.findOne({ email: email.toLowerCase() }); if (existing) { // Do not reveal which part of the credential pair is wrong to // avoid account enumeration. return res.status(409).json({ error: "Unable to create account with the details provided." }); } const user = new User({ name, email: email.toLowerCase() }); await user.setPassword(password); await user.save(); await audit("user.signup", req, { email: user.email }); const { accessToken, refreshToken } = issueSession(user, req); await user.save(); res.status(201).json({ user: user.toSafeJSON(), accessToken, refreshToken }); } catch (err) { next(err); } } async function login(req, res, next) { try { const { email, password } = req.body; const user = await User.findOne({ email: email.toLowerCase() }).select( "+passwordHash +failedLoginAttempts +lockUntil +activeRefreshTokens" ); // Constant-shaped response whether the user exists or not. const genericError = () => res.status(401).json({ error: "Invalid email or password." }); if (!user || !user.isActive) return genericError(); if (user.isLocked()) { return res.status(423).json({ error: "Account temporarily locked due to repeated failed attempts. Try again later.", }); } const valid = await user.comparePassword(password); if (!valid) { user.failedLoginAttempts = (user.failedLoginAttempts || 0) + 1; if (user.failedLoginAttempts >= MAX_FAILED_ATTEMPTS) { user.lockUntil = new Date(Date.now() + LOCK_DURATION_MS); user.failedLoginAttempts = 0; await audit("user.login.locked", req, { email: user.email }); } await user.save(); return genericError(); } user.failedLoginAttempts = 0; user.lockUntil = null; const { accessToken, refreshToken } = issueSession(user, req); await user.save(); await audit("user.login", req, { email: user.email }); res.json({ user: user.toSafeJSON(), accessToken, refreshToken }); } catch (err) { next(err); } } async function refresh(req, res, next) { try { const { refreshToken: token } = req.body; if (!token) return res.status(401).json({ error: "No refresh token provided." }); let payload; try { payload = verifyRefreshToken(token); } catch { return res.status(401).json({ error: "Invalid or expired session, please log in again." }); } const user = await User.findById(payload.sub).select("+activeRefreshTokens"); if (!user || !user.isActive || user.tokenVersion !== payload.tokenVersion) { return res.status(401).json({ error: "Session no longer valid, please log in again." }); } const stillActive = (user.activeRefreshTokens || []).some((rt) => rt.jti === payload.jti); if (!stillActive) { // Possible reuse of an already-rotated (or logged-out) refresh // token — deny it. As a precaution, also revoke every other // active session for this account, since a replayed token is a // signal the original may have leaked. user.activeRefreshTokens = []; await user.save(); return res.status(401).json({ error: "Session no longer valid, please log in again." }); } // Rotate: remove old jti, issue a new refresh token. user.activeRefreshTokens = user.activeRefreshTokens.filter((rt) => rt.jti !== payload.jti); const { accessToken, refreshToken: newRefreshToken } = issueSession(user, req); await user.save(); res.json({ accessToken, refreshToken: newRefreshToken, user: user.toSafeJSON() }); } catch (err) { next(err); } } async function logout(req, res, next) { try { const { refreshToken: token } = req.body; if (token) { try { const payload = verifyRefreshToken(token); await User.findByIdAndUpdate(payload.sub, { $pull: { activeRefreshTokens: { jti: payload.jti } } }); } catch { // token already invalid — nothing to revoke } } res.json({ success: true }); } catch (err) { next(err); } } async function me(req, res) { res.json({ user: req.user.toSafeJSON() }); } module.exports = { signup, login, refresh, logout, me };