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() {
-