diff --git a/backend/.env.example b/backend/.env.example index 85ade36..47c457f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,9 +8,9 @@ APP_URL="http://localhost:5000" FRONTEND_URL="http://localhost:5173" MAIL_HOST="smtp.gmail.com" MAIL_PORT="587" -MAIL_USER="your_email@gmail.com" -MAIL_PASS="your_app_password" -MAIL_FROM="Thé Tip Top " +MAIL_USER=abayomiandil62@gmail.com +MAIL_PASS=dumqamoupdrliygf +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. diff --git a/backend/src/config/passport.js b/backend/src/config/passport.js index 91fc6fb..1e9a434 100644 --- a/backend/src/config/passport.js +++ b/backend/src/config/passport.js @@ -1,6 +1,52 @@ const passport = require("passport"); const GoogleStrategy = require("passport-google-oauth20").Strategy; +const crypto = require("crypto"); const prisma = require("./prisma"); +const transporter = require("./mailer"); + +// 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 +// reste inaccessible. +const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h + +async function sendPasswordSetupEmail(user) { + const token = crypto.randomBytes(32).toString("hex"); + + await prisma.user.update({ + where: { id: user.id }, + data: { + resetPasswordToken: token, + resetPasswordExpires: new Date(Date.now() + PASSWORD_SETUP_EXPIRY_MS), + }, + }); + + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + const setupUrl = `${frontendUrl}/definir-mot-de-passe/${token}`; + + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: user.email, + subject: "Définissez votre mot de passe Thé Tip Top", + html: ` +
+

Bienvenue ${user.firstname} !

+

Votre compte a été créé avec Google. 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.

+

+ + Définir mon mot de passe + +

+

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.

