auth-app-api / src /controllers /auth.controller.js
AndreaNotes1's picture
Upload 36 files
6e13570 verified
Raw History Blame Contribute Delete
7.16 kB
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 };