From 47cb3b487fe86e33261ffe96b951d2881512a283 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sun, 11 Oct 2026 18:58:31 +0200 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=94=92=20security(AccountDataExporter?= =?UTF-8?q?):=20export=20des=20donn=C3=A9es=20personnelles=20de=20la=20per?= =?UTF-8?q?sonne=20connect=C3=A9e=20(#431)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AccountDataRepositoryInterface.php | 33 +++++++ .../Repository/MysqlAccountDataRepository.php | 79 +++++++++++++++ .../Service/Data/AccountDataExporter.php | 50 ++++++++++ .../MysqlAccountDataRepositoryTest.php | 97 +++++++++++++++++++ .../Service/Data/AccountDataExporterTest.php | 62 ++++++++++++ 5 files changed, 321 insertions(+) create mode 100644 src/Account/Repository/AccountDataRepositoryInterface.php create mode 100644 src/Account/Repository/MysqlAccountDataRepository.php create mode 100644 src/Account/Service/Data/AccountDataExporter.php create mode 100644 tests/Account/Repository/MysqlAccountDataRepositoryTest.php create mode 100644 tests/Account/Service/Data/AccountDataExporterTest.php diff --git a/src/Account/Repository/AccountDataRepositoryInterface.php b/src/Account/Repository/AccountDataRepositoryInterface.php new file mode 100644 index 00000000..784ed9c3 --- /dev/null +++ b/src/Account/Repository/AccountDataRepositoryInterface.php @@ -0,0 +1,33 @@ + */ + public function memberships(int $userId): array; + + /** + * Messages écrits par la personne (texte déchiffré), hors lignes système. + * + * @return list + */ + public function messages(int $userId): array; + + /** + * Réservations libres demandées par la personne. + * + * @return list + */ + public function bookings(int $userId): array; + + /** + * Demandes d'échange de créneau faites par la personne. + * + * @return list + */ + public function slotRequests(int $userId): array; +} diff --git a/src/Account/Repository/MysqlAccountDataRepository.php b/src/Account/Repository/MysqlAccountDataRepository.php new file mode 100644 index 00000000..38db9563 --- /dev/null +++ b/src/Account/Repository/MysqlAccountDataRepository.php @@ -0,0 +1,79 @@ +pdo->prepare( + 'SELECT g.id, g.name, gu.role FROM group_user gu JOIN `groups` g ON g.id = gu.group_id WHERE gu.user_id = :user_id ORDER BY g.name, g.id' + ); + $statement->execute(['user_id' => $userId]); + + return array_map( + static fn (array $row): array => ['groupId' => (int) $row['id'], 'group' => (string) $row['name'], 'role' => (string) $row['role']], + $statement->fetchAll(\PDO::FETCH_ASSOC), + ); + } + + public function messages(int $userId): array + { + $statement = $this->pdo->prepare( + 'SELECT id, conversation_id, body, created_at, edited_at FROM conversation_messages WHERE author_id = :user_id AND is_system = 0 ORDER BY id' + ); + $statement->execute(['user_id' => $userId]); + + return array_map(fn (array $row): array => [ + 'id' => (int) $row['id'], + 'conversationId' => (int) $row['conversation_id'], + 'sentAt' => (string) $row['created_at'], + 'editedAt' => $row['edited_at'] === null ? null : (string) $row['edited_at'], + 'text' => $this->cipher->decrypt((string) $row['body']), + ], $statement->fetchAll(\PDO::FETCH_ASSOC)); + } + + public function bookings(int $userId): array + { + $statement = $this->pdo->prepare( + 'SELECT g.name, b.booking_date, b.start_time, b.end_time, b.status, b.reason, b.created_at + FROM free_slot_bookings b JOIN `groups` g ON g.id = b.group_id WHERE b.booked_by_user_id = :user_id ORDER BY b.booking_date, b.start_time, b.id' + ); + $statement->execute(['user_id' => $userId]); + + return array_map(static fn (array $row): array => [ + 'group' => (string) $row['name'], + 'date' => (string) $row['booking_date'], + 'start' => substr((string) $row['start_time'], 0, 5), + 'end' => substr((string) $row['end_time'], 0, 5), + 'status' => (string) $row['status'], + 'reason' => $row['reason'] === null ? null : (string) $row['reason'], + 'createdAt' => (string) $row['created_at'], + ], $statement->fetchAll(\PDO::FETCH_ASSOC)); + } + + public function slotRequests(int $userId): array + { + $statement = $this->pdo->prepare( + 'SELECT g.name, e.occurrence_date, e.status, e.request_reason, e.created_at + FROM slot_exceptions e JOIN `groups` g ON g.id = e.requested_by_group_id WHERE e.requested_by_user_id = :user_id ORDER BY e.occurrence_date, e.id' + ); + $statement->execute(['user_id' => $userId]); + + return array_map(static fn (array $row): array => [ + 'group' => (string) $row['name'], + 'date' => (string) $row['occurrence_date'], + 'status' => (string) $row['status'], + 'reason' => $row['request_reason'] === null ? null : (string) $row['request_reason'], + 'createdAt' => (string) $row['created_at'], + ], $statement->fetchAll(\PDO::FETCH_ASSOC)); + } +} diff --git a/src/Account/Service/Data/AccountDataExporter.php b/src/Account/Service/Data/AccountDataExporter.php new file mode 100644 index 00000000..83a56910 --- /dev/null +++ b/src/Account/Service/Data/AccountDataExporter.php @@ -0,0 +1,50 @@ +>, messages: list>, bookings: list>, slotRequests: list>} + * + * @throws UserNotFoundException + */ + public function export(int $userId): array + { + $user = $this->users->findById($userId) ?? throw new UserNotFoundException("Utilisateur {$userId} introuvable."); + + return [ + 'generatedAt' => $this->clock->now()->format(\DATE_ATOM), + 'account' => [ + 'id' => $user->id(), + 'email' => $user->email(), + 'displayName' => $user->displayName(), + 'role' => $user->role()->value, + 'active' => $user->isActive(), + ], + 'groups' => $this->data->memberships($userId), + 'messages' => $this->data->messages($userId), + 'bookings' => $this->data->bookings($userId), + 'slotRequests' => $this->data->slotRequests($userId), + ]; + } +} diff --git a/tests/Account/Repository/MysqlAccountDataRepositoryTest.php b/tests/Account/Repository/MysqlAccountDataRepositoryTest.php new file mode 100644 index 00000000..b1491c34 --- /dev/null +++ b/tests/Account/Repository/MysqlAccountDataRepositoryTest.php @@ -0,0 +1,97 @@ +setUpScenario(); + $alice = $this->user('Alice'); + $bob = $this->user('Bob'); + [$this->alice, $this->bob] = [$alice->id(), $bob->id()]; + $this->alpha = $this->group('Alpha', $alice)->id(); + $this->beta = $this->group('Beta', $bob)->id(); + $this->groups->addMember($this->alpha, $bob->id(), GroupUserRole::Gestionnaire); + $this->data = new MysqlAccountDataRepository($this->pdo, TestMessageCipher::make()); + } + + #[Test] + public function testMembershipsListTheGroupsAndRolesOfThatPersonOnly(): void + { + self::assertSame([['groupId' => $this->alpha, 'group' => 'Alpha', 'role' => 'membre']], $this->data->memberships($this->alice)); + self::assertEqualsCanonicalizing( + [['groupId' => $this->alpha, 'group' => 'Alpha', 'role' => 'gestionnaire'], ['groupId' => $this->beta, 'group' => 'Beta', 'role' => 'membre']], + $this->data->memberships($this->bob), + ); + } + + #[Test] + public function testMessagesAreReturnedDecryptedAndOnlyThoseWrittenByThePerson(): void + { + $conversation = (new MysqlConversationRepository($this->pdo, TestMessageCipher::make()))->create($this->alpha, $this->beta, 'Répétition', $this->now, $this->alice); + $messages = new MysqlConversationMessageRepository($this->pdo, TestMessageCipher::make()); + $messages->addMessage($conversation->id(), $this->alice, 'Salut, jeudi ?', $this->now); + $messages->addMessage($conversation->id(), $this->bob, 'Message de Bob', $this->now->modify('+1 minute')); + $messages->addMessage($conversation->id(), $this->alice, 'Alice a rejoint', $this->now->modify('+2 minutes'), true); + + $stored = (string) $this->pdo->query('SELECT body FROM conversation_messages WHERE author_id = ' . $this->alice . ' ORDER BY id LIMIT 1')->fetchColumn(); + self::assertStringNotContainsString('Salut', $stored, 'en base, le texte est chiffré'); + + $exported = $this->data->messages($this->alice); + + self::assertCount(1, $exported, 'ni les messages des autres ni les lignes système'); + self::assertSame('Salut, jeudi ?', $exported[0]['text']); + self::assertSame($conversation->id(), $exported[0]['conversationId']); + self::assertSame($this->now->format('Y-m-d H:i:s'), $exported[0]['sentAt']); + } + + #[Test] + public function testBookingsAreThoseMadeByThePerson(): void + { + $bookings = new MysqlFreeSlotBookingRepository($this->pdo); + $bookings->create(new Requester($this->alpha, $this->alice), new \DateTimeImmutable('2026-10-07'), new TimeRange('09:00:00', '10:00:00'), 'Enregistrement'); + $bookings->create(new Requester($this->alpha, $this->bob), new \DateTimeImmutable('2026-10-08'), new TimeRange('09:00:00', '10:00:00'), 'Autre'); + + $exported = $this->data->bookings($this->alice); + + self::assertCount(1, $exported); + self::assertEquals(['date' => '2026-10-07', 'start' => '09:00', 'end' => '10:00', 'status' => 'en_attente', 'reason' => 'Enregistrement', 'group' => 'Alpha'], array_diff_key($exported[0], ['createdAt' => 1])); + } + + #[Test] + public function testAPersonWithNothingGetsEmptyLists(): void + { + $nobody = $this->user('Nobody')->id(); + + self::assertSame([], $this->data->memberships($nobody)); + self::assertSame([], $this->data->messages($nobody)); + self::assertSame([], $this->data->bookings($nobody)); + self::assertSame([], $this->data->slotRequests($nobody)); + } +} diff --git a/tests/Account/Service/Data/AccountDataExporterTest.php b/tests/Account/Service/Data/AccountDataExporterTest.php new file mode 100644 index 00000000..cf0893ae --- /dev/null +++ b/tests/Account/Service/Data/AccountDataExporterTest.php @@ -0,0 +1,62 @@ +setUpScenario(); + $alice = $this->user('Alice'); + $this->group('Alpha', $alice); + $exporter = new AccountDataExporter($this->users, new MysqlAccountDataRepository($this->pdo, TestMessageCipher::make()), new MockClock('2026-10-20 10:00:00 UTC')); + + $export = $exporter->export($alice->id()); + + self::assertSame('2026-10-20T10:00:00+00:00', $export['generatedAt']); + self::assertSame(['id' => $alice->id(), 'email' => 'alice@rehearsalbox.test', 'displayName' => 'Alice', 'role' => 'musicien', 'active' => true], $export['account']); + self::assertSame('Alpha', $export['groups'][0]['group']); + foreach (['messages', 'bookings', 'slotRequests'] as $key) { + self::assertSame([], $export[$key], $key); + } + } + + #[Test] + public function testNoSecretEverLeavesInTheExport(): void + { + $this->setUpScenario(); + $alice = $this->user('Alice'); + $exporter = new AccountDataExporter($this->users, new MysqlAccountDataRepository($this->pdo, TestMessageCipher::make()), new MockClock()); + + $json = (string) json_encode($exporter->export($alice->id())); + + self::assertStringNotContainsString('hash', strtolower($json)); + self::assertStringNotContainsString('password', strtolower($json)); + self::assertStringNotContainsString('session', strtolower($json)); + } + + #[Test] + public function testAnUnknownAccountHasNothingToExport(): void + { + $this->setUpScenario(); + $exporter = new AccountDataExporter($this->users, new MysqlAccountDataRepository($this->pdo, TestMessageCipher::make()), new MockClock()); + + $this->expectException(\App\Account\Exception\UserNotFoundException::class); + $exporter->export(999999); + } +} From 27ddf4c65d959502ebe25447c18766ef5e61e46f Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sun, 11 Oct 2026 18:58:31 +0200 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=94=92=20security(AccountDeletionServ?= =?UTF-8?q?ice):=20suppression=20de=20son=20compte=20par=20anonymisation,?= =?UTF-8?q?=20mot=20de=20passe=20redemand=C3=A9=20(#431)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/Data/AccountDeletionService.php | 90 ++++++++ templates/mail/account-deleted.html.php | 4 + templates/mail/account-deleted.txt.php | 5 + .../Data/AccountDeletionServiceTest.php | 194 ++++++++++++++++++ tests/Mail/MailRendererTest.php | 14 ++ 5 files changed, 307 insertions(+) create mode 100644 src/Account/Service/Data/AccountDeletionService.php create mode 100644 templates/mail/account-deleted.html.php create mode 100644 templates/mail/account-deleted.txt.php create mode 100644 tests/Account/Service/Data/AccountDeletionServiceTest.php diff --git a/src/Account/Service/Data/AccountDeletionService.php b/src/Account/Service/Data/AccountDeletionService.php new file mode 100644 index 00000000..fb54c43b --- /dev/null +++ b/src/Account/Service/Data/AccountDeletionService.php @@ -0,0 +1,90 @@ +clock->now()); + $user = $this->users->findById($userId) ?? throw new UserNotFoundException("Utilisateur {$userId} introuvable."); + $this->passwords->assertMatches($user, $password, $now); + + $oldEmail = $user->email(); + $this->transactions->run(function () use ($userId, $now): void { + $current = $this->users->findById($userId) ?? throw new UserNotFoundException("Utilisateur {$userId} introuvable."); + if ($current->hasRole(UserRole::Admin)) { + throw new UserAdminRuleException("Un compte administrateur ne se supprime pas lui-même : demandez à un autre administrateur de le rétrograder d'abord."); + } + + foreach ($this->groups->findByMember($userId) as $group) { + $this->managers->assertNotLastManager($group->id(), $userId); + $this->groups->removeMember($group->id(), $userId); + } + $this->resets->invalidateAllForUser($userId, $now, PasswordResetRepositoryInterface::PURPOSE_RESET); + $this->resets->invalidateAllForUser($userId, $now, PasswordResetRepositoryInterface::PURPOSE_ALERT); + $this->emailChanges->invalidateAllForUser($userId, $now); + + $this->users->save( + $current + ->withEmail(sprintf('ancien-membre-%d@supprime.invalid', $userId)) + ->withDisplayName(self::FORMER_NAME) + ->withPasswordHash($this->hasher->hash(bin2hex(random_bytes(32)))) + ->withActive(false), + ); + }); + + $this->mailbox->sendSafely(fn () => $this->mailbox->compose( + $oldEmail, + 'votre compte a été supprimé', + 'account-deleted', + ['preheader' => 'Votre compte RehearsalBox a été supprimé à votre demande.'], + ), sprintf('Suppression de compte : confirmation par e-mail impossible (utilisateur #%d)', $userId)); + } +} diff --git a/templates/mail/account-deleted.html.php b/templates/mail/account-deleted.html.php new file mode 100644 index 00000000..46d51558 --- /dev/null +++ b/templates/mail/account-deleted.html.php @@ -0,0 +1,4 @@ +

Votre compte a été supprimé

+

Bonjour,

+

Votre compte RehearsalBox a été supprimé à votre demande : vos informations personnelles (nom, adresse e-mail, mot de passe) ont été effacées et vous ne faites plus partie d'aucun groupe. Les messages que vous aviez envoyés restent visibles dans les conversations des autres, sous le nom « Ancien membre ».

+

Si vous n'êtes PAS à l'origine de cette suppression, contactez immédiatement un administrateur de RehearsalBox.

diff --git a/templates/mail/account-deleted.txt.php b/templates/mail/account-deleted.txt.php new file mode 100644 index 00000000..44a78f4d --- /dev/null +++ b/templates/mail/account-deleted.txt.php @@ -0,0 +1,5 @@ +Bonjour, + +Votre compte RehearsalBox a été supprimé à votre demande : vos informations personnelles (nom, adresse e-mail, mot de passe) ont été effacées et vous ne faites plus partie d'aucun groupe. Les messages que vous aviez envoyés restent visibles dans les conversations des autres, sous le nom « Ancien membre ». + +Si vous n'êtes PAS à l'origine de cette suppression, contactez immédiatement un administrateur de RehearsalBox. diff --git a/tests/Account/Service/Data/AccountDeletionServiceTest.php b/tests/Account/Service/Data/AccountDeletionServiceTest.php new file mode 100644 index 00000000..c9e384cb --- /dev/null +++ b/tests/Account/Service/Data/AccountDeletionServiceTest.php @@ -0,0 +1,194 @@ +mailer = new RecordingMailer(); + $hasher = new FastPasswordHasher(); + + return new AccountDeletionService( + $this->users, + $this->groups, + new GroupManagerService($this->groups, new MysqlGroupManagerRepository($this->pdo)), + new CurrentPasswordVerifier($this->users, $hasher), + new MysqlPasswordResetRepository($this->pdo), + new MysqlEmailChangeRepository($this->pdo), + $hasher, + new TransactionRunner($this->pdo), + TestMailbox::of($mailer ?? $this->mailer), + new MockClock('2026-10-20 10:00:00'), + ); + } + + private function person(string $name, UserRole $role = UserRole::Musicien): User + { + return $this->users->save(new User(0, strtolower($name) . '@rehearsalbox.test', (new FastPasswordHasher())->hash(self::PASSWORD), $name, $role, true, 0, null)); + } + + protected function setUp(): void + { + parent::setUp(); + $this->setUpScenario(); + } + + #[Test] + public function testThePersonalDataIsErasedAndTheAccountCanNoLongerSignIn(): void + { + $alice = $this->person('Alice'); + $this->group('Alpha', $alice); + $this->service()->delete($alice->id(), self::PASSWORD); + + $after = $this->users->findById($alice->id()); + self::assertNotNull($after, 'la ligne reste : les conversations et réservations des autres y sont rattachées'); + self::assertSame('Ancien membre', $after->displayName()); + self::assertSame('ancien-membre-' . $alice->id() . '@supprime.invalid', $after->email()); + self::assertFalse($after->isActive()); + self::assertNull($this->users->findByEmail('alice@rehearsalbox.test'), 'l\'adresse est libérée'); + self::assertFalse((new FastPasswordHasher())->verify(self::PASSWORD, $after->passwordHash()), 'plus de mot de passe valable'); + self::assertGreaterThan($alice->sessionVersion(), $after->sessionVersion(), 'toutes les sessions sont fermées'); + self::assertSame([], $this->groups->findByMember($alice->id()), 'plus membre d\'aucun groupe'); + } + + #[Test] + public function testTheMessagesStayInTheConversationsOfOthersUnderTheFormerMemberName(): void + { + $alice = $this->person('Alice'); + $bob = $this->person('Bob'); + $alpha = $this->group('Alpha', $alice)->id(); + $beta = $this->group('Beta', $bob)->id(); + $conversation = (new MysqlConversationRepository($this->pdo, TestMessageCipher::make()))->create($alpha, $beta, null, $this->now, $alice->id()); + $messages = new MysqlConversationMessageRepository($this->pdo, TestMessageCipher::make()); + $messages->addMessage($conversation->id(), $alice->id(), 'Salut Bob', $this->now); + + $this->service()->delete($alice->id(), self::PASSWORD); + + $remaining = $messages->messagesOf($conversation->id()); + self::assertCount(1, $remaining); + self::assertSame('Ancien membre', $remaining[0]->authorName()); + self::assertSame('Salut Bob', $remaining[0]->body()); + } + + #[Test] + public function testPendingResetAndEmailChangeLinksStopWorking(): void + { + $alice = $this->person('Alice'); + $resets = new MysqlPasswordResetRepository($this->pdo); + $changes = new MysqlEmailChangeRepository($this->pdo); + $now = new \DateTimeImmutable('2026-10-20 10:00:00'); + $resets->create($alice->id(), ResetToken::hash('lien-reinitialisation'), $now->modify('+1 hour'), $now); + $changes->create($alice->id(), 'nouvelle@rehearsalbox.test', ResetToken::hash('lien-changement'), $now->modify('+1 hour'), $now); + + $this->service()->delete($alice->id(), self::PASSWORD); + + self::assertNull($resets->consume(ResetToken::hash('lien-reinitialisation'), $now)); + self::assertNull($changes->consume(ResetToken::hash('lien-changement'), $now)); + } + + #[Test] + public function testAWrongPasswordChangesNothingAndCountsAsAFailedAttempt(): void + { + $alice = $this->person('Alice'); + + try { + $this->service()->delete($alice->id(), 'faux'); + self::fail('le mot de passe est redemandé'); + } catch (UserValidationException $e) { + self::assertArrayHasKey('currentPassword', $e->fields()); + } + + $after = $this->users->findById($alice->id()); + self::assertSame('Alice', $after->displayName()); + self::assertTrue($after->isActive()); + self::assertSame(1, $after->failedLoginAttempts()); + self::assertSame([], $this->mailer->sent); + } + + #[Test] + public function testTheSoleManagerOfAGroupMustHandOverFirst(): void + { + $alice = $this->person('Alice'); + $bob = $this->person('Bob'); + $alpha = $this->group('Alpha', $alice, $bob)->id(); + $this->groups->removeMember($alpha, $alice->id()); + $this->groups->addMember($alpha, $alice->id(), GroupUserRole::Gestionnaire); + + try { + $this->service()->delete($alice->id(), self::PASSWORD); + self::fail('dernier gestionnaire'); + } catch (LastGroupManagerException) { + } + + self::assertTrue($this->users->findById($alice->id())->isActive(), 'rien n\'a été fait'); + self::assertCount(1, $this->groups->findByMember($alice->id())); + } + + #[Test] + public function testAnAdministratorMustBeDemotedByAnotherFirst(): void + { + $admin = $this->person('Chef', UserRole::Admin); + + $this->expectException(UserAdminRuleException::class); + $this->service()->delete($admin->id(), self::PASSWORD); + } + + #[Test] + public function testTheFormerAddressIsToldAfterwardsAndAMailFailureChangesNothing(): void + { + $alice = $this->person('Alice'); + $this->service()->delete($alice->id(), self::PASSWORD); + + self::assertCount(1, $this->mailer->sent); + self::assertSame('alice@rehearsalbox.test', $this->mailer->sent[0]->getTo()[0]->getAddress()); + + $bob = $this->person('Bob'); + $this->service(new ThrowingMailer())->delete($bob->id(), self::PASSWORD); + self::assertFalse($this->users->findById($bob->id())->isActive(), 'l\'échec d\'envoi n\'annule pas la suppression'); + } + + #[Test] + public function testAnUnknownAccountIsRefused(): void + { + $this->expectException(\App\Account\Exception\UserNotFoundException::class); + $this->service()->delete(999999, self::PASSWORD); + } +} diff --git a/tests/Mail/MailRendererTest.php b/tests/Mail/MailRendererTest.php index d94a9eca..f3af97c6 100644 --- a/tests/Mail/MailRendererTest.php +++ b/tests/Mail/MailRendererTest.php @@ -196,6 +196,20 @@ public function testEmailChangedAlertEscapesTheMaskedAddress(): void self::assertStringNotContainsString('renderer->render('account-deleted', []); + + foreach ([$mail['html'], $mail['text']] as $body) { + self::assertStringContainsString('supprimé', $body); + self::assertStringContainsString('Ancien membre', $body); + self::assertStringContainsString('administrateur', $body); + self::assertStringNotContainsString('token=', $body); + } + self::assertStringNotContainsString(' Date: Sun, 11 Oct 2026 18:58:31 +0200 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=94=92=20security(AccountDataApiContr?= =?UTF-8?q?oller):=20routes=20d'export=20et=20de=20suppression=20du=20comp?= =?UTF-8?q?te=20de=20la=20session=20(#431)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/routes.php | 3 + config/services.php | 31 ++++ .../Api/AccountDataApiController.php | 61 +++++++ .../Api/AccountDataApiControllerTest.php | 159 ++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 src/Account/Controller/Api/AccountDataApiController.php create mode 100644 tests/Account/Controller/Api/AccountDataApiControllerTest.php diff --git a/config/routes.php b/config/routes.php index f6bb43a4..3c19e34d 100644 --- a/config/routes.php +++ b/config/routes.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Account\Controller\Api\AccountApiController; +use App\Account\Controller\Api\AccountDataApiController; use App\Account\Controller\Api\AuthApiController; use App\Planning\Controller\Api\AvailabilityApiController; use App\Backup\Controller\RestoreController; @@ -68,6 +69,8 @@ ['PATCH', '/api/account/email', [AccountApiController::class, 'requestEmailChange']], ['POST', '/api/account/email/confirm', [AccountApiController::class, 'confirmEmailChange']], ['POST', '/api/auth/change-password', [AccountApiController::class, 'changePassword']], + ['GET', '/api/account/export', [AccountDataApiController::class, 'export']], + ['DELETE', '/api/account', [AccountDataApiController::class, 'delete']], ['POST', '/api/auth/secure-account', [AccountApiController::class, 'secureAccount']], ['POST', '/api/auth/logout', [AuthApiController::class, 'logout']], ['POST', '/api/auth/select-group', [AuthApiController::class, 'selectGroup']], diff --git a/config/services.php b/config/services.php index a2c56074..3d7ff90b 100644 --- a/config/services.php +++ b/config/services.php @@ -53,6 +53,11 @@ use App\Metrics\Collection\MysqlMetricsRepository; use Psr\Log\LoggerInterface; use App\Account\Controller\Api\AccountApiController; +use App\Account\Controller\Api\AccountDataApiController; +use App\Account\Repository\AccountDataRepositoryInterface; +use App\Account\Repository\MysqlAccountDataRepository; +use App\Account\Service\Data\AccountDataExporter; +use App\Account\Service\Data\AccountDeletionService; use App\Account\Controller\Api\AuthApiController; use App\Planning\Controller\Api\AvailabilityApiController; use App\Planning\Controller\AdminBookingPageController; @@ -479,6 +484,32 @@ $c->get(EmailChangeService::class), )); + // Mes données (#431) : export et suppression de MON compte (le compte est celui de la session). + $container->set(AccountDataRepositoryInterface::class, fn ($c) => new MysqlAccountDataRepository($c->get(PDO::class), $c->get(MessageCipher::class))); + $container->set(AccountDataExporter::class, fn ($c) => new AccountDataExporter( + $c->get(UserRepositoryInterface::class), + $c->get(AccountDataRepositoryInterface::class), + $c->get(ClockInterface::class), + )); + $container->set(AccountDeletionService::class, fn ($c) => new AccountDeletionService( + $c->get(UserRepositoryInterface::class), + $c->get(GroupRepositoryInterface::class), + new GroupManagerService($c->get(GroupRepositoryInterface::class), $c->get(GroupManagerRepositoryInterface::class)), + new CurrentPasswordVerifier($c->get(UserRepositoryInterface::class), $c->get(PasswordHasherInterface::class)), + $c->get(PasswordResetRepositoryInterface::class), + $c->get(EmailChangeRepositoryInterface::class), + $c->get(PasswordHasherInterface::class), + $c->get(TransactionRunner::class), + $c->get(Mailbox::class), + $c->get(ClockInterface::class), + )); + $container->set(AccountDataApiController::class, fn ($c) => new AccountDataApiController( + $c->get(AuthGuard::class), + $c->get(AuthServiceInterface::class), + $c->get(AccountDataExporter::class), + $c->get(AccountDeletionService::class), + )); + $container->set(EmailChangeRepositoryInterface::class, fn ($c) => new MysqlEmailChangeRepository($c->get(PDO::class))); $container->set(EmailChangeService::class, fn ($c) => new EmailChangeService( diff --git a/src/Account/Controller/Api/AccountDataApiController.php b/src/Account/Controller/Api/AccountDataApiController.php new file mode 100644 index 00000000..949702b7 --- /dev/null +++ b/src/Account/Controller/Api/AccountDataApiController.php @@ -0,0 +1,61 @@ +authGuard->requireLogin(); + + return new JsonResponse( + $this->exporter->export($user->id()), + 200, + ['Content-Disposition' => 'attachment; filename="rehearsalbox-mes-donnees.json"'], + ); + } + + /** Suppression de mon compte : mot de passe actuel ET mot de confirmation ; la session est fermée ensuite. */ + public function delete(Request $request): JsonResponse + { + $user = $this->authGuard->requireLogin(); + + $password = $request->body('password'); + if ($request->body('confirmation') !== self::CONFIRM_WORD) { + throw new UserValidationException(['confirmation' => 'Saisissez « ' . self::CONFIRM_WORD . ' » pour confirmer.']); + } + if (!is_string($password) || $password === '') { + throw new UserValidationException(['currentPassword' => 'Votre mot de passe est requis.']); + } + + $this->deletion->delete($user->id(), $password); + $this->authService->logout(); + + return new JsonResponse([], 204); + } +} diff --git a/tests/Account/Controller/Api/AccountDataApiControllerTest.php b/tests/Account/Controller/Api/AccountDataApiControllerTest.php new file mode 100644 index 00000000..be38a88f --- /dev/null +++ b/tests/Account/Controller/Api/AccountDataApiControllerTest.php @@ -0,0 +1,159 @@ +setUpScenario(); + $hasher = new FastPasswordHasher(); + $this->session = new InMemorySession(); + $this->auth = new AuthService($this->users, $hasher, $this->session, $this->groups); + $clock = new MockClock('2026-10-20 10:00:00'); + $this->controller = new KernelTranslation(new AccountDataApiController( + new AuthGuard($this->auth), + $this->auth, + new AccountDataExporter($this->users, new MysqlAccountDataRepository($this->pdo, TestMessageCipher::make()), $clock), + new AccountDeletionService( + $this->users, + $this->groups, + new GroupManagerService($this->groups, new MysqlGroupManagerRepository($this->pdo)), + new CurrentPasswordVerifier($this->users, $hasher), + new MysqlPasswordResetRepository($this->pdo), + new MysqlEmailChangeRepository($this->pdo), + $hasher, + new TransactionRunner($this->pdo), + TestMailbox::of(new RecordingMailer()), + $clock, + ), + )); + } + + private function signedIn(string $name): User + { + $user = $this->users->save(new User(0, strtolower($name) . '@rehearsalbox.test', (new FastPasswordHasher())->hash(self::PASSWORD), $name, UserRole::Musicien, true, 0, null)); + $this->auth->attempt($user->email(), self::PASSWORD); + + return $user; + } + + private function delete(array $body): \App\Http\Response + { + return $this->controller->delete(new Request('DELETE', '/api/account', [], $body, [])); + } + + #[Test] + public function testTheExportIsADownloadOfTheSignedInPersonOnly(): void + { + $this->user('Bob'); + $alice = $this->signedIn('Alice'); + $this->group('Alpha', $alice); + + $response = $this->controller->export(new Request('GET', '/api/account/export', [], [], [])); + + self::assertSame(200, $response->statusCode()); + self::assertStringContainsString('attachment', $response->headers()['Content-Disposition']); + $json = json_decode($response->body(), true); + self::assertSame('alice@rehearsalbox.test', $json['account']['email']); + self::assertStringNotContainsString('bob@', $response->body(), 'aucune donnée d\'une autre personne'); + } + + #[Test] + public function testAnAnonymousVisitorGetsNothing(): void + { + $this->expectException(UnauthenticatedException::class); + $this->controller->export(new Request('GET', '/api/account/export', [], [], [])); + } + + #[Test] + public function testTheAccountIsTheSessionOneWhateverTheRequestSays(): void + { + $bob = $this->user('Bob'); + $this->signedIn('Alice'); + + $response = $this->controller->export(new Request('GET', '/api/account/export', ['userId' => (string) $bob->id(), 'id' => (string) $bob->id()], ['userId' => $bob->id()], [])); + + self::assertSame('alice@rehearsalbox.test', json_decode($response->body(), true)['account']['email']); + $this->delete(['userId' => $bob->id(), 'password' => self::PASSWORD, 'confirmation' => 'SUPPRIMER']); + self::assertTrue($this->users->findById($bob->id())->isActive(), 'l\'identifiant de la requête est ignoré'); + } + + #[Test] + public function testDeletionNeedsThePasswordAndTheConfirmationWord(): void + { + $alice = $this->signedIn('Alice'); + foreach ([['confirmation' => 'SUPPRIMER'], ['password' => self::PASSWORD], ['password' => self::PASSWORD, 'confirmation' => 'supprimer'], ['password' => 12, 'confirmation' => 'SUPPRIMER']] as $body) { + self::assertSame(422, $this->delete($body)->statusCode()); + } + self::assertSame(422, $this->delete(['password' => 'faux', 'confirmation' => 'SUPPRIMER'])->statusCode()); + + self::assertTrue($this->users->findById($alice->id())->isActive()); + } + + #[Test] + public function testDeletionErasesTheAccountAndEndsTheSession(): void + { + $alice = $this->signedIn('Alice'); + + $response = $this->delete(['password' => self::PASSWORD, 'confirmation' => 'SUPPRIMER']); + + self::assertSame(204, $response->statusCode()); + self::assertSame('Ancien membre', $this->users->findById($alice->id())->displayName()); + self::assertNull($this->auth->currentUser(), 'la session est fermée'); + self::assertTrue($this->session->destroyed); + } + + #[Test] + public function testTheRulesOfAdministrationAreAnswered422WithAReadableMessage(): void + { + $admin = $this->users->save(new User(0, 'chef@rehearsalbox.test', (new FastPasswordHasher())->hash(self::PASSWORD), 'Chef', UserRole::Admin, true, 0, null)); + $this->auth->attempt($admin->email(), self::PASSWORD); + + $response = $this->delete(['password' => self::PASSWORD, 'confirmation' => 'SUPPRIMER']); + + self::assertSame(422, $response->statusCode()); + self::assertStringContainsString('administrateur', json_decode($response->body(), true)['error']); + self::assertTrue($this->users->findById($admin->id())->isActive()); + } +} From b039b1b14c97ec8fc895c8089a069f94f6e9dda5 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sun, 11 Oct 2026 18:58:31 +0200 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9C=A8=20feat(account):=20=C2=AB=20Mes?= =?UTF-8?q?=20donn=C3=A9es=20=C2=BB=20et=20=C2=AB=20Supprimer=20mon=20comp?= =?UTF-8?q?te=20=C2=BB=20en=20XHR=20sur=20la=20page=20Mon=20compte=20(#431?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/assets/js/account/auth.js | 8 ++++++++ templates/account/password.php | 22 ++++++++++++++++++++++ tests/Controller/AccountPagesTest.php | 15 +++++++++++++++ tests/View/AsyncFormMarkupTest.php | 2 +- 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/public/assets/js/account/auth.js b/public/assets/js/account/auth.js index 9363a7fc..85e0fa6a 100644 --- a/public/assets/js/account/auth.js +++ b/public/assets/js/account/auth.js @@ -75,6 +75,14 @@ export function initAuth() { form.addEventListener('async-error', (event) => showToast(event.detail.message, 'error')); }); + document.querySelectorAll('rb-async-form[endpoint="/api/account"]').forEach((form) => { + form.addEventListener('async-success', () => { + clearAllDrafts(); // plus aucun brouillon de message lisible sur cet appareil + window.location.href = '/login'; + }); + form.addEventListener('async-error', (event) => showToast(event.detail.message, 'error')); + }); + document.querySelectorAll('rb-async-form[endpoint*="/auth/secure-account"]').forEach((form) => { form.addEventListener('async-success', () => revealConfirmation(form)); form.addEventListener('async-error', (event) => showToast(event.detail.message, 'error')); diff --git a/templates/account/password.php b/templates/account/password.php index e429db6f..2eb68fdd 100644 --- a/templates/account/password.php +++ b/templates/account/password.php @@ -64,6 +64,28 @@ + +
+ +

