Spaces:
Running
Running
File size: 7,158 Bytes
6e13570 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | 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 };
|