@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
|
@@ -45,6 +45,7 @@ import ProductAnalytics from "../Utils/ProductAnalytics";
|
|
|
45
45
|
import MarketingEventUtil from "../Utils/Marketing/MarketingEventUtil";
|
|
46
46
|
import { MarketingEventType } from "../../Types/Marketing/MarketingEvent";
|
|
47
47
|
import UserTotpAuthService from "./UserTotpAuthService";
|
|
48
|
+
import UserTwoFactorBackupCodeService from "./UserTwoFactorBackupCodeService";
|
|
48
49
|
import UserWebAuthnService from "./UserWebAuthnService";
|
|
49
50
|
import BadDataException from "../../Types/Exception/BadDataException";
|
|
50
51
|
import NotFoundException from "../../Types/Exception/NotFoundException";
|
|
@@ -819,6 +820,18 @@ export class Service extends DatabaseService<Model> {
|
|
|
819
820
|
const verifiedTwoFactorAuthMethodCount: number =
|
|
820
821
|
await this.countVerifiedTwoFactorAuthMethods(userId);
|
|
821
822
|
|
|
823
|
+
/*
|
|
824
|
+
* Read separately from the method count above, and NOT added to it. See
|
|
825
|
+
* the note on `deriveTwoFactorAuthStatus`: folding recovery codes into the
|
|
826
|
+
* configured-method count would report an account whose authenticator is
|
|
827
|
+
* gone as fully set up, and login would keep sending it to the challenge
|
|
828
|
+
* screen until the last code was spent.
|
|
829
|
+
*/
|
|
830
|
+
const unusedTwoFactorBackupCodeCount: number =
|
|
831
|
+
await UserTwoFactorBackupCodeService.countUnusedForUser({
|
|
832
|
+
userId: userId,
|
|
833
|
+
});
|
|
834
|
+
|
|
822
835
|
return {
|
|
823
836
|
hasPassword: Boolean(user.password),
|
|
824
837
|
isEmailVerified: Boolean(user.isEmailVerified),
|
|
@@ -828,6 +841,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
828
841
|
verifiedMethodCount: verifiedTwoFactorAuthMethodCount,
|
|
829
842
|
}),
|
|
830
843
|
verifiedTwoFactorAuthMethodCount: verifiedTwoFactorAuthMethodCount,
|
|
844
|
+
unusedTwoFactorBackupCodeCount: unusedTwoFactorBackupCodeCount,
|
|
831
845
|
hasPendingPasswordResetLink: hasPendingPasswordResetLink,
|
|
832
846
|
};
|
|
833
847
|
}
|
|
@@ -845,6 +859,17 @@ export class Service extends DatabaseService<Model> {
|
|
|
845
859
|
* Counting unverified rows would therefore report a user who has never once
|
|
846
860
|
* typed a code as fully configured -- and that user is precisely the one an
|
|
847
861
|
* operator is looking at the page to help.
|
|
862
|
+
*
|
|
863
|
+
* BACKUP CODES ARE NOT COUNTED HERE, for a related reason one step further
|
|
864
|
+
* on. This number decides `deriveTwoFactorAuthStatus`, which decides whether
|
|
865
|
+
* login shows the two factor CHALLENGE or sends the account through
|
|
866
|
+
* enrolment. An account whose only remaining material was recovery codes
|
|
867
|
+
* would, if they counted, be shown a challenge it cannot answer except by
|
|
868
|
+
* spending a code -- one per sign-in, with no way to enrol a new
|
|
869
|
+
* authenticator, until the last one is gone. Recovery codes are the way back
|
|
870
|
+
* in to an account with a factor it cannot reach; they are not a factor.
|
|
871
|
+
* `UserAuthenticationStatus.unusedTwoFactorBackupCodeCount` reports them
|
|
872
|
+
* separately.
|
|
848
873
|
*/
|
|
849
874
|
@CaptureSpan()
|
|
850
875
|
private async countVerifiedTwoFactorAuthMethods(
|
|
@@ -1036,6 +1061,23 @@ export class Service extends DatabaseService<Model> {
|
|
|
1036
1061
|
},
|
|
1037
1062
|
});
|
|
1038
1063
|
|
|
1064
|
+
/*
|
|
1065
|
+
* The recovery codes go with the authenticators, and this is not
|
|
1066
|
+
* bookkeeping. A backup code signs somebody in on its own, so a reset that
|
|
1067
|
+
* cleared the TOTP secret but left a printed list of codes alive would
|
|
1068
|
+
* have revoked nothing that an attacker who took the phone -- and the
|
|
1069
|
+
* paper next to it -- still holds. "Reset two factor auth" has to mean all
|
|
1070
|
+
* of the second-factor material, not the part that is easiest to see.
|
|
1071
|
+
*
|
|
1072
|
+
* It also matters in the harmless direction: the user is about to be sent
|
|
1073
|
+
* through enrolment at their next sign-in, and codes minted against an
|
|
1074
|
+
* authenticator that no longer exists are dead weight nobody would think
|
|
1075
|
+
* to clear.
|
|
1076
|
+
*/
|
|
1077
|
+
await UserTwoFactorBackupCodeService.deleteAllForUser({
|
|
1078
|
+
userId: user.id!,
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1039
1081
|
/*
|
|
1040
1082
|
* Ordered after the deletes, not before. A session that survives a reset
|
|
1041
1083
|
* is a session that never has to prove the second factor again, which
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import DatabaseService from "./DatabaseService";
|
|
2
|
+
import Model from "../../Models/DatabaseModels/UserTwoFactorBackupCode";
|
|
3
|
+
import TwoFactorBackupCode, {
|
|
4
|
+
BackupCodeSetSize,
|
|
5
|
+
} from "../Utils/TwoFactorBackupCode";
|
|
6
|
+
import CreateBy from "../Types/Database/CreateBy";
|
|
7
|
+
import { OnCreate } from "../Types/Database/Hooks";
|
|
8
|
+
import QueryHelper from "../Types/Database/QueryHelper";
|
|
9
|
+
import BadDataException from "../../Types/Exception/BadDataException";
|
|
10
|
+
import LIMIT_MAX from "../../Types/Database/LimitMax";
|
|
11
|
+
import SortOrder from "../../Types/BaseDatabase/SortOrder";
|
|
12
|
+
import ObjectID from "../../Types/ObjectID";
|
|
13
|
+
import PositiveNumber from "../../Types/PositiveNumber";
|
|
14
|
+
import OneUptimeDate from "../../Types/Date";
|
|
15
|
+
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
|
16
|
+
import logger from "../Utils/Logger";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How many of a user's backup codes are left, for the profile page and the
|
|
20
|
+
* admin's account view.
|
|
21
|
+
*
|
|
22
|
+
* `total` and `unused` are both reported rather than just the remaining count
|
|
23
|
+
* because they answer different questions: "have you set backup codes up at
|
|
24
|
+
* all" and "how many can you still use". A user with ten codes and a user who
|
|
25
|
+
* has spent all ten both have a `total` of ten, and only the second needs to
|
|
26
|
+
* be told to regenerate.
|
|
27
|
+
*/
|
|
28
|
+
export interface TwoFactorBackupCodeStatus {
|
|
29
|
+
total: number;
|
|
30
|
+
unused: number;
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* When the current set was minted, or null if there are none. Read off the
|
|
34
|
+
* newest row rather than stored separately -- regeneration replaces the
|
|
35
|
+
* whole set in one call, so every row in a set shares a creation time to
|
|
36
|
+
* within a few milliseconds.
|
|
37
|
+
*/
|
|
38
|
+
generatedAt: Date | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class Service extends DatabaseService<Model> {
|
|
42
|
+
public constructor() {
|
|
43
|
+
super(Model);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
* The model denies create to everyone, so the only way a row is written is
|
|
48
|
+
* `regenerateForUser` below, as root. This hook is the second lock on the
|
|
49
|
+
* same door: it refuses any create that did not come through there.
|
|
50
|
+
*
|
|
51
|
+
* Worth having both because the two guards fail differently. The table
|
|
52
|
+
* permission is enforced by the CRUD API layer and is bypassed wholesale by
|
|
53
|
+
* `isRoot`, which every internal caller uses -- so a future service that
|
|
54
|
+
* reaches for `UserTwoFactorBackupCodeService.create()` with a plaintext
|
|
55
|
+
* code, or with no owner, would sail past it. What lands in `codeHash` is
|
|
56
|
+
* the credential; there is no recovering from writing the wrong thing there.
|
|
57
|
+
*/
|
|
58
|
+
@CaptureSpan()
|
|
59
|
+
protected override async onBeforeCreate(
|
|
60
|
+
createBy: CreateBy<Model>,
|
|
61
|
+
): Promise<OnCreate<Model>> {
|
|
62
|
+
if (!createBy.data.userId) {
|
|
63
|
+
throw new BadDataException("User id is required");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!createBy.data.codeHash) {
|
|
67
|
+
throw new BadDataException("Backup code hash is required");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/*
|
|
71
|
+
* A code that arrives already spent is a caller confusing itself about
|
|
72
|
+
* which end of the lifecycle it is at. Codes are minted usable and are
|
|
73
|
+
* spent only by `consumeCode`.
|
|
74
|
+
*
|
|
75
|
+
* Deleted rather than set to undefined: `exactOptionalPropertyTypes` is on,
|
|
76
|
+
* so the property being ABSENT and the property holding `undefined` are
|
|
77
|
+
* different things to the compiler, and only the first is allowed here.
|
|
78
|
+
*/
|
|
79
|
+
delete createBy.data.usedAt;
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
createBy: createBy,
|
|
83
|
+
carryForward: {},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Mint a fresh set of backup codes for one user, replacing whatever they
|
|
89
|
+
* had, and return the PLAINTEXT codes.
|
|
90
|
+
*
|
|
91
|
+
* This is the only moment the plaintext exists anywhere. The caller shows it
|
|
92
|
+
* to the user once and then it is gone -- only the keyed digests are stored,
|
|
93
|
+
* so nothing (not this service, not a master admin, not a database dump) can
|
|
94
|
+
* produce the codes again. That is the property the feature is worth having
|
|
95
|
+
* for, and it is why the API route wraps this in a response the UI is
|
|
96
|
+
* expected to make the user acknowledge.
|
|
97
|
+
*
|
|
98
|
+
* REPLACING rather than adding is deliberate. "Generate more codes" would
|
|
99
|
+
* leave the codes from a list the user printed, lost and then regenerated
|
|
100
|
+
* over still working, which defeats the point of regenerating after a
|
|
101
|
+
* suspected compromise.
|
|
102
|
+
*
|
|
103
|
+
* ALL OR NOTHING, and this is the part that needs care. The old set is
|
|
104
|
+
* deleted first, then the new rows are written one at a time -- so a failure
|
|
105
|
+
* partway through the loop would otherwise leave the account holding a few
|
|
106
|
+
* rows that WERE written and that the caller, having thrown, never showed to
|
|
107
|
+
* anybody. `getStatusForUser` would then report "4 backup codes" to a user
|
|
108
|
+
* who has never seen one of them: codes that are unusable in practice and
|
|
109
|
+
* that hide the fact that they have no recovery route left. That is the
|
|
110
|
+
* worst state this feature can produce, because it looks exactly like the
|
|
111
|
+
* good one.
|
|
112
|
+
*
|
|
113
|
+
* So a failure is compensated: everything written for this user is removed,
|
|
114
|
+
* and the account ends with NO codes and an error on screen. "You have no
|
|
115
|
+
* backup codes" is a state the profile page already tells the user to fix;
|
|
116
|
+
* "you have four codes you have never seen" is not.
|
|
117
|
+
*
|
|
118
|
+
* The compensating delete is itself best-effort -- if it also fails there is
|
|
119
|
+
* nothing further to try -- but it turns a silent, permanent trap into two
|
|
120
|
+
* consecutive infrastructure failures.
|
|
121
|
+
*/
|
|
122
|
+
@CaptureSpan()
|
|
123
|
+
public async regenerateForUser(data: {
|
|
124
|
+
userId: ObjectID;
|
|
125
|
+
count?: number | undefined;
|
|
126
|
+
}): Promise<Array<string>> {
|
|
127
|
+
const count: number = data.count || BackupCodeSetSize;
|
|
128
|
+
|
|
129
|
+
await this.deleteAllForUser({ userId: data.userId });
|
|
130
|
+
|
|
131
|
+
const codes: Array<string> = TwoFactorBackupCode.generateCodeSet(count);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
for (const code of codes) {
|
|
135
|
+
const backupCode: Model = new Model();
|
|
136
|
+
backupCode.userId = data.userId;
|
|
137
|
+
backupCode.codeHash = TwoFactorBackupCode.hashCode({
|
|
138
|
+
code: code,
|
|
139
|
+
userId: data.userId,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await this.create({
|
|
143
|
+
data: backupCode,
|
|
144
|
+
props: {
|
|
145
|
+
isRoot: true,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
try {
|
|
151
|
+
await this.deleteAllForUser({ userId: data.userId });
|
|
152
|
+
} catch (cleanupError) {
|
|
153
|
+
/*
|
|
154
|
+
* Swallowed so the ORIGINAL failure is what the caller sees. The
|
|
155
|
+
* cleanup error is the less useful of the two -- it explains why the
|
|
156
|
+
* rollback did not happen, not why the write did not.
|
|
157
|
+
*/
|
|
158
|
+
logger.error(cleanupError);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return codes;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Spend one of this user's backup codes, if the submitted code is one of
|
|
169
|
+
* them and has not been used already.
|
|
170
|
+
*
|
|
171
|
+
* ONE STATEMENT, ON PURPOSE
|
|
172
|
+
*
|
|
173
|
+
* The obvious shape -- find the row, check `usedAt`, then update it -- has a
|
|
174
|
+
* window between the read and the write, and "single use" is the entire
|
|
175
|
+
* guarantee a backup code offers. Two sign-in attempts carrying the same
|
|
176
|
+
* code that arrive together would both read a null `usedAt` and both be let
|
|
177
|
+
* in, which is precisely the property an attacker who has watched somebody
|
|
178
|
+
* type a code off a printed list would exploit.
|
|
179
|
+
*
|
|
180
|
+
* `usedAt IS NULL` in the WHERE clause moves the decision inside Postgres,
|
|
181
|
+
* where the row lock settles it: the first statement to reach the row
|
|
182
|
+
* updates it, the second matches nothing. `RETURNING "_id"` is what turns
|
|
183
|
+
* that into an answer for the caller -- an UPDATE that matched no rows and
|
|
184
|
+
* an UPDATE that matched one are otherwise indistinguishable from here.
|
|
185
|
+
*
|
|
186
|
+
* Written as raw parameterized SQL rather than through the ORM because no
|
|
187
|
+
* write path on DatabaseService both takes a non-primary-key predicate and
|
|
188
|
+
* reports what it matched. Column and table names are literals in this file,
|
|
189
|
+
* never caller input, and all three values are bound parameters.
|
|
190
|
+
*
|
|
191
|
+
* `deletedAt IS NULL` is included because soft-deleted rows are still
|
|
192
|
+
* physically present; without it, a code from a set that regeneration
|
|
193
|
+
* replaced would still sign somebody in.
|
|
194
|
+
*
|
|
195
|
+
* @returns true when a code was spent, false when the code was wrong,
|
|
196
|
+
* already used, or belongs to somebody else.
|
|
197
|
+
*/
|
|
198
|
+
@CaptureSpan()
|
|
199
|
+
public async consumeCode(data: {
|
|
200
|
+
userId: ObjectID;
|
|
201
|
+
code: string;
|
|
202
|
+
}): Promise<boolean> {
|
|
203
|
+
const normalizedCode: string = TwoFactorBackupCode.normalizeCode(data.code);
|
|
204
|
+
|
|
205
|
+
/*
|
|
206
|
+
* Refused before the query rather than hashed and looked up. An empty
|
|
207
|
+
* submission cannot be anybody's code, and letting it through would mean
|
|
208
|
+
* one round trip per empty request on a route an attacker can call.
|
|
209
|
+
*/
|
|
210
|
+
if (!normalizedCode) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const codeHash: string = TwoFactorBackupCode.hashCode({
|
|
215
|
+
code: normalizedCode,
|
|
216
|
+
userId: data.userId,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const rows: Array<{ _id: string }> = await this.getRepository()
|
|
220
|
+
.manager.query(
|
|
221
|
+
`UPDATE "UserTwoFactorBackupCode"
|
|
222
|
+
SET "usedAt" = $1, "updatedAt" = CURRENT_TIMESTAMP
|
|
223
|
+
WHERE "userId" = $2
|
|
224
|
+
AND "codeHash" = $3
|
|
225
|
+
AND "usedAt" IS NULL
|
|
226
|
+
AND "deletedAt" IS NULL
|
|
227
|
+
RETURNING "_id"`,
|
|
228
|
+
[OneUptimeDate.getCurrentDate(), data.userId.toString(), codeHash],
|
|
229
|
+
)
|
|
230
|
+
/*
|
|
231
|
+
* For an UPDATE the postgres driver hands back `[rows, rowCount]` rather
|
|
232
|
+
* than a bare row array, so the rows have to be unwrapped. Written
|
|
233
|
+
* defensively: a driver that returns the bare array instead must read as
|
|
234
|
+
* "no code was spent", never as a silent success.
|
|
235
|
+
*/
|
|
236
|
+
.then((result: unknown): Array<{ _id: string }> => {
|
|
237
|
+
if (!Array.isArray(result)) {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const first: unknown = result[0];
|
|
242
|
+
|
|
243
|
+
return Array.isArray(first) ? (first as Array<{ _id: string }>) : [];
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return rows.length > 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* How many codes this user has, and how many are still spendable.
|
|
251
|
+
*
|
|
252
|
+
* Counted rather than fetched: the rows carry a credential digest and there
|
|
253
|
+
* is no caller that needs them, so nothing is loaded that a stray log line
|
|
254
|
+
* could then print.
|
|
255
|
+
*/
|
|
256
|
+
@CaptureSpan()
|
|
257
|
+
public async getStatusForUser(data: {
|
|
258
|
+
userId: ObjectID;
|
|
259
|
+
}): Promise<TwoFactorBackupCodeStatus> {
|
|
260
|
+
const total: PositiveNumber = await this.countBy({
|
|
261
|
+
query: {
|
|
262
|
+
userId: data.userId,
|
|
263
|
+
},
|
|
264
|
+
props: {
|
|
265
|
+
isRoot: true,
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
if (total.toNumber() === 0) {
|
|
270
|
+
return {
|
|
271
|
+
total: 0,
|
|
272
|
+
unused: 0,
|
|
273
|
+
generatedAt: null,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const unused: number = await this.countUnusedForUser({
|
|
278
|
+
userId: data.userId,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const newest: Model | null = await this.findOneBy({
|
|
282
|
+
query: {
|
|
283
|
+
userId: data.userId,
|
|
284
|
+
},
|
|
285
|
+
select: {
|
|
286
|
+
createdAt: true,
|
|
287
|
+
},
|
|
288
|
+
sort: {
|
|
289
|
+
createdAt: SortOrder.Descending,
|
|
290
|
+
},
|
|
291
|
+
props: {
|
|
292
|
+
isRoot: true,
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
total: total.toNumber(),
|
|
298
|
+
unused: unused,
|
|
299
|
+
generatedAt: newest?.createdAt || null,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* How many unused codes this user has left.
|
|
305
|
+
*
|
|
306
|
+
* Split out from `getStatusForUser` because the login path needs exactly
|
|
307
|
+
* this number and nothing else: it decides whether the two factor challenge
|
|
308
|
+
* screen offers "use a backup code" at all, and it is answered on every
|
|
309
|
+
* two-factor sign-in.
|
|
310
|
+
*
|
|
311
|
+
* `usedAt: QueryHelper.isNull()` rather than `usedAt: null`. A bare null
|
|
312
|
+
* predicate is dropped by TypeORM rather than compiled to `IS NULL`, so the
|
|
313
|
+
* count would silently include spent codes and the login page would offer a
|
|
314
|
+
* recovery route to a user with nothing left to recover with.
|
|
315
|
+
*/
|
|
316
|
+
@CaptureSpan()
|
|
317
|
+
public async countUnusedForUser(data: { userId: ObjectID }): Promise<number> {
|
|
318
|
+
const unused: PositiveNumber = await this.countBy({
|
|
319
|
+
query: {
|
|
320
|
+
userId: data.userId,
|
|
321
|
+
usedAt: QueryHelper.isNull(),
|
|
322
|
+
},
|
|
323
|
+
props: {
|
|
324
|
+
isRoot: true,
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
return unused.toNumber();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Drop every backup code this user has.
|
|
333
|
+
*
|
|
334
|
+
* Called by regeneration, and by UserService.resetTwoFactorAuth -- an
|
|
335
|
+
* operator resetting two factor auth for somebody who lost a device must
|
|
336
|
+
* take the recovery codes with it. Leaving them behind would mean the reset
|
|
337
|
+
* did not actually revoke the account's second-factor material, which is the
|
|
338
|
+
* one thing the operator pressed the button to do.
|
|
339
|
+
*/
|
|
340
|
+
@CaptureSpan()
|
|
341
|
+
public async deleteAllForUser(data: { userId: ObjectID }): Promise<void> {
|
|
342
|
+
await this.deleteBy({
|
|
343
|
+
query: {
|
|
344
|
+
userId: data.userId,
|
|
345
|
+
},
|
|
346
|
+
limit: LIMIT_MAX,
|
|
347
|
+
skip: 0,
|
|
348
|
+
props: {
|
|
349
|
+
isRoot: true,
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export default new Service();
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { EncryptionSecret } from "../EnvironmentConfig";
|
|
2
|
+
import ObjectID from "../../Types/ObjectID";
|
|
3
|
+
import CaptureSpan from "./Telemetry/CaptureSpan";
|
|
4
|
+
import crypto from "crypto";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* Single-use recovery codes for an account whose second factor is gone --
|
|
8
|
+
* the phone that held the authenticator app, or the security key that is now
|
|
9
|
+
* in a taxi somewhere.
|
|
10
|
+
*
|
|
11
|
+
* WHY THIS IS SERVER-ONLY
|
|
12
|
+
*
|
|
13
|
+
* Nothing in this file falls back to Math.random. Common/Utils/UUID does, on
|
|
14
|
+
* purpose, because it is bundled into the dashboard and has to keep producing
|
|
15
|
+
* well-formed ids in a browser with no Web Crypto -- which is exactly why
|
|
16
|
+
* ObjectID.generate() must never mint a code here. A backup code is a
|
|
17
|
+
* password-equivalent credential; if the platform cannot produce secure
|
|
18
|
+
* randomness, generation must throw rather than quietly emit something
|
|
19
|
+
* predictable. Common/Server/Utils/VerificationCode.ts makes the same call for
|
|
20
|
+
* the same reason.
|
|
21
|
+
*
|
|
22
|
+
* THE CODE SPACE
|
|
23
|
+
*
|
|
24
|
+
* Ten characters drawn uniformly from a 32 symbol alphabet is 2^50 codes, and
|
|
25
|
+
* a user holds ten of them at once -- so a blind guess lands with probability
|
|
26
|
+
* ~10/2^50, about one in 10^14. That is far beyond anything the rate limiter
|
|
27
|
+
* needs to defend, which matters because it settles the design question a TOTP
|
|
28
|
+
* code cannot settle: the six digit space is small enough that the limiter IS
|
|
29
|
+
* the control, whereas here the code itself is.
|
|
30
|
+
*
|
|
31
|
+
* THE HASH
|
|
32
|
+
*
|
|
33
|
+
* Codes are stored as HMAC-SHA256 keyed by the instance's EncryptionSecret,
|
|
34
|
+
* never in the clear, and the fast keyed-digest lane is the RIGHT one here
|
|
35
|
+
* rather than scrypt. scrypt exists to make guessing a low-entropy,
|
|
36
|
+
* human-chosen secret expensive; a code minted above has no low-entropy
|
|
37
|
+
* structure to guess, so the cost would buy nothing and would be paid on every
|
|
38
|
+
* verification. Keying with the EncryptionSecret is what a bare SHA-256 would
|
|
39
|
+
* miss: it lives in configuration rather than in Postgres, so a database dump
|
|
40
|
+
* on its own cannot be run through a dictionary of every possible code.
|
|
41
|
+
*
|
|
42
|
+
* The digest is deterministic given (userId, code), which is deliberate and is
|
|
43
|
+
* what makes single-use consumption a single conditional UPDATE rather than a
|
|
44
|
+
* read of every one of the user's rows followed by a comparison of each. A
|
|
45
|
+
* per-row salt would forfeit that for no gain -- see the note on domain
|
|
46
|
+
* separation below for the property it would have been bought for, which the
|
|
47
|
+
* userId already provides.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Crockford's Base32 alphabet: the digits and the uppercase letters, minus
|
|
52
|
+
* I, L, O and U.
|
|
53
|
+
*
|
|
54
|
+
* These codes get read off a screen and typed back in months later, possibly
|
|
55
|
+
* from a piece of paper in a drawer, so the alphabet is chosen for the eye
|
|
56
|
+
* rather than for density. I/1, L/1 and O/0 are the pairs people transcribe
|
|
57
|
+
* wrongly; U is dropped by Crockford so that no code can spell an obscenity
|
|
58
|
+
* at a user who did nothing to deserve one.
|
|
59
|
+
*
|
|
60
|
+
* Exactly 32 symbols, so each character carries a clean five bits and the
|
|
61
|
+
* entropy arithmetic in the header comment is exact rather than approximate.
|
|
62
|
+
*/
|
|
63
|
+
export const BackupCodeAlphabet: string = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
64
|
+
|
|
65
|
+
/** Characters per code. Ten symbols over a 32 symbol alphabet is 2^50. */
|
|
66
|
+
export const BackupCodeLength: number = 10;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How many codes a user is issued at once.
|
|
70
|
+
*
|
|
71
|
+
* Ten is enough that losing a phone does not become an emergency after the
|
|
72
|
+
* second sign-in, and small enough that the printed list is one short block a
|
|
73
|
+
* person will actually keep.
|
|
74
|
+
*/
|
|
75
|
+
export const BackupCodeSetSize: number = 10;
|
|
76
|
+
|
|
77
|
+
/** Characters per group in the displayed form, e.g. `AB3D5-9XZQ2`. */
|
|
78
|
+
const DISPLAY_GROUP_LENGTH: number = 5;
|
|
79
|
+
|
|
80
|
+
/*
|
|
81
|
+
* Prefixed into every digest so a stored hash is bound to the scheme that
|
|
82
|
+
* produced it. If the construction below ever has to change, old rows keep
|
|
83
|
+
* verifying under the version they were written with instead of silently
|
|
84
|
+
* failing to match and locking a user out of their own recovery codes.
|
|
85
|
+
*/
|
|
86
|
+
const HASH_SCHEME_VERSION: string = "v1";
|
|
87
|
+
|
|
88
|
+
/*
|
|
89
|
+
* Characters a person plausibly types in place of a symbol that is not in the
|
|
90
|
+
* alphabet. Applied before the strip below, so `O` becomes `0` rather than
|
|
91
|
+
* being deleted -- deleting it would shorten the code and guarantee a
|
|
92
|
+
* mismatch, which is the confusing failure this map exists to avoid.
|
|
93
|
+
*/
|
|
94
|
+
const AMBIGUOUS_CHARACTER_MAP: Record<string, string> = {
|
|
95
|
+
I: "1",
|
|
96
|
+
L: "1",
|
|
97
|
+
O: "0",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export default class TwoFactorBackupCode {
|
|
101
|
+
/**
|
|
102
|
+
* One code, drawn uniformly from the alphabet above.
|
|
103
|
+
*
|
|
104
|
+
* `crypto.randomInt` per character rather than `randomBytes(n) % 32`. The
|
|
105
|
+
* modulo version happens to be uniform for this alphabet only because 32
|
|
106
|
+
* divides 256, and it stops being uniform the moment somebody edits the
|
|
107
|
+
* alphabet -- silently, with no test that would notice. `randomInt` rejects
|
|
108
|
+
* out-of-range draws internally for whatever bound it is given, so the
|
|
109
|
+
* uniformity does not depend on a coincidence nobody wrote down.
|
|
110
|
+
*/
|
|
111
|
+
@CaptureSpan()
|
|
112
|
+
public static generateCode(): string {
|
|
113
|
+
let code: string = "";
|
|
114
|
+
|
|
115
|
+
for (let index: number = 0; index < BackupCodeLength; index++) {
|
|
116
|
+
code += BackupCodeAlphabet.charAt(
|
|
117
|
+
crypto.randomInt(0, BackupCodeAlphabet.length),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return code;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A full set of distinct codes.
|
|
126
|
+
*
|
|
127
|
+
* Duplicates are not a security problem -- at 2^50 the birthday odds across
|
|
128
|
+
* ten draws are around 4e-14 -- but a duplicate WOULD be a correctness
|
|
129
|
+
* problem downstream: two rows sharing a digest means consuming one leaves a
|
|
130
|
+
* second, identical, still-valid code behind, so a "single-use" code would
|
|
131
|
+
* work twice. Cheaper to rule out here than to reason about there.
|
|
132
|
+
*/
|
|
133
|
+
@CaptureSpan()
|
|
134
|
+
public static generateCodeSet(
|
|
135
|
+
count: number = BackupCodeSetSize,
|
|
136
|
+
): Array<string> {
|
|
137
|
+
const codes: Set<string> = new Set<string>();
|
|
138
|
+
|
|
139
|
+
while (codes.size < count) {
|
|
140
|
+
codes.add(TwoFactorBackupCode.generateCode());
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return Array.from(codes);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The form shown to the user: one hyphen in the middle, for the same reason
|
|
148
|
+
* the alphabet drops ambiguous letters -- a ten character run is hard to
|
|
149
|
+
* read back without losing your place.
|
|
150
|
+
*
|
|
151
|
+
* Purely cosmetic. `normalizeCode` strips the hyphen straight back out, so
|
|
152
|
+
* a user may type the code with it, without it, or with the spaces their
|
|
153
|
+
* password manager pasted in.
|
|
154
|
+
*/
|
|
155
|
+
@CaptureSpan()
|
|
156
|
+
public static formatForDisplay(code: string): string {
|
|
157
|
+
const groups: Array<string> = [];
|
|
158
|
+
|
|
159
|
+
for (
|
|
160
|
+
let index: number = 0;
|
|
161
|
+
index < code.length;
|
|
162
|
+
index += DISPLAY_GROUP_LENGTH
|
|
163
|
+
) {
|
|
164
|
+
groups.push(code.substring(index, index + DISPLAY_GROUP_LENGTH));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return groups.join("-");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Reduce whatever the user typed to the canonical form the digest is
|
|
172
|
+
* computed over.
|
|
173
|
+
*
|
|
174
|
+
* Everything here is about not rejecting somebody who supplied exactly the
|
|
175
|
+
* right secret material: the display hyphen, the spaces a clipboard adds,
|
|
176
|
+
* lowercase from a phone keyboard, and the three transcription confusions
|
|
177
|
+
* the alphabet was chosen to make survivable. Anything still outside the
|
|
178
|
+
* alphabet after that is dropped rather than rejected -- this is a
|
|
179
|
+
* canonicaliser, not a validator; the digest comparison is what decides
|
|
180
|
+
* whether a code is real.
|
|
181
|
+
*
|
|
182
|
+
* @param rawCode - The code exactly as submitted.
|
|
183
|
+
* @returns The code reduced to alphabet symbols, uppercase.
|
|
184
|
+
*/
|
|
185
|
+
@CaptureSpan()
|
|
186
|
+
public static normalizeCode(rawCode: string): string {
|
|
187
|
+
/*
|
|
188
|
+
* The code arrives straight off a JSON body, so it is only a string
|
|
189
|
+
* because the client chose to send one. A number or an object here must
|
|
190
|
+
* fail verification, not throw out of `.toUpperCase` and surface as a 500.
|
|
191
|
+
*/
|
|
192
|
+
if (typeof rawCode !== "string" || !rawCode) {
|
|
193
|
+
return "";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let normalized: string = "";
|
|
197
|
+
|
|
198
|
+
for (const character of rawCode.toUpperCase()) {
|
|
199
|
+
const mapped: string = AMBIGUOUS_CHARACTER_MAP[character] || character;
|
|
200
|
+
|
|
201
|
+
if (BackupCodeAlphabet.includes(mapped)) {
|
|
202
|
+
normalized += mapped;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return normalized;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The digest stored for one of `userId`'s codes.
|
|
211
|
+
*
|
|
212
|
+
* Domain separated by the owning user, which is what stops one precomputed
|
|
213
|
+
* table from inverting every account's codes at once and stops two users who
|
|
214
|
+
* happen to be issued the same code from being visibly linked by a matching
|
|
215
|
+
* row. The user id is known at verification time -- the password has already
|
|
216
|
+
* been accepted by then -- so binding to it costs nothing.
|
|
217
|
+
*
|
|
218
|
+
* Both parts are LENGTH-PREFIXED into the message rather than merely
|
|
219
|
+
* concatenated, so no pair of (userId, code) values can be rearranged into
|
|
220
|
+
* the same byte string as another pair. Concatenation alone is a real
|
|
221
|
+
* ambiguity, not a theoretical one: without prefixes, ("ab", "cd") and
|
|
222
|
+
* ("abc", "d") hash identically.
|
|
223
|
+
*/
|
|
224
|
+
@CaptureSpan()
|
|
225
|
+
public static hashCode(data: { code: string; userId: ObjectID }): string {
|
|
226
|
+
const message: string = [
|
|
227
|
+
HASH_SCHEME_VERSION,
|
|
228
|
+
data.userId.toString(),
|
|
229
|
+
TwoFactorBackupCode.normalizeCode(data.code),
|
|
230
|
+
]
|
|
231
|
+
.map((part: string) => {
|
|
232
|
+
return `${part.length}:${part}`;
|
|
233
|
+
})
|
|
234
|
+
.join("");
|
|
235
|
+
|
|
236
|
+
return crypto
|
|
237
|
+
.createHmac("sha256", EncryptionSecret.toString())
|
|
238
|
+
.update(message)
|
|
239
|
+
.digest("hex");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Compare two digests without leaking, through timing, how many leading
|
|
244
|
+
* characters matched.
|
|
245
|
+
*
|
|
246
|
+
* The consume path compares digests in Postgres rather than here, so this is
|
|
247
|
+
* for callers that already hold both -- but it exists so that nobody writing
|
|
248
|
+
* one reaches for `===` and hands an attacker a way to recover a stored hash
|
|
249
|
+
* one character at a time.
|
|
250
|
+
*
|
|
251
|
+
* Lengths are compared first because `crypto.timingSafeEqual` THROWS on a
|
|
252
|
+
* length mismatch. Digests here are always 64 hex characters, so an early
|
|
253
|
+
* exit only happens on malformed input, where the length is not the secret.
|
|
254
|
+
*/
|
|
255
|
+
@CaptureSpan()
|
|
256
|
+
public static isHashEqual(a: string, b: string): boolean {
|
|
257
|
+
if (!a || !b || a.length !== b.length) {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return crypto.timingSafeEqual(
|
|
262
|
+
Buffer.from(a, "utf8"),
|
|
263
|
+
Buffer.from(b, "utf8"),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|