Files

242 lines
9.3 KiB
JavaScript

import { AccessError } from './errors.mjs'
export class AuthenticationLimiter {
constructor({
globalLimit = 20,
windowMs = 60_000,
maxConcurrent = 2,
backoffThreshold = 5,
initialBackoffMs = 30_000,
maxBackoffMs = 15 * 60_000,
consecutiveResetMs = 24 * 60 * 60_000,
sourceWindows,
globalWindows,
clock = () => Date.now(),
} = {}) {
this.globalLimit = globalLimit
this.windowMs = windowMs
this.maxConcurrent = maxConcurrent
this.backoffThreshold = backoffThreshold
this.initialBackoffMs = initialBackoffMs
this.maxBackoffMs = maxBackoffMs
this.consecutiveResetMs = consecutiveResetMs
this.sourceWindows = sourceWindows ?? [
{ limit: 12, windowMs: 60 * 60_000 },
{ limit: 24, windowMs: 24 * 60 * 60_000 },
]
this.globalWindows = globalWindows ?? [
{ limit: globalLimit, windowMs },
{ limit: 60, windowMs: 60 * 60_000 },
{ limit: 120, windowMs: 24 * 60 * 60_000 },
]
this.maxSourceWindowMs = Math.max(...this.sourceWindows.map(value => value.windowMs))
this.maxGlobalWindowMs = Math.max(...this.globalWindows.map(value => value.windowMs))
this.clock = clock
this.global = []
this.sources = new Map()
this.consecutiveFailures = new Map()
this.active = 0
this.lastPruneAt = Number.NEGATIVE_INFINITY
}
prune(values, now, windowMs = this.windowMs) {
while (values.length > 0 && this.timestamp(values[0]) <= now - windowMs) values.shift()
}
timestamp(value) { return typeof value === 'number' ? value : value.at }
retryAfterSeconds(values, windows, now) {
let retryAfterMs = 0
for (const { limit, windowMs } of windows) {
const withinWindow = values.filter(value => this.timestamp(value) > now - windowMs)
if (withinWindow.length < limit) continue
retryAfterMs = Math.max(
retryAfterMs,
this.timestamp(withinWindow[withinWindow.length - limit]) + windowMs - now,
)
}
return Math.max(0, Math.ceil(retryAfterMs / 1_000))
}
retryKey(accountId, sourceId, credential = 'main') { return `${accountId}\u0000${credential}\u0000${sourceId}` }
retryPrefix(accountId, credential = 'all') {
return credential === 'all' ? `${accountId}\u0000` : `${accountId}\u0000${credential}\u0000`
}
pruneExpiredSources(now) {
if (now - this.lastPruneAt < this.windowMs) return
this.lastPruneAt = now
for (const [key, values] of this.sources) {
this.prune(values, now, this.maxSourceWindowMs)
if (values.length === 0) this.sources.delete(key)
}
for (const [key, retry] of this.consecutiveFailures) {
if (retry.lastFailureAt <= now - this.consecutiveResetMs) {
this.consecutiveFailures.delete(key)
}
}
}
currentRetry(accountId = 'unknown', sourceId = 'unknown', now = this.clock(), credential = 'main') {
const retryKey = this.retryKey(accountId, sourceId, credential)
const retry = this.consecutiveFailures.get(retryKey)
if (retry !== undefined && retry.lastFailureAt <= now - this.consecutiveResetMs) {
this.consecutiveFailures.delete(retryKey)
return undefined
}
return retry
}
checkRetry(accountId = 'unknown', sourceId = 'unknown', credential = 'main') {
const now = this.clock()
const retry = this.currentRetry(accountId, sourceId, now, credential)
if (retry !== undefined && retry.blockedUntil > now) {
const retryAfterSeconds = Math.max(1, Math.ceil((retry.blockedUntil - now) / 1_000))
throw new AccessError(
'AUTHENTICATION_RETRY_REQUIRED',
`authentication retry is available in ${retryAfterSeconds} seconds`,
429,
{ retryAfterSeconds },
)
}
}
enter(accountId = 'unknown', sourceId = 'unknown', credential = 'main') {
this.checkRetry(accountId, sourceId, credential)
const now = this.clock()
this.pruneExpiredSources(now)
const retryKey = this.retryKey(accountId, sourceId, credential)
const source = this.sources.get(retryKey) ?? []
this.prune(source, now, this.maxSourceWindowMs)
this.sources.set(retryKey, source)
this.prune(this.global, now, this.maxGlobalWindowMs)
if (this.active >= this.maxConcurrent) {
throw new AccessError(
'AUTHENTICATION_RATE_LIMITED',
'authentication is temporarily unavailable',
429,
{ retryAfterSeconds: 1 },
)
}
const globalRetryAfterSeconds = this.retryAfterSeconds(this.global, this.globalWindows, now)
const sourceRetryAfterSeconds = this.retryAfterSeconds(source, this.sourceWindows, now)
if (sourceRetryAfterSeconds > 0) {
throw new AccessError(
'AUTHENTICATION_RATE_LIMITED',
`browser authentication is available in ${sourceRetryAfterSeconds} seconds`,
429,
{ retryAfterSeconds: sourceRetryAfterSeconds },
)
}
if (globalRetryAfterSeconds > 0) {
throw new AccessError(
'AUTHENTICATION_RATE_LIMITED',
`administrator authentication is available in ${globalRetryAfterSeconds} seconds`,
429,
{ retryAfterSeconds: globalRetryAfterSeconds },
)
}
this.active += 1
let released = false
const release = success => {
if (released) return this.sourceStatus(accountId, sourceId, credential)
released = true
this.active -= 1
if (success !== true) {
const failedAt = this.clock()
this.global.push({ at: failedAt, credential })
source.push(failedAt)
const previous = this.consecutiveFailures.get(retryKey)
const failures = (previous !== undefined
&& previous.lastFailureAt > failedAt - this.consecutiveResetMs ? previous.failures : 0) + 1
const exponent = Math.max(0, failures - this.backoffThreshold)
const backoffMs = failures < this.backoffThreshold
? 0
: Math.min(this.maxBackoffMs, this.initialBackoffMs * (2 ** exponent))
this.consecutiveFailures.set(retryKey, {
failures,
blockedUntil: backoffMs === 0 ? 0 : failedAt + backoffMs,
lastFailureAt: failedAt,
})
} else {
this.consecutiveFailures.delete(this.retryKey(accountId, sourceId, credential))
this.sources.delete(this.retryKey(accountId, sourceId, credential))
}
return this.sourceStatus(accountId, sourceId, credential)
}
return { release }
}
sourceStatus(accountId = 'unknown', sourceId = 'unknown', credential = 'main') {
const retry = this.currentRetry(accountId, sourceId, this.clock(), credential)
if (retry === undefined) return { consecutiveFailures: 0, retryAfterSeconds: 0 }
return {
consecutiveFailures: retry.failures,
retryAfterSeconds: Math.max(0, Math.ceil((retry.blockedUntil - this.clock()) / 1_000)),
}
}
status(accountId = 'unknown', credential = 'all') {
const now = this.clock()
this.pruneExpiredSources(now)
this.prune(this.global, now, this.maxGlobalWindowMs)
const prefix = this.retryPrefix(accountId, credential)
for (const [key, retry] of this.consecutiveFailures) {
if (key.startsWith(prefix) && retry.lastFailureAt <= now - this.consecutiveResetMs) {
this.consecutiveFailures.delete(key)
}
}
const states = [...this.consecutiveFailures.entries()]
.filter(([key]) => key.startsWith(prefix))
.map(([, retry]) => ({
consecutiveFailures: retry.failures,
retryAfterSeconds: Math.max(0, Math.ceil((retry.blockedUntil - this.clock()) / 1_000)),
}))
const sourcePrefix = prefix
const sourceRetryAfterSeconds = Math.max(0, ...[...this.sources.entries()]
.filter(([key]) => key.startsWith(sourcePrefix))
.map(([, values]) => this.retryAfterSeconds(values, this.sourceWindows, now)))
const global = credential === 'all'
? this.global : this.global.filter(value => value.credential === credential)
return {
activeSources: states.length,
consecutiveFailures: Math.max(0, ...states.map(value => value.consecutiveFailures)),
retryAfterSeconds: Math.max(0, ...states.map(value => value.retryAfterSeconds)),
sourceRetryAfterSeconds,
globalFailures: global.length,
globalRetryAfterSeconds: this.retryAfterSeconds(global, this.globalWindows, now),
}
}
clear(accountId = 'unknown', credential = 'all') {
const previous = this.status(accountId, credential)
const prefix = this.retryPrefix(accountId, credential)
for (const key of this.consecutiveFailures.keys()) {
if (key.startsWith(prefix)) this.consecutiveFailures.delete(key)
}
for (const key of this.sources.keys()) {
if (key.startsWith(prefix)) this.sources.delete(key)
}
this.global = credential === 'all'
? [] : this.global.filter(value => value.credential !== credential)
return { cleared: previous.activeSources > 0 || previous.globalFailures > 0, ...previous }
}
clearGlobal(credential = 'all') {
const now = this.clock()
this.prune(this.global, now, this.maxGlobalWindowMs)
const matching = credential === 'all'
? this.global : this.global.filter(value => value.credential === credential)
const globalFailures = matching.length
const globalRetryAfterSeconds = this.retryAfterSeconds(matching, this.globalWindows, now)
this.global = credential === 'all'
? [] : this.global.filter(value => value.credential !== credential)
return {
cleared: globalFailures > 0,
globalFailures,
globalRetryAfterSeconds,
}
}
}