ajout de la connection via facebook et du captcha

This commit is contained in:
Emma781227
2026-09-14 21:08:04 +02:00
parent 28d743deb5
commit 8a6355cde1
29 changed files with 617 additions and 153 deletions

1
Jenkinsfile vendored
View File

@@ -229,6 +229,7 @@ pipeline {
docker build \ docker build \
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \ --build-arg VITE_API_URL="${FRONTEND_API_URL}" \
--build-arg VITE_TURNSTILE_SITE_KEY="${TURNSTILE_SITE_KEY:-}" \
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \ -t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
frontend frontend
''' '''

View File

@@ -19,3 +19,8 @@ GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET="" GOOGLE_CLIENT_SECRET=""
FACEBOOK_APP_ID="" FACEBOOK_APP_ID=""
FACEBOOK_APP_SECRET="" FACEBOOK_APP_SECRET=""
# Cloudflare Turnstile (anti-robot sur inscription et connexion). Laisser vide
# désactive la vérification. La clé publique va dans frontend/.env
# (VITE_TURNSTILE_SITE_KEY).
TURNSTILE_SECRET_KEY=""

View File

@@ -3,6 +3,8 @@ module.exports = {
testMatch: ["**/tests/**/*.test.js"], testMatch: ["**/tests/**/*.test.js"],
setupFiles: ["<rootDir>/tests/setupEnv.js"],
clearMocks: true, clearMocks: true,
testTimeout: 10000, testTimeout: 10000,

View File

@@ -18,6 +18,7 @@
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-facebook": "^3.0.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"prisma": "^6.19.3", "prisma": "^6.19.3",
"swagger-jsdoc": "^6.3.0", "swagger-jsdoc": "^6.3.0",
@@ -5823,6 +5824,18 @@
"url": "https://github.com/sponsors/jaredhanson" "url": "https://github.com/sponsors/jaredhanson"
} }
}, },
"node_modules/passport-facebook": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/passport-facebook/-/passport-facebook-3.0.0.tgz",
"integrity": "sha512-K/qNzuFsFISYAyC1Nma4qgY/12V3RSLFdFVsPKXiKZt434wOvthFW1p7zKa1iQihQMRhaWorVE1o3Vi1o+ZgeQ==",
"license": "MIT",
"dependencies": {
"passport-oauth2": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-google-oauth20": { "node_modules/passport-google-oauth20": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",

View File

@@ -25,6 +25,7 @@
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-facebook": "^3.0.0",
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"prisma": "^6.19.3", "prisma": "^6.19.3",
"swagger-jsdoc": "^6.3.0", "swagger-jsdoc": "^6.3.0",

View File

@@ -1,17 +1,21 @@
const passport = require("passport"); const passport = require("passport");
const GoogleStrategy = require("passport-google-oauth20").Strategy; const GoogleStrategy = require("passport-google-oauth20").Strategy;
const FacebookStrategy = require("passport-facebook").Strategy;
const crypto = require("crypto"); const crypto = require("crypto");
const prisma = require("./prisma"); const prisma = require("./prisma");
const transporter = require("./mailer"); const transporter = require("./mailer");
const getFrontendUrl = require("../utils/frontendUrl");
const PROVIDER_LABELS = { GOOGLE: "Google", FACEBOOK: "Facebook" };
// Un compte créé via un provider externe n'a pas de mot de passe. On lui en // Un compte créé via un provider externe n'a pas de mot de passe. On lui en
// propose un dès l'inscription, via le même mécanisme de jeton que la // propose un dès l'inscription, via le même mécanisme de jeton que la
// réinitialisation : sans mot de passe, l'utilisateur dépend entièrement de // réinitialisation : sans mot de passe, l'utilisateur dépend entièrement du
// Google pour se connecter, et le formulaire de changement de son profil lui // provider pour se connecter, et le formulaire de changement de son profil lui
// reste inaccessible. // reste inaccessible.
const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h
async function sendPasswordSetupEmail(user) { async function sendPasswordSetupEmail(user, providerLabel) {
const token = crypto.randomBytes(32).toString("hex"); const token = crypto.randomBytes(32).toString("hex");
await prisma.user.update({ await prisma.user.update({
@@ -22,8 +26,7 @@ async function sendPasswordSetupEmail(user) {
}, },
}); });
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; const setupUrl = `${getFrontendUrl()}/definir-mot-de-passe/${token}`;
const setupUrl = `${frontendUrl}/definir-mot-de-passe/${token}`;
await transporter.sendMail({ await transporter.sendMail({
from: process.env.MAIL_FROM, from: process.env.MAIL_FROM,
@@ -32,9 +35,9 @@ async function sendPasswordSetupEmail(user) {
html: ` html: `
<div style="font-family:sans-serif;max-width:480px;margin:auto"> <div style="font-family:sans-serif;max-width:480px;margin:auto">
<h2 style="color:#006B6B">Bienvenue ${user.firstname} !</h2> <h2 style="color:#006B6B">Bienvenue ${user.firstname} !</h2>
<p>Votre compte a été créé avec Google. Vous pouvez continuer à vous <p>Votre compte a été créé avec ${providerLabel}. Vous pouvez continuer à vous
connecter de cette façon, ou définir un mot de passe pour accéder connecter de cette façon, ou définir un mot de passe pour accéder
également à votre compte sans passer par Google.</p> également à votre compte sans passer par ${providerLabel}.</p>
<p style="text-align:center;padding:20px 0"> <p style="text-align:center;padding:20px 0">
<a href="${setupUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block"> <a href="${setupUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
Définir mon mot de passe Définir mon mot de passe
@@ -42,48 +45,39 @@ async function sendPasswordSetupEmail(user) {
</p> </p>
<p style="color:#888;font-size:13px">Ce lien expire dans 24 heures. Si vous <p style="color:#888;font-size:13px">Ce lien expire dans 24 heures. Si vous
ne souhaitez pas de mot de passe, ignorez simplement cet email : votre ne souhaitez pas de mot de passe, ignorez simplement cet email : votre
connexion Google continuera de fonctionner.</p> connexion ${providerLabel} continuera de fonctionner.</p>
</div> </div>
`, `,
}); });
} }
// Authentification stateless : le callback OAuth émet directement notre JWT, // Callback de vérification commun à tous les providers : passport normalise
// donc pas de session Express ni de serialize/deserialize à brancher. // le profil (id, name, emails, photos), seule la valeur de `provider` change.
function verifyOAuthProfile(provider) {
const providerLabel = PROVIDER_LABELS[provider];
const googleEnabled = return async (accessToken, refreshToken, profile, done) => {
Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET);
if (googleEnabled) {
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: `${process.env.APP_URL || "http://localhost:5000"}/api/auth/google/callback`,
scope: ["profile", "email"],
},
async (accessToken, refreshToken, profile, done) => {
try { try {
const email = profile.emails?.[0]?.value; const email = profile.emails?.[0]?.value;
// Cas fréquent chez Facebook : compte ouvert avec un numéro de téléphone.
if (!email) { if (!email) {
return done(null, false, { return done(null, false, {
message: "Aucune adresse email n'est associée à ce compte Google.", message: `Aucune adresse email n'est associée à ce compte ${providerLabel}.`,
}); });
} }
// 1. Compte déjà lié à ce compte Google : connexion directe. // 1. Compte déjà lié à ce compte externe : connexion directe.
const byProvider = await prisma.user.findUnique({ const byProvider = await prisma.user.findUnique({
where: { where: {
provider_providerId: { provider: "GOOGLE", providerId: profile.id }, provider_providerId: { provider, providerId: profile.id },
}, },
}); });
if (byProvider) return done(null, byProvider); if (byProvider) return done(null, byProvider);
// 2. Un compte existe avec le même email : on le rattache à Google. // 2. Un compte existe avec le même email : on le rattache au provider.
// L'email étant déjà validé par Google, on en profite pour lever // L'email étant déjà validé par le provider, on en profite pour lever
// la vérification si elle était en attente. // la vérification si elle était en attente.
const byEmail = await prisma.user.findUnique({ where: { email } }); const byEmail = await prisma.user.findUnique({ where: { email } });
@@ -91,7 +85,7 @@ if (googleEnabled) {
const linked = await prisma.user.update({ const linked = await prisma.user.update({
where: { id: byEmail.id }, where: { id: byEmail.id },
data: { data: {
provider: "GOOGLE", provider,
providerId: profile.id, providerId: profile.id,
isEmailVerified: true, isEmailVerified: true,
emailVerifyToken: null, emailVerifyToken: null,
@@ -109,7 +103,7 @@ if (googleEnabled) {
lastname: profile.name?.familyName || "", lastname: profile.name?.familyName || "",
email, email,
password: null, password: null,
provider: "GOOGLE", provider,
providerId: profile.id, providerId: profile.id,
avatarUrl: profile.photos?.[0]?.value || null, avatarUrl: profile.photos?.[0]?.value || null,
isEmailVerified: true, isEmailVerified: true,
@@ -119,7 +113,7 @@ if (googleEnabled) {
// Un envoi qui échoue ne doit pas bloquer la connexion : le compte // Un envoi qui échoue ne doit pas bloquer la connexion : le compte
// est valide, et « mot de passe oublié » permet de rattraper. // est valide, et « mot de passe oublié » permet de rattraper.
try { try {
await sendPasswordSetupEmail(created); await sendPasswordSetupEmail(created, providerLabel);
} catch (mailError) { } catch (mailError) {
console.error("Erreur envoi email de definition du mot de passe:", mailError); console.error("Erreur envoi email de definition du mot de passe:", mailError);
} }
@@ -128,9 +122,49 @@ if (googleEnabled) {
} catch (error) { } catch (error) {
return done(error); return done(error);
} }
} };
}
// Authentification stateless : le callback OAuth émet directement notre JWT,
// donc pas de session Express ni de serialize/deserialize à brancher.
const apiUrl = process.env.APP_URL || "http://localhost:5000";
const googleEnabled =
Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET);
const facebookEnabled =
Boolean(process.env.FACEBOOK_APP_ID) && Boolean(process.env.FACEBOOK_APP_SECRET);
if (googleEnabled) {
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: `${apiUrl}/api/auth/google/callback`,
scope: ["profile", "email"],
},
verifyOAuthProfile("GOOGLE")
) )
); );
} }
module.exports = { passport, googleEnabled }; if (facebookEnabled) {
passport.use(
new FacebookStrategy(
{
clientID: process.env.FACEBOOK_APP_ID,
clientSecret: process.env.FACEBOOK_APP_SECRET,
callbackURL: `${apiUrl}/api/auth/facebook/callback`,
// Sans profileFields, la Graph API ne renvoie que l'id et le nom.
profileFields: ["id", "displayName", "name", "emails", "photos"],
// appsecret_proof : empêche l'usage d'un access token volé ailleurs.
enableProof: true,
},
verifyOAuthProfile("FACEBOOK")
)
);
}
module.exports = { passport, googleEnabled, facebookEnabled, verifyOAuthProfile };

View File

@@ -3,6 +3,7 @@ const jwt = require("jsonwebtoken");
const crypto = require("crypto"); const crypto = require("crypto");
const prisma = require("../config/prisma"); const prisma = require("../config/prisma");
const transporter = require("../config/mailer"); const transporter = require("../config/mailer");
const getFrontendUrl = require("../utils/frontendUrl");
const register = async (req, res) => { const register = async (req, res) => {
try { try {
@@ -254,7 +255,7 @@ const forgotPassword = async (req, res) => {
// Le lien doit ouvrir le formulaire du front, pas la route POST de l'API : // Le lien doit ouvrir le formulaire du front, pas la route POST de l'API :
// un clic depuis un email est une requête GET, qui tombait en 404. // un clic depuis un email est une requête GET, qui tombait en 404.
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; const frontendUrl = getFrontendUrl();
const resetUrl = `${frontendUrl}/definir-mot-de-passe/${resetPasswordToken}`; const resetUrl = `${frontendUrl}/definir-mot-de-passe/${resetPasswordToken}`;
await transporter.sendMail({ await transporter.sendMail({
@@ -397,7 +398,7 @@ const verifyCode = async (req, res) => {
// le même JWT que la connexion classique et on renvoie le navigateur vers le // le même JWT que la connexion classique et on renvoie le navigateur vers le
// front, qui récupère le token dans l'URL. // front, qui récupère le token dans l'URL.
const oauthCallback = (req, res) => { const oauthCallback = (req, res) => {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; const frontendUrl = getFrontendUrl();
try { try {
const user = req.user; const user = req.user;
@@ -426,7 +427,11 @@ const oauthCallback = (req, res) => {
}) })
).toString("base64"); ).toString("base64");
return res.redirect(`${frontendUrl}/auth/callback?token=${token}&user=${payload}`); // encodeURIComponent : le base64 peut contenir des « + » que le front
// relirait comme des espaces dans la query string.
return res.redirect(
`${frontendUrl}/auth/callback?token=${token}&user=${encodeURIComponent(payload)}`
);
} catch (error) { } catch (error) {
console.error("Erreur oauthCallback:", error); console.error("Erreur oauthCallback:", error);
return res.redirect( return res.redirect(

View File

@@ -0,0 +1,34 @@
const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
// Vérifie le jeton Cloudflare Turnstile envoyé par le front dans
// `captchaToken`. Sans TURNSTILE_SECRET_KEY, la vérification est ignorée
// (même logique que googleEnabled dans config/passport.js).
// À placer AVANT validate() : zod retire les clés inconnues de req.body.
const verifyCaptcha = async (req, res, next) => {
const secret = process.env.TURNSTILE_SECRET_KEY;
if (!secret) return next();
const token = req.body?.captchaToken;
if (!token) {
return res.status(400).json({ message: "Veuillez confirmer que vous n'êtes pas un robot." });
}
try {
const response = await fetch(VERIFY_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret, response: token, remoteip: req.ip }),
});
const data = await response.json();
if (!data.success) {
return res.status(400).json({ message: "Vérification anti-robot échouée, veuillez réessayer." });
}
return next();
} catch (error) {
console.error("Erreur vérification Turnstile :", error);
return res.status(503).json({ message: "Service de vérification indisponible, réessayez plus tard." });
}
};
module.exports = verifyCaptcha;

View File

@@ -2,8 +2,10 @@ const express = require("express");
const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller"); const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller");
const authMiddleware = require("../middlewares/auth.middleware"); const authMiddleware = require("../middlewares/auth.middleware");
const validate = require("../middlewares/validate.middleware"); const validate = require("../middlewares/validate.middleware");
const verifyCaptcha = require("../middlewares/captcha.middleware");
const { registerSchema, loginSchema } = require("../validations/auth.validation"); const { registerSchema, loginSchema } = require("../validations/auth.validation");
const { passport, googleEnabled } = require("../config/passport"); const { passport, googleEnabled, facebookEnabled } = require("../config/passport");
const getFrontendUrl = require("../utils/frontendUrl");
const router = express.Router(); const router = express.Router();
@@ -33,7 +35,7 @@ const router = express.Router();
* 201: * 201:
* description: Utilisateur créé * description: Utilisateur créé
*/ */
router.post("/register", validate(registerSchema), register); router.post("/register", verifyCaptcha, validate(registerSchema), register);
/** /**
* @swagger * @swagger
@@ -49,7 +51,7 @@ router.post("/register", validate(registerSchema), register);
* description: Connexion réussie * description: Connexion réussie
*/ */
router.post("/login", validate(loginSchema), login); router.post("/login", verifyCaptcha, validate(loginSchema), login);
/** /**
* @swagger * @swagger
@@ -187,30 +189,73 @@ router.delete("/account", authMiddleware, deleteAccount);
* 302: * 302:
* description: Redirection vers FRONTEND_URL/auth/callback avec le token. * description: Redirection vers FRONTEND_URL/auth/callback avec le token.
*/ */
if (googleEnabled) {
router.get( /**
"/google", * @swagger
passport.authenticate("google", { scope: ["profile", "email"], session: false }) * /api/auth/facebook:
); * get:
* summary: Démarre la connexion via Facebook (redirection OAuth 2.0)
* tags:
* - Auth
* responses:
* 302:
* description: Redirection vers l'écran de consentement Facebook.
* 503:
* description: Connexion Facebook non configurée sur ce serveur.
*/
/**
* @swagger
* /api/auth/facebook/callback:
* get:
* summary: Retour de Facebook, émet le JWT et redirige vers le front
* tags:
* - Auth
* responses:
* 302:
* description: Redirection vers FRONTEND_URL/auth/callback avec le token.
*/
function mountOAuthRoutes({ path, strategy, label, enabled, scope }) {
if (!enabled) {
// Sans identifiants configurés, on répond explicitement plutôt que de
// laisser passport planter au démarrage.
const notConfigured = (req, res) =>
res.status(503).json({ message: `La connexion ${label} n'est pas configurée sur ce serveur.` });
router.get(path, notConfigured);
router.get(`${path}/callback`, notConfigured);
return;
}
router.get(path, passport.authenticate(strategy, { scope, session: false }));
router.get( router.get(
"/google/callback", `${path}/callback`,
passport.authenticate("google", { passport.authenticate(strategy, {
session: false, session: false,
failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:5173"}/auth/callback?error=${encodeURIComponent( failureRedirect: `${getFrontendUrl()}/auth/callback?error=${encodeURIComponent(
"Connexion Google refusée." `Connexion ${label} refusée.`
)}`, )}`,
}), }),
oauthCallback oauthCallback
); );
} else {
// Sans identifiants configurés, on répond explicitement plutôt que de
// laisser passport planter au démarrage.
const notConfigured = (req, res) =>
res.status(503).json({ message: "La connexion Google n'est pas configurée sur ce serveur." });
router.get("/google", notConfigured);
router.get("/google/callback", notConfigured);
} }
mountOAuthRoutes({
path: "/google",
strategy: "google",
label: "Google",
enabled: googleEnabled,
scope: ["profile", "email"],
});
mountOAuthRoutes({
path: "/facebook",
strategy: "facebook",
label: "Facebook",
enabled: facebookEnabled,
scope: ["email"],
});
module.exports = router; module.exports = router;

View File

@@ -0,0 +1,9 @@
// FRONTEND_URL peut lister plusieurs origines séparées par des virgules (prod :
// domaine nu + www), ce qui sert à CORS dans app.js. Pour construire un lien
// (email, redirection OAuth), on ne garde que la première, l'URL canonique.
function getFrontendUrl() {
const first = (process.env.FRONTEND_URL || "").split(",")[0].trim();
return (first || "http://localhost:5173").replace(/\/+$/, "");
}
module.exports = getFrontendUrl;

View File

@@ -0,0 +1,75 @@
const verifyCaptcha = require("../src/middlewares/captcha.middleware");
function mockRes() {
const res = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
return res;
}
describe("verifyCaptcha", () => {
const originalFetch = global.fetch;
beforeEach(() => {
process.env.TURNSTILE_SECRET_KEY = "secret-test";
global.fetch = jest.fn();
});
afterEach(() => {
process.env.TURNSTILE_SECRET_KEY = "";
global.fetch = originalFetch;
});
it("laisse passer sans vérifier quand aucune clé n'est configurée", async () => {
process.env.TURNSTILE_SECRET_KEY = "";
const next = jest.fn();
await verifyCaptcha({ body: {} }, mockRes(), next);
expect(next).toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
});
it("refuse une requête sans jeton", async () => {
const res = mockRes();
const next = jest.fn();
await verifyCaptcha({ body: {} }, res, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(next).not.toHaveBeenCalled();
});
it("appelle next quand Cloudflare valide le jeton", async () => {
global.fetch.mockResolvedValue({ json: () => Promise.resolve({ success: true }) });
const next = jest.fn();
await verifyCaptcha({ body: { captchaToken: "jeton" }, ip: "1.2.3.4" }, mockRes(), next);
expect(next).toHaveBeenCalled();
const [, options] = global.fetch.mock.calls[0];
expect(JSON.parse(options.body)).toEqual({ secret: "secret-test", response: "jeton", remoteip: "1.2.3.4" });
});
it("refuse quand Cloudflare rejette le jeton", async () => {
global.fetch.mockResolvedValue({ json: () => Promise.resolve({ success: false }) });
const res = mockRes();
const next = jest.fn();
await verifyCaptcha({ body: { captchaToken: "faux" } }, res, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(next).not.toHaveBeenCalled();
});
it("répond 503 quand Cloudflare est injoignable", async () => {
global.fetch.mockRejectedValue(new Error("réseau"));
jest.spyOn(console, "error").mockImplementation(() => {});
const res = mockRes();
await verifyCaptcha({ body: { captchaToken: "jeton" } }, res, jest.fn());
expect(res.status).toHaveBeenCalledWith(503);
console.error.mockRestore();
});
});

View File

@@ -0,0 +1,113 @@
// Identifiants factices posés avant le chargement de passport : la stratégie
// Facebook est ainsi enregistrée même sans vraie app Meta configurée.
process.env.FACEBOOK_APP_ID = "test-app-id";
process.env.FACEBOOK_APP_SECRET = "test-app-secret";
const request = require("supertest");
const app = require("../src/app");
const prisma = require("../src/config/prisma");
const mailer = require("../src/config/mailer");
const { passport } = require("../src/config/passport");
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
const EMAIL = "facebook-login-test@test.local";
const EMAIL_EXISTANT = "facebook-link-test@test.local";
function profil(overrides = {}) {
return {
id: "sim-facebook-id-987654",
displayName: "Léa Durand",
name: { givenName: "Léa", familyName: "Durand" },
emails: [{ value: EMAIL }],
photos: [{ value: "https://example.test/fb.png" }],
...overrides,
};
}
// Rejoue le callback de la strategie Facebook sans sortir sur le reseau.
function verifierProfilFacebook(profile) {
const strategy = passport._strategy("facebook");
return new Promise((resolve, reject) => {
strategy._verify("access-token", "refresh-token", profile, (err, user, info) => {
if (err) reject(err);
else resolve({ user, info });
});
});
}
async function cleanup() {
await prisma.user.deleteMany({ where: { email: { in: [EMAIL, EMAIL_EXISTANT] } } });
}
beforeAll(cleanup);
afterAll(async () => {
await cleanup();
await prisma.$disconnect();
});
describe("Connexion Facebook", () => {
beforeEach(() => jest.clearAllMocks());
test("la route de depart redirige vers Facebook", async () => {
const res = await request(app).get("/api/auth/facebook");
expect(res.status).toBe(302);
const url = new URL(res.headers.location);
expect(url.hostname).toBe("www.facebook.com");
expect(url.searchParams.get("client_id")).toBe("test-app-id");
expect(url.searchParams.get("scope")).toBe("email");
expect(url.searchParams.get("redirect_uri")).toMatch(/\/api\/auth\/facebook\/callback$/);
});
test("un premier passage cree un compte FACEBOOK sans mot de passe", async () => {
mailer.sendMail.mockResolvedValue(true);
const { user } = await verifierProfilFacebook(profil());
expect(user.provider).toBe("FACEBOOK");
expect(user.providerId).toBe("sim-facebook-id-987654");
expect(user.password).toBeNull();
expect(user.isEmailVerified).toBe(true);
expect(user.firstname).toBe("Léa");
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
expect(mailer.sendMail.mock.calls[0][0].html).toContain("créé avec Facebook");
});
test("un second passage reconnait le compte sans renvoyer d email", async () => {
const { user } = await verifierProfilFacebook(profil());
expect(user.email).toBe(EMAIL);
expect(mailer.sendMail).not.toHaveBeenCalled();
});
test("un compte existant avec le meme email est rattache a Facebook", async () => {
await prisma.user.create({
data: { firstname: "Paul", lastname: "Martin", email: EMAIL_EXISTANT, password: "hash", isEmailVerified: false },
});
const { user } = await verifierProfilFacebook(
profil({ id: "sim-facebook-id-link", emails: [{ value: EMAIL_EXISTANT }] })
);
expect(user.provider).toBe("FACEBOOK");
expect(user.isEmailVerified).toBe(true);
// Le mot de passe existant est conserve.
expect(user.password).toBe("hash");
});
test("un compte Facebook sans email est refuse", async () => {
const { user, info } = await verifierProfilFacebook(profil({ id: "sim-sans-email", emails: undefined }));
expect(user).toBe(false);
expect(info.message).toMatch(/Facebook/);
});
test("la connexion par mot de passe oriente vers le bouton Facebook", async () => {
const res = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "PeuImporte123" });
expect(res.status).toBe(401);
expect(res.body.message).toMatch(/connexion Facebook/);
});
});

View File

@@ -0,0 +1,64 @@
// Ce fichier ne charge pas app.js, donc pas le .env : le JWT a besoin d'un secret.
process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret";
const getFrontendUrl = require("../src/utils/frontendUrl");
const { oauthCallback } = require("../src/controllers/auth.controller");
describe("getFrontendUrl", () => {
const original = process.env.FRONTEND_URL;
afterEach(() => {
process.env.FRONTEND_URL = original;
});
test("ne garde que la premiere origine d une liste (CORS prod)", () => {
process.env.FRONTEND_URL = "https://site.fr, https://www.site.fr";
expect(getFrontendUrl()).toBe("https://site.fr");
});
test("retire le slash final", () => {
process.env.FRONTEND_URL = "https://site.fr/";
expect(getFrontendUrl()).toBe("https://site.fr");
});
test("retombe sur localhost sans configuration", () => {
delete process.env.FRONTEND_URL;
expect(getFrontendUrl()).toBe("http://localhost:5173");
});
});
describe("oauthCallback", () => {
const original = process.env.FRONTEND_URL;
afterEach(() => {
process.env.FRONTEND_URL = original;
});
test("redirige vers une URL valide avec un profil lisible par le front", () => {
process.env.FRONTEND_URL = "https://site.fr,https://www.site.fr";
const res = { redirect: jest.fn() };
// Accents (UTF-8) et « ~ » : ce profil produit un base64 contenant « + »,
// qui deviendrait un espace sans encodage.
const user = { id: 7, firstname: "Chloé", lastname: "Lefèvre", email: "ab~test@test.local", role: "CLIENT" };
expect(Buffer.from(JSON.stringify(user)).toString("base64")).toContain("+");
oauthCallback({ user }, res);
const url = new URL(res.redirect.mock.calls[0][0]);
expect(`${url.origin}${url.pathname}`).toBe("https://site.fr/auth/callback");
expect(url.searchParams.get("token")).toBeTruthy();
// Même décodage que frontend/src/pages/AuthCallback.jsx.
const bytes = Uint8Array.from(atob(url.searchParams.get("user")), (c) => c.charCodeAt(0));
expect(JSON.parse(new TextDecoder().decode(bytes))).toEqual(user);
});
test("renvoie une erreur au front quand passport n a pas d utilisateur", () => {
process.env.FRONTEND_URL = "https://site.fr,https://www.site.fr";
const res = { redirect: jest.fn() };
oauthCallback({}, res);
const url = new URL(res.redirect.mock.calls[0][0]);
expect(url.origin).toBe("https://site.fr");
expect(url.searchParams.get("error")).toMatch(/refusée/);
});
});

View File

@@ -0,0 +1,4 @@
// Exécuté avant chaque fichier de test, donc avant le dotenv de src/app.js :
// dotenv n'écrase pas une variable déjà définie, le captcha reste désactivé
// pour les tests d'API (captcha.middleware.test.js le teste à part).
process.env.TURNSTILE_SECRET_KEY = "";

View File

@@ -46,6 +46,8 @@ services:
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}" GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}" FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}"
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}" FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}"
# Vide, la vérification anti-robot Turnstile est désactivée.
TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY:-}"
command: > command: >
sh -c " sh -c "

View File

@@ -46,6 +46,8 @@ services:
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}" GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}" FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}"
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}" FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}"
# Vide, la vérification anti-robot Turnstile est désactivée.
TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY:-}"
command: > command: >
sh -c " sh -c "

View File

@@ -46,6 +46,8 @@ services:
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}" GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}" FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}"
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}" FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}"
# Vide, la vérification anti-robot Turnstile est désactivée.
TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY:-}"
command: > command: >
sh -c " sh -c "

