diff --git a/backend/prisma/migrations/20260925_003_tree_share_password/migration.sql b/backend/prisma/migrations/20260925_003_tree_share_password/migration.sql new file mode 100644 index 0000000..f0bf5d4 --- /dev/null +++ b/backend/prisma/migrations/20260925_003_tree_share_password/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "tree_share_passwords" ( + "treeId" TEXT NOT NULL, + "passwordEnc" TEXT NOT NULL, + "setAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "tree_share_passwords_pkey" PRIMARY KEY ("treeId") +); + +-- AddForeignKey +ALTER TABLE "tree_share_passwords" ADD CONSTRAINT "tree_share_passwords_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a2b13b6..a552e40 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -109,6 +109,7 @@ model Tree { deletedAt DateTime? owner User @relation("TreeOwner", fields: [ownerUserId], references: [id], onDelete: Restrict) accessPasswords TreeAccessPasswords? + sharePassword TreeSharePassword? memberships TreeMembership[] sharedUserAccesses UserTreeAccess[] settings TreeSettings? @@ -135,6 +136,17 @@ model TreeAccessPasswords { @@map("tree_access_passwords") } +// Mot de passe de partage en clair chiffré, pour que le propriétaire voie toujours celui en vigueur. +// Table à part : écrire dans tree_access_passwords changerait updatedAt, qui sert de version aux accès ouverts. +model TreeSharePassword { + treeId String @id + passwordEnc String + setAt DateTime @default(now()) + tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) + + @@map("tree_share_passwords") +} + model TreeMembership { id String @id @default(cuid()) treeId String diff --git a/backend/src/lib/share-password.ts b/backend/src/lib/share-password.ts new file mode 100644 index 0000000..f121fac --- /dev/null +++ b/backend/src/lib/share-password.ts @@ -0,0 +1,35 @@ +import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto' + +import { env } from '../config/env.js' + +// Le mot de passe de partage est fait pour être donné à la famille : le propriétaire doit pouvoir le relire. +// Chiffré en AES-256-GCM, clé dérivée de JWT_SECRET (changer ce secret rend les valeurs illisibles, +// le panneau repasse alors en « à saisir », sans rien casser pour la famille). +const VERSION = 'v1' + +function getKey(): Buffer { + return Buffer.from(hkdfSync('sha256', env.JWT_SECRET, 'parenthese', 'share-password-v1', 32)) +} + +export function encryptSharePassword(value: string): string { + const iv = randomBytes(12) + const cipher = createCipheriv('aes-256-gcm', getKey(), iv) + const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + return `${VERSION}:${Buffer.concat([iv, tag, encrypted]).toString('base64')}` +} + +export function decryptSharePassword(stored: string | null | undefined): string | null { + if (!stored || !stored.startsWith(`${VERSION}:`)) { + return null + } + + try { + const raw = Buffer.from(stored.slice(VERSION.length + 1), 'base64') + const decipher = createDecipheriv('aes-256-gcm', getKey(), raw.subarray(0, 12)) + decipher.setAuthTag(raw.subarray(12, 28)) + return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8') + } catch { + return null + } +} diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index 3ffb981..07c7e18 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -4,6 +4,7 @@ import { z } from 'zod' import { env } from '../config/env.js' import { hashPassword, verifyPassword } from '../lib/auth.js' +import { decryptSharePassword, encryptSharePassword } from '../lib/share-password.js' import { purgeTrees } from '../lib/tree-purge.js' import type { MembershipRole } from '../types/auth.js' import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js' @@ -50,6 +51,10 @@ const patchPasswordsSchema = z message: 'at_least_one_password_required', }) +const rememberPasswordSchema = z.object({ + password: z.string().min(1).max(128), +}) + const patchTreeSettingsSchema = z .object({ memberContributionPolicy: z.enum(['direct', 'pending']).optional(), @@ -419,6 +424,9 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { contributorHash, }, }, + ...(parsed.data.visitorPassword === parsed.data.contributorPassword + ? { sharePassword: { create: { passwordEnc: encryptSharePassword(parsed.data.visitorPassword) } } } + : {}), memberships: { create: { userId: user.userId, @@ -838,6 +846,73 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { return reply.send({ treeId: params.data.id, role }) }) + // Le mot de passe de partage en vigueur, pour le propriétaire (null si jamais retenu par le serveur) + app.get('/trees/:id/access/passwords', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const params = treeIdParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_tree_id' }) + } + + const membership = await getMembership(app, user.userId, params.data.id) + if (!membership || !hasAdminRight(membership.role)) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const stored = await app.prisma.treeSharePassword.findUnique({ where: { treeId: params.data.id } }) + return reply.send({ share: decryptSharePassword(stored?.passwordEnc) }) + }) + + // Arbres d'avant : le propriétaire tape le mot de passe déjà donné, le serveur le vérifie et le retient + // sans rien changer aux accès (les hash et leur updatedAt ne bougent pas). + app.post('/trees/:id/access/passwords/remember', async (request, reply) => { + const user = requireUser(request, reply) + if (!user) { + return + } + + const params = treeIdParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_tree_id' }) + } + + const payload = rememberPasswordSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + } + + const membership = await getMembership(app, user.userId, params.data.id) + if (!membership || !hasAdminRight(membership.role)) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const accessConfig = await app.prisma.treeAccessPasswords.findUnique({ where: { treeId: params.data.id } }) + if (!accessConfig) { + return reply.code(404).send({ error: 'tree_not_found' }) + } + + const password = payload.data.password.trim() + const matches = + (await verifyPassword(password, accessConfig.contributorHash)) || + (await verifyPassword(password, accessConfig.visitorHash)) + if (!matches) { + return reply.code(422).send({ error: 'password_mismatch' }) + } + + const passwordEnc = encryptSharePassword(password) + await app.prisma.treeSharePassword.upsert({ + where: { treeId: params.data.id }, + create: { treeId: params.data.id, passwordEnc }, + update: { passwordEnc, setAt: new Date() }, + }) + + return reply.send({ share: password }) + }) + app.patch('/trees/:id/access/passwords', async (request, reply) => { const user = requireUser(request, reply) if (!user) { @@ -891,6 +966,18 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { data, }) + // Le mot de passe lisible suit toujours celui en vigueur ; deux mots de passe distincts = plus de valeur unique + if (payload.data.password) { + const passwordEnc = encryptSharePassword(payload.data.password) + await app.prisma.treeSharePassword.upsert({ + where: { treeId: params.data.id }, + create: { treeId: params.data.id, passwordEnc }, + update: { passwordEnc, setAt: new Date() }, + }) + } else { + await app.prisma.treeSharePassword.deleteMany({ where: { treeId: params.data.id } }) + } + await app.prisma.auditLog.create({ data: { treeId: params.data.id, diff --git a/backend/test/api-security.integration.test.ts b/backend/test/api-security.integration.test.ts index 5c9edff..84af498 100644 --- a/backend/test/api-security.integration.test.ts +++ b/backend/test/api-security.integration.test.ts @@ -31,6 +31,11 @@ const { prismaMock } = vi.hoisted(() => ({ create: vi.fn(), update: vi.fn(), }, + treeSharePassword: { + findUnique: vi.fn(), + upsert: vi.fn(), + deleteMany: vi.fn(), + }, userTreeAccess: { findMany: vi.fn(), findUnique: vi.fn(), @@ -164,6 +169,9 @@ beforeEach(() => { }) prismaMock.userTreeAccess.updateMany.mockResolvedValue({ count: 0 }) prismaMock.treeAccessPasswords.update.mockResolvedValue({ treeId: 'tree-1' }) + prismaMock.treeSharePassword.findUnique.mockResolvedValue(null) + prismaMock.treeSharePassword.upsert.mockResolvedValue({ treeId: 'tree-1' }) + prismaMock.treeSharePassword.deleteMany.mockResolvedValue({ count: 0 }) prismaMock.treeSettings.findUnique.mockResolvedValue({ treeId: 'tree-1', memberContributionPolicy: 'pending', @@ -673,6 +681,101 @@ describe('API security and authz', () => { await app.close() }) + // Mot de passe toujours à jour chez le propriétaire (25/09/2026) : gardé chiffré par le serveur + it('keeps the new share password readable for the owner', async () => { + const { decryptSharePassword } = await import('../src/lib/share-password') + const app = createApp() + const token = await createUserToken(app) + + await app.inject({ + method: 'PATCH', + url: '/trees/tree-1/access/passwords', + headers: { authorization: `Bearer ${token}` }, + payload: { password: 'lilas-en-avril' }, + }) + + const stored = prismaMock.treeSharePassword.upsert.mock.calls.at(-1)?.[0]?.create?.passwordEnc + expect(stored).not.toContain('lilas-en-avril') + expect(decryptSharePassword(stored)).toBe('lilas-en-avril') + + prismaMock.treeSharePassword.findUnique.mockResolvedValue({ treeId: 'tree-1', passwordEnc: stored }) + const read = await app.inject({ + method: 'GET', + url: '/trees/tree-1/access/passwords', + headers: { authorization: `Bearer ${token}` }, + }) + expect(read.statusCode).toBe(200) + expect(read.json().share).toBe('lilas-en-avril') + await app.close() + }) + + it('forgets the readable password when two different passwords are set', async () => { + const app = createApp() + const token = await createUserToken(app) + + await app.inject({ + method: 'PATCH', + url: '/trees/tree-1/access/passwords', + headers: { authorization: `Bearer ${token}` }, + payload: { visitorPassword: 'regarder-seul' }, + }) + + expect(prismaMock.treeSharePassword.deleteMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1' } }) + expect(prismaMock.treeSharePassword.upsert).not.toHaveBeenCalled() + await app.close() + }) + + it('remembers the current password without changing access', async () => { + const { hashPassword } = await import('../src/lib/auth') + const app = createApp() + const token = await createUserToken(app) + prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({ + treeId: 'tree-1', + visitorHash: await hashPassword('ancien-visiteur'), + contributorHash: await hashPassword('celui-de-la-famille'), + updatedAt: new Date('2026-02-11T10:00:00.000Z'), + tree: { deletedAt: null }, + }) + + const wrong = await app.inject({ + method: 'POST', + url: '/trees/tree-1/access/passwords/remember', + headers: { authorization: `Bearer ${token}` }, + payload: { password: 'pas-le-bon' }, + }) + expect(wrong.statusCode).toBe(422) + expect(prismaMock.treeSharePassword.upsert).not.toHaveBeenCalled() + + const right = await app.inject({ + method: 'POST', + url: '/trees/tree-1/access/passwords/remember', + headers: { authorization: `Bearer ${token}` }, + payload: { password: 'celui-de-la-famille' }, + }) + expect(right.statusCode).toBe(200) + expect(right.json().share).toBe('celui-de-la-famille') + expect(prismaMock.treeSharePassword.upsert).toHaveBeenCalledTimes(1) + expect(prismaMock.treeAccessPasswords.update).not.toHaveBeenCalled() + await app.close() + }) + + it('refuses the readable password to anyone but an admin', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.treeMembership.findUnique.mockResolvedValue(null) + + const read = await app.inject({ + method: 'GET', + url: '/trees/tree-1/access/passwords', + headers: { authorization: `Bearer ${token}` }, + }) + expect(read.statusCode).toBe(403) + + const anonymous = await app.inject({ method: 'GET', url: '/trees/tree-1/access/passwords' }) + expect(anonymous.statusCode).toBe(401) + await app.close() + }) + // Contributions fiables (25/09/2026) : mot d'accompagnement, liens d'une personne ajoutée const signShareToken = (app: any) => app.jwt.sign({ kind: 'tree_access', diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a60de94..9cc8584 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -31,6 +31,8 @@ import { linkTreeAccess, updateTree, rotateTreePasswords, + fetchSharePassword, + rememberSharePassword, resolveTreeBySlug, uploadPersonAvatar, updatePerson, @@ -423,36 +425,17 @@ function buildContribChangesList(draft = {}) { return items } -function getInvitePasswordStorageKey(treeId, role) { - return `invite_password_${treeId}_${role}` -} - -// Mot de passe de partage connu de ce navigateur (le serveur ne garde qu'un hash). -// Repli sur les anciennes clés « contributor » puis « visitor » d'avant le mot de passe unique. -function readInvitePasswordsFromStorage(treeId) { +// Anciennes copies locales du mot de passe (avant qu'il soit gardé par le serveur) : on les efface, +// elles pouvaient montrer un mot de passe changé depuis un autre appareil. +function clearLegacyInvitePasswords(treeId) { if (!treeId) { - return { share: '' } - } - - try { - return { - share: localStorage.getItem(getInvitePasswordStorageKey(treeId, 'share')) - || localStorage.getItem(getInvitePasswordStorageKey(treeId, 'contributor')) - || localStorage.getItem(getInvitePasswordStorageKey(treeId, 'visitor')) - || '', - } - } catch { - return { share: '' } - } -} - -function writeInvitePasswordsToStorage(treeId, passwords) { - if (!treeId || !passwords.share) { return } try { - localStorage.setItem(getInvitePasswordStorageKey(treeId, 'share'), passwords.share) + for (const role of ['share', 'contributor', 'visitor']) { + localStorage.removeItem(`invite_password_${treeId}_${role}`) + } } catch { // Ignore localStorage failures } @@ -1240,16 +1223,52 @@ function App() { }, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId]) const handleOpenContributionPanel = useCallback(async () => { - const knownPasswords = readInvitePasswordsFromStorage(tree.treeContext.treeId) + const treeId = tree.treeContext.treeId + clearLegacyInvitePasswords(treeId) await resolveContributionShareUrl() + let share = '' + const accessToken = auth.userAuth.token || tree.treeContext.accessToken + if (treeId && accessToken && tree.canEditCurrentTree) { + try { + share = (await fetchSharePassword(treeId, accessToken))?.share || '' + } catch { + share = '' + } + } setContribInviteState((current) => ({ ...current, - knownPasswords, + knownPasswords: { share }, error: '', message: '', })) await contrib.handleOpenContributionPanel() - }, [contrib, resolveContributionShareUrl, tree.treeContext.treeId]) // eslint-disable-line react-hooks/exhaustive-deps + }, [contrib, resolveContributionShareUrl, tree.treeContext.treeId, tree.canEditCurrentTree, tree.treeContext.accessToken, auth.userAuth.token]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleContributionPasswordRemember = useCallback(async ({ password }) => { + const accessToken = auth.userAuth.token || tree.treeContext.accessToken + if (!tree.treeContext.treeId || !accessToken) { + return { ok: false, error: 'Session invalide.' } + } + + setContribInviteState((current) => ({ ...current, loading: true, error: '', message: '' })) + try { + const result = await rememberSharePassword(tree.treeContext.treeId, String(password || '').trim(), accessToken) + setContribInviteState((current) => ({ + ...current, + knownPasswords: { share: result?.share || '' }, + loading: false, + error: '', + message: "C'est bien lui. Rien ne change pour la famille.", + })) + return { ok: true } + } catch (error) { + const message = error?.status === 422 + ? "Ce n'est pas le mot de passe actuel de l'arbre." + : getAccountErrorMessage(error) + setContribInviteState((current) => ({ ...current, loading: false, error: message, message: '' })) + return { ok: false, error: message } + } + }, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId]) const handleContributionPasswordRotate = useCallback(async ({ password }) => { if (!tree.treeContext.treeId) { @@ -1288,7 +1307,6 @@ function App() { try { await rotateTreePasswords(tree.treeContext.treeId, payload, accessToken) const storedPasswords = { share: nextPassword } - writeInvitePasswordsToStorage(tree.treeContext.treeId, storedPasswords) setContribInviteState((current) => ({ ...current, @@ -2149,7 +2167,7 @@ function App() { ? normalizedSlug : `famille-${Date.now().toString(36)}` - // Un seul mot de passe de partage, gardé dans ce navigateur pour que le propriétaire le voie + // Un seul mot de passe de partage, gardé chiffré par le serveur pour que le propriétaire le voie const sharePassword = generateSecurePassword() const createdTree = await createTree( { @@ -2161,7 +2179,6 @@ function App() { }, token, ) - writeInvitePasswordsToStorage(createdTree.id, { share: sharePassword }) trackAppEvent('tree_created', { tree_id: toOpaqueTreeId(createdTree.id) }) const createdSelf = await createPerson( @@ -2603,6 +2620,7 @@ function App() { onClose={contrib.handleCloseContributionPanel} onRefresh={contrib.refreshContributionSessions} onRotatePasswords={handleContributionPasswordRotate} + onRememberPassword={handleContributionPasswordRemember} onReviewSession={contrib.handleReviewContributionSession} /> { const [decisionMap, setDecisionMap] = useState({}) const [collapsedSessions, setCollapsedSessions] = useState(new Set()) const [confirmApproveAll, setConfirmApproveAll] = useState(null) const [showPassword, setShowPassword] = useState(false) - const [editingPassword, setEditingPassword] = useState(false) + // '' | 'change' (nouveau mot de passe) | 'remember' (arbre d'avant : saisir celui déjà donné) + const [passwordMode, setPasswordMode] = useState('') const [newPassword, setNewPassword] = useState('') const [inviteLocalError, setInviteLocalError] = useState('') const [copyFeedback, setCopyFeedback] = useState('') @@ -279,11 +295,16 @@ const ContributionPanel = ({ useEffect(() => { if (!visible) return - setEditingPassword(false) + setPasswordMode('') setNewPassword('') setInviteLocalError('') }, [inviteKnownPasswords?.share, visible]) + const knownShare = inviteKnownPasswords?.share || '' + const defaultInviteText = knownShare && inviteShareUrl ? buildInviteText(inviteShareUrl, knownShare) : '' + const [inviteText, setInviteText] = useState('') + useEffect(() => { setInviteText(defaultInviteText) }, [defaultInviteText]) + const handleCopy = async (value, label) => { const text = String(value || '').trim() if (!text) return @@ -294,9 +315,15 @@ const ContributionPanel = ({ const handleSavePassword = async (e) => { e.preventDefault() setInviteLocalError('') + if (passwordMode === 'remember') { + if (!newPassword.trim()) return + const result = await onRememberPassword?.({ password: newPassword.trim() }) + if (result?.ok) { setPasswordMode(''); setNewPassword('') } + return + } if (newPassword.trim().length < 8) { setInviteLocalError('Minimum 8 caractères.'); return } const result = await onRotatePasswords?.({ password: newPassword.trim() }) - if (result?.ok) { setEditingPassword(false); setNewPassword('') } + if (result?.ok) { setPasswordMode(''); setNewPassword('') } } if (!visible) return null @@ -334,22 +361,36 @@ const ContributionPanel = ({
Mot de passepour regarder et ajouter - {editingPassword ? ( + {passwordMode ? (
- setNewPassword(e.target.value)} /> - - + setNewPassword(e.target.value)} /> + +
) : ( <> - - {inviteKnownPasswords?.share && } - + + {knownShare && } + {!knownShare && } + )}
-

Changer le mot de passe oblige les personnes qui ont déjà ouvert l'arbre à saisir le nouveau.

+

+ {passwordMode === 'remember' || (!knownShare && !passwordMode) + ? "Déjà donné à la famille ? Saisissez-le ici. Il est vérifié puis gardé, rien ne change pour ceux qui ont déjà ouvert l'arbre." + : "Changer le mot de passe oblige les personnes qui ont déjà ouvert l'arbre à saisir le nouveau."} +

+ {defaultInviteText && ( +
+ +