diff --git a/Jenkinsfile b/Jenkinsfile index b7b2a71..ec4d929 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -229,6 +229,7 @@ pipeline { docker build \ --build-arg VITE_API_URL="${FRONTEND_API_URL}" \ + --build-arg VITE_TURNSTILE_SITE_KEY="${TURNSTILE_SITE_KEY:-}" \ -t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \ frontend ''' diff --git a/backend/.env.example b/backend/.env.example index 47c457f..e434a0f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,3 +19,8 @@ GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" FACEBOOK_APP_ID="" 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="" diff --git a/backend/jest.config.js b/backend/jest.config.js index b803d50..bbaefc1 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -3,6 +3,8 @@ module.exports = { testMatch: ["**/tests/**/*.test.js"], + setupFiles: ["/tests/setupEnv.js"], + clearMocks: true, testTimeout: 10000, diff --git a/backend/package-lock.json b/backend/package-lock.json index 45cd3e0..515ad9d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,6 +18,7 @@ "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.3", "passport": "^0.7.0", + "passport-facebook": "^3.0.0", "passport-google-oauth20": "^2.0.0", "prisma": "^6.19.3", "swagger-jsdoc": "^6.3.0", @@ -5823,6 +5824,18 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index a637c62..b9569c0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.3", "passport": "^0.7.0", + "passport-facebook": "^3.0.0", "passport-google-oauth20": "^2.0.0", "prisma": "^6.19.3", "swagger-jsdoc": "^6.3.0", diff --git a/backend/src/config/passport.js b/backend/src/config/passport.js index 1e9a434..b9e2903 100644 --- a/backend/src/config/passport.js +++ b/backend/src/config/passport.js @@ -1,17 +1,21 @@ const passport = require("passport"); const GoogleStrategy = require("passport-google-oauth20").Strategy; +const FacebookStrategy = require("passport-facebook").Strategy; const crypto = require("crypto"); const prisma = require("./prisma"); 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 // 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 -// Google pour se connecter, et le formulaire de changement de son profil lui +// réinitialisation : sans mot de passe, l'utilisateur dépend entièrement du +// provider pour se connecter, et le formulaire de changement de son profil lui // reste inaccessible. 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"); await prisma.user.update({ @@ -22,8 +26,7 @@ async function sendPasswordSetupEmail(user) { }, }); - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; - const setupUrl = `${frontendUrl}/definir-mot-de-passe/${token}`; + const setupUrl = `${getFrontendUrl()}/definir-mot-de-passe/${token}`; await transporter.sendMail({ from: process.env.MAIL_FROM, @@ -32,9 +35,9 @@ async function sendPasswordSetupEmail(user) { html: `

Bienvenue ${user.firstname} !

-

Votre compte a été créé avec Google. Vous pouvez continuer à vous +

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 - également à votre compte sans passer par Google.

+ également à votre compte sans passer par ${providerLabel}.

Définir mon mot de passe @@ -42,95 +45,126 @@ async function sendPasswordSetupEmail(user) {

Ce lien expire dans 24 heures. Si vous ne souhaitez pas de mot de passe, ignorez simplement cet email : votre - connexion Google continuera de fonctionner.

+ connexion ${providerLabel} continuera de fonctionner.

`, }); } +// Callback de vérification commun à tous les providers : passport normalise +// le profil (id, name, emails, photos), seule la valeur de `provider` change. +function verifyOAuthProfile(provider) { + const providerLabel = PROVIDER_LABELS[provider]; + + return async (accessToken, refreshToken, profile, done) => { + try { + const email = profile.emails?.[0]?.value; + + // Cas fréquent chez Facebook : compte ouvert avec un numéro de téléphone. + if (!email) { + return done(null, false, { + message: `Aucune adresse email n'est associée à ce compte ${providerLabel}.`, + }); + } + + // 1. Compte déjà lié à ce compte externe : connexion directe. + const byProvider = await prisma.user.findUnique({ + where: { + provider_providerId: { provider, providerId: profile.id }, + }, + }); + + if (byProvider) return done(null, byProvider); + + // 2. Un compte existe avec le même email : on le rattache au provider. + // L'email étant déjà validé par le provider, on en profite pour lever + // la vérification si elle était en attente. + const byEmail = await prisma.user.findUnique({ where: { email } }); + + if (byEmail) { + const linked = await prisma.user.update({ + where: { id: byEmail.id }, + data: { + provider, + providerId: profile.id, + isEmailVerified: true, + emailVerifyToken: null, + emailVerifyExpires: null, + avatarUrl: byEmail.avatarUrl || profile.photos?.[0]?.value || null, + }, + }); + return done(null, linked); + } + + // 3. Première connexion : création du compte. + const created = await prisma.user.create({ + data: { + firstname: profile.name?.givenName || profile.displayName || "Utilisateur", + lastname: profile.name?.familyName || "", + email, + password: null, + provider, + providerId: profile.id, + avatarUrl: profile.photos?.[0]?.value || null, + isEmailVerified: true, + }, + }); + + // Un envoi qui échoue ne doit pas bloquer la connexion : le compte + // est valide, et « mot de passe oublié » permet de rattraper. + try { + await sendPasswordSetupEmail(created, providerLabel); + } catch (mailError) { + console.error("Erreur envoi email de definition du mot de passe:", mailError); + } + + return done(null, created); + } catch (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: `${process.env.APP_URL || "http://localhost:5000"}/api/auth/google/callback`, + callbackURL: `${apiUrl}/api/auth/google/callback`, scope: ["profile", "email"], }, - async (accessToken, refreshToken, profile, done) => { - try { - const email = profile.emails?.[0]?.value; - - if (!email) { - return done(null, false, { - message: "Aucune adresse email n'est associée à ce compte Google.", - }); - } - - // 1. Compte déjà lié à ce compte Google : connexion directe. - const byProvider = await prisma.user.findUnique({ - where: { - provider_providerId: { provider: "GOOGLE", providerId: profile.id }, - }, - }); - - if (byProvider) return done(null, byProvider); - - // 2. Un compte existe avec le même email : on le rattache à Google. - // L'email étant déjà validé par Google, on en profite pour lever - // la vérification si elle était en attente. - const byEmail = await prisma.user.findUnique({ where: { email } }); - - if (byEmail) { - const linked = await prisma.user.update({ - where: { id: byEmail.id }, - data: { - provider: "GOOGLE", - providerId: profile.id, - isEmailVerified: true, - emailVerifyToken: null, - emailVerifyExpires: null, - avatarUrl: byEmail.avatarUrl || profile.photos?.[0]?.value || null, - }, - }); - return done(null, linked); - } - - // 3. Première connexion : création du compte. - const created = await prisma.user.create({ - data: { - firstname: profile.name?.givenName || profile.displayName || "Utilisateur", - lastname: profile.name?.familyName || "", - email, - password: null, - provider: "GOOGLE", - providerId: profile.id, - avatarUrl: profile.photos?.[0]?.value || null, - isEmailVerified: true, - }, - }); - - // Un envoi qui échoue ne doit pas bloquer la connexion : le compte - // est valide, et « mot de passe oublié » permet de rattraper. - try { - await sendPasswordSetupEmail(created); - } catch (mailError) { - console.error("Erreur envoi email de definition du mot de passe:", mailError); - } - - return done(null, created); - } catch (error) { - return done(error); - } - } + 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 }; diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index cef2552..cfc12bb 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -3,6 +3,7 @@ const jwt = require("jsonwebtoken"); const crypto = require("crypto"); const prisma = require("../config/prisma"); const transporter = require("../config/mailer"); +const getFrontendUrl = require("../utils/frontendUrl"); const register = async (req, res) => { 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 : // 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}`; 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 // front, qui récupère le token dans l'URL. const oauthCallback = (req, res) => { - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + const frontendUrl = getFrontendUrl(); try { const user = req.user; @@ -426,7 +427,11 @@ const oauthCallback = (req, res) => { }) ).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) { console.error("Erreur oauthCallback:", error); return res.redirect( diff --git a/backend/src/middlewares/captcha.middleware.js b/backend/src/middlewares/captcha.middleware.js new file mode 100644 index 0000000..8fe7eb3 --- /dev/null +++ b/backend/src/middlewares/captcha.middleware.js @@ -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; diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index c697f00..33cf984 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -2,8 +2,10 @@ const express = require("express"); const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const validate = require("../middlewares/validate.middleware"); +const verifyCaptcha = require("../middlewares/captcha.middleware"); 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(); @@ -33,7 +35,7 @@ const router = express.Router(); * 201: * description: Utilisateur créé */ -router.post("/register", validate(registerSchema), register); +router.post("/register", verifyCaptcha, validate(registerSchema), register); /** * @swagger @@ -49,7 +51,7 @@ router.post("/register", validate(registerSchema), register); * description: Connexion réussie */ -router.post("/login", validate(loginSchema), login); +router.post("/login", verifyCaptcha, validate(loginSchema), login); /** * @swagger @@ -187,30 +189,73 @@ router.delete("/account", authMiddleware, deleteAccount); * 302: * description: Redirection vers FRONTEND_URL/auth/callback avec le token. */ -if (googleEnabled) { - router.get( - "/google", - passport.authenticate("google", { scope: ["profile", "email"], session: false }) - ); + +/** + * @swagger + * /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( - "/google/callback", - passport.authenticate("google", { + `${path}/callback`, + passport.authenticate(strategy, { session: false, - failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:5173"}/auth/callback?error=${encodeURIComponent( - "Connexion Google refusée." + failureRedirect: `${getFrontendUrl()}/auth/callback?error=${encodeURIComponent( + `Connexion ${label} refusée.` )}`, }), 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; \ No newline at end of file diff --git a/backend/src/utils/frontendUrl.js b/backend/src/utils/frontendUrl.js new file mode 100644 index 0000000..3d0c3da --- /dev/null +++ b/backend/src/utils/frontendUrl.js @@ -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; diff --git a/backend/tests/captcha.middleware.test.js b/backend/tests/captcha.middleware.test.js new file mode 100644 index 0000000..e9539d9 --- /dev/null +++ b/backend/tests/captcha.middleware.test.js @@ -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(); + }); +}); diff --git a/backend/tests/facebookLogin.test.js b/backend/tests/facebookLogin.test.js new file mode 100644 index 0000000..ebc094f --- /dev/null +++ b/backend/tests/facebookLogin.test.js @@ -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/); + }); +}); diff --git a/backend/tests/oauthCallback.test.js b/backend/tests/oauthCallback.test.js new file mode 100644 index 0000000..334a63b --- /dev/null +++ b/backend/tests/oauthCallback.test.js @@ -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/); + }); +}); diff --git a/backend/tests/setupEnv.js b/backend/tests/setupEnv.js new file mode 100644 index 0000000..b55f9ca --- /dev/null +++ b/backend/tests/setupEnv.js @@ -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 = ""; diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml index b1aaef0..f9ec95f 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -46,6 +46,8 @@ services: GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}" FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}" FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}" + # Vide, la vérification anti-robot Turnstile est désactivée. + TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY:-}" command: > sh -c " diff --git a/deploy/preprod/docker-compose.yml b/deploy/preprod/docker-compose.yml index 2729f5e..04ff8b8 100644 --- a/deploy/preprod/docker-compose.yml +++ b/deploy/preprod/docker-compose.yml @@ -46,6 +46,8 @@ services: GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}" FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}" FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}" + # Vide, la vérification anti-robot Turnstile est désactivée. + TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY:-}" command: > sh -c " diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index 8cfb3a7..32a1a90 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -46,6 +46,8 @@ services: GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}" FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}" FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}" + # Vide, la vérification anti-robot Turnstile est désactivée. + TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY:-}" command: > sh -c " diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..438657a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/frontend/Dockerfile b/frontend/Dockerfile index f5f20fa..9aa2373 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -11,6 +11,9 @@ COPY . . ARG 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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3da5c2b..ffd9a20 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "ttt-front", "version": "0.0.0", "dependencies": { + "@marsidev/react-turnstile": "^1.6.1", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.17.0" @@ -121,6 +122,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -441,6 +443,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -489,33 +492,11 @@ } ], "license": "MIT", + "peer": true, "engines": { "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": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", @@ -789,6 +770,16 @@ "@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": { "version": "0.144.0", "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", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", @@ -1511,6 +1501,7 @@ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1521,6 +1512,7 @@ "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1670,6 +1662,7 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1710,7 +1703,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -1721,7 +1713,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -1815,6 +1806,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -1994,8 +1986,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.5.408", @@ -2067,6 +2058,7 @@ "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -2943,7 +2935,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -3151,6 +3142,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3250,7 +3242,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -3275,6 +3266,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3284,6 +3276,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3296,8 +3289,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-router": { "version": "7.18.2", @@ -3692,6 +3684,7 @@ "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -4249,6 +4242,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index be58ffb..9bfff8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "test:watch": "vitest" }, "dependencies": { + "@marsidev/react-turnstile": "^1.6.1", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.17.0" diff --git a/frontend/src/components/Captcha.jsx b/frontend/src/components/Captcha.jsx new file mode 100644 index 0000000..c56e189 --- /dev/null +++ b/frontend/src/components/Captcha.jsx @@ -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 ( + onToken('')} + onError={() => onToken('')} + /> + ) +} diff --git a/frontend/src/config/captcha.js b/frontend/src/config/captcha.js new file mode 100644 index 0000000..844ce45 --- /dev/null +++ b/frontend/src/config/captcha.js @@ -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) diff --git a/frontend/src/pages/AuthCallback.jsx b/frontend/src/pages/AuthCallback.jsx index 3cb997f..16f6e7f 100644 --- a/frontend/src/pages/AuthCallback.jsx +++ b/frontend/src/pages/AuthCallback.jsx @@ -26,7 +26,10 @@ export default function AuthCallback() { error = 'Réponse de connexion incomplète.' } else { 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 { error = 'Réponse de connexion illisible.' } diff --git a/frontend/src/pages/Connexion.jsx b/frontend/src/pages/Connexion.jsx index fc7adbd..9d3613e 100644 --- a/frontend/src/pages/Connexion.jsx +++ b/frontend/src/pages/Connexion.jsx @@ -1,8 +1,10 @@ -import { useState } from 'react' +import { useState, useRef } from 'react' import { Link, useNavigate, useLocation } from 'react-router-dom' import { loginApi } from '../services/auth' import { useAuth } from '../context/AuthContext' import usePageMeta from '../hooks/usePageMeta' +import Captcha from '../components/Captcha' +import { CAPTCHA_ENABLED } from '../config/captcha' export default function Connexion() { usePageMeta({ @@ -18,14 +20,16 @@ export default function Connexion() { const [showPassword, setShowPassword] = useState(false) const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const [captchaToken, setCaptchaToken] = useState('') + const captchaRef = useRef() const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value }) // Navigation pleine page (et non fetch) : le flux OAuth passe par des - // redirections vers Google puis retour sur /auth/callback. - const handleGoogleLogin = () => { + // redirections vers Google/Facebook puis retour sur /auth/callback. + const handleOAuthLogin = (provider) => { 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) => { @@ -33,13 +37,16 @@ export default function Connexion() { setError('') setLoading(true) try { - const { token, user } = await loginApi(form) + const { token, user } = await loginApi({ ...form, captchaToken }) login(user, token) if (user.role === 'ADMIN') navigate('/admin') else if (user.role === 'EMPLOYEE') navigate('/employe') else navigate('/mes-gains') } catch (err) { setError(err.message) + // Le jeton a été consommé par le backend : on en redemande un. + captchaRef.current?.reset() + setCaptchaToken('') } finally { setLoading(false) } @@ -118,9 +125,11 @@ export default function Connexion() { + + -