@oneuptime/common 12.0.21 → 12.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/UserTwoFactorBackupCode.ts +262 -0
- package/Server/API/UserTwoFactorBackupCodeAPI.ts +258 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.ts +63 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/Index.ts +2 -0
- package/Server/Services/UserService.ts +42 -0
- package/Server/Services/UserTwoFactorBackupCodeService.ts +355 -0
- package/Server/Utils/TwoFactorBackupCode.ts +266 -0
- package/Tests/Server/API/UserAuthenticationAPI.test.ts +16 -3
- package/Tests/Server/API/UserTwoFactorAuthAdminAPI.test.ts +18 -2
- package/Tests/Server/API/UserTwoFactorBackupCodeAPI.test.ts +1390 -0
- package/Tests/Server/Services/UserAuthenticationService.test.ts +23 -1
- package/Tests/Server/Services/UserTwoFactorAuthAdmin.test.ts +21 -0
- package/Tests/Server/Services/UserTwoFactorBackupCodeAdminSurface.test.ts +919 -0
- package/Tests/Server/Services/UserTwoFactorBackupCodeService.test.ts +862 -0
- package/Tests/Server/Utils/TwoFactorBackupCode.test.ts +475 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/UserAuthenticationStatus.ts +16 -0
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js +277 -0
- package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js.map +1 -0
- package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js +201 -0
- package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js +46 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +39 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js +327 -0
- package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js.map +1 -0
- package/build/dist/Server/Utils/TwoFactorBackupCode.js +269 -0
- package/build/dist/Server/Utils/TwoFactorBackupCode.js.map +1 -0
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
import UserService from "../../../Server/Services/UserService";
|
|
2
|
+
import UserSessionService from "../../../Server/Services/UserSessionService";
|
|
3
|
+
import UserTotpAuthService from "../../../Server/Services/UserTotpAuthService";
|
|
4
|
+
import UserTwoFactorBackupCodeService from "../../../Server/Services/UserTwoFactorBackupCodeService";
|
|
5
|
+
import UserWebAuthnService from "../../../Server/Services/UserWebAuthnService";
|
|
6
|
+
import logger from "../../../Server/Utils/Logger";
|
|
7
|
+
import User from "../../../Models/DatabaseModels/User";
|
|
8
|
+
import NotFoundException from "../../../Types/Exception/NotFoundException";
|
|
9
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
10
|
+
import PositiveNumber from "../../../Types/PositiveNumber";
|
|
11
|
+
import TwoFactorAuthStatus from "../../../Types/TwoFactorAuthStatus";
|
|
12
|
+
import UserAuthenticationStatus from "../../../Types/UserAuthenticationStatus";
|
|
13
|
+
import { getJestSpyOn } from "../../Spy";
|
|
14
|
+
import {
|
|
15
|
+
afterEach,
|
|
16
|
+
beforeEach,
|
|
17
|
+
describe,
|
|
18
|
+
expect,
|
|
19
|
+
jest,
|
|
20
|
+
test,
|
|
21
|
+
} from "@jest/globals";
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
* ---------------------------------------------------------------------------
|
|
25
|
+
* The two UserService methods that backup codes had to be threaded into:
|
|
26
|
+
* resetTwoFactorAuth and getAuthenticationStatus.
|
|
27
|
+
*
|
|
28
|
+
* Backup codes are password-equivalent credentials that live in a table
|
|
29
|
+
* neither of these methods knew about until now, and the two ways of getting
|
|
30
|
+
* the wiring wrong fail in opposite directions -- one leaves a credential
|
|
31
|
+
* alive that everybody believes is gone, the other kills an account by
|
|
32
|
+
* counting a credential twice:
|
|
33
|
+
*
|
|
34
|
+
* - resetTwoFactorAuth is the lost-device fix, and an operator presses it
|
|
35
|
+
* believing it revokes the account's second-factor material. A reset that
|
|
36
|
+
* cleared the TOTP secret and the security keys but left a printed list of
|
|
37
|
+
* recovery codes behind would have revoked NOTHING from a thief who took
|
|
38
|
+
* the phone and the paper beside it -- while reporting success, logging
|
|
39
|
+
* success, and leaving the Authentication page looking exactly as it does
|
|
40
|
+
* after a reset that worked. Nothing in the product surfaces the leftover
|
|
41
|
+
* rows; only this test does.
|
|
42
|
+
* - getAuthenticationStatus has to report the codes WITHOUT letting them
|
|
43
|
+
* count as a configured factor. Folding them into
|
|
44
|
+
* verifiedTwoFactorAuthMethodCount is the single most natural-looking edit
|
|
45
|
+
* anybody will ever make to that method, it makes the page read more
|
|
46
|
+
* "complete", and it locks people out permanently. The long comment on
|
|
47
|
+
* "backup codes do not make an account look configured" below is the whole
|
|
48
|
+
* reason this file exists.
|
|
49
|
+
*
|
|
50
|
+
* SIBLING FILES, so nothing here is duplicated:
|
|
51
|
+
*
|
|
52
|
+
* - UserTwoFactorAuthAdmin.test.ts owns resetTwoFactorAuth and
|
|
53
|
+
* setTwoFactorAuthRequired as they were BEFORE backup codes: the TOTP and
|
|
54
|
+
* WebAuthn delete shapes (LIMIT_MAX, root, no isVerified predicate), the
|
|
55
|
+
* revocation reason's ShortText bound, and the three deleted hooks. This
|
|
56
|
+
* file adds only the backup-code step and the properties that step can
|
|
57
|
+
* break.
|
|
58
|
+
* - UserAuthenticationService.test.ts owns getAuthenticationStatus's
|
|
59
|
+
* password, email-verification and reset-link fields, and the tri-state as
|
|
60
|
+
* derived from TOTP and WebAuthn alone. This file adds only
|
|
61
|
+
* unusedTwoFactorBackupCodeCount and the separation between it and the
|
|
62
|
+
* method count.
|
|
63
|
+
* - The code generation, hashing and single-use consumption of the codes
|
|
64
|
+
* themselves are not here at all; they belong to
|
|
65
|
+
* Common/Server/Utils/TwoFactorBackupCode.ts and its service.
|
|
66
|
+
*
|
|
67
|
+
* Everything stubs at each service's own public boundary -- findOneBy /
|
|
68
|
+
* updateOneById / updateBy on the UserService singleton, deleteBy and countBy
|
|
69
|
+
* on the two authenticator services, deleteAllForUser and countUnusedForUser
|
|
70
|
+
* on UserTwoFactorBackupCodeService, plus UserSessionService -- so every
|
|
71
|
+
* assertion is about what these two methods ASK for. Deliberately NOT mocked:
|
|
72
|
+
* UserService.deriveTwoFactorAuthStatus, which is a pure static and is the
|
|
73
|
+
* thing under test in the tri-state cases -- stubbing it would leave this file
|
|
74
|
+
* asserting that a mock returns what the mock was told to return. No database
|
|
75
|
+
* is involved.
|
|
76
|
+
* ---------------------------------------------------------------------------
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/*
|
|
80
|
+
* A spy plus the name to blame in a violation message, so a failure says WHICH
|
|
81
|
+
* write entry point was used rather than only that one of them was.
|
|
82
|
+
*/
|
|
83
|
+
type NamedSpy = {
|
|
84
|
+
name: string;
|
|
85
|
+
spy: any;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/*
|
|
89
|
+
* One row of the tri-state matrix: verified authenticator apps, verified
|
|
90
|
+
* security keys, unused backup codes, and the status the trio must produce.
|
|
91
|
+
* Declared as a tuple type rather than inferred so the callback's parameters
|
|
92
|
+
* can carry real types instead of `number | TwoFactorAuthStatus`.
|
|
93
|
+
*/
|
|
94
|
+
type TwoFactorStatusCase = [number, number, number, TwoFactorAuthStatus];
|
|
95
|
+
|
|
96
|
+
type BuildUserFunction = (data: {
|
|
97
|
+
id: ObjectID;
|
|
98
|
+
enableTwoFactorAuth?: boolean | undefined;
|
|
99
|
+
}) => User;
|
|
100
|
+
|
|
101
|
+
const buildUser: BuildUserFunction = (data: {
|
|
102
|
+
id: ObjectID;
|
|
103
|
+
enableTwoFactorAuth?: boolean | undefined;
|
|
104
|
+
}): User => {
|
|
105
|
+
const user: User = new User();
|
|
106
|
+
user.id = data.id;
|
|
107
|
+
|
|
108
|
+
/*
|
|
109
|
+
* Assigned only when supplied, so an omitted column is genuinely absent --
|
|
110
|
+
* the shape a `select` that did not ask for it produces -- rather than
|
|
111
|
+
* explicitly undefined.
|
|
112
|
+
*/
|
|
113
|
+
if (data.enableTwoFactorAuth !== undefined) {
|
|
114
|
+
user.enableTwoFactorAuth = data.enableTwoFactorAuth;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return user;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
describe("UserService -- the admin surfaces backup codes plug into", () => {
|
|
121
|
+
/*
|
|
122
|
+
* TWO DISTINCT IDS ON PURPOSE, exactly as in UserTwoFactorAuthAdmin.test.ts,
|
|
123
|
+
* and for this file the distinction earns its keep twice over.
|
|
124
|
+
*
|
|
125
|
+
* `userId` is what the CALLER asked about -- the id in the admin route's
|
|
126
|
+
* URL. `foundUserId` is the id on the row `findOneBy` actually resolves.
|
|
127
|
+
* `resetTwoFactorAuth` looks the row up and then keys every delete and the
|
|
128
|
+
* revocation on `user.id!`, never on `data.userId`, and the newest of those
|
|
129
|
+
* deletes destroys credentials. If the fixture made the two ids the same
|
|
130
|
+
* value -- the obvious way to write it -- then `deleteAllForUser({ userId:
|
|
131
|
+
* data.userId })` and `deleteAllForUser({ userId: user.id! })` would produce
|
|
132
|
+
* byte-identical calls, and a backup-code delete aimed at an id nobody
|
|
133
|
+
* confirmed exists would be indistinguishable from a correct one.
|
|
134
|
+
*/
|
|
135
|
+
let userId: ObjectID;
|
|
136
|
+
let foundUserId: ObjectID;
|
|
137
|
+
let findOneBySpy: any;
|
|
138
|
+
let updateOneByIdSpy: any;
|
|
139
|
+
let updateBySpy: any;
|
|
140
|
+
let revokeSessionsSpy: any;
|
|
141
|
+
let totpDeleteBySpy: any;
|
|
142
|
+
let webAuthnDeleteBySpy: any;
|
|
143
|
+
let totpCountBySpy: any;
|
|
144
|
+
let webAuthnCountBySpy: any;
|
|
145
|
+
let backupCodeDeleteAllSpy: any;
|
|
146
|
+
let backupCodeCountUnusedSpy: any;
|
|
147
|
+
let loggerInfoSpy: any;
|
|
148
|
+
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
jest.restoreAllMocks();
|
|
151
|
+
|
|
152
|
+
userId = ObjectID.generate();
|
|
153
|
+
foundUserId = ObjectID.generate();
|
|
154
|
+
|
|
155
|
+
findOneBySpy = getJestSpyOn(UserService, "findOneBy").mockImplementation(
|
|
156
|
+
async (): Promise<User | null> => {
|
|
157
|
+
return null;
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
updateOneByIdSpy = getJestSpyOn(
|
|
162
|
+
UserService,
|
|
163
|
+
"updateOneById",
|
|
164
|
+
).mockImplementation(async (): Promise<void> => {
|
|
165
|
+
return undefined;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/*
|
|
169
|
+
* The OTHER write entry point on this service. `updateOneById` is the one
|
|
170
|
+
* these methods use today, but `updateBy` is what it funnels into, so
|
|
171
|
+
* spying on both is what lets "this method writes nothing to the User row"
|
|
172
|
+
* be asserted as a fact about the service rather than about one method
|
|
173
|
+
* name.
|
|
174
|
+
*/
|
|
175
|
+
updateBySpy = getJestSpyOn(UserService, "updateBy").mockImplementation(
|
|
176
|
+
async (): Promise<number> => {
|
|
177
|
+
return 0;
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
revokeSessionsSpy = getJestSpyOn(
|
|
182
|
+
UserSessionService,
|
|
183
|
+
"revokeAllSessionsByUserId",
|
|
184
|
+
).mockImplementation(async (): Promise<void> => {
|
|
185
|
+
return undefined;
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
totpDeleteBySpy = getJestSpyOn(
|
|
189
|
+
UserTotpAuthService,
|
|
190
|
+
"deleteBy",
|
|
191
|
+
).mockImplementation(async (): Promise<number> => {
|
|
192
|
+
return 0;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
webAuthnDeleteBySpy = getJestSpyOn(
|
|
196
|
+
UserWebAuthnService,
|
|
197
|
+
"deleteBy",
|
|
198
|
+
).mockImplementation(async (): Promise<number> => {
|
|
199
|
+
return 0;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/*
|
|
203
|
+
* getAuthenticationStatus counts this user's verified authenticators to
|
|
204
|
+
* derive the tri-state. Stubbed to zero by default so every test that is
|
|
205
|
+
* not ABOUT the authenticator side keeps describing a user with nothing
|
|
206
|
+
* set up; the cases that care override them.
|
|
207
|
+
*/
|
|
208
|
+
totpCountBySpy = getJestSpyOn(
|
|
209
|
+
UserTotpAuthService,
|
|
210
|
+
"countBy",
|
|
211
|
+
).mockImplementation(async (): Promise<PositiveNumber> => {
|
|
212
|
+
return new PositiveNumber(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
webAuthnCountBySpy = getJestSpyOn(
|
|
216
|
+
UserWebAuthnService,
|
|
217
|
+
"countBy",
|
|
218
|
+
).mockImplementation(async (): Promise<PositiveNumber> => {
|
|
219
|
+
return new PositiveNumber(0);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
/*
|
|
223
|
+
* The two new dependencies, stubbed at the backup-code service's own
|
|
224
|
+
* public surface rather than at its `deleteBy` / `countBy` underneath.
|
|
225
|
+
* That boundary is the contract UserService depends on: how
|
|
226
|
+
* `deleteAllForUser` pages the delete and how `countUnusedForUser` spells
|
|
227
|
+
* "usedAt IS NULL" are that service's business, and they have their own
|
|
228
|
+
* tests. What this file is entitled to assert is that UserService calls
|
|
229
|
+
* them, for the right user, at the right moment.
|
|
230
|
+
*/
|
|
231
|
+
backupCodeDeleteAllSpy = getJestSpyOn(
|
|
232
|
+
UserTwoFactorBackupCodeService,
|
|
233
|
+
"deleteAllForUser",
|
|
234
|
+
).mockImplementation(async (): Promise<void> => {
|
|
235
|
+
return undefined;
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
backupCodeCountUnusedSpy = getJestSpyOn(
|
|
239
|
+
UserTwoFactorBackupCodeService,
|
|
240
|
+
"countUnusedForUser",
|
|
241
|
+
).mockImplementation(async (): Promise<number> => {
|
|
242
|
+
return 0;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
loggerInfoSpy = getJestSpyOn(logger, "info").mockImplementation(
|
|
246
|
+
(): void => {
|
|
247
|
+
return undefined;
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
/*
|
|
252
|
+
* The deliberate-failure cases below hand a rejection to the CaptureSpan
|
|
253
|
+
* decorator, which records it. That is correct behaviour, and it is also a
|
|
254
|
+
* stack trace on stderr for a test that passed -- silenced so a genuine
|
|
255
|
+
* error in this suite's output is still worth reading.
|
|
256
|
+
*/
|
|
257
|
+
getJestSpyOn(logger, "error").mockImplementation((): void => {
|
|
258
|
+
return undefined;
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
afterEach(() => {
|
|
263
|
+
jest.restoreAllMocks();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
type ResolveUserFunction = (user: User | null) => void;
|
|
267
|
+
|
|
268
|
+
// Makes the next (and every subsequent) findOneBy return this row.
|
|
269
|
+
const resolveUser: ResolveUserFunction = (user: User | null): void => {
|
|
270
|
+
findOneBySpy.mockImplementation(async (): Promise<User | null> => {
|
|
271
|
+
return user;
|
|
272
|
+
});
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
type FlushMicrotasksFunction = () => Promise<void>;
|
|
276
|
+
|
|
277
|
+
/*
|
|
278
|
+
* Lets every already-scheduled continuation run without advancing anything
|
|
279
|
+
* that is genuinely blocked. Used by the "is it awaited?" case: after this,
|
|
280
|
+
* a method that did NOT await its dependency has definitely finished.
|
|
281
|
+
*/
|
|
282
|
+
const flushMicrotasks: FlushMicrotasksFunction = async (): Promise<void> => {
|
|
283
|
+
await new Promise<void>((resolve: () => void) => {
|
|
284
|
+
setTimeout(resolve, 0);
|
|
285
|
+
});
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
describe("resetTwoFactorAuth -- the backup codes go with the authenticators", () => {
|
|
289
|
+
test("deletes the user's backup codes, keyed on the SAME id the authenticator deletes use", async () => {
|
|
290
|
+
/*
|
|
291
|
+
* Two facts in one place, and the second is the one a same-id fixture
|
|
292
|
+
* would hide.
|
|
293
|
+
*
|
|
294
|
+
* That the codes are deleted at all is the point of the step: a code
|
|
295
|
+
* signs somebody in on its own, so a reset that skipped them would have
|
|
296
|
+
* revoked nothing from whoever is holding the stolen device and the
|
|
297
|
+
* printed list beside it.
|
|
298
|
+
*
|
|
299
|
+
* And it must be the RESOLVED user's id, not the requested one. Every
|
|
300
|
+
* other delete in this method is keyed on `user.id!` -- the id of a row
|
|
301
|
+
* that was actually confirmed to exist -- so a backup-code delete keyed
|
|
302
|
+
* on `data.userId` would silently disagree with its two neighbours the
|
|
303
|
+
* moment the lookup ever resolves something else (a soft-delete filter,
|
|
304
|
+
* a tenancy scope, a future findOneBy overload). The failure mode is not
|
|
305
|
+
* an exception: it is a reset that clears two of the three tables for
|
|
306
|
+
* one user and none of the third, reported as a success.
|
|
307
|
+
*
|
|
308
|
+
* Compared against the TOTP delete's own target rather than only against
|
|
309
|
+
* the fixture, so the assertion states the invariant that matters --
|
|
310
|
+
* these deletes all describe one account -- instead of restating a
|
|
311
|
+
* constant.
|
|
312
|
+
*/
|
|
313
|
+
resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
|
|
314
|
+
|
|
315
|
+
await UserService.resetTwoFactorAuth({ userId: userId });
|
|
316
|
+
|
|
317
|
+
expect(backupCodeDeleteAllSpy).toHaveBeenCalledTimes(1);
|
|
318
|
+
|
|
319
|
+
const call: any = backupCodeDeleteAllSpy.mock.calls[0][0];
|
|
320
|
+
const totpTargetId: string =
|
|
321
|
+
totpDeleteBySpy.mock.calls[0][0].query.userId.toString();
|
|
322
|
+
const webAuthnTargetId: string =
|
|
323
|
+
webAuthnDeleteBySpy.mock.calls[0][0].query.userId.toString();
|
|
324
|
+
|
|
325
|
+
const violations: Array<string> = [];
|
|
326
|
+
|
|
327
|
+
if (call.userId.toString() !== foundUserId.toString()) {
|
|
328
|
+
violations.push(
|
|
329
|
+
`the backup code delete targeted ${call.userId.toString()}, not the found user ${foundUserId.toString()}`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (call.userId.toString() !== totpTargetId) {
|
|
334
|
+
violations.push(
|
|
335
|
+
"the backup code delete and the authenticator app delete targeted different users",
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (call.userId.toString() !== webAuthnTargetId) {
|
|
340
|
+
violations.push(
|
|
341
|
+
"the backup code delete and the security key delete targeted different users",
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
expect(violations).toEqual([]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("the backup code delete carries nothing but the owner", async () => {
|
|
349
|
+
/*
|
|
350
|
+
* The tempting extra predicate is `usedAt`: "only delete the codes that
|
|
351
|
+
* are still usable" reads as tidy and is wrong in both directions. Spent
|
|
352
|
+
* rows are what the profile page counts to say "3 of 10 remaining" and
|
|
353
|
+
* what a user asking "did somebody else get in?" reads a timestamp off,
|
|
354
|
+
* so a filtered delete would leave the account claiming a set it no
|
|
355
|
+
* longer has -- and would leave the reset visibly half-done to anybody
|
|
356
|
+
* who looked at the table.
|
|
357
|
+
*
|
|
358
|
+
* Asserted as the exact argument object rather than as "no usedAt key",
|
|
359
|
+
* so a filter in the other direction (deleting only the spent rows and
|
|
360
|
+
* keeping every live code) is caught by the same line.
|
|
361
|
+
*/
|
|
362
|
+
resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
|
|
363
|
+
|
|
364
|
+
await UserService.resetTwoFactorAuth({ userId: userId });
|
|
365
|
+
|
|
366
|
+
expect(backupCodeDeleteAllSpy.mock.calls[0][0]).toEqual({
|
|
367
|
+
userId: foundUserId,
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("the backup codes are gone BEFORE the sessions are revoked", async () => {
|
|
372
|
+
/*
|
|
373
|
+
* Ordering, not just presence, and for exactly the reason the two
|
|
374
|
+
* existing deletes are ordered ahead of the revocation.
|
|
375
|
+
*
|
|
376
|
+
* Revoking first opens a window between the revocation and the delete in
|
|
377
|
+
* which a still-live backup code is a WORKING second factor: whoever
|
|
378
|
+
* holds the password and the list signs in through
|
|
379
|
+
* POST /verify-backup-code, spends one code, and comes out with a fresh
|
|
380
|
+
* session on the far side of a reset that was supposed to have locked
|
|
381
|
+
* them out. The window is small and entirely real -- each delete is a
|
|
382
|
+
* round trip to Postgres -- and it is worse for codes than for the
|
|
383
|
+
* authenticators, because a code needs no device at all: a photograph of
|
|
384
|
+
* the printed list is enough.
|
|
385
|
+
*
|
|
386
|
+
* The revocation is also the LAST thing that can fail. Ordered as it is,
|
|
387
|
+
* a crash mid-reset leaves the credentials destroyed and some sessions
|
|
388
|
+
* alive, which the next attempt fixes. Ordered the other way it leaves
|
|
389
|
+
* the sessions killed and the credentials intact, which nothing fixes
|
|
390
|
+
* and nobody notices.
|
|
391
|
+
*/
|
|
392
|
+
resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
|
|
393
|
+
|
|
394
|
+
await UserService.resetTwoFactorAuth({ userId: userId });
|
|
395
|
+
|
|
396
|
+
const backupCodeOrder: number = backupCodeDeleteAllSpy.mock
|
|
397
|
+
.invocationCallOrder[0] as number;
|
|
398
|
+
const revokeOrder: number = revokeSessionsSpy.mock
|
|
399
|
+
.invocationCallOrder[0] as number;
|
|
400
|
+
const lookupOrder: number = findOneBySpy.mock
|
|
401
|
+
.invocationCallOrder[0] as number;
|
|
402
|
+
|
|
403
|
+
const violations: Array<string> = [];
|
|
404
|
+
|
|
405
|
+
if (!(backupCodeOrder < revokeOrder)) {
|
|
406
|
+
violations.push(
|
|
407
|
+
"sessions were revoked before the backup codes were deleted, leaving a live code as a working second factor",
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!(lookupOrder < backupCodeOrder)) {
|
|
412
|
+
violations.push(
|
|
413
|
+
"backup codes were deleted before the user row was confirmed to exist",
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
expect(violations).toEqual([]);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("does not resolve until the backup code delete has actually settled", async () => {
|
|
421
|
+
/*
|
|
422
|
+
* A fire-and-forget delete would still call the spy, so every assertion
|
|
423
|
+
* above would keep passing while the guarantee evaporated. The reset
|
|
424
|
+
* would answer 200, the operator would tell the user "you are safe now",
|
|
425
|
+
* and the delete could fail afterwards with nobody listening -- an
|
|
426
|
+
* unhandled rejection in a log nobody reads, and ten working recovery
|
|
427
|
+
* codes still in the table.
|
|
428
|
+
*
|
|
429
|
+
* It also matters for the ORDERING above, which is only meaningful if
|
|
430
|
+
* each step completes before the next begins: an unawaited delete
|
|
431
|
+
* running concurrently with the revocation reopens the same window this
|
|
432
|
+
* file just closed.
|
|
433
|
+
*
|
|
434
|
+
* So the await is pinned directly, by blocking the delete on a gate this
|
|
435
|
+
* test holds and checking the method has not finished.
|
|
436
|
+
*/
|
|
437
|
+
resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
|
|
438
|
+
|
|
439
|
+
let releaseDelete: () => void = (): void => {
|
|
440
|
+
return undefined;
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
const deleteGate: Promise<void> = new Promise<void>(
|
|
444
|
+
(resolve: () => void) => {
|
|
445
|
+
releaseDelete = resolve;
|
|
446
|
+
},
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
backupCodeDeleteAllSpy.mockImplementation(async (): Promise<void> => {
|
|
450
|
+
await deleteGate;
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
let settled: boolean = false;
|
|
454
|
+
|
|
455
|
+
const pending: Promise<void> = UserService.resetTwoFactorAuth({
|
|
456
|
+
userId: userId,
|
|
457
|
+
}).then((): void => {
|
|
458
|
+
settled = true;
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
await flushMicrotasks();
|
|
462
|
+
|
|
463
|
+
const violations: Array<string> = [];
|
|
464
|
+
|
|
465
|
+
if (backupCodeDeleteAllSpy.mock.calls.length !== 1) {
|
|
466
|
+
violations.push(
|
|
467
|
+
`the backup code delete was called ${backupCodeDeleteAllSpy.mock.calls.length} time(s), expected once`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (settled) {
|
|
472
|
+
violations.push(
|
|
473
|
+
"resetTwoFactorAuth resolved while the backup code delete was still in flight",
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (revokeSessionsSpy.mock.calls.length !== 0) {
|
|
478
|
+
violations.push(
|
|
479
|
+
"sessions were revoked while the backup code delete was still in flight",
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
expect(violations).toEqual([]);
|
|
484
|
+
|
|
485
|
+
releaseDelete();
|
|
486
|
+
await pending;
|
|
487
|
+
|
|
488
|
+
expect(settled).toBe(true);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("throws NotFoundException for a user that does not exist, deleting no backup codes", async () => {
|
|
492
|
+
/*
|
|
493
|
+
* `deleteAllForUser` is keyed on `userId`, so a missing row would make it
|
|
494
|
+
* a harmless no-op -- which is precisely why the check has to stay
|
|
495
|
+
* explicit rather than being left to the delete. An operator acting on a
|
|
496
|
+
* mistyped or stale id would otherwise be told the reset worked, walk
|
|
497
|
+
* away, and the person who actually needs it is still locked out holding
|
|
498
|
+
* codes they were told were void.
|
|
499
|
+
*
|
|
500
|
+
* The revocation is checked alongside because it is the one step that is
|
|
501
|
+
* NOT harmless against a wrong id: it signs out whoever that id really
|
|
502
|
+
* belongs to.
|
|
503
|
+
*/
|
|
504
|
+
resolveUser(null);
|
|
505
|
+
|
|
506
|
+
await expect(
|
|
507
|
+
UserService.resetTwoFactorAuth({ userId: userId }),
|
|
508
|
+
).rejects.toThrow(NotFoundException);
|
|
509
|
+
|
|
510
|
+
const violations: Array<string> = [];
|
|
511
|
+
|
|
512
|
+
const untouched: Array<NamedSpy> = [
|
|
513
|
+
{ name: "deleteAllForUser", spy: backupCodeDeleteAllSpy },
|
|
514
|
+
{ name: "UserTotpAuthService.deleteBy", spy: totpDeleteBySpy },
|
|
515
|
+
{ name: "UserWebAuthnService.deleteBy", spy: webAuthnDeleteBySpy },
|
|
516
|
+
{ name: "revokeAllSessionsByUserId", spy: revokeSessionsSpy },
|
|
517
|
+
];
|
|
518
|
+
|
|
519
|
+
for (const entry of untouched) {
|
|
520
|
+
if (entry.spy.mock.calls.length > 0) {
|
|
521
|
+
violations.push(`${entry.name} ran for a user that does not exist`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
expect(violations).toEqual([]);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("still does NOT touch enableTwoFactorAuth now that the codes go too", async () => {
|
|
529
|
+
/*
|
|
530
|
+
* UserTwoFactorAuthAdmin.test.ts asserts this for the method as it was
|
|
531
|
+
* before backup codes existed. It is re-asserted here, with the new
|
|
532
|
+
* delete in place, because the new delete is what makes clearing the
|
|
533
|
+
* flag look reasonable: after this method runs, the account has NO
|
|
534
|
+
* second-factor material of any kind left -- no authenticator, no
|
|
535
|
+
* security key, and now no recovery codes either -- and "there is
|
|
536
|
+
* nothing to satisfy the requirement with, so lift the requirement" is a
|
|
537
|
+
* short, sympathetic-looking edit to write directly underneath the line
|
|
538
|
+
* that deleted the codes.
|
|
539
|
+
*
|
|
540
|
+
* It is also the one edit that turns a lost phone into a silent
|
|
541
|
+
* downgrade. "Reset" means clear the configuration and KEEP the
|
|
542
|
+
* requirement: the account lands on a new QR code at its next sign-in,
|
|
543
|
+
* which is the whole point. An operator helping with a lost phone would
|
|
544
|
+
* have no reason to notice that the mandate quietly went away.
|
|
545
|
+
*
|
|
546
|
+
* Asserted as "no user update at all" through BOTH write entry points,
|
|
547
|
+
* which is the stronger and simpler fact: this method has no business
|
|
548
|
+
* writing to the User row through any of them.
|
|
549
|
+
*/
|
|
550
|
+
resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
|
|
551
|
+
|
|
552
|
+
await UserService.resetTwoFactorAuth({ userId: userId });
|
|
553
|
+
|
|
554
|
+
const writeEntryPoints: Array<NamedSpy> = [
|
|
555
|
+
{ name: "updateOneById", spy: updateOneByIdSpy },
|
|
556
|
+
{ name: "updateBy", spy: updateBySpy },
|
|
557
|
+
];
|
|
558
|
+
|
|
559
|
+
const violations: Array<string> = [];
|
|
560
|
+
|
|
561
|
+
for (const entryPoint of writeEntryPoints) {
|
|
562
|
+
for (const call of entryPoint.spy.mock.calls) {
|
|
563
|
+
const keys: Array<string> = Object.keys(call[0]?.data || {});
|
|
564
|
+
|
|
565
|
+
violations.push(
|
|
566
|
+
`resetTwoFactorAuth wrote to the User row via ${entryPoint.name} with data keys [${keys.join(", ")}]`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
expect(violations).toEqual([]);
|
|
572
|
+
|
|
573
|
+
/*
|
|
574
|
+
* ...and the backup codes really were part of this run, so the above is
|
|
575
|
+
* a statement about the new method rather than about the old one.
|
|
576
|
+
*/
|
|
577
|
+
expect(backupCodeDeleteAllSpy).toHaveBeenCalledTimes(1);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test("is a successful no-op for a user who never generated any codes", async () => {
|
|
581
|
+
/*
|
|
582
|
+
* The operator pressing this button does not know what the user has set
|
|
583
|
+
* up -- that is why they are on the page -- and most accounts have no
|
|
584
|
+
* backup codes at all. Refusing, or short-circuiting the rest of the
|
|
585
|
+
* reset, because there was nothing to delete would turn the ordinary
|
|
586
|
+
* case into an error the operator has to interpret, on the one page
|
|
587
|
+
* where interpreting an error wrongly means leaving somebody locked out.
|
|
588
|
+
*/
|
|
589
|
+
resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
|
|
590
|
+
|
|
591
|
+
await expect(
|
|
592
|
+
UserService.resetTwoFactorAuth({ userId: userId }),
|
|
593
|
+
).resolves.toBeUndefined();
|
|
594
|
+
|
|
595
|
+
expect(backupCodeDeleteAllSpy).toHaveBeenCalledTimes(1);
|
|
596
|
+
expect(revokeSessionsSpy).toHaveBeenCalledTimes(1);
|
|
597
|
+
expect(loggerInfoSpy.mock.calls.length).toBeGreaterThan(0);
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
describe("getAuthenticationStatus -- reporting the codes without counting them", () => {
|
|
602
|
+
type StatusForFunction = () => Promise<UserAuthenticationStatus>;
|
|
603
|
+
|
|
604
|
+
const statusFor: StatusForFunction =
|
|
605
|
+
async (): Promise<UserAuthenticationStatus> => {
|
|
606
|
+
return await UserService.getAuthenticationStatus(userId);
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
test("reports unusedTwoFactorBackupCodeCount straight from countUnusedForUser", async () => {
|
|
610
|
+
/*
|
|
611
|
+
* The number an operator reads off a lost-phone ticket to decide whether
|
|
612
|
+
* they are needed at all. A user with codes left should be told to use
|
|
613
|
+
* one; only a user with none needs the reset button, which signs them
|
|
614
|
+
* out everywhere and marches them through enrolment. Reporting a
|
|
615
|
+
* hard-coded zero, or the TOTAL rather than the UNUSED count, points the
|
|
616
|
+
* operator at the wrong half of that decision -- and the second mistake
|
|
617
|
+
* is invisible, because a user who has spent all ten codes and a user
|
|
618
|
+
* holding ten both have a total of ten.
|
|
619
|
+
*/
|
|
620
|
+
resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
|
|
621
|
+
|
|
622
|
+
backupCodeCountUnusedSpy.mockResolvedValue(7 as never);
|
|
623
|
+
|
|
624
|
+
const status: UserAuthenticationStatus = await statusFor();
|
|
625
|
+
|
|
626
|
+
expect(backupCodeCountUnusedSpy).toHaveBeenCalledTimes(1);
|
|
627
|
+
expect(status.unusedTwoFactorBackupCodeCount).toBe(7);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("the backup code count describes the same user as the method counts", async () => {
|
|
631
|
+
/*
|
|
632
|
+
* Three probes go out for one page -- verified TOTP rows, verified
|
|
633
|
+
* security keys, unused backup codes -- and they must all be about the
|
|
634
|
+
* same account. A page that mixed one user's authenticators with
|
|
635
|
+
* another's recovery codes would be a coherent-looking screen that is
|
|
636
|
+
* true of nobody, and the operator's next click on it revokes or resets
|
|
637
|
+
* somebody's access.
|
|
638
|
+
*
|
|
639
|
+
* Written as a comparison between the three calls rather than against
|
|
640
|
+
* the fixture, because that is the invariant: whichever id this method
|
|
641
|
+
* decides to key its reads on, it has to use one id.
|
|
642
|
+
*/
|
|
643
|
+
resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
|
|
644
|
+
|
|
645
|
+
await statusFor();
|
|
646
|
+
|
|
647
|
+
const totpProbeId: string =
|
|
648
|
+
totpCountBySpy.mock.calls[0][0].query.userId.toString();
|
|
649
|
+
const webAuthnProbeId: string =
|
|
650
|
+
webAuthnCountBySpy.mock.calls[0][0].query.userId.toString();
|
|
651
|
+
const backupProbeId: string =
|
|
652
|
+
backupCodeCountUnusedSpy.mock.calls[0][0].userId.toString();
|
|
653
|
+
|
|
654
|
+
const violations: Array<string> = [];
|
|
655
|
+
|
|
656
|
+
if (backupProbeId !== totpProbeId) {
|
|
657
|
+
violations.push(
|
|
658
|
+
`the backup code count is about ${backupProbeId} but the authenticator app count is about ${totpProbeId}`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (backupProbeId !== webAuthnProbeId) {
|
|
663
|
+
violations.push(
|
|
664
|
+
`the backup code count is about ${backupProbeId} but the security key count is about ${webAuthnProbeId}`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
expect(violations).toEqual([]);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("backup codes do NOT make an account look configured", async () => {
|
|
672
|
+
/*
|
|
673
|
+
* -------------------------------------------------------------------
|
|
674
|
+
* THE ONE THAT MATTERS, and the reason this whole file exists.
|
|
675
|
+
*
|
|
676
|
+
* The account here is the exact shape a reset leaves behind, or that a
|
|
677
|
+
* user creates by generating codes before enrolling: two factor auth
|
|
678
|
+
* REQUIRED, zero verified authenticators, ten unused backup codes.
|
|
679
|
+
*
|
|
680
|
+
* The edit this test forbids is one line long and looks like a bug fix.
|
|
681
|
+
* `verifiedTwoFactorAuthMethodCount` is described as "how many ways can
|
|
682
|
+
* this person satisfy the second factor", and a backup code manifestly
|
|
683
|
+
* IS one of those ways -- so adding the code count to it makes the
|
|
684
|
+
* Authentication page stop saying "Enabled - Pending Setup" about
|
|
685
|
+
* somebody who is visibly holding ten working credentials. The page
|
|
686
|
+
* reads better afterwards. Everything still renders. No test that only
|
|
687
|
+
* checks fields in isolation would notice.
|
|
688
|
+
*
|
|
689
|
+
* What it actually does is lock the account out for good.
|
|
690
|
+
*
|
|
691
|
+
* That count feeds `deriveTwoFactorAuthStatus`, and the status decides
|
|
692
|
+
* which door login opens. Non-zero means EnabledConfigured, and
|
|
693
|
+
* EnabledConfigured means /login sends the user to the two factor
|
|
694
|
+
* CHALLENGE screen instead of through enrolment. The challenge screen
|
|
695
|
+
* asks for a code from an authenticator that does not exist. The only
|
|
696
|
+
* thing the user can answer it with is a backup code -- so they spend
|
|
697
|
+
* one, get in, and land in a product that offers them no enrolment step
|
|
698
|
+
* because the server believes they are already configured. Next
|
|
699
|
+
* sign-in: another code. Ten sign-ins later there are none left, the
|
|
700
|
+
* challenge screen has nothing behind it at all, and the account needs
|
|
701
|
+
* an administrator -- who is the person this page was built for, now
|
|
702
|
+
* looking at a page that told them everything was fine.
|
|
703
|
+
*
|
|
704
|
+
* Backup codes are the way BACK IN to an account that has a factor it
|
|
705
|
+
* cannot currently reach. They are not the factor. The two numbers stay
|
|
706
|
+
* separate so that the page can say both things at once: "cannot sign in
|
|
707
|
+
* unaided" AND "has ten codes to sign in with", which is precisely the
|
|
708
|
+
* pair an operator needs and precisely the pair that a single summed
|
|
709
|
+
* count destroys.
|
|
710
|
+
*
|
|
711
|
+
* All three fields are pinned together, because the bug shows up as a
|
|
712
|
+
* DISAGREEMENT between them rather than in any one of them: ten codes
|
|
713
|
+
* reported correctly, and a method count that quietly includes them.
|
|
714
|
+
* -------------------------------------------------------------------
|
|
715
|
+
*/
|
|
716
|
+
resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
|
|
717
|
+
|
|
718
|
+
backupCodeCountUnusedSpy.mockResolvedValue(10 as never);
|
|
719
|
+
|
|
720
|
+
const status: UserAuthenticationStatus = await statusFor();
|
|
721
|
+
|
|
722
|
+
const violations: Array<string> = [];
|
|
723
|
+
|
|
724
|
+
if (status.verifiedTwoFactorAuthMethodCount !== 0) {
|
|
725
|
+
violations.push(
|
|
726
|
+
`ten backup codes were counted as ${status.verifiedTwoFactorAuthMethodCount} verified two factor method(s); login would send this account to a challenge screen it cannot answer`,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (
|
|
731
|
+
status.twoFactorAuthStatus !== TwoFactorAuthStatus.EnabledPendingSetup
|
|
732
|
+
) {
|
|
733
|
+
violations.push(
|
|
734
|
+
`an account with no verified factor and ten backup codes reported ${status.twoFactorAuthStatus} instead of ${TwoFactorAuthStatus.EnabledPendingSetup}; it would never be offered enrolment again`,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (status.unusedTwoFactorBackupCodeCount !== 10) {
|
|
739
|
+
violations.push(
|
|
740
|
+
`the ten backup codes were reported as ${status.unusedTwoFactorBackupCodeCount}; the operator cannot tell whether this user can recover unaided`,
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// The requirement itself is untouched by any of this.
|
|
745
|
+
if (status.isTwoFactorAuthEnabled !== true) {
|
|
746
|
+
violations.push(
|
|
747
|
+
"the two factor auth requirement was reported as off for an account that has it on",
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
expect(violations).toEqual([]);
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
/*
|
|
755
|
+
* The same invariant swept across the range, so it cannot be satisfied by
|
|
756
|
+
* a special case at ten, and in BOTH directions: codes must not promote
|
|
757
|
+
* EnabledPendingSetup to EnabledConfigured, and must not be needed to
|
|
758
|
+
* reach EnabledConfigured either. The rows with a verified factor AND
|
|
759
|
+
* codes are the ordinary healthy account -- if a summed count were ever
|
|
760
|
+
* introduced they would still pass, which is why the zero-factor rows
|
|
761
|
+
* above them are the load-bearing ones.
|
|
762
|
+
*/
|
|
763
|
+
const twoFactorStatusCases: Array<TwoFactorStatusCase> = [
|
|
764
|
+
[0, 0, 0, TwoFactorAuthStatus.EnabledPendingSetup],
|
|
765
|
+
[0, 0, 1, TwoFactorAuthStatus.EnabledPendingSetup],
|
|
766
|
+
[0, 0, 3, TwoFactorAuthStatus.EnabledPendingSetup],
|
|
767
|
+
[0, 0, 10, TwoFactorAuthStatus.EnabledPendingSetup],
|
|
768
|
+
[1, 0, 0, TwoFactorAuthStatus.EnabledConfigured],
|
|
769
|
+
[1, 0, 10, TwoFactorAuthStatus.EnabledConfigured],
|
|
770
|
+
[0, 1, 10, TwoFactorAuthStatus.EnabledConfigured],
|
|
771
|
+
[2, 1, 10, TwoFactorAuthStatus.EnabledConfigured],
|
|
772
|
+
];
|
|
773
|
+
|
|
774
|
+
test.each(twoFactorStatusCases)(
|
|
775
|
+
"%p verified apps + %p verified keys + %p unused backup codes reads as %s",
|
|
776
|
+
async (
|
|
777
|
+
verifiedTotpCount: number,
|
|
778
|
+
verifiedWebAuthnCount: number,
|
|
779
|
+
unusedBackupCodeCount: number,
|
|
780
|
+
expectedStatus: TwoFactorAuthStatus,
|
|
781
|
+
): Promise<void> => {
|
|
782
|
+
resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
|
|
783
|
+
|
|
784
|
+
totpCountBySpy.mockResolvedValue(
|
|
785
|
+
new PositiveNumber(verifiedTotpCount) as never,
|
|
786
|
+
);
|
|
787
|
+
webAuthnCountBySpy.mockResolvedValue(
|
|
788
|
+
new PositiveNumber(verifiedWebAuthnCount) as never,
|
|
789
|
+
);
|
|
790
|
+
backupCodeCountUnusedSpy.mockResolvedValue(
|
|
791
|
+
unusedBackupCodeCount as never,
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
const status: UserAuthenticationStatus = await statusFor();
|
|
795
|
+
|
|
796
|
+
const violations: Array<string> = [];
|
|
797
|
+
|
|
798
|
+
if (status.twoFactorAuthStatus !== expectedStatus) {
|
|
799
|
+
violations.push(
|
|
800
|
+
`status was ${status.twoFactorAuthStatus}, expected ${expectedStatus}`,
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (
|
|
805
|
+
status.verifiedTwoFactorAuthMethodCount !==
|
|
806
|
+
verifiedTotpCount + verifiedWebAuthnCount
|
|
807
|
+
) {
|
|
808
|
+
violations.push(
|
|
809
|
+
`the verified method count was ${status.verifiedTwoFactorAuthMethodCount}, expected ${verifiedTotpCount + verifiedWebAuthnCount} -- the backup codes leaked into it`,
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (status.unusedTwoFactorBackupCodeCount !== unusedBackupCodeCount) {
|
|
814
|
+
violations.push(
|
|
815
|
+
`the unused backup code count was ${status.unusedTwoFactorBackupCodeCount}, expected ${unusedBackupCodeCount}`,
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
expect(violations).toEqual([]);
|
|
820
|
+
},
|
|
821
|
+
);
|
|
822
|
+
|
|
823
|
+
test("codes on an account with the requirement OFF still read NotEnabled", async () => {
|
|
824
|
+
/*
|
|
825
|
+
* Generating backup codes is deliberately NOT gated on
|
|
826
|
+
* `enableTwoFactorAuth` -- doing it before turning two factor auth on is
|
|
827
|
+
* the sensible order -- so "has codes, has no requirement" is a state
|
|
828
|
+
* the product creates on purpose, not a corruption.
|
|
829
|
+
*
|
|
830
|
+
* It must not read as two factor auth being on. An operator told
|
|
831
|
+
* "Enabled" about an account that login waves straight through on a
|
|
832
|
+
* password alone has been told the opposite of the truth about the only
|
|
833
|
+
* thing this page exists to report, and would have no reason to ask why
|
|
834
|
+
* a compromise happened without a second factor being involved.
|
|
835
|
+
*/
|
|
836
|
+
resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: false }));
|
|
837
|
+
|
|
838
|
+
backupCodeCountUnusedSpy.mockResolvedValue(10 as never);
|
|
839
|
+
|
|
840
|
+
const status: UserAuthenticationStatus = await statusFor();
|
|
841
|
+
|
|
842
|
+
const violations: Array<string> = [];
|
|
843
|
+
|
|
844
|
+
if (status.twoFactorAuthStatus !== TwoFactorAuthStatus.NotEnabled) {
|
|
845
|
+
violations.push(
|
|
846
|
+
`an account with ten backup codes and no requirement reported ${status.twoFactorAuthStatus}`,
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (status.isTwoFactorAuthEnabled !== false) {
|
|
851
|
+
violations.push(
|
|
852
|
+
"backup codes made isTwoFactorAuthEnabled read true for an account with the requirement off",
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (status.unusedTwoFactorBackupCodeCount !== 10) {
|
|
857
|
+
violations.push(
|
|
858
|
+
"the codes an unrequired account holds were not reported at all",
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
expect(violations).toEqual([]);
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
test("throws NotFoundException for a user that does not exist, counting nothing", async () => {
|
|
866
|
+
/*
|
|
867
|
+
* Returning a status object full of zeroes for an id that does not exist
|
|
868
|
+
* would render an ordinary-looking page describing nobody, and
|
|
869
|
+
* "unusedTwoFactorBackupCodeCount: 0" is the reading that sends an
|
|
870
|
+
* operator straight to the reset button. They have to be told, not shown
|
|
871
|
+
* defaults.
|
|
872
|
+
*
|
|
873
|
+
* The count is asserted as not-called rather than merely not-reported,
|
|
874
|
+
* because a probe issued before the row is confirmed is a query per
|
|
875
|
+
* mistyped id on an endpoint an operator can hold down.
|
|
876
|
+
*/
|
|
877
|
+
resolveUser(null);
|
|
878
|
+
|
|
879
|
+
await expect(UserService.getAuthenticationStatus(userId)).rejects.toThrow(
|
|
880
|
+
NotFoundException,
|
|
881
|
+
);
|
|
882
|
+
|
|
883
|
+
const violations: Array<string> = [];
|
|
884
|
+
|
|
885
|
+
const probes: Array<NamedSpy> = [
|
|
886
|
+
{ name: "countUnusedForUser", spy: backupCodeCountUnusedSpy },
|
|
887
|
+
{ name: "UserTotpAuthService.countBy", spy: totpCountBySpy },
|
|
888
|
+
{ name: "UserWebAuthnService.countBy", spy: webAuthnCountBySpy },
|
|
889
|
+
];
|
|
890
|
+
|
|
891
|
+
for (const probe of probes) {
|
|
892
|
+
if (probe.spy.mock.calls.length > 0) {
|
|
893
|
+
violations.push(
|
|
894
|
+
`${probe.name} was queried for a user that does not exist`,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
expect(violations).toEqual([]);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test("reading the status deletes nothing", async () => {
|
|
903
|
+
/*
|
|
904
|
+
* A read path, asserted as one. `getAuthenticationStatus` and
|
|
905
|
+
* `resetTwoFactorAuth` are the two halves of the same admin page and
|
|
906
|
+
* they now share a dependency, so this pins the direction of that
|
|
907
|
+
* dependency: the page an operator opens to LOOK at an account must
|
|
908
|
+
* never be the thing that voids its recovery codes.
|
|
909
|
+
*/
|
|
910
|
+
resolveUser(buildUser({ id: foundUserId, enableTwoFactorAuth: true }));
|
|
911
|
+
|
|
912
|
+
await statusFor();
|
|
913
|
+
|
|
914
|
+
expect(backupCodeDeleteAllSpy).not.toHaveBeenCalled();
|
|
915
|
+
expect(updateOneByIdSpy).not.toHaveBeenCalled();
|
|
916
|
+
expect(revokeSessionsSpy).not.toHaveBeenCalled();
|
|
917
|
+
});
|
|
918
|
+
});
|
|
919
|
+
});
|