온라인 인증 시도와 처리 비용 및 Kakao 응답 경계를 제한한다
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
export interface AuthCounter {
|
||||
key: string;
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
}
|
||||
|
||||
export interface AuthCounterStore {
|
||||
readonly available?: boolean;
|
||||
/** Atomically check every counter, then charge them only when accepted. */
|
||||
consume(counters: AuthCounter[]): Promise<number>;
|
||||
}
|
||||
|
||||
export class AuthBudgetError extends Error {
|
||||
constructor(
|
||||
readonly reason: 'limited' | 'unavailable',
|
||||
readonly retryAfterSeconds = 1
|
||||
) {
|
||||
super(
|
||||
reason === 'limited'
|
||||
? 'Authentication attempts are temporarily limited.'
|
||||
: 'Authentication protection is unavailable.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const CONSUME_SCRIPT = `
|
||||
local retry = 0
|
||||
for i, key in ipairs(KEYS) do
|
||||
local count = tonumber(redis.call('GET', key) or '0')
|
||||
if count >= tonumber(ARGV[i * 2 - 1]) then
|
||||
local ttl = redis.call('PTTL', key)
|
||||
if ttl < 0 then
|
||||
return -1
|
||||
end
|
||||
retry = math.max(retry, math.max(ttl, 1))
|
||||
end
|
||||
end
|
||||
if retry > 0 then return retry end
|
||||
for i, key in ipairs(KEYS) do
|
||||
local count = redis.call('INCR', key)
|
||||
if count == 1 then redis.call('PEXPIRE', key, ARGV[i * 2]) end
|
||||
end
|
||||
return 0
|
||||
`;
|
||||
|
||||
export class RedisAuthCounterStore implements AuthCounterStore {
|
||||
constructor(
|
||||
private readonly client: {
|
||||
readonly isReady?: boolean;
|
||||
withAbortSignal?: (signal: AbortSignal) => {
|
||||
eval: (script: string, options: { keys: string[]; arguments: string[] }) => Promise<unknown>;
|
||||
};
|
||||
eval: (script: string, options: { keys: string[]; arguments: string[] }) => Promise<unknown>;
|
||||
}
|
||||
) {}
|
||||
|
||||
get available(): boolean {
|
||||
return this.client.isReady !== false;
|
||||
}
|
||||
|
||||
async consume(counters: AuthCounter[]): Promise<number> {
|
||||
if (this.client.isReady === false) throw new AuthBudgetError('unavailable');
|
||||
const client = this.client.withAbortSignal?.(AbortSignal.timeout(2000)) ?? this.client;
|
||||
const result = await client.eval(CONSUME_SCRIPT, {
|
||||
keys: counters.map((counter) => counter.key),
|
||||
arguments: counters.flatMap((counter) => [String(counter.limit), String(counter.windowMs)]),
|
||||
});
|
||||
if (typeof result !== 'number' || !Number.isFinite(result) || result < 0) {
|
||||
throw new AuthBudgetError('unavailable');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Direct callers/tests have a bounded local store; the HTTP server always injects Redis. */
|
||||
export class InMemoryAuthCounterStore implements AuthCounterStore {
|
||||
private readonly entries = new Map<string, { count: number; expiresAt: number }>();
|
||||
|
||||
async consume(counters: AuthCounter[]): Promise<number> {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.entries) if (entry.expiresAt <= now) this.entries.delete(key);
|
||||
if (this.entries.size + counters.filter((counter) => !this.entries.has(counter.key)).length > 8192) {
|
||||
throw new AuthBudgetError('unavailable');
|
||||
}
|
||||
let retryMs = 0;
|
||||
for (const counter of counters) {
|
||||
const entry = this.entries.get(counter.key);
|
||||
if (entry && entry.count >= counter.limit) retryMs = Math.max(retryMs, entry.expiresAt - now);
|
||||
}
|
||||
if (retryMs > 0) return retryMs;
|
||||
for (const counter of counters) {
|
||||
const entry = this.entries.get(counter.key) ?? { count: 0, expiresAt: now + counter.windowMs };
|
||||
entry.count += 1;
|
||||
this.entries.set(counter.key, entry);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const EXPENSIVE_ACTIONS = new Set([
|
||||
'auth.bootstrapLocal',
|
||||
'auth.kakaoExchange',
|
||||
'auth.kakaoResolveAccount',
|
||||
'auth.kakaoSetPassword',
|
||||
'auth.register',
|
||||
'auth.registerLocal',
|
||||
'auth.login',
|
||||
'auth.kakaoOtp',
|
||||
'account.changePassword',
|
||||
'account.scheduleDeletion',
|
||||
]);
|
||||
const PUBLIC_ACTIONS = new Set(['auth.passwordKey', 'auth.checkRegistrationField', 'auth.kakaoStart']);
|
||||
|
||||
export const isBudgetedAuthAction = (action: string): boolean =>
|
||||
EXPENSIVE_ACTIONS.has(action) || PUBLIC_ACTIONS.has(action);
|
||||
|
||||
export class AuthAttemptBudget {
|
||||
private active = 0;
|
||||
private pendingChecks = 0;
|
||||
|
||||
constructor(
|
||||
private readonly store: AuthCounterStore,
|
||||
private readonly prefix: string,
|
||||
private readonly secret: string
|
||||
) {}
|
||||
|
||||
private key(kind: string, value: string): string {
|
||||
const digest = createHmac('sha256', this.secret).update(`auth-budget-v1:${kind}:${value}`).digest('hex');
|
||||
return `${this.prefix}:auth-budget:v1:${kind}:${digest}`;
|
||||
}
|
||||
|
||||
async run<T>(
|
||||
action: string,
|
||||
client: string,
|
||||
subject: string | undefined | (() => Promise<string | undefined>),
|
||||
work: () => Promise<T>
|
||||
): Promise<T> {
|
||||
if (this.store.available === false || this.pendingChecks >= 32) throw new AuthBudgetError('unavailable');
|
||||
const expensive = EXPENSIVE_ACTIONS.has(action);
|
||||
if (expensive && this.active >= 4) throw new AuthBudgetError('limited');
|
||||
if (expensive) this.active += 1;
|
||||
try {
|
||||
const consume = async (): Promise<number> => {
|
||||
const resolvedSubject = typeof subject === 'function' ? await subject() : subject;
|
||||
const counters: AuthCounter[] = [
|
||||
{ key: this.key('global', 'all'), limit: 1200, windowMs: 60_000 },
|
||||
{ key: this.key('client', client), limit: 600, windowMs: 60_000 },
|
||||
];
|
||||
if (resolvedSubject)
|
||||
counters.push({ key: this.key('subject', resolvedSubject), limit: 10, windowMs: 60_000 });
|
||||
return this.store.consume(counters);
|
||||
};
|
||||
this.pendingChecks += 1;
|
||||
const checking = consume();
|
||||
// A caller timeout does not prove the underlying Redis/actor read settled.
|
||||
void checking.then(
|
||||
() => {
|
||||
this.pendingChecks -= 1;
|
||||
},
|
||||
() => {
|
||||
this.pendingChecks -= 1;
|
||||
}
|
||||
);
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let retryMs: number;
|
||||
try {
|
||||
retryMs = await Promise.race([
|
||||
checking,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => reject(new AuthBudgetError('unavailable')), 2000);
|
||||
timeout.unref();
|
||||
}),
|
||||
]);
|
||||
} catch {
|
||||
throw new AuthBudgetError('unavailable');
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
if (retryMs > 0) throw new AuthBudgetError('limited', Math.max(1, Math.ceil(retryMs / 1000)));
|
||||
return await work();
|
||||
} finally {
|
||||
if (expensive) this.active -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export interface KakaoOAuthConfig {
|
||||
redirectUri: string;
|
||||
oauthHost?: string;
|
||||
apiHost?: string;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface KakaoOAuthToken {
|
||||
@@ -52,12 +53,57 @@ export class KakaoOAuthClient {
|
||||
private readonly redirectUri: string;
|
||||
private readonly oauthHost: string;
|
||||
private readonly apiHost: string;
|
||||
private readonly requestTimeoutMs: number;
|
||||
|
||||
constructor(config: KakaoOAuthConfig) {
|
||||
this.restKey = config.restKey;
|
||||
this.redirectUri = config.redirectUri;
|
||||
this.oauthHost = config.oauthHost ?? 'https://kauth.kakao.com';
|
||||
this.apiHost = config.apiHost ?? 'https://kapi.kakao.com';
|
||||
this.requestTimeoutMs = config.requestTimeoutMs ?? 10_000;
|
||||
if (!Number.isInteger(this.requestTimeoutMs) || this.requestTimeoutMs < 50 || this.requestTimeoutMs > 10_000) {
|
||||
throw new Error('Kakao request timeout must be between 50 and 10000 milliseconds.');
|
||||
}
|
||||
}
|
||||
|
||||
private async requestJson(
|
||||
url: URL,
|
||||
init: RequestInit
|
||||
): Promise<{ response: Response; payload: Record<string, unknown> }> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||
});
|
||||
if (!response.body) throw new Error('Missing response body.');
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
bytes += chunk.value.byteLength;
|
||||
if (bytes > 64 * 1024) {
|
||||
void reader.cancel().catch(() => undefined);
|
||||
throw new Error('Response is too large.');
|
||||
}
|
||||
chunks.push(chunk.value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const payload: unknown = JSON.parse(
|
||||
new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))
|
||||
);
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
||||
throw new Error('Invalid response shape.');
|
||||
return { response, payload: payload as Record<string, unknown> };
|
||||
} catch {
|
||||
// Provider/network payloads can include access tokens or account details.
|
||||
throw new Error('Kakao HTTP request failed.');
|
||||
}
|
||||
}
|
||||
|
||||
buildAuthUrl(state: string, scopes: string[]): string {
|
||||
@@ -73,7 +119,7 @@ export class KakaoOAuthClient {
|
||||
}
|
||||
|
||||
async exchangeCode(code: string): Promise<KakaoOAuthToken> {
|
||||
const response = await fetch(new URL('/oauth/token', this.oauthHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/oauth/token', this.oauthHost), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -85,15 +131,14 @@ export class KakaoOAuthClient {
|
||||
code,
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(`Kakao OAuth token error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao OAuth token error.');
|
||||
}
|
||||
return parseToken(payload);
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<KakaoOAuthToken> {
|
||||
const response = await fetch(new URL('/oauth/token', this.oauthHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/oauth/token', this.oauthHost), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -104,27 +149,25 @@ export class KakaoOAuthClient {
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(`Kakao OAuth refresh error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao OAuth refresh error.');
|
||||
}
|
||||
return parseToken(payload);
|
||||
}
|
||||
|
||||
async signup(accessToken: string): Promise<KakaoSignupResult> {
|
||||
const response = await fetch(new URL('/v1/user/signup', this.apiHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/v1/user/signup', this.apiHost), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok && payload.code === -102 && payload.msg === 'already registered') {
|
||||
return {
|
||||
alreadyRegistered: true,
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Kakao signup error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao signup error.');
|
||||
}
|
||||
return {
|
||||
id: payload.id ? String(payload.id) : undefined,
|
||||
@@ -133,15 +176,14 @@ export class KakaoOAuthClient {
|
||||
}
|
||||
|
||||
async getMe(accessToken: string): Promise<KakaoUserInfo> {
|
||||
const response = await fetch(new URL('/v2/user/me', this.apiHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/v2/user/me', this.apiHost), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
if (!response.ok) {
|
||||
throw new Error(`Kakao me error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao me error.');
|
||||
}
|
||||
const kakaoAccount = (payload.kakao_account ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
@@ -156,7 +198,7 @@ export class KakaoOAuthClient {
|
||||
}
|
||||
|
||||
async sendTalkMessage(accessToken: string, message: string, link: string): Promise<void> {
|
||||
const response = await fetch(new URL('/v2/api/talk/memo/default/send', this.apiHost), {
|
||||
const { response, payload } = await this.requestJson(new URL('/v2/api/talk/memo/default/send', this.apiHost), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
@@ -174,10 +216,9 @@ export class KakaoOAuthClient {
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
const code = Number(payload.code ?? 0);
|
||||
if (!response.ok || code < 0) {
|
||||
throw new Error(`Kakao talk message error: ${JSON.stringify(payload)}`);
|
||||
throw new Error('Kakao talk message error.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AuthAttemptBudget, InMemoryAuthCounterStore } from './auth/attemptBudget.js';
|
||||
import type { GatewayFlushPublisher } from './auth/flushPublisher.js';
|
||||
import type { GatewaySessionService } from './auth/sessionService.js';
|
||||
import type { UserRepository } from './auth/userRepository.js';
|
||||
@@ -40,6 +41,8 @@ export interface GatewayApiContext {
|
||||
releases: GatewayReleaseRepository;
|
||||
orchestrator: GatewayOrchestratorHandle;
|
||||
profileStatus: GatewayProfileStatusService;
|
||||
authBudget: AuthAttemptBudget;
|
||||
requestIp: string;
|
||||
requestHeaders: Record<string, string | string[] | undefined>;
|
||||
prisma: GatewayPrismaClient;
|
||||
adminAudit: AdminAuditStore;
|
||||
@@ -69,6 +72,8 @@ export const createGatewayApiContext = (options: {
|
||||
releases?: GatewayReleaseRepository;
|
||||
orchestrator: GatewayOrchestratorHandle;
|
||||
profileStatus: GatewayProfileStatusService;
|
||||
authBudget?: AuthAttemptBudget;
|
||||
requestIp?: string;
|
||||
requestHeaders?: Record<string, string | string[] | undefined>;
|
||||
prisma: GatewayPrismaClient;
|
||||
adminAudit?: AdminAuditStore;
|
||||
@@ -95,6 +100,10 @@ export const createGatewayApiContext = (options: {
|
||||
releases: options.releases ?? createGatewayReleaseRepository(options.prisma),
|
||||
orchestrator: options.orchestrator,
|
||||
profileStatus: options.profileStatus,
|
||||
authBudget:
|
||||
options.authBudget ??
|
||||
new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'direct-caller', options.gameTokenSecret),
|
||||
requestIp: options.requestIp ?? 'unknown',
|
||||
requestHeaders: options.requestHeaders ?? {},
|
||||
prisma: options.prisma,
|
||||
adminAudit: options.adminAudit ?? createAdminAuditStore(options.prisma),
|
||||
|
||||
@@ -25,6 +25,7 @@ import { createPasswordEnvelopeService } from './auth/passwordEnvelope.js';
|
||||
import { RedisGatewaySessionService } from './auth/redisSessionService.js';
|
||||
import { createGatewayOrchestrator } from './orchestrator/orchestratorFactory.js';
|
||||
import { createGatewayReleaseRepository } from './orchestrator/gatewayReleaseRepository.js';
|
||||
import { AuthAttemptBudget, RedisAuthCounterStore } from './auth/attemptBudget.js';
|
||||
import { appRouter } from './router.js';
|
||||
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
|
||||
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
|
||||
@@ -66,6 +67,11 @@ export const createGatewayApiServer = async () => {
|
||||
sessionTtlSeconds: config.sessionTtlSeconds,
|
||||
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
|
||||
});
|
||||
const authBudget = new AuthAttemptBudget(
|
||||
new RedisAuthCounterStore(redis.client),
|
||||
config.redisKeyPrefix,
|
||||
config.gameTokenSecret
|
||||
);
|
||||
const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel);
|
||||
const kakaoClient = new KakaoOAuthClient({
|
||||
restKey: config.kakaoRestKey,
|
||||
@@ -91,6 +97,8 @@ export const createGatewayApiServer = async () => {
|
||||
);
|
||||
const app = fastify({
|
||||
logger: safeHttpLoggerOptions,
|
||||
// Count the actual peer; forwarded headers are untrusted without an explicit proxy policy.
|
||||
trustProxy: false,
|
||||
routerOptions: gatewayFastifyRouterOptions,
|
||||
});
|
||||
app.setErrorHandler(safeHttpErrorHandler);
|
||||
@@ -154,6 +162,8 @@ export const createGatewayApiServer = async () => {
|
||||
releases,
|
||||
orchestrator,
|
||||
profileStatus,
|
||||
authBudget,
|
||||
requestIp: req.ip,
|
||||
requestHeaders: req.headers,
|
||||
prisma: postgres.prisma as GatewayPrismaClient,
|
||||
navigationConfig,
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
|
||||
import { initTRPC } from '@trpc/server';
|
||||
import { AuthBudgetError, isBudgetedAuthAction } from './auth/attemptBudget.js';
|
||||
import { initTRPC, TRPCError } from '@trpc/server';
|
||||
|
||||
import type { GatewayApiContext } from './context.js';
|
||||
|
||||
const t = initTRPC.context<GatewayApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
|
||||
|
||||
export const router = t.router;
|
||||
export const procedure = t.procedure;
|
||||
export const procedure = t.procedure.use(async ({ ctx, path, getRawInput, next }) => {
|
||||
if (!isBudgetedAuthAction(path)) return next();
|
||||
const raw: unknown = await getRawInput();
|
||||
const input = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
||||
const resolveSubject = async (): Promise<string | undefined> => {
|
||||
if (path === 'auth.login' && typeof input.username === 'string' && input.username.length <= 64) {
|
||||
return `login:${input.username.trim().toLocaleLowerCase('en-US')}`;
|
||||
}
|
||||
if (path.startsWith('account.') && typeof input.sessionToken === 'string' && input.sessionToken.length <= 256) {
|
||||
const session = await ctx.sessions.getSession(input.sessionToken);
|
||||
if (session) return `account:${session.userId}`;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
try {
|
||||
return await ctx.authBudget.run(path, ctx.requestIp, resolveSubject, () => next());
|
||||
} catch (error) {
|
||||
if (!(error instanceof AuthBudgetError)) throw error;
|
||||
throw new TRPCError({
|
||||
code: error.reason === 'limited' ? 'TOO_MANY_REQUESTS' : 'SERVICE_UNAVAILABLE',
|
||||
message:
|
||||
error.reason === 'limited'
|
||||
? `인증 요청이 많습니다. ${error.retryAfterSeconds}초 후 다시 시도해 주세요.`
|
||||
: '인증 보호 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user