feat: 레거시 재이관과 계정 복구 기반을 추가

중앙 이전 기록 스키마와 버전 정규화기를 도입하고, 재실행 시 현행 계정 상태를 보존한다. 카카오 인증 뒤 이관 비밀번호를 1회 설정하는 흐름과 비카카오 계정용 안전한 CLI 복구 경로를 추가한다.
This commit is contained in:
2026-08-17 15:55:32 +00:00
parent 50e7d894e4
commit fc7de05017
33 changed files with 1720 additions and 150 deletions
@@ -133,6 +133,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
kakaoGraceStartedAt: now.toISOString(),
passwordSalt: password.salt,
passwordHash: password.hash,
passwordResetRequired: false,
createdAt: now.toISOString(),
};
usersByName.set(input.username, user);
@@ -150,6 +151,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
const upgraded = await hasher.hash(password);
user.passwordSalt = upgraded.salt;
user.passwordHash = upgraded.hash;
user.passwordResetRequired = false;
}
return verified.ok;
},
@@ -159,6 +161,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
const next = await hasher.hash(password);
user.passwordSalt = next.salt;
user.passwordHash = next.hash;
user.passwordResetRequired = false;
return;
}
}
+11 -6
View File
@@ -35,7 +35,9 @@ export const hasActiveSpecialAccountGrant = (
grants: readonly SpecialAccountAccessGrantRecord[],
now: Date = new Date()
): boolean =>
grants.some((grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime()));
grants.some(
(grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime())
);
const appliesToProfile = (grant: SpecialAccountAccessGrantRecord, profile: string, profileName: string): boolean =>
grant.profiles.length === 0 || grant.profiles.includes(profile) || grant.profiles.includes(profileName);
@@ -67,9 +69,7 @@ const resolveSpecialAccess = (options: {
const selected = active.find((grant) => grant.allowsGeneralCreation) ?? active[0]!;
const expiresAt = active.some((grant) => !grant.expiresAt)
? null
: active
.map((grant) => grant.expiresAt!)
.sort((left, right) => right.localeCompare(left))[0] ?? null;
: (active.map((grant) => grant.expiresAt!).sort((left, right) => right.localeCompare(left))[0] ?? null);
return {
kind: selected.kind,
grantId: selected.id,
@@ -98,7 +98,10 @@ export const resolveLocalAccountProfilePolicy = (options: {
'localAccountGeneralCreationGraceDays',
generalCreationDefault
);
const kakaoVerified = options.user.oauthType === 'KAKAO' && Boolean(options.user.kakaoVerifiedAt);
const kakaoVerified =
options.user.oauthType === 'KAKAO' &&
Boolean(options.user.oauthId?.trim()) &&
Boolean(options.user.kakaoVerifiedAt);
const graceStartedAt = new Date(options.user.kakaoGraceStartedAt);
const now = options.now ?? new Date();
const specialAccess = resolveSpecialAccess({
@@ -116,7 +119,9 @@ export const resolveLocalAccountProfilePolicy = (options: {
const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS);
const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt;
const canCreateGeneral =
kakaoVerified || specialAccess?.allowsGeneralCreation === true || (accessAllowed && now < generalCreationEndsAt);
kakaoVerified ||
specialAccess?.allowsGeneralCreation === true ||
(accessAllowed && now < generalCreationEndsAt);
return {
requiresKakaoVerification: !kakaoVerified && specialAccess === null,
+11 -4
View File
@@ -14,7 +14,7 @@ export interface OAuthPendingState {
export interface OAuthSession {
id: string;
mode: OAuthMode;
intent?: 'register' | 'link_existing' | 'rejoin';
intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup';
targetUserId?: string;
kakaoId: string;
email: string;
@@ -93,6 +93,14 @@ end
return cjson.encode({ status = 'verified', userId = challenge.userId })
`;
const consumeOnceScript = `
local raw = redis.call('GET', KEYS[1])
if raw then
redis.call('DEL', KEYS[1])
end
return raw
`;
export class RedisOAuthSessionStore implements OAuthSessionStore {
private readonly client: RedisClientLike;
private readonly prefix: string;
@@ -161,12 +169,11 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
async consumeSession(sessionId: string): Promise<OAuthSession | null> {
const key = this.sessionKey(sessionId);
const raw = await this.client.get(key);
const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] });
if (!raw) {
return null;
}
await this.client.del(key);
return parseJson<OAuthSession>(raw);
return typeof raw === 'string' ? parseJson<OAuthSession>(raw) : null;
}
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
@@ -59,6 +59,7 @@ const mapUser = (row: {
displayName: string;
passwordHash: string;
passwordSalt: string;
passwordResetRequired: boolean;
roles: GatewayPrisma.JsonValue;
sanctions: GatewayPrisma.JsonValue;
oauthType: 'NONE' | 'KAKAO';
@@ -107,6 +108,7 @@ const mapUser = (row: {
deleteAfter: row.deleteAfter?.toISOString(),
passwordHash: row.passwordHash,
passwordSalt: row.passwordSalt,
passwordResetRequired: row.passwordResetRequired,
createdAt: row.createdAt.toISOString(),
legacyMemberNo: readLegacyMemberNo(row.legacyData),
legacyGrade: readLegacyGrade(row.legacyData),
@@ -250,6 +252,7 @@ export const createPostgresUserRepository = (
displayName: input.displayName ?? input.username,
passwordHash: password.hash,
passwordSalt: password.salt,
passwordResetRequired: false,
roles: ['user'] satisfies GatewayPrisma.JsonArray,
sanctions: {} satisfies GatewayPrisma.JsonObject,
oauthType,
@@ -274,10 +277,12 @@ export const createPostgresUserRepository = (
data: {
passwordHash: upgraded.hash,
passwordSalt: upgraded.salt,
passwordResetRequired: false,
},
});
user.passwordHash = upgraded.hash;
user.passwordSalt = upgraded.salt;
user.passwordResetRequired = false;
}
return verified.ok;
},
@@ -288,6 +293,7 @@ export const createPostgresUserRepository = (
data: {
passwordHash: next.hash,
passwordSalt: next.salt,
passwordResetRequired: false,
},
});
},
+2 -1
View File
@@ -24,6 +24,7 @@ export interface UserRecord {
deleteAfter?: string;
passwordHash: string;
passwordSalt: string;
passwordResetRequired: boolean;
createdAt: string;
legacyMemberNo?: number;
legacyGrade?: number;
@@ -128,7 +129,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({
displayName: user.displayName,
roles: user.roles,
picture: user.picture,
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt),
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.oauthId?.trim()) && Boolean(user.kakaoVerifiedAt),
kakaoGraceStartedAt: user.kakaoGraceStartedAt,
createdAt: user.createdAt,
});
+102 -3
View File
@@ -91,6 +91,42 @@ const finishKakaoLogin = async <T extends 'login' | 'verified'>(
};
};
const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verified'>(
ctx: GatewayApiContext,
user: UserRecord,
accessToken: string,
successStatus: T
) => {
if (!user.passwordResetRequired) {
return finishKakaoLogin(ctx, user, accessToken, successStatus);
}
if (user.oauthType !== 'KAKAO' || !user.oauthId || !user.email) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '카카오 계정 연결 정보가 올바르지 않아 비밀번호를 설정할 수 없습니다.',
});
}
const oauthInfo = user.oauthInfo ?? {};
const passwordSetup = await ctx.oauthSessions.createSession({
mode: successStatus === 'verified' ? 'verify' : 'login',
intent: 'password_setup',
targetUserId: user.id,
kakaoId: user.oauthId,
email: user.email,
accessToken,
refreshToken: oauthInfo.refreshToken,
accessTokenValidUntil: oauthInfo.accessTokenValidUntil ?? new Date().toISOString(),
refreshTokenValidUntil: oauthInfo.refreshTokenValidUntil,
createdAt: new Date().toISOString(),
});
return {
status: 'password_setup' as const,
oauthSessionId: passwordSetup.id,
email: passwordSetup.email,
successStatus,
};
};
export const appRouter = router({
health: router({
ping: procedure.query(() => ({
@@ -348,7 +384,7 @@ export const appRouter = router({
}
const refreshed = (await ctx.users.findById(verified.id)) ?? verified;
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified');
return finishKakaoLogin(ctx, refreshed, token.accessToken, 'verified');
return finishKakaoLoginOrRequestPasswordSetup(ctx, refreshed, token.accessToken, 'verified');
}
if (pending.mode === 'change_pw') {
@@ -415,7 +451,7 @@ export const appRouter = router({
cause: error,
});
}
return finishKakaoLogin(ctx, synced, token.accessToken, 'login');
return finishKakaoLoginOrRequestPasswordSetup(ctx, synced, token.accessToken, 'login');
}
const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
@@ -562,7 +598,70 @@ export const appRouter = router({
});
}
await ctx.flushPublisher.publishUserFlush(linked.id, 'kakao-account-relinked');
return finishKakaoLogin(ctx, linked, oauthSession.accessToken, 'login');
return finishKakaoLoginOrRequestPasswordSetup(ctx, linked, oauthSession.accessToken, 'login');
}),
kakaoSetPassword: procedure
.input(
z.object({
oauthSessionId: z.string().uuid(),
credential: zPasswordEnvelope,
})
)
.mutation(async ({ ctx, input }) => {
const password = openPassword(ctx.passwordEnvelope, input.credential);
const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId);
if (!oauthSession || oauthSession.intent !== 'password_setup' || !oauthSession.targetUserId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: '비밀번호 설정 세션이 만료되었습니다. 카카오 로그인을 다시 진행해 주세요.',
});
}
const user = await ctx.users.findById(oauthSession.targetUserId);
if (
!user ||
user.oauthType !== 'KAKAO' ||
user.oauthId !== oauthSession.kakaoId ||
user.email?.toLowerCase() !== oauthSession.email.toLowerCase() ||
!user.passwordResetRequired
) {
throw new TRPCError({
code: 'CONFLICT',
message: '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.',
});
}
if (user.deleteAfter) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Account deletion is pending.' });
}
if (isLoginBanned(user.sanctions)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Account login is blocked.' });
}
let verifiedProfile;
try {
verifiedProfile = readVerifiedKakaoProfile(await ctx.kakaoClient.getMe(oauthSession.accessToken));
} catch (error) {
return throwKakaoVerificationError(error);
}
if (
verifiedProfile.kakaoId !== oauthSession.kakaoId ||
verifiedProfile.email !== oauthSession.email.toLowerCase()
) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: '카카오 계정 정보가 비밀번호 설정 세션과 일치하지 않습니다.',
});
}
await ctx.users.updatePassword(user.id, password);
const refreshed = await ctx.users.findById(user.id);
if (!refreshed) {
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾지 못했습니다.' });
}
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'password-changed');
return finishKakaoLogin(
ctx,
refreshed,
oauthSession.accessToken,
oauthSession.mode === 'verify' ? 'verified' : 'login'
);
}),
register: procedure
.input(
+114 -2
View File
@@ -631,7 +631,7 @@ describe('gateway auth flow', () => {
});
it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => {
const { caller, users, kakaoProfile, sentTalkMessages, flushPublisher } = buildCaller();
const { caller, users, kakaoProfile, sealPassword, sentTalkMessages, flushPublisher } = buildCaller();
const emailOwner = await users.createUser({
username: 'email-owner',
password: 'owner-password',
@@ -642,6 +642,7 @@ describe('gateway auth flow', () => {
info: {},
},
});
emailOwner.passwordResetRequired = true;
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
kakaoProfile.id = 'different-kakao-id';
@@ -659,7 +660,14 @@ describe('gateway auth flow', () => {
oauthSessionId: recovery.oauthSessionId,
action: 'link_existing',
});
expect(linked.status).toBe('otp');
expect(linked.status).toBe('password_setup');
if (linked.status !== 'password_setup') throw new Error('Expected migrated password setup.');
expect(sentTalkMessages).toHaveLength(0);
const passwordSet = await caller.auth.kakaoSetPassword({
oauthSessionId: linked.oauthSessionId,
credential: sealPassword('replacement-password'),
});
expect(passwordSet.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({
@@ -668,6 +676,108 @@ describe('gateway auth flow', () => {
email: 'tester@example.com',
});
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked');
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'password-changed');
});
it('requires a one-time password setup before an imported Kakao account can receive a session', async () => {
const { caller, users, sessions, kakaoProfile, sealPassword, sentTalkMessages } = buildCaller({
kakaoId: 'imported-kakao-id',
kakaoEmail: 'imported@example.com',
});
const user = await users.createUser({
username: 'imported-kakao-user',
password: 'legacy-password',
oauth: {
type: 'KAKAO',
id: kakaoProfile.id,
email: kakaoProfile.email,
info: {},
},
});
user.passwordResetRequired = true;
const createSession = vi.spyOn(sessions, 'createSession');
const start = await caller.auth.kakaoStart({ mode: 'login' });
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
expect(login).toMatchObject({
status: 'password_setup',
email: 'imported@example.com',
successStatus: 'login',
});
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
expect(login).not.toHaveProperty('sessionToken');
expect(createSession).not.toHaveBeenCalled();
expect(sentTalkMessages).toHaveLength(0);
const setup = await caller.auth.kakaoSetPassword({
oauthSessionId: login.oauthSessionId,
credential: sealPassword('new-imported-password'),
});
expect(setup.status).toBe('otp');
expect((await users.findById(user.id))?.passwordResetRequired).toBe(false);
expect(await users.verifyPassword(user, 'new-imported-password')).toBe(true);
await expect(
caller.auth.kakaoSetPassword({
oauthSessionId: login.oauthSessionId,
credential: sealPassword('another-password'),
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
it('rechecks sanctions before consuming a migrated password setup', async () => {
const { caller, users, kakaoProfile, sealPassword } = buildCaller({
kakaoId: 'sanctioned-setup-id',
kakaoEmail: 'sanctioned-setup@example.com',
});
const user = await users.createUser({
username: 'sanctioned-setup-user',
password: 'legacy-password',
oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} },
});
user.passwordResetRequired = true;
const start = await caller.auth.kakaoStart({ mode: 'login' });
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
await users.updateSanctions(user.id, { bannedUntil: '2099-01-01T00:00:00.000Z' });
await expect(
caller.auth.kakaoSetPassword({
oauthSessionId: login.oauthSessionId,
credential: sealPassword('blocked-password'),
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect((await users.findById(user.id))?.passwordResetRequired).toBe(true);
});
it('consumes password setup when the provider identity changes before submission', async () => {
const { caller, users, kakaoProfile, sealPassword } = buildCaller({
kakaoId: 'setup-target-id',
kakaoEmail: 'setup-target@example.com',
});
const user = await users.createUser({
username: 'setup-target-user',
password: 'legacy-password',
oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} },
});
user.passwordResetRequired = true;
const start = await caller.auth.kakaoStart({ mode: 'login' });
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
kakaoProfile.id = 'changed-provider-id';
await expect(
caller.auth.kakaoSetPassword({
oauthSessionId: login.oauthSessionId,
credential: sealPassword('new-target-password'),
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
expect((await users.findById(user.id))?.passwordResetRequired).toBe(true);
await expect(
caller.auth.kakaoSetPassword({
oauthSessionId: login.oauthSessionId,
credential: sealPassword('new-target-password'),
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
it('asks for rejoin confirmation when Kakao is already registered but no retained email owner exists', async () => {
@@ -1147,6 +1257,7 @@ describe('account self service', () => {
username: 'self-service',
password: 'current-password',
});
user.passwordResetRequired = true;
const session = await sessions.createSession(user);
await expect(
@@ -1165,6 +1276,7 @@ describe('account self service', () => {
const refreshed = await users.findById(user.id);
expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true);
expect(refreshed?.passwordResetRequired).toBe(false);
});
it('revokes the session and schedules deletion after 30 days', async () => {
@@ -16,6 +16,7 @@ const buildLocalUser = (graceStartedAt: Date): UserRecord => ({
kakaoGraceStartedAt: graceStartedAt.toISOString(),
passwordHash: 'unused',
passwordSalt: '',
passwordResetRequired: false,
createdAt: graceStartedAt.toISOString(),
});
@@ -90,6 +91,25 @@ describe('local account profile policy', () => {
});
});
it('does not trust a migrated Kakao marker without a valid provider ID', () => {
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
user.oauthType = 'KAKAO';
user.oauthId = ' ';
user.kakaoVerifiedAt = '2026-07-26T00:00:00.000Z';
const policy = resolveLocalAccountProfilePolicy({
profile: 'che',
defaultGraceDays: 0,
user,
now: new Date('2026-07-26T00:00:00.000Z'),
});
expect(policy).toMatchObject({
kakaoVerified: false,
requiresKakaoVerification: true,
accessAllowed: false,
});
});
it('extends account access with an administrator override without widening general creation grace', () => {
const user = buildLocalUser(new Date('2026-07-20T00:00:00.000Z'));
user.kakaoGraceUntil = '2026-08-20T00:00:00.000Z';
@@ -61,13 +61,15 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
});
sessionIds.add(session.id);
await expect(store.consumeSession(session.id)).resolves.toMatchObject({
await expect(client.ttl(`${prefix}:oauth-session:${session.id}`)).resolves.toBeGreaterThan(0);
const consumed = await Promise.all([store.consumeSession(session.id), store.consumeSession(session.id)]);
expect(consumed.filter((value) => value !== null)).toHaveLength(1);
expect(consumed.find((value) => value !== null)).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 () => {
@@ -34,10 +34,12 @@ describe('password credential compatibility', () => {
});
user.passwordSalt = 'core-salt';
user.passwordHash = createHash('sha256').update('core-salt:current-password').digest('hex');
user.passwordResetRequired = true;
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
expect(user.passwordSalt).toBe('');
expect(user.passwordResetRequired).toBe(false);
});
it('upgrades an imported ref double-SHA-512 credential after a successful login', async () => {
@@ -52,10 +54,12 @@ describe('password credential compatibility', () => {
const browserHash = createHash('sha512').update(`${globalSalt}current-password${globalSalt}`).digest('hex');
user.passwordSalt = userSalt;
user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex');
user.passwordResetRequired = true;
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
expect(user.passwordSalt).toBe('');
expect(user.passwordResetRequired).toBe(false);
});
it('does not accept an imported ref credential without the matching global salt', async () => {
+3 -7
View File
@@ -38,8 +38,8 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260813000000_split_gateway_profile_identity',
gameSchemaHead: '20260816000000_add_read_model_change_journal',
gatewaySchemaHead: '20260817000000_add_password_reset_required',
gameSchemaHead: '20260817001000_add_dedicated_legacy_archive',
});
});
@@ -61,11 +61,7 @@ describe('readReleaseManifest', () => {
it('allows only the explicit controller self-upgrade boundary to cross protocol versions', async () => {
const futureProtocol = RELEASE_CONTROLLER_PROTOCOL + 1;
const workspace = await createWorkspace(
'20260801000000_gateway',
'20260801000000_game',
futureProtocol
);
const workspace = await createWorkspace('20260801000000_gateway', '20260801000000_game', futureProtocol);
await expect(readReleaseManifest(workspace)).rejects.toThrow(
`Release requires controller protocol ${futureProtocol}`
@@ -1,3 +1,5 @@
import { generateKeyPairSync } from 'node:crypto';
import { expect, test, type Page, type Route } from '@playwright/test';
const response = (data: unknown) => ({ result: { data } });
@@ -6,6 +8,43 @@ const operationNames = (route: Route): string[] => {
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
const installPasswordSetupFixture = async (page: Page) => {
const calls: string[] = [];
await page.route('**/gateway/api/trpc/**', async (route) => {
const results = operationNames(route).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.passwordKey') {
return response({ keyId: 'password-setup-key', publicKeyPem, algorithm: 'RSA-OAEP-256' });
}
if (operation === 'auth.kakaoExchange') {
return response({
status: 'password_setup',
oauthSessionId: '11111111-1111-4111-8111-111111111112',
email: 'migrated@example.test',
successStatus: 'login',
});
}
if (operation === 'auth.kakaoSetPassword') {
return response({
status: 'otp',
challengeId: '11111111-1111-4111-8111-111111111111',
expiresAt: '2026-08-17T12:03:00.000Z',
attemptsRemaining: 3,
});
}
throw new Error(`Unhandled password setup fixture operation: ${operation}`);
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
return calls;
};
const installFixture = async (page: Page, action: 'link_existing' | 'rejoin') => {
const calls: string[] = [];
await page.route('**/gateway/api/trpc/**', async (route) => {
@@ -108,4 +147,27 @@ for (const viewport of [
expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1);
expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372);
});
test(`sets a migrated password before opening the OTP dialog on ${viewport.name}`, async ({ page }) => {
const calls = await installPasswordSetupFixture(page);
await page.setViewportSize(viewport);
await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state');
const form = page.getByRole('form', { name: '새 비밀번호 설정' });
await expect(form).toBeVisible();
await expect(form).toContainText('카카오 인증으로 기존 계정을 확인했습니다.');
await expect(form.getByLabel('카카오 이메일')).toHaveValue('migrated@example.test');
const geometry = await form.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { width: rect.width, right: rect.right };
});
await form.getByLabel('새 비밀번호').fill('new-password-value');
await form.getByLabel('비밀번호 확인').fill('new-password-value');
await form.getByRole('button', { name: '새 비밀번호 설정' }).click();
await expect(page.getByRole('dialog', { name: '인증 코드 필요' })).toBeVisible();
expect(calls.filter((operation) => operation === 'auth.kakaoSetPassword')).toHaveLength(1);
expect(geometry.width).toBeGreaterThan(300);
expect(geometry.right).toBeLessThanOrEqual(viewport.width);
});
}
@@ -14,6 +14,8 @@ const submitting = ref(false);
const errorMessage = ref('');
const infoMessage = ref('');
const oauthSessionId = ref('');
const passwordSetupSessionId = ref('');
const passwordSetupSuccessStatus = ref<'login' | 'verified'>('login');
const email = ref('');
const username = ref('');
const password = ref('');
@@ -60,6 +62,12 @@ const completeExchange = async (): Promise<void> => {
infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.';
return;
}
if (result.status === 'password_setup') {
passwordSetupSessionId.value = result.oauthSessionId;
passwordSetupSuccessStatus.value = result.successStatus;
email.value = result.email;
return;
}
if (result.status === 'account_recovery') {
accountRecovery.value = result;
email.value = result.email;
@@ -94,6 +102,12 @@ const resolveAccount = async (): Promise<void> => {
await router.replace('/lobby');
return;
}
if (result.status === 'password_setup') {
passwordSetupSessionId.value = result.oauthSessionId;
passwordSetupSuccessStatus.value = result.successStatus;
email.value = result.email;
return;
}
oauthSessionId.value = result.oauthSessionId;
email.value = result.email;
} catch (error) {
@@ -103,6 +117,36 @@ const resolveAccount = async (): Promise<void> => {
}
};
const setMigratedPassword = async (): Promise<void> => {
errorMessage.value = '';
if (password.value !== confirmPassword.value) {
errorMessage.value = '비밀번호 확인이 일치하지 않습니다.';
return;
}
submitting.value = true;
try {
const credential = await sealPassword(password.value);
const result = await trpc.auth.kakaoSetPassword.mutate({
oauthSessionId: passwordSetupSessionId.value,
credential,
});
password.value = '';
confirmPassword.value = '';
passwordSetupSessionId.value = '';
if (result.status === 'otp') {
otpChallenge.value = result;
otpSuccessStatus.value = passwordSetupSuccessStatus.value;
return;
}
window.localStorage.setItem('sammo-session-token', result.sessionToken);
await router.replace(result.status === 'verified' ? '/lobby?verified=1' : '/lobby');
} 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) {
@@ -156,7 +200,15 @@ onMounted(() => {
<main id="oauth-container">
<h1>삼국지 모의전투 HiDCHe</h1>
<section class="oauth-card">
<h2>{{ accountRecovery ? '카카오 계정 연결 확인' : '회원가입' }}</h2>
<h2>
{{
accountRecovery
? '카카오 계정 연결 확인'
: passwordSetupSessionId
? '새 비밀번호 설정'
: '회원가입'
}}
</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="카카오 계정 연결 확인">
@@ -181,6 +233,46 @@ onMounted(() => {
<RouterLink class="back-link" to="/">취소</RouterLink>
</div>
</div>
<form
v-else-if="passwordSetupSessionId"
class="password-setup-form"
aria-label=" 비밀번호 설정"
@submit.prevent="setMigratedPassword"
>
<p class="oauth-message">
카카오 인증으로 기존 계정을 확인했습니다. 앞으로 사용할 비밀번호를 설정해 주세요.
</p>
<div class="form-row">
<label for="migrated-password-email">카카오 이메일</label>
<input id="migrated-password-email" :value="email" readonly />
</div>
<div class="form-row">
<label for="migrated-password"> 비밀번호</label>
<input
id="migrated-password"
v-model="password"
type="password"
minlength="6"
autocomplete="new-password"
required
/>
</div>
<div class="form-row">
<label for="migrated-password-confirm">비밀번호 확인</label>
<input
id="migrated-password-confirm"
v-model="confirmPassword"
type="password"
minlength="6"
autocomplete="new-password"
required
/>
</div>
<button class="register-button" type="submit" :disabled="submitting">
{{ submitting ? '설정 중...' : '새 비밀번호 설정' }}
</button>
<RouterLink class="back-link" to="/">취소</RouterLink>
</form>
<form v-else-if="oauthSessionId" @submit.prevent="register">
<div class="form-row">
<label for="oauth-email">카카오 이메일</label>