-
Notifications
You must be signed in to change notification settings - Fork 27
Expand file tree
/
Copy pathResetUserTwoFactorTest.php
More file actions
187 lines (152 loc) · 6.66 KB
/
Copy pathResetUserTwoFactorTest.php
File metadata and controls
187 lines (152 loc) · 6.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
<?php
/*
* Copyright (c) 2026 Netresearch DTT GmbH
* SPDX-License-Identifier: AGPL-3.0-only
*/
declare(strict_types=1);
namespace Tests\Controller;
use OTPHP\TOTP;
use Symfony\Component\HttpFoundation\Request;
use Tests\AbstractWebTestCase;
use function is_string;
use function json_encode;
/**
* Covers the admin break-glass reset (ADR-018 D2): POST /user/reset-2fa clears a
* target user's TOTP secret AND backup codes. ROLE_ADMIN only; a self-reset is
* refused (that path must go through the self-service, re-auth-gated flow); the
* clear is unconditional and idempotent.
*
* Requests are sent as JSON (CONTENT_TYPE application/json) to match the SPA's
* postJson + the endpoint's #[MapRequestPayload] binding.
*
* @internal
*
* @coversNothing
*/
final class ResetUserTwoFactorTest extends AbstractWebTestCase
{
private const int ADMIN_ID = 1; // unittest (ROLE_ADMIN)
private const int TARGET_ID = 2; // developer
public function testAdminResetClearsAnotherUsersTwoFactorAndBackupCodes(): void
{
// Enrol the target user (developer, id 2) in their own session.
$this->logInSession('developer');
$this->enrolCurrentUser();
self::assertNotNull($this->storedTotpSecret(self::TARGET_ID), 'precondition: the target is enrolled');
self::assertNotNull($this->storedBackupCodes(self::TARGET_ID), 'precondition: the target has backup codes');
// Switch to the admin and reset the target.
$this->logInSession('unittest');
$this->postReset(self::TARGET_ID);
$this->assertStatusCode(200);
self::assertTrue($this->jsonBody()['success'] ?? false);
self::assertNull($this->storedTotpSecret(self::TARGET_ID), 'the secret is cleared');
self::assertNull($this->storedBackupCodes(self::TARGET_ID), 'the backup codes are cleared too');
}
public function testAdminResetRemovesTheTargetsPasskeys(): void
{
// Seed a passkey for the target directly (a real WebAuthn ceremony can't
// run headless): mint the handle on the user, insert one credential row.
self::assertNotNull($this->connection);
$handle = 'aaaaaaaa-bbbb-cccc-dddd-eeeeffff0000';
$this->connection->executeStatement('UPDATE users SET webauthn_user_handle = ? WHERE id = ?', [$handle, self::TARGET_ID]);
$this->connection->executeStatement(
"INSERT INTO webauthn_credentials (public_key_credential_id, type, transports, attestation_type, trust_path, aaguid, credential_public_key, user_handle, counter, other_ui, backup_eligible, backup_status, uv_initialized)
VALUES ('dGVzdC1jcmVkZW50aWFs', 'public-key', '[]', 'none', '{}', '00000000-0000-0000-0000-000000000000', 'dGVzdC1rZXk=', ?, 0, NULL, NULL, NULL, NULL)",
[$handle],
);
self::assertSame(1, $this->passkeyCount($handle), 'precondition: the target has a passkey');
$this->logInSession('unittest');
$this->postReset(self::TARGET_ID);
$this->assertStatusCode(200);
self::assertSame(0, $this->passkeyCount($handle), 'the break-glass reset removes the passkeys');
}
public function testAdminCannotResetTheirOwnTwoFactor(): void
{
$this->logInSession('unittest');
$this->enrolCurrentUser();
self::assertNotNull($this->storedTotpSecret(self::ADMIN_ID), 'precondition: the admin is enrolled');
$this->postReset(self::ADMIN_ID);
$this->assertStatusCode(400);
self::assertNotNull($this->storedTotpSecret(self::ADMIN_ID), 'a self-reset must not clear the secret');
}
public function testResetIsIdempotentForAUserWithoutTwoFactor(): void
{
$this->logInSession('unittest');
self::assertNull($this->storedTotpSecret(self::TARGET_ID), 'precondition: no 2FA enrolled');
$this->postReset(self::TARGET_ID);
$this->assertStatusCode(200);
self::assertTrue($this->jsonBody()['success'] ?? false);
}
public function testResetUnknownUserReturns404(): void
{
$this->logInSession('unittest');
$this->postReset(999999);
$this->assertStatusCode(404);
}
public function testResetRequiresAdmin(): void
{
// A plain developer must not be able to strip another account's 2FA.
$this->logInSession('developer');
$this->postReset(self::ADMIN_ID);
$this->assertStatusCode(403);
}
/** POST /user/reset-2fa with a JSON body, as the SPA does. */
private function postReset(int $id): void
{
$this->client->request(
Request::METHOD_POST,
'/user/reset-2fa',
[],
[],
['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'],
(string) json_encode(['id' => $id]),
);
}
/** Run the real TOTP enrolment (start → confirm) for the logged-in user. */
private function enrolCurrentUser(): void
{
$this->client->request(Request::METHOD_POST, '/settings/2fa/totp/start', [], [], ['HTTP_ACCEPT' => 'application/json']);
$this->assertStatusCode(200);
$secret = $this->jsonBody()['secret'] ?? null;
if (!is_string($secret) || '' === $secret) {
self::fail('start endpoint did not return a secret');
}
$code = TOTP::createFromSecret($secret)->now();
$this->client->request(Request::METHOD_POST, '/settings/2fa/totp/confirm', ['code' => $code], [], ['HTTP_ACCEPT' => 'application/json']);
$this->assertStatusCode(200);
}
/**
* @return array<mixed, mixed>
*/
private function jsonBody(): array
{
$content = $this->client->getResponse()->getContent();
self::assertIsString($content);
$data = json_decode($content, true);
self::assertIsArray($data);
return $data;
}
private function storedTotpSecret(int $userId): ?string
{
return $this->storedColumn('totp_secret', $userId);
}
private function storedBackupCodes(int $userId): ?string
{
return $this->storedColumn('backup_codes', $userId);
}
private function storedColumn(string $column, int $userId): ?string
{
$connection = $this->connection;
self::assertNotNull($connection);
$value = $connection->fetchOne('SELECT ' . $column . ' FROM users WHERE id = ?', [$userId]);
return is_string($value) ? $value : null;
}
private function passkeyCount(string $handle): int
{
$connection = $this->connection;
self::assertNotNull($connection);
$value = $connection->fetchOne('SELECT COUNT(*) FROM webauthn_credentials WHERE user_handle = ?', [$handle]);
self::assertIsNumeric($value);
return (int) $value;
}
}