fix(comptes) : suppression sans mot de passe si PASSWORD_LOGIN=false, identité OIDC liée par sub

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 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Haiku 5.5 committed 2026-10-21 15:51:47 +02:00
1 parent 6bb0a832d2
commit 25b33ca056
5 files changed
+121 -11

No files matched your search

+10 -1
View File
@@ -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<string> {
@@ -8,4 +10,11 @@ export async function hashPassword(value: string): Promise<string> {
export async function verifyPassword(value: string, hash: string): Promise<boolean> {
return bcrypt.compare(value, hash)
}
}
// 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
}
+9 -7
View File
@@ -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<AnyJwtPayload & { exp?: number; iat?: number }>()
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' })
+9 -2
View File
@@ -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) },
})
},
)
+70 -1
View File
@@ -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()
+23
View File
@@ -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()