1
frontend/.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.env
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

View File

@@ -11,6 +11,9 @@ COPY . .
ARG VITE_API_URL ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL} ENV VITE_API_URL=${VITE_API_URL}
ARG VITE_TURNSTILE_SITE_KEY
ENV VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY}
RUN npm run build RUN npm run build

View File

@@ -8,6 +8,7 @@
"name": "ttt-front", "name": "ttt-front",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@marsidev/react-turnstile": "^1.6.1",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-router-dom": "^7.17.0" "react-router-dom": "^7.17.0"
@@ -121,6 +122,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@@ -441,6 +443,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
}, },
@@ -489,33 +492,11 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20.19.0" "node": ">=20.19.0"
} }
}, },
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
@@ -789,6 +770,16 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@marsidev/react-turnstile": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.6.1.tgz",
"integrity": "sha512-ojI9NLYv821IRKi7KraTkxTto6KC/m58hNNyKxvYxm/cQBu5MI5eMkaiJs2JSQBFiXE3LPQI3seJVjv/2lgALA==",
"license": "MIT",
"peerDependencies": {
"react": "^17.0.2 || ^18.0.0 || ^19.0",
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0"
}
},
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.144.0", "version": "0.144.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
@@ -1463,8 +1454,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@types/chai": { "node_modules/@types/chai": {
"version": "5.2.3", "version": "5.2.3",
@@ -1511,6 +1501,7 @@
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -1521,6 +1512,7 @@
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -1670,6 +1662,7 @@
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1710,7 +1703,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -1721,7 +1713,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -1815,6 +1806,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.11.12", "baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809", "caniuse-lite": "^1.0.30001809",
@@ -1994,8 +1986,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.408", "version": "1.5.408",
@@ -2067,6 +2058,7 @@
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -2943,7 +2935,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -3151,6 +3142,7 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3250,7 +3242,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -3275,6 +3266,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -3284,6 +3276,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -3296,8 +3289,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.18.2", "version": "7.18.2",
@@ -3692,6 +3684,7 @@
"integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.33.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.5", "picomatch": "^4.0.5",
@@ -4249,6 +4242,7 @@
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -13,6 +13,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@marsidev/react-turnstile": "^1.6.1",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-router-dom": "^7.17.0" "react-router-dom": "^7.17.0"

