refont de la connexion par google et validation de lenvoie de mail a l'utilisater

This commit is contained in:
Emma781227
2026-08-19 21:56:18 +02:00
parent fa21d9af2a
commit e895325254
9 changed files with 450 additions and 11 deletions

View File

@@ -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 <your_email@gmail.com>"
MAIL_USER=abayomiandil62@gmail.com
MAIL_PASS=dumqamoupdrliygf
MAIL_FROM="Thé Tip Top <abayomiandil62@gmail.com>"
# 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.

View File

@@ -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: `
<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
connecter de cette façon, ou définir un mot de passe pour accéder
également à votre compte sans passer par Google.</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
</a>
</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>
</div>
`,
});
}
// 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);

View File

@@ -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,

View File

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

View File

@@ -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;