From 0c1e27ee4a7ab1bd178b22a1955d3e93d6ed4640 Mon Sep 17 00:00:00 2001 From: Hide_D Date: Tue, 30 Dec 2025 02:19:32 +0000 Subject: [PATCH] feat: integrate Kakao OAuth for user authentication and enhance user repository - Added Kakao OAuth client implementation for handling authentication flows. - Updated user repository to support OAuth-based user creation and retrieval. - Introduced Redis-based session management for OAuth sessions. - Enhanced in-memory user repository to handle OAuth user data. - Updated environment configuration files to include new OAuth settings. - Modified API routes to support Kakao login and registration flows. - Added Prisma schema updates for user model to accommodate OAuth fields. - Implemented tests for the new authentication flow and user repository methods. --- .env.ci.example | 14 ++ .env.example | 37 ++++ .env.ops.example | 15 ++ app/gateway-api/package.json | 1 + .../src/auth/inMemoryUserRepository.ts | 39 ++++ app/gateway-api/src/auth/kakaoClient.ts | 181 ++++++++++++++++++ app/gateway-api/src/auth/oauthSessionStore.ts | 159 +++++++++++++++ .../src/auth/postgresUserRepository.ts | 125 ++++++++++++ app/gateway-api/src/auth/userRepository.ts | 22 +++ app/gateway-api/src/config.ts | 20 ++ app/gateway-api/src/context.ts | 11 ++ app/gateway-api/src/index.ts | 3 + app/gateway-api/src/router.ts | 175 ++++++++++++++++- app/gateway-api/src/server.ts | 29 ++- app/gateway-api/test/authFlow.test.ts | 41 +++- packages/infra/prisma/schema.prisma | 24 +++ pnpm-lock.yaml | 3 + 17 files changed, 893 insertions(+), 6 deletions(-) create mode 100644 .env.example create mode 100644 app/gateway-api/src/auth/kakaoClient.ts create mode 100644 app/gateway-api/src/auth/oauthSessionStore.ts create mode 100644 app/gateway-api/src/auth/postgresUserRepository.ts diff --git a/.env.ci.example b/.env.ci.example index 09e5196..a200971 100644 --- a/.env.ci.example +++ b/.env.ci.example @@ -2,3 +2,17 @@ POSTGRES_DB=sammo_test POSTGRES_USER=sammo POSTGRES_PASSWORD=ci-postgres REDIS_PASSWORD=ci-redis +REDIS_HOST=127.0.0.1 +REDIS_PORT=16379 +REDIS_DB=0 +GAME_TOKEN_SECRET=ci-secret +GATEWAY_REDIS_PREFIX=sammo:gateway +GATEWAY_API_HOST=127.0.0.1 +GATEWAY_API_PORT=13000 +GATEWAY_PUBLIC_URL=http://localhost:13000 +GAME_API_HOST=127.0.0.1 +GAME_API_PORT=14000 +PROFILE=che +SCENARIO=default +KAKAO_REST_KEY=ci-kakao-rest-key +KAKAO_REDIRECT_URI=http://localhost:13000/oauth/kakao/callback diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f1b663f --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# Database +POSTGRES_HOST=127.0.0.1 +POSTGRES_PORT=15432 +POSTGRES_DB=sammo +POSTGRES_USER=sammo +POSTGRES_PASSWORD=change-me +# DATABASE_URL=postgresql://sammo:change-me@127.0.0.1:15432/sammo?schema=public + +# Redis +REDIS_HOST=127.0.0.1 +REDIS_PORT=16379 +REDIS_DB=0 +REDIS_PASSWORD=change-me +# REDIS_URL=redis://:change-me@127.0.0.1:16379/0 + +# Shared secrets +GAME_TOKEN_SECRET=change-me + +# Gateway API +GATEWAY_API_HOST=0.0.0.0 +GATEWAY_API_PORT=13000 +GATEWAY_PUBLIC_URL=http://localhost:13000 +GATEWAY_REDIS_PREFIX=sammo:gateway +SESSION_TTL_SECONDS=604800 +GAME_SESSION_TTL_SECONDS=21600 +OAUTH_SESSION_TTL_SECONDS=600 +KAKAO_REST_KEY=your-kakao-rest-key +KAKAO_ADMIN_KEY=your-kakao-admin-key +KAKAO_REDIRECT_URI=http://localhost:13000/oauth/kakao/callback + +# Game API +GAME_API_HOST=0.0.0.0 +GAME_API_PORT=14000 +PROFILE=che +SCENARIO=default +DAEMON_REQUEST_TIMEOUT_MS=5000 +TRPC_PATH=/trpc diff --git a/.env.ops.example b/.env.ops.example index c22154d..108bf80 100644 --- a/.env.ops.example +++ b/.env.ops.example @@ -2,3 +2,18 @@ POSTGRES_DB=sammo POSTGRES_USER=sammo POSTGRES_PASSWORD=change-me REDIS_PASSWORD=change-me +REDIS_HOST=127.0.0.1 +REDIS_PORT=16379 +REDIS_DB=0 +GAME_TOKEN_SECRET=change-me +GATEWAY_REDIS_PREFIX=sammo:gateway +GATEWAY_API_HOST=0.0.0.0 +GATEWAY_API_PORT=13000 +GATEWAY_PUBLIC_URL=https://example.com +GAME_API_HOST=0.0.0.0 +GAME_API_PORT=14000 +PROFILE=che +SCENARIO=default +KAKAO_REST_KEY=your-kakao-rest-key +KAKAO_ADMIN_KEY=your-kakao-admin-key +KAKAO_REDIRECT_URI=https://example.com/oauth/kakao/callback diff --git a/app/gateway-api/package.json b/app/gateway-api/package.json index f02607b..ebc6ee9 100644 --- a/app/gateway-api/package.json +++ b/app/gateway-api/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@fastify/cors": "^10.0.1", + "@prisma/client": "^7.2.0", "@sammo-ts/common": "workspace:*", "@sammo-ts/infra": "workspace:*", "@trpc/server": "^11.4.4", diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index f08e348..683907f 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -8,31 +8,70 @@ export const createInMemoryUserRepository = ( hasher: PasswordHasher = createSimplePasswordHasher() ): UserRepository => { const usersByName = new Map(); + const usersByOauthId = new Map(); + const usersByEmail = new Map(); return { async findByUsername(username: string): Promise { return usersByName.get(username) ?? null; }, + async findByOauthId(type: 'KAKAO', oauthId: string): Promise { + return usersByOauthId.get(`${type}:${oauthId}`) ?? null; + }, + async findByEmail(email: string): Promise { + return usersByEmail.get(email.toLowerCase()) ?? null; + }, async createUser(input: CreateUserInput): Promise { if (usersByName.has(input.username)) { throw new Error('User already exists.'); } const salt = hasher.createSalt(); + const oauthType = input.oauth?.type ?? 'NONE'; const user: UserRecord = { id: randomUUID(), username: input.username, displayName: input.displayName ?? input.username, roles: ['user'], sanctions: {}, + oauthType, + oauthId: input.oauth?.id, + email: input.oauth?.email, + oauthInfo: input.oauth?.info, passwordSalt: salt, passwordHash: hasher.hash(input.password, salt), createdAt: new Date().toISOString(), }; usersByName.set(input.username, user); + if (user.oauthType === 'KAKAO' && user.oauthId) { + usersByOauthId.set(`${user.oauthType}:${user.oauthId}`, user); + } + if (user.email) { + usersByEmail.set(user.email.toLowerCase(), user); + } return user; }, async verifyPassword(user: UserRecord, password: string): Promise { return hasher.hash(password, user.passwordSalt) === user.passwordHash; }, + async updatePassword(userId: string, password: string): Promise { + for (const user of usersByName.values()) { + if (user.id === userId) { + const salt = hasher.createSalt(); + user.passwordSalt = salt; + user.passwordHash = hasher.hash(password, salt); + return; + } + } + throw new Error('User not found.'); + }, + async updateOAuthInfo(userId: string, oauthInfo: UserRecord['oauthInfo']): Promise { + for (const user of usersByName.values()) { + if (user.id === userId) { + user.oauthInfo = oauthInfo; + return; + } + } + throw new Error('User not found.'); + }, }; }; diff --git a/app/gateway-api/src/auth/kakaoClient.ts b/app/gateway-api/src/auth/kakaoClient.ts new file mode 100644 index 0000000..5711059 --- /dev/null +++ b/app/gateway-api/src/auth/kakaoClient.ts @@ -0,0 +1,181 @@ +export interface KakaoOAuthConfig { + restKey: string; + adminKey?: string; + redirectUri: string; + oauthHost?: string; + apiHost?: string; +} + +export interface KakaoOAuthToken { + accessToken: string; + refreshToken?: string; + accessTokenExpiresIn: number; + refreshTokenExpiresIn?: number; +} + +export interface KakaoAccountInfo { + hasEmail: boolean; + email?: string; + isEmailValid?: boolean; + isEmailVerified?: boolean; +} + +export interface KakaoUserInfo { + id: string; + kakaoAccount: KakaoAccountInfo; +} + +const buildForm = (params: Record): URLSearchParams => { + const form = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + form.append(key, value); + } + return form; +}; + +const parseToken = (payload: Record): KakaoOAuthToken => { + return { + accessToken: String(payload.access_token ?? ''), + refreshToken: payload.refresh_token ? String(payload.refresh_token) : undefined, + accessTokenExpiresIn: Number(payload.expires_in ?? 0), + refreshTokenExpiresIn: payload.refresh_token_expires_in + ? Number(payload.refresh_token_expires_in) + : undefined, + }; +}; + +export class KakaoOAuthClient { + private readonly restKey: string; + private readonly adminKey?: string; + private readonly redirectUri: string; + private readonly oauthHost: string; + private readonly apiHost: string; + + constructor(config: KakaoOAuthConfig) { + this.restKey = config.restKey; + this.adminKey = config.adminKey; + this.redirectUri = config.redirectUri; + this.oauthHost = config.oauthHost ?? 'https://kauth.kakao.com'; + this.apiHost = config.apiHost ?? 'https://kapi.kakao.com'; + } + + buildAuthUrl(state: string, scopes: string[]): string { + const base = new URL('/oauth/authorize', this.oauthHost); + base.searchParams.set('client_id', this.restKey); + base.searchParams.set('redirect_uri', this.redirectUri); + base.searchParams.set('response_type', 'code'); + base.searchParams.set('state', state); + if (scopes.length > 0) { + base.searchParams.set('scope', scopes.join(',')); + } + return base.toString(); + } + + async exchangeCode(code: string): Promise { + const response = await fetch(new URL('/oauth/token', this.oauthHost), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: buildForm({ + grant_type: 'authorization_code', + client_id: this.restKey, + redirect_uri: this.redirectUri, + code, + }), + }); + const payload = (await response.json()) as Record; + if (!response.ok) { + throw new Error(`Kakao OAuth token error: ${JSON.stringify(payload)}`); + } + return parseToken(payload); + } + + async refreshToken(refreshToken: string): Promise { + const response = await fetch(new URL('/oauth/token', this.oauthHost), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: buildForm({ + grant_type: 'refresh_token', + client_id: this.restKey, + refresh_token: refreshToken, + }), + }); + const payload = (await response.json()) as Record; + if (!response.ok) { + throw new Error(`Kakao OAuth refresh error: ${JSON.stringify(payload)}`); + } + return parseToken(payload); + } + + async signup(accessToken: string): Promise<{ id?: string; msg?: string }> { + const response = await fetch(new URL('/v1/user/signup', this.apiHost), { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + const payload = (await response.json()) as Record; + if (!response.ok) { + throw new Error(`Kakao signup error: ${JSON.stringify(payload)}`); + } + return { + id: payload.id ? String(payload.id) : undefined, + msg: payload.msg ? String(payload.msg) : undefined, + }; + } + + async getMe(accessToken: string): Promise { + const response = await fetch(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)}`); + } + const kakaoAccount = (payload.kakao_account ?? {}) as Record; + return { + id: String(payload.id ?? ''), + kakaoAccount: { + hasEmail: Boolean(kakaoAccount.has_email ?? false), + email: kakaoAccount.email ? String(kakaoAccount.email) : undefined, + isEmailValid: kakaoAccount.is_email_valid + ? Boolean(kakaoAccount.is_email_valid) + : undefined, + isEmailVerified: kakaoAccount.is_email_verified + ? Boolean(kakaoAccount.is_email_verified) + : undefined, + }, + }; + } + + async sendTalkMessage(accessToken: string, message: string, link: string): Promise { + const response = await fetch(new URL('/v2/api/talk/memo/default/send', this.apiHost), { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: buildForm({ + template_object: JSON.stringify({ + object_type: 'text', + text: message, + link: { + web_url: link, + mobile_web_url: link, + }, + button_title: '로그인 페이지 열기', + }), + }), + }); + 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)}`); + } + } +} diff --git a/app/gateway-api/src/auth/oauthSessionStore.ts b/app/gateway-api/src/auth/oauthSessionStore.ts new file mode 100644 index 0000000..13aeba7 --- /dev/null +++ b/app/gateway-api/src/auth/oauthSessionStore.ts @@ -0,0 +1,159 @@ +import { randomUUID } from 'node:crypto'; + +export type OAuthMode = 'login' | 'change_pw'; + +export interface OAuthPendingState { + state: string; + mode: OAuthMode; + scopes: string[]; + createdAt: string; +} + +export interface OAuthSession { + id: string; + mode: OAuthMode; + kakaoId: string; + email: string; + accessToken: string; + refreshToken?: string; + accessTokenValidUntil: string; + refreshTokenValidUntil?: string; + createdAt: string; +} + +export interface OAuthSessionStore { + createPendingState(mode: OAuthMode, scopes: string[]): Promise; + consumePendingState(state: string): Promise; + createSession(session: Omit): Promise; + consumeSession(sessionId: string): Promise; +} + +interface RedisPipeline { + set(key: string, value: string, options?: { EX?: number }): RedisPipeline; + del(key: string): RedisPipeline; + exec(): Promise; +} + +interface RedisClientLike { + get(key: string): Promise; + set(key: string, value: string, options?: { EX?: number }): Promise; + del(key: string): Promise; + multi(): RedisPipeline; +} + +const parseJson = (raw: string | null): T | null => { + if (!raw) { + return null; + } + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +}; + +export class RedisOAuthSessionStore implements OAuthSessionStore { + private readonly client: RedisClientLike; + private readonly prefix: string; + private readonly ttlSeconds: number; + + constructor(client: RedisClientLike, prefix: string, ttlSeconds: number) { + this.client = client; + this.prefix = prefix; + this.ttlSeconds = ttlSeconds; + } + + private stateKey(state: string): string { + return `${this.prefix}:oauth-state:${state}`; + } + + private sessionKey(sessionId: string): string { + return `${this.prefix}:oauth-session:${sessionId}`; + } + + async createPendingState(mode: OAuthMode, scopes: string[]): Promise { + const state: OAuthPendingState = { + state: randomUUID(), + mode, + scopes, + createdAt: new Date().toISOString(), + }; + await this.client.set(this.stateKey(state.state), JSON.stringify(state), { + EX: this.ttlSeconds, + }); + return state; + } + + async consumePendingState(state: string): Promise { + const key = this.stateKey(state); + const raw = await this.client.get(key); + if (!raw) { + return null; + } + await this.client.del(key); + return parseJson(raw); + } + + async createSession(session: Omit): Promise { + const stored: OAuthSession = { + ...session, + id: randomUUID(), + }; + await this.client.set(this.sessionKey(stored.id), JSON.stringify(stored), { + EX: this.ttlSeconds, + }); + return stored; + } + + async consumeSession(sessionId: string): Promise { + const key = this.sessionKey(sessionId); + const raw = await this.client.get(key); + if (!raw) { + return null; + } + await this.client.del(key); + return parseJson(raw); + } +} + +// 테스트용 인메모리 OAuth 세션 저장소. +export class InMemoryOAuthSessionStore implements OAuthSessionStore { + private readonly pendingStates = new Map(); + private readonly sessions = new Map(); + + async createPendingState(mode: OAuthMode, scopes: string[]): Promise { + const pending: OAuthPendingState = { + state: randomUUID(), + mode, + scopes, + createdAt: new Date().toISOString(), + }; + this.pendingStates.set(pending.state, pending); + return pending; + } + + async consumePendingState(state: string): Promise { + const pending = this.pendingStates.get(state) ?? null; + if (pending) { + this.pendingStates.delete(state); + } + return pending; + } + + async createSession(session: Omit): Promise { + const stored: OAuthSession = { + ...session, + id: randomUUID(), + }; + this.sessions.set(stored.id, stored); + return stored; + } + + async consumeSession(sessionId: string): Promise { + const session = this.sessions.get(sessionId) ?? null; + if (session) { + this.sessions.delete(sessionId); + } + return session; + } +} diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts new file mode 100644 index 0000000..53c65c0 --- /dev/null +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -0,0 +1,125 @@ +import { Prisma, type PrismaClient } from '@prisma/client'; + +import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js'; +import type { + CreateUserInput, + UserOAuthInfo, + UserRecord, + UserRepository, + UserSanctions, +} from './userRepository.js'; + +const readStringArray = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is string => typeof item === 'string'); +}; + +const readObject = (value: unknown, fallback: T): T => { + if (!value || typeof value !== 'object') { + return fallback; + } + return value as T; +}; + +const mapUser = (row: { + id: string; + loginId: string; + displayName: string; + passwordHash: string; + passwordSalt: string; + roles: Prisma.JsonValue; + sanctions: Prisma.JsonValue; + oauthType: 'NONE' | 'KAKAO'; + oauthId: string | null; + email: string | null; + oauthInfo: Prisma.JsonValue; + createdAt: Date; +}): UserRecord => ({ + id: row.id, + username: row.loginId, + displayName: row.displayName, + roles: readStringArray(row.roles), + sanctions: readObject(row.sanctions, {}), + oauthType: row.oauthType, + oauthId: row.oauthId ?? undefined, + email: row.email ?? undefined, + oauthInfo: readObject(row.oauthInfo, {}), + passwordHash: row.passwordHash, + passwordSalt: row.passwordSalt, + createdAt: row.createdAt.toISOString(), +}); + +export const createPostgresUserRepository = ( + prisma: PrismaClient, + hasher: PasswordHasher = createSimplePasswordHasher() +): UserRepository => { + return { + async findByUsername(username: string): Promise { + const row = await prisma.appUser.findUnique({ + where: { + loginId: username, + }, + }); + return row ? mapUser(row) : null; + }, + async findByOauthId(type: 'KAKAO', oauthId: string): Promise { + const row = await prisma.appUser.findFirst({ + where: { + oauthType: type, + oauthId, + }, + }); + return row ? mapUser(row) : null; + }, + async findByEmail(email: string): Promise { + const row = await prisma.appUser.findUnique({ + where: { + email: email.toLowerCase(), + }, + }); + return row ? mapUser(row) : null; + }, + async createUser(input: CreateUserInput): Promise { + const salt = hasher.createSalt(); + const oauthType = input.oauth?.type ?? 'NONE'; + const row = await prisma.appUser.create({ + data: { + loginId: input.username, + displayName: input.displayName ?? input.username, + passwordHash: hasher.hash(input.password, salt), + passwordSalt: salt, + roles: ['user'] satisfies Prisma.JsonArray, + sanctions: {} satisfies Prisma.JsonObject, + oauthType, + oauthId: input.oauth?.id, + email: input.oauth?.email?.toLowerCase(), + oauthInfo: (input.oauth?.info ?? {}) as Prisma.JsonObject, + }, + }); + return mapUser(row); + }, + async verifyPassword(user: UserRecord, password: string): Promise { + return hasher.hash(password, user.passwordSalt) === user.passwordHash; + }, + async updatePassword(userId: string, password: string): Promise { + const salt = hasher.createSalt(); + await prisma.appUser.update({ + where: { id: userId }, + data: { + passwordHash: hasher.hash(password, salt), + passwordSalt: salt, + }, + }); + }, + async updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise { + await prisma.appUser.update({ + where: { id: userId }, + data: { + oauthInfo: oauthInfo as Prisma.JsonObject, + }, + }); + }, + }; +}; diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index c879e0a..89debbc 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -4,6 +4,10 @@ export interface UserRecord { displayName: string; roles: string[]; sanctions: UserSanctions; + oauthType: 'NONE' | 'KAKAO'; + oauthId?: string; + email?: string; + oauthInfo?: UserOAuthInfo; passwordHash: string; passwordSalt: string; createdAt: string; @@ -38,10 +42,28 @@ export interface CreateUserInput { username: string; password: string; displayName?: string; + oauth?: { + type: 'KAKAO'; + id: string; + email: string; + info: UserOAuthInfo; + }; } export interface UserRepository { findByUsername(username: string): Promise; + findByOauthId(type: 'KAKAO', oauthId: string): Promise; + findByEmail(email: string): Promise; createUser(input: CreateUserInput): Promise; verifyPassword(user: UserRecord, password: string): Promise; + updatePassword(userId: string, password: string): Promise; + updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise; +} + +export interface UserOAuthInfo { + accessToken?: string; + refreshToken?: string; + accessTokenValidUntil?: string; + refreshTokenValidUntil?: string; + nextPasswordChange?: string; } diff --git a/app/gateway-api/src/config.ts b/app/gateway-api/src/config.ts index 48038f6..12d600f 100644 --- a/app/gateway-api/src/config.ts +++ b/app/gateway-api/src/config.ts @@ -7,6 +7,11 @@ export interface GatewayApiConfig { sessionTtlSeconds: number; gameSessionTtlSeconds: number; gameTokenSecret: string; + oauthSessionTtlSeconds: number; + kakaoRestKey: string; + kakaoAdminKey?: string; + kakaoRedirectUri: string; + publicBaseUrl: string; } const parseNumber = (value: string | undefined, fallback: number, label: string): number => { @@ -27,6 +32,12 @@ export const resolveGatewayApiConfigFromEnv = ( if (!secret) { throw new Error('GAME_TOKEN_SECRET is required for gateway token encryption.'); } + const kakaoRestKey = env.KAKAO_REST_KEY ?? ''; + const kakaoRedirectUri = env.KAKAO_REDIRECT_URI ?? ''; + if (!kakaoRestKey || !kakaoRedirectUri) { + throw new Error('KAKAO_REST_KEY and KAKAO_REDIRECT_URI are required.'); + } + const publicBaseUrl = env.GATEWAY_PUBLIC_URL ?? kakaoRedirectUri; const redisKeyPrefix = env.GATEWAY_REDIS_PREFIX ?? 'sammo:gateway'; return { host: env.GATEWAY_API_HOST ?? '0.0.0.0', @@ -41,5 +52,14 @@ export const resolveGatewayApiConfigFromEnv = ( 'GAME_SESSION_TTL_SECONDS' ), gameTokenSecret: secret, + oauthSessionTtlSeconds: parseNumber( + env.OAUTH_SESSION_TTL_SECONDS, + 10 * 60, + 'OAUTH_SESSION_TTL_SECONDS' + ), + kakaoRestKey, + kakaoAdminKey: env.KAKAO_ADMIN_KEY, + kakaoRedirectUri, + publicBaseUrl, }; }; diff --git a/app/gateway-api/src/context.ts b/app/gateway-api/src/context.ts index aae4146..fc35edc 100644 --- a/app/gateway-api/src/context.ts +++ b/app/gateway-api/src/context.ts @@ -1,6 +1,8 @@ import type { GatewayFlushPublisher } from './auth/flushPublisher.js'; import type { GatewaySessionService } from './auth/sessionService.js'; import type { UserRepository } from './auth/userRepository.js'; +import type { KakaoOAuthClient } from './auth/kakaoClient.js'; +import type { OAuthSessionStore } from './auth/oauthSessionStore.js'; export interface GatewayApiContext { users: UserRepository; @@ -8,6 +10,9 @@ export interface GatewayApiContext { flushPublisher: GatewayFlushPublisher; gameTokenSecret: string; gameSessionTtlSeconds: number; + kakaoClient: KakaoOAuthClient; + oauthSessions: OAuthSessionStore; + publicBaseUrl: string; } export const createGatewayApiContext = (options: { @@ -16,10 +21,16 @@ export const createGatewayApiContext = (options: { flushPublisher: GatewayFlushPublisher; gameTokenSecret: string; gameSessionTtlSeconds: number; + kakaoClient: KakaoOAuthClient; + oauthSessions: OAuthSessionStore; + publicBaseUrl: string; }): GatewayApiContext => ({ users: options.users, sessions: options.sessions, flushPublisher: options.flushPublisher, gameTokenSecret: options.gameTokenSecret, gameSessionTtlSeconds: options.gameSessionTtlSeconds, + kakaoClient: options.kakaoClient, + oauthSessions: options.oauthSessions, + publicBaseUrl: options.publicBaseUrl, }); diff --git a/app/gateway-api/src/index.ts b/app/gateway-api/src/index.ts index 5ab21d3..c9ca0f5 100644 --- a/app/gateway-api/src/index.ts +++ b/app/gateway-api/src/index.ts @@ -15,6 +15,9 @@ export * from './auth/inMemorySessionService.js'; export * from './auth/redisSessionService.js'; export * from './auth/redisKeys.js'; export * from './auth/flushPublisher.js'; +export * from './auth/kakaoClient.js'; +export * from './auth/oauthSessionStore.js'; +export * from './auth/postgresUserRepository.js'; const isMain = (): boolean => { if (!process.argv[1]) { diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 53b44e2..e124bf1 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -1,3 +1,5 @@ +import { randomBytes } from 'node:crypto'; + import { TRPCError } from '@trpc/server'; import { z } from 'zod'; @@ -8,10 +10,12 @@ import { import { procedure, router } from './trpc.js'; import { toPublicUser } from './auth/userRepository.js'; +import type { UserOAuthInfo } from './auth/userRepository.js'; const zUsername = z.string().min(2).max(32); const zPassword = z.string().min(6).max(128); const zProfile = z.string().min(1).max(64); +const zOAuthMode = z.enum(['login', 'change_pw']); const parseDate = (value: string): Date | null => { const parsed = new Date(value); @@ -29,15 +33,159 @@ export const appRouter = router({ })), }), auth: router({ + kakaoStart: procedure + .input( + z.object({ + mode: zOAuthMode.optional(), + scopes: z.array(z.string()).optional(), + }).optional() + ) + .query(async ({ ctx, input }) => { + const mode = input?.mode ?? 'login'; + const scopes = input?.scopes ?? ['account_email']; + const pending = await ctx.oauthSessions.createPendingState(mode, scopes); + const authUrl = ctx.kakaoClient.buildAuthUrl(pending.state, pending.scopes); + return { + mode, + state: pending.state, + authUrl, + }; + }), + kakaoExchange: procedure + .input( + z.object({ + code: z.string().min(1), + state: z.string().min(1), + }) + ) + .mutation(async ({ ctx, input }) => { + const pending = await ctx.oauthSessions.consumePendingState(input.state); + if (!pending) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid OAuth state.', + }); + } + const token = await ctx.kakaoClient.exchangeCode(input.code); + const accessTokenValidUntil = new Date( + Date.now() + token.accessTokenExpiresIn * 1000 + ).toISOString(); + const refreshTokenValidUntil = token.refreshTokenExpiresIn + ? new Date(Date.now() + token.refreshTokenExpiresIn * 1000).toISOString() + : undefined; + + const signupResult = await ctx.kakaoClient.signup(token.accessToken); + if (!signupResult.id && signupResult.msg !== 'already registered') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '카카오 앱 연결에 실패했습니다.', + }); + } + const me = await ctx.kakaoClient.getMe(token.accessToken); + const kakaoAccount = me.kakaoAccount; + if (!kakaoAccount.hasEmail || !kakaoAccount.email) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '이메일 정보 제공에 동의해야 합니다.', + }); + } + if (!kakaoAccount.isEmailValid || !kakaoAccount.isEmailVerified) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '카카오 계정 이메일이 인증되지 않았습니다.', + }); + } + + const oauthInfo: UserOAuthInfo = { + accessToken: token.accessToken, + refreshToken: token.refreshToken, + accessTokenValidUntil, + refreshTokenValidUntil, + }; + + const existing = + (await ctx.users.findByOauthId('KAKAO', me.id)) ?? + (await ctx.users.findByEmail(kakaoAccount.email)); + + if (pending.mode === 'change_pw') { + if (!existing) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: '카카오 계정에 연결된 사용자를 찾지 못했습니다.', + }); + } + const nextPasswordChange = existing.oauthInfo?.nextPasswordChange + ? parseDate(existing.oauthInfo.nextPasswordChange) + : null; + if (nextPasswordChange && Date.now() < nextPasswordChange.getTime()) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: '비밀번호 초기화는 잠시 후 다시 시도해주세요.', + }); + } + const tempPassword = randomBytes(4).toString('hex'); + await ctx.kakaoClient.sendTalkMessage( + token.accessToken, + `임시 비밀번호는 ${tempPassword} 입니다. 로그인 후 바로 다른 비밀번호로 변경해주세요.`, + ctx.publicBaseUrl + ); + const nextChange = new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(); + await ctx.users.updatePassword(existing.id, tempPassword); + await ctx.users.updateOAuthInfo(existing.id, { + ...oauthInfo, + nextPasswordChange: nextChange, + }); + return { + status: 'change_pw' as const, + ok: true, + }; + } + + if (existing) { + await ctx.users.updateOAuthInfo(existing.id, oauthInfo); + const session = await ctx.sessions.createSession(existing); + return { + status: 'login' as const, + user: toPublicUser(existing), + sessionToken: session.sessionToken, + issuedAt: session.issuedAt, + }; + } + + const stored = await ctx.oauthSessions.createSession({ + mode: pending.mode, + kakaoId: me.id, + email: kakaoAccount.email, + accessToken: token.accessToken, + refreshToken: token.refreshToken, + accessTokenValidUntil, + refreshTokenValidUntil, + createdAt: new Date().toISOString(), + }); + + return { + status: 'join' as const, + oauthSessionId: stored.id, + email: stored.email, + }; + }), register: procedure .input( z.object({ + oauthSessionId: z.string().min(1), username: zUsername, password: zPassword, displayName: z.string().min(2).max(40).optional(), }) ) .mutation(async ({ ctx, input }) => { + const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId); + if (!oauthSession) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'OAuth 세션이 만료되었습니다.', + }); + } const existing = await ctx.users.findByUsername(input.username); if (existing) { throw new TRPCError({ @@ -45,9 +193,34 @@ export const appRouter = router({ message: 'Username already exists.', }); } + const existingOAuth = + (await ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId)) ?? + (await ctx.users.findByEmail(oauthSession.email)); + if (existingOAuth) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'OAuth account already registered.', + }); + } + const oauthInfo: UserOAuthInfo = { + accessToken: oauthSession.accessToken, + refreshToken: oauthSession.refreshToken, + accessTokenValidUntil: oauthSession.accessTokenValidUntil, + refreshTokenValidUntil: oauthSession.refreshTokenValidUntil, + }; let created = null; try { - created = await ctx.users.createUser(input); + created = await ctx.users.createUser({ + username: input.username, + password: input.password, + displayName: input.displayName, + oauth: { + type: 'KAKAO', + id: oauthSession.kakaoId, + email: oauthSession.email, + info: oauthInfo, + }, + }); } catch (error) { throw new TRPCError({ code: 'CONFLICT', diff --git a/app/gateway-api/src/server.ts b/app/gateway-api/src/server.ts index 6c8b3b3..92aa9a8 100644 --- a/app/gateway-api/src/server.ts +++ b/app/gateway-api/src/server.ts @@ -1,27 +1,46 @@ import fastify from 'fastify'; import cors from '@fastify/cors'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; -import { createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra'; +import { + createPostgresConnector, + createRedisConnector, + resolvePostgresConfigFromEnv, + resolveRedisConfigFromEnv, +} from '@sammo-ts/infra'; import { resolveGatewayApiConfigFromEnv } from './config.js'; import { createGatewayApiContext } from './context.js'; import { RedisGatewayFlushPublisher } from './auth/flushPublisher.js'; -import { createInMemoryUserRepository } from './auth/inMemoryUserRepository.js'; +import { KakaoOAuthClient } from './auth/kakaoClient.js'; +import { RedisOAuthSessionStore } from './auth/oauthSessionStore.js'; +import { createPostgresUserRepository } from './auth/postgresUserRepository.js'; import { RedisGatewaySessionService } from './auth/redisSessionService.js'; import { appRouter } from './router.js'; export const createGatewayApiServer = async () => { const config = resolveGatewayApiConfigFromEnv(); + const postgres = createPostgresConnector(resolvePostgresConfigFromEnv()); const redis = createRedisConnector(resolveRedisConfigFromEnv()); + await postgres.connect(); await redis.connect(); - const users = createInMemoryUserRepository(); + const users = createPostgresUserRepository(postgres.prisma); const sessions = new RedisGatewaySessionService(redis.client, { keyPrefix: config.redisKeyPrefix, sessionTtlSeconds: config.sessionTtlSeconds, gameSessionTtlSeconds: config.gameSessionTtlSeconds, }); const flushPublisher = new RedisGatewayFlushPublisher(redis.client, config.flushChannel); + const kakaoClient = new KakaoOAuthClient({ + restKey: config.kakaoRestKey, + adminKey: config.kakaoAdminKey, + redirectUri: config.kakaoRedirectUri, + }); + const oauthSessions = new RedisOAuthSessionStore( + redis.client, + config.redisKeyPrefix, + config.oauthSessionTtlSeconds + ); const app = fastify({ logger: true, @@ -43,6 +62,9 @@ export const createGatewayApiServer = async () => { flushPublisher, gameTokenSecret: config.gameTokenSecret, gameSessionTtlSeconds: config.gameSessionTtlSeconds, + kakaoClient, + oauthSessions, + publicBaseUrl: config.publicBaseUrl, }), }, }); @@ -53,6 +75,7 @@ export const createGatewayApiServer = async () => { app.addHook('onClose', async () => { await redis.disconnect(); + await postgres.disconnect(); }); return { diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 94d1376..792465d 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js'; import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js'; +import { InMemoryOAuthSessionStore } from '../src/auth/oauthSessionStore.js'; import { createGatewayApiContext } from '../src/context.js'; import { appRouter } from '../src/router.js'; @@ -14,21 +15,57 @@ const buildCaller = () => { const flushPublisher = { publishUserFlush: async () => {}, }; - return appRouter.createCaller( + const oauthSessions = new InMemoryOAuthSessionStore(); + const kakaoClient = { + buildAuthUrl: () => '', + exchangeCode: async () => { + throw new Error('not used'); + }, + refreshToken: async () => { + throw new Error('not used'); + }, + signup: async () => ({ id: '1' }), + getMe: async () => ({ + id: '1', + kakaoAccount: { + hasEmail: true, + email: 'tester@example.com', + isEmailValid: true, + isEmailVerified: true, + }, + }), + sendTalkMessage: async () => {}, + }; + const caller = appRouter.createCaller( createGatewayApiContext({ users, sessions, flushPublisher, gameTokenSecret: 'test-secret', gameSessionTtlSeconds: 600, + kakaoClient, + oauthSessions, + publicBaseUrl: 'http://localhost', }) ); + return { caller, oauthSessions }; }; describe('gateway auth flow', () => { it('registers and issues a game session', async () => { - const caller = buildCaller(); + const { caller, oauthSessions } = buildCaller(); + const oauthSession = await oauthSessions.createSession({ + mode: 'login', + kakaoId: '1', + email: 'tester@example.com', + accessToken: 'token', + refreshToken: 'refresh', + accessTokenValidUntil: new Date().toISOString(), + refreshTokenValidUntil: new Date().toISOString(), + createdAt: new Date().toISOString(), + }); const register = await caller.auth.register({ + oauthSessionId: oauthSession.id, username: 'tester', password: 'secretpass', displayName: 'Tester', diff --git a/packages/infra/prisma/schema.prisma b/packages/infra/prisma/schema.prisma index 335335b..2a7dc9d 100644 --- a/packages/infra/prisma/schema.prisma +++ b/packages/infra/prisma/schema.prisma @@ -22,6 +22,30 @@ enum LogCategory { USER } +enum OAuthType { + NONE + KAKAO +} + +model AppUser { + id String @id @default(uuid()) + loginId String @unique @map("login_id") + displayName String @map("display_name") + passwordHash String @map("password_hash") + passwordSalt String @map("password_salt") + roles Json @default(dbgenerated("'[]'::jsonb")) + sanctions Json @default(dbgenerated("'{}'::jsonb")) + oauthType OAuthType @default(NONE) @map("oauth_type") + oauthId String? @unique @map("oauth_id") + email String? @unique + oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + lastLoginAt DateTime? @map("last_login_at") + + @@map("app_user") +} + model WorldState { id Int @id @default(autoincrement()) scenarioCode String @map("scenario_code") diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9178d6f..e1c36d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: '@fastify/cors': specifier: ^10.0.1 version: 10.1.0 + '@prisma/client': + specifier: ^7.2.0 + version: 7.2.0(prisma@7.2.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3) '@sammo-ts/common': specifier: workspace:* version: link:../../packages/common