@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,862 @@
|
|
|
1
|
+
import UserTwoFactorBackupCodeService, {
|
|
2
|
+
TwoFactorBackupCodeStatus,
|
|
3
|
+
} from "../../../Server/Services/UserTwoFactorBackupCodeService";
|
|
4
|
+
import TwoFactorBackupCode, {
|
|
5
|
+
BackupCodeSetSize,
|
|
6
|
+
} from "../../../Server/Utils/TwoFactorBackupCode";
|
|
7
|
+
import UserTwoFactorBackupCode from "../../../Models/DatabaseModels/UserTwoFactorBackupCode";
|
|
8
|
+
import LIMIT_MAX from "../../../Types/Database/LimitMax";
|
|
9
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
10
|
+
import PositiveNumber from "../../../Types/PositiveNumber";
|
|
11
|
+
import BadDataException from "../../../Types/Exception/BadDataException";
|
|
12
|
+
import CreateBy from "../../../Server/Types/Database/CreateBy";
|
|
13
|
+
import { FindOperator } from "typeorm";
|
|
14
|
+
import { getJestSpyOn } from "../../Spy";
|
|
15
|
+
import {
|
|
16
|
+
afterEach,
|
|
17
|
+
beforeEach,
|
|
18
|
+
describe,
|
|
19
|
+
expect,
|
|
20
|
+
jest,
|
|
21
|
+
test,
|
|
22
|
+
} from "@jest/globals";
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
* ---------------------------------------------------------------------------
|
|
26
|
+
* UserTwoFactorBackupCodeService -- minting, spending and counting a user's
|
|
27
|
+
* recovery codes.
|
|
28
|
+
*
|
|
29
|
+
* WHAT THIS FILE IS GUARDING
|
|
30
|
+
*
|
|
31
|
+
* "Single use" is the ONLY promise a backup code makes beyond being a secret.
|
|
32
|
+
* A code that can be spent twice is a code that keeps working after the user
|
|
33
|
+
* has watched somebody use it, and the obvious implementation -- find the row,
|
|
34
|
+
* check `usedAt`, then update it -- gets that wrong under exactly the
|
|
35
|
+
* conditions an attacker would arrange. `consumeCode` therefore does the whole
|
|
36
|
+
* thing in ONE conditional UPDATE, and the assertions below are about the
|
|
37
|
+
* shape of that statement rather than about a value it returns, because the
|
|
38
|
+
* atomicity lives in the SQL and nowhere else.
|
|
39
|
+
*
|
|
40
|
+
* The second thing guarded here is regeneration. It must REPLACE, never add:
|
|
41
|
+
* a user who regenerates after losing a printed list has to know the lost list
|
|
42
|
+
* is dead, and "generate ten more" leaves it alive.
|
|
43
|
+
*
|
|
44
|
+
* WHAT IS MOCKED, AND WHAT IS NOT
|
|
45
|
+
*
|
|
46
|
+
* The database is. `getRepository` is stubbed down to a `manager.query` spy so
|
|
47
|
+
* the exact SQL and its bound parameters can be inspected -- there is no
|
|
48
|
+
* Postgres in this suite, and the statement is the thing under test.
|
|
49
|
+
*
|
|
50
|
+
* TwoFactorBackupCode is NOT mocked. The digests written by regeneration are
|
|
51
|
+
* checked against the real hashing function, because a service that stored
|
|
52
|
+
* something other than the digest the login path recomputes would produce a
|
|
53
|
+
* set of codes that has never worked and cannot be told apart from a set that
|
|
54
|
+
* has all been used.
|
|
55
|
+
*
|
|
56
|
+
* The crypto itself is covered by
|
|
57
|
+
* Common/Tests/Server/Utils/TwoFactorBackupCode.test.ts; the admin surfaces
|
|
58
|
+
* that call into this service are covered by
|
|
59
|
+
* Common/Tests/Server/Services/UserTwoFactorBackupCodeAdminSurface.test.ts.
|
|
60
|
+
* ---------------------------------------------------------------------------
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
const USER_ID: ObjectID = new ObjectID("33333333-3333-4333-8333-333333333333");
|
|
64
|
+
|
|
65
|
+
/*
|
|
66
|
+
* The postgres driver hands back `[rows, rowCount]` for an UPDATE rather than
|
|
67
|
+
* a bare row array. Every stub here reproduces that shape, because getting it
|
|
68
|
+
* wrong is precisely how a consumed code could read as "not consumed" (or, far
|
|
69
|
+
* worse, the other way round).
|
|
70
|
+
*/
|
|
71
|
+
type UpdateResultFunction = (rowCount: number) => Array<unknown>;
|
|
72
|
+
|
|
73
|
+
const updateResult: UpdateResultFunction = (
|
|
74
|
+
rowCount: number,
|
|
75
|
+
): Array<unknown> => {
|
|
76
|
+
const rows: Array<{ _id: string }> = [];
|
|
77
|
+
|
|
78
|
+
for (let index: number = 0; index < rowCount; index++) {
|
|
79
|
+
rows.push({ _id: ObjectID.generate().toString() });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return [rows, rowCount];
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/*
|
|
86
|
+
* Deliberately `any`. The Common suite type-checks its tests (unlike the App
|
|
87
|
+
* suite, which transpiles only), and jest's own `Mock` generic does not line
|
|
88
|
+
* up with the loosely typed `mock.calls` reads below.
|
|
89
|
+
*/
|
|
90
|
+
let queryMock: any;
|
|
91
|
+
let createSpy: any;
|
|
92
|
+
let countBySpy: any;
|
|
93
|
+
let findOneBySpy: any;
|
|
94
|
+
let deleteBySpy: any;
|
|
95
|
+
|
|
96
|
+
/* Every row `create` was asked to write, in order. */
|
|
97
|
+
let createdRows: Array<UserTwoFactorBackupCode> = [];
|
|
98
|
+
|
|
99
|
+
type StubQueryFunction = (result: unknown) => void;
|
|
100
|
+
|
|
101
|
+
const stubQuery: StubQueryFunction = (result: unknown): void => {
|
|
102
|
+
queryMock.mockResolvedValue(result as never);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
jest.restoreAllMocks();
|
|
107
|
+
|
|
108
|
+
createdRows = [];
|
|
109
|
+
|
|
110
|
+
queryMock = jest.fn();
|
|
111
|
+
stubQuery(updateResult(0));
|
|
112
|
+
|
|
113
|
+
getJestSpyOn(UserTwoFactorBackupCodeService, "getRepository").mockReturnValue(
|
|
114
|
+
{
|
|
115
|
+
manager: {
|
|
116
|
+
query: (...args: Array<unknown>): unknown => {
|
|
117
|
+
return queryMock(...args);
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
} as never,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
createSpy = getJestSpyOn(
|
|
124
|
+
UserTwoFactorBackupCodeService,
|
|
125
|
+
"create",
|
|
126
|
+
).mockImplementation(async (input: any): Promise<UserTwoFactorBackupCode> => {
|
|
127
|
+
createdRows.push(input.data as UserTwoFactorBackupCode);
|
|
128
|
+
return input.data as UserTwoFactorBackupCode;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
countBySpy = getJestSpyOn(
|
|
132
|
+
UserTwoFactorBackupCodeService,
|
|
133
|
+
"countBy",
|
|
134
|
+
).mockImplementation(async (): Promise<PositiveNumber> => {
|
|
135
|
+
return new PositiveNumber(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
findOneBySpy = getJestSpyOn(
|
|
139
|
+
UserTwoFactorBackupCodeService,
|
|
140
|
+
"findOneBy",
|
|
141
|
+
).mockImplementation(async (): Promise<UserTwoFactorBackupCode | null> => {
|
|
142
|
+
return null;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
deleteBySpy = getJestSpyOn(
|
|
146
|
+
UserTwoFactorBackupCodeService,
|
|
147
|
+
"deleteBy",
|
|
148
|
+
).mockImplementation(async (): Promise<number> => {
|
|
149
|
+
return 0;
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
jest.restoreAllMocks();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("regenerateForUser -- minting a set", () => {
|
|
158
|
+
test("returns a full set of plaintext codes", async () => {
|
|
159
|
+
const codes: Array<string> =
|
|
160
|
+
await UserTwoFactorBackupCodeService.regenerateForUser({
|
|
161
|
+
userId: USER_ID,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(codes).toHaveLength(BackupCodeSetSize);
|
|
165
|
+
expect(new Set(codes).size).toBe(BackupCodeSetSize);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("honours an explicit count", async () => {
|
|
169
|
+
const codes: Array<string> =
|
|
170
|
+
await UserTwoFactorBackupCodeService.regenerateForUser({
|
|
171
|
+
userId: USER_ID,
|
|
172
|
+
count: 4,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
expect(codes).toHaveLength(4);
|
|
176
|
+
expect(createSpy).toHaveBeenCalledTimes(4);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/*
|
|
180
|
+
* THE ONE THAT MATTERS MOST. If the row held anything other than the digest
|
|
181
|
+
* the login path recomputes -- the plaintext, a differently keyed hash, a
|
|
182
|
+
* digest computed for the wrong user -- then every code in the set would be
|
|
183
|
+
* rejected at sign-in, and the user would find out at the worst possible
|
|
184
|
+
* moment: locked out, holding a list they were told to trust.
|
|
185
|
+
*/
|
|
186
|
+
test("stores the digest the login path will recompute, never the code", async () => {
|
|
187
|
+
const codes: Array<string> =
|
|
188
|
+
await UserTwoFactorBackupCodeService.regenerateForUser({
|
|
189
|
+
userId: USER_ID,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const storedHashes: Array<string> = createdRows.map(
|
|
193
|
+
(row: UserTwoFactorBackupCode) => {
|
|
194
|
+
return row.codeHash || "";
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const expectedHashes: Array<string> = codes.map((code: string) => {
|
|
199
|
+
return TwoFactorBackupCode.hashCode({ code: code, userId: USER_ID });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
expect(storedHashes).toEqual(expectedHashes);
|
|
203
|
+
|
|
204
|
+
const leaked: Array<string> = codes.filter((code: string) => {
|
|
205
|
+
return storedHashes.includes(code);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(leaked).toEqual([]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("owns every row to the user it was asked for", async () => {
|
|
212
|
+
await UserTwoFactorBackupCodeService.regenerateForUser({
|
|
213
|
+
userId: USER_ID,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const violations: Array<string> = [];
|
|
217
|
+
|
|
218
|
+
for (const row of createdRows) {
|
|
219
|
+
if (row.userId?.toString() !== USER_ID.toString()) {
|
|
220
|
+
violations.push(String(row.userId));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
expect(violations).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("mints every row unused", async () => {
|
|
228
|
+
await UserTwoFactorBackupCodeService.regenerateForUser({
|
|
229
|
+
userId: USER_ID,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const spent: Array<unknown> = createdRows
|
|
233
|
+
.map((row: UserTwoFactorBackupCode) => {
|
|
234
|
+
return row.usedAt;
|
|
235
|
+
})
|
|
236
|
+
.filter((usedAt: Date | undefined) => {
|
|
237
|
+
return Boolean(usedAt);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(spent).toEqual([]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
/*
|
|
244
|
+
* REPLACE, not add. A user regenerating after losing a printed list is
|
|
245
|
+
* telling us that list may be in somebody else's hands; leaving those codes
|
|
246
|
+
* alive would make the button they pressed a no-op against the exact threat
|
|
247
|
+
* they pressed it for.
|
|
248
|
+
*/
|
|
249
|
+
test("deletes the previous set before writing the new one", async () => {
|
|
250
|
+
await UserTwoFactorBackupCodeService.regenerateForUser({
|
|
251
|
+
userId: USER_ID,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(deleteBySpy).toHaveBeenCalledTimes(1);
|
|
255
|
+
|
|
256
|
+
const deleteOrder: number = deleteBySpy.mock.invocationCallOrder[0]!;
|
|
257
|
+
const firstCreateOrder: number = createSpy.mock.invocationCallOrder[0]!;
|
|
258
|
+
|
|
259
|
+
expect(deleteOrder).toBeLessThan(firstCreateOrder);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("deletes only this user's codes, and does not cap below the set size", async () => {
|
|
263
|
+
await UserTwoFactorBackupCodeService.regenerateForUser({
|
|
264
|
+
userId: USER_ID,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(deleteBySpy).toHaveBeenCalledWith(
|
|
268
|
+
expect.objectContaining({
|
|
269
|
+
query: { userId: USER_ID },
|
|
270
|
+
limit: LIMIT_MAX,
|
|
271
|
+
skip: 0,
|
|
272
|
+
props: { isRoot: true },
|
|
273
|
+
}),
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
/*
|
|
278
|
+
* Every write goes through `isRoot`. The model denies create to everybody --
|
|
279
|
+
* including the code's own owner -- because a caller who could supply a
|
|
280
|
+
* `codeHash` would be choosing their own recovery credential.
|
|
281
|
+
*/
|
|
282
|
+
test("writes as root, because nothing else is permitted to", async () => {
|
|
283
|
+
await UserTwoFactorBackupCodeService.regenerateForUser({
|
|
284
|
+
userId: USER_ID,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const violations: Array<unknown> = [];
|
|
288
|
+
|
|
289
|
+
for (const call of createSpy.mock.calls) {
|
|
290
|
+
const input: any = (call as Array<unknown>)[0];
|
|
291
|
+
|
|
292
|
+
if (input?.props?.isRoot !== true) {
|
|
293
|
+
violations.push(input?.props);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
expect(violations).toEqual([]);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("regenerateForUser -- when a write fails partway through", () => {
|
|
302
|
+
/*
|
|
303
|
+
* THE STATE THAT LOOKS LIKE THE GOOD ONE.
|
|
304
|
+
*
|
|
305
|
+
* The old set is deleted first, then the new rows go in one at a time. A
|
|
306
|
+
* failure on, say, the fifth insert would otherwise leave four rows behind
|
|
307
|
+
* -- rows the caller never returned to anybody, because it threw. The
|
|
308
|
+
* profile page would then read "4 backup codes" off the database and tell a
|
|
309
|
+
* user they have a recovery route, when in fact they hold none of those four
|
|
310
|
+
* codes and never saw them.
|
|
311
|
+
*
|
|
312
|
+
* That is worse than having no codes at all, because "you have no backup
|
|
313
|
+
* codes" is a warning the page already shows and the user can act on.
|
|
314
|
+
*/
|
|
315
|
+
test("leaves no codes behind rather than codes nobody has seen", async () => {
|
|
316
|
+
let created: number = 0;
|
|
317
|
+
|
|
318
|
+
createSpy.mockImplementation(
|
|
319
|
+
async (input: any): Promise<UserTwoFactorBackupCode> => {
|
|
320
|
+
created++;
|
|
321
|
+
|
|
322
|
+
if (created === 5) {
|
|
323
|
+
throw new Error("Database not connected");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
createdRows.push(input.data as UserTwoFactorBackupCode);
|
|
327
|
+
return input.data as UserTwoFactorBackupCode;
|
|
328
|
+
},
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
await expect(
|
|
332
|
+
UserTwoFactorBackupCodeService.regenerateForUser({ userId: USER_ID }),
|
|
333
|
+
).rejects.toThrow("Database not connected");
|
|
334
|
+
|
|
335
|
+
const violations: Array<string> = [];
|
|
336
|
+
|
|
337
|
+
/*
|
|
338
|
+
* Twice: once to clear the previous set, once to compensate for the
|
|
339
|
+
* partial write. Both scoped to this user.
|
|
340
|
+
*/
|
|
341
|
+
if (deleteBySpy.mock.calls.length !== 2) {
|
|
342
|
+
violations.push(
|
|
343
|
+
`expected two deletes, saw ${String(deleteBySpy.mock.calls.length)}`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const lastDeleteOrder: number =
|
|
348
|
+
deleteBySpy.mock.invocationCallOrder[
|
|
349
|
+
deleteBySpy.mock.invocationCallOrder.length - 1
|
|
350
|
+
]!;
|
|
351
|
+
const lastCreateOrder: number =
|
|
352
|
+
createSpy.mock.invocationCallOrder[
|
|
353
|
+
createSpy.mock.invocationCallOrder.length - 1
|
|
354
|
+
]!;
|
|
355
|
+
|
|
356
|
+
if (lastDeleteOrder < lastCreateOrder) {
|
|
357
|
+
violations.push(
|
|
358
|
+
"the compensating delete ran before the failing write, so it cleaned up nothing",
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
expect(violations).toEqual([]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
/*
|
|
366
|
+
* The ORIGINAL failure is what the caller has to see. If the compensating
|
|
367
|
+
* delete also fails, reporting that one instead would tell an operator why
|
|
368
|
+
* the rollback did not happen while hiding why the write did not -- and the
|
|
369
|
+
* second is the one they can act on.
|
|
370
|
+
*/
|
|
371
|
+
test("reports the write failure even when the cleanup also fails", async () => {
|
|
372
|
+
createSpy.mockImplementation(async (): Promise<UserTwoFactorBackupCode> => {
|
|
373
|
+
throw new Error("the write failed");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
let cleanupAttempted: boolean = false;
|
|
377
|
+
|
|
378
|
+
deleteBySpy.mockImplementation(async (): Promise<number> => {
|
|
379
|
+
if (cleanupAttempted) {
|
|
380
|
+
throw new Error("the cleanup failed");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
cleanupAttempted = true;
|
|
384
|
+
return 0;
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await expect(
|
|
388
|
+
UserTwoFactorBackupCodeService.regenerateForUser({ userId: USER_ID }),
|
|
389
|
+
).rejects.toThrow("the write failed");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
/*
|
|
393
|
+
* A failure in the FIRST delete must not be compensated, because nothing has
|
|
394
|
+
* been written yet -- and, more to the point, the old set is still intact.
|
|
395
|
+
* Re-running a delete that has just failed buys nothing and could mask the
|
|
396
|
+
* fact that the user's existing codes are untouched.
|
|
397
|
+
*/
|
|
398
|
+
test("does not write or compensate when the initial delete fails", async () => {
|
|
399
|
+
deleteBySpy.mockImplementation(async (): Promise<number> => {
|
|
400
|
+
throw new Error("Database not connected");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
await expect(
|
|
404
|
+
UserTwoFactorBackupCodeService.regenerateForUser({ userId: USER_ID }),
|
|
405
|
+
).rejects.toThrow("Database not connected");
|
|
406
|
+
|
|
407
|
+
expect(deleteBySpy).toHaveBeenCalledTimes(1);
|
|
408
|
+
expect(createSpy).not.toHaveBeenCalled();
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe("onBeforeCreate -- the second lock on the create path", () => {
|
|
413
|
+
/*
|
|
414
|
+
* The table permission is the first lock, and `isRoot` walks straight past
|
|
415
|
+
* it -- which every internal caller uses. So the hook has to refuse the two
|
|
416
|
+
* things that would silently produce an unusable or unowned credential.
|
|
417
|
+
*/
|
|
418
|
+
type InvokeHookFunction = (
|
|
419
|
+
data: Partial<UserTwoFactorBackupCode>,
|
|
420
|
+
) => Promise<CreateBy<UserTwoFactorBackupCode>>;
|
|
421
|
+
|
|
422
|
+
const invokeHook: InvokeHookFunction = async (
|
|
423
|
+
data: Partial<UserTwoFactorBackupCode>,
|
|
424
|
+
): Promise<CreateBy<UserTwoFactorBackupCode>> => {
|
|
425
|
+
const row: UserTwoFactorBackupCode = new UserTwoFactorBackupCode();
|
|
426
|
+
Object.assign(row, data);
|
|
427
|
+
|
|
428
|
+
const createBy: CreateBy<UserTwoFactorBackupCode> = {
|
|
429
|
+
data: row,
|
|
430
|
+
props: { isRoot: true },
|
|
431
|
+
} as CreateBy<UserTwoFactorBackupCode>;
|
|
432
|
+
|
|
433
|
+
const result: { createBy: CreateBy<UserTwoFactorBackupCode> } = await (
|
|
434
|
+
UserTwoFactorBackupCodeService as any
|
|
435
|
+
).onBeforeCreate(createBy);
|
|
436
|
+
|
|
437
|
+
return result.createBy;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
test("refuses a row with no owner", async () => {
|
|
441
|
+
await expect(invokeHook({ codeHash: "a".repeat(64) })).rejects.toThrow(
|
|
442
|
+
BadDataException,
|
|
443
|
+
);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("refuses a row with no digest", async () => {
|
|
447
|
+
await expect(invokeHook({ userId: USER_ID })).rejects.toThrow(
|
|
448
|
+
BadDataException,
|
|
449
|
+
);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
/*
|
|
453
|
+
* A code that arrives already spent is a caller confused about which end of
|
|
454
|
+
* the lifecycle it is at -- and would be a row the user can never use,
|
|
455
|
+
* counted against their remaining total.
|
|
456
|
+
*/
|
|
457
|
+
test("strips a usedAt that a caller tried to set", async () => {
|
|
458
|
+
const createBy: CreateBy<UserTwoFactorBackupCode> = await invokeHook({
|
|
459
|
+
userId: USER_ID,
|
|
460
|
+
codeHash: "a".repeat(64),
|
|
461
|
+
usedAt: new Date(),
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
expect(createBy.data.usedAt).toBeUndefined();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("lets a well formed row through", async () => {
|
|
468
|
+
const createBy: CreateBy<UserTwoFactorBackupCode> = await invokeHook({
|
|
469
|
+
userId: USER_ID,
|
|
470
|
+
codeHash: "a".repeat(64),
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
expect(createBy.data.userId).toEqual(USER_ID);
|
|
474
|
+
expect(createBy.data.codeHash).toBe("a".repeat(64));
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
describe("consumeCode -- spending one, exactly once", () => {
|
|
479
|
+
test("reports success when the statement matched a row", async () => {
|
|
480
|
+
stubQuery(updateResult(1));
|
|
481
|
+
|
|
482
|
+
await expect(
|
|
483
|
+
UserTwoFactorBackupCodeService.consumeCode({
|
|
484
|
+
userId: USER_ID,
|
|
485
|
+
code: "ABCDE-12345",
|
|
486
|
+
}),
|
|
487
|
+
).resolves.toBe(true);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("reports failure when the statement matched nothing", async () => {
|
|
491
|
+
stubQuery(updateResult(0));
|
|
492
|
+
|
|
493
|
+
await expect(
|
|
494
|
+
UserTwoFactorBackupCodeService.consumeCode({
|
|
495
|
+
userId: USER_ID,
|
|
496
|
+
code: "ABCDE-12345",
|
|
497
|
+
}),
|
|
498
|
+
).resolves.toBe(false);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
/*
|
|
502
|
+
* THE ATOMICITY. `usedAt IS NULL` has to be in the WHERE clause of the same
|
|
503
|
+
* statement that sets it, or two sign-ins carrying the same code both read a
|
|
504
|
+
* null and both get let in. There is no assertion available for "this is
|
|
505
|
+
* atomic" other than looking at the SQL, so that is what this does.
|
|
506
|
+
*/
|
|
507
|
+
test("decides single-use inside the statement, not around it", async () => {
|
|
508
|
+
stubQuery(updateResult(1));
|
|
509
|
+
|
|
510
|
+
await UserTwoFactorBackupCodeService.consumeCode({
|
|
511
|
+
userId: USER_ID,
|
|
512
|
+
code: "ABCDE-12345",
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const sql: string = String(
|
|
516
|
+
(queryMock.mock.calls[0] as Array<unknown>)[0],
|
|
517
|
+
).replace(/\s+/g, " ");
|
|
518
|
+
|
|
519
|
+
expect(sql).toContain('UPDATE "UserTwoFactorBackupCode"');
|
|
520
|
+
expect(sql).toContain('"usedAt" IS NULL');
|
|
521
|
+
expect(sql).toContain('RETURNING "_id"');
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
/*
|
|
525
|
+
* Soft-deleted rows are still physically present. Without this predicate, a
|
|
526
|
+
* code from a set that regeneration replaced would keep signing people in --
|
|
527
|
+
* which is the one thing regeneration exists to stop.
|
|
528
|
+
*/
|
|
529
|
+
test("ignores soft-deleted rows", async () => {
|
|
530
|
+
stubQuery(updateResult(1));
|
|
531
|
+
|
|
532
|
+
await UserTwoFactorBackupCodeService.consumeCode({
|
|
533
|
+
userId: USER_ID,
|
|
534
|
+
code: "ABCDE-12345",
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const sql: string = String(
|
|
538
|
+
(queryMock.mock.calls[0] as Array<unknown>)[0],
|
|
539
|
+
).replace(/\s+/g, " ");
|
|
540
|
+
|
|
541
|
+
expect(sql).toContain('"deletedAt" IS NULL');
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test("scopes the statement to the owning user", async () => {
|
|
545
|
+
stubQuery(updateResult(1));
|
|
546
|
+
|
|
547
|
+
await UserTwoFactorBackupCodeService.consumeCode({
|
|
548
|
+
userId: USER_ID,
|
|
549
|
+
code: "ABCDE-12345",
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const [sql, params] = queryMock.mock.calls[0] as [string, Array<unknown>];
|
|
553
|
+
|
|
554
|
+
expect(String(sql).replace(/\s+/g, " ")).toContain('"userId" = $2');
|
|
555
|
+
expect(params[1]).toBe(USER_ID.toString());
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
/*
|
|
559
|
+
* Every value is bound, never interpolated. The code is caller-supplied, so
|
|
560
|
+
* an interpolated statement here would be a SQL injection on an
|
|
561
|
+
* unauthenticated route.
|
|
562
|
+
*/
|
|
563
|
+
test("binds every value as a parameter", async () => {
|
|
564
|
+
stubQuery(updateResult(1));
|
|
565
|
+
|
|
566
|
+
await UserTwoFactorBackupCodeService.consumeCode({
|
|
567
|
+
userId: USER_ID,
|
|
568
|
+
code: '\'; DROP TABLE "User"; --',
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const [sql, params] = queryMock.mock.calls[0] as [string, Array<unknown>];
|
|
572
|
+
|
|
573
|
+
expect(sql).not.toContain("DROP TABLE");
|
|
574
|
+
expect(params).toHaveLength(3);
|
|
575
|
+
expect(params[0]).toBeInstanceOf(Date);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("looks the code up by its digest, never by the code itself", async () => {
|
|
579
|
+
stubQuery(updateResult(1));
|
|
580
|
+
|
|
581
|
+
const code: string = "ABCDE-12345";
|
|
582
|
+
|
|
583
|
+
await UserTwoFactorBackupCodeService.consumeCode({
|
|
584
|
+
userId: USER_ID,
|
|
585
|
+
code: code,
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const params: Array<unknown> = (
|
|
589
|
+
queryMock.mock.calls[0] as [string, Array<unknown>]
|
|
590
|
+
)[1];
|
|
591
|
+
|
|
592
|
+
expect(params[2]).toBe(
|
|
593
|
+
TwoFactorBackupCode.hashCode({ code: code, userId: USER_ID }),
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
expect(params).not.toContain(code);
|
|
597
|
+
expect(params).not.toContain("ABCDE12345");
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
/*
|
|
601
|
+
* The user is typing off a printed list, months later, on whatever keyboard
|
|
602
|
+
* they have. Each of these is a way to reject somebody who supplied exactly
|
|
603
|
+
* the right secret.
|
|
604
|
+
*/
|
|
605
|
+
test.each([
|
|
606
|
+
"ABCDE-12345",
|
|
607
|
+
"ABCDE12345",
|
|
608
|
+
"abcde-12345",
|
|
609
|
+
" ABCDE 12345 ",
|
|
610
|
+
"abcde\n12345",
|
|
611
|
+
])("accepts the code typed as %p", async (typed: string) => {
|
|
612
|
+
stubQuery(updateResult(1));
|
|
613
|
+
|
|
614
|
+
await UserTwoFactorBackupCodeService.consumeCode({
|
|
615
|
+
userId: USER_ID,
|
|
616
|
+
code: typed,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
const params: Array<unknown> = (
|
|
620
|
+
queryMock.mock.calls[0] as [string, Array<unknown>]
|
|
621
|
+
)[1];
|
|
622
|
+
|
|
623
|
+
expect(params[2]).toBe(
|
|
624
|
+
TwoFactorBackupCode.hashCode({
|
|
625
|
+
code: "ABCDE12345",
|
|
626
|
+
userId: USER_ID,
|
|
627
|
+
}),
|
|
628
|
+
);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
/*
|
|
632
|
+
* Refused before the round trip. An empty submission cannot be anybody's
|
|
633
|
+
* code, and this route is one an attacker can call.
|
|
634
|
+
*/
|
|
635
|
+
test.each([
|
|
636
|
+
["", "empty"],
|
|
637
|
+
[" ", "whitespace"],
|
|
638
|
+
["---", "punctuation only"],
|
|
639
|
+
])(
|
|
640
|
+
"refuses %p (%s) without touching the database",
|
|
641
|
+
async (code: string, _label: string) => {
|
|
642
|
+
await expect(
|
|
643
|
+
UserTwoFactorBackupCodeService.consumeCode({
|
|
644
|
+
userId: USER_ID,
|
|
645
|
+
code: code,
|
|
646
|
+
}),
|
|
647
|
+
).resolves.toBe(false);
|
|
648
|
+
|
|
649
|
+
expect(queryMock).not.toHaveBeenCalled();
|
|
650
|
+
},
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
test.each([[undefined], [null], [12345], [{}]])(
|
|
654
|
+
"refuses the non-string submission %p without throwing",
|
|
655
|
+
async (code: unknown) => {
|
|
656
|
+
await expect(
|
|
657
|
+
UserTwoFactorBackupCodeService.consumeCode({
|
|
658
|
+
userId: USER_ID,
|
|
659
|
+
code: code as string,
|
|
660
|
+
}),
|
|
661
|
+
).resolves.toBe(false);
|
|
662
|
+
|
|
663
|
+
expect(queryMock).not.toHaveBeenCalled();
|
|
664
|
+
},
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
/*
|
|
668
|
+
* The driver's return shape is not something this code controls. Anything
|
|
669
|
+
* it does not recognise must read as "no code was spent" -- the direction
|
|
670
|
+
* that refuses a login, never the direction that grants one.
|
|
671
|
+
*/
|
|
672
|
+
test.each([
|
|
673
|
+
[null, "null"],
|
|
674
|
+
[undefined, "undefined"],
|
|
675
|
+
[[], "an empty array"],
|
|
676
|
+
[[[], 0], "zero rows"],
|
|
677
|
+
[{}, "an object"],
|
|
678
|
+
[["not an array", 1], "a non-array rows slot"],
|
|
679
|
+
])(
|
|
680
|
+
"treats %p (%s) as nothing consumed",
|
|
681
|
+
async (result: unknown, _label: string) => {
|
|
682
|
+
stubQuery(result);
|
|
683
|
+
|
|
684
|
+
await expect(
|
|
685
|
+
UserTwoFactorBackupCodeService.consumeCode({
|
|
686
|
+
userId: USER_ID,
|
|
687
|
+
code: "ABCDE-12345",
|
|
688
|
+
}),
|
|
689
|
+
).resolves.toBe(false);
|
|
690
|
+
},
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
test("stamps usedAt rather than leaving the row looking unspent", async () => {
|
|
694
|
+
stubQuery(updateResult(1));
|
|
695
|
+
|
|
696
|
+
await UserTwoFactorBackupCodeService.consumeCode({
|
|
697
|
+
userId: USER_ID,
|
|
698
|
+
code: "ABCDE-12345",
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const [sql, params] = queryMock.mock.calls[0] as [string, Array<unknown>];
|
|
702
|
+
|
|
703
|
+
expect(String(sql).replace(/\s+/g, " ")).toContain('SET "usedAt" = $1');
|
|
704
|
+
expect(params[0]).toBeInstanceOf(Date);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
describe("countUnusedForUser", () => {
|
|
709
|
+
/*
|
|
710
|
+
* `usedAt: null` on its own is DROPPED by TypeORM rather than compiled to
|
|
711
|
+
* `IS NULL`, so the count would silently include spent codes -- and the
|
|
712
|
+
* login page would offer a recovery route to somebody with nothing left to
|
|
713
|
+
* recover with.
|
|
714
|
+
*/
|
|
715
|
+
test("counts only codes that have not been spent", async () => {
|
|
716
|
+
countBySpy.mockImplementation(async (): Promise<PositiveNumber> => {
|
|
717
|
+
return new PositiveNumber(7);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
const unused: number =
|
|
721
|
+
await UserTwoFactorBackupCodeService.countUnusedForUser({
|
|
722
|
+
userId: USER_ID,
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
expect(unused).toBe(7);
|
|
726
|
+
|
|
727
|
+
const call: { query: Record<string, any>; props: Record<string, unknown> } =
|
|
728
|
+
(
|
|
729
|
+
countBySpy.mock.calls[0] as Array<{
|
|
730
|
+
query: Record<string, any>;
|
|
731
|
+
props: Record<string, unknown>;
|
|
732
|
+
}>
|
|
733
|
+
)[0]!;
|
|
734
|
+
|
|
735
|
+
expect(call.query["userId"]).toEqual(USER_ID);
|
|
736
|
+
expect(call.props).toEqual({ isRoot: true });
|
|
737
|
+
|
|
738
|
+
/*
|
|
739
|
+
* Compared through the SQL the operator emits rather than by deep equality
|
|
740
|
+
* against `QueryHelper.isNull()`. Two calls to that helper build two
|
|
741
|
+
* different closures, so `toEqual` compares function identity and fails on
|
|
742
|
+
* a query that is in fact correct -- and the thing worth pinning is the
|
|
743
|
+
* SQL anyway: a bare `usedAt: null` is DROPPED by TypeORM rather than
|
|
744
|
+
* compiled to `IS NULL`, so the count would silently include spent codes
|
|
745
|
+
* and the login page would offer a recovery route to somebody with nothing
|
|
746
|
+
* left to recover with.
|
|
747
|
+
*/
|
|
748
|
+
const usedAtOperator: any = call.query["usedAt"];
|
|
749
|
+
|
|
750
|
+
expect(usedAtOperator).toBeInstanceOf(FindOperator);
|
|
751
|
+
expect(usedAtOperator.type).toBe("raw");
|
|
752
|
+
expect(usedAtOperator.getSql('"usedAt"')).toContain("IS NULL");
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("reports zero for a user with no codes", async () => {
|
|
756
|
+
countBySpy.mockImplementation(async (): Promise<PositiveNumber> => {
|
|
757
|
+
return new PositiveNumber(0);
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
await expect(
|
|
761
|
+
UserTwoFactorBackupCodeService.countUnusedForUser({ userId: USER_ID }),
|
|
762
|
+
).resolves.toBe(0);
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
describe("getStatusForUser", () => {
|
|
767
|
+
test("short-circuits for a user who has never generated any", async () => {
|
|
768
|
+
countBySpy.mockImplementation(async (): Promise<PositiveNumber> => {
|
|
769
|
+
return new PositiveNumber(0);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
const status: TwoFactorBackupCodeStatus =
|
|
773
|
+
await UserTwoFactorBackupCodeService.getStatusForUser({
|
|
774
|
+
userId: USER_ID,
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
expect(status).toEqual({ total: 0, unused: 0, generatedAt: null });
|
|
778
|
+
|
|
779
|
+
/*
|
|
780
|
+
* One count, then nothing. Reading the newest row for a `generatedAt` that
|
|
781
|
+
* cannot exist is a query per page load for no answer.
|
|
782
|
+
*/
|
|
783
|
+
expect(countBySpy).toHaveBeenCalledTimes(1);
|
|
784
|
+
expect(findOneBySpy).not.toHaveBeenCalled();
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test("reports the total, the unused count and when the set was minted", async () => {
|
|
788
|
+
const generatedAt: Date = new Date("2026-01-02T03:04:05.000Z");
|
|
789
|
+
|
|
790
|
+
let call: number = 0;
|
|
791
|
+
|
|
792
|
+
countBySpy.mockImplementation(async (): Promise<PositiveNumber> => {
|
|
793
|
+
call++;
|
|
794
|
+
// First call is the total, second is the unused count.
|
|
795
|
+
return new PositiveNumber(call === 1 ? 10 : 4);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
findOneBySpy.mockImplementation(
|
|
799
|
+
async (): Promise<UserTwoFactorBackupCode> => {
|
|
800
|
+
const row: UserTwoFactorBackupCode = new UserTwoFactorBackupCode();
|
|
801
|
+
row.createdAt = generatedAt;
|
|
802
|
+
return row;
|
|
803
|
+
},
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
const status: TwoFactorBackupCodeStatus =
|
|
807
|
+
await UserTwoFactorBackupCodeService.getStatusForUser({
|
|
808
|
+
userId: USER_ID,
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
expect(status).toEqual({
|
|
812
|
+
total: 10,
|
|
813
|
+
unused: 4,
|
|
814
|
+
generatedAt: generatedAt,
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
/*
|
|
819
|
+
* The rows carry a credential digest and no caller needs it, so nothing that
|
|
820
|
+
* a stray log line could print is ever loaded.
|
|
821
|
+
*/
|
|
822
|
+
test("never selects the digest", async () => {
|
|
823
|
+
let call: number = 0;
|
|
824
|
+
|
|
825
|
+
countBySpy.mockImplementation(async (): Promise<PositiveNumber> => {
|
|
826
|
+
call++;
|
|
827
|
+
return new PositiveNumber(call === 1 ? 10 : 4);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
findOneBySpy.mockImplementation(
|
|
831
|
+
async (): Promise<UserTwoFactorBackupCode> => {
|
|
832
|
+
return new UserTwoFactorBackupCode();
|
|
833
|
+
},
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
await UserTwoFactorBackupCodeService.getStatusForUser({
|
|
837
|
+
userId: USER_ID,
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
const select: Record<string, unknown> = (
|
|
841
|
+
findOneBySpy.mock.calls[0] as Array<{ select: Record<string, unknown> }>
|
|
842
|
+
)[0]!.select;
|
|
843
|
+
|
|
844
|
+
expect(select["codeHash"]).toBeUndefined();
|
|
845
|
+
expect(select).toEqual({ createdAt: true });
|
|
846
|
+
});
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
describe("deleteAllForUser", () => {
|
|
850
|
+
test("removes every code the user has, scoped to that user", async () => {
|
|
851
|
+
await UserTwoFactorBackupCodeService.deleteAllForUser({ userId: USER_ID });
|
|
852
|
+
|
|
853
|
+
expect(deleteBySpy).toHaveBeenCalledWith(
|
|
854
|
+
expect.objectContaining({
|
|
855
|
+
query: { userId: USER_ID },
|
|
856
|
+
limit: LIMIT_MAX,
|
|
857
|
+
skip: 0,
|
|
858
|
+
props: { isRoot: true },
|
|
859
|
+
}),
|
|
860
|
+
);
|
|
861
|
+
});
|
|
862
|
+
});
|