feat(gateway): recover orphaned Kakao account links

This commit is contained in:
2026-08-08 10:01:23 +00:00
parent 4374c490ac
commit 5c0aac5561
13 changed files with 634 additions and 27 deletions
+7 -5
View File
@@ -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 입력을
@@ -161,24 +161,58 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
throw new Error('User not found.');
},
async linkKakao(userId, input): Promise<UserRecord> {
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<UserRecord> {
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<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
@@ -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;
@@ -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<UserRecord> {
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<void> {
await prisma.appUser.update({
where: { id: userId },
@@ -122,6 +122,15 @@ export interface UserRepository {
verifiedAt: Date;
}
): Promise<UserRecord>;
relinkKakaoByEmail(
userId: string,
input: {
oauthId: string;
email: string;
oauthInfo: UserOAuthInfo;
verifiedAt: Date;
}
): Promise<UserRecord>;
updateRoles(userId: string, roles: string[]): Promise<void>;
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void>;
+137 -7
View File
@@ -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({
+97 -7
View File
@@ -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('복구 여부를 먼저 선택'),
});
});
@@ -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<void>) | 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 });
});
});
+28 -1
View File
@@ -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<string>();
const challengeIds = new Set<string>();
const sessionIds = new Set<string>();
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();
@@ -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);
});
}
@@ -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,
@@ -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<void> => {
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<void> => {
}
};
const resolveAccount = async (): Promise<void> => {
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<void> => {
errorMessage.value = '';
if (password.value !== confirmPassword.value) {
@@ -117,9 +156,31 @@ onMounted(() => {
<main id="oauth-container">
<h1>삼국지 모의전투 HiDCHe</h1>
<section class="oauth-card">
<h2>회원가입</h2>
<h2>{{ accountRecovery ? '카카오 계정 연결 확인' : '회원가입' }}</h2>
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
<p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</p>
<div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인">
<p v-if="accountRecovery.action === 'link_existing'">
<strong>{{ accountRecovery.email }}</strong> 이메일로 보존된 기존 계정이 있습니다. 계정에
카카오 로그인을 연결해드릴까요?
</p>
<p v-else>
<strong>{{ accountRecovery.email }}</strong> 이메일은 카카오에 이미 가입된 연결이 있지만
서비스에서 연결할 계정을 찾지 못했습니다. 계정으로 재가입하시겠습니까?
</p>
<div class="recovery-actions">
<button class="register-button" type="button" :disabled="submitting" @click="resolveAccount">
{{
submitting
? '처리 중...'
: accountRecovery.action === 'link_existing'
? '기존 계정에 연결'
: '재가입'
}}
</button>
<RouterLink class="back-link" to="/">취소</RouterLink>
</div>
</div>
<form v-else-if="oauthSessionId" @submit.prevent="register">
<div class="form-row">
<label for="oauth-email">카카오 이메일</label>
@@ -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;
+16 -4
View File
@@ -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`를 호출합니다.