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