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
+1330 -191

No files matched your search

+7 -2
View File
@@ -58,8 +58,13 @@ Gateway 자체 릴리스는 Gateway 프로세스 밖의 `release-controller`가
Kakao 계정은 OAuth callback과 일반 비밀번호 로그인 모두에서 Kakao 고유 ID와 Kakao 계정은 OAuth callback과 일반 비밀번호 로그인 모두에서 Kakao 고유 ID와
현재 인증 이메일을 다시 확인합니다. 로컬 고유 ID 연결이 없지만 영구 보존된 현재 인증 이메일을 다시 확인합니다. 로컬 고유 ID 연결이 없지만 영구 보존된
이메일 계정이 있으면 자동 로그인하지 않고 그 계정에 연결할지 묻습니다. 기존 이메일 계정이 있으면 자동 로그인하지 않고 그 계정에 연결할지 묻습니다. 기존
계정 연결을 확인하면 이전 Kakao ID와 과거 talk proof를 교체하고 새 계정 연결은 새 “나와의 채팅” 숫자 코드 인증까지 끝난 뒤에만 저장하며, 그 전에는
“나와의 채팅” 숫자 코드로 다시 증명한 뒤에만 Gateway session을 발급합니다. 이전 연결과 비밀번호를 유지합니다. 일반 계정은 카카오가 인증한 동일 이메일로
관리자 승인 없이 복구하고, 기존 서비스 비밀번호로 확인하면 이메일이 달라도
새 카카오에 연결할 수 있습니다. 관리자 계정 교체에는 승인을 유지하며 다른 계정과의
연결·이메일 충돌은 자동 처리하지 않습니다. 새 교체부터 이전 Kakao ID는 원래 계정의
복구 이력으로 보존하여 같은 계정에만 다시 연결할 수 있습니다. 기존에 영구 폐기된
ID는 계속 차단합니다.
Kakao가 `already registered`를 반환했지만 보존 이메일 계정도 없으면 재가입 Kakao가 `already registered`를 반환했지만 보존 이메일 계정도 없으면 재가입
확인 뒤 신규 가입 form을 엽니다. 이미 로컬에 연결된 stable ID의 변경 이메일이 확인 뒤 신규 가입 form을 엽니다. 이미 로컬에 연결된 stable ID의 변경 이메일이
다른 계정에 있으면 기존처럼 충돌을 거부합니다. 다른 계정에 있으면 기존처럼 충돌을 거부합니다.
@@ -103,6 +103,7 @@ const EXPENSIVE_ACTIONS = new Set([
'auth.bootstrapLocal', 'auth.bootstrapLocal',
'auth.kakaoExchange', 'auth.kakaoExchange',
'auth.kakaoResolveAccount', 'auth.kakaoResolveAccount',
'auth.kakaoLinkWithPassword',
'auth.kakaoSetPassword', 'auth.kakaoSetPassword',
'auth.register', 'auth.register',
'auth.registerLocal', '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 { randomUUID } from 'node:crypto';
import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js'; import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js';
@@ -29,6 +32,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
const usersByOauthId = new Map<string, UserRecord>(); const usersByOauthId = new Map<string, UserRecord>();
const usersByEmail = new Map<string, UserRecord>(); const usersByEmail = new Map<string, UserRecord>();
const retiredKakaoIds = new Set<string>(); const retiredKakaoIds = new Set<string>();
const detachedKakaoOwners = new Map<string, string>();
const iconsById = new Map<string, UserIconRecord>(); const iconsById = new Map<string, UserIconRecord>();
const specialAccessGrantsById = new Map<string, SpecialAccountAccessGrantRecord>(); const specialAccessGrantsById = new Map<string, SpecialAccountAccessGrantRecord>();
@@ -42,6 +46,62 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
).toISOString(); ).toISOString();
return { 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> { async findById(id: string): Promise<UserRecord | null> {
for (const user of usersByName.values()) { for (const user of usersByName.values()) {
if (user.id === id) { if (user.id === id) {
@@ -103,6 +163,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
if ( if (
input.oauth && input.oauth &&
(retiredKakaoIds.has(input.oauth.id) || (retiredKakaoIds.has(input.oauth.id) ||
detachedKakaoOwners.has(input.oauth.id) ||
usersByOauthId.has(`${input.oauth.type}:${input.oauth.id}`) || usersByOauthId.has(`${input.oauth.type}:${input.oauth.id}`) ||
usersByEmail.has(input.oauth.email.toLowerCase())) usersByEmail.has(input.oauth.email.toLowerCase()))
) { ) {
@@ -204,9 +265,19 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
} }
throw new Error('User not found.'); 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()) { for (const user of usersByName.values()) {
if (user.id === userId) { 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(); user.kakaoTalkVerifiedUntil = validUntil.toISOString();
return user; return user;
} }
@@ -305,6 +376,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
!user.kakaoReplacementApprovedByUserId || !user.kakaoReplacementApprovedByUserId ||
!user.kakaoReplacementReason || !user.kakaoReplacementReason ||
retiredKakaoIds.has(input.oauthId) || retiredKakaoIds.has(input.oauthId) ||
(detachedKakaoOwners.has(input.oauthId) && detachedKakaoOwners.get(input.oauthId) !== userId) ||
(oauthOwner && oauthOwner.id !== userId) || (oauthOwner && oauthOwner.id !== userId) ||
(emailOwner && emailOwner.id !== userId) (emailOwner && emailOwner.id !== userId)
) { ) {
@@ -194,16 +194,17 @@ export const requireKakaoTalkProof = async (options: {
kakaoClient: KakaoOAuthClient; kakaoClient: KakaoOAuthClient;
oauthSessions: OAuthSessionStore; oauthSessions: OAuthSessionStore;
publicBaseUrl: string; publicBaseUrl: string;
oauthSessionId?: string;
now?: Date; now?: Date;
}): Promise<KakaoOtpRequired | null> => { }): Promise<KakaoOtpRequired | null> => {
const now = options.now ?? new Date(); const now = options.now ?? new Date();
const validUntil = parseDate(options.user.kakaoTalkVerifiedUntil); const validUntil = parseDate(options.user.kakaoTalkVerifiedUntil);
if (validUntil && isAfter(validUntil, now)) { if (!options.oauthSessionId && validUntil && isAfter(validUntil, now)) {
return null; return null;
} }
const active = await options.oauthSessions.getLoginChallengeForUser(options.user.id); const active = await options.oauthSessions.getLoginChallengeForUser(options.user.id);
if (active) { if (active && active.oauthSessionId === options.oauthSessionId) {
return { return {
status: 'otp', status: 'otp',
challengeId: active.id, challengeId: active.id,
@@ -215,6 +216,9 @@ export const requireKakaoTalkProof = async (options: {
const expiresAt = addSeconds(now, KAKAO_OTP_TTL_SECONDS); const expiresAt = addSeconds(now, KAKAO_OTP_TTL_SECONDS);
const challenge = await options.oauthSessions.createLoginChallenge({ const challenge = await options.oauthSessions.createLoginChallenge({
userId: options.user.id, 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)), code: String(randomInt(1000, 10_000)),
attemptsRemaining: KAKAO_OTP_ATTEMPTS, attemptsRemaining: KAKAO_OTP_ATTEMPTS,
expiresAt: expiresAt.toISOString(), expiresAt: expiresAt.toISOString(),
@@ -246,7 +250,7 @@ export const verifyKakaoTalkChallenge = async (options: {
oauthSessions: OAuthSessionStore; oauthSessions: OAuthSessionStore;
users: UserRepository; users: UserRepository;
now?: Date; now?: Date;
}): Promise<{ user: UserRecord; validUntil: string }> => { }): Promise<{ user: UserRecord; validUntil: string; pendingLinkSessionId?: string }> => {
const now = options.now ?? new Date(); const now = options.now ?? new Date();
const result = await options.oauthSessions.verifyLoginChallenge(options.challengeId, options.code, now); const result = await options.oauthSessions.verifyLoginChallenge(options.challengeId, options.code, now);
if (result.status === 'expired') { if (result.status === 'expired') {
@@ -268,11 +272,27 @@ export const verifyKakaoTalkChallenge = async (options: {
} }
const user = await options.users.findById(result.userId); 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', '카카오 계정 연결 정보를 찾지 못했습니다.'); 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 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() }; 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 { randomUUID } from 'node:crypto';
import { parseJson } from '@sammo-ts/common'; 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'; export type OAuthMode = 'login' | 'change_pw' | 'verify';
@@ -22,7 +24,12 @@ export interface OAuthPendingState {
export interface OAuthSession { export interface OAuthSession {
id: string; id: string;
mode: OAuthMode; 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; targetUserId?: string;
kakaoId: string; kakaoId: string;
email: string; email: string;
@@ -34,6 +41,9 @@ export interface OAuthSession {
} }
export interface KakaoLoginChallenge { export interface KakaoLoginChallenge {
authRevision?: number;
oauthId?: string;
oauthSessionId?: string;
id: string; id: string;
userId: string; userId: string;
code: string; code: string;
@@ -43,7 +53,7 @@ export interface KakaoLoginChallenge {
} }
export type KakaoLoginChallengeResult = export type KakaoLoginChallengeResult =
| { status: 'verified'; userId: string } | { status: 'verified'; userId: string; oauthSessionId?: string; authRevision?: number; oauthId?: string }
| { status: 'mismatch'; attemptsRemaining: number; expiresAt: string } | { status: 'mismatch'; attemptsRemaining: number; expiresAt: string }
| { status: 'locked'; expiresAt: string } | { status: 'locked'; expiresAt: string }
| { status: 'expired' }; | { status: 'expired' };
@@ -55,6 +65,7 @@ export interface OAuthSessionStore {
consumePendingState(state: string): Promise<OAuthPendingState | null>; consumePendingState(state: string): Promise<OAuthPendingState | null>;
createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession>; createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession>;
consumeSession(sessionId: string): Promise<OAuthSession | null>; consumeSession(sessionId: string): Promise<OAuthSession | null>;
getSession(sessionId: string): Promise<OAuthSession | null>;
getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null>; getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null>;
createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge>; createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge>;
verifyLoginChallenge(challengeId: string, code: string, now?: Date): Promise<KakaoLoginChallengeResult>; 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 if redis.call('GET', userKey) == challenge.id then
redis.call('DEL', userKey) redis.call('DEL', userKey)
end 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 = ` const consumeOnceScript = `
@@ -188,6 +199,11 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
return stored; 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> { async consumeSession(sessionId: string): Promise<OAuthSession | null> {
const key = this.sessionKey(sessionId); const key = this.sessionKey(sessionId);
const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] }); const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] });
@@ -296,6 +312,10 @@ export class InMemoryOAuthSessionStore implements OAuthSessionStore {
return stored; return stored;
} }
async getSession(sessionId: string): Promise<OAuthSession | null> {
return this.sessions.get(sessionId) ?? null;
}
async consumeSession(sessionId: string): Promise<OAuthSession | null> { async consumeSession(sessionId: string): Promise<OAuthSession | null> {
const session = this.sessions.get(sessionId) ?? null; const session = this.sessions.get(sessionId) ?? null;
if (session) { if (session) {
@@ -357,6 +377,12 @@ export class InMemoryOAuthSessionStore implements OAuthSessionStore {
if (this.userLoginChallenges.get(challenge.userId) === challengeId) { if (this.userLoginChallenges.get(challenge.userId) === challengeId) {
this.userLoginChallenges.delete(challenge.userId); 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 { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js'; import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js';
@@ -175,6 +178,91 @@ export const createPostgresUserRepository = (
hasher: PasswordHasher = createPasswordHasher() hasher: PasswordHasher = createPasswordHasher()
): UserRepository => { ): UserRepository => {
return { 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> { async findById(id: string): Promise<UserRecord | null> {
const row = await prisma.appUser.findUnique({ const row = await prisma.appUser.findUnique({
where: { where: {
@@ -286,6 +374,12 @@ export const createPostgresUserRepository = (
if (retired) { if (retired) {
throw new Error('Kakao identity is permanently 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 }); return tx.appUser.create({ data });
}, },
{ isolationLevel: 'Serializable' } { isolationLevel: 'Serializable' }
@@ -340,9 +434,18 @@ export const createPostgresUserRepository = (
}); });
return mapUser(row); 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({ const row = await prisma.appUser.update({
where: { id: userId }, where: {
id: userId,
...(authRevision !== undefined ? { authRevision } : {}),
...(oauthId ? { oauthId } : {}),
},
data: { kakaoTalkVerifiedUntil: validUntil }, data: { kakaoTalkVerifiedUntil: validUntil },
}); });
return mapUser(row); return mapUser(row);
@@ -357,6 +460,12 @@ export const createPostgresUserRepository = (
if (retired) { if (retired) {
throw new Error('Kakao identity is permanently 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({ const row = await tx.appUser.update({
where: { id: userId }, where: { id: userId },
data: { data: {
@@ -384,6 +493,12 @@ export const createPostgresUserRepository = (
if (retired) { if (retired) {
throw new Error('Kakao identity is permanently 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({ const updated = await tx.appUser.updateMany({
where: { where: {
id: userId, id: userId,
@@ -444,13 +559,17 @@ export const createPostgresUserRepository = (
throw new Error('Kakao account replacement is not approved.'); throw new Error('Kakao account replacement is not approved.');
} }
const normalizedEmail = input.email.toLowerCase(); 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: { oauthId: input.oauthId }, select: { id: true } }),
tx.appUser.findUnique({ where: { email: normalizedEmail }, select: { id: true } }), tx.appUser.findUnique({ where: { email: normalizedEmail }, select: { id: true } }),
tx.retiredKakaoIdentity.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }), tx.retiredKakaoIdentity.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }),
tx.kakaoIdentityLinkHistory.findFirst({
where: { previousOauthId: input.oauthId, userId: { not: userId } },
}),
]); ]);
if ( if (
retired || retired ||
detached ||
(oauthOwner && oauthOwner.id !== userId) || (oauthOwner && oauthOwner.id !== userId) ||
(emailOwner && emailOwner.id !== userId) (emailOwner && emailOwner.id !== userId)
) { ) {
+21 -1
View File
@@ -1,3 +1,4 @@
import type { KakaoLinkAuthorization, KakaoLinkSnapshot } from './kakaoLinkPolicy.js';
export interface UserRecord { export interface UserRecord {
id: string; id: string;
username: 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 { export interface UserRepository {
findDetachedKakaoOwner(oauthId: string): Promise<string | null>;
completeKakaoIdentityLink(userId: string, input: CompleteKakaoLinkInput): Promise<UserRecord>;
findById(id: string): Promise<UserRecord | null>; findById(id: string): Promise<UserRecord | null>;
findByIds(ids: string[]): Promise<UserRecord[]>; findByIds(ids: string[]): Promise<UserRecord[]>;
findByUsername(username: string): Promise<UserRecord | null>; findByUsername(username: string): Promise<UserRecord | null>;
@@ -168,7 +183,12 @@ export interface UserRepository {
updatePassword(userId: string, password: string): Promise<void>; updatePassword(userId: string, password: string): Promise<void>;
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>; updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
syncKakaoIdentity(userId: string, email: string, oauthInfo: UserOAuthInfo): Promise<UserRecord>; 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( linkKakao(
userId: string, userId: string,
input: { input: {
+99 -89
View File
@@ -24,6 +24,12 @@ import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
import { isGatewaySessionCurrent } from './auth/sessionValidity.js'; import { isGatewaySessionCurrent } from './auth/sessionValidity.js';
import { GameSessionLimitError } from './auth/sessionService.js'; import { GameSessionLimitError } from './auth/sessionService.js';
import { assertLoginAllowed, canCancelDeletion, completeGatewayLogin } from './auth/deletionRecovery.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 { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
import type { GatewayApiContext } from './context.js'; import type { GatewayApiContext } from './context.js';
import { listScenarioPreviews } from './scenario/scenarioCatalog.js'; import { listScenarioPreviews } from './scenario/scenarioCatalog.js';
@@ -394,57 +400,30 @@ export const appRouter = router({
}); });
} }
const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, localUser.oauthInfo); const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, localUser.oauthInfo);
let verified: UserRecord; if (localUser.oauthType !== 'KAKAO' || localUser.oauthId !== profile.kakaoId) {
const replacingKakao = const stored = await ctx.oauthSessions.createSession({
localUser.oauthType === 'KAKAO' && mode: pending.mode,
Boolean(localUser.oauthId) && intent: 'link_existing',
localUser.oauthId !== profile.kakaoId; targetUserId: localUser.id,
if (replacingKakao) { kakaoId: profile.kakaoId,
try {
verified = await ctx.users.replaceKakaoWithApprovedIdentity(localUser.id, {
oauthId: profile.kakaoId,
email: profile.email, email: profile.email,
oauthInfo, accessToken: token.accessToken,
verifiedAt: new Date(), refreshToken: token.refreshToken,
}); accessTokenValidUntil: oauthInfo.accessTokenValidUntil!,
} catch (error) { refreshTokenValidUntil: oauthInfo.refreshTokenValidUntil,
throw new TRPCError({ createdAt: new Date().toISOString(),
code: 'PRECONDITION_FAILED',
message: '관리자의 카카오 계정 교체 승인이 없거나 만료되었습니다.',
cause: error,
}); });
return {
status: 'account_recovery' as const,
action: 'link_existing' as const,
oauthSessionId: stored.id,
email: stored.email,
passwordRequired: localUser.email?.toLowerCase() !== profile.email,
};
} }
} else if (existingById?.id !== localUser.id && localUser.oauthType !== 'KAKAO') { const verified = await ctx.users.syncKakaoIdentity(localUser.id, profile.email, oauthInfo);
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,
});
}
}
const refreshed = (await ctx.users.findById(verified.id)) ?? verified; const refreshed = (await ctx.users.findById(verified.id)) ?? verified;
await ctx.flushPublisher.publishUserFlush( await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified');
refreshed.id,
replacingKakao ? 'kakao-account-replaced' : 'kakao-verified'
);
return finishKakaoLoginOrRequestPasswordSetup(ctx, refreshed, token.accessToken, 'verified'); return finishKakaoLoginOrRequestPasswordSetup(ctx, refreshed, token.accessToken, 'verified');
} }
@@ -515,8 +494,20 @@ export const appRouter = router({
return finishKakaoLoginOrRequestPasswordSetup(ctx, synced, token.accessToken, 'login'); 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 joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
const recoveryIntent = existingByEmail const recoveryIntent = recoveryTarget
? ('link_existing' as const) ? ('link_existing' as const)
: alreadyRegisteredWithKakao : alreadyRegisteredWithKakao
? ('rejoin' as const) ? ('rejoin' as const)
@@ -524,7 +515,7 @@ export const appRouter = router({
const stored = await ctx.oauthSessions.createSession({ const stored = await ctx.oauthSessions.createSession({
mode: pending.mode, mode: pending.mode,
intent: recoveryIntent, intent: recoveryIntent,
targetUserId: existingByEmail?.id, targetUserId: recoveryTarget?.id,
kakaoId: profile.kakaoId, kakaoId: profile.kakaoId,
email: profile.email, email: profile.email,
accessToken: token.accessToken, accessToken: token.accessToken,
@@ -540,6 +531,9 @@ export const appRouter = router({
action: recoveryIntent, action: recoveryIntent,
oauthSessionId: stored.id, oauthSessionId: stored.id,
email: stored.email, email: stored.email,
passwordRequired: recoveryTarget
? recoveryTarget.email?.toLowerCase() !== profile.email
: false,
}; };
} }
@@ -557,7 +551,7 @@ export const appRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId); const oauthSession = await ctx.oauthSessions.getSession(input.oauthSessionId);
if (!oauthSession) { if (!oauthSession) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', 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') { if (input.action === 'rejoin') {
const [oauthOwner, emailOwner] = await Promise.all([ const [oauthOwner, emailOwner] = await Promise.all([
ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId), ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId),
@@ -611,7 +607,7 @@ export const appRouter = router({
ctx.users.findByEmail(oauthSession.email), ctx.users.findByEmail(oauthSession.email),
ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId), ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId),
]); ]);
if (!targetUser || emailOwner?.id !== targetUser.id) { if (!targetUser || (emailOwner && emailOwner.id !== targetUser.id)) {
throw new TRPCError({ throw new TRPCError({
code: 'CONFLICT', code: 'CONFLICT',
message: message:
@@ -626,46 +622,40 @@ export const appRouter = router({
} }
assertLoginAllowed(targetUser); assertLoginAllowed(targetUser);
const oauthInfo: UserOAuthInfo = { return beginPendingKakaoLink(ctx, targetUser, oauthSession);
accessToken: oauthSession.accessToken, }),
refreshToken: oauthSession.refreshToken, kakaoLinkWithPassword: procedure
accessTokenValidUntil: oauthSession.accessTokenValidUntil, .input(z.object({ oauthSessionId: z.string().uuid(), username: zUsername, credential: zPasswordEnvelope }))
refreshTokenValidUntil: oauthSession.refreshTokenValidUntil, .mutation(async ({ ctx, input }) => {
}; const oauth = await ctx.oauthSessions.getSession(input.oauthSessionId);
let linked: UserRecord; if (!oauth || !['register', 'rejoin', 'link_existing'].includes(oauth.intent ?? 'register'))
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) {
throw new TRPCError({ throw new TRPCError({
code: targetUser.oauthType === 'KAKAO' ? 'PRECONDITION_FAILED' : 'CONFLICT', code: 'UNAUTHORIZED',
message: message: '카카오 연결 확인이 만료되었습니다. 다시 로그인해 주세요.',
targetUser.oauthType === 'KAKAO'
? '기존 카카오 계정 교체에는 관리자 승인이 필요합니다.'
: '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.',
cause: error,
}); });
} await validatePendingKakaoProfile(ctx, oauth);
await ctx.flushPublisher.publishUserFlush( const user = await ctx.users.findByUsername(input.username);
linked.id, const password = openPassword(ctx.passwordEnvelope, input.credential);
targetUser.oauthType === 'KAKAO' ? 'kakao-account-replaced' : 'kakao-account-relinked' if (
); !user ||
return finishKakaoLoginOrRequestPasswordSetup(ctx, linked, oauthSession.accessToken, 'login'); !(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 kakaoSetPassword: procedure
.input( .input(
@@ -676,6 +666,19 @@ export const appRouter = router({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const password = openPassword(ctx.passwordEnvelope, input.credential); 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); const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId);
if (!oauthSession || oauthSession.intent !== 'password_setup' || !oauthSession.targetUserId) { if (!oauthSession || oauthSession.intent !== 'password_setup' || !oauthSession.targetUserId) {
throw new TRPCError({ throw new TRPCError({
@@ -976,6 +979,13 @@ export const appRouter = router({
oauthSessions: ctx.oauthSessions, oauthSessions: ctx.oauthSessions,
users: ctx.users, users: ctx.users,
}).catch((error: unknown) => throwKakaoVerificationError(error)); }).catch((error: unknown) => throwKakaoVerificationError(error));
if (verified.pendingLinkSessionId)
return finishPendingKakaoLink(
ctx,
verified.pendingLinkSessionId,
verified.user.id,
verified.validUntil
);
return { return {
...(await completeGatewayLogin(ctx, verified.user, 'login')), ...(await completeGatewayLogin(ctx, verified.user, 'login')),
validUntil: verified.validUntil, 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 raw: unknown = await getRawInput();
const input = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}; const input = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const resolveSubject = async (): Promise<string | undefined> => { 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')}`; return `login:${input.username.trim().toLocaleLowerCase('en-US')}`;
} }
if ( if (
@@ -2,12 +2,15 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra'; import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
import { snapshotKakaoLink } from '../src/auth/kakaoLinkPolicy.js';
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js'; import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL; const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl); const integration = describe.skipIf(!databaseUrl);
const firstUserId = 'f28aa8ce-6bd0-45e5-a127-056de53e3161'; const firstUserId = 'f28aa8ce-6bd0-45e5-a127-056de53e3161';
const secondUserId = '8f610116-64bd-4f18-bf7f-eae1737b227c'; 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 oldKakaoId = 'account-identity-old-kakao';
const newKakaoId = 'account-identity-new-kakao'; const newKakaoId = 'account-identity-new-kakao';
@@ -30,9 +33,34 @@ integration('account identity PostgreSQL transaction', () => {
db = connector.prisma; db = connector.prisma;
closeDb = () => connector.disconnect(); closeDb = () => connector.disconnect();
await db.retiredKakaoIdentity.deleteMany({ where: { oauthId: { in: [oldKakaoId, newKakaoId] } } }); 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({ await db.appUser.createMany({
data: [ 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, id: firstUserId,
loginId: 'identity-integration-one', loginId: 'identity-integration-one',
@@ -63,7 +91,10 @@ integration('account identity PostgreSQL transaction', () => {
afterAll(async () => { afterAll(async () => {
await db?.retiredKakaoIdentity.deleteMany({ where: { oauthId: { in: [oldKakaoId, newKakaoId] } } }); 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?.(); await closeDb?.();
}); });
@@ -160,4 +191,93 @@ integration('account identity PostgreSQL transaction', () => {
expect(restored?.oauthId).toBe(before?.oauthId); expect(restored?.oauthId).toBe(before?.oauthId);
expect(restored?.sanctions).toEqual(before?.sanctions); 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, flushPublisher,
userIconUpload, userIconUpload,
kakaoProfile, kakaoProfile,
kakaoClient,
sentTalkMessages, sentTalkMessages,
refreshTokenCalls, refreshTokenCalls,
sealPassword, sealPassword,
@@ -640,11 +641,17 @@ describe('gateway auth flow', () => {
}); });
setSessionHeader(register.sessionToken); setSessionHeader(register.sessionToken);
const start = await caller.auth.kakaoStart({ mode: 'verify' }); const start = await caller.auth.kakaoStart({ mode: 'verify' });
const verified = await caller.auth.kakaoExchange({ const choice = await caller.auth.kakaoExchange({
code: 'oauth-code', code: 'oauth-code',
state: start.state, 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'); expect(verified.status).toBe('otp');
if (verified.status !== 'otp') { if (verified.status !== 'otp') {
throw new Error('Expected Kakao OTP challenge.'); throw new Error('Expected Kakao OTP challenge.');
@@ -685,11 +692,6 @@ describe('gateway auth flow', () => {
}); });
emailOwner.passwordResetRequired = true; emailOwner.passwordResetRequired = true;
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000)); 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'; kakaoProfile.id = 'different-kakao-id';
const start = await caller.auth.kakaoStart({ mode: 'login' }); const start = await caller.auth.kakaoStart({ mode: 'login' });
@@ -715,15 +717,16 @@ describe('gateway auth flow', () => {
}); });
expect(passwordSet.status).toBe('otp'); expect(passwordSet.status).toBe('otp');
expect(sentTalkMessages).toHaveLength(1); 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.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
expect(await users.isKakaoIdentityRetired('original-kakao-id')).toBe(true); expect(await users.isKakaoIdentityRetired('original-kakao-id')).toBe(false);
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({ expect(await users.findDetachedKakaoOwner('original-kakao-id')).toBe(emailOwner.id);
id: emailOwner.id, expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({ id: emailOwner.id });
username: 'email-owner', expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked');
email: 'tester@example.com',
});
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-replaced');
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 () => { 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 () => { it('completes an approved administrator replacement only after fresh talk proof and keeps old-ID ownership', async () => {
const { caller, users, sessions, kakaoProfile, setSessionHeader } = buildCaller({ const f = buildCaller({ kakaoId: 'new-kakao-id', kakaoEmail: 'new-kakao@example.com' });
kakaoId: 'new-kakao-id', const user = await f.users.createUser({
kakaoEmail: 'new-kakao@example.com',
});
const user = await users.createUser({
username: 'approved-replacement-user', username: 'approved-replacement-user',
password: 'replacement-password', password: 'replacement-password',
oauth: { type: 'KAKAO', id: 'old-kakao-id', email: 'old-kakao@example.com', info: {} }, oauth: { type: 'KAKAO', id: 'old-kakao-id', email: 'old-kakao@example.com', info: {} },
}); });
const oldSession = await sessions.createSession(user); await f.users.updateRoles(user.id, ['admin']);
setSessionHeader(oldSession.sessionToken); await f.users.setKakaoReplacementApproval(user.id, {
await users.setKakaoReplacementApproval(user.id, { until: new Date(Date.now() + 60000),
until: new Date(Date.now() + 60_000), approvedByUserId: 'operator',
approvedByUserId: 'admin-user-id', reason: '본인 확인 완료',
reason: '사용자 본인 확인 완료',
}); });
const old = await f.sessions.createSession(user);
const start = await caller.auth.kakaoStart({ mode: 'verify', sessionToken: oldSession.sessionToken }); f.setSessionHeader(old.sessionToken);
const result = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state }); const start = await f.caller.auth.kakaoStart({ mode: 'verify' });
expect(result).toMatchObject({ status: 'otp', successStatus: 'verified' }); const choice = await f.caller.auth.kakaoExchange({ code: 'code', state: start.state });
expect(await users.findById(user.id)).toMatchObject({ 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', oauthId: 'new-kakao-id',
email: 'new-kakao@example.com',
kakaoReplacementApprovedUntil: undefined,
authRevision: 1, authRevision: 1,
kakaoReplacementApprovedUntil: undefined,
}); });
await expect(users.isKakaoIdentityRetired('old-kakao-id')).resolves.toBe(true); expect(await f.users.isKakaoIdentityRetired('old-kakao-id')).toBe(false);
await expect(caller.auth.me({ sessionToken: oldSession.sessionToken })).resolves.toBeNull(); expect(await f.users.findDetachedKakaoOwner('old-kakao-id')).toBe(user.id);
expect(await f.caller.auth.me({ sessionToken: old.sessionToken })).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('영구 폐기') });
}); });
it('synchronizes a changed email by stable Kakao ID during Kakao login', async () => { 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 () => { it('blocks Kakao login while a ban is active', async () => {
const { caller, users, sealPassword, setSessionHeader } = buildCaller(); const { caller, users } = buildCaller();
const register = await caller.auth.registerLocal({ await users.createUser({
username: 'kakao-banned-user', username: 'kakao-banned-user',
credential: sealPassword('kakao-banned-password'), password: 'kakao-banned-password',
displayName: '카카오제재유저', oauth: { type: 'KAKAO', id: '1', email: 'tester@example.com', info: {} },
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
}); });
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'); const stored = await users.findByUsername('kakao-banned-user');
expect(stored).not.toBeNull(); expect(stored).not.toBeNull();
if (stored) { if (stored) {
@@ -1948,3 +1940,230 @@ describe('account deletion cancellation', () => {
expect(canCancelDeletion({ ...legacy!, legacyMemberNo: undefined }, new Date('2026-11-07'))).toBe(false); 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 () => { it('atomically consumes a successful code once', async () => {
const challenge = await createChallenge(); const challenge = await createChallenge();
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20261022000000_add_open_requests', gatewaySchemaHead: '20261107000000_kakao_identity_link_history',
gameSchemaHead: '20260916080000_index_play_audit_decision_month', 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(','); 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 }> = []; const requests: Array<{ operation: string; body: string }> = [];
await page.addInitScript(() => { await page.addInitScript(() => {
window.localStorage.setItem('sammo-session-token', 'kakao-replacement-session'); window.localStorage.setItem('sammo-session-token', 'kakao-replacement-session');
@@ -33,7 +33,7 @@ const installFixture = async (page: Page) => {
nextRetireAt: null, nextRetireAt: null,
thirdPartyUse: false, thirdPartyUse: false,
deleteAfter: null, 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') { if (operation === 'account.notifications.get') {
@@ -63,7 +63,7 @@ const installFixture = async (page: Page) => {
return requests; 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, page,
}, testInfo) => { }, testInfo) => {
const requests = await installFixture(page); const requests = await installFixture(page);
@@ -88,7 +88,7 @@ test('starts an approved Kakao replacement with explicit permanent-retirement co
await button.click(); await button.click();
await expect(page).toHaveURL(/replacement=started/); await expect(page).toHaveURL(/replacement=started/);
expect(confirmation).toContain('기존 카카오 계정은 영구 폐기'); expect(confirmation).toContain('새 카카오톡 인증을 완료하면');
const startRequest = requests.find(({ operation }) => operation === 'auth.kakaoStart'); const startRequest = requests.find(({ operation }) => operation === 'auth.kakaoStart');
expect(startRequest?.body).toContain('verify'); expect(startRequest?.body).toContain('verify');
expect(startRequest?.body).toContain('kakao-replacement-session'); 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 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 }); 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 { generateKeyPairSync } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Page, type Route } from '@playwright/test';
@@ -172,3 +173,174 @@ for (const viewport of [
expect(geometry.right).toBeLessThanOrEqual(viewport.width); 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( const notificationEventTypes = computed(
() => (notificationState.value?.eventTypes ?? []) as readonly WebPushEventType[] () => (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'); const sessionToken = (): string | null => window.localStorage.getItem('sammo-session-token');
@@ -321,7 +316,7 @@ const changePassword = async (): Promise<void> => {
const startKakaoReplacement = async (): Promise<void> => { const startKakaoReplacement = async (): Promise<void> => {
if ( if (
!window.confirm( !window.confirm(
'새 카카오 계정 연결이 끝나면 기존 카카오 계정은 영구 폐기되어 다시 가입하거나 연결할 수 없습니다. 계속하시겠습니까?' '새 카카오톡 인증을 완료하면 로그인에 사용할 카카오 계정이 바뀌고 기존 세션이 종료됩니다. 계속하시겠습니까?'
) )
) { ) {
return; return;
@@ -667,7 +662,7 @@ onBeforeUnmount(() => {
· 교체 승인 {{ formatServerDateTime(account.kakaoReplacementApprovedUntil) }}까지 · 교체 승인 {{ formatServerDateTime(account.kakaoReplacementApprovedUntil) }}까지
</template> </template>
<button <button
v-if="kakaoReplacementApproved" v-if="account.oauthType === 'KAKAO'"
class="skin-button compact" class="skin-button compact"
type="button" type="button"
:disabled="busy" :disabled="busy"
@@ -26,12 +26,19 @@ const email = ref('');
const username = ref(''); const username = ref('');
const password = ref(''); const password = ref('');
const confirmPassword = 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 displayName = ref('');
const termsAgreed = ref(false); const termsAgreed = ref(false);
const privacyAgreed = ref(false); const privacyAgreed = ref(false);
const thirdPartyUse = ref(false); const thirdPartyUse = ref(false);
const accountRecovery = ref<{ const accountRecovery = ref<{
action: 'link_existing' | 'rejoin'; action: 'link_existing' | 'rejoin';
passwordRequired?: boolean;
oauthSessionId: string; oauthSessionId: string;
email: string; email: string;
} | null>(null); } | null>(null);
@@ -102,20 +109,11 @@ const resolveAccount = async (): Promise<void> => {
action: accountRecovery.value.action, action: accountRecovery.value.action,
}); });
accountRecovery.value = null; accountRecovery.value = null;
if (result.status === 'deletion_pending') {
handleDeletionPending(result);
return;
}
if (result.status === 'otp') { if (result.status === 'otp') {
otpChallenge.value = result; otpChallenge.value = result;
otpSuccessStatus.value = result.successStatus; otpSuccessStatus.value = result.successStatus;
return; return;
} }
if (result.status === 'login') {
window.localStorage.setItem('sammo-session-token', result.sessionToken);
await router.replace('/lobby');
return;
}
if (result.status === 'password_setup') { if (result.status === 'password_setup') {
passwordSetupSessionId.value = result.oauthSessionId; passwordSetupSessionId.value = result.oauthSessionId;
passwordSetupSuccessStatus.value = result.successStatus; 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> => { const setMigratedPassword = async (): Promise<void> => {
errorMessage.value = ''; errorMessage.value = '';
if (password.value !== confirmPassword.value) { if (password.value !== confirmPassword.value) {
@@ -224,7 +252,9 @@ onMounted(() => {
<section class="oauth-card"> <section class="oauth-card">
<h2> <h2>
{{ {{
deletionPending passwordLinkOpen && (oauthSessionId || accountRecovery)
? '기존 계정 연결'
: deletionPending
? '탈퇴 취소' ? '탈퇴 취소'
: accountRecovery : accountRecovery
? '카카오 계정 연결 확인' ? '카카오 계정 연결 확인'
@@ -241,17 +271,32 @@ onMounted(() => {
@restored="handleOtpVerified" @restored="handleOtpVerified"
@cancel="router.replace('/')" @cancel="router.replace('/')"
/> />
<div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인"> <div
<p v-if="accountRecovery.action === 'link_existing'"> 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> 이메일로 보존된 기존 계정이 있습니다. 이 계정에 <strong>{{ accountRecovery.email }}</strong> 이메일로 보존된 기존 계정이 있습니다. 이 계정에
카카오 로그인을 연결해드릴까요? 카카오 로그인을 연결해드릴까요?
</p> </p>
<p v-else-if="accountRecovery.passwordRequired">
새 카카오 이메일로 기존 연결을 확인했습니다. 이메일이 다르거나 추가 확인이 필요한 계정은
아래에서 기존 계정 비밀번호를 확인해 주세요. 비밀번호를 모르면 관리자에게 복구를 요청해 주세요.
</p>
<p v-else> <p v-else>
<strong>{{ accountRecovery.email }}</strong> 이메일은 카카오에 이미 가입된 연결이 있지만 이 <strong>{{ accountRecovery.email }}</strong> 이메일은 카카오에 이미 가입된 연결이 있지만 이
서비스에서 연결할 계정을 찾지 못했습니다. 새 계정으로 재가입하시겠습니까? 서비스에서 연결할 계정을 찾지 못했습니다. 새 계정으로 재가입하시겠습니까?
</p> </p>
<div class="recovery-actions"> <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 submitting
? '처리 중...' ? '처리 중...'
@@ -303,7 +348,7 @@ onMounted(() => {
</button> </button>
<RouterLink class="back-link" to="/">취소</RouterLink> <RouterLink class="back-link" to="/">취소</RouterLink>
</form> </form>
<form v-else-if="oauthSessionId" @submit.prevent="register"> <form v-else-if="oauthSessionId && !passwordLinkOpen" @submit.prevent="register">
<div class="form-row"> <div class="form-row">
<label for="oauth-email">카카오 이메일</label> <label for="oauth-email">카카오 이메일</label>
<input id="oauth-email" :value="email" readonly /> <input id="oauth-email" :value="email" readonly />
@@ -358,6 +403,37 @@ onMounted(() => {
{{ submitting ? '가입 중...' : '가입' }} {{ submitting ? '가입 중...' : '가입' }}
</button> </button>
</form> </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> <p v-if="errorMessage" class="oauth-error" role="alert">{{ errorMessage }}</p>
<RouterLink v-if="!loading && !oauthSessionId && !deletionPending" class="back-link" to="/" <RouterLink v-if="!loading && !oauthSessionId && !deletionPending" class="back-link" to="/"
>돌아가기</RouterLink >돌아가기</RouterLink
@@ -410,6 +486,17 @@ onMounted(() => {
padding: 18px; 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 { .recovery-panel {
padding: 18px; padding: 18px;
text-align: center; 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") @@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 { model RetiredKakaoIdentity {
id String @id @default(uuid()) id String @id @default(uuid())
oauthId String @unique @map("oauth_id") oauthId String @unique @map("oauth_id")
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"formatVersion": 1, "formatVersion": 1,
"controllerProtocol": 2, "controllerProtocol": 2,
"gatewaySchemaHead": "20261022000000_add_open_requests", "gatewaySchemaHead": "20261107000000_kakao_identity_link_history",
"gameSchemaHead": "20260916080000_index_play_audit_decision_month", "gameSchemaHead": "20260916080000_index_play_audit_decision_month",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
} }