diff --git a/README.md b/README.md index 873bca31..cdb7ed22 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,13 @@ Gateway 자체 릴리스는 Gateway 프로세스 밖의 `release-controller`가 `GatewayReleaseOperation` queue를 처리합니다. Kakao 계정은 OAuth callback과 일반 비밀번호 로그인 모두에서 Kakao 고유 ID와 -현재 인증 이메일을 다시 확인합니다. 새 Kakao ID의 이메일이 기존 계정에 있거나 -기존 Kakao ID의 변경 이메일이 다른 계정에 있으면 로그인을 거부합니다. 확인된 -이메일은 `AppUser.email`에 동기화하며, 10일마다 `talk_message` scope로 -“나와의 채팅” 숫자 코드를 보내 소유 증명을 완료한 뒤에만 새 Gateway session을 -발급합니다. +현재 인증 이메일을 다시 확인합니다. 로컬 고유 ID 연결이 없지만 영구 보존된 +이메일 계정이 있으면 자동 로그인하지 않고 그 계정에 연결할지 묻습니다. 기존 +계정 연결을 확인하면 이전 Kakao ID와 과거 talk proof를 교체하고 새 +“나와의 채팅” 숫자 코드로 다시 증명한 뒤에만 Gateway session을 발급합니다. +Kakao가 `already registered`를 반환했지만 보존 이메일 계정도 없으면 재가입 +확인 뒤 신규 가입 form을 엽니다. 이미 로컬에 연결된 stable ID의 변경 이메일이 +다른 계정에 있으면 기존처럼 충돌을 거부합니다. 각 game profile은 별도 PostgreSQL schema를 사용합니다. `game-api`는 인증된 요청을 검증하고 직접 처리할 mutation 또는 daemon 입력을 diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index 1903af41..4ffba6ee 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -161,24 +161,58 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp throw new Error('User not found.'); }, async linkKakao(userId, input): Promise { - if (usersByOauthId.has(`KAKAO:${input.oauthId}`) || usersByEmail.has(input.email.toLowerCase())) { + const normalizedEmail = input.email.toLowerCase(); + const oauthOwner = usersByOauthId.get(`KAKAO:${input.oauthId}`); + const emailOwner = usersByEmail.get(normalizedEmail); + if ((oauthOwner && oauthOwner.id !== userId) || (emailOwner && emailOwner.id !== userId)) { throw new Error('Kakao account already linked.'); } for (const user of usersByName.values()) { if (user.id !== userId) { continue; } + if (user.oauthType === 'KAKAO' && user.oauthId) { + usersByOauthId.delete(`KAKAO:${user.oauthId}`); + } + if (user.email && user.email.toLowerCase() !== normalizedEmail) { + usersByEmail.delete(user.email.toLowerCase()); + } user.oauthType = 'KAKAO'; user.oauthId = input.oauthId; - user.email = input.email.toLowerCase(); + user.email = normalizedEmail; user.oauthInfo = input.oauthInfo; user.kakaoVerifiedAt = input.verifiedAt.toISOString(); + user.kakaoTalkVerifiedUntil = undefined; usersByOauthId.set(`KAKAO:${input.oauthId}`, user); usersByEmail.set(user.email, user); return user; } throw new Error('User not found.'); }, + async relinkKakaoByEmail(userId, input): Promise { + const normalizedEmail = input.email.toLowerCase(); + const oauthOwner = usersByOauthId.get(`KAKAO:${input.oauthId}`); + const emailOwner = usersByEmail.get(normalizedEmail); + if ((oauthOwner && oauthOwner.id !== userId) || emailOwner?.id !== userId) { + throw new Error('Kakao account recovery ownership changed.'); + } + for (const user of usersByName.values()) { + if (user.id !== userId || user.email?.toLowerCase() !== normalizedEmail) { + continue; + } + if (user.oauthType === 'KAKAO' && user.oauthId) { + usersByOauthId.delete(`KAKAO:${user.oauthId}`); + } + user.oauthType = 'KAKAO'; + user.oauthId = input.oauthId; + user.oauthInfo = input.oauthInfo; + user.kakaoVerifiedAt = input.verifiedAt.toISOString(); + user.kakaoTalkVerifiedUntil = undefined; + usersByOauthId.set(`KAKAO:${input.oauthId}`, user); + return user; + } + throw new Error('Kakao account recovery ownership changed.'); + }, async updateRoles(userId: string, roles: string[]): Promise { for (const user of usersByName.values()) { if (user.id === userId) { diff --git a/app/gateway-api/src/auth/oauthSessionStore.ts b/app/gateway-api/src/auth/oauthSessionStore.ts index c1f19069..3a3c3bfb 100644 --- a/app/gateway-api/src/auth/oauthSessionStore.ts +++ b/app/gateway-api/src/auth/oauthSessionStore.ts @@ -14,6 +14,8 @@ export interface OAuthPendingState { export interface OAuthSession { id: string; mode: OAuthMode; + intent?: 'register' | 'link_existing' | 'rejoin'; + targetUserId?: string; kakaoId: string; email: string; accessToken: string; diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index a0e7e2fd..5f160b80 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -251,10 +251,32 @@ export const createPostgresUserRepository = ( email: input.email.toLowerCase(), oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject, kakaoVerifiedAt: input.verifiedAt, + kakaoTalkVerifiedUntil: null, }, }); return mapUser(row); }, + async relinkKakaoByEmail(userId, input): Promise { + const normalizedEmail = input.email.toLowerCase(); + const updated = await prisma.appUser.updateMany({ + where: { + id: userId, + email: normalizedEmail, + }, + data: { + oauthType: 'KAKAO', + oauthId: input.oauthId, + oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject, + kakaoVerifiedAt: input.verifiedAt, + kakaoTalkVerifiedUntil: null, + }, + }); + if (updated.count !== 1) { + throw new Error('Kakao account recovery ownership changed.'); + } + const row = await prisma.appUser.findUniqueOrThrow({ where: { id: userId } }); + return mapUser(row); + }, async updateRoles(userId: string, roles: string[]): Promise { await prisma.appUser.update({ where: { id: userId }, diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index 73e01c9d..85afec4c 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -122,6 +122,15 @@ export interface UserRepository { verifiedAt: Date; } ): Promise; + relinkKakaoByEmail( + userId: string, + input: { + oauthId: string; + email: string; + oauthInfo: UserOAuthInfo; + verifiedAt: Date; + } + ): Promise; updateRoles(userId: string, roles: string[]): Promise; updateSanctions(userId: string, sanctions: UserSanctions): Promise; updateKakaoGraceUntil(userId: string, until: Date | null): Promise; diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index c8712e10..281ce283 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -35,6 +35,7 @@ const zUsername = z const zPassword = z.string().min(6).max(128); const zProfile = z.string().min(1).max(64); const zOAuthMode = z.enum(['login', 'change_pw', 'verify']); +const zKakaoRecoveryAction = z.enum(['link_existing', 'rejoin']); const zBootstrapToken = z.string().min(1); const parseDate = (value: string): Date | null => { @@ -252,7 +253,8 @@ export const appRouter = router({ const tokenIssuedAt = new Date(); const signupResult = await ctx.kakaoClient.signup(token.accessToken); - if (!signupResult.id && signupResult.msg !== 'already registered') { + const alreadyRegisteredWithKakao = !signupResult.id && signupResult.msg === 'already registered'; + if (!signupResult.id && !alreadyRegisteredWithKakao) { throw new TRPCError({ code: 'BAD_REQUEST', message: '카카오 앱 연결에 실패했습니다.', @@ -270,12 +272,6 @@ export const appRouter = router({ ctx.users.findByOauthId('KAKAO', profile.kakaoId), ctx.users.findByEmail(profile.email), ]); - if (existingByEmail && existingByEmail.id !== existingById?.id) { - throw new TRPCError({ - code: 'CONFLICT', - message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.', - }); - } if (pending.mode === 'verify') { if (!pending.userId) { @@ -416,8 +412,15 @@ export const appRouter = router({ } const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt); + const recoveryIntent = existingByEmail + ? ('link_existing' as const) + : alreadyRegisteredWithKakao + ? ('rejoin' as const) + : ('register' as const); const stored = await ctx.oauthSessions.createSession({ mode: pending.mode, + intent: recoveryIntent, + targetUserId: existingByEmail?.id, kakaoId: profile.kakaoId, email: profile.email, accessToken: token.accessToken, @@ -427,12 +430,133 @@ export const appRouter = router({ createdAt: new Date().toISOString(), }); + if (recoveryIntent !== 'register') { + return { + status: 'account_recovery' as const, + action: recoveryIntent, + oauthSessionId: stored.id, + email: stored.email, + }; + } + return { status: 'join' as const, oauthSessionId: stored.id, email: stored.email, }; }), + kakaoResolveAccount: procedure + .input( + z.object({ + oauthSessionId: z.string().min(1), + action: zKakaoRecoveryAction, + }) + ) + .mutation(async ({ ctx, input }) => { + const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId); + if (!oauthSession) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: '카카오 계정 복구 세션이 만료되었습니다.', + }); + } + if (oauthSession.intent !== input.action) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '카카오 계정 복구 선택이 올바르지 않습니다.', + }); + } + + if (input.action === 'rejoin') { + const [oauthOwner, emailOwner] = await Promise.all([ + ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId), + ctx.users.findByEmail(oauthSession.email), + ]); + if (oauthOwner || emailOwner) { + throw new TRPCError({ + code: 'CONFLICT', + message: '연결할 기존 계정이 확인되었습니다. 카카오 로그인을 처음부터 다시 진행해 주세요.', + }); + } + const registrationSession = await ctx.oauthSessions.createSession({ + mode: oauthSession.mode, + intent: 'register', + kakaoId: oauthSession.kakaoId, + email: oauthSession.email, + accessToken: oauthSession.accessToken, + refreshToken: oauthSession.refreshToken, + accessTokenValidUntil: oauthSession.accessTokenValidUntil, + refreshTokenValidUntil: oauthSession.refreshTokenValidUntil, + createdAt: new Date().toISOString(), + }); + return { + status: 'join' as const, + oauthSessionId: registrationSession.id, + email: registrationSession.email, + }; + } + + if (!oauthSession.targetUserId) { + throw new TRPCError({ + code: 'CONFLICT', + message: '연결할 기존 계정을 찾지 못했습니다. 카카오 로그인을 처음부터 다시 진행해 주세요.', + }); + } + const [targetUser, emailOwner, oauthOwner] = await Promise.all([ + ctx.users.findById(oauthSession.targetUserId), + ctx.users.findByEmail(oauthSession.email), + ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId), + ]); + if (!targetUser || emailOwner?.id !== targetUser.id) { + throw new TRPCError({ + code: 'CONFLICT', + message: + '보존된 이메일의 계정 정보가 변경되었습니다. 카카오 로그인을 처음부터 다시 진행해 주세요.', + }); + } + if (oauthOwner && oauthOwner.id !== targetUser.id) { + throw new TRPCError({ + code: 'CONFLICT', + message: '이미 다른 계정에 연결된 카카오 계정입니다.', + }); + } + if (targetUser.deleteAfter) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '탈퇴 처리 중인 계정에는 카카오 계정을 다시 연결할 수 없습니다.', + }); + } + if (isLoginBanned(targetUser.sanctions)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Account login is blocked.', + }); + } + + const oauthInfo: UserOAuthInfo = { + accessToken: oauthSession.accessToken, + refreshToken: oauthSession.refreshToken, + accessTokenValidUntil: oauthSession.accessTokenValidUntil, + refreshTokenValidUntil: oauthSession.refreshTokenValidUntil, + }; + let linked: UserRecord; + try { + linked = await ctx.users.relinkKakaoByEmail(targetUser.id, { + oauthId: oauthSession.kakaoId, + email: oauthSession.email, + oauthInfo, + verifiedAt: new Date(), + }); + } catch (error) { + throw new TRPCError({ + code: 'CONFLICT', + message: '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.', + cause: error, + }); + } + await ctx.flushPublisher.publishUserFlush(linked.id, 'kakao-account-relinked'); + return finishKakaoLogin(ctx, linked, oauthSession.accessToken, 'login'); + }), register: procedure .input( z.object({ @@ -460,6 +584,12 @@ export const appRouter = router({ message: 'OAuth 세션이 만료되었습니다.', }); } + if (oauthSession.intent && oauthSession.intent !== 'register') { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '카카오 계정 복구 여부를 먼저 선택해 주세요.', + }); + } const existing = await ctx.users.findByUsername(input.username); if (existing) { throw new TRPCError({ diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 0c4faf30..0bcd70dd 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -24,6 +24,7 @@ const buildCaller = ( profileListError?: Error; kakaoId?: string; kakaoEmail?: string; + kakaoSignupAlreadyRegistered?: boolean; allowKakaoRefresh?: boolean; } = {} ) => { @@ -69,7 +70,8 @@ const buildCaller = ( accessTokenExpiresIn: 3600, }; }, - signup: async () => ({ id: '1' }), + signup: async () => + options.kakaoSignupAlreadyRegistered ? { msg: 'already registered' as const } : { id: kakaoProfile.id }, getMe: async () => ({ id: kakaoProfile.id, kakaoAccount: { @@ -472,9 +474,9 @@ describe('gateway auth flow', () => { expect(decodeURIComponent(start.authUrl)).toContain('scope=account_email,talk_message'); }); - it('rejects a new Kakao identity when its verified email is already registered', async () => { - const { caller, users, kakaoProfile } = buildCaller(); - await users.createUser({ + it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => { + const { caller, users, kakaoProfile, sentTalkMessages, flushPublisher } = buildCaller(); + const emailOwner = await users.createUser({ username: 'email-owner', password: 'owner-password', oauth: { @@ -484,12 +486,100 @@ describe('gateway auth flow', () => { info: {}, }, }); + await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000)); kakaoProfile.id = 'different-kakao-id'; const start = await caller.auth.kakaoStart({ mode: 'login' }); - await expect(caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state })).rejects.toMatchObject({ - code: 'CONFLICT', - message: expect.stringContaining('이미 다른 계정에서 사용 중인 카카오 이메일'), + const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state }); + + expect(recovery).toMatchObject({ + status: 'account_recovery', + action: 'link_existing', + email: 'tester@example.com', + }); + if (recovery.status !== 'account_recovery') throw new Error('Expected account recovery choice.'); + + const linked = await caller.auth.kakaoResolveAccount({ + oauthSessionId: recovery.oauthSessionId, + action: 'link_existing', + }); + expect(linked.status).toBe('otp'); + expect(sentTalkMessages).toHaveLength(1); + expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull(); + expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({ + id: emailOwner.id, + username: 'email-owner', + email: 'tester@example.com', + }); + expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked'); + }); + + it('asks for rejoin confirmation when Kakao is already registered but no retained email owner exists', async () => { + const { caller, users, sealPassword } = buildCaller({ kakaoSignupAlreadyRegistered: true }); + + const start = await caller.auth.kakaoStart({ mode: 'login' }); + const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state }); + expect(recovery).toMatchObject({ + status: 'account_recovery', + action: 'rejoin', + email: 'tester@example.com', + }); + if (recovery.status !== 'account_recovery') throw new Error('Expected rejoin choice.'); + + const confirmed = await caller.auth.kakaoResolveAccount({ + oauthSessionId: recovery.oauthSessionId, + action: 'rejoin', + }); + expect(confirmed).toMatchObject({ status: 'join', email: 'tester@example.com' }); + if (confirmed.status !== 'join') throw new Error('Expected registration session.'); + + const registered = await caller.auth.register({ + oauthSessionId: confirmed.oauthSessionId, + username: 'rejoined-user', + credential: sealPassword('rejoined-password'), + displayName: '재가입사용자', + termsAgreed: true, + privacyAgreed: true, + thirdPartyUse: false, + }); + expect(registered.status).toBe('otp'); + expect(await users.findByUsername('rejoined-user')).toMatchObject({ + oauthType: 'KAKAO', + oauthId: '1', + email: 'tester@example.com', + }); + }); + + it('does not let the registration mutation bypass the recovery confirmation', async () => { + const { caller, users, sealPassword } = buildCaller(); + await users.createUser({ + username: 'retained-owner', + password: 'owner-password', + oauth: { + type: 'KAKAO', + id: 'former-kakao-id', + email: 'tester@example.com', + info: {}, + }, + }); + + const start = await caller.auth.kakaoStart({ mode: 'login' }); + const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state }); + if (recovery.status !== 'account_recovery') throw new Error('Expected account recovery choice.'); + + await expect( + caller.auth.register({ + oauthSessionId: recovery.oauthSessionId, + username: 'bypass-user', + credential: sealPassword('bypass-password'), + displayName: '우회사용자', + termsAgreed: true, + privacyAgreed: true, + thirdPartyUse: false, + }) + ).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + message: expect.stringContaining('복구 여부를 먼저 선택'), }); }); diff --git a/app/gateway-api/test/kakaoAccountRelink.integration.test.ts b/app/gateway-api/test/kakaoAccountRelink.integration.test.ts new file mode 100644 index 00000000..e3f4273f --- /dev/null +++ b/app/gateway-api/test/kakaoAccountRelink.integration.test.ts @@ -0,0 +1,79 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra'; + +import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js'; + +const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const userId = '3dd08c49-279e-41ad-a12b-51b914cc51c8'; + +const assertDedicatedSchema = (): void => { + const expected = process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA; + const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null; + if (!expected || !expected.endsWith('_gateway_runtime_integration') || actual !== expected) { + throw new Error('Refusing to mutate a Gateway database outside the runner-owned integration schema.'); + } +}; + +integration('Kakao account relink PostgreSQL boundary', () => { + let db: GatewayPrismaClient; + let closeDb: (() => Promise) | undefined; + let initialized = false; + + beforeAll(async () => { + assertDedicatedSchema(); + const connector = createGatewayPostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + initialized = true; + closeDb = () => connector.disconnect(); + await db.appUser.deleteMany({ where: { id: userId } }); + await db.appUser.create({ + data: { + id: userId, + loginId: 'kakao-relink-integration', + displayName: '카카오 재연결 통합', + passwordHash: 'not-used', + passwordSalt: 'not-used', + roles: ['user'], + sanctions: {}, + oauthType: 'KAKAO', + oauthId: 'former-kakao-id', + email: 'retained@example.test', + kakaoVerifiedAt: new Date('2026-08-01T00:00:00.000Z'), + kakaoTalkVerifiedUntil: new Date('2026-08-20T00:00:00.000Z'), + }, + }); + }); + + afterAll(async () => { + if (initialized) { + await db.appUser.deleteMany({ where: { id: userId } }); + } + await closeDb?.(); + }); + + it('relinks the new provider identity while preserving email ownership and resetting the old talk proof', async () => { + const users = createPostgresUserRepository(db); + const linked = await users.relinkKakaoByEmail(userId, { + oauthId: 'replacement-kakao-id', + email: 'retained@example.test', + oauthInfo: { + accessToken: 'replacement-access-token', + accessTokenValidUntil: '2026-08-08T12:00:00.000Z', + }, + verifiedAt: new Date('2026-08-08T10:00:00.000Z'), + }); + + expect(linked).toMatchObject({ + id: userId, + oauthType: 'KAKAO', + oauthId: 'replacement-kakao-id', + email: 'retained@example.test', + kakaoTalkVerifiedUntil: undefined, + }); + await expect(users.findByOauthId('KAKAO', 'former-kakao-id')).resolves.toBeNull(); + await expect(users.findByOauthId('KAKAO', 'replacement-kakao-id')).resolves.toMatchObject({ id: userId }); + }); +}); diff --git a/app/gateway-api/test/oauthSessionStore.test.ts b/app/gateway-api/test/oauthSessionStore.test.ts index de3c41b2..681935de 100644 --- a/app/gateway-api/test/oauthSessionStore.test.ts +++ b/app/gateway-api/test/oauthSessionStore.test.ts @@ -7,12 +7,13 @@ import { RedisOAuthSessionStore } from '../src/auth/oauthSessionStore.js'; const redisUrl = process.env.GATEWAY_OAUTH_REDIS_TEST_URL; -describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () => { +describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => { const prefix = `gateway-oauth-test:${randomUUID()}`; const client = createClient({ url: redisUrl }); const store = new RedisOAuthSessionStore(client, prefix, 300); const userIds = new Set(); const challengeIds = new Set(); + const sessionIds = new Set(); beforeAll(async () => { await client.connect(); @@ -22,6 +23,7 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () => const keys = [ ...[...challengeIds].map((id) => `${prefix}:kakao-login-challenge:${id}`), ...[...userIds].map((id) => `${prefix}:kakao-login-challenge-user:${id}`), + ...[...sessionIds].map((id) => `${prefix}:oauth-session:${id}`), ]; if (keys.length > 0) { await client.del(keys); @@ -43,6 +45,31 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () => return challenge; }; + it('preserves and consumes a retained-email recovery target once', async () => { + const targetUserId = randomUUID(); + const session = await store.createSession({ + mode: 'login', + intent: 'link_existing', + targetUserId, + kakaoId: 'replacement-kakao-id', + email: 'retained@example.test', + accessToken: 'access-token', + refreshToken: 'refresh-token', + accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(), + refreshTokenValidUntil: new Date(Date.now() + 86_400_000).toISOString(), + createdAt: new Date().toISOString(), + }); + sessionIds.add(session.id); + + await expect(store.consumeSession(session.id)).resolves.toMatchObject({ + id: session.id, + intent: 'link_existing', + targetUserId, + email: 'retained@example.test', + }); + await expect(store.consumeSession(session.id)).resolves.toBeNull(); + }); + it('atomically consumes a successful code once', async () => { const challenge = await createChallenge(); diff --git a/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts b/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts new file mode 100644 index 00000000..e2b8ffb2 --- /dev/null +++ b/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts @@ -0,0 +1,111 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const installFixture = async (page: Page, action: 'link_existing' | 'rejoin') => { + const calls: string[] = []; + await page.route('**/gateway/api/trpc/**', async (route) => { + const operations = operationNames(route); + const results = operations.map((operation) => { + calls.push(operation); + if (operation === 'me') return response(null); + if (operation === 'lobby.notice') return response(''); + if (operation === 'lobby.profiles') return response([]); + if (operation === 'auth.kakaoExchange') { + return response({ + status: 'account_recovery', + action, + oauthSessionId: `${action}-session`, + email: 'retained@example.test', + }); + } + if (operation === 'auth.kakaoResolveAccount') { + return action === 'link_existing' + ? response({ + status: 'otp', + successStatus: 'login', + challengeId: '11111111-1111-4111-8111-111111111111', + expiresAt: '2026-08-08T12:03:00.000Z', + attemptsRemaining: 3, + }) + : response({ + status: 'join', + oauthSessionId: 'confirmed-registration-session', + email: 'retained@example.test', + }); + } + throw new Error(`Unhandled Kakao account recovery fixture operation: ${operation}`); + }); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); + return calls; +}; + +const verifyRecoveryChoice = async (page: Page, action: 'link_existing' | 'rejoin') => { + const group = page.getByRole('group', { name: '카카오 계정 연결 확인' }); + const confirm = group.getByRole('button', { + name: action === 'link_existing' ? '기존 계정에 연결' : '재가입', + }); + await expect(group).toBeVisible(); + await expect(group).toContainText('retained@example.test'); + await expect(group).toContainText( + action === 'link_existing' ? '이 계정에 카카오 로그인을 연결해드릴까요?' : '새 계정으로 재가입하시겠습니까?' + ); + const geometry = await group.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + width: rect.width, + height: rect.height, + backgroundColor: style.backgroundColor, + fontSize: style.fontSize, + }; + }); + expect(geometry.width).toBeGreaterThan(300); + expect(geometry.backgroundColor).toBe('rgba(0, 0, 0, 0)'); + + await confirm.hover(); + await page.waitForTimeout(200); + expect(await confirm.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(47, 77, 108)'); + await confirm.focus(); + expect(await confirm.evaluate((element) => getComputedStyle(element).outlineStyle)).toBe('solid'); + return { group, confirm, geometry }; +}; + +for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 390, height: 844 }, +] as const) { + test(`links the retained email account only after confirmation on ${viewport.name}`, async ({ page }) => { + const calls = await installFixture(page, 'link_existing'); + await page.setViewportSize(viewport); + await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state'); + const { confirm, geometry } = await verifyRecoveryChoice(page, 'link_existing'); + + await confirm.click(); + await expect(page.getByRole('dialog', { name: '인증 코드 필요' })).toBeVisible(); + expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1); + expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372); + }); + + test(`continues an orphaned Kakao connection as a new registration on ${viewport.name}`, async ({ page }) => { + const calls = await installFixture(page, 'rejoin'); + await page.setViewportSize(viewport); + await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state'); + const { confirm, geometry } = await verifyRecoveryChoice(page, 'rejoin'); + + await confirm.click(); + await expect(page.getByRole('heading', { name: '회원가입' })).toBeVisible(); + await expect(page.getByLabel('카카오 이메일')).toHaveValue('retained@example.test'); + expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1); + expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372); + }); +} diff --git a/app/gateway-frontend/e2e/playwright.config.mjs b/app/gateway-frontend/e2e/playwright.config.mjs index f242191d..7d73c694 100644 --- a/app/gateway-frontend/e2e/playwright.config.mjs +++ b/app/gateway-frontend/e2e/playwright.config.mjs @@ -17,6 +17,7 @@ export default defineConfig({ 'legacy-log-html.spec.ts', 'gateway-notice-html.spec.ts', 'kakao-otp.spec.ts', + 'kakao-account-recovery.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/app/gateway-frontend/src/views/OAuthCallbackView.vue b/app/gateway-frontend/src/views/OAuthCallbackView.vue index 7316524a..e925c558 100644 --- a/app/gateway-frontend/src/views/OAuthCallbackView.vue +++ b/app/gateway-frontend/src/views/OAuthCallbackView.vue @@ -22,6 +22,11 @@ const displayName = ref(''); const termsAgreed = ref(false); const privacyAgreed = ref(false); const thirdPartyUse = ref(false); +const accountRecovery = ref<{ + action: 'link_existing' | 'rejoin'; + oauthSessionId: string; + email: string; +} | null>(null); const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null); const otpSuccessStatus = ref<'login' | 'verified'>('login'); const appBase = import.meta.env.BASE_URL; @@ -55,6 +60,11 @@ const completeExchange = async (): Promise => { infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.'; return; } + if (result.status === 'account_recovery') { + accountRecovery.value = result; + email.value = result.email; + return; + } oauthSessionId.value = result.oauthSessionId; email.value = result.email; } catch (error) { @@ -64,6 +74,35 @@ const completeExchange = async (): Promise => { } }; +const resolveAccount = async (): Promise => { + if (!accountRecovery.value) return; + errorMessage.value = ''; + submitting.value = true; + try { + const result = await trpc.auth.kakaoResolveAccount.mutate({ + oauthSessionId: accountRecovery.value.oauthSessionId, + action: accountRecovery.value.action, + }); + accountRecovery.value = null; + if (result.status === 'otp') { + otpChallenge.value = result; + otpSuccessStatus.value = result.successStatus; + return; + } + if (result.status === 'login') { + window.localStorage.setItem('sammo-session-token', result.sessionToken); + await router.replace('/lobby'); + return; + } + oauthSessionId.value = result.oauthSessionId; + email.value = result.email; + } catch (error) { + errorMessage.value = error instanceof Error ? error.message : '카카오 계정 연결을 완료하지 못했습니다.'; + } finally { + submitting.value = false; + } +}; + const register = async (): Promise => { errorMessage.value = ''; if (password.value !== confirmPassword.value) { @@ -117,9 +156,31 @@ onMounted(() => {

삼국지 모의전투 HiDCHe

-

회원가입

+

{{ accountRecovery ? '카카오 계정 연결 확인' : '회원가입' }}

카카오 인증을 확인하는 중...

{{ infoMessage }}

+
+

+ {{ accountRecovery.email }} 이메일로 보존된 기존 계정이 있습니다. 이 계정에 + 카카오 로그인을 연결해드릴까요? +

+

+ {{ accountRecovery.email }} 이메일은 카카오에 이미 가입된 연결이 있지만 이 + 서비스에서 연결할 계정을 찾지 못했습니다. 새 계정으로 재가입하시겠습니까? +

+
+ + 취소 +
+
@@ -224,6 +285,33 @@ onMounted(() => { padding: 18px; } +.recovery-panel { + padding: 18px; + text-align: center; +} + +.recovery-panel p { + margin: 0; + line-height: 1.6; +} + +.recovery-panel strong { + color: #ffd180; +} + +.recovery-actions { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + margin-top: 18px; +} + +.recovery-actions .register-button, +.recovery-actions .back-link { + margin: 0; +} + .form-row, .agreement-row { display: grid; diff --git a/docs/architecture/runtime.md b/docs/architecture/runtime.md index 30ddcd36..47228b6c 100644 --- a/docs/architecture/runtime.md +++ b/docs/architecture/runtime.md @@ -34,10 +34,22 @@ Gateway API는 다음 저장 경계를 사용합니다. Kakao 로그인은 URL 이름만으로 사용자를 연결하지 않습니다. `account_email`과 `talk_message` scope를 항상 요청하고 callback의 `/v2/user/me` 응답에서 고유 ID, -이메일 보유·유효·인증 상태를 확인합니다. 고유 ID가 다른 계정의 같은 이메일로 -접근하는 경우와 변경 이메일이 이미 다른 `AppUser`에 속한 경우는 `CONFLICT`로 -끝나며 session을 만들지 않습니다. 기존 Kakao 계정이면 stable OAuth ID로 -사용자를 찾은 뒤 이메일과 갱신된 token metadata를 함께 저장합니다. +이메일 보유·유효·인증 상태를 확인합니다. 기존 Kakao 계정이면 stable OAuth ID로 +사용자를 찾은 뒤 이메일과 갱신된 token metadata를 함께 저장합니다. stable ID가 +로컬에 없지만 같은 `AppUser.email` 소유자가 있으면 일회용 OAuth session에 대상 +사용자 ID를 묶고 `account_recovery/link_existing` 선택을 반환합니다. 사용자가 +확인한 경우에만 그 행의 Kakao ID와 token metadata를 교체하며, 과거 +`kakaoTalkVerifiedUntil`은 무효화하여 새 KakaoTalk OTP를 통과하기 전에는 login +session을 발급하지 않습니다. 확인과 저장 사이에 email 또는 OAuth 소유자가 +바뀌면 다시 시작하도록 거부합니다. + +Kakao `/v1/user/signup`이 `already registered`를 반환했는데 stable ID와 email +소유자가 모두 없으면 `account_recovery/rejoin`을 반환합니다. 사용자가 재가입을 +확인해야 별도의 `register` intent session을 발급하므로 기존 가입 mutation으로 +확인 단계를 우회할 수 없습니다. 반대로 provider 연결이 이번 요청에서 새로 +생성됐고 로컬 email 소유자도 없으면 바로 신규 가입 form으로 진행합니다. 이미 +로컬 stable ID가 있는 상태에서 현재 이메일이 다른 `AppUser`에 속하면 identity를 +이메일 계정으로 옮기지 않고 기존처럼 `CONFLICT`로 끝냅니다. 일반 비밀번호 로그인도 `oauth_type=KAKAO`이면 저장 access token을 사용하고, 필요하면 아직 유효한 refresh token으로 갱신한 뒤 `/v2/user/me`를 호출합니다.