Spaces:
Running
Running
Download src/controllers/auth.controller.js from AndreaNotes1/auth-app-api: direct link, hf CLI and curl.
- Browser
- Download file 7.16 kB
-
https://huggingface.co/spaces/AndreaNotes1/auth-app-api/resolve/main/src/controllers/auth.controller.js
- Command line
-
hf download hf://spaces/AndreaNotes1/auth-app-api/src/controllers/auth.controller.js
-
curl -L -o auth.controller.js https://huggingface.co/spaces/AndreaNotes1/auth-app-api/resolve/main/src/controllers/auth.controller.js
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 }; | |