@crombolo/server 0.13.2 → 0.14.1

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.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Two-factor sign-in with an authenticator app (story 032), for backoffice users
3
+ * and members alike: setting it up, turning it on with ten recovery codes,
4
+ * checking a code at sign-in, and turning it off. The secret is sealed with
5
+ * `CROMBOLO_SECRET_KEY`, so two-factor sign-in cannot be turned on without the
6
+ * key. docs/operations.md.
7
+ *
8
+ * Setup is stateless, as Umbraco's is: the secret goes to the browser with its
9
+ * QR code, and comes back with the first code, which proves the app has it.
10
+ */
11
+ import {
12
+ generateRecoveryCodes,
13
+ generateTotpSecret,
14
+ hashRecoveryCode,
15
+ otpauthUri,
16
+ qrCodeDataUrl,
17
+ verifyTotp,
18
+ } from '@crombolo/auth'
19
+ import { type Db, TwoFactorRepository } from '@crombolo/data'
20
+ import { openSecret, SECRET_KEY_VARIABLE, sealSecret, usableSecretKey } from './secrets.ts'
21
+
22
+ /** Umbraco's names for the authenticator provider, which the backoffice matches by. */
23
+ export const USER_TWO_FACTOR_PROVIDER = 'UmbracoUserAppAuthenticator'
24
+ export const MEMBER_TWO_FACTOR_PROVIDER = 'UmbracoMemberAppAuthenticator'
25
+
26
+ export type TwoFactorResult<T = undefined> =
27
+ | ({ ok: true } & (T extends undefined ? object : { value: T }))
28
+ | { ok: false; reason: 'unavailable' | 'invalidCode' | 'notEnabled'; message: string }
29
+
30
+ export class TwoFactorService {
31
+ #repository: TwoFactorRepository
32
+ #key: string | undefined
33
+ #issuer: string
34
+
35
+ constructor(db: Db, options: { secretKey: string | undefined; issuer: string }) {
36
+ this.#repository = new TwoFactorRepository(db)
37
+ this.#key = usableSecretKey(options.secretKey)
38
+ this.#issuer = options.issuer
39
+ }
40
+
41
+ /** Whether two-factor sign-in can be turned on here: only with the key to seal secrets. */
42
+ get available(): boolean {
43
+ return this.#key !== undefined
44
+ }
45
+
46
+ #unavailable() {
47
+ return {
48
+ ok: false as const,
49
+ reason: 'unavailable' as const,
50
+ message: `Two-factor sign-in needs ${SECRET_KEY_VARIABLE} set on the server.`,
51
+ }
52
+ }
53
+
54
+ providers(ownerKey: string): Promise<string[]> {
55
+ return this.#repository.providers(ownerKey)
56
+ }
57
+
58
+ enabledAmong(ownerKeys: readonly string[]): Promise<Set<string>> {
59
+ return this.#repository.enabledAmong(ownerKeys)
60
+ }
61
+
62
+ /** A new secret, its QR code and the URI in it, for an account named `account`. */
63
+ setup(
64
+ account: string,
65
+ ): TwoFactorResult<{ secret: string; qrCodeSetupImageUrl: string; otpauthUri: string }> {
66
+ if (!this.#key) return this.#unavailable()
67
+ const secret = generateTotpSecret()
68
+ const uri = otpauthUri({ secret, account, issuer: this.#issuer })
69
+ return { ok: true, value: { secret, qrCodeSetupImageUrl: qrCodeDataUrl(uri), otpauthUri: uri } }
70
+ }
71
+
72
+ /** Turns the provider on when `code` is right for `secret`; answers with the recovery codes, shown once. */
73
+ async enable(
74
+ ownerKey: string,
75
+ provider: string,
76
+ secret: string,
77
+ code: string,
78
+ ): Promise<TwoFactorResult<{ recoveryCodes: string[] }>> {
79
+ if (!this.#key) return this.#unavailable()
80
+ const step = verifyTotp(secret, code)
81
+ if (step === undefined)
82
+ return {
83
+ ok: false,
84
+ reason: 'invalidCode',
85
+ message: 'That code is not right. Try the next one.',
86
+ }
87
+ const recoveryCodes = generateRecoveryCodes()
88
+ await this.#repository.enable(
89
+ ownerKey,
90
+ provider,
91
+ sealSecret(secret, this.#key),
92
+ step,
93
+ recoveryCodes.map(hashRecoveryCode),
94
+ )
95
+ return { ok: true, value: { recoveryCodes } }
96
+ }
97
+
98
+ /**
99
+ * Whether a code — from the app, or a recovery code — is this owner's, now. An
100
+ * accepted app code's step is recorded, so it cannot be used again; a recovery
101
+ * code is spent.
102
+ */
103
+ async verify(ownerKey: string, provider: string, code: string): Promise<boolean> {
104
+ const login = await this.#repository.get(ownerKey, provider)
105
+ if (!login || !this.#key) return false
106
+ const digits = code.replace(/\s/g, '')
107
+ if (/^\d{6}$/.test(digits)) {
108
+ let secret: string
109
+ try {
110
+ secret = openSecret(login.sealedSecret, this.#key)
111
+ } catch {
112
+ return false
113
+ }
114
+ const step = verifyTotp(secret, digits, { lastUsedStep: login.lastUsedStep })
115
+ return step !== undefined && (await this.#repository.useStep(ownerKey, provider, step))
116
+ }
117
+ return this.#repository.useRecoveryCode(ownerKey, hashRecoveryCode(code))
118
+ }
119
+
120
+ /** Turns a provider off for its owner, who proves it with a code. */
121
+ async disable(ownerKey: string, provider: string, code: string): Promise<TwoFactorResult> {
122
+ if (!(await this.#repository.get(ownerKey, provider)))
123
+ return { ok: false, reason: 'notEnabled', message: 'Two-factor sign-in is not on.' }
124
+ if (!(await this.verify(ownerKey, provider, code)))
125
+ return { ok: false, reason: 'invalidCode', message: 'That code is not right.' }
126
+ await this.#repository.disable(ownerKey, provider)
127
+ return { ok: true }
128
+ }
129
+
130
+ /** Turns it off for someone else, as an administrator does for a lost phone. */
131
+ async reset(ownerKey: string, provider?: string): Promise<void> {
132
+ await this.#repository.disable(ownerKey, provider)
133
+ }
134
+
135
+ recoveryCodesLeft(ownerKey: string): Promise<number> {
136
+ return this.#repository.recoveryCodesLeft(ownerKey)
137
+ }
138
+ }