242 lines
9.3 KiB
JavaScript
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,
|
|
}
|
|
}
|
|
}
|