온라인 인증 시도와 처리 비용 및 Kakao 응답 경계를 제한한다

This commit is contained in:
2026-10-09 03:43:59 +09:00
parent 9dceaf8e4e
commit 3d9f7392df
12 changed files with 874 additions and 25 deletions
+187
View File
@@ -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;
}
}
}
+56 -15
View File
@@ -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.');
}
}
}
+9
View File
@@ -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),
+10
View File
@@ -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,
+29 -2
View File
@@ -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}초 후 다시 시도해 주세요.`
: '인증 보호 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.',
});
}
});