Spaces:
Running
Running
Upload 34 files
Browse files- package.json +0 -1
- src/app.js +13 -5
- src/config/env.js +0 -3
- src/controllers/auth.controller.js +52 -51
- src/routes/auth.routes.js +7 -2
package.json
CHANGED
|
@@ -17,7 +17,6 @@
|
|
| 17 |
"bcryptjs": "^2.4.3",
|
| 18 |
"cloudinary": "^2.5.1",
|
| 19 |
"compression": "^1.7.4",
|
| 20 |
-
"cookie-parser": "^1.4.6",
|
| 21 |
"cors": "^2.8.5",
|
| 22 |
"dotenv": "^16.4.5",
|
| 23 |
"express": "^4.19.2",
|
|
|
|
| 17 |
"bcryptjs": "^2.4.3",
|
| 18 |
"cloudinary": "^2.5.1",
|
| 19 |
"compression": "^1.7.4",
|
|
|
|
| 20 |
"cors": "^2.8.5",
|
| 21 |
"dotenv": "^16.4.5",
|
| 22 |
"express": "^4.19.2",
|
src/app.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
| 1 |
const express = require("express");
|
| 2 |
const helmet = require("helmet");
|
| 3 |
const cors = require("cors");
|
| 4 |
-
const cookieParser = require("cookie-parser");
|
| 5 |
const compression = require("compression");
|
| 6 |
const morgan = require("morgan");
|
| 7 |
const mongoSanitize = require("express-mongo-sanitize");
|
|
@@ -41,8 +40,18 @@ app.use(
|
|
| 41 |
})
|
| 42 |
);
|
| 43 |
|
| 44 |
-
// --- CORS: only the configured frontend origins may call the API
|
| 45 |
-
// credentials
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
app.use(
|
| 47 |
cors({
|
| 48 |
origin(origin, callback) {
|
|
@@ -54,14 +63,13 @@ app.use(
|
|
| 54 |
logger.warn(`CORS rejected origin: "${origin}" (allow-list: ${JSON.stringify(env.clientOrigins)})`);
|
| 55 |
return callback(new Error("Not allowed by CORS"));
|
| 56 |
},
|
| 57 |
-
credentials:
|
| 58 |
})
|
| 59 |
);
|
| 60 |
|
| 61 |
app.use(compression());
|
| 62 |
app.use(express.json({ limit: "100kb" })); // small limit: this API never needs large bodies
|
| 63 |
app.use(express.urlencoded({ extended: true, limit: "100kb" }));
|
| 64 |
-
app.use(cookieParser(env.cookieSecret));
|
| 65 |
|
| 66 |
// Strip any keys starting with "$" or containing "." from user input to
|
| 67 |
// block MongoDB operator-injection attacks.
|
|
|
|
| 1 |
const express = require("express");
|
| 2 |
const helmet = require("helmet");
|
| 3 |
const cors = require("cors");
|
|
|
|
| 4 |
const compression = require("compression");
|
| 5 |
const morgan = require("morgan");
|
| 6 |
const mongoSanitize = require("express-mongo-sanitize");
|
|
|
|
| 40 |
})
|
| 41 |
);
|
| 42 |
|
| 43 |
+
// --- CORS: only the configured frontend origins may call the API.
|
| 44 |
+
// `credentials: false` is deliberate β this API is designed to run
|
| 45 |
+
// cross-site from its frontend (e.g. a Hugging Face Space backend +
|
| 46 |
+
// a separate Vercel-hosted frontend), and some hosting proxies in that
|
| 47 |
+
// kind of setup intercept CORS preflight requests before they reach
|
| 48 |
+
// this app and strip the Access-Control-Allow-Credentials header,
|
| 49 |
+
// which silently breaks anything relying on `credentials: include`.
|
| 50 |
+
// Sessions are carried via a bearer access token (Authorization
|
| 51 |
+
// header) plus a refresh token returned in the JSON response body
|
| 52 |
+
// instead of a cookie, so no cross-site credentialed request is ever
|
| 53 |
+
// needed β see controllers/auth.controller.js for the reasoning and
|
| 54 |
+
// trade-offs of that approach in more detail. ---
|
| 55 |
app.use(
|
| 56 |
cors({
|
| 57 |
origin(origin, callback) {
|
|
|
|
| 63 |
logger.warn(`CORS rejected origin: "${origin}" (allow-list: ${JSON.stringify(env.clientOrigins)})`);
|
| 64 |
return callback(new Error("Not allowed by CORS"));
|
| 65 |
},
|
| 66 |
+
credentials: false,
|
| 67 |
})
|
| 68 |
);
|
| 69 |
|
| 70 |
app.use(compression());
|
| 71 |
app.use(express.json({ limit: "100kb" })); // small limit: this API never needs large bodies
|
| 72 |
app.use(express.urlencoded({ extended: true, limit: "100kb" }));
|
|
|
|
| 73 |
|
| 74 |
// Strip any keys starting with "$" or containing "." from user input to
|
| 75 |
// block MongoDB operator-injection attacks.
|
src/config/env.js
CHANGED
|
@@ -8,7 +8,6 @@ const REQUIRED_IN_PRODUCTION = [
|
|
| 8 |
"JWT_ACCESS_SECRET",
|
| 9 |
"JWT_REFRESH_SECRET",
|
| 10 |
"FIELD_ENCRYPTION_KEY",
|
| 11 |
-
"COOKIE_SECRET",
|
| 12 |
"SMTP_HOST",
|
| 13 |
"SMTP_USER",
|
| 14 |
"SMTP_PASS",
|
|
@@ -33,8 +32,6 @@ const env = {
|
|
| 33 |
jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
|
| 34 |
|
| 35 |
fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY,
|
| 36 |
-
cookieSecret: process.env.COOKIE_SECRET,
|
| 37 |
-
cookieSecure: process.env.COOKIE_SECURE === "true",
|
| 38 |
|
| 39 |
smtp: {
|
| 40 |
host: process.env.SMTP_HOST,
|
|
|
|
| 8 |
"JWT_ACCESS_SECRET",
|
| 9 |
"JWT_REFRESH_SECRET",
|
| 10 |
"FIELD_ENCRYPTION_KEY",
|
|
|
|
| 11 |
"SMTP_HOST",
|
| 12 |
"SMTP_USER",
|
| 13 |
"SMTP_PASS",
|
|
|
|
| 32 |
jwtRefreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
|
| 33 |
|
| 34 |
fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY,
|
|
|
|
|
|
|
| 35 |
|
| 36 |
smtp: {
|
| 37 |
host: process.env.SMTP_HOST,
|
src/controllers/auth.controller.js
CHANGED
|
@@ -1,22 +1,34 @@
|
|
| 1 |
const User = require("../models/User");
|
| 2 |
const AuditLog = require("../models/AuditLog");
|
| 3 |
const { signAccessToken, signRefreshToken, verifyRefreshToken } = require("../utils/jwt");
|
| 4 |
-
const { env } = require("../config/env");
|
| 5 |
const logger = require("../utils/logger");
|
| 6 |
|
| 7 |
const MAX_FAILED_ATTEMPTS = 5;
|
| 8 |
const LOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes
|
| 9 |
-
const
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
async function audit(action, req, extra = {}) {
|
| 22 |
try {
|
|
@@ -33,6 +45,20 @@ async function audit(action, req, extra = {}) {
|
|
| 33 |
}
|
| 34 |
}
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
async function signup(req, res, next) {
|
| 37 |
try {
|
| 38 |
const { name, email, password } = req.body;
|
|
@@ -50,19 +76,10 @@ async function signup(req, res, next) {
|
|
| 50 |
|
| 51 |
await audit("user.signup", req, { email: user.email });
|
| 52 |
|
| 53 |
-
const accessToken =
|
| 54 |
-
const { token: refreshToken, jti } = signRefreshToken(user);
|
| 55 |
-
user.activeRefreshTokens = user.activeRefreshTokens || [];
|
| 56 |
-
user.activeRefreshTokens.push({
|
| 57 |
-
jti,
|
| 58 |
-
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
| 59 |
-
userAgent: req.headers["user-agent"],
|
| 60 |
-
ip: req.ip,
|
| 61 |
-
});
|
| 62 |
await user.save();
|
| 63 |
|
| 64 |
-
res.
|
| 65 |
-
res.status(201).json({ user: user.toSafeJSON(), accessToken });
|
| 66 |
} catch (err) {
|
| 67 |
next(err);
|
| 68 |
}
|
|
@@ -102,22 +119,12 @@ async function login(req, res, next) {
|
|
| 102 |
user.failedLoginAttempts = 0;
|
| 103 |
user.lockUntil = null;
|
| 104 |
|
| 105 |
-
const accessToken =
|
| 106 |
-
const { token: refreshToken, jti } = signRefreshToken(user);
|
| 107 |
-
user.activeRefreshTokens = (user.activeRefreshTokens || [])
|
| 108 |
-
.filter((rt) => rt.expiresAt > new Date()) // prune expired sessions
|
| 109 |
-
.concat({
|
| 110 |
-
jti,
|
| 111 |
-
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
| 112 |
-
userAgent: req.headers["user-agent"],
|
| 113 |
-
ip: req.ip,
|
| 114 |
-
});
|
| 115 |
await user.save();
|
| 116 |
|
| 117 |
await audit("user.login", req, { email: user.email });
|
| 118 |
|
| 119 |
-
res.
|
| 120 |
-
res.json({ user: user.toSafeJSON(), accessToken });
|
| 121 |
} catch (err) {
|
| 122 |
next(err);
|
| 123 |
}
|
|
@@ -125,14 +132,13 @@ async function login(req, res, next) {
|
|
| 125 |
|
| 126 |
async function refresh(req, res, next) {
|
| 127 |
try {
|
| 128 |
-
const token = req.
|
| 129 |
if (!token) return res.status(401).json({ error: "No refresh token provided." });
|
| 130 |
|
| 131 |
let payload;
|
| 132 |
try {
|
| 133 |
payload = verifyRefreshToken(token);
|
| 134 |
} catch {
|
| 135 |
-
res.clearCookie(REFRESH_COOKIE_NAME, refreshCookieOptions());
|
| 136 |
return res.status(401).json({ error: "Invalid or expired session, please log in again." });
|
| 137 |
}
|
| 138 |
|
|
@@ -143,25 +149,21 @@ async function refresh(req, res, next) {
|
|
| 143 |
|
| 144 |
const stillActive = (user.activeRefreshTokens || []).some((rt) => rt.jti === payload.jti);
|
| 145 |
if (!stillActive) {
|
| 146 |
-
// Possible
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
return res.status(401).json({ error: "Session no longer valid, please log in again." });
|
| 149 |
}
|
| 150 |
|
| 151 |
// Rotate: remove old jti, issue a new refresh token.
|
| 152 |
user.activeRefreshTokens = user.activeRefreshTokens.filter((rt) => rt.jti !== payload.jti);
|
| 153 |
-
const accessToken =
|
| 154 |
-
const { token: newRefreshToken, jti: newJti } = signRefreshToken(user);
|
| 155 |
-
user.activeRefreshTokens.push({
|
| 156 |
-
jti: newJti,
|
| 157 |
-
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
| 158 |
-
userAgent: req.headers["user-agent"],
|
| 159 |
-
ip: req.ip,
|
| 160 |
-
});
|
| 161 |
await user.save();
|
| 162 |
|
| 163 |
-
res.
|
| 164 |
-
res.json({ accessToken, user: user.toSafeJSON() });
|
| 165 |
} catch (err) {
|
| 166 |
next(err);
|
| 167 |
}
|
|
@@ -169,7 +171,7 @@ async function refresh(req, res, next) {
|
|
| 169 |
|
| 170 |
async function logout(req, res, next) {
|
| 171 |
try {
|
| 172 |
-
const token = req.
|
| 173 |
if (token) {
|
| 174 |
try {
|
| 175 |
const payload = verifyRefreshToken(token);
|
|
@@ -178,7 +180,6 @@ async function logout(req, res, next) {
|
|
| 178 |
// token already invalid β nothing to revoke
|
| 179 |
}
|
| 180 |
}
|
| 181 |
-
res.clearCookie(REFRESH_COOKIE_NAME, refreshCookieOptions());
|
| 182 |
res.json({ success: true });
|
| 183 |
} catch (err) {
|
| 184 |
next(err);
|
|
|
|
| 1 |
const User = require("../models/User");
|
| 2 |
const AuditLog = require("../models/AuditLog");
|
| 3 |
const { signAccessToken, signRefreshToken, verifyRefreshToken } = require("../utils/jwt");
|
|
|
|
| 4 |
const logger = require("../utils/logger");
|
| 5 |
|
| 6 |
const MAX_FAILED_ATTEMPTS = 5;
|
| 7 |
const LOCK_DURATION_MS = 15 * 60 * 1000; // 15 minutes
|
| 8 |
+
const REFRESH_TOKEN_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
|
| 9 |
+
|
| 10 |
+
// --------------------------------------------------------------------
|
| 11 |
+
// Refresh tokens are returned in the JSON response body (and stored by
|
| 12 |
+
// the client in localStorage) rather than as an httpOnly cookie.
|
| 13 |
+
//
|
| 14 |
+
// Why: this API and its frontend are deployed on different domains
|
| 15 |
+
// (e.g. a Hugging Face Space + a separate Vercel domain), and some
|
| 16 |
+
// hosting proxies in that kind of cross-site setup intercept CORS
|
| 17 |
+
// preflight requests before they reach the app and strip the
|
| 18 |
+
// Access-Control-Allow-Credentials header β which silently breaks any
|
| 19 |
+
// flow that depends on browsers sending a cross-site cookie. Avoiding
|
| 20 |
+
// `credentials: include` entirely sidesteps that class of problem.
|
| 21 |
+
//
|
| 22 |
+
// The trade-off: an httpOnly cookie can never be read by JavaScript,
|
| 23 |
+
// even from an XSS payload; a token sitting in localStorage can be.
|
| 24 |
+
// We narrow that risk with what's still in our control β access
|
| 25 |
+
// tokens stay short-lived (15 min) and refresh tokens rotate on every
|
| 26 |
+
// use with reuse detection (see `refresh` below) β but this is
|
| 27 |
+
// genuinely a lower security bar than the cookie-based approach, and
|
| 28 |
+
// worth knowing if you migrate this API to a same-site deployment
|
| 29 |
+
// later (at which point switching back to an httpOnly cookie is
|
| 30 |
+
// recommended).
|
| 31 |
+
// --------------------------------------------------------------------
|
| 32 |
|
| 33 |
async function audit(action, req, extra = {}) {
|
| 34 |
try {
|
|
|
|
| 45 |
}
|
| 46 |
}
|
| 47 |
|
| 48 |
+
function issueSession(user, req) {
|
| 49 |
+
const accessToken = signAccessToken(user);
|
| 50 |
+
const { token: refreshToken, jti } = signRefreshToken(user);
|
| 51 |
+
user.activeRefreshTokens = (user.activeRefreshTokens || [])
|
| 52 |
+
.filter((rt) => rt.expiresAt > new Date()) // prune expired sessions as we go
|
| 53 |
+
.concat({
|
| 54 |
+
jti,
|
| 55 |
+
expiresAt: new Date(Date.now() + REFRESH_TOKEN_LIFETIME_MS),
|
| 56 |
+
userAgent: req.headers["user-agent"],
|
| 57 |
+
ip: req.ip,
|
| 58 |
+
});
|
| 59 |
+
return { accessToken, refreshToken };
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
async function signup(req, res, next) {
|
| 63 |
try {
|
| 64 |
const { name, email, password } = req.body;
|
|
|
|
| 76 |
|
| 77 |
await audit("user.signup", req, { email: user.email });
|
| 78 |
|
| 79 |
+
const { accessToken, refreshToken } = issueSession(user, req);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
await user.save();
|
| 81 |
|
| 82 |
+
res.status(201).json({ user: user.toSafeJSON(), accessToken, refreshToken });
|
|
|
|
| 83 |
} catch (err) {
|
| 84 |
next(err);
|
| 85 |
}
|
|
|
|
| 119 |
user.failedLoginAttempts = 0;
|
| 120 |
user.lockUntil = null;
|
| 121 |
|
| 122 |
+
const { accessToken, refreshToken } = issueSession(user, req);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
await user.save();
|
| 124 |
|
| 125 |
await audit("user.login", req, { email: user.email });
|
| 126 |
|
| 127 |
+
res.json({ user: user.toSafeJSON(), accessToken, refreshToken });
|
|
|
|
| 128 |
} catch (err) {
|
| 129 |
next(err);
|
| 130 |
}
|
|
|
|
| 132 |
|
| 133 |
async function refresh(req, res, next) {
|
| 134 |
try {
|
| 135 |
+
const { refreshToken: token } = req.body;
|
| 136 |
if (!token) return res.status(401).json({ error: "No refresh token provided." });
|
| 137 |
|
| 138 |
let payload;
|
| 139 |
try {
|
| 140 |
payload = verifyRefreshToken(token);
|
| 141 |
} catch {
|
|
|
|
| 142 |
return res.status(401).json({ error: "Invalid or expired session, please log in again." });
|
| 143 |
}
|
| 144 |
|
|
|
|
| 149 |
|
| 150 |
const stillActive = (user.activeRefreshTokens || []).some((rt) => rt.jti === payload.jti);
|
| 151 |
if (!stillActive) {
|
| 152 |
+
// Possible reuse of an already-rotated (or logged-out) refresh
|
| 153 |
+
// token β deny it. As a precaution, also revoke every other
|
| 154 |
+
// active session for this account, since a replayed token is a
|
| 155 |
+
// signal the original may have leaked.
|
| 156 |
+
user.activeRefreshTokens = [];
|
| 157 |
+
await user.save();
|
| 158 |
return res.status(401).json({ error: "Session no longer valid, please log in again." });
|
| 159 |
}
|
| 160 |
|
| 161 |
// Rotate: remove old jti, issue a new refresh token.
|
| 162 |
user.activeRefreshTokens = user.activeRefreshTokens.filter((rt) => rt.jti !== payload.jti);
|
| 163 |
+
const { accessToken, refreshToken: newRefreshToken } = issueSession(user, req);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
await user.save();
|
| 165 |
|
| 166 |
+
res.json({ accessToken, refreshToken: newRefreshToken, user: user.toSafeJSON() });
|
|
|
|
| 167 |
} catch (err) {
|
| 168 |
next(err);
|
| 169 |
}
|
|
|
|
| 171 |
|
| 172 |
async function logout(req, res, next) {
|
| 173 |
try {
|
| 174 |
+
const { refreshToken: token } = req.body;
|
| 175 |
if (token) {
|
| 176 |
try {
|
| 177 |
const payload = verifyRefreshToken(token);
|
|
|
|
| 180 |
// token already invalid β nothing to revoke
|
| 181 |
}
|
| 182 |
}
|
|
|
|
| 183 |
res.json({ success: true });
|
| 184 |
} catch (err) {
|
| 185 |
next(err);
|
src/routes/auth.routes.js
CHANGED
|
@@ -38,8 +38,13 @@ router.post(
|
|
| 38 |
ctrl.login
|
| 39 |
);
|
| 40 |
|
| 41 |
-
router.post(
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
router.get("/me", requireAuth, ctrl.me);
|
| 44 |
|
| 45 |
module.exports = router;
|
|
|
|
| 38 |
ctrl.login
|
| 39 |
);
|
| 40 |
|
| 41 |
+
router.post(
|
| 42 |
+
"/refresh",
|
| 43 |
+
[body("refreshToken").notEmpty().withMessage("A refresh token is required.")],
|
| 44 |
+
validate,
|
| 45 |
+
ctrl.refresh
|
| 46 |
+
);
|
| 47 |
+
router.post("/logout", [body("refreshToken").optional({ nullable: true }).isString()], validate, ctrl.logout);
|
| 48 |
router.get("/me", requireAuth, ctrl.me);
|
| 49 |
|
| 50 |
module.exports = router;
|