View File

@@ -0,0 +1,19 @@
import { Turnstile } from '@marsidev/react-turnstile'
import { CAPTCHA_ENABLED, TURNSTILE_SITE_KEY } from '../config/captcha'
// `ref` expose reset() : un jeton Turnstile n'est valable qu'une fois, il faut
// en redemander un après chaque envoi refusé.
export default function Captcha({ ref, onToken }) {
if (!CAPTCHA_ENABLED) return null
return (
<Turnstile
ref={ref}
siteKey={TURNSTILE_SITE_KEY}
options={{ language: 'fr', size: 'flexible' }}
onSuccess={onToken}
onExpire={() => onToken('')}
onError={() => onToken('')}
/>
)
}

View File

@@ -0,0 +1,8 @@
export const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY
// Sans clé (tests, dev sans .env), aucun widget n'est affiché et aucun jeton
// n'est exigé côté front ; le backend fait de même sans TURNSTILE_SECRET_KEY.
// navigator.webdriver : le pré-rendu Playwright (scripts/prerender.js) ne doit
// pas figer l'iframe Cloudflare dans le HTML statique.
export const CAPTCHA_ENABLED =
Boolean(TURNSTILE_SITE_KEY) && !(typeof navigator !== 'undefined' && navigator.webdriver)