+
+ `, + }); +} // Authentification stateless : le callback OAuth émet directement notre JWT, // donc pas de session Express ni de serialize/deserialize à brancher. @@ -70,6 +116,14 @@ if (googleEnabled) { }, }); + // 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); diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 846d945..cef2552 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -252,7 +252,10 @@ const forgotPassword = async (req, res) => { }, }); - const resetUrl = `${process.env.APP_URL}/api/auth/reset-password/${resetPasswordToken}`; + // 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 resetUrl = `${frontendUrl}/definir-mot-de-passe/${resetPasswordToken}`; await transporter.sendMail({ from: process.env.MAIL_FROM, @@ -283,7 +286,7 @@ const forgotPassword = async (req, res) => { const resetPassword = async (req, res) => { try { const { token } = req.params; - const { password } = req.body; + const { password, confirmPassword } = req.body; if (!password || password.length < 8) { return res.status(400).json({ @@ -291,6 +294,14 @@ const resetPassword = async (req, res) => { }); } + // La confirmation est vérifiée côté serveur aussi : le contrôle du + // navigateur ne protège pas un appel direct à l'API. + if (confirmPassword !== undefined && password !== confirmPassword) { + return res.status(400).json({ + message: "Les deux mots de passe ne correspondent pas.", + }); + } + const user = await prisma.user.findFirst({ where: { resetPasswordToken: token, diff --git a/backend/src/middlewares/auth.middleware.js b/backend/src/middlewares/auth.middleware.js index e3d07b4..072501e 100644 --- a/backend/src/middlewares/auth.middleware.js +++ b/backend/src/middlewares/auth.middleware.js @@ -23,6 +23,8 @@ const authMiddleware = async (req, res, next) => { lastname: true, email: true, role: true, + provider: true, + password: true, }, }); @@ -32,7 +34,11 @@ const authMiddleware = async (req, res, next) => { }); } - req.user = user; + // req.user est renvoyé tel quel par /api/auth/me : on remplace le hash par + // un simple booléen, qui suffit au profil pour proposer « définir » ou + // « modifier » son mot de passe. + const { password, ...rest } = user; + req.user = { ...rest, hasPassword: Boolean(password) }; next(); } catch (error) { return res.status(401).json({ diff --git a/backend/tests/googlePassword.test.js b/backend/tests/googlePassword.test.js new file mode 100644 index 0000000..b0ceb71 --- /dev/null +++ b/backend/tests/googlePassword.test.js @@ -0,0 +1,142 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const mailer = require("../src/config/mailer"); +const { passport, googleEnabled } = require("../src/config/passport"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() })); + +const EMAIL = "google-pwd-test@test.local"; +const PROFILE = { + id: "sim-google-id-123456", + displayName: "Chloe Martin", + name: { givenName: "Chloe", familyName: "Martin" }, + emails: [{ value: EMAIL }], + photos: [{ value: "https://example.test/avatar.png" }], +}; + +async function cleanup() { + await prisma.user.deleteMany({ where: { email: EMAIL } }); +} + +// Rejoue le callback de la strategie Google sans sortir sur le reseau. +function verifierProfilGoogle() { + const strategy = passport._strategy("google"); + return new Promise((resolve, reject) => { + strategy._verify("access-token", "refresh-token", PROFILE, (err, user) => { + if (err) reject(err); + else resolve(user); + }); + }); +} + +beforeAll(cleanup); +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +const decrire = googleEnabled ? describe : describe.skip; + +decrire("Inscription Google et definition du mot de passe", () => { + beforeEach(() => jest.clearAllMocks()); + + test("un compte Google est cree sans mot de passe et recoit un lien", async () => { + mailer.sendMail.mockResolvedValue(true); + + const user = await verifierProfilGoogle(); + + expect(user.provider).toBe("GOOGLE"); + expect(user.password).toBeNull(); + expect(user.isEmailVerified).toBe(true); + + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + const envoi = mailer.sendMail.mock.calls[0][0]; + expect(envoi.to).toBe(EMAIL); + expect(envoi.subject).toMatch(/mot de passe/i); + + // Le lien doit viser le formulaire du front, pas la route POST de l API. + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + expect(envoi.html).toContain(`${frontendUrl}/definir-mot-de-passe/`); + + const enBase = await prisma.user.findUnique({ where: { email: EMAIL } }); + expect(enBase.resetPasswordToken).toHaveLength(64); + expect(enBase.resetPasswordExpires.getTime()).toBeGreaterThan(Date.now()); + ctxToken = enBase.resetPasswordToken; + }); + + test("le profil signale que le compte n a pas de mot de passe", async () => { + const jwt = require("jsonwebtoken"); + const u = await prisma.user.findUnique({ where: { email: EMAIL } }); + const token = jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" }); + + const res = await request(app).get("/api/auth/me").set("Authorization", "Bearer " + token).expect(200); + + expect(res.body.user.hasPassword).toBe(false); + expect(res.body.user.provider).toBe("GOOGLE"); + // Le hash ne doit jamais sortir de l API. + expect(res.body.user.password).toBeUndefined(); + }); + + test("la connexion par mot de passe est refusee tant qu il n en existe pas", 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 Google/); + }); + + test("une confirmation qui ne correspond pas est refusee", async () => { + const res = await request(app) + .post("/api/auth/reset-password/" + ctxToken) + .send({ password: "MonMotDePasse1", confirmPassword: "AutreMotDePasse2" }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/ne correspondent pas/); + + const u = await prisma.user.findUnique({ where: { email: EMAIL } }); + expect(u.password).toBeNull(); + }); + + test("un mot de passe trop court est refuse", async () => { + const res = await request(app) + .post("/api/auth/reset-password/" + ctxToken) + .send({ password: "court", confirmPassword: "court" }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/8 caractères/); + }); + + test("le mot de passe defini permet ensuite de se connecter et de le changer", async () => { + const ok = await request(app) + .post("/api/auth/reset-password/" + ctxToken) + .send({ password: "MonMotDePasse1", confirmPassword: "MonMotDePasse1" }) + .expect(200); + expect(ok.body.message).toMatch(/réinitialisé/); + + const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "MonMotDePasse1" }).expect(200); + expect(login.body.token).toBeDefined(); + + // Le jeton a bien ete consomme. + const u = await prisma.user.findUnique({ where: { email: EMAIL } }); + expect(u.resetPasswordToken).toBeNull(); + + const me = await request(app).get("/api/auth/me").set("Authorization", "Bearer " + login.body.token).expect(200); + expect(me.body.user.hasPassword).toBe(true); + + const change = await request(app) + .patch("/api/auth/change-password") + .set("Authorization", "Bearer " + login.body.token) + .send({ currentPassword: "MonMotDePasse1", newPassword: "EncoreUnAutre2" }); + expect(change.status).toBe(200); + }); + + test("une seconde connexion Google ne renvoie pas de nouveau lien", async () => { + mailer.sendMail.mockResolvedValue(true); + + const user = await verifierProfilGoogle(); + + expect(user.email).toBe(EMAIL); + expect(mailer.sendMail).not.toHaveBeenCalled(); + }); +}); + +let ctxToken; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8c1e936..ff45923 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ import CookieBanner from './components/CookieBanner.jsx' import Connexion from './pages/Connexion' import Inscription from './pages/Inscription' import AuthCallback from './pages/AuthCallback' +import DefinirMotDePasse from './pages/DefinirMotDePasse' import Accueil from './pages/Accueil' import ReglesJeu from './pages/ReglesJeu' import MesGains from './pages/MesGains' @@ -31,6 +32,7 @@ export default function App() { } /> } /> } /> + } /> {/* Pages publiques */} } /> diff --git a/frontend/src/pages/DefinirMotDePasse.jsx b/frontend/src/pages/DefinirMotDePasse.jsx new file mode 100644 index 0000000..fb5a4a0 --- /dev/null +++ b/frontend/src/pages/DefinirMotDePasse.jsx @@ -0,0 +1,133 @@ +import { useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { resetPasswordApi } from '../services/auth' + +// Sert les deux parcours qui passent par un jeton envoyé par email : +// la définition initiale du mot de passe après une inscription Google, +// et la réinitialisation classique depuis « mot de passe oublié ». +export default function DefinirMotDePasse() { + const { token } = useParams() + const navigate = useNavigate() + + const [form, setForm] = useState({ password: '', confirmPassword: '' }) + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [loading, setLoading] = useState(false) + + const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value }) + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + + if (form.password.length < 8) { + setError('Le mot de passe doit contenir au moins 8 caractères.') + return + } + if (form.password !== form.confirmPassword) { + setError('Les deux mots de passe ne correspondent pas.') + return + } + + setLoading(true) + try { + await resetPasswordApi({ token, ...form }) + setSuccess('Mot de passe enregistré. Vous pouvez maintenant vous connecter avec votre email.') + setTimeout(() => navigate('/connexion'), 2500) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + if (success) { + return ( +
+
+ {success} +
+ + Aller à la connexion + +
+ ) + } + + return ( +
+
+

Mot de passe

+

+ Choisissez un mot de passe pour accéder à votre compte sans passer par Google. +

+
+ + {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ +
+ + +
+ + + + +
+ +

+ + Retour à la connexion + +

+
+ ) +} diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index 612d848..7d85f1e 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -1,7 +1,7 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import UserNav from '../components/UserNav' -import { changePasswordApi, deleteAccountApi } from '../services/auth' +import { changePasswordApi, deleteAccountApi, meApi, forgotPasswordApi } from '../services/auth' import { useHandleAuthError } from '../hooks/useHandleAuthError' import { useAuth } from '../context/AuthContext' @@ -22,6 +22,35 @@ export default function Profile() { const [pwError, setPwError] = useState('') const [pwSuccess, setPwSuccess] = useState(false) + // Un compte créé via Google peut ne pas avoir de mot de passe : le + // formulaire de modification n'aurait alors aucun sens. L'information vient + // de l'API, car le profil stocké en localStorage ne la contient pas. + const [hasPassword, setHasPassword] = useState(null) + const [linkSending, setLinkSending] = useState(false) + const [linkSent, setLinkSent] = useState(false) + const [linkError, setLinkError] = useState('') + + useEffect(() => { + meApi() + .then((data) => setHasPassword(data.user.hasPassword)) + .catch((e) => { + if (!handleAuthError(e)) setHasPassword(true) + }) + }, []) + + const handleSendSetupLink = async () => { + setLinkError('') + setLinkSending(true) + try { + await forgotPasswordApi(user.email) + setLinkSent(true) + } catch (e) { + if (!handleAuthError(e)) setLinkError(e.message) + } finally { + setLinkSending(false) + } + } + const handlePwChange = (e) => setPwForm({ ...pwForm, [e.target.name]: e.target.value }) const handlePwSubmit = async (e) => { @@ -97,9 +126,37 @@ export default function Profile() { {/* Section changement de mot de passe */}
-

Changer mon mot de passe

+

+ {hasPassword === false ? 'Définir un mot de passe' : 'Changer mon mot de passe'} +

- {pwSuccess && ( + {hasPassword === false && ( +
+

+ Votre compte a été créé avec Google et n'a pas encore de mot de passe. + Définissez-en un pour pouvoir aussi vous connecter avec votre email. +

+ + {linkSent ? ( +

+ Lien envoyé à {user.email}. Consultez votre boîte mail. +

+ ) : ( + + )} + + {linkError &&

{linkError}

} +
+ )} + + {hasPassword !== false && pwSuccess && (
@@ -108,12 +165,13 @@ export default function Profile() {
)} - {pwError && ( + {hasPassword !== false && pwError && (
{pwError}
)} + {hasPassword !== false && (
{[ { name: 'currentPassword', label: 'Mot de passe actuel', key: 'current' }, @@ -153,6 +211,7 @@ export default function Profile() { {pwLoading ? 'Modification…' : 'Modifier le mot de passe'}
+ )}
) diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index c115d90..5103caa 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -57,6 +57,38 @@ export async function verifyCodeApi({ email, code }) { return data } +export async function forgotPasswordApi(email) { + const res = await fetch(`${BASE}/forgot-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status) + return data +} + +export async function resetPasswordApi({ token, password, confirmPassword }) { + const res = await fetch(`${BASE}/reset-password/${token}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password, confirmPassword }), + }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || 'Erreur lors de la définition du mot de passe.', res.status) + return data +} + +export async function meApi() { + const token = localStorage.getItem('token') + const res = await fetch(`${BASE}/me`, { + headers: { Authorization: `Bearer ${token}` }, + }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || 'Erreur lors de la récupération du profil.', res.status) + return data +} + export async function deleteAccountApi() { const token = localStorage.getItem('token') const res = await fetch(`${BASE}/account`, {