feat: PKCE auth + CI/CD deploy
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 25s
Some checks failed
CI/CD — Build & Deploy / Build & Deploy (push) Failing after 25s
- Frontend: PKCE flow (oauth.js, api.js centralized, cookie-based AuthContext) - Backend: token introspection, cookies httpOnly, refresh endpoint - Replaced localStorage JWT with httpOnly session cookies - useSaveSync migrated to cookie auth - cookie-parser added - Gitea CI workflow (vps-runner pattern)
This commit is contained in:
@@ -1,86 +1,32 @@
|
||||
// Load the express module to create a web application
|
||||
|
||||
const express = require("express");
|
||||
const cors = require("cors");
|
||||
const cookieParser = require("cookie-parser");
|
||||
|
||||
const app = express();
|
||||
|
||||
// Configure it
|
||||
|
||||
/* ************************************************************************* */
|
||||
|
||||
// CORS Handling: Why is the current code commented out and do I need to define specific allowed origins for my project?
|
||||
|
||||
// CORS (Cross-Origin Resource Sharing) is a security mechanism in web browsers that blocks requests from a different domain than the server.
|
||||
// You may find the following magic line in forums:
|
||||
|
||||
// app.use(cors());
|
||||
|
||||
// You should NOT do that: such code uses the `cors` module to allow all origins, which can pose security issues.
|
||||
// For this pedagogical template, the CORS code is commented out to show the need for defining specific allowed origins.
|
||||
|
||||
// To enable CORS and define allowed origins:
|
||||
// 1. Install the `cors` module in the backend directory
|
||||
// 2. Uncomment the line `const cors = require("cors");`
|
||||
// 3. Uncomment the section `app.use(cors({ origin: [...] }))`
|
||||
// 4. Be sure to only have URLs in the array with domains from which you want to allow requests.
|
||||
// For example: ["http://mysite.com", "http://another-domain.com"]
|
||||
|
||||
const cors = require("cors");
|
||||
// Trust reverse proxy (Apache on VPS)
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
// CORS — frontend + SuperOAuth origins
|
||||
app.use(
|
||||
cors({
|
||||
origin: [
|
||||
process.env.FRONTEND_URL,
|
||||
process.env.SUPER_OAUTH_URL,
|
||||
],
|
||||
].filter(Boolean),
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
/* ************************************************************************* */
|
||||
|
||||
// Request Parsing: Understanding the purpose of this part
|
||||
|
||||
// Request parsing is necessary to extract data sent by the client in an HTTP request.
|
||||
// For example to access the body of a POST request.
|
||||
// The current code contains different parsing options as comments to demonstrate different ways of extracting data.
|
||||
|
||||
// 1. `express.json()`: Parses requests with JSON data.
|
||||
// 2. `express.urlencoded()`: Parses requests with URL-encoded data.
|
||||
// 3. `express.text()`: Parses requests with raw text data.
|
||||
// 4. `express.raw()`: Parses requests with raw binary data.
|
||||
|
||||
// Uncomment one or more of these options depending on the format of the data sent by your client:
|
||||
// Cookie parser with signing
|
||||
const cookieSecret = process.env.COOKIE_SECRET;
|
||||
if (!cookieSecret) {
|
||||
console.error("COOKIE_SECRET manquant — cookies non signés !");
|
||||
}
|
||||
app.use(cookieParser(cookieSecret));
|
||||
|
||||
// JSON body parser
|
||||
app.use(express.json());
|
||||
// app.use(express.urlencoded());
|
||||
// app.use(express.text());
|
||||
// app.use(express.raw());
|
||||
|
||||
/* ************************************************************************* */
|
||||
|
||||
// Cookies: Why and how to use the `cookie-parser` module?
|
||||
|
||||
// Cookies are small pieces of data stored in the client's browser. They are often used to store user-specific information or session data.
|
||||
|
||||
// The `cookie-parser` module allows us to parse and manage cookies in our Express application. It parses the `Cookie` header in incoming requests and populates `req.cookies` with an object containing the cookies.
|
||||
|
||||
// To use `cookie-parser`, make sure it is installed in `backend/package.json` (you may need to install it separately):
|
||||
// npm install cookie-parser
|
||||
|
||||
// Then, require the module and use it as middleware in your Express application:
|
||||
|
||||
// const cookieParser = require("cookie-parser");
|
||||
// app.use(cookieParser());
|
||||
|
||||
// Once `cookie-parser` is set up, you can read and set cookies in your routes.
|
||||
// For example, to set a cookie named "username" with the value "john":
|
||||
// res.cookie("username", "john");
|
||||
|
||||
// To read the value of a cookie named "username":
|
||||
// const username = req.cookies.username;
|
||||
|
||||
/* ************************************************************************* */
|
||||
|
||||
// Import the API routes from the router module
|
||||
const path = require("path");
|
||||
@@ -89,35 +35,7 @@ const router = require("./router");
|
||||
// Mount the API routes under the "/api" endpoint
|
||||
app.use("/api", router);
|
||||
|
||||
/* ************************************************************************* */
|
||||
|
||||
// Production-ready setup: What is it for, and when should I enable it?
|
||||
|
||||
// The code includes commented sections to set up a production environment where the frontend and backend are served from the same server.
|
||||
|
||||
// What it's for:
|
||||
// - Serving frontend static files from the backend, which is useful when building a single-page application with React, Angular, etc.
|
||||
// - Redirecting unhandled requests (e.g., all requests not matching a defined API route) to the frontend's index.html. This allows the frontend to handle client-side routing.
|
||||
|
||||
// When to enable it:
|
||||
// It depends on your project and its structure. If you are developing a single-page application, you'll enable these sections when you are ready to deploy your project to production.
|
||||
|
||||
// To enable production configuration:
|
||||
// 1. Uncomment the lines related to serving static files and redirecting unhandled requests.
|
||||
// 2. Ensure that the `reactBuildPath` points to the correct directory where your frontend's build artifacts are located.
|
||||
|
||||
// const reactBuildPath = `${__dirname}/../../frontend/dist`;
|
||||
|
||||
// // Serve react resources
|
||||
|
||||
// app.use(express.static(reactBuildPath));
|
||||
|
||||
// // Redirect unhandled requests to the react index file
|
||||
|
||||
// app.get("*", (req, res) => {
|
||||
// res.sendFile(`${reactBuildPath}/index.html`);
|
||||
// });
|
||||
|
||||
// Serve frontend static files in production
|
||||
app.use("*", (req, res) => {
|
||||
if (req.originalUrl.includes("assets")) {
|
||||
res.sendFile(
|
||||
@@ -127,26 +45,5 @@ app.use("*", (req, res) => {
|
||||
res.sendFile(path.resolve(__dirname, `../../frontend/dist/index.html`));
|
||||
}
|
||||
});
|
||||
/* ************************************************************************* */
|
||||
|
||||
// Middleware for Error Logging (Uncomment to enable)
|
||||
// Important: Error-handling middleware should be defined last, after other app.use() and routes calls.
|
||||
|
||||
/*
|
||||
// Define a middleware function to log errors
|
||||
const logErrors = (err, req, res, next) => {
|
||||
// Log the error to the console for debugging purposes
|
||||
console.error(err);
|
||||
console.error("on req:", req.method, req.path);
|
||||
|
||||
// Pass the error to the next middleware in the stack
|
||||
next(err);
|
||||
};
|
||||
|
||||
// Mount the logErrors middleware globally
|
||||
app.use(logErrors);
|
||||
*/
|
||||
|
||||
/* ************************************************************************* */
|
||||
|
||||
module.exports = app;
|
||||
|
||||
@@ -1,78 +1,175 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const tables = require("../tables");
|
||||
|
||||
const secretKey = process.env.APP_SECRET;
|
||||
const SUPER_OAUTH_URL = process.env.SUPER_OAUTH_URL;
|
||||
const COOKIE_NAME = "session";
|
||||
const REFRESH_COOKIE_NAME = "refresh_token";
|
||||
|
||||
/**
|
||||
* GET /api/auth/callback?code=<token>
|
||||
*
|
||||
* Reçoit le token SuperOAuth depuis le frontend après redirect OAuth.
|
||||
* Valide auprès de SuperOAuth, résout ou crée le user local, retourne un JWT local.
|
||||
*/
|
||||
const callback = async (req, res) => {
|
||||
const { code } = req.query;
|
||||
function cookieOptions(maxAgeDays) {
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
return {
|
||||
httpOnly: true,
|
||||
signed: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax",
|
||||
maxAge: maxAgeDays * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ message: "Missing OAuth code." });
|
||||
async function introspectToken(token) {
|
||||
if (!SUPER_OAUTH_URL) {
|
||||
throw new Error("SUPER_OAUTH_URL not configured");
|
||||
}
|
||||
|
||||
const superOAuthUrl = process.env.SUPER_OAUTH_URL;
|
||||
if (!superOAuthUrl) {
|
||||
return res.status(500).json({ message: "Auth service not configured." });
|
||||
const response = await fetch(`${SUPER_OAUTH_URL}/api/v1/auth/token/validate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.data?.valid || !data.data?.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!data.data.user.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.data.user;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/auth/session
|
||||
* Receives { token, refreshToken? } from frontend after PKCE exchange.
|
||||
* Introspects token via SuperOAuth, upserts local user, sets httpOnly cookies.
|
||||
*/
|
||||
const session = async (req, res) => {
|
||||
const { token, refreshToken } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({ message: "Missing token." });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${superOAuthUrl}/api/v1/auth/token/validate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: code }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.data?.valid || !data.data?.user) {
|
||||
const oauthUser = await introspectToken(token);
|
||||
if (!oauthUser) {
|
||||
return res.status(401).json({ message: "Invalid OAuth token." });
|
||||
}
|
||||
|
||||
if (!data.data.user.isActive) {
|
||||
return res.status(401).json({ message: "Account is disabled." });
|
||||
}
|
||||
|
||||
const { id: superOAuthId, email, nickname } = data.data.user;
|
||||
const { id: superOAuthId, email, nickname } = oauthUser;
|
||||
|
||||
let localUser = await tables.users.getBySuperOAuthId(superOAuthId);
|
||||
|
||||
if (!localUser) {
|
||||
// Premier login OAuth — créer le compte local
|
||||
const insertId = await tables.users.create({
|
||||
nickname: nickname ?? email?.split("@")[0] ?? `user_${Date.now()}`,
|
||||
mail: email ?? `${superOAuthId}@oauth.local`,
|
||||
password: "", // pas de password local — auth via SuperOAuth uniquement
|
||||
password: "",
|
||||
tetardcoin: 1000,
|
||||
});
|
||||
await tables.users.linkSuperOAuth(insertId, superOAuthId);
|
||||
localUser = await tables.users.read(insertId);
|
||||
}
|
||||
|
||||
const token = jwt.sign({ user: localUser.id }, secretKey, { expiresIn: "7d" });
|
||||
// Set session cookie
|
||||
res.cookie(COOKIE_NAME, String(localUser.id), cookieOptions(7));
|
||||
|
||||
return res.status(200).json({
|
||||
message: "Connexion réussie",
|
||||
user: localUser,
|
||||
token,
|
||||
});
|
||||
// Set refresh token cookie if provided
|
||||
if (refreshToken) {
|
||||
res.cookie(REFRESH_COOKIE_NAME, refreshToken, cookieOptions(30));
|
||||
}
|
||||
|
||||
return res.status(200).json({ user: localUser });
|
||||
} catch (err) {
|
||||
console.error("auth/callback — error", err);
|
||||
console.error("auth/session — error", err);
|
||||
return res.status(500).json({ message: "Internal server error." });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* Reads refresh_token from cookie, exchanges for new access token via SuperOAuth.
|
||||
*/
|
||||
const refresh = async (req, res) => {
|
||||
const refreshToken = req.signedCookies?.[REFRESH_COOKIE_NAME];
|
||||
if (!refreshToken) {
|
||||
return res.status(401).json({ message: "No refresh token." });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SUPER_OAUTH_URL}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(401).json({ message: "Refresh token invalid or expired." });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.access_token) {
|
||||
return res.status(401).json({ message: "Refresh failed." });
|
||||
}
|
||||
|
||||
// Validate new token
|
||||
const oauthUser = await introspectToken(data.access_token);
|
||||
if (!oauthUser) {
|
||||
return res.status(401).json({ message: "Refreshed token invalid." });
|
||||
}
|
||||
|
||||
const localUser = await tables.users.getBySuperOAuthId(oauthUser.id);
|
||||
if (!localUser) {
|
||||
return res.status(401).json({ message: "User not found." });
|
||||
}
|
||||
|
||||
// Set new cookies
|
||||
res.cookie(COOKIE_NAME, String(localUser.id), cookieOptions(7));
|
||||
if (data.refresh_token) {
|
||||
res.cookie(REFRESH_COOKIE_NAME, data.refresh_token, cookieOptions(30));
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("auth/refresh — error", err);
|
||||
return res.status(500).json({ message: "Internal server error." });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Returns current user from session cookie.
|
||||
*/
|
||||
const me = async (req, res) => {
|
||||
const userId = req.signedCookies?.[COOKIE_NAME];
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: "Not authenticated." });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await tables.users.read(parseInt(userId, 10));
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: "User not found." });
|
||||
}
|
||||
return res.status(200).json(user);
|
||||
} catch (err) {
|
||||
console.error("auth/me — error", err);
|
||||
return res.status(500).json({ message: "Internal server error." });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Stateless — le token JWT est géré côté client.
|
||||
* Clears session cookies.
|
||||
*/
|
||||
const logout = (_req, res) => {
|
||||
res.clearCookie(COOKIE_NAME);
|
||||
res.clearCookie(REFRESH_COOKIE_NAME);
|
||||
return res.status(200).json({ message: "Déconnexion réussie." });
|
||||
};
|
||||
|
||||
module.exports = { callback, logout };
|
||||
module.exports = { session, refresh, me, logout };
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const tables = require("../tables");
|
||||
|
||||
const secretKey = process.env.APP_SECRET;
|
||||
/**
|
||||
* Cookie-based auth middleware.
|
||||
* Reads signed session cookie → resolves local user → sets req.user = user.id
|
||||
*/
|
||||
const verifyToken = async (req, res, next) => {
|
||||
const userId = req.signedCookies?.session;
|
||||
|
||||
const verifyToken = (req, res, next) => {
|
||||
const token = req.header("x-auth-token");
|
||||
|
||||
if (!token) {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ message: "Access denied. No token provided." });
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: "Access denied. No session." });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, secretKey);
|
||||
req.user = decoded.user;
|
||||
next();
|
||||
return null;
|
||||
} catch (error) {
|
||||
return res.status(401).json({ message: "Invalid token." });
|
||||
const user = await tables.users.read(parseInt(userId, 10));
|
||||
if (!user) {
|
||||
return res.status(401).json({ message: "User not found." });
|
||||
}
|
||||
req.user = user.id;
|
||||
return next();
|
||||
} catch (err) {
|
||||
console.error("verifyToken — error", err);
|
||||
return res.status(500).json({ message: "Internal server error." });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,18 +2,13 @@ const express = require("express");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/* ************************************************************************* */
|
||||
// Define Your API Routes Here
|
||||
/* ************************************************************************* */
|
||||
|
||||
// Import Controllers
|
||||
const userControllers = require("./controllers/userControllers");
|
||||
const authControllers = require("./controllers/authControllers");
|
||||
const saveControllers = require("./controllers/saveControllers");
|
||||
const verifyToken = require("./middlewares/verifyToken");
|
||||
const verifyOAuth = require("./middlewares/verifyOAuth");
|
||||
|
||||
// Vérifie que le token appartient au même utilisateur que :id
|
||||
// Vérifie que le cookie session appartient au même utilisateur que :id
|
||||
const verifySelf = (req, res, next) => {
|
||||
if (String(req.user) !== String(req.params.id)) {
|
||||
return res.status(403).json({ message: "Forbidden." });
|
||||
@@ -21,11 +16,13 @@ const verifySelf = (req, res, next) => {
|
||||
return next();
|
||||
};
|
||||
|
||||
// Auth SuperOAuth
|
||||
router.get("/auth/callback", authControllers.callback);
|
||||
// Auth — PKCE flow (cookie-based)
|
||||
router.post("/auth/session", authControllers.session);
|
||||
router.post("/auth/refresh", authControllers.refresh);
|
||||
router.get("/auth/me", authControllers.me);
|
||||
router.post("/auth/logout", authControllers.logout);
|
||||
|
||||
// User management (auth locale — conservée pendant migration)
|
||||
// User management
|
||||
router.get("/users", verifyToken, userControllers.browse);
|
||||
router.get("/users/:id", verifyToken, verifySelf, userControllers.read);
|
||||
router.get("/users/:id/field", verifyToken, verifySelf, userControllers.read);
|
||||
@@ -34,14 +31,11 @@ router.post("/users", userControllers.add);
|
||||
router.delete("/users/:id", verifyToken, verifySelf, userControllers.destroy);
|
||||
router.post("/login", userControllers.login);
|
||||
|
||||
// Sync game state — SuperOAuth uniquement
|
||||
router.patch("/users/:id/coins", verifyOAuth, verifySelf, userControllers.updateCoins);
|
||||
// Sync game state — cookie auth (was verifyOAuth, now same as verifyToken)
|
||||
router.patch("/users/:id/coins", verifyToken, verifySelf, userControllers.updateCoins);
|
||||
|
||||
// Game saves — JWT required
|
||||
// Game saves — cookie auth
|
||||
router.get("/save", verifyToken, saveControllers.load);
|
||||
router.post("/save", verifyToken, saveControllers.save);
|
||||
|
||||
|
||||
/* ************************************************************************* */
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user