ajout de la connection via facebook et du captcha
This commit is contained in:
1
Jenkinsfile
vendored
1
Jenkinsfile
vendored
@@ -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
|
||||
'''
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -3,6 +3,8 @@ module.exports = {
|
||||
|
||||
testMatch: ["**/tests/**/*.test.js"],
|
||||
|
||||
setupFiles: ["<rootDir>/tests/setupEnv.js"],
|
||||
|
||||
clearMocks: true,
|
||||
|
||||
testTimeout: 10000,
|
||||
|
||||
13
backend/package-lock.json
generated
13
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: `
|
||||
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||
<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
|
||||
également à votre compte sans passer par Google.</p>
|
||||
également à votre compte sans passer par ${providerLabel}.</p>
|
||||
<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">
|
||||
Définir mon mot de passe
|
||||
@@ -42,95 +45,126 @@ async function sendPasswordSetupEmail(user) {
|
||||
</p>
|
||||
<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
|
||||
connexion Google continuera de fonctionner.</p>
|
||||
connexion ${providerLabel} continuera de fonctionner.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
// 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 };
|
||||
|
||||
@@ -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(
|
||||
|
||||
34
backend/src/middlewares/captcha.middleware.js
Normal file
34
backend/src/middlewares/captcha.middleware.js
Normal 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;
|
||||
@@ -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;
|
||||
9
backend/src/utils/frontendUrl.js
Normal file
9
backend/src/utils/frontendUrl.js
Normal 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;
|
||||
75
backend/tests/captcha.middleware.test.js
Normal file
75
backend/tests/captcha.middleware.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
113
backend/tests/facebookLogin.test.js
Normal file
113
backend/tests/facebookLogin.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
64
backend/tests/oauthCallback.test.js
Normal file
64
backend/tests/oauthCallback.test.js
Normal 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/);
|
||||
});
|
||||
});
|
||||
4
backend/tests/setupEnv.js
Normal file
4
backend/tests/setupEnv.js
Normal 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 = "";
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 "
|
||||
|
||||
1
frontend/.gitignore
vendored
1
frontend/.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
60
frontend/package-lock.json
generated
60
frontend/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
19
frontend/src/components/Captcha.jsx
Normal file
19
frontend/src/components/Captcha.jsx
Normal 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('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
8
frontend/src/config/captcha.js
Normal file
8
frontend/src/config/captcha.js
Normal 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)
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{loading ? 'Connexion...' : 'Se connecter'}
|
||||
@@ -136,7 +145,7 @@ export default function Connexion() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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"
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
disabled
|
||||
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-500 cursor-not-allowed opacity-60"
|
||||
onClick={() => handleOAuthLogin('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">
|
||||
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { registerApi, verifyCodeApi } from '../services/auth'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
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.
|
||||
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
|
||||
@@ -50,6 +52,8 @@ export default function Inscription() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [captchaToken, setCaptchaToken] = useState('')
|
||||
const captchaRef = useRef()
|
||||
|
||||
// Code à 4 chiffres
|
||||
const [digits, setDigits] = useState(['', '', '', ''])
|
||||
@@ -59,10 +63,10 @@ export default function Inscription() {
|
||||
|
||||
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.
|
||||
const handleGoogleSignup = () => {
|
||||
// Même route que la connexion : le provider crée le compte à la première venue.
|
||||
const handleOAuthSignup = (provider) => {
|
||||
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
|
||||
@@ -85,11 +89,15 @@ export default function Inscription() {
|
||||
birthdate: form.birthdate,
|
||||
gender: form.gender,
|
||||
marketingOptIn,
|
||||
captchaToken,
|
||||
})
|
||||
setEmail(form.email)
|
||||
setStep('verify')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
// Le jeton a été consommé par le backend : on en redemande un.
|
||||
captchaRef.current?.reset()
|
||||
setCaptchaToken('')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -183,7 +191,7 @@ export default function Inscription() {
|
||||
Le code expire dans 15 minutes.{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError('') }}
|
||||
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError(''); setCaptchaToken('') }}
|
||||
className="text-[#006B6B] hover:underline"
|
||||
>
|
||||
Recommencer
|
||||
@@ -281,7 +289,9 @@ export default function Inscription() {
|
||||
</span>
|
||||
</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">
|
||||
{loading ? 'Inscription…' : "S'inscrire"}
|
||||
</button>
|
||||
@@ -294,7 +304,7 @@ export default function Inscription() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
@@ -303,7 +313,7 @@ export default function Inscription() {
|
||||
</svg>
|
||||
Google
|
||||
</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">
|
||||
<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" />
|
||||
|
||||
@@ -7,11 +7,11 @@ function apiError(message, status) {
|
||||
return err
|
||||
}
|
||||
|
||||
export async function loginApi({ email, password }) {
|
||||
export async function loginApi({ email, password, captchaToken }) {
|
||||
const res = await fetch(`${BASE}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ email, password, captchaToken: captchaToken || undefined }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw apiError(data.message || 'Erreur de connexion.', res.status)
|
||||
@@ -26,6 +26,7 @@ export async function registerApi({
|
||||
birthdate,
|
||||
gender,
|
||||
marketingOptIn,
|
||||
captchaToken,
|
||||
}) {
|
||||
const res = await fetch(`${BASE}/register`, {
|
||||
method: 'POST',
|
||||
@@ -40,6 +41,7 @@ export async function registerApi({
|
||||
birthdate: birthdate || undefined,
|
||||
gender: gender || undefined,
|
||||
marketingOptIn: marketingOptIn ?? undefined,
|
||||
captchaToken: captchaToken || undefined,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
@@ -42,7 +42,8 @@ describe('Connexion', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -13,5 +13,8 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
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: '' },
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user