View File

@@ -26,7 +26,10 @@ export default function AuthCallback() {
error = 'Réponse de connexion incomplète.' error = 'Réponse de connexion incomplète.'
} else { } else {
try { try {
user = JSON.parse(atob(encodedUser)) // atob renvoie des octets : on les relit en UTF-8, sinon « Chloé »
// s'afficherait « Chloé ».
const bytes = Uint8Array.from(atob(encodedUser), (c) => c.charCodeAt(0))
user = JSON.parse(new TextDecoder().decode(bytes))
} catch { } catch {
error = 'Réponse de connexion illisible.' error = 'Réponse de connexion illisible.'
} }

View File

@@ -1,8 +1,10 @@
import { useState } from 'react' import { useState, useRef } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom' import { Link, useNavigate, useLocation } from 'react-router-dom'
import { loginApi } from '../services/auth' import { loginApi } from '../services/auth'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import usePageMeta from '../hooks/usePageMeta' import usePageMeta from '../hooks/usePageMeta'
import Captcha from '../components/Captcha'
import { CAPTCHA_ENABLED } from '../config/captcha'
export default function Connexion() { export default function Connexion() {
usePageMeta({ usePageMeta({
@@ -18,14 +20,16 @@ export default function Connexion() {
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [captchaToken, setCaptchaToken] = useState('')
const captchaRef = useRef()
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value }) const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
// Navigation pleine page (et non fetch) : le flux OAuth passe par des // Navigation pleine page (et non fetch) : le flux OAuth passe par des
// redirections vers Google puis retour sur /auth/callback. // redirections vers Google/Facebook puis retour sur /auth/callback.
const handleGoogleLogin = () => { const handleOAuthLogin = (provider) => {
const apiUrl = import.meta.env.VITE_API_URL || '' const apiUrl = import.meta.env.VITE_API_URL || ''
window.location.href = `${apiUrl}/api/auth/google` window.location.href = `${apiUrl}/api/auth/${provider}`
} }
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
@@ -33,13 +37,16 @@ export default function Connexion() {
setError('') setError('')
setLoading(true) setLoading(true)
try { try {
const { token, user } = await loginApi(form) const { token, user } = await loginApi({ ...form, captchaToken })
login(user, token) login(user, token)
if (user.role === 'ADMIN') navigate('/admin') if (user.role === 'ADMIN') navigate('/admin')
else if (user.role === 'EMPLOYEE') navigate('/employe') else if (user.role === 'EMPLOYEE') navigate('/employe')
else navigate('/mes-gains') else navigate('/mes-gains')
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
// Le jeton a été consommé par le backend : on en redemande un.
captchaRef.current?.reset()
setCaptchaToken('')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -118,9 +125,11 @@ export default function Connexion() {
</div> </div>
</div> </div>
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading || (CAPTCHA_ENABLED && !captchaToken)}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors" className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
> >
{loading ? 'Connexion...' : 'Se connecter'} {loading ? 'Connexion...' : 'Se connecter'}
@@ -136,7 +145,7 @@ export default function Connexion() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
onClick={handleGoogleLogin} onClick={() => handleOAuthLogin('google')}
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true"> <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
@@ -149,9 +158,8 @@ export default function Connexion() {
</button> </button>
<button <button
type="button" type="button"
disabled onClick={() => handleOAuthLogin('facebook')}
title="Connexion Facebook bientôt disponible" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-500 cursor-not-allowed opacity-60"
> >
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"> <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="12" fill="#1877F2" /> <circle cx="12" cy="12" r="12" fill="#1877F2" />

View File

@@ -3,6 +3,8 @@ import { Link, useNavigate } from 'react-router-dom'
import { registerApi, verifyCodeApi } from '../services/auth' import { registerApi, verifyCodeApi } from '../services/auth'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import usePageMeta from '../hooks/usePageMeta' import usePageMeta from '../hooks/usePageMeta'
import Captcha from '../components/Captcha'
import { CAPTCHA_ENABLED } from '../config/captcha'
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur. // Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
const AUJOURD_HUI = new Date().toISOString().split('T')[0] const AUJOURD_HUI = new Date().toISOString().split('T')[0]
@@ -50,6 +52,8 @@ export default function Inscription() {
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [captchaToken, setCaptchaToken] = useState('')
const captchaRef = useRef()
// Code à 4 chiffres // Code à 4 chiffres
const [digits, setDigits] = useState(['', '', '', '']) const [digits, setDigits] = useState(['', '', '', ''])
@@ -59,10 +63,10 @@ export default function Inscription() {
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value }) const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
// Même route que la connexion : Google crée le compte à la première venue. // Même route que la connexion : le provider crée le compte à la première venue.
const handleGoogleSignup = () => { const handleOAuthSignup = (provider) => {
const apiUrl = import.meta.env.VITE_API_URL || '' const apiUrl = import.meta.env.VITE_API_URL || ''
window.location.href = `${apiUrl}/api/auth/google` window.location.href = `${apiUrl}/api/auth/${provider}`
} }
// Étape 1 — Inscription // Étape 1 — Inscription
@@ -85,11 +89,15 @@ export default function Inscription() {
birthdate: form.birthdate, birthdate: form.birthdate,
gender: form.gender, gender: form.gender,
marketingOptIn, marketingOptIn,
captchaToken,
}) })
setEmail(form.email) setEmail(form.email)
setStep('verify') setStep('verify')
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
// Le jeton a été consommé par le backend : on en redemande un.
captchaRef.current?.reset()
setCaptchaToken('')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -183,7 +191,7 @@ export default function Inscription() {
Le code expire dans 15 minutes.{' '} Le code expire dans 15 minutes.{' '}
<button <button
type="button" type="button"
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError('') }} onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError(''); setCaptchaToken('') }}
className="text-[#006B6B] hover:underline" className="text-[#006B6B] hover:underline"
> >
Recommencer Recommencer
@@ -281,7 +289,9 @@ export default function Inscription() {
</span> </span>
</label> </label>
<button type="submit" disabled={loading || !acceptedTerms} <Captcha ref={captchaRef} onToken={setCaptchaToken} />
<button type="submit" disabled={loading || !acceptedTerms || (CAPTCHA_ENABLED && !captchaToken)}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"> className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
{loading ? 'Inscription…' : "S'inscrire"} {loading ? 'Inscription…' : "S'inscrire"}
</button> </button>
@@ -294,7 +304,7 @@ export default function Inscription() {
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button type="button" onClick={handleGoogleSignup} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"> <button type="button" onClick={() => handleOAuthSignup('google')} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true"> <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path> <path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path> <path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
@@ -303,7 +313,7 @@ export default function Inscription() {
</svg> </svg>
Google Google
</button> </button>
<button type="button" disabled title="Inscription Facebook bientôt disponible" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-500 cursor-not-allowed opacity-60"> <button type="button" onClick={() => handleOAuthSignup('facebook')} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"> <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="12" fill="#1877F2" /> <circle cx="12" cy="12" r="12" fill="#1877F2" />
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" /> <path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />

View File

@@ -7,11 +7,11 @@ function apiError(message, status) {
return err return err
} }
export async function loginApi({ email, password }) { export async function loginApi({ email, password, captchaToken }) {
const res = await fetch(`${BASE}/login`, { const res = await fetch(`${BASE}/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password, captchaToken: captchaToken || undefined }),
}) })
const data = await res.json() const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur de connexion.', res.status) if (!res.ok) throw apiError(data.message || 'Erreur de connexion.', res.status)
@@ -26,6 +26,7 @@ export async function registerApi({
birthdate, birthdate,
gender, gender,
marketingOptIn, marketingOptIn,
captchaToken,
}) { }) {
const res = await fetch(`${BASE}/register`, { const res = await fetch(`${BASE}/register`, {
method: 'POST', method: 'POST',
@@ -40,6 +41,7 @@ export async function registerApi({
birthdate: birthdate || undefined, birthdate: birthdate || undefined,
gender: gender || undefined, gender: gender || undefined,
marketingOptIn: marketingOptIn ?? undefined, marketingOptIn: marketingOptIn ?? undefined,
captchaToken: captchaToken || undefined,
}), }),
}) })
const data = await res.json() const data = await res.json()

View File

@@ -42,7 +42,8 @@ describe('Connexion', () => {
await userEvent.click(screen.getByRole('button', { name: /se connecter/i })) await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
expect(await screen.findByText('Email ou mot de passe incorrect.')).toBeInTheDocument() expect(await screen.findByText('Email ou mot de passe incorrect.')).toBeInTheDocument()
expect(loginApi).toHaveBeenCalledWith({ email: 'test@example.com', password: 'mauvais-mdp' }) // captchaToken vide : aucun widget Turnstile en test (voir vite.config.js).
expect(loginApi).toHaveBeenCalledWith({ email: 'test@example.com', password: 'mauvais-mdp', captchaToken: '' })
}) })
it('connecte un client et redirige vers /mes-gains', async () => { it('connecte un client et redirige vers /mes-gains', async () => {

View File

@@ -13,5 +13,8 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,
setupFiles: './src/setupTests.js', setupFiles: './src/setupTests.js',
// Le .env local est aussi chargé en test : sans cette surcharge, le widget
// Turnstile bloquerait la soumission des formulaires dans jsdom.
env: { VITE_TURNSTILE_SITE_KEY: '' },
}, },
}) })