@meith/accounts 0.35.0 → 0.35.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.
- package/package.json +3 -3
- package/src/federation/catalog.ts +5 -0
- package/src/federation/oidc.ts +11 -1
- package/src/memory-repos.ts +26 -1
- package/src/ports.ts +6 -0
- package/src/service.ts +16 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/accounts",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"hash-wasm": "^4.12.0",
|
|
23
|
-
"@meith/i18n": "0.35.
|
|
24
|
-
"@meith/subscriptions": "0.35.
|
|
23
|
+
"@meith/i18n": "0.35.1",
|
|
24
|
+
"@meith/subscriptions": "0.35.1"
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -25,6 +25,7 @@ const GOOGLE_ISSUER = 'https://accounts.google.com'
|
|
|
25
25
|
|
|
26
26
|
export interface ProviderBuildDeps {
|
|
27
27
|
readonly fetcher?: Fetcher
|
|
28
|
+
readonly allowPrivateHosts?: boolean
|
|
28
29
|
readonly clock?: () => Date
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -65,6 +66,9 @@ export function providerFor(
|
|
|
65
66
|
clientSecret: options.google.clientSecret,
|
|
66
67
|
scopes: DEFAULT_OIDC_SCOPES,
|
|
67
68
|
...(deps.fetcher === undefined ? {} : { fetcher: deps.fetcher }),
|
|
69
|
+
...(deps.allowPrivateHosts === undefined
|
|
70
|
+
? {}
|
|
71
|
+
: { allowPrivateHosts: deps.allowPrivateHosts }),
|
|
68
72
|
...(deps.clock === undefined ? {} : { clock: deps.clock }),
|
|
69
73
|
})
|
|
70
74
|
}
|
|
@@ -79,6 +83,7 @@ export function providerFor(
|
|
|
79
83
|
clientSecret: options.oidc.clientSecret,
|
|
80
84
|
scopes: parseScopes(options.oidc.scopes),
|
|
81
85
|
...(deps.fetcher === undefined ? {} : { fetcher: deps.fetcher }),
|
|
86
|
+
...(deps.allowPrivateHosts === undefined ? {} : { allowPrivateHosts: deps.allowPrivateHosts }),
|
|
82
87
|
...(deps.clock === undefined ? {} : { clock: deps.clock }),
|
|
83
88
|
})
|
|
84
89
|
}
|
package/src/federation/oidc.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ConfigurationError, ValidationError } from '@meith/core'
|
|
2
|
+
import { assertAllowedUrl } from '@meith/core/outbound'
|
|
2
3
|
import { msg } from '@meith/i18n'
|
|
3
4
|
|
|
4
5
|
import { fetchJson, readBoolean, readString } from './http'
|
|
@@ -21,9 +22,18 @@ export interface OidcProviderConfig {
|
|
|
21
22
|
readonly clientSecret: string
|
|
22
23
|
readonly scopes: readonly string[]
|
|
23
24
|
readonly fetcher?: Fetcher
|
|
25
|
+
readonly allowPrivateHosts?: boolean
|
|
24
26
|
readonly clock?: () => Date
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
function guardedFetcher(allowPrivateHosts: boolean): Fetcher {
|
|
30
|
+
return async (input, init) => {
|
|
31
|
+
const target = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
|
32
|
+
assertAllowedUrl(target, { allowPrivateHosts })
|
|
33
|
+
return fetch(input, { ...init, redirect: 'error' })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
interface Discovery {
|
|
28
38
|
readonly issuer: string
|
|
29
39
|
readonly authorizationEndpoint: string
|
|
@@ -35,7 +45,7 @@ interface Discovery {
|
|
|
35
45
|
export const DEFAULT_OIDC_SCOPES = ['openid', 'email', 'profile'] as const
|
|
36
46
|
|
|
37
47
|
export function oidcProvider(config: OidcProviderConfig): IdentityProvider {
|
|
38
|
-
const fetcher = config.fetcher ??
|
|
48
|
+
const fetcher = config.fetcher ?? guardedFetcher(config.allowPrivateHosts ?? false)
|
|
39
49
|
const clock = config.clock ?? (() => new Date())
|
|
40
50
|
const issuer = config.issuer.replace(/\/$/, '')
|
|
41
51
|
|
package/src/memory-repos.ts
CHANGED
|
@@ -414,19 +414,44 @@ class MemoryCredentialTokens implements CredentialTokenRepository {
|
|
|
414
414
|
}
|
|
415
415
|
|
|
416
416
|
interface Attempt {
|
|
417
|
+
id: number
|
|
417
418
|
succeeded: boolean
|
|
418
419
|
at: Date
|
|
419
420
|
}
|
|
420
421
|
|
|
421
422
|
class MemoryLoginAttempts implements LoginAttemptRepository {
|
|
422
423
|
private readonly buckets = new Map<string, Attempt[]>()
|
|
424
|
+
private nextId = 1
|
|
423
425
|
|
|
424
426
|
async record(bucket: string, succeeded: boolean, at: Date): Promise<void> {
|
|
425
427
|
const list = this.buckets.get(bucket) ?? []
|
|
426
|
-
list.push({ succeeded, at })
|
|
428
|
+
list.push({ id: this.nextId++, succeeded, at })
|
|
427
429
|
this.buckets.set(bucket, list)
|
|
428
430
|
}
|
|
429
431
|
|
|
432
|
+
async recordFailureAndCount(
|
|
433
|
+
bucket: string,
|
|
434
|
+
since: Date,
|
|
435
|
+
at: Date,
|
|
436
|
+
): Promise<{ readonly id: number; readonly count: number }> {
|
|
437
|
+
const list = this.buckets.get(bucket) ?? []
|
|
438
|
+
const id = this.nextId++
|
|
439
|
+
list.push({ id, succeeded: false, at })
|
|
440
|
+
this.buckets.set(bucket, list)
|
|
441
|
+
const count = list.filter((a) => !a.succeeded && a.at.getTime() > since.getTime()).length
|
|
442
|
+
return { id, count }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async removeAttempt(id: number): Promise<void> {
|
|
446
|
+
for (const list of this.buckets.values()) {
|
|
447
|
+
const index = list.findIndex((a) => a.id === id)
|
|
448
|
+
if (index !== -1) {
|
|
449
|
+
list.splice(index, 1)
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
430
455
|
async countFailuresSince(bucket: string, since: Date): Promise<number> {
|
|
431
456
|
const list = this.buckets.get(bucket) ?? []
|
|
432
457
|
return list.filter((a) => !a.succeeded && a.at.getTime() >= since.getTime()).length
|
package/src/ports.ts
CHANGED
|
@@ -316,6 +316,12 @@ export interface AuthEventRepository {
|
|
|
316
316
|
|
|
317
317
|
export interface LoginAttemptRepository {
|
|
318
318
|
record(bucket: string, succeeded: boolean, at: Date): Promise<void>
|
|
319
|
+
recordFailureAndCount(
|
|
320
|
+
bucket: string,
|
|
321
|
+
since: Date,
|
|
322
|
+
at: Date,
|
|
323
|
+
): Promise<{ readonly id: number; readonly count: number }>
|
|
324
|
+
removeAttempt(id: number): Promise<void>
|
|
319
325
|
countFailuresSince(bucket: string, since: Date): Promise<number>
|
|
320
326
|
clear(bucket: string): Promise<void>
|
|
321
327
|
}
|
package/src/service.ts
CHANGED
|
@@ -338,11 +338,18 @@ export class IdentityService {
|
|
|
338
338
|
await this.assertNotFiltered({ ip: context.ip })
|
|
339
339
|
|
|
340
340
|
const since = new Date(at.getTime() - this.config.lockoutMinutes * 60_000)
|
|
341
|
+
const spent: { readonly key: string; readonly id: number; readonly clearOnSuccess: boolean }[] =
|
|
342
|
+
[]
|
|
341
343
|
for (const counter of counters) {
|
|
342
344
|
const max = counter.max ?? this.config.maxLoginAttempts
|
|
343
345
|
if (max <= 0) continue
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
+
const { id, count } = await this.store.loginAttempts.recordFailureAndCount(
|
|
347
|
+
counter.key,
|
|
348
|
+
since,
|
|
349
|
+
at,
|
|
350
|
+
)
|
|
351
|
+
spent.push({ key: counter.key, id, clearOnSuccess: counter.clearOnSuccess !== false })
|
|
352
|
+
if (count > max) {
|
|
346
353
|
throw new ForbiddenError(msg('error.accounts.too-many-failed-attempts-please'))
|
|
347
354
|
}
|
|
348
355
|
}
|
|
@@ -355,29 +362,20 @@ export class IdentityService {
|
|
|
355
362
|
const encoded = account?.passwordHash ?? (await dummyHash())
|
|
356
363
|
const ok = await verifyPassword(password, encoded)
|
|
357
364
|
|
|
358
|
-
const recordFailure = async (): Promise<void> => {
|
|
359
|
-
for (const counter of counters) {
|
|
360
|
-
await this.store.loginAttempts.record(counter.key, false, at)
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
365
|
if (!account || !ok || account.passwordHash === null) {
|
|
365
|
-
await recordFailure()
|
|
366
366
|
throw new ValidationError(msg('error.accounts.incorrect-username-password'))
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
const refusal = await this.signInRefusal(account)
|
|
370
370
|
if (refusal !== null) {
|
|
371
|
-
await recordFailure()
|
|
372
371
|
throw new ForbiddenError(refusal)
|
|
373
372
|
}
|
|
374
373
|
|
|
375
374
|
await this.assertNotFiltered({ username: account.username, email: account.email })
|
|
376
375
|
|
|
377
|
-
for (const
|
|
378
|
-
if (
|
|
379
|
-
await this.store.loginAttempts.
|
|
380
|
-
await this.store.loginAttempts.clear(counter.key)
|
|
376
|
+
for (const entry of spent) {
|
|
377
|
+
if (entry.clearOnSuccess) await this.store.loginAttempts.clear(entry.key)
|
|
378
|
+
else await this.store.loginAttempts.removeAttempt(entry.id)
|
|
381
379
|
}
|
|
382
380
|
|
|
383
381
|
if (needsRehash(encoded)) {
|
|
@@ -448,25 +446,22 @@ export class IdentityService {
|
|
|
448
446
|
await this.store.tokens.revokeAllForUser(userId, 'second_factor')
|
|
449
447
|
}
|
|
450
448
|
|
|
451
|
-
async
|
|
449
|
+
async spendSecondFactorAttempt(userId: number): Promise<void> {
|
|
452
450
|
const max = this.config.maxLoginAttempts
|
|
453
451
|
if (max <= 0) return
|
|
454
452
|
|
|
455
453
|
const since = new Date(this.now().getTime() - this.config.lockoutMinutes * 60_000)
|
|
456
|
-
const
|
|
454
|
+
const { count } = await this.store.loginAttempts.recordFailureAndCount(
|
|
457
455
|
secondFactorBucket(userId),
|
|
458
456
|
since,
|
|
457
|
+
this.now(),
|
|
459
458
|
)
|
|
460
459
|
|
|
461
|
-
if (
|
|
460
|
+
if (count > max) {
|
|
462
461
|
throw new ForbiddenError(msg('error.accounts.too-many-wrong-codes-please'))
|
|
463
462
|
}
|
|
464
463
|
}
|
|
465
464
|
|
|
466
|
-
async recordSecondFactorFailure(userId: number): Promise<void> {
|
|
467
|
-
await this.store.loginAttempts.record(secondFactorBucket(userId), false, this.now())
|
|
468
|
-
}
|
|
469
|
-
|
|
470
465
|
async clearSecondFactorFailures(userId: number): Promise<void> {
|
|
471
466
|
await this.store.loginAttempts.clear(secondFactorBucket(userId))
|
|
472
467
|
}
|