온라인 인증 시도와 처리 비용 및 Kakao 응답 경계를 제한한다
This commit is contained in:
@@ -38,6 +38,10 @@ GATEWAY_INTERNAL_API_URL=http://127.0.0.1:13000
|
|||||||
ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000
|
ACCOUNT_ICON_RESET_RECONCILE_INTERVAL_MS=30000
|
||||||
WEB_PUSH_OUTBOX_POLL_MS=1000
|
WEB_PUSH_OUTBOX_POLL_MS=1000
|
||||||
GATEWAY_PUBLIC_URL=http://localhost:13000
|
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_REDIS_PREFIX=sammo:gateway
|
||||||
GATEWAY_DB_SCHEMA=public
|
GATEWAY_DB_SCHEMA=public
|
||||||
GATEWAY_WORKSPACE_ROOT=/path/to/core2026
|
GATEWAY_WORKSPACE_ROOT=/path/to/core2026
|
||||||
|
|||||||
@@ -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;
|
redirectUri: string;
|
||||||
oauthHost?: string;
|
oauthHost?: string;
|
||||||
apiHost?: string;
|
apiHost?: string;
|
||||||
|
requestTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KakaoOAuthToken {
|
export interface KakaoOAuthToken {
|
||||||
@@ -52,12 +53,57 @@ export class KakaoOAuthClient {
|
|||||||
private readonly redirectUri: string;
|
private readonly redirectUri: string;
|
||||||
private readonly oauthHost: string;
|
private readonly oauthHost: string;
|
||||||
private readonly apiHost: string;
|
private readonly apiHost: string;
|
||||||
|
private readonly requestTimeoutMs: number;
|
||||||
|
|
||||||
constructor(config: KakaoOAuthConfig) {
|
constructor(config: KakaoOAuthConfig) {
|
||||||
this.restKey = config.restKey;
|
this.restKey = config.restKey;
|
||||||
this.redirectUri = config.redirectUri;
|
this.redirectUri = config.redirectUri;
|
||||||
this.oauthHost = config.oauthHost ?? 'https://kauth.kakao.com';
|
this.oauthHost = config.oauthHost ?? 'https://kauth.kakao.com';
|
||||||
this.apiHost = config.apiHost ?? 'https://kapi.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 {
|
buildAuthUrl(state: string, scopes: string[]): string {
|
||||||
@@ -73,7 +119,7 @@ export class KakaoOAuthClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async exchangeCode(code: string): Promise<KakaoOAuthToken> {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
@@ -85,15 +131,14 @@ export class KakaoOAuthClient {
|
|||||||
code,
|
code,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const payload = (await response.json()) as Record<string, unknown>;
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Kakao OAuth token error: ${JSON.stringify(payload)}`);
|
throw new Error('Kakao OAuth token error.');
|
||||||
}
|
}
|
||||||
return parseToken(payload);
|
return parseToken(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshToken(refreshToken: string): Promise<KakaoOAuthToken> {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
@@ -104,27 +149,25 @@ export class KakaoOAuthClient {
|
|||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const payload = (await response.json()) as Record<string, unknown>;
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Kakao OAuth refresh error: ${JSON.stringify(payload)}`);
|
throw new Error('Kakao OAuth refresh error.');
|
||||||
}
|
}
|
||||||
return parseToken(payload);
|
return parseToken(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async signup(accessToken: string): Promise<KakaoSignupResult> {
|
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: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const payload = (await response.json()) as Record<string, unknown>;
|
|
||||||
if (!response.ok && payload.code === -102 && payload.msg === 'already registered') {
|
if (!response.ok && payload.code === -102 && payload.msg === 'already registered') {
|
||||||
return {
|
return {
|
||||||
alreadyRegistered: true,
|
alreadyRegistered: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Kakao signup error: ${JSON.stringify(payload)}`);
|
throw new Error('Kakao signup error.');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: payload.id ? String(payload.id) : undefined,
|
id: payload.id ? String(payload.id) : undefined,
|
||||||
@@ -133,15 +176,14 @@ export class KakaoOAuthClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getMe(accessToken: string): Promise<KakaoUserInfo> {
|
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',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const payload = (await response.json()) as Record<string, unknown>;
|
|
||||||
if (!response.ok) {
|
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>;
|
const kakaoAccount = (payload.kakao_account ?? {}) as Record<string, unknown>;
|
||||||
return {
|
return {
|
||||||
@@ -156,7 +198,7 @@ export class KakaoOAuthClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendTalkMessage(accessToken: string, message: string, link: string): Promise<void> {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
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);
|
const code = Number(payload.code ?? 0);
|
||||||
if (!response.ok || 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 { GatewayFlushPublisher } from './auth/flushPublisher.js';
|
||||||
import type { GatewaySessionService } from './auth/sessionService.js';
|
import type { GatewaySessionService } from './auth/sessionService.js';
|
||||||
import type { UserRepository } from './auth/userRepository.js';
|
import type { UserRepository } from './auth/userRepository.js';
|
||||||
@@ -40,6 +41,8 @@ export interface GatewayApiContext {
|
|||||||
releases: GatewayReleaseRepository;
|
releases: GatewayReleaseRepository;
|
||||||
orchestrator: GatewayOrchestratorHandle;
|
orchestrator: GatewayOrchestratorHandle;
|
||||||
profileStatus: GatewayProfileStatusService;
|
profileStatus: GatewayProfileStatusService;
|
||||||
|
authBudget: AuthAttemptBudget;
|
||||||
|
requestIp: string;
|
||||||
requestHeaders: Record<string, string | string[] | undefined>;
|
requestHeaders: Record<string, string | string[] | undefined>;
|
||||||
prisma: GatewayPrismaClient;
|
prisma: GatewayPrismaClient;
|
||||||
adminAudit: AdminAuditStore;
|
adminAudit: AdminAuditStore;
|
||||||
@@ -69,6 +72,8 @@ export const createGatewayApiContext = (options: {
|
|||||||
releases?: GatewayReleaseRepository;
|
releases?: GatewayReleaseRepository;
|
||||||
orchestrator: GatewayOrchestratorHandle;
|
orchestrator: GatewayOrchestratorHandle;
|
||||||
profileStatus: GatewayProfileStatusService;
|
profileStatus: GatewayProfileStatusService;
|
||||||
|
authBudget?: AuthAttemptBudget;
|
||||||
|
requestIp?: string;
|
||||||
requestHeaders?: Record<string, string | string[] | undefined>;
|
requestHeaders?: Record<string, string | string[] | undefined>;
|
||||||
prisma: GatewayPrismaClient;
|
prisma: GatewayPrismaClient;
|
||||||
adminAudit?: AdminAuditStore;
|
adminAudit?: AdminAuditStore;
|
||||||
@@ -95,6 +100,10 @@ export const createGatewayApiContext = (options: {
|
|||||||
releases: options.releases ?? createGatewayReleaseRepository(options.prisma),
|
releases: options.releases ?? createGatewayReleaseRepository(options.prisma),
|
||||||
orchestrator: options.orchestrator,
|
orchestrator: options.orchestrator,
|
||||||
profileStatus: options.profileStatus,
|
profileStatus: options.profileStatus,
|
||||||
|
authBudget:
|
||||||
|
options.authBudget ??
|
||||||
|
new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'direct-caller', options.gameTokenSecret),
|
||||||
|
requestIp: options.requestIp ?? 'unknown',
|
||||||
requestHeaders: options.requestHeaders ?? {},
|
requestHeaders: options.requestHeaders ?? {},
|
||||||
prisma: options.prisma,
|
prisma: options.prisma,
|
||||||
adminAudit: options.adminAudit ?? createAdminAuditStore(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 { RedisGatewaySessionService } from './auth/redisSessionService.js';
|
||||||
import { createGatewayOrchestrator } from './orchestrator/orchestratorFactory.js';
|
import { createGatewayOrchestrator } from './orchestrator/orchestratorFactory.js';
|
||||||
import { createGatewayReleaseRepository } from './orchestrator/gatewayReleaseRepository.js';
|
import { createGatewayReleaseRepository } from './orchestrator/gatewayReleaseRepository.js';
|
||||||
|
import { AuthAttemptBudget, RedisAuthCounterStore } from './auth/attemptBudget.js';
|
||||||
import { appRouter } from './router.js';
|
import { appRouter } from './router.js';
|
||||||
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
|
import { RepositoryProfileStatusService } from './lobby/profileStatusService.js';
|
||||||
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
|
import { registerAccountIconInternalRoute } from './auth/accountIconInternalRoute.js';
|
||||||
@@ -66,6 +67,11 @@ export const createGatewayApiServer = async () => {
|
|||||||
sessionTtlSeconds: config.sessionTtlSeconds,
|
sessionTtlSeconds: config.sessionTtlSeconds,
|
||||||
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
|
gameSessionTtlSeconds: config.gameSessionTtlSeconds,
|
||||||
});
|
});
|
||||||
|
const authBudget = new AuthAttemptBudget(
|
||||||
|
new RedisAuthCounterStore(redis.client),
|
||||||
|
config.redisKeyPrefix,
|
||||||
|
config.gameTokenSecret
|
||||||
|
);
|
||||||
const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel);
|
const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel);
|
||||||
const kakaoClient = new KakaoOAuthClient({
|
const kakaoClient = new KakaoOAuthClient({
|
||||||
restKey: config.kakaoRestKey,
|
restKey: config.kakaoRestKey,
|
||||||
@@ -91,6 +97,8 @@ export const createGatewayApiServer = async () => {
|
|||||||
);
|
);
|
||||||
const app = fastify({
|
const app = fastify({
|
||||||
logger: safeHttpLoggerOptions,
|
logger: safeHttpLoggerOptions,
|
||||||
|
// Count the actual peer; forwarded headers are untrusted without an explicit proxy policy.
|
||||||
|
trustProxy: false,
|
||||||
routerOptions: gatewayFastifyRouterOptions,
|
routerOptions: gatewayFastifyRouterOptions,
|
||||||
});
|
});
|
||||||
app.setErrorHandler(safeHttpErrorHandler);
|
app.setErrorHandler(safeHttpErrorHandler);
|
||||||
@@ -154,6 +162,8 @@ export const createGatewayApiServer = async () => {
|
|||||||
releases,
|
releases,
|
||||||
orchestrator,
|
orchestrator,
|
||||||
profileStatus,
|
profileStatus,
|
||||||
|
authBudget,
|
||||||
|
requestIp: req.ip,
|
||||||
requestHeaders: req.headers,
|
requestHeaders: req.headers,
|
||||||
prisma: postgres.prisma as GatewayPrismaClient,
|
prisma: postgres.prisma as GatewayPrismaClient,
|
||||||
navigationConfig,
|
navigationConfig,
|
||||||
|
|||||||
@@ -1,9 +1,36 @@
|
|||||||
import { safeTrpcErrorFormatter } from '@sammo-ts/common';
|
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';
|
import type { GatewayApiContext } from './context.js';
|
||||||
|
|
||||||
const t = initTRPC.context<GatewayApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
|
const t = initTRPC.context<GatewayApiContext>().create({ errorFormatter: safeTrpcErrorFormatter });
|
||||||
|
|
||||||
export const router = t.router;
|
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}초 후 다시 시도해 주세요.`
|
||||||
|
: '인증 보호 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void>((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<number>((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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>();
|
||||||
|
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<string, string> = {}) =>
|
||||||
|
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<void>((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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,11 +25,16 @@ describe('Kakao OAuth HTTP transport', () => {
|
|||||||
await expect(createClient().signup('access-token')).resolves.toEqual({
|
await expect(createClient().signup('access-token')).resolves.toEqual({
|
||||||
alreadyRegistered: true,
|
alreadyRegistered: true,
|
||||||
});
|
});
|
||||||
expect(fetchMock).toHaveBeenCalledWith(new URL('https://kapi.example.test/v1/user/signup'), {
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
headers: {
|
new URL('https://kapi.example.test/v1/user/signup'),
|
||||||
Authorization: 'Bearer access-token',
|
expect.objectContaining({
|
||||||
},
|
headers: {
|
||||||
});
|
Authorization: 'Bearer access-token',
|
||||||
|
},
|
||||||
|
redirect: 'error',
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('continues to reject unrelated Kakao signup errors', async () => {
|
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(
|
await expect(createClient().signup('access-token')).rejects.toThrow('Kakao signup error.');
|
||||||
'Kakao signup error: {"msg":"invalid request","code":-201}'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<void>((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<void>((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.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -566,3 +566,54 @@ message는 일반 문구로 치환한다. 4xx의 사용자용 검증/권한 문
|
|||||||
이 정책은 HTTP API 로그의 범위이며 release builder의 관리자 build log를
|
이 정책은 HTTP API 로그의 범위이며 release builder의 관리자 build log를
|
||||||
일괄 숨기지 않는다. 장애 조사 시 request ID·route·status·code와 별도 격리 재현을
|
일괄 숨기지 않는다. 장애 조사 시 request ID·route·status·code와 별도 격리 재현을
|
||||||
사용하며 운영 HTTP 로그에 credential이나 raw error payload를 다시 추가하지 않는다.
|
사용하며 운영 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를 공유하지 않으며 새 코드의
|
||||||
|
검증 결과를 실제 공개 배포 근거로 사용하지 않는다.
|
||||||
|
|||||||
Reference in New Issue
Block a user