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