mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-11 16:12:14 +00:00
Mot de passe de partage : apostrophe de téléphone et espace finale acceptées
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7a6a3a0942
commit
84d0637ab7
4 files changed
+89
-8
No files matched your search
@@ -0,0 +1,25 @@
|
||||
// Les claviers de téléphone remplacent ' par ’ et ajoutent parfois une espace après le dernier mot.
|
||||
// Un mot de passe de partage tapé ainsi doit quand même ouvrir l'arbre : on essaie aussi ces variantes.
|
||||
const CURLY_APOSTROPHES = /[‘’ʼ´`]/g
|
||||
const CURLY_QUOTES = /[“”«»]/g
|
||||
|
||||
export function sharePasswordVariants(input: string): string[] {
|
||||
const trimmed = input.trim()
|
||||
const straight = trimmed.replace(CURLY_APOSTROPHES, "'").replace(CURLY_QUOTES, '"')
|
||||
const curly = trimmed.replace(/'/g, '’')
|
||||
return [...new Set([input, trimmed, straight, curly])].filter(Boolean)
|
||||
}
|
||||
|
||||
// Renvoie la variante qui correspond à l'un des hash, ou null
|
||||
export async function matchSharePassword(
|
||||
input: string,
|
||||
hashes: string[],
|
||||
verify: (password: string, hash: string) => Promise<boolean>,
|
||||
): Promise<string | null> {
|
||||
for (const candidate of sharePasswordVariants(input)) {
|
||||
for (const hash of hashes) {
|
||||
if (await verify(candidate, hash)) return candidate
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
import { hashPassword, verifyPassword } from '../lib/auth.js'
|
||||
import { matchSharePassword } from '../lib/password-variants.js'
|
||||
import { decryptSharePassword, encryptSharePassword } from '../lib/share-password.js'
|
||||
import { purgeTrees } from '../lib/tree-purge.js'
|
||||
import type { MembershipRole } from '../types/auth.js'
|
||||
@@ -695,9 +696,12 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
// Un seul mot de passe de partage (25/09/2026) : regarder et proposer. Les deux colonnes restent,
|
||||
// les arbres créés avant ont encore deux mots de passe différents, les deux ouvrent en contributeur.
|
||||
const matches =
|
||||
(await verifyPassword(payload.data.password, accessConfig.contributorHash)) ||
|
||||
(await verifyPassword(payload.data.password, accessConfig.visitorHash))
|
||||
// Apostrophe typographique ou espace finale ajoutées par un clavier de téléphone : acceptées (password-variants.ts)
|
||||
const matches = await matchSharePassword(
|
||||
payload.data.password,
|
||||
[accessConfig.contributorHash, accessConfig.visitorHash],
|
||||
verifyPassword,
|
||||
)
|
||||
|
||||
if (!matches) {
|
||||
return reply.code(401).send({ error: 'invalid_password' })
|
||||
@@ -895,11 +899,13 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
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) {
|
||||
// On garde la variante exacte qui correspond au hash, c'est elle que la famille doit recevoir
|
||||
const password = await matchSharePassword(
|
||||
payload.data.password,
|
||||
[accessConfig.contributorHash, accessConfig.visitorHash],
|
||||
verifyPassword,
|
||||
)
|
||||
if (!password) {
|
||||
return reply.code(422).send({ error: 'password_mismatch' })
|
||||
}
|
||||
|
||||
|
||||
@@ -605,6 +605,38 @@ describe('API security and authz', () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('accepts the share password typed with a phone apostrophe and a trailing space', async () => {
|
||||
const { hashPassword } = await import('../src/lib/auth')
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
|
||||
const hash = await hashPassword("Lavenuedel'Avenir")
|
||||
prismaMock.treeAccessPasswords.findUnique.mockResolvedValue({
|
||||
treeId: 'tree-1',
|
||||
visitorHash: hash,
|
||||
contributorHash: hash,
|
||||
updatedAt: new Date('2026-02-11T10:00:00.000Z'),
|
||||
tree: { deletedAt: null },
|
||||
})
|
||||
|
||||
for (const typed of ['Lavenuedel’Avenir', "Lavenuedel'Avenir ", 'Lavenuedel’Avenir ']) {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/trees/tree-1/access/unlock',
|
||||
payload: { password: typed },
|
||||
})
|
||||
expect(response.statusCode, JSON.stringify(typed)).toBe(200)
|
||||
}
|
||||
|
||||
const wrong = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/trees/tree-1/access/unlock',
|
||||
payload: { password: 'lavenuedel’avenir' },
|
||||
})
|
||||
expect(wrong.statusCode).toBe(401)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('still rejects a wrong share password', async () => {
|
||||
const { hashPassword } = await import('../src/lib/auth')
|
||||
const app = createApp()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { sharePasswordVariants } from '../src/lib/password-variants'
|
||||
|
||||
describe('sharePasswordVariants', () => {
|
||||
it('ajoute la forme droite et la forme typographique de l\'apostrophe', () => {
|
||||
expect(sharePasswordVariants('Lavenuedel’Avenir')).toContain("Lavenuedel'Avenir")
|
||||
expect(sharePasswordVariants("Lavenuedel'Avenir")).toContain('Lavenuedel’Avenir')
|
||||
})
|
||||
|
||||
it('retire les espaces autour sans toucher à la casse', () => {
|
||||
expect(sharePasswordVariants(' Soleil ')).toEqual([' Soleil ', 'Soleil'])
|
||||
})
|
||||
|
||||
it('ne change rien à un mot de passe simple', () => {
|
||||
expect(sharePasswordVariants('123soleil')).toEqual(['123soleil'])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user