forked from FoundKeyGang/FoundKey
server: indicate Retry-After when rate limiting
This refactors the rate limiting code to throw an ApiError directly. Changelog: Added
This commit is contained in:
parent
9b76c805ec
commit
57cf6c7163
4 changed files with 27 additions and 23 deletions
|
@ -35,10 +35,8 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
|
||||||
limit.key = ep.name;
|
limit.key = ep.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit
|
// Rate limit, may throw an ApiError
|
||||||
await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor).catch(() => {
|
await limiter(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor);
|
||||||
throw new ApiError('RATE_LIMIT_EXCEEDED');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ep.meta.requireCredential && user == null) {
|
if (ep.meta.requireCredential && user == null) {
|
||||||
|
|
|
@ -29,8 +29,16 @@ export class ApiError extends Error {
|
||||||
*/
|
*/
|
||||||
public apply(ctx: Koa.Context, endpoint: string): void {
|
public apply(ctx: Koa.Context, endpoint: string): void {
|
||||||
ctx.status = this.httpStatusCode;
|
ctx.status = this.httpStatusCode;
|
||||||
if (ctx.status === 401) {
|
// set additional headers
|
||||||
ctx.response.set('WWW-Authenticate', 'Bearer');
|
switch (ctx.status) {
|
||||||
|
case 401:
|
||||||
|
ctx.response.set('WWW-Authenticate', 'Bearer');
|
||||||
|
break;
|
||||||
|
case 429:
|
||||||
|
if (typeof this.info === 'object' && typeof this.info.reset === 'number') {
|
||||||
|
ctx.respose.set('Retry-After', Math.floor(this.info.reset - (Date.now() / 1000)));
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
error: {
|
error: {
|
||||||
|
|
|
@ -2,11 +2,12 @@ import Limiter from 'ratelimiter';
|
||||||
import Logger from '@/services/logger.js';
|
import Logger from '@/services/logger.js';
|
||||||
import { redisClient } from '@/db/redis.js';
|
import { redisClient } from '@/db/redis.js';
|
||||||
import { IEndpointMeta } from './endpoints.js';
|
import { IEndpointMeta } from './endpoints.js';
|
||||||
|
import { ApiError } from './error.js';
|
||||||
|
|
||||||
const logger = new Logger('limiter');
|
const logger = new Logger('limiter');
|
||||||
|
|
||||||
export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((ok, reject) => {
|
export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string) => new Promise<void>((resolve) => {
|
||||||
if (process.env.NODE_ENV === 'test') ok();
|
if (process.env.NODE_ENV === 'test') resolve();
|
||||||
|
|
||||||
const hasShortTermLimit = typeof limitation.minInterval === 'number';
|
const hasShortTermLimit = typeof limitation.minInterval === 'number';
|
||||||
|
|
||||||
|
@ -19,10 +20,10 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
|
||||||
} else if (hasLongTermLimit) {
|
} else if (hasLongTermLimit) {
|
||||||
max();
|
max();
|
||||||
} else {
|
} else {
|
||||||
ok();
|
resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Short-term limit
|
// Short-term limit, calls long term limit if appropriate.
|
||||||
function min(): void {
|
function min(): void {
|
||||||
const minIntervalLimiter = new Limiter({
|
const minIntervalLimiter = new Limiter({
|
||||||
id: `${actor}:${limitation.key}:min`,
|
id: `${actor}:${limitation.key}:min`,
|
||||||
|
@ -33,18 +34,19 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
|
||||||
|
|
||||||
minIntervalLimiter.get((err, info) => {
|
minIntervalLimiter.get((err, info) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return reject('ERR');
|
logger.error(err);
|
||||||
|
throw new ApiError('INTERNAL_ERROR');
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
|
logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
|
||||||
|
|
||||||
if (info.remaining === 0) {
|
if (info.remaining === 0) {
|
||||||
reject('BRIEF_REQUEST_INTERVAL');
|
throw new ApiError('RATE_LIMIT_EXCEEDED', info);
|
||||||
} else {
|
} else {
|
||||||
if (hasLongTermLimit) {
|
if (hasLongTermLimit) {
|
||||||
max();
|
max();
|
||||||
} else {
|
} else {
|
||||||
ok();
|
resolve();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -61,15 +63,16 @@ export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable<
|
||||||
|
|
||||||
limiter.get((err, info) => {
|
limiter.get((err, info) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return reject('ERR');
|
logger.error(err);
|
||||||
|
throw new ApiError('INTERNAL_ERROR');
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
|
logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
|
||||||
|
|
||||||
if (info.remaining === 0) {
|
if (info.remaining === 0) {
|
||||||
reject('RATE_LIMIT_EXCEEDED');
|
throw new ApiError('RATE_LIMIT_EXCEEDED', info);
|
||||||
} else {
|
} else {
|
||||||
ok();
|
resolve();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,13 +25,8 @@ export default async (ctx: Koa.Context) => {
|
||||||
new ApiError(e, info).apply(ctx, 'signin');
|
new ApiError(e, info).apply(ctx, 'signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// not more than 1 attempt per second and not more than 10 attempts per hour
|
||||||
// not more than 1 attempt per second and not more than 10 attempts per hour
|
await limiter({ key: 'signin', duration: HOUR, max: 10, minInterval: SECOND }, getIpHash(ctx.ip));
|
||||||
await limiter({ key: 'signin', duration: HOUR, max: 10, minInterval: SECOND }, getIpHash(ctx.ip));
|
|
||||||
} catch (err) {
|
|
||||||
error('RATE_LIMIT_EXCEEDED');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof username !== 'string') {
|
if (typeof username !== 'string') {
|
||||||
error('INVALID_PARAM', { param: 'username', reason: 'not a string' });
|
error('INVALID_PARAM', { param: 'username', reason: 'not a string' });
|
||||||
|
|
Loading…
Reference in a new issue