feat: 카카오 계정의 본인 복구와 인증 후 연결 교체를 추가한다

This commit is contained in:
Hide_D committed 2026-11-07 21:31:16 +09:00
1 parent d1cdf8a85f
commit c81d304073
22 files changed
+1338 -199

No files matched your search

+7 -2
View File
@@ -58,8 +58,13 @@ Gateway 자체 릴리스는 Gateway 프로세스 밖의 `release-controller`가
Kakao 계정은 OAuth callback과 일반 비밀번호 로그인 모두에서 Kakao 고유 ID와
현재 인증 이메일을 다시 확인합니다. 로컬 고유 ID 연결이 없지만 영구 보존된
이메일 계정이 있으면 자동 로그인하지 않고 그 계정에 연결할지 묻습니다. 기존
계정 연결을 확인하면 이전 Kakao ID와 과거 talk proof를 교체하고 새
“나와의 채팅” 숫자 코드로 다시 증명한 뒤에만 Gateway session을 발급합니다.
계정 연결은 새 “나와의 채팅” 숫자 코드 인증까지 끝난 뒤에만 저장하며, 그 전에는
이전 연결과 비밀번호를 유지합니다. 일반 계정은 카카오가 인증한 동일 이메일로
관리자 승인 없이 복구하고, 기존 서비스 비밀번호로 확인하면 이메일이 달라도
새 카카오에 연결할 수 있습니다. 관리자 계정 교체에는 승인을 유지하며 다른 계정과의
연결·이메일 충돌은 자동 처리하지 않습니다. 새 교체부터 이전 Kakao ID는 원래 계정의
복구 이력으로 보존하여 같은 계정에만 다시 연결할 수 있습니다. 기존에 영구 폐기된
ID는 계속 차단합니다.
Kakao가 `already registered`를 반환했지만 보존 이메일 계정도 없으면 재가입
확인 뒤 신규 가입 form을 엽니다. 이미 로컬에 연결된 stable ID의 변경 이메일이
다른 계정에 있으면 기존처럼 충돌을 거부합니다.
@@ -103,6 +103,7 @@ const EXPENSIVE_ACTIONS = new Set([
'auth.bootstrapLocal',
'auth.kakaoExchange',
'auth.kakaoResolveAccount',
'auth.kakaoLinkWithPassword',
'auth.kakaoSetPassword',
'auth.register',
'auth.registerLocal',
@@ -1,3 +1,6 @@
import { authorizeKakaoLink, matchesKakaoLinkSnapshot } from './kakaoLinkPolicy.js';
import { assertLoginAllowed } from './deletionRecovery.js';
import type { CompleteKakaoLinkInput } from './userRepository.js';
import { randomUUID } from 'node:crypto';
import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js';
@@ -29,6 +32,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
const usersByOauthId = new Map<string, UserRecord>();
const usersByEmail = new Map<string, UserRecord>();
const retiredKakaoIds = new Set<string>();
const detachedKakaoOwners = new Map<string, string>();
const iconsById = new Map<string, UserIconRecord>();
const specialAccessGrantsById = new Map<string, SpecialAccountAccessGrantRecord>();
@@ -42,6 +46,62 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
).toISOString();
return {
async findDetachedKakaoOwner(oauthId: string): Promise<string | null> {
return detachedKakaoOwners.get(oauthId) ?? null;
},
async completeKakaoIdentityLink(userId: string, input: CompleteKakaoLinkInput): Promise<UserRecord> {
const password = input.password ? await hasher.hash(input.password) : undefined;
const user = [...usersByName.values()].find((row) => row.id === userId);
if (!user || !matchesKakaoLinkSnapshot(user, input.expected))
throw new Error('Kakao recovery account changed.');
assertLoginAllowed(user);
if (
authorizeKakaoLink(
user,
input.email,
input.authorization === 'PASSWORD',
input.firstUserId,
input.verifiedAt
) !== input.authorization
)
throw new Error('Kakao recovery authorization changed.');
const email = input.email.toLowerCase();
const owner = usersByOauthId.get(`KAKAO:${input.oauthId}`);
const emailOwner = usersByEmail.get(email);
const detached = detachedKakaoOwners.get(input.oauthId);
if (
retiredKakaoIds.has(input.oauthId) ||
(detached && detached !== userId) ||
(owner && owner.id !== userId) ||
(emailOwner && emailOwner.id !== userId)
)
throw new Error('Kakao recovery identity ownership changed.');
if (user.passwordResetRequired && !password) throw new Error('A new password is required.');
if (user.oauthId && user.oauthId !== input.oauthId) {
detachedKakaoOwners.set(user.oauthId, userId);
usersByOauthId.delete(`KAKAO:${user.oauthId}`);
}
if (user.email) usersByEmail.delete(user.email.toLowerCase());
Object.assign(user, {
oauthType: 'KAKAO',
oauthId: input.oauthId,
email,
oauthInfo: input.oauthInfo,
kakaoVerifiedAt: input.verifiedAt.toISOString(),
kakaoTalkVerifiedUntil: input.talkVerifiedUntil.toISOString(),
authRevision: (user.authRevision ?? 0) + 1,
sessionRevokedBefore: input.verifiedAt.toISOString(),
kakaoReplacementApprovedUntil: undefined,
kakaoReplacementApprovedByUserId: undefined,
kakaoReplacementReason: undefined,
...(password
? { passwordHash: password.hash, passwordSalt: password.salt, passwordResetRequired: false }
: {}),
});
usersByOauthId.set(`KAKAO:${input.oauthId}`, user);
usersByEmail.set(email, user);
return user;
},
async findById(id: string): Promise<UserRecord | null> {
for (const user of usersByName.values()) {
if (user.id === id) {
@@ -103,6 +163,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
if (
input.oauth &&
(retiredKakaoIds.has(input.oauth.id) ||
detachedKakaoOwners.has(input.oauth.id) ||
usersByOauthId.has(`${input.oauth.type}:${input.oauth.id}`) ||
usersByEmail.has(input.oauth.email.toLowerCase()))
) {
@@ -204,9 +265,19 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
}
throw new Error('User not found.');
},
async markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord> {
async markKakaoTalkVerified(
userId: string,
validUntil: Date,
authRevision?: number,
oauthId?: string
): Promise<UserRecord> {
for (const user of usersByName.values()) {
if (user.id === userId) {
if (
(authRevision !== undefined && (user.authRevision ?? 0) !== authRevision) ||
(oauthId && user.oauthId !== oauthId)
)
throw new Error('Kakao identity changed.');
user.kakaoTalkVerifiedUntil = validUntil.toISOString();
return user;
}
@@ -305,6 +376,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
!user.kakaoReplacementApprovedByUserId ||
!user.kakaoReplacementReason ||
retiredKakaoIds.has(input.oauthId) ||
(detachedKakaoOwners.has(input.oauthId) && detachedKakaoOwners.get(input.oauthId) !== userId) ||
(oauthOwner && oauthOwner.id !== userId) ||
(emailOwner && emailOwner.id !== userId)
) {
@@ -194,16 +194,17 @@ export const requireKakaoTalkProof = async (options: {
kakaoClient: KakaoOAuthClient;
oauthSessions: OAuthSessionStore;
publicBaseUrl: string;
oauthSessionId?: string;
now?: Date;
}): Promise<KakaoOtpRequired | null> => {
const now = options.now ?? new Date();
const validUntil = parseDate(options.user.kakaoTalkVerifiedUntil);
if (validUntil && isAfter(validUntil, now)) {
if (!options.oauthSessionId && validUntil && isAfter(validUntil, now)) {
return null;
}
const active = await options.oauthSessions.getLoginChallengeForUser(options.user.id);
if (active) {
if (active && active.oauthSessionId === options.oauthSessionId) {
return {
status: 'otp',
challengeId: active.id,
@@ -215,6 +216,9 @@ export const requireKakaoTalkProof = async (options: {
const expiresAt = addSeconds(now, KAKAO_OTP_TTL_SECONDS);
const challenge = await options.oauthSessions.createLoginChallenge({
userId: options.user.id,
authRevision: options.user.authRevision ?? 0,
...(options.user.oauthId ? { oauthId: options.user.oauthId } : {}),
...(options.oauthSessionId ? { oauthSessionId: options.oauthSessionId } : {}),
code: String(randomInt(1000, 10_000)),
attemptsRemaining: KAKAO_OTP_ATTEMPTS,
expiresAt: expiresAt.toISOString(),
@@ -246,7 +250,7 @@ export const verifyKakaoTalkChallenge = async (options: {
oauthSessions: OAuthSessionStore;
users: UserRepository;
now?: Date;
}): Promise<{ user: UserRecord; validUntil: string }> => {
}): Promise<{ user: UserRecord; validUntil: string; pendingLinkSessionId?: string }> => {
const now = options.now ?? new Date();
const result = await options.oauthSessions.verifyLoginChallenge(options.challengeId, options.code, now);
if (result.status === 'expired') {
@@ -268,11 +272,27 @@ export const verifyKakaoTalkChallenge = async (options: {
}
const user = await options.users.findById(result.userId);
if (!user || user.oauthType !== 'KAKAO' || !user.oauthId) {
if (!user || (!result.oauthSessionId && (user.oauthType !== 'KAKAO' || !user.oauthId))) {
throw new KakaoVerificationError('REAUTH_REQUIRED', '카카오 계정 연결 정보를 찾지 못했습니다.');
}
if (
result.authRevision === undefined ||
result.authRevision !== (user.authRevision ?? 0) ||
(result.oauthId && result.oauthId !== user.oauthId)
)
throw new KakaoVerificationError('REAUTH_REQUIRED', '계정 인증 정보가 변경되었습니다. 다시 로그인해 주세요.');
const proofValidUntil = addDays(now, KAKAO_TALK_VERIFICATION_DAYS);
const verified = await options.users.markKakaoTalkVerified(user.id, proofValidUntil);
if (result.oauthSessionId)
return { user, validUntil: proofValidUntil.toISOString(), pendingLinkSessionId: result.oauthSessionId };
const verified = await options.users
.markKakaoTalkVerified(user.id, proofValidUntil, result.authRevision, result.oauthId)
.catch((error: unknown) => {
throw new KakaoVerificationError(
'REAUTH_REQUIRED',
'계정 인증 정보가 변경되었습니다. 다시 로그인해 주세요.',
{ cause: error }
);
});
return { user: verified, validUntil: proofValidUntil.toISOString() };
};
@@ -0,0 +1,62 @@
import { createHash } from 'node:crypto';
import { TRPCError } from '@trpc/server';
import type { UserRecord } from './userRepository.js';
export type KakaoLinkAuthorization = 'EMAIL' | 'PASSWORD' | 'ADMIN';
export interface KakaoLinkSnapshot {
authRevision: number;
oauthType: UserRecord['oauthType'];
oauthId?: string;
email?: string;
passwordFingerprint: string;
}
export const snapshotKakaoLink = (user: UserRecord): KakaoLinkSnapshot => ({
authRevision: user.authRevision ?? 0,
oauthType: user.oauthType,
oauthId: user.oauthId,
email: user.email,
passwordFingerprint: createHash('sha256').update(user.passwordHash).digest('hex'),
});
export const matchesKakaoLinkSnapshot = (user: UserRecord, expected: KakaoLinkSnapshot): boolean =>
(user.authRevision ?? 0) === expected.authRevision &&
user.oauthType === expected.oauthType &&
user.oauthId === expected.oauthId &&
user.email === expected.email &&
createHash('sha256').update(user.passwordHash).digest('hex') === expected.passwordFingerprint;
export const authorizeKakaoLink = (
user: UserRecord,
email: string,
passwordVerified: boolean,
firstUserId: string | undefined,
now = new Date()
): KakaoLinkAuthorization => {
const privileged =
user.id === firstUserId ||
user.roles.some((role) => role === 'superuser' || role === 'admin' || role.startsWith('admin.'));
const approved = Boolean(
user.kakaoReplacementApprovedByUserId &&
user.kakaoReplacementReason &&
user.kakaoReplacementApprovedUntil &&
new Date(user.kakaoReplacementApprovedUntil) > now
);
if (privileged && user.oauthType === 'KAKAO' && user.oauthId) {
if (approved) return 'ADMIN';
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '관리자 계정의 카카오 교체에는 관리자 승인이 필요합니다.',
});
}
if (passwordVerified) return 'PASSWORD';
if (privileged)
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '관리자 계정의 최초 카카오 연결은 기존 비밀번호를 확인해야 합니다.',
});
if (!privileged && user.email?.toLowerCase() === email.toLowerCase()) return 'EMAIL';
if (approved) return 'ADMIN';
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '이메일이 다른 계정은 기존 비밀번호로 확인하거나 관리자에게 복구를 요청해 주세요.',
});
};
+30 -4
View File
@@ -1,5 +1,7 @@
import { randomUUID } from 'node:crypto';
import { parseJson } from '@sammo-ts/common';
import type { KakaoLinkAuthorization, KakaoLinkSnapshot } from './kakaoLinkPolicy.js';
import type { PasswordEnvelopeInput } from './passwordEnvelope.js';
export type OAuthMode = 'login' | 'change_pw' | 'verify';
@@ -22,7 +24,12 @@ export interface OAuthPendingState {
export interface OAuthSession {
id: string;
mode: OAuthMode;
intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup';
intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup' | 'identity_link';
identityLink?: {
expected: KakaoLinkSnapshot;
authorization: KakaoLinkAuthorization;
credential?: PasswordEnvelopeInput;
};
targetUserId?: string;
kakaoId: string;
email: string;
@@ -34,6 +41,9 @@ export interface OAuthSession {
}
export interface KakaoLoginChallenge {
authRevision?: number;
oauthId?: string;
oauthSessionId?: string;
id: string;
userId: string;
code: string;
@@ -43,7 +53,7 @@ export interface KakaoLoginChallenge {
}
export type KakaoLoginChallengeResult =
| { status: 'verified'; userId: string }
| { status: 'verified'; userId: string; oauthSessionId?: string; authRevision?: number; oauthId?: string }
| { status: 'mismatch'; attemptsRemaining: number; expiresAt: string }
| { status: 'locked'; expiresAt: string }
| { status: 'expired' };
@@ -55,6 +65,7 @@ export interface OAuthSessionStore {
consumePendingState(state: string): Promise<OAuthPendingState | null>;
createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession>;
consumeSession(sessionId: string): Promise<OAuthSession | null>;
getSession(sessionId: string): Promise<OAuthSession | null>;
getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null>;
createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge>;
verifyLoginChallenge(challengeId: string, code: string, now?: Date): Promise<KakaoLoginChallengeResult>;
@@ -100,7 +111,7 @@ local userKey = ARGV[2] .. challenge.userId
if redis.call('GET', userKey) == challenge.id then
redis.call('DEL', userKey)
end
return cjson.encode({ status = 'verified', userId = challenge.userId })
return cjson.encode({ status = 'verified', userId = challenge.userId, oauthSessionId = challenge.oauthSessionId, authRevision = challenge.authRevision, oauthId = challenge.oauthId })
`;
const consumeOnceScript = `
@@ -188,6 +199,11 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
return stored;
}
async getSession(sessionId: string): Promise<OAuthSession | null> {
const raw = await this.client.get(this.sessionKey(sessionId));
return raw ? parseJson<OAuthSession>(raw) : null;
}
async consumeSession(sessionId: string): Promise<OAuthSession | null> {
const key = this.sessionKey(sessionId);
const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] });
@@ -296,6 +312,10 @@ export class InMemoryOAuthSessionStore implements OAuthSessionStore {
return stored;
}
async getSession(sessionId: string): Promise<OAuthSession | null> {
return this.sessions.get(sessionId) ?? null;
}
async consumeSession(sessionId: string): Promise<OAuthSession | null> {
const session = this.sessions.get(sessionId) ?? null;
if (session) {
@@ -357,6 +377,12 @@ export class InMemoryOAuthSessionStore implements OAuthSessionStore {
if (this.userLoginChallenges.get(challenge.userId) === challengeId) {
this.userLoginChallenges.delete(challenge.userId);
}
return { status: 'verified', userId: challenge.userId };
return {
status: 'verified',
userId: challenge.userId,
...(challenge.authRevision !== undefined ? { authRevision: challenge.authRevision } : {}),
...(challenge.oauthId ? { oauthId: challenge.oauthId } : {}),
...(challenge.oauthSessionId ? { oauthSessionId: challenge.oauthSessionId } : {}),
};
}
}
@@ -0,0 +1,144 @@
import { TRPCError } from '@trpc/server';
import type { GatewayApiContext } from '../context.js';
import type { OAuthSession } from './oauthSessionStore.js';
import type { UserRecord } from './userRepository.js';
import { assertLoginAllowed, completeGatewayLogin } from './deletionRecovery.js';
import { authorizeKakaoLink, snapshotKakaoLink } from './kakaoLinkPolicy.js';
import { KakaoVerificationError, readVerifiedKakaoProfile, requireKakaoTalkProof } from './kakaoAccountVerification.js';
import { openPassword } from './registrationInput.js';
const LINK_TTL_MS = 300_000;
const firstUserId = async (ctx: GatewayApiContext): Promise<string | undefined> =>
(await ctx.prisma.appUser.findFirst({ orderBy: { createdAt: 'asc' }, select: { id: true } }))?.id;
export const validatePendingKakaoProfile = async (ctx: GatewayApiContext, session: OAuthSession): Promise<void> => {
const createdAt = Date.parse(session.createdAt);
if (!Number.isFinite(createdAt) || Date.now() - createdAt > LINK_TTL_MS || createdAt > Date.now()) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: '카카오 연결 확인이 만료되었습니다. 카카오 로그인을 다시 진행해 주세요.',
});
}
const profile = await ctx.kakaoClient
.getMe(session.accessToken)
.then(readVerifiedKakaoProfile)
.catch((error: unknown) => {
if (error instanceof KakaoVerificationError)
throw new TRPCError({ code: 'UNAUTHORIZED', message: error.message, cause: error });
throw error;
});
if (profile.kakaoId !== session.kakaoId || profile.email !== session.email.toLowerCase()) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: '카카오 계정 정보가 변경되었습니다. 처음부터 다시 진행해 주세요.',
});
}
};
export const requestPendingKakaoOtp = async (ctx: GatewayApiContext, session: OAuthSession, user: UserRecord) => {
assertLoginAllowed(user);
const challenge = await requireKakaoTalkProof({
user,
accessToken: session.accessToken,
kakaoClient: ctx.kakaoClient,
oauthSessions: ctx.oauthSessions,
publicBaseUrl: ctx.publicBaseUrl,
oauthSessionId: session.id,
}).catch((error: unknown) => {
if (error instanceof KakaoVerificationError)
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: error.message, cause: error });
throw error;
});
if (!challenge) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR' });
return { ...challenge, successStatus: session.mode === 'verify' ? ('verified' as const) : ('login' as const) };
};
export const beginPendingKakaoLink = async (
ctx: GatewayApiContext,
user: UserRecord,
oauth: OAuthSession,
passwordVerified = false
) => {
await validatePendingKakaoProfile(ctx, oauth);
assertLoginAllowed(user);
const authorization = authorizeKakaoLink(user, oauth.email, passwordVerified, await firstUserId(ctx));
const [owner, detached] = await Promise.all([
ctx.users.findByOauthId('KAKAO', oauth.kakaoId),
ctx.users.findDetachedKakaoOwner(oauth.kakaoId),
]);
if (
(owner && owner.id !== user.id) ||
(detached && detached !== user.id) ||
(await ctx.users.isKakaoIdentityRetired(oauth.kakaoId))
) {
throw new TRPCError({
code: 'CONFLICT',
message: '다른 계정에 귀속된 카카오 계정입니다. 관리자에게 문의해 주세요.',
});
}
const session = await ctx.oauthSessions.createSession({
...oauth,
intent: 'identity_link',
targetUserId: user.id,
createdAt: new Date().toISOString(),
identityLink: { expected: snapshotKakaoLink(user), authorization },
});
if (user.passwordResetRequired) {
return {
status: 'password_setup' as const,
oauthSessionId: session.id,
email: session.email,
successStatus: session.mode === 'verify' ? ('verified' as const) : ('login' as const),
};
}
return requestPendingKakaoOtp(ctx, session, user);
};
export const finishPendingKakaoLink = async (
ctx: GatewayApiContext,
sessionId: string,
userId: string,
validUntil: string
) => {
const pending = await ctx.oauthSessions.consumeSession(sessionId);
if (!pending || pending.intent !== 'identity_link' || !pending.identityLink || pending.targetUserId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: '카카오 연결 확인이 만료되었습니다. 다시 로그인해 주세요.',
});
}
await validatePendingKakaoProfile(ctx, pending);
let user: UserRecord;
try {
user = await ctx.users.completeKakaoIdentityLink(userId, {
...pending.identityLink,
oauthId: pending.kakaoId,
email: pending.email,
oauthInfo: {
accessToken: pending.accessToken,
refreshToken: pending.refreshToken,
accessTokenValidUntil: pending.accessTokenValidUntil,
refreshTokenValidUntil: pending.refreshTokenValidUntil,
},
verifiedAt: new Date(),
talkVerifiedUntil: new Date(validUntil),
firstUserId: await firstUserId(ctx),
...(pending.identityLink.credential
? { password: openPassword(ctx.passwordEnvelope, pending.identityLink.credential) }
: {}),
});
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: 'CONFLICT',
message: '계정 연결 상태가 변경되었습니다. 카카오 로그인을 다시 진행해 주세요.',
cause: error,
});
}
try {
await ctx.flushPublisher.publishUserFlush(user.id, 'kakao-account-relinked');
} catch {
// DB의 인증 revision은 이미 증가했다. 다음 접속의 재검증을 유지하고 저장 성공을 반환한다.
}
return { ...(await completeGatewayLogin(ctx, user, 'login')), validUntil };
};
@@ -1,3 +1,6 @@
import { authorizeKakaoLink, matchesKakaoLinkSnapshot } from './kakaoLinkPolicy.js';
import { assertLoginAllowed } from './deletionRecovery.js';
import type { CompleteKakaoLinkInput } from './userRepository.js';
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js';
@@ -175,6 +178,91 @@ export const createPostgresUserRepository = (
hasher: PasswordHasher = createPasswordHasher()
): UserRepository => {
return {
async findDetachedKakaoOwner(oauthId: string): Promise<string | null> {
const row = await prisma.kakaoIdentityLinkHistory.findFirst({
where: { previousOauthId: oauthId },
orderBy: { createdAt: 'desc' },
select: { userId: true },
});
return row?.userId ?? null;
},
async completeKakaoIdentityLink(userId: string, input: CompleteKakaoLinkInput): Promise<UserRecord> {
const password = input.password ? await hasher.hash(input.password) : undefined;
return prisma.$transaction(
async (tx) => {
const row = await tx.appUser.findUnique({ where: { id: userId } });
if (!row) throw new Error('Kakao recovery account no longer exists.');
const user = mapUser(row);
assertLoginAllowed(user);
if (!matchesKakaoLinkSnapshot(user, input.expected))
throw new Error('Kakao recovery account changed.');
const first = await tx.appUser.findFirst({ orderBy: { createdAt: 'asc' }, select: { id: true } });
if (
authorizeKakaoLink(
user,
input.email,
input.authorization === 'PASSWORD',
first?.id,
input.verifiedAt
) !== input.authorization
)
throw new Error('Kakao recovery authorization changed.');
if (user.passwordResetRequired && !password) throw new Error('A new password is required.');
const [owner, emailOwner, retired, detached] = await Promise.all([
tx.appUser.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }),
tx.appUser.findUnique({ where: { email: input.email.toLowerCase() }, select: { id: true } }),
tx.retiredKakaoIdentity.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }),
tx.kakaoIdentityLinkHistory.findFirst({
where: { previousOauthId: input.oauthId, userId: { not: userId } },
}),
]);
if (
retired ||
detached ||
(owner && owner.id !== userId) ||
(emailOwner && emailOwner.id !== userId)
)
throw new Error('Kakao recovery identity ownership changed.');
if (user.oauthId && user.oauthId !== input.oauthId) {
await tx.kakaoIdentityLinkHistory.create({
data: {
userId,
previousOauthId: user.oauthId,
nextOauthId: input.oauthId,
authorization: input.authorization,
},
});
}
// OTP까지 확인한 새 연결과 비밀번호/인증 revision을 하나의 transaction에 저장한다.
return mapUser(
await tx.appUser.update({
where: { id: userId },
data: {
oauthType: 'KAKAO',
oauthId: input.oauthId,
email: input.email.toLowerCase(),
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
kakaoVerifiedAt: input.verifiedAt,
kakaoTalkVerifiedUntil: input.talkVerifiedUntil,
authRevision: { increment: 1 },
sessionRevokedBefore: input.verifiedAt,
kakaoReplacementApprovedUntil: null,
kakaoReplacementApprovedByUserId: null,
kakaoReplacementReason: null,
...(password
? {
passwordHash: password.hash,
passwordSalt: password.salt,
passwordResetRequired: false,
}
: {}),
},
})
);
},
{ isolationLevel: 'Serializable' }
);
},
async findById(id: string): Promise<UserRecord | null> {
const row = await prisma.appUser.findUnique({
where: {
@@ -286,6 +374,12 @@ export const createPostgresUserRepository = (
if (retired) {
throw new Error('Kakao identity is permanently retired.');
}
if (
await tx.kakaoIdentityLinkHistory.findFirst({
where: { previousOauthId: input.oauth!.id },
})
)
throw new Error('Kakao identity belongs to a former service account.');
return tx.appUser.create({ data });
},
{ isolationLevel: 'Serializable' }
@@ -340,9 +434,18 @@ export const createPostgresUserRepository = (
});
return mapUser(row);
},
async markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord> {
async markKakaoTalkVerified(
userId: string,
validUntil: Date,
authRevision?: number,
oauthId?: string
): Promise<UserRecord> {
const row = await prisma.appUser.update({
where: { id: userId },
where: {
id: userId,
...(authRevision !== undefined ? { authRevision } : {}),
...(oauthId ? { oauthId } : {}),
},
data: { kakaoTalkVerifiedUntil: validUntil },
});
return mapUser(row);
@@ -357,6 +460,12 @@ export const createPostgresUserRepository = (
if (retired) {
throw new Error('Kakao identity is permanently retired.');
}
if (
await tx.kakaoIdentityLinkHistory.findFirst({
where: { previousOauthId: input.oauthId, userId: { not: userId } },
})
)
throw new Error('Kakao identity belongs to another service account.');
const row = await tx.appUser.update({
where: { id: userId },
data: {
@@ -384,6 +493,12 @@ export const createPostgresUserRepository = (
if (retired) {
throw new Error('Kakao identity is permanently retired.');
}
if (
await tx.kakaoIdentityLinkHistory.findFirst({
where: { previousOauthId: input.oauthId, userId: { not: userId } },
})
)
throw new Error('Kakao identity belongs to another service account.');
const updated = await tx.appUser.updateMany({
where: {
id: userId,
@@ -444,13 +559,17 @@ export const createPostgresUserRepository = (
throw new Error('Kakao account replacement is not approved.');
}
const normalizedEmail = input.email.toLowerCase();
const [oauthOwner, emailOwner, retired] = await Promise.all([
const [oauthOwner, emailOwner, retired, detached] = await Promise.all([
tx.appUser.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }),
tx.appUser.findUnique({ where: { email: normalizedEmail }, select: { id: true } }),
tx.retiredKakaoIdentity.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }),
tx.kakaoIdentityLinkHistory.findFirst({
where: { previousOauthId: input.oauthId, userId: { not: userId } },
}),
]);
if (
retired ||
detached ||
(oauthOwner && oauthOwner.id !== userId) ||
(emailOwner && emailOwner.id !== userId)
) {
+21 -1
View File
@@ -1,3 +1,4 @@
import type { KakaoLinkAuthorization, KakaoLinkSnapshot } from './kakaoLinkPolicy.js';
export interface UserRecord {
id: string;
username: string;
@@ -155,7 +156,21 @@ export interface CreateUserInput {
};
}
export interface CompleteKakaoLinkInput {
firstUserId?: string;
expected: KakaoLinkSnapshot;
authorization: KakaoLinkAuthorization;
oauthId: string;
email: string;
oauthInfo: UserOAuthInfo;
verifiedAt: Date;
talkVerifiedUntil: Date;
password?: string;
}
export interface UserRepository {
findDetachedKakaoOwner(oauthId: string): Promise<string | null>;
completeKakaoIdentityLink(userId: string, input: CompleteKakaoLinkInput): Promise<UserRecord>;
findById(id: string): Promise<UserRecord | null>;
findByIds(ids: string[]): Promise<UserRecord[]>;
findByUsername(username: string): Promise<UserRecord | null>;
@@ -168,7 +183,12 @@ export interface UserRepository {
updatePassword(userId: string, password: string): Promise<void>;
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
syncKakaoIdentity(userId: string, email: string, oauthInfo: UserOAuthInfo): Promise<UserRecord>;
markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord>;
markKakaoTalkVerified(
userId: string,
validUntil: Date,
authRevision?: number,
oauthId?: string
): Promise<UserRecord>;
linkKakao(
userId: string,
input: {
+101 -91
View File
@@ -24,6 +24,12 @@ import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
import { isGatewaySessionCurrent } from './auth/sessionValidity.js';
import { GameSessionLimitError } from './auth/sessionService.js';
import { assertLoginAllowed, canCancelDeletion, completeGatewayLogin } from './auth/deletionRecovery.js';
import {
beginPendingKakaoLink,
finishPendingKakaoLink,
requestPendingKakaoOtp,
validatePendingKakaoProfile,
} from './auth/pendingKakaoLink.js';
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
import type { GatewayApiContext } from './context.js';
import { listScenarioPreviews } from './scenario/scenarioCatalog.js';
@@ -394,57 +400,30 @@ export const appRouter = router({
});
}
const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, localUser.oauthInfo);
let verified: UserRecord;
const replacingKakao =
localUser.oauthType === 'KAKAO' &&
Boolean(localUser.oauthId) &&
localUser.oauthId !== profile.kakaoId;
if (replacingKakao) {
try {
verified = await ctx.users.replaceKakaoWithApprovedIdentity(localUser.id, {
oauthId: profile.kakaoId,
email: profile.email,
oauthInfo,
verifiedAt: new Date(),
});
} catch (error) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '관리자의 카카오 계정 교체 승인이 없거나 만료되었습니다.',
cause: error,
});
}
} else if (existingById?.id !== localUser.id && localUser.oauthType !== 'KAKAO') {
try {
verified = await ctx.users.linkKakao(localUser.id, {
oauthId: profile.kakaoId,
email: profile.email,
oauthInfo,
verifiedAt: new Date(),
});
} catch (error) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 계정에 연결된 카카오 계정입니다.',
cause: error,
});
}
} else {
try {
verified = await ctx.users.syncKakaoIdentity(localUser.id, profile.email, oauthInfo);
} catch (error) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.',
cause: error,
});
}
if (localUser.oauthType !== 'KAKAO' || localUser.oauthId !== profile.kakaoId) {
const stored = await ctx.oauthSessions.createSession({
mode: pending.mode,
intent: 'link_existing',
targetUserId: localUser.id,
kakaoId: profile.kakaoId,
email: profile.email,
accessToken: token.accessToken,
refreshToken: token.refreshToken,
accessTokenValidUntil: oauthInfo.accessTokenValidUntil!,
refreshTokenValidUntil: oauthInfo.refreshTokenValidUntil,
createdAt: new Date().toISOString(),
});
return {
status: 'account_recovery' as const,
action: 'link_existing' as const,
oauthSessionId: stored.id,
email: stored.email,
passwordRequired: localUser.email?.toLowerCase() !== profile.email,
};
}
const verified = await ctx.users.syncKakaoIdentity(localUser.id, profile.email, oauthInfo);
const refreshed = (await ctx.users.findById(verified.id)) ?? verified;
await ctx.flushPublisher.publishUserFlush(
refreshed.id,
replacingKakao ? 'kakao-account-replaced' : 'kakao-verified'
);
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified');
return finishKakaoLoginOrRequestPasswordSetup(ctx, refreshed, token.accessToken, 'verified');
}
@@ -515,8 +494,20 @@ export const appRouter = router({
return finishKakaoLoginOrRequestPasswordSetup(ctx, synced, token.accessToken, 'login');
}
const detachedOwnerId = await ctx.users.findDetachedKakaoOwner(profile.kakaoId);
const detachedOwner = detachedOwnerId ? await ctx.users.findById(detachedOwnerId) : null;
if (
detachedOwnerId &&
(!detachedOwner || (existingByEmail && existingByEmail.id !== detachedOwnerId))
) {
throw new TRPCError({
code: 'CONFLICT',
message: '이전 계정에 귀속된 카카오 계정입니다. 관리자에게 복구를 요청해 주세요.',
});
}
const recoveryTarget = detachedOwner ?? existingByEmail;
const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
const recoveryIntent = existingByEmail
const recoveryIntent = recoveryTarget
? ('link_existing' as const)
: alreadyRegisteredWithKakao
? ('rejoin' as const)
@@ -524,7 +515,7 @@ export const appRouter = router({
const stored = await ctx.oauthSessions.createSession({
mode: pending.mode,
intent: recoveryIntent,
targetUserId: existingByEmail?.id,
targetUserId: recoveryTarget?.id,
kakaoId: profile.kakaoId,
email: profile.email,
accessToken: token.accessToken,
@@ -540,6 +531,9 @@ export const appRouter = router({
action: recoveryIntent,
oauthSessionId: stored.id,
email: stored.email,
passwordRequired: recoveryTarget
? recoveryTarget.email?.toLowerCase() !== profile.email
: false,
};
}
@@ -557,7 +551,7 @@ export const appRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId);
const oauthSession = await ctx.oauthSessions.getSession(input.oauthSessionId);
if (!oauthSession) {
throw new TRPCError({
code: 'UNAUTHORIZED',
@@ -571,6 +565,8 @@ export const appRouter = router({
});
}
if (!(await ctx.oauthSessions.consumeSession(input.oauthSessionId)))
throw new TRPCError({ code: 'UNAUTHORIZED', message: '카카오 계정 확인이 이미 사용되었습니다.' });
if (input.action === 'rejoin') {
const [oauthOwner, emailOwner] = await Promise.all([
ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId),
@@ -611,7 +607,7 @@ export const appRouter = router({
ctx.users.findByEmail(oauthSession.email),
ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId),
]);
if (!targetUser || emailOwner?.id !== targetUser.id) {
if (!targetUser || (emailOwner && emailOwner.id !== targetUser.id)) {
throw new TRPCError({
code: 'CONFLICT',
message:
@@ -626,46 +622,40 @@ export const appRouter = router({
}
assertLoginAllowed(targetUser);
const oauthInfo: UserOAuthInfo = {
accessToken: oauthSession.accessToken,
refreshToken: oauthSession.refreshToken,
accessTokenValidUntil: oauthSession.accessTokenValidUntil,
refreshTokenValidUntil: oauthSession.refreshTokenValidUntil,
};
let linked: UserRecord;
try {
const replacement =
targetUser.oauthType === 'KAKAO' &&
Boolean(targetUser.oauthId) &&
targetUser.oauthId !== oauthSession.kakaoId;
linked = replacement
? await ctx.users.replaceKakaoWithApprovedIdentity(targetUser.id, {
oauthId: oauthSession.kakaoId,
email: oauthSession.email,
oauthInfo,
verifiedAt: new Date(),
})
: await ctx.users.relinkKakaoByEmail(targetUser.id, {
oauthId: oauthSession.kakaoId,
email: oauthSession.email,
oauthInfo,
verifiedAt: new Date(),
});
} catch (error) {
return beginPendingKakaoLink(ctx, targetUser, oauthSession);
}),
kakaoLinkWithPassword: procedure
.input(z.object({ oauthSessionId: z.string().uuid(), username: zUsername, credential: zPasswordEnvelope }))
.mutation(async ({ ctx, input }) => {
const oauth = await ctx.oauthSessions.getSession(input.oauthSessionId);
if (!oauth || !['register', 'rejoin', 'link_existing'].includes(oauth.intent ?? 'register'))
throw new TRPCError({
code: targetUser.oauthType === 'KAKAO' ? 'PRECONDITION_FAILED' : 'CONFLICT',
message:
targetUser.oauthType === 'KAKAO'
? '기존 카카오 계정 교체에는 관리자 승인이 필요합니다.'
: '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.',
cause: error,
code: 'UNAUTHORIZED',
message: '카카오 연결 확인이 만료되었습니다. 다시 로그인해 주세요.',
});
}
await ctx.flushPublisher.publishUserFlush(
linked.id,
targetUser.oauthType === 'KAKAO' ? 'kakao-account-replaced' : 'kakao-account-relinked'
);
return finishKakaoLoginOrRequestPasswordSetup(ctx, linked, oauthSession.accessToken, 'login');
await validatePendingKakaoProfile(ctx, oauth);
const user = await ctx.users.findByUsername(input.username);
const password = openPassword(ctx.passwordEnvelope, input.credential);
if (
!user ||
!(await ctx.users.verifyPassword(user, password)) ||
(oauth.targetUserId && oauth.targetUserId !== user.id)
)
throw new TRPCError({
code: 'UNAUTHORIZED',
message: '기존 계정명 또는 비밀번호가 일치하지 않습니다.',
});
const emailOwner = await ctx.users.findByEmail(oauth.email);
if (emailOwner && emailOwner.id !== user.id)
throw new TRPCError({
code: 'CONFLICT',
message: '새 카카오 이메일이 다른 계정에 연결되어 있습니다. 관리자에게 문의해 주세요.',
});
if (!(await ctx.oauthSessions.consumeSession(oauth.id)))
throw new TRPCError({ code: 'UNAUTHORIZED', message: '카카오 연결 확인이 이미 사용되었습니다.' });
const refreshed = await ctx.users.findById(user.id);
if (!refreshed) throw new TRPCError({ code: 'UNAUTHORIZED' });
return beginPendingKakaoLink(ctx, refreshed, oauth, true);
}),
kakaoSetPassword: procedure
.input(
@@ -676,6 +666,19 @@ export const appRouter = router({
)
.mutation(async ({ ctx, input }) => {
const password = openPassword(ctx.passwordEnvelope, input.credential);
const staged = await ctx.oauthSessions.getSession(input.oauthSessionId);
if (staged?.intent === 'identity_link' && staged.identityLink && staged.targetUserId) {
await validatePendingKakaoProfile(ctx, staged);
const user = await ctx.users.findById(staged.targetUserId);
if (!user) throw new TRPCError({ code: 'UNAUTHORIZED' });
if (!(await ctx.oauthSessions.consumeSession(staged.id)))
throw new TRPCError({ code: 'UNAUTHORIZED' });
const next = await ctx.oauthSessions.createSession({
...staged,
identityLink: { ...staged.identityLink, credential: input.credential },
});
return requestPendingKakaoOtp(ctx, next, user);
}
const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId);
if (!oauthSession || oauthSession.intent !== 'password_setup' || !oauthSession.targetUserId) {
throw new TRPCError({
@@ -976,6 +979,13 @@ export const appRouter = router({
oauthSessions: ctx.oauthSessions,
users: ctx.users,
}).catch((error: unknown) => throwKakaoVerificationError(error));
if (verified.pendingLinkSessionId)
return finishPendingKakaoLink(
ctx,
verified.pendingLinkSessionId,
verified.user.id,
verified.validUntil
);
return {
...(await completeGatewayLogin(ctx, verified.user, 'login')),
validUntil: verified.validUntil,
+5 -1
View File
@@ -12,7 +12,11 @@ export const procedure = t.procedure.use(async ({ ctx, path, getRawInput, next }
const raw: unknown = await getRawInput();
const input = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const resolveSubject = async (): Promise<string | undefined> => {
if (path === 'auth.login' && typeof input.username === 'string' && input.username.length <= 64) {
if (
(path === 'auth.login' || path === 'auth.kakaoLinkWithPassword') &&
typeof input.username === 'string' &&
input.username.length <= 64
) {
return `login:${input.username.trim().toLocaleLowerCase('en-US')}`;
}
if (
@@ -2,12 +2,15 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
import { snapshotKakaoLink } from '../src/auth/kakaoLinkPolicy.js';
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const firstUserId = 'f28aa8ce-6bd0-45e5-a127-056de53e3161';
const secondUserId = '8f610116-64bd-4f18-bf7f-eae1737b227c';
const bootstrapId = '00000000-0000-4000-8000-000000000042';
const selfUserId = '00000000-0000-4000-8000-000000000043';
const oldKakaoId = 'account-identity-old-kakao';
const newKakaoId = 'account-identity-new-kakao';
@@ -30,9 +33,34 @@ integration('account identity PostgreSQL transaction', () => {
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.retiredKakaoIdentity.deleteMany({ where: { oauthId: { in: [oldKakaoId, newKakaoId] } } });
await db.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId] } } });
await db.kakaoIdentityLinkHistory.deleteMany({
where: { userId: { in: [firstUserId, secondUserId, selfUserId] } },
});
await db.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId, bootstrapId, selfUserId] } } });
await db.appUser.createMany({
data: [
{
id: bootstrapId,
loginId: 'identity-bootstrap',
displayName: '복구통합관리자',
passwordHash: 'not-used',
passwordSalt: 'not-used',
roles: ['superuser'],
sanctions: {},
createdAt: new Date('2000-01-01'),
},
{
id: selfUserId,
loginId: 'identity-self',
displayName: '직접복구계정',
passwordHash: 'not-used',
passwordSalt: 'not-used',
roles: ['user'],
sanctions: {},
oauthType: 'KAKAO',
oauthId: 'self-db-old',
email: 'self-db@example.test',
},
{
id: firstUserId,
loginId: 'identity-integration-one',
@@ -63,7 +91,10 @@ integration('account identity PostgreSQL transaction', () => {
afterAll(async () => {
await db?.retiredKakaoIdentity.deleteMany({ where: { oauthId: { in: [oldKakaoId, newKakaoId] } } });
await db?.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId] } } });
await db?.kakaoIdentityLinkHistory.deleteMany({
where: { userId: { in: [firstUserId, secondUserId, selfUserId] } },
});
await db?.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId, bootstrapId, selfUserId] } } });
await closeDb?.();
});
@@ -160,4 +191,93 @@ integration('account identity PostgreSQL transaction', () => {
expect(restored?.oauthId).toBe(before?.oauthId);
expect(restored?.sanctions).toEqual(before?.sanctions);
});
it('commits identity, imported password, proof and history together, and restricts former identities to their owner', async () => {
const users = createPostgresUserRepository(db);
await db.appUser.update({ where: { id: selfUserId }, data: { passwordResetRequired: true } });
const before = await users.findById(selfUserId);
const now = new Date();
const input = {
expected: snapshotKakaoLink(before!),
authorization: 'EMAIL' as const,
oauthId: 'self-db-new',
email: 'self-db@example.test',
oauthInfo: {},
verifiedAt: now,
talkVerifiedUntil: new Date(now.getTime() + 86400000),
password: 'new-self-password',
};
const restored = await users.completeKakaoIdentityLink(selfUserId, input);
expect(restored).toMatchObject({
oauthId: 'self-db-new',
passwordResetRequired: false,
authRevision: (before?.authRevision ?? 0) + 1,
});
expect(await users.verifyPassword(restored, 'new-self-password')).toBe(true);
expect(await users.findDetachedKakaoOwner('self-db-old')).toBe(selfUserId);
expect(await users.isKakaoIdentityRetired('self-db-old')).toBe(false);
expect(await db.kakaoIdentityLinkHistory.count({ where: { userId: selfUserId } })).toBe(1);
await expect(users.completeKakaoIdentityLink(selfUserId, input)).rejects.toThrow();
await expect(
users.createUser({
username: 'identity-history-claim',
password: 'secretpass',
oauth: { type: 'KAKAO', id: 'self-db-old', email: 'claim@example.test', info: {} },
})
).rejects.toThrow();
const prior = await users.findById(selfUserId);
await users.completeKakaoIdentityLink(selfUserId, {
...input,
expected: snapshotKakaoLink(prior!),
oauthId: 'self-db-old',
});
expect((await users.findById(selfUserId))?.oauthId).toBe('self-db-old');
const snapshot = await users.findById(selfUserId);
const conflicting = await users.findById(secondUserId);
await expect(
users.completeKakaoIdentityLink(selfUserId, {
...input,
expected: snapshotKakaoLink(snapshot!),
oauthId: conflicting!.oauthId!,
password: 'must-not-be-saved',
})
).rejects.toThrow();
expect((await users.findById(selfUserId))?.passwordHash).toBe(snapshot?.passwordHash);
expect(await db.kakaoIdentityLinkHistory.count({ where: { userId: selfUserId } })).toBe(2);
await expect(
users.markKakaoTalkVerified(selfUserId, new Date(Date.now() + 10000), 0, 'self-db-old')
).rejects.toThrow();
});
it('allows only one concurrent recovery against the same PostgreSQL snapshot', async () => {
const users = createPostgresUserRepository(db);
const user = await users.findById(selfUserId);
const input = {
expected: snapshotKakaoLink(user!),
authorization: 'EMAIL' as const,
email: 'self-db@example.test',
oauthInfo: {},
verifiedAt: new Date(),
talkVerifiedUntil: new Date(Date.now() + 86400000),
};
const results = await Promise.allSettled(
['self-db-race-one', 'self-db-race-two'].map((oauthId) =>
users.completeKakaoIdentityLink(selfUserId, { ...input, oauthId })
)
);
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1);
expect((await users.findById(selfUserId))?.authRevision).toBe((user?.authRevision ?? 0) + 1);
});
it('keeps former identity ownership after the service account is deleted', async () => {
const users = createPostgresUserRepository(db);
await users.deleteUser(selfUserId);
expect(await users.findDetachedKakaoOwner('self-db-old')).toBe(selfUserId);
await expect(
users.createUser({
username: 'deleted-owner-claim',
password: 'secretpass',
oauth: { type: 'KAKAO', id: 'self-db-old', email: 'deleted-owner-claim@example.test', info: {} },
})
).rejects.toThrow();
});
});
+275 -56
View File
@@ -269,6 +269,7 @@ const buildCaller = (
flushPublisher,
userIconUpload,
kakaoProfile,
kakaoClient,
sentTalkMessages,
refreshTokenCalls,
sealPassword,
@@ -640,11 +641,17 @@ describe('gateway auth flow', () => {
});
setSessionHeader(register.sessionToken);
const start = await caller.auth.kakaoStart({ mode: 'verify' });
const verified = await caller.auth.kakaoExchange({
const choice = await caller.auth.kakaoExchange({
code: 'oauth-code',
state: start.state,
});
if (choice.status !== 'account_recovery') throw new Error('Expected link confirmation.');
const verified = await caller.auth.kakaoLinkWithPassword({
oauthSessionId: choice.oauthSessionId,
username: 'verify-user',
credential: sealPassword('verify-password'),
});
expect(verified.status).toBe('otp');
if (verified.status !== 'otp') {
throw new Error('Expected Kakao OTP challenge.');
@@ -685,11 +692,6 @@ describe('gateway auth flow', () => {
});
emailOwner.passwordResetRequired = true;
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
await users.setKakaoReplacementApproval(emailOwner.id, {
until: new Date(Date.now() + 60_000),
approvedByUserId: 'admin-user-id',
reason: '이메일 보존 계정의 교체 승인',
});
kakaoProfile.id = 'different-kakao-id';
const start = await caller.auth.kakaoStart({ mode: 'login' });
@@ -715,15 +717,16 @@ describe('gateway auth flow', () => {
});
expect(passwordSet.status).toBe('otp');
expect(sentTalkMessages).toHaveLength(1);
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toMatchObject({ id: emailOwner.id });
expect((await users.findById(emailOwner.id))?.passwordResetRequired).toBe(true);
if (passwordSet.status !== 'otp') throw new Error('Expected OTP.');
const code = sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1];
await caller.auth.kakaoOtp({ challengeId: passwordSet.challengeId, code: code! });
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
expect(await users.isKakaoIdentityRetired('original-kakao-id')).toBe(true);
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-replaced');
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'password-changed');
expect(await users.isKakaoIdentityRetired('original-kakao-id')).toBe(false);
expect(await users.findDetachedKakaoOwner('original-kakao-id')).toBe(emailOwner.id);
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({ id: emailOwner.id });
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked');
});
it('requires a one-time password setup before an imported Kakao account can receive a session', async () => {
@@ -896,42 +899,41 @@ describe('gateway auth flow', () => {
});
});
it('replaces an approved Kakao identity, retires the old ID, and invalidates older sessions', async () => {
const { caller, users, sessions, kakaoProfile, setSessionHeader } = buildCaller({
kakaoId: 'new-kakao-id',
kakaoEmail: 'new-kakao@example.com',
});
const user = await users.createUser({
it('completes an approved administrator replacement only after fresh talk proof and keeps old-ID ownership', async () => {
const f = buildCaller({ kakaoId: 'new-kakao-id', kakaoEmail: 'new-kakao@example.com' });
const user = await f.users.createUser({
username: 'approved-replacement-user',
password: 'replacement-password',
oauth: { type: 'KAKAO', id: 'old-kakao-id', email: 'old-kakao@example.com', info: {} },
});
const oldSession = await sessions.createSession(user);
setSessionHeader(oldSession.sessionToken);
await users.setKakaoReplacementApproval(user.id, {
until: new Date(Date.now() + 60_000),
approvedByUserId: 'admin-user-id',
reason: '사용자 본인 확인 완료',
await f.users.updateRoles(user.id, ['admin']);
await f.users.setKakaoReplacementApproval(user.id, {
until: new Date(Date.now() + 60000),
approvedByUserId: 'operator',
reason: '본인 확인 완료',
});
const start = await caller.auth.kakaoStart({ mode: 'verify', sessionToken: oldSession.sessionToken });
const result = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
expect(result).toMatchObject({ status: 'otp', successStatus: 'verified' });
expect(await users.findById(user.id)).toMatchObject({
const old = await f.sessions.createSession(user);
f.setSessionHeader(old.sessionToken);
const start = await f.caller.auth.kakaoStart({ mode: 'verify' });
const choice = await f.caller.auth.kakaoExchange({ code: 'code', state: start.state });
if (choice.status !== 'account_recovery') throw new Error('Expected confirmation.');
const otp = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: choice.oauthSessionId,
action: 'link_existing',
});
if (otp.status !== 'otp') throw new Error('Expected OTP.');
expect((await f.users.findById(user.id))?.oauthId).toBe('old-kakao-id');
expect(await f.caller.auth.me({ sessionToken: old.sessionToken })).not.toBeNull();
const code = f.sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1];
await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: code! });
expect(await f.users.findById(user.id)).toMatchObject({
oauthId: 'new-kakao-id',
email: 'new-kakao@example.com',
kakaoReplacementApprovedUntil: undefined,
authRevision: 1,
kakaoReplacementApprovedUntil: undefined,
});
await expect(users.isKakaoIdentityRetired('old-kakao-id')).resolves.toBe(true);
await expect(caller.auth.me({ sessionToken: oldSession.sessionToken })).resolves.toBeNull();
kakaoProfile.id = 'old-kakao-id';
kakaoProfile.email = 'old-kakao@example.com';
const retiredStart = await caller.auth.kakaoStart({ mode: 'login' });
await expect(
caller.auth.kakaoExchange({ code: 'oauth-code', state: retiredStart.state })
).rejects.toMatchObject({ code: 'FORBIDDEN', message: expect.stringContaining('영구 폐기') });
expect(await f.users.isKakaoIdentityRetired('old-kakao-id')).toBe(false);
expect(await f.users.findDetachedKakaoOwner('old-kakao-id')).toBe(user.id);
expect(await f.caller.auth.me({ sessionToken: old.sessionToken })).toBeNull();
});
it('synchronizes a changed email by stable Kakao ID during Kakao login', async () => {
@@ -1133,22 +1135,12 @@ describe('gateway auth flow', () => {
});
it('blocks Kakao login while a ban is active', async () => {
const { caller, users, sealPassword, setSessionHeader } = buildCaller();
const register = await caller.auth.registerLocal({
const { caller, users } = buildCaller();
await users.createUser({
username: 'kakao-banned-user',
credential: sealPassword('kakao-banned-password'),
displayName: '카카오제재유저',
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
password: 'kakao-banned-password',
oauth: { type: 'KAKAO', id: '1', email: 'tester@example.com', info: {} },
});
setSessionHeader(register.sessionToken);
const verifyStart = await caller.auth.kakaoStart({ mode: 'verify' });
await caller.auth.kakaoExchange({
code: 'oauth-code',
state: verifyStart.state,
});
const stored = await users.findByUsername('kakao-banned-user');
expect(stored).not.toBeNull();
if (stored) {
@@ -1948,3 +1940,230 @@ describe('account deletion cancellation', () => {
expect(canCancelDeletion({ ...legacy!, legacyMemberNo: undefined }, new Date('2026-11-07'))).toBe(false);
});
});
describe('staged Kakao self recovery', () => {
const prepare = async (
options: { differentEmail?: boolean; resetPassword?: boolean; privileged?: boolean } = {}
) => {
const f = buildCaller({
kakaoId: 'new-self-kakao',
kakaoEmail: options.differentEmail ? 'new@example.test' : 'owner@example.test',
});
const user = await f.users.createUser({
username: 'self-recover',
password: 'originalpass',
oauth: { type: 'KAKAO', id: 'old-self-kakao', email: 'owner@example.test', info: {} },
});
if (options.privileged) await f.users.updateRoles(user.id, ['admin']);
user.passwordResetRequired = options.resetPassword ?? false;
user.kakaoTalkVerifiedUntil = new Date(Date.now() + 86400000).toISOString();
const old = await f.sessions.createSession(user);
const start = await f.caller.auth.kakaoStart({ mode: 'login' });
const choice = await f.caller.auth.kakaoExchange({ code: 'code', state: start.state });
return { ...f, user, old, choice };
};
const otpCode = (f: { sentTalkMessages: string[] }): string =>
f.sentTalkMessages.at(-1)!.match(/인증 코드는 (\d{4})/)![1]!;
it('recovers an ordinary matching-email account without approval and only commits after fresh OTP', async () => {
const f = await prepare();
if (f.choice.status !== 'account_recovery') throw new Error('Expected confirmation.');
const oldChallenge = await f.oauthSessions.createLoginChallenge({
userId: f.user.id,
code: '1111',
attemptsRemaining: 3,
expiresAt: new Date(Date.now() + 180000).toISOString(),
createdAt: new Date().toISOString(),
authRevision: 0,
oauthId: 'old-self-kakao',
});
const otp = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: f.choice.oauthSessionId,
action: 'link_existing',
});
if (otp.status !== 'otp') throw new Error('Expected fresh OTP despite a cached proof.');
expect((await f.users.findById(f.user.id))?.oauthId).toBe('old-self-kakao');
expect(await f.caller.auth.me({ sessionToken: f.old.sessionToken })).not.toBeNull();
expect(await f.users.findDetachedKakaoOwner('old-self-kakao')).toBeNull();
await expect(f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: '0000' })).rejects.toBeDefined();
expect((await f.users.findById(f.user.id))?.oauthId).toBe('old-self-kakao');
expect((await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) })).status).toBe('login');
expect(await f.caller.auth.me({ sessionToken: f.old.sessionToken })).toBeNull();
await expect(f.caller.auth.kakaoOtp({ challengeId: oldChallenge.id, code: '1111' })).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
});
expect(await f.users.findDetachedKakaoOwner('old-self-kakao')).toBe(f.user.id);
expect(await f.users.isKakaoIdentityRetired('old-self-kakao')).toBe(false);
await expect(f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) })).rejects.toBeDefined();
f.kakaoProfile.id = 'old-self-kakao';
const start = await f.caller.auth.kakaoStart({ mode: 'login' });
const choice = await f.caller.auth.kakaoExchange({ code: 'code', state: start.state });
if (choice.status !== 'account_recovery') throw new Error('Expected same-owner recovery.');
const back = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: choice.oauthSessionId,
action: 'link_existing',
});
if (back.status !== 'otp') throw new Error('Expected OTP.');
await f.caller.auth.kakaoOtp({ challengeId: back.challengeId, code: otpCode(f) });
expect((await f.users.findById(f.user.id))?.oauthId).toBe('old-self-kakao');
await expect(
f.users.createUser({
username: 'claim-other',
password: 'secretpass',
oauth: { type: 'KAKAO', id: 'new-self-kakao', email: 'other@example.test', info: {} },
})
).rejects.toThrow();
});
it('accepts the old password for a different email, keeps retries possible and verifies new talk ownership', async () => {
const f = await prepare({ differentEmail: true });
if (f.choice.status !== 'join') throw new Error('Expected no email match.');
await expect(
f.caller.auth.kakaoLinkWithPassword({
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('wrongpass'),
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
const otp = await f.caller.auth.kakaoLinkWithPassword({
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('originalpass'),
});
if (otp.status !== 'otp') throw new Error('Expected OTP.');
expect((await f.users.findById(f.user.id))?.email).toBe('owner@example.test');
await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) });
expect(await f.users.findById(f.user.id)).toMatchObject({
oauthId: 'new-self-kakao',
email: 'new@example.test',
});
});
it('keeps migrated passwords and old identities intact until the final OTP commit', async () => {
const f = await prepare({ resetPassword: true });
if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.');
const setup = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: f.choice.oauthSessionId,
action: 'link_existing',
});
if (setup.status !== 'password_setup') throw new Error('Expected password setup.');
const hash = f.user.passwordHash;
const otp = await f.caller.auth.kakaoSetPassword({
oauthSessionId: setup.oauthSessionId,
credential: f.sealPassword('new-valid-pass'),
});
if (otp.status !== 'otp') throw new Error('Expected OTP.');
expect(f.user.passwordHash).toBe(hash);
expect(f.user.passwordResetRequired).toBe(true);
await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) });
expect(f.user.passwordResetRequired).toBe(false);
expect(await f.users.verifyPassword(f.user, 'new-valid-pass')).toBe(true);
});
it('still requires approval for privileged accounts even with the password', async () => {
const f = await prepare({ privileged: true });
if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.');
await expect(
f.caller.auth.kakaoLinkWithPassword({
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('originalpass'),
})
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
expect(f.user.oauthId).toBe('old-self-kakao');
});
it.each(['roles', 'password', 'sanction', 'identity', 'expired', 'provider'] as const)(
'does not commit if %s changes during OTP',
async (change) => {
const f = await prepare();
if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.');
const otp = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: f.choice.oauthSessionId,
action: 'link_existing',
});
if (otp.status !== 'otp') throw new Error('Expected OTP.');
if (change === 'roles') await f.users.updateRoles(f.user.id, ['admin']);
if (change === 'password') await f.users.updatePassword(f.user.id, 'changed-elsewhere');
if (change === 'sanction')
await f.users.updateSanctions(f.user.id, {
bannedUntil: new Date(Date.now() + 86400000).toISOString(),
});
if (change === 'identity') f.user.authRevision = 1;
if (change === 'expired') {
const challenge = await f.oauthSessions.getLoginChallengeForUser(f.user.id);
const pending = await f.oauthSessions.getSession(challenge!.oauthSessionId!);
pending!.createdAt = new Date(Date.now() - 301000).toISOString();
}
if (change === 'provider') f.kakaoProfile.id = 'another-kakao';
await expect(
f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) })
).rejects.toBeDefined();
expect(f.user.oauthId).toBe('old-self-kakao');
expect(await f.users.findDetachedKakaoOwner('old-self-kakao')).toBeNull();
}
);
it('does not change any identity when sending the new talk challenge fails', async () => {
const f = await prepare();
if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.');
vi.spyOn(f.kakaoClient, 'sendTalkMessage').mockRejectedValue(new Error('synthetic provider failure'));
await expect(
f.caller.auth.kakaoResolveAccount({ oauthSessionId: f.choice.oauthSessionId, action: 'link_existing' })
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED', message: '카카오톡 인증 코드를 보내지 못했습니다.' });
expect(f.user.oauthId).toBe('old-self-kakao');
expect(await f.caller.auth.me({ sessionToken: f.old.sessionToken })).not.toBeNull();
});
it('checks password-based recovery over real HTTP before issuing the new challenge', async () => {
const f = await prepare({ differentEmail: true });
if (f.choice.status !== 'join') throw new Error('Expected join fallback.');
const app = fastify();
await app.register(fastifyTRPCPlugin, {
prefix: '/gateway/api/trpc',
trpcOptions: { router: appRouter, ...trpcJsonBodyHttpServerOptions, createContext: () => f.context },
});
try {
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const post = (name: string, input: object) =>
fetch(`${address}/gateway/api/trpc/${name}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
});
expect(
(
await post('auth.kakaoLinkWithPassword', {
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('incorrect'),
})
).status
).toBe(401);
expect(
(
await post('auth.kakaoLinkWithPassword', {
oauthSessionId: 'invalid',
username: f.user.username,
credential: f.sealPassword('originalpass'),
})
).status
).toBe(400);
const response = await post('auth.kakaoLinkWithPassword', {
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('originalpass'),
});
expect(response.status).toBe(200);
const data = (await response.json()) as { result: { data: { status: string; challengeId: string } } };
expect(data.result.data.status).toBe('otp');
expect(f.user.oauthId).toBe('old-self-kakao');
expect(
(await post('auth.kakaoOtp', { challengeId: data.result.data.challengeId, code: otpCode(f) })).status
).toBe(200);
expect(f.user.oauthId).toBe('new-self-kakao');
} finally {
await app.close();
}
});
});
@@ -104,6 +104,34 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
}
});
it('preserves the exact pending OAuth session and old identity revision in a one-use challenge', async () => {
const challenge = await store.createLoginChallenge({
userId: randomUUID(),
code: '1234',
attemptsRemaining: 3,
expiresAt: new Date(Date.now() + 180000).toISOString(),
createdAt: new Date().toISOString(),
oauthSessionId: randomUUID(),
authRevision: 7,
oauthId: 'identity-before-change',
});
userIds.add(challenge.userId);
challengeIds.add(challenge.id);
const results = await Promise.all([
store.verifyLoginChallenge(challenge.id, '1234'),
store.verifyLoginChallenge(challenge.id, '1234'),
]);
expect(results.filter((r) => r.status === 'verified')).toEqual([
{
status: 'verified',
userId: challenge.userId,
oauthSessionId: challenge.oauthSessionId,
authRevision: 7,
oauthId: 'identity-before-change',
},
]);
});
it('atomically consumes a successful code once', async () => {
const challenge = await createChallenge();
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20261022000000_add_open_requests',
gatewaySchemaHead: '20261107000000_kakao_identity_link_history',
gameSchemaHead: '20260916080000_index_play_audit_decision_month',
});
});
@@ -7,7 +7,7 @@ const operationNames = (route: Route): string[] => {
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const installFixture = async (page: Page) => {
const installFixture = async (page: Page, approved = true) => {
const requests: Array<{ operation: string; body: string }> = [];
await page.addInitScript(() => {
window.localStorage.setItem('sammo-session-token', 'kakao-replacement-session');
@@ -33,7 +33,7 @@ const installFixture = async (page: Page) => {
nextRetireAt: null,
thirdPartyUse: false,
deleteAfter: null,
kakaoReplacementApprovedUntil: new Date(Date.now() + 86_400_000).toISOString(),
kakaoReplacementApprovedUntil: approved ? new Date(Date.now() + 86_400_000).toISOString() : null,
});
}
if (operation === 'account.notifications.get') {
@@ -63,7 +63,7 @@ const installFixture = async (page: Page) => {
return requests;
};
test('starts an approved Kakao replacement with explicit permanent-retirement confirmation', async ({
test('starts a Kakao replacement with confirmation before authenticating the new account', async ({
page,
}, testInfo) => {
const requests = await installFixture(page);
@@ -88,7 +88,7 @@ test('starts an approved Kakao replacement with explicit permanent-retirement co
await button.click();
await expect(page).toHaveURL(/replacement=started/);
expect(confirmation).toContain('기존 카카오 계정은 영구 폐기');
expect(confirmation).toContain('새 카카오톡 인증을 완료하면');
const startRequest = requests.find(({ operation }) => operation === 'auth.kakaoStart');
expect(startRequest?.body).toContain('verify');
expect(startRequest?.body).toContain('kakao-replacement-session');
@@ -105,3 +105,13 @@ test('starts an approved Kakao replacement with explicit permanent-retirement co
await writeFile(testInfo.outputPath('account-kakao-replacement-mobile-geometry.json'), JSON.stringify(geometry));
await page.screenshot({ path: testInfo.outputPath('account-kakao-replacement-mobile.png'), fullPage: true });
});
test('offers ordinary users account replacement without an administrator approval', async ({ page }) => {
const requests = await installFixture(page, false);
page.on('dialog', (dialog) => dialog.accept());
await page.goto('account');
await expect(page.getByText(/교체 승인 .*까지/)).toHaveCount(0);
await page.getByRole('button', { name: '새 카카오 계정으로 교체', exact: true }).click();
await expect(page).toHaveURL(/replacement=started/);
expect(requests.some(({ operation }) => operation === 'auth.kakaoStart')).toBe(true);
});
@@ -1,4 +1,5 @@
import { generateKeyPairSync } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import { expect, test, type Page, type Route } from '@playwright/test';
@@ -172,3 +173,174 @@ for (const viewport of [
expect(geometry.right).toBeLessThanOrEqual(viewport.width);
});
}
const installSelfRecoveryFixture = async (page: Page, byPassword: boolean) => {
const calls: string[] = [];
let loggedIn = false;
let passwordAttempts = 0;
let release: (() => void) | undefined;
await page.route('**/gateway/api/trpc/**', async (route) => {
const results = [];
for (const operation of operationNames(route)) {
calls.push(operation);
let data: unknown;
if (operation === 'me')
data = loggedIn
? {
id: 'self-user',
username: 'self-user',
displayName: '복구 사용자',
roles: ['user'],
kakaoVerified: true,
}
: null;
else if (operation === 'lobby.notice') data = '';
else if (operation === 'lobby.profiles') data = [];
else if (operation === 'auth.passwordKey')
data = { keyId: 'self-link-key', publicKeyPem, algorithm: 'RSA-OAEP-256' };
else if (operation === 'auth.kakaoExchange')
data = byPassword
? {
status: 'join',
oauthSessionId: '33333333-3333-4333-8333-333333333333',
email: 'new@example.test',
}
: {
status: 'account_recovery',
action: 'link_existing',
oauthSessionId: '33333333-3333-4333-8333-333333333333',
email: 'retained@example.test',
passwordRequired: false,
};
else if (operation === 'auth.kakaoLinkWithPassword') {
const input = Object.values(
route.request().postDataJSON() as Record<
string,
{ username: string; credential: { ciphertext: string } }
>
)[0]!;
expect(input.username).toBe('self-user');
expect(input.credential.ciphertext).not.toBe('fixture-password');
passwordAttempts++;
if (passwordAttempts === 1) {
results.push({
error: {
message: '기존 계정명 또는 비밀번호가 일치하지 않습니다.',
code: -32001,
data: { code: 'UNAUTHORIZED', httpStatus: 401 },
},
});
continue;
}
await new Promise<void>((resolve) => {
release = resolve;
});
data = {
status: 'otp',
successStatus: 'login',
challengeId: '44444444-4444-4444-8444-444444444444',
expiresAt: '2099-01-01T00:00:00Z',
attemptsRemaining: 3,
};
} else if (operation === 'auth.kakaoResolveAccount')
data = {
status: 'otp',
successStatus: 'login',
challengeId: '44444444-4444-4444-8444-444444444444',
expiresAt: '2099-01-01T00:00:00Z',
attemptsRemaining: 3,
};
else if (operation === 'auth.kakaoOtp') {
loggedIn = true;
data = { status: 'login', sessionToken: 'fixture-self-recovered', validUntil: '2099-01-01T00:00:00Z' };
} else throw new Error(`Unhandled self recovery operation: ${operation}`);
results.push(response(data));
}
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(results) });
});
return {
calls,
release: () => {
expect(release).toBeDefined();
release!();
},
};
};
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 390, height: 844 },
]) {
for (const byPassword of [false, true]) {
test(`self-recovers by ${byPassword ? 'existing password' : 'matching email'} and new talk OTP on ${viewport.name}`, async ({
page,
}, testInfo) => {
await page.setViewportSize(viewport);
const f = await installSelfRecoveryFixture(page, byPassword);
await page.goto('/gateway/oauth/callback?code=fixture-code&state=fixture-state');
if (byPassword) {
await page.getByText('기존 계정 비밀번호로 연결', { exact: true }).click();
const form = page.getByRole('form', { name: '기존 비밀번호로 카카오 연결' });
await form.getByLabel('기존 계정명').fill('self-user');
await form.getByLabel('기존 비밀번호').fill('wrong-password');
const button = form.getByRole('button', { name: '비밀번호 확인 후 연결' });
await button.click();
await expect(page.getByRole('alert')).toContainText('일치하지 않습니다.');
await form.getByLabel('기존 비밀번호').fill('fixture-password');
await page.evaluate(() => document.fonts.ready);
await button.hover();
expect(await button.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(47, 77, 108)');
await page.mouse.down();
expect(await button.evaluate((el) => el.matches(':active'))).toBe(true);
await page.mouse.move(1, 1);
await page.mouse.up();
await button.focus();
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab');
await expect(button).toBeFocused();
const geometry = await form.evaluate((el) => {
const rect = el.getBoundingClientRect();
return {
left: rect.left,
right: rect.right,
width: rect.width,
height: rect.height,
viewport: innerWidth,
fontSize: getComputedStyle(el).fontSize,
html: el.outerHTML,
overflow: document.documentElement.scrollWidth - innerWidth,
};
});
expect(geometry.overflow).toBe(0);
expect(geometry.right).toBeLessThanOrEqual(viewport.width);
await writeFile(testInfo.outputPath('self-link-geometry.json'), JSON.stringify(geometry, null, 2));
await writeFile(testInfo.outputPath('self-link-panel.html'), geometry.html);
await testInfo.attach('self-link-geometry', {
body: JSON.stringify(geometry),
contentType: 'application/json',
});
await page.screenshot({ path: testInfo.outputPath('self-link-password.png'), fullPage: true });
await button.click();
await expect(form.getByRole('button')).toBeDisabled();
await expect.poll(() => f.calls.filter((call) => call === 'auth.kakaoLinkWithPassword').length).toBe(2);
f.release();
} else {
await page.getByRole('button', { name: '기존 계정에 연결', exact: true }).click();
}
const dialog = page.getByRole('dialog', { name: '인증 코드 필요' });
await expect(dialog).toBeVisible();
expect(await page.evaluate(() => localStorage.getItem('sammo-session-token'))).toBeNull();
await page.screenshot({ path: testInfo.outputPath('self-link-before-commit.png'), fullPage: true });
await dialog.locator('input').fill('1234');
await dialog.getByRole('button', { name: '제출', exact: true }).click();
await expect(page).toHaveURL(/\/gateway\/lobby$/);
expect(await page.evaluate(() => localStorage.getItem('sammo-session-token'))).toBe(
'fixture-self-recovered'
);
expect(f.calls).not.toContain('auth.register');
expect(f.calls).not.toContain('admin.users.setKakaoReplacementApproval');
await page.reload();
await expect(page).toHaveURL(/\/gateway\/lobby$/);
});
}
}
@@ -87,11 +87,6 @@ const currentProfile = computed(() =>
const notificationEventTypes = computed(
() => (notificationState.value?.eventTypes ?? []) as readonly WebPushEventType[]
);
const kakaoReplacementApproved = computed(
() =>
Boolean(account.value?.kakaoReplacementApprovedUntil) &&
new Date(account.value!.kakaoReplacementApprovedUntil!).getTime() > Date.now()
);
const sessionToken = (): string | null => window.localStorage.getItem('sammo-session-token');
@@ -321,7 +316,7 @@ const changePassword = async (): Promise<void> => {
const startKakaoReplacement = async (): Promise<void> => {
if (
!window.confirm(
'새 카카오 계정 연결이 끝나면 기존 카카오 계정은 영구 폐기되어 다시 가입하거나 연결할 수 없습니다. 계속하시겠습니까?'
'새 카카오톡 인증을 완료하면 로그인에 사용할 카카오 계정이 바뀌고 기존 세션이 종료됩니다. 계속하시겠습니까?'
)
) {
return;
@@ -667,7 +662,7 @@ onBeforeUnmount(() => {
· 교체 승인 {{ formatServerDateTime(account.kakaoReplacementApprovedUntil) }}까지
</template>
<button
v-if="kakaoReplacementApproved"
v-if="account.oauthType === 'KAKAO'"
class="skin-button compact"
type="button"
:disabled="busy"
@@ -26,12 +26,19 @@ const email = ref('');
const username = ref('');
const password = ref('');
const confirmPassword = ref('');
const linkUsername = ref('');
const linkPassword = ref('');
const passwordLinkOpen = ref(false);
const handlePasswordLinkToggle = (event: Event): void => {
if (event.currentTarget instanceof HTMLDetailsElement) passwordLinkOpen.value = event.currentTarget.open;
};
const displayName = ref('');
const termsAgreed = ref(false);
const privacyAgreed = ref(false);
const thirdPartyUse = ref(false);
const accountRecovery = ref<{
action: 'link_existing' | 'rejoin';
passwordRequired?: boolean;
oauthSessionId: string;
email: string;
} | null>(null);
@@ -102,20 +109,11 @@ const resolveAccount = async (): Promise<void> => {
action: accountRecovery.value.action,
});
accountRecovery.value = null;
if (result.status === 'deletion_pending') {
handleDeletionPending(result);
return;
}
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;
}
if (result.status === 'password_setup') {
passwordSetupSessionId.value = result.oauthSessionId;
passwordSetupSuccessStatus.value = result.successStatus;
@@ -131,6 +129,36 @@ const resolveAccount = async (): Promise<void> => {
}
};
const linkWithPassword = async (): Promise<void> => {
const sessionId = accountRecovery.value?.oauthSessionId || oauthSessionId.value;
if (!sessionId || submitting.value) return;
submitting.value = true;
errorMessage.value = '';
try {
const credential = await sealPassword(linkPassword.value);
const result = await trpc.auth.kakaoLinkWithPassword.mutate({
oauthSessionId: sessionId,
username: linkUsername.value,
credential,
});
linkPassword.value = '';
accountRecovery.value = null;
oauthSessionId.value = '';
if (result.status === 'otp') {
otpChallenge.value = result;
otpSuccessStatus.value = result.successStatus;
} else {
passwordSetupSessionId.value = result.oauthSessionId;
passwordSetupSuccessStatus.value = result.successStatus;
email.value = result.email;
}
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '기존 계정 비밀번호를 확인하지 못했습니다.';
} finally {
submitting.value = false;
}
};
const setMigratedPassword = async (): Promise<void> => {
errorMessage.value = '';
if (password.value !== confirmPassword.value) {
@@ -224,13 +252,15 @@ onMounted(() => {
<section class="oauth-card">
<h2>
{{
deletionPending
? '탈퇴 취소'
: accountRecovery
? '카카오 계정 연결 확인'
: passwordSetupSessionId
? '새 비밀번호 설정'
: '회원가입'
passwordLinkOpen && (oauthSessionId || accountRecovery)
? '기존 계정 연결'
: deletionPending
? '탈퇴 취소'
: accountRecovery
? '카카오 계정 연결 확인'
: passwordSetupSessionId
? '새 비밀번호 설정'
: '회원가입'
}}
</h2>
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
@@ -241,17 +271,32 @@ onMounted(() => {
@restored="handleOtpVerified"
@cancel="router.replace('/')"
/>
<div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인">
<p v-if="accountRecovery.action === 'link_existing'">
<div
v-else-if="accountRecovery && !passwordLinkOpen"
class="recovery-panel"
role="group"
aria-label="카카오 계정 연결 확인"
>
<p v-if="accountRecovery.action === 'link_existing' && !accountRecovery.passwordRequired">
<strong>{{ accountRecovery.email }}</strong> 이메일로 보존된 기존 계정이 있습니다. 이 계정에
카카오 로그인을 연결해드릴까요?
</p>
<p v-else-if="accountRecovery.passwordRequired">
새 카카오 이메일로 기존 연결을 확인했습니다. 이메일이 다르거나 추가 확인이 필요한 계정은
아래에서 기존 계정 비밀번호를 확인해 주세요. 비밀번호를 모르면 관리자에게 복구를 요청해 주세요.
</p>
<p v-else>
<strong>{{ accountRecovery.email }}</strong> 이메일은 카카오에 이미 가입된 연결이 있지만 이
서비스에서 연결할 계정을 찾지 못했습니다. 새 계정으로 재가입하시겠습니까?
</p>
<div class="recovery-actions">
<button class="register-button" type="button" :disabled="submitting" @click="resolveAccount">
<button
v-if="!accountRecovery.passwordRequired"
class="register-button"
type="button"
:disabled="submitting"
@click="resolveAccount"
>
{{
submitting
? '처리 중...'
@@ -303,7 +348,7 @@ onMounted(() => {
</button>
<RouterLink class="back-link" to="/">취소</RouterLink>
</form>
<form v-else-if="oauthSessionId" @submit.prevent="register">
<form v-else-if="oauthSessionId && !passwordLinkOpen" @submit.prevent="register">
<div class="form-row">
<label for="oauth-email">카카오 이메일</label>
<input id="oauth-email" :value="email" readonly />
@@ -358,6 +403,37 @@ onMounted(() => {
{{ submitting ? '가입 중...' : '가입' }}
</button>
</form>
<details
v-if="oauthSessionId || accountRecovery"
class="password-link-panel"
:open="accountRecovery?.passwordRequired || passwordLinkOpen"
@toggle="handlePasswordLinkToggle"
>
<summary>기존 계정 비밀번호로 연결</summary>
<form aria-label="기존 비밀번호로 카카오 연결" @submit.prevent="linkWithPassword">
<p>
기존 카카오 인증을 사용할 수 없어도, 기존 계정 비밀번호와 새 카카오톡 인증으로 연결할 수
있습니다.
</p>
<div class="form-row">
<label for="link-username">기존 계정명</label>
<input id="link-username" v-model="linkUsername" autocomplete="username" required />
</div>
<div class="form-row">
<label for="link-password">기존 비밀번호</label>
<input
id="link-password"
v-model="linkPassword"
type="password"
autocomplete="current-password"
required
/>
</div>
<button class="register-button" type="submit" :disabled="submitting">
{{ submitting ? '확인 중...' : '비밀번호 확인 후 연결' }}
</button>
</form>
</details>
<p v-if="errorMessage" class="oauth-error" role="alert">{{ errorMessage }}</p>
<RouterLink v-if="!loading && !oauthSessionId && !deletionPending" class="back-link" to="/"
>돌아가기</RouterLink
@@ -410,6 +486,17 @@ onMounted(() => {
padding: 18px;
}
.password-link-panel {
margin: 18px;
}
.password-link-panel summary {
cursor: pointer;
color: #6db9ff;
}
.password-link-panel p {
line-height: 1.6;
}
.recovery-panel {
padding: 18px;
text-align: center;
@@ -0,0 +1,11 @@
CREATE TABLE "kakao_identity_link_history" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"previous_oauth_id" TEXT NOT NULL,
"next_oauth_id" TEXT NOT NULL,
"authorization" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "kakao_identity_link_history_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "kakao_identity_link_history_previous_oauth_id_created_at_idx" ON "kakao_identity_link_history"("previous_oauth_id", "created_at");
CREATE INDEX "kakao_identity_link_history_user_id_created_at_idx" ON "kakao_identity_link_history"("user_id", "created_at");
+14
View File
@@ -126,6 +126,20 @@ model AppUser {
@@map("app_user")
}
// 교체 이력을 보존하며 이전 ID는 같은 서비스 계정만 다시 연결할 수 있다.
model KakaoIdentityLinkHistory {
id String @id @default(uuid())
userId String @map("user_id")
previousOauthId String @map("previous_oauth_id")
nextOauthId String @map("next_oauth_id")
authorization String
createdAt DateTime @default(now()) @map("created_at")
@@index([previousOauthId, createdAt])
@@index([userId, createdAt])
@@map("kakao_identity_link_history")
}
model RetiredKakaoIdentity {
id String @id @default(uuid())
oauthId String @unique @map("oauth_id")
+1 -1
View File
@@ -1,7 +1,7 @@
{
"formatVersion": 1,
"controllerProtocol": 2,
"gatewaySchemaHead": "20261022000000_add_open_requests",
"gatewaySchemaHead": "20261107000000_kakao_identity_link_history",
"gameSchemaHead": "20260916080000_index_play_audit_decision_month",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
}