ajout de la connection via facebook et du captcha
This commit is contained in:
@@ -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 = "";
|
||||
Reference in New Issue
Block a user