diff --git a/.env.example b/.env.example index 44d5b700..782f4cee 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,10 @@ GATEWAY_INTERNAL_API_URL=http://127.0.0.1:13000 ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000 WEB_PUSH_OUTBOX_POLL_MS=1000 GATEWAY_PUBLIC_URL=http://localhost:13000 +# Gateway authentication protection shares atomic 60s counters under this prefix. +# Defaults: 10 login attempts/account, 600 auth requests/socket peer, 1200 globally; +# expensive auth work is capped at 4/process. Forwarded headers are not trusted. +# These are security defaults in auth/attemptBudget.ts, not disable-able env toggles. GATEWAY_REDIS_PREFIX=sammo:gateway GATEWAY_DB_SCHEMA=public GATEWAY_WORKSPACE_ROOT=/path/to/core2026 diff --git a/app/gateway-api/src/auth/attemptBudget.ts b/app/gateway-api/src/auth/attemptBudget.ts new file mode 100644 index 00000000..86278f20 --- /dev/null +++ b/app/gateway-api/src/auth/attemptBudget.ts @@ -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; +} + +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; + }; + eval: (script: string, options: { keys: string[]; arguments: string[] }) => Promise; + } + ) {} + + get available(): boolean { + return this.client.isReady !== false; + } + + async consume(counters: AuthCounter[]): Promise { + 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(); + + async consume(counters: AuthCounter[]): Promise { + 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( + action: string, + client: string, + subject: string | undefined | (() => Promise), + work: () => Promise + ): Promise { + 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 => { + 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 | undefined; + let retryMs: number; + try { + retryMs = await Promise.race([ + checking, + new Promise((_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; + } + } +} diff --git a/app/gateway-api/src/auth/kakaoClient.ts b/app/gateway-api/src/auth/kakaoClient.ts index 171071d0..4c1cffd6 100644 --- a/app/gateway-api/src/auth/kakaoClient.ts +++ b/app/gateway-api/src/auth/kakaoClient.ts @@ -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 }> { + 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 }; + } 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 { - 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; 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 { - 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; 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 { - 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; 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 { - 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; 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; return { @@ -156,7 +198,7 @@ export class KakaoOAuthClient { } async sendTalkMessage(accessToken: string, message: string, link: string): Promise { - 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; 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.'); } } } diff --git a/app/gateway-api/src/context.ts b/app/gateway-api/src/context.ts index 6fc94805..abe2792b 100644 --- a/app/gateway-api/src/context.ts +++ b/app/gateway-api/src/context.ts @@ -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; prisma: GatewayPrismaClient; adminAudit: AdminAuditStore; @@ -69,6 +72,8 @@ export const createGatewayApiContext = (options: { releases?: GatewayReleaseRepository; orchestrator: GatewayOrchestratorHandle; profileStatus: GatewayProfileStatusService; + authBudget?: AuthAttemptBudget; + requestIp?: string; requestHeaders?: Record; 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), diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index 04273bcc..d8ee053f 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -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, diff --git a/app/gateway-api/src/trpc.ts b/app/gateway-api/src/trpc.ts index 739369e5..4b551851 100644 --- a/app/gateway-api/src/trpc.ts +++ b/app/gateway-api/src/trpc.ts @@ -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().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) : {}; + const resolveSubject = async (): Promise => { + 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}초 후 다시 시도해 주세요.` + : '인증 보호 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.', + }); + } +}); diff --git a/app/gateway-api/test/authAttemptBudget.integration.test.ts b/app/gateway-api/test/authAttemptBudget.integration.test.ts new file mode 100644 index 00000000..76ea2239 --- /dev/null +++ b/app/gateway-api/test/authAttemptBudget.integration.test.ts @@ -0,0 +1,53 @@ +import { randomUUID } from 'node:crypto'; +import { createClient } from 'redis'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { RedisAuthCounterStore } from '../src/auth/attemptBudget.js'; + +const redisUrl = process.env.GATEWAY_AUTH_REDIS_TEST_URL; + +describe.skipIf(!redisUrl)('Redis authentication budget atomicity', () => { + const client = createClient({ url: redisUrl }); + const prefix = `auth-budget-test:${randomUUID()}`; + const store = new RedisAuthCounterStore(client); + const keys = ['subject', 'global', 'expires', 'invalid'].map((kind) => `${prefix}:${kind}`); + beforeAll(async () => { + await client.connect(); + }); + afterAll(async () => { + await client.del(keys); + await client.quit(); + }); + + it('admits exactly ten concurrent attempts and does not charge other buckets on rejection', async () => { + const attempts = await Promise.all( + Array.from({ length: 100 }, () => + store.consume([ + { key: keys[0]!, limit: 10, windowMs: 60_000 }, + { key: keys[1]!, limit: 1000, windowMs: 60_000 }, + ]) + ) + ); + expect(attempts.filter((retry) => retry === 0)).toHaveLength(10); + expect(attempts.filter((retry) => retry > 0)).toHaveLength(90); + expect(await client.get(keys[0]!)).toBe('10'); + expect(await client.get(keys[1]!)).toBe('10'); + expect(await client.pTTL(keys[0]!)).toBeGreaterThan(0); + }); + + it('keeps the original expiry under repeated rejected attempts and permits recovery', async () => { + const counters = [{ key: keys[2]!, limit: 1, windowMs: 120 }]; + expect(await store.consume(counters)).toBe(0); + const before = await client.pTTL(keys[2]!); + expect(await store.consume(counters)).toBeGreaterThan(0); + expect(await client.pTTL(keys[2]!)).toBeLessThanOrEqual(before); + await expect.poll(() => client.exists(keys[2]!), { interval: 20, timeout: 1000 }).toBe(0); + expect(await store.consume(counters)).toBe(0); + }); + + it('rejects a malformed counter without an expiry instead of permanently locking the account', async () => { + await client.set(keys[3]!, '10'); + await expect(store.consume([{ key: keys[3]!, limit: 10, windowMs: 60_000 }])).rejects.toMatchObject({ + reason: 'unavailable', + }); + }); +}); diff --git a/app/gateway-api/test/authAttemptBudget.test.ts b/app/gateway-api/test/authAttemptBudget.test.ts new file mode 100644 index 00000000..086ab981 --- /dev/null +++ b/app/gateway-api/test/authAttemptBudget.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AuthAttemptBudget, AuthBudgetError, InMemoryAuthCounterStore } from '../src/auth/attemptBudget.js'; + +describe('authentication attempt and resource budgets', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('shares a login subject across clients without exposing account or address in keys', async () => { + const store = new InMemoryAuthCounterStore(); + const consume = vi.spyOn(store, 'consume'); + const budget = new AuthAttemptBudget(store, 'fixture', 'fixture-secret'); + for (let i = 0; i < 10; i++) + await budget.run('auth.login', `client-${i}`, 'login:fixture-user', async () => 'ok'); + const work = vi.fn(async () => 'must not run'); + await expect(budget.run('auth.login', 'another-client', 'login:fixture-user', work)).rejects.toMatchObject({ + reason: 'limited', + retryAfterSeconds: 60, + }); + expect(work).not.toHaveBeenCalled(); + expect(JSON.stringify(consume.mock.calls)).not.toContain('fixture-user'); + expect(JSON.stringify(consume.mock.calls)).not.toContain('client-'); + await expect(budget.run('auth.login', 'another-client', 'login:other-user', async () => 'ok')).resolves.toBe( + 'ok' + ); + }); + + it('does not extend a blocked account window and admits it after expiry', async () => { + vi.useFakeTimers(); + const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret'); + for (let i = 0; i < 10; i++) await budget.run('auth.login', 'client', 'login:user', async () => null); + await vi.advanceTimersByTimeAsync(59_000); + await expect(budget.run('auth.login', 'client', 'login:user', async () => null)).rejects.toMatchObject({ + retryAfterSeconds: 1, + }); + await vi.advanceTimersByTimeAsync(1000); + await expect(budget.run('auth.login', 'client', 'login:user', async () => 'recovered')).resolves.toBe( + 'recovered' + ); + }); + + it('limits public auth traffic per peer and across peers without charging rejected requests', async () => { + const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret'); + for (let i = 0; i < 600; i++) await budget.run('auth.passwordKey', 'peer-a', undefined, async () => null); + await expect(budget.run('auth.kakaoStart', 'peer-a', undefined, async () => null)).rejects.toMatchObject({ + reason: 'limited', + }); + for (let i = 0; i < 600; i++) await budget.run('auth.passwordKey', 'peer-b', undefined, async () => null); + await expect(budget.run('auth.kakaoStart', 'peer-c', undefined, async () => null)).rejects.toMatchObject({ + reason: 'limited', + }); + }); + + it('bounds expensive work and releases capacity after resolver failures', async () => { + const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret'); + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const work = vi.fn(async () => { + await pending; + throw new Error('fixture resolver failure'); + }); + const running = Array.from({ length: 4 }, () => + budget.run('auth.login', 'client', undefined, work).catch(() => null) + ); + await vi.waitFor(() => expect(work).toHaveBeenCalledTimes(4)); + const rejected = vi.fn(async () => null); + await expect(budget.run('auth.registerLocal', 'client', undefined, rejected)).rejects.toBeInstanceOf( + AuthBudgetError + ); + expect(rejected).not.toHaveBeenCalled(); + release(); + await Promise.all(running); + await expect(budget.run('auth.login', 'client', undefined, async () => 'ok')).resolves.toBe('ok'); + }); + + it('bounds unfinished checks after caller timeouts and recovers only after they settle', async () => { + vi.useFakeTimers(); + let release!: (retry: number) => void; + const waiting = new Promise((resolve) => { + release = resolve; + }); + const store = { consume: vi.fn(() => waiting) }; + const budget = new AuthAttemptBudget(store, 'fixture', 'secret'); + const work = vi.fn(async () => 'ok'); + const requests = Array.from({ length: 32 }, () => + budget.run('auth.passwordKey', 'client', undefined, work).catch((error: unknown) => error) + ); + await vi.advanceTimersByTimeAsync(2001); + expect( + (await Promise.all(requests)).every( + (error) => error instanceof AuthBudgetError && error.reason === 'unavailable' + ) + ).toBe(true); + const actorLookup = vi.fn(async () => undefined); + await expect(budget.run('auth.login', 'client', actorLookup, work)).rejects.toMatchObject({ + reason: 'unavailable', + }); + expect(actorLookup).not.toHaveBeenCalled(); + expect(work).not.toHaveBeenCalled(); + expect(store.consume).toHaveBeenCalledTimes(32); + release(0); + await Promise.resolve(); + await Promise.resolve(); + await expect(budget.run('auth.login', 'client', undefined, work)).resolves.toBe('ok'); + expect(work).toHaveBeenCalledTimes(1); + }); + + it('fails closed before credential work when the counter backend errors or stalls', async () => { + const work = vi.fn(async () => null); + const failing = new AuthAttemptBudget( + { + consume: async () => { + throw new Error('private-backend-error'); + }, + }, + 'fixture', + 'secret' + ); + await expect(failing.run('auth.login', 'client', undefined, work)).rejects.toMatchObject({ + reason: 'unavailable', + }); + vi.useFakeTimers(); + const stalled = new AuthAttemptBudget({ consume: () => new Promise(() => {}) }, 'fixture', 'secret'); + const result = stalled.run('auth.login', 'client', undefined, work).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(2001); + expect(await result).toMatchObject({ reason: 'unavailable' }); + expect(work).not.toHaveBeenCalled(); + }); +}); diff --git a/app/gateway-api/test/authBudgetHttp.integration.test.ts b/app/gateway-api/test/authBudgetHttp.integration.test.ts new file mode 100644 index 00000000..f2315d12 --- /dev/null +++ b/app/gateway-api/test/authBudgetHttp.integration.test.ts @@ -0,0 +1,238 @@ +import { constants, publicEncrypt, randomUUID } from 'node:crypto'; +import fastify, { type FastifyRequest } from 'fastify'; +import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; +import { createClient } from 'redis'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common'; +import { createGatewayApiContext } from '../src/context.js'; +import { appRouter } from '../src/router.js'; +import { AuthAttemptBudget, RedisAuthCounterStore } from '../src/auth/attemptBudget.js'; +import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js'; +import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js'; +import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js'; + +const redisUrl = process.env.GATEWAY_AUTH_REDIS_TEST_URL; + +describe.skipIf(!redisUrl)('authentication budgets over real HTTP and Redis', () => { + const client = createClient({ url: redisUrl }); + const prefix = `auth-budget-http-test:${randomUUID()}`; + const envelope = createPasswordEnvelopeService(); + beforeAll(async () => { + await client.connect(); + }); + afterAll(async () => { + const keys: string[] = []; + for await (const page of client.scanIterator({ MATCH: `${prefix}:*`, COUNT: 100 })) keys.push(...page); + if (keys.length) await client.del(keys); + await client.quit(); + }); + const credential = (password: string) => { + const key = envelope.getPublicKey(); + return { + keyId: key.keyId, + ciphertext: publicEncrypt( + { key: key.publicKeyPem, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' }, + Buffer.from(password) + ).toString('base64'), + }; + }; + const fixture = async (available = true) => { + const users = createInMemoryUserRepository(); + await users.createUser({ username: 'target-user', password: 'correct-password' }); + await users.createUser({ username: 'other-user', password: 'correct-password' }); + const requestIps = new Set(); + const peer = (request: FastifyRequest): string => { + requestIps.add(request.ip); + return request.ip; + }; + const verify = vi.spyOn(users, 'verifyPassword'); + const open = vi.fn(envelope.open); + const sessions = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 600 }); + const authBudget = new AuthAttemptBudget( + new RedisAuthCounterStore(available ? client : client.duplicate()), + `${prefix}:${randomUUID()}`, + 'synthetic-budget-key' + ); + const app = fastify({ trustProxy: false }); + await app.register(fastifyTRPCPlugin, { + prefix: '/trpc', + trpcOptions: { + router: appRouter, + ...trpcJsonBodyHttpServerOptions, + createContext: ({ req }: { req: FastifyRequest }) => + createGatewayApiContext({ + users, + sessions, + passwordEnvelope: { ...envelope, open }, + authBudget, + requestIp: peer(req), + requestHeaders: req.headers, + flushPublisher: { publishUserFlush: async () => {} }, + gameTokenSecret: 'synthetic-game-key', + gameSessionTtlSeconds: 600, + kakaoClient: {} as never, + oauthSessions: {} as never, + publicBaseUrl: 'http://localhost', + adminLocalAccountEnabled: true, + localRegistrationEnabled: true, + localAccountGraceDays: 7, + profiles: {} as never, + orchestrator: {} as never, + profileStatus: {} as never, + prisma: {} as never, + }), + }, + }); + const address = await app.listen({ host: '127.0.0.1', port: 0 }); + const post = (operation: string, input: unknown, headers: Record = {}) => + fetch(`${address}/trpc/${operation}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(input), + }); + return { app, address, post, users, sessions, verify, open, requestIps }; + }; + + it('blocks an eleventh normalized account attempt before RSA/Argon2 despite spoofed forwarding headers', async () => { + const f = await fixture(); + try { + for (let i = 0; i < 10; i++) { + const response = await f.post( + 'auth.login', + { username: i % 2 ? ' TARGET-USER ' : 'target-user', credential: credential('wrong-password') }, + { 'x-forwarded-for': `198.51.100.${i + 1}` } + ); + expect(response.status).toBe(401); + } + expect(f.requestIps.size).toBe(1); + expect(f.requestIps.has('127.0.0.1')).toBe(true); + expect(f.verify).toHaveBeenCalledTimes(10); + expect(f.open).toHaveBeenCalledTimes(10); + const denied = await f.post( + 'auth.login', + { username: 'target-user', credential: credential('correct-password') }, + { 'x-forwarded-for': '203.0.113.99' } + ); + expect(denied.status).toBe(429); + expect(JSON.stringify(await denied.json())).not.toContain('stack'); + expect(f.verify).toHaveBeenCalledTimes(10); + expect(f.open).toHaveBeenCalledTimes(10); + expect( + (await f.post('auth.login', { username: 'other-user', credential: credential('correct-password') })) + .status + ).toBe(200); + } finally { + await f.app.close(); + } + }); + + it('charges each operation in a tRPC batch instead of granting one budget per HTTP request', async () => { + const f = await fixture(); + try { + const statuses: number[] = []; + for (let round = 0; round < 4; round++) { + const input = Object.fromEntries( + Array.from({ length: 3 }, (_value, i) => [ + String(i), + { username: 'target-user', credential: credential('wrong-password') }, + ]) + ); + const response = await f.post('auth.login,auth.login,auth.login?batch=1', input); + const body = (await response.json()) as Array<{ error: { data: { httpStatus: number } } }>; + statuses.push(...body.map((entry) => entry.error.data.httpStatus)); + } + expect(statuses.filter((status) => status === 401)).toHaveLength(10); + expect(statuses.filter((status) => status === 429)).toHaveLength(2); + expect(f.verify).toHaveBeenCalledTimes(10); + } finally { + await f.app.close(); + } + }); + + it('shares the password/deletion subject across sessions of the same authenticated actor', async () => { + const f = await fixture(); + const user = await f.users.findByUsername('target-user'); + try { + for (let i = 0; i < 10; i++) { + const session = await f.sessions.createSession(user!); + const response = await f.post(i % 2 ? 'account.changePassword' : 'account.scheduleDeletion', { + sessionToken: session.sessionToken, + currentCredential: credential('wrong-password'), + newCredential: credential('new-password'), + }); + expect(response.status).toBe(401); + } + const session = await f.sessions.createSession(user!); + expect( + ( + await f.post('account.scheduleDeletion', { + sessionToken: session.sessionToken, + currentCredential: credential('correct-password'), + }) + ).status + ).toBe(429); + expect(f.verify).toHaveBeenCalledTimes(10); + expect(f.open).toHaveBeenCalledTimes(10); + expect((await f.users.findById(user!.id))?.deleteAfter).toBeUndefined(); + } finally { + await f.app.close(); + } + }); + + it('fails closed on unavailable protection while keeping logout independent of its counters', async () => { + const f = await fixture(false); + try { + expect( + (await f.post('auth.login', { username: 'target-user', credential: credential('correct-password') })) + .status + ).toBe(503); + expect(f.open).not.toHaveBeenCalled(); + expect(f.verify).not.toHaveBeenCalled(); + const user = await f.users.findByUsername('target-user'); + const session = await f.sessions.createSession(user!); + expect((await f.post('auth.logout', { sessionToken: session.sessionToken })).status).toBe(200); + expect(await f.sessions.getSession(session.sessionToken)).toBeNull(); + expect( + (await f.post('auth.login', { username: 123, credential: credential('correct-password') })).status + ).toBe(503); + const malformed = await fetch(`${f.address}/trpc/auth.login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{', + }); + expect(malformed.status).toBe(400); + } finally { + await f.app.close(); + } + }); + + it('bounds parallel credential work and admits a subsequent request after all failures settle', async () => { + const f = await fixture(); + let release!: () => void; + const waiting = new Promise((resolve) => { + release = resolve; + }); + f.verify.mockImplementation(async () => { + await waiting; + return false; + }); + const input = { username: 'target-user', credential: credential('wrong-password') }; + const pending = Array.from({ length: 4 }, () => f.post('auth.login', input)); + try { + await vi.waitFor(() => expect(f.verify).toHaveBeenCalledTimes(4)); + expect((await f.post('auth.login', input)).status).toBe(429); + expect(f.open).toHaveBeenCalledTimes(4); + release(); + expect((await Promise.all(pending)).map((r) => r.status)).toEqual([401, 401, 401, 401]); + f.verify.mockRestore(); + expect( + (await f.post('auth.login', { username: 'target-user', credential: credential('correct-password') })) + .status + ).toBe(200); + } finally { + release(); + await Promise.allSettled(pending); + await f.app.close(); + } + }); +}); diff --git a/app/gateway-api/test/kakaoClient.test.ts b/app/gateway-api/test/kakaoClient.test.ts index 12b9b9d0..73c00215 100644 --- a/app/gateway-api/test/kakaoClient.test.ts +++ b/app/gateway-api/test/kakaoClient.test.ts @@ -25,11 +25,16 @@ describe('Kakao OAuth HTTP transport', () => { await expect(createClient().signup('access-token')).resolves.toEqual({ alreadyRegistered: true, }); - expect(fetchMock).toHaveBeenCalledWith(new URL('https://kapi.example.test/v1/user/signup'), { - headers: { - Authorization: 'Bearer access-token', - }, - }); + expect(fetchMock).toHaveBeenCalledWith( + new URL('https://kapi.example.test/v1/user/signup'), + expect.objectContaining({ + headers: { + Authorization: 'Bearer access-token', + }, + redirect: 'error', + signal: expect.any(AbortSignal), + }) + ); }); it('continues to reject unrelated Kakao signup errors', async () => { @@ -40,8 +45,6 @@ describe('Kakao OAuth HTTP transport', () => { }) ); - await expect(createClient().signup('access-token')).rejects.toThrow( - 'Kakao signup error: {"msg":"invalid request","code":-201}' - ); + await expect(createClient().signup('access-token')).rejects.toThrow('Kakao signup error.'); }); }); diff --git a/app/gateway-api/test/kakaoTransportLimits.test.ts b/app/gateway-api/test/kakaoTransportLimits.test.ts new file mode 100644 index 00000000..01030c11 --- /dev/null +++ b/app/gateway-api/test/kakaoTransportLimits.test.ts @@ -0,0 +1,95 @@ +import { createServer, type Server, type ServerResponse } from 'node:http'; +import { gzipSync } from 'node:zlib'; +import { afterEach, describe, expect, it } from 'vitest'; +import { KakaoOAuthClient } from '../src/auth/kakaoClient.js'; + +describe('bounded Kakao provider transport', () => { + const servers: Server[] = []; + afterEach(async () => { + for (const server of servers.splice(0)) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } + }); + const provider = async (respond: (response: ServerResponse) => void, requestTimeoutMs = 1000) => { + const server = createServer((_request, response) => respond(response)); + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Fixture provider listener failed.'); + const origin = `http://127.0.0.1:${address.port}`; + return new KakaoOAuthClient({ + restKey: 'synthetic-key', + redirectUri: `${origin}/callback`, + apiHost: origin, + oauthHost: origin, + requestTimeoutMs, + }); + }; + + it('retains successful token/account responses and documented recovery responses', async () => { + const client = await provider((res) => + res.end( + JSON.stringify({ + id: 42, + access_token: 'synthetic-token', + expires_in: 3600, + kakao_account: { + has_email: true, + email: 'fixture@example.test', + is_email_valid: true, + is_email_verified: true, + }, + }) + ) + ); + expect(await client.exchangeCode('synthetic-code')).toMatchObject({ + accessToken: 'synthetic-token', + accessTokenExpiresIn: 3600, + }); + expect(await client.getMe('synthetic-token')).toMatchObject({ + id: '42', + kakaoAccount: { email: 'fixture@example.test', isEmailVerified: true }, + }); + }); + + it('aborts both header and response-body stalls within the configured deadline', async () => { + const silent = await provider(() => {}, 100); + await expect(silent.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.'); + const partial = await provider((res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.write('{'); + }, 100); + await expect(partial.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.'); + }); + + it('limits decompressed response bytes and rejects malformed JSON without exposing payloads', async () => { + const oversized = await provider((res) => { + res.writeHead(200, { 'content-type': 'application/json', 'content-encoding': 'gzip' }); + res.end(gzipSync(JSON.stringify({ payload: 'x'.repeat(2 * 1024 * 1024) }))); + }); + await expect(oversized.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.'); + const malformed = await provider((res) => res.end('synthetic-sensitive-provider-data')); + await expect(malformed.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.'); + }); + + it('does not follow redirects or expose provider error details', async () => { + let destinationRequests = 0; + const destination = await provider((res) => { + destinationRequests += 1; + res.end('{}'); + }); + const destinationUrl = new URL(destination.buildAuthUrl('state', [])); + const redirect = await provider((res) => { + res.writeHead(302, { location: destinationUrl.origin }); + res.end(); + }); + await expect(redirect.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.'); + expect(destinationRequests).toBe(0); + const failure = await provider((res) => { + res.writeHead(400); + res.end(JSON.stringify({ msg: 'synthetic-provider-secret', code: -201 })); + }); + await expect(failure.signup('synthetic-token')).rejects.toThrow('Kakao signup error.'); + }); +}); diff --git a/docs/release-operations.md b/docs/release-operations.md index 32b1ed08..ec6760d4 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -566,3 +566,54 @@ message는 일반 문구로 치환한다. 4xx의 사용자용 검증/권한 문 이 정책은 HTTP API 로그의 범위이며 release builder의 관리자 build log를 일괄 숨기지 않는다. 장애 조사 시 request ID·route·status·code와 별도 격리 재현을 사용하며 운영 HTTP 로그에 credential이나 raw error payload를 다시 추가하지 않는다. + +## 온라인 인증 시도와 비용 제한 (2026-10-09) + +Gateway HTTP server는 기존 전용 Redis와 `GATEWAY_REDIS_PREFIX`에 인증 counter를 +공유한다. 별도 credential이나 비활성화 env key는 추가하지 않는다. 각 procedure +실행 전에 원자적으로 모든 counter를 검사하고 허용된 요청만 차감한다. 같은 tRPC +batch의 항목도 각각 차감한다. 기본 창은 최초 허용 요청부터 60초이며 다음과 같다. + +| 경계 | 기본 상한 | +| ----------------------------------------------------------------------- | ---------------------- | +| 정규화한 로그인 계정명, peer와 무관 | 10회/60초 | +| 같은 인증 actor의 비밀번호 변경·탈퇴, session을 바꿔도 공유 | 10회/60초 | +| 인증 시작·등록·자격 증명·OTP와 public key/가입 필드 검사, socket peer별 | 600회/60초 | +| 위 인증 procedure 전체, 같은 Gateway Redis namespace | 1200회/60초 | +| RSA/Argon2/provider 호출이 있는 인증 작업, API 프로세스별 | 동시 4개, 대기 큐 없음 | + +계정 counter는 존재하지 않는 이름과 성공/실패를 동일하게 차감하고 성공 직후에도 +초기화하지 않는다. 거절된 시도는 TTL이나 다른 counter를 갱신하지 않으므로 공격자가 +고정된 창을 연장할 수 없다. 계정 자체를 DB에서 잠그거나 sanction을 만들지 않으며 +기존 session·OAuth 시작/복구·logout은 계정명 counter와 별도다. 계속되는 공격이 +매 새 창의 예산을 소진하면 password login은 일시 제한될 수 있다. account 중심 +제한의 가용성 tradeoff로 기록하고 운영자는 인증 정책을 지워서 해결하지 않는다. + +Fastify `trustProxy:false`를 유지해 임의 `X-Forwarded-For`로 peer counter를 바꿀 수 +없다. 현재 Docker의 Caddy 뒤에서는 이 counter가 해당 proxy를 공유하는 사용자들의 +합계다. 이를 인터넷 client별 제한이라고 보고하지 않는다. 원래 client IP를 써야 +한다면 실제 두 proxy 경계와 덮어쓰기 규칙, 좁은 trusted proxy allowlist를 먼저 +결정해야 하며 `trustProxy:true`나 단순 hop 수를 켜지 않는다. global/account counter와 +동시 비용 상한은 forwarded header에 의존하지 않는다. + +아직 끝나지 않은 counter/actor 확인은 프로세스당 32개로 제한한다. 요청의 2초 +시간 초과 뒤에도 이 자리는 실제 확인 Promise가 끝날 때까지 유지한다. backend가 +멈춘 동안 대기하는 payload가 계속 늘어나지 않게 하며 확인이 끝나면 정상 복구한다. + +Redis 확인은 2초 안에 끝나야 하고 unready/error/잘못된 counter는 503으로 fail +closed 처리한다. 한도를 넘으면 429와 재시도 초를 포함한 메시지를 보낸다. +Redis namespace 내 key는 purpose를 나눈 HMAC으로 식별해 계정명/IP 원문을 저장하지 +않으며 모두 TTL이 있다. key namespace 때문에 secret rotation은 남은 최대 60초의 +시도 counter의 key 식별자를 전환한다. 내부 직접 caller/test의 기본 context는 +bounded memory store를 사용하지만 실제 HTTP server는 공유 Redis budget을 주입한다. + +Kakao transport는 각 HTTP 요청의 header와 body 전체에 10초 deadline, decoded JSON +응답 64 KiB 상한과 redirect 거절을 적용한다. provider payload·token·account 정보를 +오류 문구에 넣지 않는다. `-102 already registered`의 명시적 복구 분기는 유지한다. +여러 요청을 순차 수행하는 OAuth flow의 전체 시간이 10초라는 뜻은 아니다. 상한은 +게임 시간·OTP 만료·기존 session lifetime·Argon2 강도와 무관하다. + +이 단계는 온라인 자격 증명/가입 비용을 보호하며 established session의 일반 조회, +logout과 game-session 교환에 동일한 인증 시작 budget을 강제로 적용하지 않는다. +임시 운영 stack과 공개 운영/개발 stack은 Redis와 prefix를 공유하지 않으며 새 코드의 +검증 결과를 실제 공개 배포 근거로 사용하지 않는다.