From 25b33ca0560a590481bf0b36a4ca08d83012e36b Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Wed, 21 Oct 2026 15:51:47 +0200 Subject: [PATCH] =?UTF-8?q?fix(comptes)=20:=20suppression=20sans=20mot=20d?= =?UTF-8?q?e=20passe=20si=20PASSWORD=5FLOGIN=3Dfalse,=20identit=C3=A9=20OI?= =?UTF-8?q?DC=20li=C3=A9e=20par=20sub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suppression de compte : un compte qui avait un mot de passe mais qui est relié à OIDC, sur une instance avec PASSWORD_LOGIN=false, ne pouvait plus se supprimer (le mot de passe ne sert plus). Il se confirme désormais par son email après une connexion récente, comme un compte né du fournisseur. /me et l'échange OIDC renvoient hasPassword=false dans ce cas, pour que l'écran demande l'email : aucun changement d'interface. Identité OIDC : un email vérifié déjà relié à une autre identité (un autre sujet) n'est plus repris par un nouveau sujet (oidc_email_taken). La connexion d'une identité déjà reliée reste faite par émetteur et sujet, jamais par email. Co-Authored-By: Claude Haiku 5.5 --- backend/src/lib/auth.ts | 11 ++++- backend/src/routes/auth.ts | 16 ++++--- backend/src/routes/oidc.ts | 11 ++++- backend/test/data-deletion.test.ts | 71 +++++++++++++++++++++++++++++- backend/test/oidc.test.ts | 23 ++++++++++ 5 files changed, 121 insertions(+), 11 deletions(-) diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index edc534b..60b5737 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -1,5 +1,7 @@ import bcrypt from 'bcryptjs' +import { env } from '../config/env.js' + const HASH_ROUNDS = 12 export async function hashPassword(value: string): Promise { @@ -8,4 +10,11 @@ export async function hashPassword(value: string): Promise { export async function verifyPassword(value: string, hash: string): Promise { return bcrypt.compare(value, hash) -} \ No newline at end of file +} + +// Un compte à mot de passe se confirme avec son mot de passe seulement si la connexion par mot de passe est ouverte. +// Avec PASSWORD_LOGIN=false, un compte relié à un fournisseur OIDC (et qui avait un mot de passe) ne peut plus le +// redonner : il se confirme par son email, comme un compte né du fournisseur, après une connexion récente. +export function confirmsWithPassword(passwordHash: string | null | undefined): boolean { + return Boolean(passwordHash) && env.PASSWORD_LOGIN +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index e47179d..1499d9c 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto' import { z } from 'zod' import { env } from '../config/env.js' -import { hashPassword, verifyPassword } from '../lib/auth.js' +import { confirmsWithPassword, hashPassword, verifyPassword } from '../lib/auth.js' import { isOidcEnabled } from '../lib/oidc.js' import { createAccountIfAllowed, isRegistrationOpen } from '../lib/registration.js' import { signUserSession } from '../lib/user-session.js' @@ -237,7 +237,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => { } // hasPassword : l'écran de suppression demande le mot de passe, ou l'email pour un compte sans mot de passe - return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: Boolean(user.passwordHash) } }) + return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash) } }) }) app.get('/me/last-tree', async (request, reply) => { @@ -379,17 +379,19 @@ export const authRoutes: FastifyPluginAsync = async (app) => { const payload = await request.jwtVerify() - if (!user.passwordHash) { - // Compte né d'une connexion OIDC : pas de mot de passe à redonner. Il retape son email, et la session doit - // venir d'une connexion récente chez le fournisseur (une session volée ou oubliée ouverte ne suffit pas). + const withPassword = confirmsWithPassword(user.passwordHash) + if (!withPassword) { + // Compte né d'une connexion OIDC, ou dont la connexion par mot de passe est fermée : pas de mot de passe à + // redonner. Il retape son email, et la session doit venir d'une connexion récente chez le fournisseur + // (une session volée ou oubliée ouverte ne suffit pas). const sessionAgeMs = typeof payload.iat === 'number' ? Date.now() - payload.iat * 1000 : Infinity if (sessionAgeMs > RECENT_SIGN_IN_MS) { return reply.code(403).send({ error: 'recent_sign_in_required' }) } } - const isValid = user.passwordHash - ? Boolean(parsed.data.password) && await verifyPassword(parsed.data.password as string, user.passwordHash) + const isValid = withPassword + ? Boolean(parsed.data.password) && await verifyPassword(parsed.data.password as string, user.passwordHash as string) : (parsed.data.confirmEmail ?? '').trim().toLowerCase() === user.email if (!isValid) { return reply.code(403).send({ error: 'invalid_password' }) diff --git a/backend/src/routes/oidc.ts b/backend/src/routes/oidc.ts index be38479..ef12b63 100644 --- a/backend/src/routes/oidc.ts +++ b/backend/src/routes/oidc.ts @@ -13,6 +13,7 @@ import { type OidcIdentity, type OidcPending, } from '../lib/oidc.js' +import { confirmsWithPassword } from '../lib/auth.js' import { createAccountIfAllowed } from '../lib/registration.js' import { signUserSession } from '../lib/user-session.js' @@ -64,7 +65,10 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro if (!identity.email) return { error: 'oidc_email_missing' } if (env.OIDC_REQUIRE_VERIFIED_EMAIL && !identity.emailVerified) return { error: 'oidc_email_unverified' } - const existing = await app.prisma.user.findUnique({ where: { email: identity.email }, select: { id: true, passwordHash: true } }) + const existing = await app.prisma.user.findUnique({ + where: { email: identity.email }, + select: { id: true, passwordHash: true, identities: { select: { id: true }, take: 1 } }, + }) if (existing) { // Relier un compte existant demande toujours un email vérifié, même avec OIDC_REQUIRE_VERIFIED_EMAIL=false : // sinon quiconque déclare l'adresse d'un autre chez le fournisseur prendrait son compte @@ -72,6 +76,9 @@ async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Pro // Jamais vers un compte à mot de passe : l'inscription ne vérifie pas l'adresse, un tiers aurait pu créer ce // compte avec l'email de la victime, et sa connexion par le fournisseur y serait reliée, lisible par le tiers if (existing.passwordHash) return { error: 'oidc_email_taken' } + // Un compte déjà relié à une autre identité (un autre sujet) n'est jamais repris par un nouveau sujet qui + // présente le même email : le fournisseur a pu réattribuer l'adresse. Le compte reste à celui qui l'a relié. + if (existing.identities?.length) return { error: 'oidc_email_taken' } await app.prisma.userIdentity.create({ data: { userId: existing.id, issuer: identity.issuer, subject: identity.subject }, @@ -210,7 +217,7 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => { return reply.send({ token: signUserSession(app, user), - user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: Boolean(user.passwordHash) }, + user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash) }, }) }, ) diff --git a/backend/test/data-deletion.test.ts b/backend/test/data-deletion.test.ts index e6b4e07..0919f42 100644 --- a/backend/test/data-deletion.test.ts +++ b/backend/test/data-deletion.test.ts @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import type { FastifyInstance } from 'fastify' -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { prismaMock } = vi.hoisted(() => ({ prismaMock: { @@ -53,6 +53,7 @@ let storageRoot = '' let passwordHash = '' let createApp: () => FastifyInstance let resolveTreeMediaDirectory: (treeId: string, root?: string) => string | null +let env: { PASSWORD_LOGIN: boolean } function applyTestEnv(): void { process.env.NODE_ENV = 'test' @@ -97,6 +98,7 @@ beforeAll(async () => { createApp = (await import('../src/app.js')).createApp resolveTreeMediaDirectory = (await import('../src/lib/tree-purge.js')).resolveTreeMediaDirectory + ;({ env } = await import('../src/config/env.js')) }) afterAll(async () => { @@ -220,6 +222,73 @@ describe('DELETE /auth/me', () => { }, 15000) }) + describe('compte à mot de passe relié à OIDC, connexion par mot de passe fermée (PASSWORD_LOGIN=false)', () => { + const signedAt = async (app: FastifyInstance, minutesAgo: number) => { + await app.ready() + return (app as any).jwt.sign({ + kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com', + iat: Math.floor(Date.now() / 1000) - minutesAgo * 60, + }, { expiresIn: '1h' }) + } + + beforeEach(() => { + env.PASSWORD_LOGIN = false + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash }) + }) + + afterEach(() => { + env.PASSWORD_LOGIN = true + }) + + it('se supprime avec son email retapé, juste après une connexion (le mot de passe ne peut plus servir)', async () => { + const app = createApp() + const response = await app.inject({ + method: 'DELETE', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 2)}` }, + payload: { confirmEmail: 'user@example.com' }, + }) + expect(response.statusCode).toBe(200) + expect(prismaMock.user.delete).toHaveBeenCalled() + await app.close() + }) + + it('refuse une session de plus de 15 minutes, même avec le bon email', async () => { + const app = createApp() + const response = await app.inject({ + method: 'DELETE', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 20)}` }, + payload: { confirmEmail: 'user@example.com' }, + }) + expect(response.statusCode).toBe(403) + expect(response.json()).toEqual({ error: 'recent_sign_in_required' }) + expect(prismaMock.user.delete).not.toHaveBeenCalled() + await app.close() + }) + + it('refuse un autre email', async () => { + const app = createApp() + const response = await app.inject({ + method: 'DELETE', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 2)}` }, + payload: { confirmEmail: 'autre@example.com' }, + }) + expect(response.statusCode).toBe(403) + expect(prismaMock.user.delete).not.toHaveBeenCalled() + await app.close() + }) + + it('dit à l’écran de demander l’email, pas un mot de passe (hasPassword=false)', async () => { + const app = createApp() + const response = await app.inject({ + method: 'GET', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 2)}` }, + }) + expect(response.statusCode).toBe(200) + expect(response.json().user.hasPassword).toBe(false) + await app.close() + }) + }) + it('rejects a wrong password without deleting anything', async () => { const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg') const app = createApp() diff --git a/backend/test/oidc.test.ts b/backend/test/oidc.test.ts index 154e60a..ca4c795 100644 --- a/backend/test/oidc.test.ts +++ b/backend/test/oidc.test.ts @@ -168,6 +168,29 @@ describe('compte relié, créé ou refusé', () => { expect(prismaMock.user.create).not.toHaveBeenCalled() }) + it('identité déjà reliée : retrouvée par émetteur et sujet, jamais par email (le fournisseur a pu le réattribuer)', async () => { + prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' }) + oidcMock.finishAuthorization.mockResolvedValue({ ...identity, email: 'reattribue@example.com' }) + const { cookie } = await start() + const res = await callback(cookie) + expect(prismaMock.userIdentity.findUnique).toHaveBeenCalledWith({ + where: { issuer_subject: { issuer: ISSUER, subject: 'sub-1' } }, + select: { userId: true }, + }) + expect(prismaMock.user.findUnique).not.toHaveBeenCalled() + expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy() + }) + + it("nouveau sujet avec un email vérifié déjà relié à un autre sujet : refusé, le compte ne change pas de mains", async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: null, identities: [{ id: 'identity-1' }] }) + oidcMock.finishAuthorization.mockResolvedValue({ ...identity, subject: 'sub-2' }) + const { cookie } = await start() + const res = await callback(cookie) + expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_taken') + expect(prismaMock.userIdentity.create).not.toHaveBeenCalled() + expect(prismaMock.user.create).not.toHaveBeenCalled() + }) + it("email vérifié d'un compte à mot de passe : refusé, ni relié ni dupliqué (le compte a pu être créé par un tiers)", async () => { prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: 'hash' }) const { cookie } = await start()