Votre nom, votre adresse e-mail et votre mot de passe sont effacés et vous quittez vos groupes. Les messages que vous avez envoyés restent dans les conversations des autres, sous le nom « Ancien membre ». Cette action est définitive. Un gestionnaire seul dans son groupe, ou un administrateur, doit d'abord passer la main.

+
+
+ + + +
+
+ + + +
+ +
+
diff --git a/tests/Controller/AccountPagesTest.php b/tests/Controller/AccountPagesTest.php index 9dc3d61e..6ecd0c8c 100644 --- a/tests/Controller/AccountPagesTest.php +++ b/tests/Controller/AccountPagesTest.php @@ -257,4 +257,19 @@ public function testConfirmEmailPageWithoutTokenShowsAnInvalidLinkMessage(): voi self::assertStringContainsString('invalide ou incomplet', $response->body()); self::assertStringNotContainsString('body()); } + + #[Test] + public function testTheAccountPageOffersTheExportAndTheAsyncDeletionOfTheAccount(): void + { + $controller = $this->controller(); + $this->logIn(UserRole::Musicien); + + $html = $controller->accountPassword()->body(); + + self::assertStringContainsString('href="/api/account/export"', $html); + self::assertStringContainsString('', $html, 'suppression en XHR, jamais un formulaire natif'); + self::assertStringContainsString('name="confirmation"', $html); + self::assertStringContainsString('SUPPRIMER', $html); + self::assertStringContainsString('data-field-error="currentPassword"', $html); + } } diff --git a/tests/View/AsyncFormMarkupTest.php b/tests/View/AsyncFormMarkupTest.php index f87eb333..a8589794 100644 --- a/tests/View/AsyncFormMarkupTest.php +++ b/tests/View/AsyncFormMarkupTest.php @@ -50,7 +50,7 @@ public function testEveryAsyncFormComponentWrapsExactlyOneFormAndHasAnEndpoint() $total += $opened; } - self::assertSame(13, $total, 'les 13 formulaires asynchrones des gabarits'); + self::assertSame(14, $total, 'les 14 formulaires asynchrones des gabarits'); } #[Test] From ddfbebd08226a02925aab33431e8cf063d2a5898 Mon Sep 17 00:00:00 2001 From: Ronan Lenouvel Date: Sun, 11 Oct 2026 18:58:31 +0200 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=93=96=20docs(rgpd):=20inventaire=20d?= =?UTF-8?q?es=20donn=C3=A9es=20personnelles,=20dur=C3=A9es=20de=20conserva?= =?UTF-8?q?tion=20et=20droits=20(#431)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rgpd.md | 33 +++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 2 files changed, 34 insertions(+) create mode 100644 .claude/rgpd.md diff --git a/.claude/rgpd.md b/.claude/rgpd.md new file mode 100644 index 00000000..f44f5551 --- /dev/null +++ b/.claude/rgpd.md @@ -0,0 +1,33 @@ +# Données personnelles et durées de conservation (#431) + +Ce qu'RehearsalBox garde d'une personne, pourquoi, combien de temps, et ce que la personne peut faire elle-même. À tenir à jour à chaque nouvelle donnée personnelle (nouvelle colonne, nouveau journal, nouvel e-mail). + +## Inventaire et durées + +| Donnée | Où | Pourquoi | Durée de conservation | +|---|---|---|---| +| Nom affiché, adresse e-mail, rôle, état du compte | `users` | identifier la personne, la joindre, contrôler l'accès | tant que le compte existe ; **anonymisés à la suppression** (la ligne reste, « Ancien membre », adresse inutilisable) | +| Empreinte du mot de passe, compteur d'échecs, version de session | `users` | connexion sécurisée | idem ; l'empreinte est remplacée par un secret aléatoire à la suppression | +| Appartenance aux groupes et rôle | `group_user` | droits d'accès | supprimée avec le compte | +| Messages (texte), titres de conversation, anciennes versions corrigées | `conversation_*` | la messagerie entre groupes | texte **chiffré en base** (#171). Conservés tant que la conversation existe (ils appartiennent aussi aux autres participants) ; corbeille : 30 jours avant effacement définitif ; anciennes versions corrigées : 30 jours | +| Réservations libres, demandes d'échange de créneau | `free_slot_bookings`, `slot_exceptions` | gérer l'occupation du local | tant que le groupe existe ; rattachées à la ligne anonymisée après suppression du compte | +| Jetons de réinitialisation / changement d'adresse | `password_resets`, `email_changes` | opérations sécurisées | usage unique, 1 heure ; révoqués à la suppression du compte | +| Évènements de limitation (empreintes d'adresse IP et d'identifiant) | `throttle_events` | freiner les essais de connexion | 24 heures, empreintes seulement, jamais d'adresse en clair | +| Mesures d'exploitation (adresse IP **hachée** avec un secret, route, statut) | `metric_events`, `metric_hourly` | santé du site, détection d'abus | événements 30 jours (plafonnés par adresse et par jour, #442) ; agrégats 90 jours ; aucune adresse en clair | +| Journal d'erreurs applicatif | `storage/logs` (hors base) | diagnostic | rotation par taille (voir `logging` dans `config/config.php`), fichier réservé au propriétaire ; jamais de mot de passe ni de jeton | +| Documents des groupes (PDF, images) | `storage/group-documents` | partage de fiches techniques | tant que le groupe les garde ; images ré-encodées sans métadonnées (#432) | +| Sauvegardes de la base | `backups/` (hors racine web) | reprise après incident | 14 quotidiennes + 7 d'avant déploiement, puis effacées ; **une suppression de compte n'y est appliquée qu'à leur expiration** | + +## Droits exercés par la personne, depuis « Mon compte » + +- **Accès / portabilité** : « Télécharger mes données (JSON) » (`GET /api/account/export`) : profil, groupes, messages écrits (texte déchiffré), réservations et demandes. Aucun secret, aucune donnée d'autrui. +- **Effacement** : « Supprimer mon compte » (`DELETE /api/account`, mot de passe + mot SUPPRIMER). Voir `AccountDeletionService` : anonymisation, sortie des groupes, révocation des liens et des sessions, e-mail de confirmation à l'ancienne adresse. Refusé tant qu'un compte est administrateur ou dernier gestionnaire d'un groupe (il passe la main d'abord). +- **Rectification** : nom et adresse modifiables dans « Mon compte ». + +## Limites connues et décisions + +- Les messages envoyés **restent** dans les conversations des autres sous « Ancien membre » : ils font partie de leurs échanges. Une demande d'effacement des messages eux-mêmes se traite à la main (administrateur) : pas de fonction libre-service. +- Les mentions et libellés (`message_mentions.label`) gardent le nom tel qu'il était au moment du message. +- L'annuaire interne (recherche de membres, messages directs) est visible de tout membre connecté par construction ; aucune adresse e-mail n'y figure (#429). +- Les sauvegardes ne sont pas réécrites : un compte supprimé peut y subsister jusqu'à 14 jours (quotidiennes). À mentionner à la personne qui le demande. +- Aucun traceur ni service tiers côté navigateur (CSP `default-src 'self'`) ; le lieu d'hébergement et le contrat de sous-traitance relèvent du propriétaire (hors dépôt public). diff --git a/CLAUDE.md b/CLAUDE.md index 117ea374..29cfe364 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ Sommaire de référence. Lire avant toute intervention — suivre les liens pour | Layout CSS, mobile-first | [.claude/layout.md](.claude/layout.md) | | CI/CD, suivi | [.claude/cicd.md](.claude/cicd.md) | | Déploiement (WIP, hébergeur non choisi) | [.claude/deploiement.md](.claude/deploiement.md) | +| Données personnelles, durées, droits | [.claude/rgpd.md](.claude/rgpd.md) | ---