From 464d666343355467ae2dacee63f04732e66746b7 Mon Sep 17 00:00:00 2001 From: Emma781227 Date: Mon, 17 Aug 2026 00:23:18 +0200 Subject: [PATCH] Connexion via Google (OAuth 2.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute l'authentification Google en complément de la connexion par mot de passe. Le callback OAuth émet le même JWT que la connexion classique, donc le middleware d'authentification et ProtectedRoute restent inchangés. Backend - stratégie passport-google-oauth20 dans src/config/passport.js - routes GET /api/auth/google et /google/callback, documentées dans Swagger - un compte existant avec le même email est rattaché automatiquement au compte Google, et son email passe vérifié - les routes répondent 503 si les identifiants ne sont pas configurés, plutôt que de faire échouer le démarrage du serveur - login et changePassword ne comparent plus un hash inexistant sur les comptes sans mot de passe Base de données - password devient nullable - colonnes provider, providerId et avatarUrl, index unique sur le couple provider/providerId Frontend - page /auth/callback qui récupère le token et redirige selon le rôle - boutons Google de Connexion et Inscription branchés - boutons Facebook désactivés en attendant les identifiants Meta Co-Authored-By: Claude Opus 5 --- backend/.env.example | 8 ++ backend/package-lock.json | 95 +++++++++++++++++++ backend/package.json | 2 + .../migration.sql | 11 +++ backend/prisma/schema.prisma | 16 +++- backend/src/app.js | 3 + backend/src/config/passport.js | 82 ++++++++++++++++ backend/src/controllers/auth.controller.js | 60 ++++++++++++ backend/src/routes/auth.routes.js | 54 ++++++++++- frontend/src/App.jsx | 2 + frontend/src/pages/AuthCallback.jsx | 68 +++++++++++++ frontend/src/pages/Connexion.jsx | 13 ++- frontend/src/pages/Inscription.jsx | 11 ++- 13 files changed, 418 insertions(+), 7 deletions(-) create mode 100644 backend/prisma/migrations/20260817000000_add_oauth_providers/migration.sql create mode 100644 backend/src/config/passport.js create mode 100644 frontend/src/pages/AuthCallback.jsx diff --git a/backend/.env.example b/backend/.env.example index 3134cbe..85ade36 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,3 +11,11 @@ MAIL_PORT="587" MAIL_USER="your_email@gmail.com" MAIL_PASS="your_app_password" MAIL_FROM="Thé Tip Top " + +# Connexion externe (OAuth 2.0). Laisser vide désactive proprement le bouton +# correspondant : la route répond 503 au lieu de faire planter le serveur. +# URI de redirection à déclarer côté provider : APP_URL + /api/auth/google/callback +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" +FACEBOOK_APP_ID="" +FACEBOOK_APP_SECRET="" diff --git a/backend/package-lock.json b/backend/package-lock.json index d1c0f8d..7e007c1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,6 +17,8 @@ "helmet": "^8.3.0", "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.3", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "prisma": "^6.19.3", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", @@ -2174,6 +2176,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.40", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", @@ -5598,6 +5609,12 @@ "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", "license": "MIT" }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5758,6 +5775,64 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5819,6 +5894,11 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -6765,6 +6845,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -6857,6 +6943,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/backend/package.json b/backend/package.json index 4e7c48b..a1f3b3b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,6 +23,8 @@ "helmet": "^8.3.0", "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.3", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "prisma": "^6.19.3", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", diff --git a/backend/prisma/migrations/20260817000000_add_oauth_providers/migration.sql b/backend/prisma/migrations/20260817000000_add_oauth_providers/migration.sql new file mode 100644 index 0000000..1aa9d93 --- /dev/null +++ b/backend/prisma/migrations/20260817000000_add_oauth_providers/migration.sql @@ -0,0 +1,11 @@ +-- CreateEnum +CREATE TYPE "AuthProvider" AS ENUM ('LOCAL', 'GOOGLE', 'FACEBOOK'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "avatarUrl" TEXT, +ADD COLUMN "provider" "AuthProvider" NOT NULL DEFAULT 'LOCAL', +ADD COLUMN "providerId" TEXT, +ALTER COLUMN "password" DROP NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "User_provider_providerId_key" ON "User"("provider", "providerId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7ab6470..89737cd 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -24,12 +24,20 @@ enum ClaimStatus { CANCELLED } +enum AuthProvider { + LOCAL + GOOGLE + FACEBOOK +} + model User { id Int @id @default(autoincrement()) firstname String lastname String email String @unique - password String + // Nul pour les comptes créés via un provider externe (Google, Facebook) : + // ces utilisateurs n'ont jamais choisi de mot de passe chez nous. + password String? role Role @default(CLIENT) birthdate DateTime? gender String? @@ -43,10 +51,16 @@ model User { resetPasswordToken String? resetPasswordExpires DateTime? + provider AuthProvider @default(LOCAL) + providerId String? + avatarUrl String? + tickets Ticket[] participations Participation[] grandPrizeWinner GrandPrizeWinner? claimsValidated Claim[] @relation("EmployeeClaims") + + @@unique([provider, providerId]) } model Lot { diff --git a/backend/src/app.js b/backend/src/app.js index 856e21e..d0c5d3d 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -11,6 +11,7 @@ const adminRoutes = require("./routes/admin.routes"); const swaggerUi = require("swagger-ui-express"); const swaggerSpec = require("./docs/swagger"); +const { passport } = require("./config/passport"); const app = express(); @@ -25,6 +26,8 @@ app.use((req, res, next) => { }); app.use(cors({ origin: allowedOrigins })); app.use(express.json()); +// OAuth stateless : initialize() suffit, aucune session n'est ouverte. +app.use(passport.initialize()); // Anti-bruteforce sur les routes d'authentification uniquement : 20 // tentatives par IP toutes les 15 minutes, sans limiter le reste de l'API diff --git a/backend/src/config/passport.js b/backend/src/config/passport.js new file mode 100644 index 0000000..91fc6fb --- /dev/null +++ b/backend/src/config/passport.js @@ -0,0 +1,82 @@ +const passport = require("passport"); +const GoogleStrategy = require("passport-google-oauth20").Strategy; +const prisma = require("./prisma"); + +// Authentification stateless : le callback OAuth émet directement notre JWT, +// donc pas de session Express ni de serialize/deserialize à brancher. + +const googleEnabled = + Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET); + +if (googleEnabled) { + passport.use( + new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: `${process.env.APP_URL || "http://localhost:5000"}/api/auth/google/callback`, + scope: ["profile", "email"], + }, + async (accessToken, refreshToken, profile, done) => { + try { + 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, + }, + }); + + return done(null, created); + } catch (error) { + return done(error); + } + } + ) + ); +} + +module.exports = { passport, googleEnabled }; diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 4810fd6..bc78912 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -156,6 +156,14 @@ const login = async (req, res) => { }); } + // Compte créé via un provider externe : aucun mot de passe à comparer. + if (!user.password) { + const providerLabel = user.provider === "FACEBOOK" ? "Facebook" : "Google"; + return res.status(401).json({ + message: `Ce compte utilise la connexion ${providerLabel}. Utilisez le bouton « ${providerLabel} » pour vous connecter.`, + }); + } + const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { @@ -354,6 +362,50 @@ const verifyCode = async (req, res) => { } }; +// Fin du parcours OAuth : passport a posé l'utilisateur sur req.user, on émet +// 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"; + + try { + const user = req.user; + + if (!user) { + return res.redirect( + `${frontendUrl}/auth/callback?error=${encodeURIComponent( + "Connexion externe refusée." + )}` + ); + } + + const token = jwt.sign( + { id: user.id, role: user.role }, + process.env.JWT_SECRET, + { expiresIn: "24h" } + ); + + const payload = Buffer.from( + JSON.stringify({ + id: user.id, + firstname: user.firstname, + lastname: user.lastname, + email: user.email, + role: user.role, + }) + ).toString("base64"); + + return res.redirect(`${frontendUrl}/auth/callback?token=${token}&user=${payload}`); + } catch (error) { + console.error("Erreur oauthCallback:", error); + return res.redirect( + `${frontendUrl}/auth/callback?error=${encodeURIComponent( + "Erreur serveur lors de la connexion externe." + )}` + ); + } +}; + const me = async (req, res) => { return res.json({ user: req.user, @@ -415,6 +467,13 @@ const changePassword = async (req, res) => { const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user.password) { + return res.status(400).json({ + message: + "Ce compte utilise une connexion externe et n'a pas de mot de passe à modifier.", + }); + } + const isValid = await bcrypt.compare(currentPassword, user.password); if (!isValid) { return res.status(401).json({ message: "Mot de passe actuel incorrect." }); @@ -440,4 +499,5 @@ module.exports = { forgotPassword, changePassword, deleteAccount, + oauthCallback, }; \ No newline at end of file diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index 6053cc4..c697f00 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -1,8 +1,9 @@ const express = require("express"); -const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount } = require("../controllers/auth.controller"); +const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const validate = require("../middlewares/validate.middleware"); const { registerSchema, loginSchema } = require("../validations/auth.validation"); +const { passport, googleEnabled } = require("../config/passport"); const router = express.Router(); @@ -161,4 +162,55 @@ router.post("/verify-code", verifyCode); router.patch("/change-password", authMiddleware, changePassword); router.delete("/account", authMiddleware, deleteAccount); +/** + * @swagger + * /api/auth/google: + * get: + * summary: Démarre la connexion via Google (redirection OAuth 2.0) + * tags: + * - Auth + * responses: + * 302: + * description: Redirection vers l'écran de consentement Google. + * 503: + * description: Connexion Google non configurée sur ce serveur. + */ + +/** + * @swagger + * /api/auth/google/callback: + * get: + * summary: Retour de Google, émet le JWT et redirige vers le front + * tags: + * - Auth + * responses: + * 302: + * description: Redirection vers FRONTEND_URL/auth/callback avec le token. + */ +if (googleEnabled) { + router.get( + "/google", + passport.authenticate("google", { scope: ["profile", "email"], session: false }) + ); + + router.get( + "/google/callback", + passport.authenticate("google", { + session: false, + failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:5173"}/auth/callback?error=${encodeURIComponent( + "Connexion Google 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); +} + module.exports = router; \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index afcc658..8c1e936 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,6 +5,7 @@ import ProtectedRoute from './components/ProtectedRoute.jsx' import CookieBanner from './components/CookieBanner.jsx' import Connexion from './pages/Connexion' import Inscription from './pages/Inscription' +import AuthCallback from './pages/AuthCallback' import Accueil from './pages/Accueil' import ReglesJeu from './pages/ReglesJeu' import MesGains from './pages/MesGains' @@ -29,6 +30,7 @@ export default function App() { } /> } /> } /> + } /> {/* Pages publiques */} } /> diff --git a/frontend/src/pages/AuthCallback.jsx b/frontend/src/pages/AuthCallback.jsx new file mode 100644 index 0000000..574b4ae --- /dev/null +++ b/frontend/src/pages/AuthCallback.jsx @@ -0,0 +1,68 @@ +import { useEffect, useRef, useState } from 'react' +import { useNavigate, useSearchParams, Link } from 'react-router-dom' +import { useAuth } from '../context/AuthContext' + +// Point d'arrivée après un aller-retour OAuth : le backend nous renvoie ici +// avec le JWT et le profil encodés dans l'URL. +export default function AuthCallback() { + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const { login } = useAuth() + const [error, setError] = useState('') + // StrictMode monte deux fois en dev : on ne traite le token qu'une seule fois. + const handled = useRef(false) + + useEffect(() => { + if (handled.current) return + handled.current = true + + const errorParam = searchParams.get('error') + if (errorParam) { + setError(errorParam) + return + } + + const token = searchParams.get('token') + const encodedUser = searchParams.get('user') + + if (!token || !encodedUser) { + setError('Réponse de connexion incomplète.') + return + } + + try { + const user = JSON.parse(atob(encodedUser)) + login(user, token) + + if (user.role === 'ADMIN') navigate('/admin', { replace: true }) + else if (user.role === 'EMPLOYEE') navigate('/employe', { replace: true }) + else navigate('/mes-gains', { replace: true }) + } catch { + setError('Réponse de connexion illisible.') + } + }, [searchParams, login, navigate]) + + if (error) { + return ( +
+
+ {error} +
+ + Retour à la connexion + +
+ ) + } + + return ( +
+

+ Connexion en cours… +

+
+ ) +} diff --git a/frontend/src/pages/Connexion.jsx b/frontend/src/pages/Connexion.jsx index 3a9b1d2..e931393 100644 --- a/frontend/src/pages/Connexion.jsx +++ b/frontend/src/pages/Connexion.jsx @@ -15,6 +15,12 @@ export default function Connexion() { 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 = () => { + window.location.href = '/api/auth/google' + } + const handleSubmit = async (e) => { e.preventDefault() setError('') @@ -123,6 +129,7 @@ export default function Connexion() {
-