@meith/accounts 0.35.0 → 0.36.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/accounts",
3
- "version": "0.35.0",
3
+ "version": "0.36.0",
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.0",
24
- "@meith/subscriptions": "0.35.0"
23
+ "@meith/i18n": "0.36.0",
24
+ "@meith/subscriptions": "0.36.0"
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
  }
@@ -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 ?? fetch
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
 
@@ -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 failures = await this.store.loginAttempts.countFailuresSince(counter.key, since)
345
- if (failures >= max) {
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 counter of counters) {
378
- if (counter.clearOnSuccess === false) continue
379
- await this.store.loginAttempts.record(counter.key, true, at)
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 assertSecondFactorAttemptsLeft(userId: number): Promise<void> {
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 failures = await this.store.loginAttempts.countFailuresSince(
454
+ const { count } = await this.store.loginAttempts.recordFailureAndCount(
457
455
  secondFactorBucket(userId),
458
456
  since,
457
+ this.now(),
459
458
  )
460
459
 
461
- if (failures >= max) {
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
  }