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

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

No files matched your search

@@ -103,6 +103,7 @@ const EXPENSIVE_ACTIONS = new Set([
'auth.bootstrapLocal',
'auth.kakaoExchange',
'auth.kakaoResolveAccount',
'auth.kakaoLinkWithPassword',
'auth.kakaoSetPassword',
'auth.register',
'auth.registerLocal',
@@ -1,3 +1,6 @@
import { authorizeKakaoLink, matchesKakaoLinkSnapshot } from './kakaoLinkPolicy.js';
import { assertLoginAllowed } from './deletionRecovery.js';
import type { CompleteKakaoLinkInput } from './userRepository.js';
import { randomUUID } from 'node:crypto';
import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js';
@@ -29,6 +32,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
const usersByOauthId = new Map<string, UserRecord>();
const usersByEmail = new Map<string, UserRecord>();
const retiredKakaoIds = new Set<string>();
const detachedKakaoOwners = new Map<string, string>();
const iconsById = new Map<string, UserIconRecord>();
const specialAccessGrantsById = new Map<string, SpecialAccountAccessGrantRecord>();
@@ -42,6 +46,62 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
).toISOString();
return {
async findDetachedKakaoOwner(oauthId: string): Promise<string | null> {
return detachedKakaoOwners.get(oauthId) ?? null;
},
async completeKakaoIdentityLink(userId: string, input: CompleteKakaoLinkInput): Promise<UserRecord> {
const password = input.password ? await hasher.hash(input.password) : undefined;
const user = [...usersByName.values()].find((row) => row.id === userId);
if (!user || !matchesKakaoLinkSnapshot(user, input.expected))
throw new Error('Kakao recovery account changed.');
assertLoginAllowed(user);
if (
authorizeKakaoLink(
user,
input.email,
input.authorization === 'PASSWORD',
input.firstUserId,
input.verifiedAt
) !== input.authorization
)
throw new Error('Kakao recovery authorization changed.');
const email = input.email.toLowerCase();
const owner = usersByOauthId.get(`KAKAO:${input.oauthId}`);
const emailOwner = usersByEmail.get(email);
const detached = detachedKakaoOwners.get(input.oauthId);
if (
retiredKakaoIds.has(input.oauthId) ||
(detached && detached !== userId) ||
(owner && owner.id !== userId) ||
(emailOwner && emailOwner.id !== userId)
)
throw new Error('Kakao recovery identity ownership changed.');
if (user.passwordResetRequired && !password) throw new Error('A new password is required.');
if (user.oauthId && user.oauthId !== input.oauthId) {
detachedKakaoOwners.set(user.oauthId, userId);
usersByOauthId.delete(`KAKAO:${user.oauthId}`);
}
if (user.email) usersByEmail.delete(user.email.toLowerCase());
Object.assign(user, {
oauthType: 'KAKAO',
oauthId: input.oauthId,
email,
oauthInfo: input.oauthInfo,
kakaoVerifiedAt: input.verifiedAt.toISOString(),
kakaoTalkVerifiedUntil: input.talkVerifiedUntil.toISOString(),
authRevision: (user.authRevision ?? 0) + 1,
sessionRevokedBefore: input.verifiedAt.toISOString(),
kakaoReplacementApprovedUntil: undefined,
kakaoReplacementApprovedByUserId: undefined,
kakaoReplacementReason: undefined,
...(password
? { passwordHash: password.hash, passwordSalt: password.salt, passwordResetRequired: false }
: {}),
});
usersByOauthId.set(`KAKAO:${input.oauthId}`, user);
usersByEmail.set(email, user);
return user;
},
async findById(id: string): Promise<UserRecord | null> {
for (const user of usersByName.values()) {
if (user.id === id) {
@@ -103,6 +163,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
if (
input.oauth &&
(retiredKakaoIds.has(input.oauth.id) ||
detachedKakaoOwners.has(input.oauth.id) ||
usersByOauthId.has(`${input.oauth.type}:${input.oauth.id}`) ||
usersByEmail.has(input.oauth.email.toLowerCase()))
) {
@@ -204,9 +265,19 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
}
throw new Error('User not found.');
},
async markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord> {
async markKakaoTalkVerified(
userId: string,
validUntil: Date,
authRevision?: number,
oauthId?: string
): Promise<UserRecord> {
for (const user of usersByName.values()) {
if (user.id === userId) {
if (
(authRevision !== undefined && (user.authRevision ?? 0) !== authRevision) ||
(oauthId && user.oauthId !== oauthId)
)
throw new Error('Kakao identity changed.');
user.kakaoTalkVerifiedUntil = validUntil.toISOString();
return user;
}
@@ -305,6 +376,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
!user.kakaoReplacementApprovedByUserId ||
!user.kakaoReplacementReason ||
retiredKakaoIds.has(input.oauthId) ||
(detachedKakaoOwners.has(input.oauthId) && detachedKakaoOwners.get(input.oauthId) !== userId) ||
(oauthOwner && oauthOwner.id !== userId) ||
(emailOwner && emailOwner.id !== userId)
) {
@@ -194,16 +194,17 @@ export const requireKakaoTalkProof = async (options: {
kakaoClient: KakaoOAuthClient;
oauthSessions: OAuthSessionStore;
publicBaseUrl: string;
oauthSessionId?: string;
now?: Date;
}): Promise<KakaoOtpRequired | null> => {
const now = options.now ?? new Date();
const validUntil = parseDate(options.user.kakaoTalkVerifiedUntil);
if (validUntil && isAfter(validUntil, now)) {
if (!options.oauthSessionId && validUntil && isAfter(validUntil, now)) {
return null;
}
const active = await options.oauthSessions.getLoginChallengeForUser(options.user.id);
if (active) {
if (active && active.oauthSessionId === options.oauthSessionId) {
return {
status: 'otp',
challengeId: active.id,
@@ -215,6 +216,9 @@ export const requireKakaoTalkProof = async (options: {
const expiresAt = addSeconds(now, KAKAO_OTP_TTL_SECONDS);
const challenge = await options.oauthSessions.createLoginChallenge({
userId: options.user.id,
authRevision: options.user.authRevision ?? 0,
...(options.user.oauthId ? { oauthId: options.user.oauthId } : {}),
...(options.oauthSessionId ? { oauthSessionId: options.oauthSessionId } : {}),
code: String(randomInt(1000, 10_000)),
attemptsRemaining: KAKAO_OTP_ATTEMPTS,
expiresAt: expiresAt.toISOString(),
@@ -246,7 +250,7 @@ export const verifyKakaoTalkChallenge = async (options: {
oauthSessions: OAuthSessionStore;
users: UserRepository;
now?: Date;
}): Promise<{ user: UserRecord; validUntil: string }> => {
}): Promise<{ user: UserRecord; validUntil: string; pendingLinkSessionId?: string }> => {
const now = options.now ?? new Date();
const result = await options.oauthSessions.verifyLoginChallenge(options.challengeId, options.code, now);
if (result.status === 'expired') {
@@ -268,11 +272,27 @@ export const verifyKakaoTalkChallenge = async (options: {
}
const user = await options.users.findById(result.userId);
if (!user || user.oauthType !== 'KAKAO' || !user.oauthId) {
if (!user || (!result.oauthSessionId && (user.oauthType !== 'KAKAO' || !user.oauthId))) {
throw new KakaoVerificationError('REAUTH_REQUIRED', '카카오 계정 연결 정보를 찾지 못했습니다.');
}
if (
result.authRevision === undefined ||
result.authRevision !== (user.authRevision ?? 0) ||
(result.oauthId && result.oauthId !== user.oauthId)
)
throw new KakaoVerificationError('REAUTH_REQUIRED', '계정 인증 정보가 변경되었습니다. 다시 로그인해 주세요.');
const proofValidUntil = addDays(now, KAKAO_TALK_VERIFICATION_DAYS);
const verified = await options.users.markKakaoTalkVerified(user.id, proofValidUntil);
if (result.oauthSessionId)
return { user, validUntil: proofValidUntil.toISOString(), pendingLinkSessionId: result.oauthSessionId };
const verified = await options.users
.markKakaoTalkVerified(user.id, proofValidUntil, result.authRevision, result.oauthId)
.catch((error: unknown) => {
throw new KakaoVerificationError(
'REAUTH_REQUIRED',
'계정 인증 정보가 변경되었습니다. 다시 로그인해 주세요.',
{ cause: error }
);
});
return { user: verified, validUntil: proofValidUntil.toISOString() };
};
@@ -0,0 +1,62 @@
import { createHash } from 'node:crypto';
import { TRPCError } from '@trpc/server';
import type { UserRecord } from './userRepository.js';
export type KakaoLinkAuthorization = 'EMAIL' | 'PASSWORD' | 'ADMIN';
export interface KakaoLinkSnapshot {
authRevision: number;
oauthType: UserRecord['oauthType'];
oauthId?: string;
email?: string;
passwordFingerprint: string;
}
export const snapshotKakaoLink = (user: UserRecord): KakaoLinkSnapshot => ({
authRevision: user.authRevision ?? 0,
oauthType: user.oauthType,
oauthId: user.oauthId,
email: user.email,
passwordFingerprint: createHash('sha256').update(user.passwordHash).digest('hex'),
});
export const matchesKakaoLinkSnapshot = (user: UserRecord, expected: KakaoLinkSnapshot): boolean =>
(user.authRevision ?? 0) === expected.authRevision &&
user.oauthType === expected.oauthType &&
user.oauthId === expected.oauthId &&
user.email === expected.email &&
createHash('sha256').update(user.passwordHash).digest('hex') === expected.passwordFingerprint;
export const authorizeKakaoLink = (
user: UserRecord,
email: string,
passwordVerified: boolean,
firstUserId: string | undefined,
now = new Date()
): KakaoLinkAuthorization => {
const privileged =
user.id === firstUserId ||
user.roles.some((role) => role === 'superuser' || role === 'admin' || role.startsWith('admin.'));
const approved = Boolean(
user.kakaoReplacementApprovedByUserId &&
user.kakaoReplacementReason &&
user.kakaoReplacementApprovedUntil &&
new Date(user.kakaoReplacementApprovedUntil) > now
);
if (privileged && user.oauthType === 'KAKAO' && user.oauthId) {
if (approved) return 'ADMIN';
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '관리자 계정의 카카오 교체에는 관리자 승인이 필요합니다.',
});
}
if (passwordVerified) return 'PASSWORD';
if (privileged)
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '관리자 계정의 최초 카카오 연결은 기존 비밀번호를 확인해야 합니다.',
});
if (!privileged && user.email?.toLowerCase() === email.toLowerCase()) return 'EMAIL';
if (approved) return 'ADMIN';
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '이메일이 다른 계정은 기존 비밀번호로 확인하거나 관리자에게 복구를 요청해 주세요.',
});
};
+30 -4
View File
@@ -1,5 +1,7 @@
import { randomUUID } from 'node:crypto';
import { parseJson } from '@sammo-ts/common';
import type { KakaoLinkAuthorization, KakaoLinkSnapshot } from './kakaoLinkPolicy.js';
import type { PasswordEnvelopeInput } from './passwordEnvelope.js';
export type OAuthMode = 'login' | 'change_pw' | 'verify';
@@ -22,7 +24,12 @@ export interface OAuthPendingState {
export interface OAuthSession {
id: string;
mode: OAuthMode;
intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup';
intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup' | 'identity_link';
identityLink?: {
expected: KakaoLinkSnapshot;
authorization: KakaoLinkAuthorization;
credential?: PasswordEnvelopeInput;
};
targetUserId?: string;
kakaoId: string;
email: string;
@@ -34,6 +41,9 @@ export interface OAuthSession {
}
export interface KakaoLoginChallenge {
authRevision?: number;
oauthId?: string;
oauthSessionId?: string;
id: string;
userId: string;
code: string;
@@ -43,7 +53,7 @@ export interface KakaoLoginChallenge {
}
export type KakaoLoginChallengeResult =
| { status: 'verified'; userId: string }
| { status: 'verified'; userId: string; oauthSessionId?: string; authRevision?: number; oauthId?: string }
| { status: 'mismatch'; attemptsRemaining: number; expiresAt: string }
| { status: 'locked'; expiresAt: string }
| { status: 'expired' };
@@ -55,6 +65,7 @@ export interface OAuthSessionStore {
consumePendingState(state: string): Promise<OAuthPendingState | null>;
createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession>;
consumeSession(sessionId: string): Promise<OAuthSession | null>;
getSession(sessionId: string): Promise<OAuthSession | null>;
getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null>;
createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge>;
verifyLoginChallenge(challengeId: string, code: string, now?: Date): Promise<KakaoLoginChallengeResult>;
@@ -100,7 +111,7 @@ local userKey = ARGV[2] .. challenge.userId
if redis.call('GET', userKey) == challenge.id then
redis.call('DEL', userKey)
end
return cjson.encode({ status = 'verified', userId = challenge.userId })
return cjson.encode({ status = 'verified', userId = challenge.userId, oauthSessionId = challenge.oauthSessionId, authRevision = challenge.authRevision, oauthId = challenge.oauthId })
`;
const consumeOnceScript = `
@@ -188,6 +199,11 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
return stored;
}
async getSession(sessionId: string): Promise<OAuthSession | null> {
const raw = await this.client.get(this.sessionKey(sessionId));
return raw ? parseJson<OAuthSession>(raw) : null;
}
async consumeSession(sessionId: string): Promise<OAuthSession | null> {
const key = this.sessionKey(sessionId);
const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] });
@@ -296,6 +312,10 @@ export class InMemoryOAuthSessionStore implements OAuthSessionStore {
return stored;
}
async getSession(sessionId: string): Promise<OAuthSession | null> {
return this.sessions.get(sessionId) ?? null;
}
async consumeSession(sessionId: string): Promise<OAuthSession | null> {
const session = this.sessions.get(sessionId) ?? null;
if (session) {
@@ -357,6 +377,12 @@ export class InMemoryOAuthSessionStore implements OAuthSessionStore {
if (this.userLoginChallenges.get(challenge.userId) === challengeId) {
this.userLoginChallenges.delete(challenge.userId);
}
return { status: 'verified', userId: challenge.userId };
return {
status: 'verified',
userId: challenge.userId,
...(challenge.authRevision !== undefined ? { authRevision: challenge.authRevision } : {}),
...(challenge.oauthId ? { oauthId: challenge.oauthId } : {}),
...(challenge.oauthSessionId ? { oauthSessionId: challenge.oauthSessionId } : {}),
};
}
}
@@ -0,0 +1,144 @@
import { TRPCError } from '@trpc/server';
import type { GatewayApiContext } from '../context.js';
import type { OAuthSession } from './oauthSessionStore.js';
import type { UserRecord } from './userRepository.js';
import { assertLoginAllowed, completeGatewayLogin } from './deletionRecovery.js';
import { authorizeKakaoLink, snapshotKakaoLink } from './kakaoLinkPolicy.js';
import { KakaoVerificationError, readVerifiedKakaoProfile, requireKakaoTalkProof } from './kakaoAccountVerification.js';
import { openPassword } from './registrationInput.js';
const LINK_TTL_MS = 300_000;
const firstUserId = async (ctx: GatewayApiContext): Promise<string | undefined> =>
(await ctx.prisma.appUser.findFirst({ orderBy: { createdAt: 'asc' }, select: { id: true } }))?.id;
export const validatePendingKakaoProfile = async (ctx: GatewayApiContext, session: OAuthSession): Promise<void> => {
const createdAt = Date.parse(session.createdAt);
if (!Number.isFinite(createdAt) || Date.now() - createdAt > LINK_TTL_MS || createdAt > Date.now()) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: '카카오 연결 확인이 만료되었습니다. 카카오 로그인을 다시 진행해 주세요.',
});
}
const profile = await ctx.kakaoClient
.getMe(session.accessToken)
.then(readVerifiedKakaoProfile)
.catch((error: unknown) => {
if (error instanceof KakaoVerificationError)
throw new TRPCError({ code: 'UNAUTHORIZED', message: error.message, cause: error });
throw error;
});
if (profile.kakaoId !== session.kakaoId || profile.email !== session.email.toLowerCase()) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: '카카오 계정 정보가 변경되었습니다. 처음부터 다시 진행해 주세요.',
});
}
};
export const requestPendingKakaoOtp = async (ctx: GatewayApiContext, session: OAuthSession, user: UserRecord) => {
assertLoginAllowed(user);
const challenge = await requireKakaoTalkProof({
user,
accessToken: session.accessToken,
kakaoClient: ctx.kakaoClient,
oauthSessions: ctx.oauthSessions,
publicBaseUrl: ctx.publicBaseUrl,
oauthSessionId: session.id,
}).catch((error: unknown) => {
if (error instanceof KakaoVerificationError)
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: error.message, cause: error });
throw error;
});
if (!challenge) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR' });
return { ...challenge, successStatus: session.mode === 'verify' ? ('verified' as const) : ('login' as const) };
};
export const beginPendingKakaoLink = async (
ctx: GatewayApiContext,
user: UserRecord,
oauth: OAuthSession,
passwordVerified = false
) => {
await validatePendingKakaoProfile(ctx, oauth);
assertLoginAllowed(user);
const authorization = authorizeKakaoLink(user, oauth.email, passwordVerified, await firstUserId(ctx));
const [owner, detached] = await Promise.all([
ctx.users.findByOauthId('KAKAO', oauth.kakaoId),
ctx.users.findDetachedKakaoOwner(oauth.kakaoId),
]);
if (
(owner && owner.id !== user.id) ||
(detached && detached !== user.id) ||
(await ctx.users.isKakaoIdentityRetired(oauth.kakaoId))
) {
throw new TRPCError({
code: 'CONFLICT',
message: '다른 계정에 귀속된 카카오 계정입니다. 관리자에게 문의해 주세요.',
});
}
const session = await ctx.oauthSessions.createSession({
...oauth,
intent: 'identity_link',
targetUserId: user.id,
createdAt: new Date().toISOString(),
identityLink: { expected: snapshotKakaoLink(user), authorization },
});
if (user.passwordResetRequired) {
return {
status: 'password_setup' as const,
oauthSessionId: session.id,
email: session.email,
successStatus: session.mode === 'verify' ? ('verified' as const) : ('login' as const),
};
}
return requestPendingKakaoOtp(ctx, session, user);
};
export const finishPendingKakaoLink = async (
ctx: GatewayApiContext,
sessionId: string,
userId: string,
validUntil: string
) => {
const pending = await ctx.oauthSessions.consumeSession(sessionId);
if (!pending || pending.intent !== 'identity_link' || !pending.identityLink || pending.targetUserId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: '카카오 연결 확인이 만료되었습니다. 다시 로그인해 주세요.',
});
}
await validatePendingKakaoProfile(ctx, pending);
let user: UserRecord;
try {
user = await ctx.users.completeKakaoIdentityLink(userId, {
...pending.identityLink,
oauthId: pending.kakaoId,
email: pending.email,
oauthInfo: {
accessToken: pending.accessToken,
refreshToken: pending.refreshToken,
accessTokenValidUntil: pending.accessTokenValidUntil,
refreshTokenValidUntil: pending.refreshTokenValidUntil,
},
verifiedAt: new Date(),
talkVerifiedUntil: new Date(validUntil),
firstUserId: await firstUserId(ctx),
...(pending.identityLink.credential
? { password: openPassword(ctx.passwordEnvelope, pending.identityLink.credential) }
: {}),
});
} catch (error) {
if (error instanceof TRPCError) throw error;
throw new TRPCError({
code: 'CONFLICT',
message: '계정 연결 상태가 변경되었습니다. 카카오 로그인을 다시 진행해 주세요.',
cause: error,
});
}
try {
await ctx.flushPublisher.publishUserFlush(user.id, 'kakao-account-relinked');
} catch {
// DB의 인증 revision은 이미 증가했다. 다음 접속의 재검증을 유지하고 저장 성공을 반환한다.
}
return { ...(await completeGatewayLogin(ctx, user, 'login')), validUntil };
};
@@ -1,3 +1,6 @@
import { authorizeKakaoLink, matchesKakaoLinkSnapshot } from './kakaoLinkPolicy.js';
import { assertLoginAllowed } from './deletionRecovery.js';
import type { CompleteKakaoLinkInput } from './userRepository.js';
import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js';
@@ -175,6 +178,91 @@ export const createPostgresUserRepository = (
hasher: PasswordHasher = createPasswordHasher()
): UserRepository => {
return {
async findDetachedKakaoOwner(oauthId: string): Promise<string | null> {
const row = await prisma.kakaoIdentityLinkHistory.findFirst({
where: { previousOauthId: oauthId },
orderBy: { createdAt: 'desc' },
select: { userId: true },
});
return row?.userId ?? null;
},
async completeKakaoIdentityLink(userId: string, input: CompleteKakaoLinkInput): Promise<UserRecord> {
const password = input.password ? await hasher.hash(input.password) : undefined;
return prisma.$transaction(
async (tx) => {
const row = await tx.appUser.findUnique({ where: { id: userId } });
if (!row) throw new Error('Kakao recovery account no longer exists.');
const user = mapUser(row);
assertLoginAllowed(user);
if (!matchesKakaoLinkSnapshot(user, input.expected))
throw new Error('Kakao recovery account changed.');
const first = await tx.appUser.findFirst({ orderBy: { createdAt: 'asc' }, select: { id: true } });
if (
authorizeKakaoLink(
user,
input.email,
input.authorization === 'PASSWORD',
first?.id,
input.verifiedAt
) !== input.authorization
)
throw new Error('Kakao recovery authorization changed.');
if (user.passwordResetRequired && !password) throw new Error('A new password is required.');
const [owner, emailOwner, retired, detached] = await Promise.all([
tx.appUser.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }),
tx.appUser.findUnique({ where: { email: input.email.toLowerCase() }, select: { id: true } }),
tx.retiredKakaoIdentity.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }),
tx.kakaoIdentityLinkHistory.findFirst({
where: { previousOauthId: input.oauthId, userId: { not: userId } },
}),
]);
if (
retired ||
detached ||
(owner && owner.id !== userId) ||
(emailOwner && emailOwner.id !== userId)
)
throw new Error('Kakao recovery identity ownership changed.');
if (user.oauthId && user.oauthId !== input.oauthId) {
await tx.kakaoIdentityLinkHistory.create({
data: {
userId,
previousOauthId: user.oauthId,
nextOauthId: input.oauthId,
authorization: input.authorization,
},
});
}
// OTP까지 확인한 새 연결과 비밀번호/인증 revision을 하나의 transaction에 저장한다.
return mapUser(
await tx.appUser.update({
where: { id: userId },
data: {
oauthType: 'KAKAO',
oauthId: input.oauthId,
email: input.email.toLowerCase(),
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
kakaoVerifiedAt: input.verifiedAt,
kakaoTalkVerifiedUntil: input.talkVerifiedUntil,
authRevision: { increment: 1 },
sessionRevokedBefore: input.verifiedAt,
kakaoReplacementApprovedUntil: null,
kakaoReplacementApprovedByUserId: null,
kakaoReplacementReason: null,
...(password
? {
passwordHash: password.hash,
passwordSalt: password.salt,
passwordResetRequired: false,
}
: {}),
},
})
);
},
{ isolationLevel: 'Serializable' }
);
},
async findById(id: string): Promise<UserRecord | null> {
const row = await prisma.appUser.findUnique({
where: {
@@ -286,6 +374,12 @@ export const createPostgresUserRepository = (
if (retired) {
throw new Error('Kakao identity is permanently retired.');
}
if (
await tx.kakaoIdentityLinkHistory.findFirst({
where: { previousOauthId: input.oauth!.id },
})
)
throw new Error('Kakao identity belongs to a former service account.');
return tx.appUser.create({ data });
},
{ isolationLevel: 'Serializable' }
@@ -340,9 +434,18 @@ export const createPostgresUserRepository = (
});
return mapUser(row);
},
async markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord> {
async markKakaoTalkVerified(
userId: string,
validUntil: Date,
authRevision?: number,
oauthId?: string
): Promise<UserRecord> {
const row = await prisma.appUser.update({
where: { id: userId },
where: {
id: userId,
...(authRevision !== undefined ? { authRevision } : {}),
...(oauthId ? { oauthId } : {}),
},
data: { kakaoTalkVerifiedUntil: validUntil },
});
return mapUser(row);
@@ -357,6 +460,12 @@ export const createPostgresUserRepository = (
if (retired) {
throw new Error('Kakao identity is permanently retired.');
}
if (
await tx.kakaoIdentityLinkHistory.findFirst({
where: { previousOauthId: input.oauthId, userId: { not: userId } },
})
)
throw new Error('Kakao identity belongs to another service account.');
const row = await tx.appUser.update({
where: { id: userId },
data: {
@@ -384,6 +493,12 @@ export const createPostgresUserRepository = (
if (retired) {
throw new Error('Kakao identity is permanently retired.');
}
if (
await tx.kakaoIdentityLinkHistory.findFirst({
where: { previousOauthId: input.oauthId, userId: { not: userId } },
})
)
throw new Error('Kakao identity belongs to another service account.');
const updated = await tx.appUser.updateMany({
where: {
id: userId,
@@ -444,13 +559,17 @@ export const createPostgresUserRepository = (
throw new Error('Kakao account replacement is not approved.');
}
const normalizedEmail = input.email.toLowerCase();
const [oauthOwner, emailOwner, retired] = await Promise.all([
const [oauthOwner, emailOwner, retired, detached] = await Promise.all([
tx.appUser.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }),
tx.appUser.findUnique({ where: { email: normalizedEmail }, select: { id: true } }),
tx.retiredKakaoIdentity.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }),
tx.kakaoIdentityLinkHistory.findFirst({
where: { previousOauthId: input.oauthId, userId: { not: userId } },
}),
]);
if (
retired ||
detached ||
(oauthOwner && oauthOwner.id !== userId) ||
(emailOwner && emailOwner.id !== userId)
) {
+21 -1
View File
@@ -1,3 +1,4 @@
import type { KakaoLinkAuthorization, KakaoLinkSnapshot } from './kakaoLinkPolicy.js';
export interface UserRecord {
id: string;
username: string;
@@ -155,7 +156,21 @@ export interface CreateUserInput {
};
}
export interface CompleteKakaoLinkInput {
firstUserId?: string;
expected: KakaoLinkSnapshot;
authorization: KakaoLinkAuthorization;
oauthId: string;
email: string;
oauthInfo: UserOAuthInfo;
verifiedAt: Date;
talkVerifiedUntil: Date;
password?: string;
}
export interface UserRepository {
findDetachedKakaoOwner(oauthId: string): Promise<string | null>;
completeKakaoIdentityLink(userId: string, input: CompleteKakaoLinkInput): Promise<UserRecord>;
findById(id: string): Promise<UserRecord | null>;
findByIds(ids: string[]): Promise<UserRecord[]>;
findByUsername(username: string): Promise<UserRecord | null>;
@@ -168,7 +183,12 @@ export interface UserRepository {
updatePassword(userId: string, password: string): Promise<void>;
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
syncKakaoIdentity(userId: string, email: string, oauthInfo: UserOAuthInfo): Promise<UserRecord>;
markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord>;
markKakaoTalkVerified(
userId: string,
validUntil: Date,
authRevision?: number,
oauthId?: string
): Promise<UserRecord>;
linkKakao(
userId: string,
input: {
+101 -91
View File
@@ -24,6 +24,12 @@ import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
import { isGatewaySessionCurrent } from './auth/sessionValidity.js';
import { GameSessionLimitError } from './auth/sessionService.js';
import { assertLoginAllowed, canCancelDeletion, completeGatewayLogin } from './auth/deletionRecovery.js';
import {
beginPendingKakaoLink,
finishPendingKakaoLink,
requestPendingKakaoOtp,
validatePendingKakaoProfile,
} from './auth/pendingKakaoLink.js';
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
import type { GatewayApiContext } from './context.js';
import { listScenarioPreviews } from './scenario/scenarioCatalog.js';
@@ -394,57 +400,30 @@ export const appRouter = router({
});
}
const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, localUser.oauthInfo);
let verified: UserRecord;
const replacingKakao =
localUser.oauthType === 'KAKAO' &&
Boolean(localUser.oauthId) &&
localUser.oauthId !== profile.kakaoId;
if (replacingKakao) {
try {
verified = await ctx.users.replaceKakaoWithApprovedIdentity(localUser.id, {
oauthId: profile.kakaoId,
email: profile.email,
oauthInfo,
verifiedAt: new Date(),
});
} catch (error) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: '관리자의 카카오 계정 교체 승인이 없거나 만료되었습니다.',
cause: error,
});
}
} else if (existingById?.id !== localUser.id && localUser.oauthType !== 'KAKAO') {
try {
verified = await ctx.users.linkKakao(localUser.id, {
oauthId: profile.kakaoId,
email: profile.email,
oauthInfo,
verifiedAt: new Date(),
});
} catch (error) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 계정에 연결된 카카오 계정입니다.',
cause: error,
});
}
} else {
try {
verified = await ctx.users.syncKakaoIdentity(localUser.id, profile.email, oauthInfo);
} catch (error) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.',
cause: error,
});
}
if (localUser.oauthType !== 'KAKAO' || localUser.oauthId !== profile.kakaoId) {
const stored = await ctx.oauthSessions.createSession({
mode: pending.mode,
intent: 'link_existing',
targetUserId: localUser.id,
kakaoId: profile.kakaoId,
email: profile.email,
accessToken: token.accessToken,
refreshToken: token.refreshToken,
accessTokenValidUntil: oauthInfo.accessTokenValidUntil!,
refreshTokenValidUntil: oauthInfo.refreshTokenValidUntil,
createdAt: new Date().toISOString(),
});
return {
status: 'account_recovery' as const,
action: 'link_existing' as const,
oauthSessionId: stored.id,
email: stored.email,
passwordRequired: localUser.email?.toLowerCase() !== profile.email,
};
}
const verified = await ctx.users.syncKakaoIdentity(localUser.id, profile.email, oauthInfo);
const refreshed = (await ctx.users.findById(verified.id)) ?? verified;
await ctx.flushPublisher.publishUserFlush(
refreshed.id,
replacingKakao ? 'kakao-account-replaced' : 'kakao-verified'
);
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified');
return finishKakaoLoginOrRequestPasswordSetup(ctx, refreshed, token.accessToken, 'verified');
}
@@ -515,8 +494,20 @@ export const appRouter = router({
return finishKakaoLoginOrRequestPasswordSetup(ctx, synced, token.accessToken, 'login');
}
const detachedOwnerId = await ctx.users.findDetachedKakaoOwner(profile.kakaoId);
const detachedOwner = detachedOwnerId ? await ctx.users.findById(detachedOwnerId) : null;
if (
detachedOwnerId &&
(!detachedOwner || (existingByEmail && existingByEmail.id !== detachedOwnerId))
) {
throw new TRPCError({
code: 'CONFLICT',
message: '이전 계정에 귀속된 카카오 계정입니다. 관리자에게 복구를 요청해 주세요.',
});
}
const recoveryTarget = detachedOwner ?? existingByEmail;
const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
const recoveryIntent = existingByEmail
const recoveryIntent = recoveryTarget
? ('link_existing' as const)
: alreadyRegisteredWithKakao
? ('rejoin' as const)
@@ -524,7 +515,7 @@ export const appRouter = router({
const stored = await ctx.oauthSessions.createSession({
mode: pending.mode,
intent: recoveryIntent,
targetUserId: existingByEmail?.id,
targetUserId: recoveryTarget?.id,
kakaoId: profile.kakaoId,
email: profile.email,
accessToken: token.accessToken,
@@ -540,6 +531,9 @@ export const appRouter = router({
action: recoveryIntent,
oauthSessionId: stored.id,
email: stored.email,
passwordRequired: recoveryTarget
? recoveryTarget.email?.toLowerCase() !== profile.email
: false,
};
}
@@ -557,7 +551,7 @@ export const appRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId);
const oauthSession = await ctx.oauthSessions.getSession(input.oauthSessionId);
if (!oauthSession) {
throw new TRPCError({
code: 'UNAUTHORIZED',
@@ -571,6 +565,8 @@ export const appRouter = router({
});
}
if (!(await ctx.oauthSessions.consumeSession(input.oauthSessionId)))
throw new TRPCError({ code: 'UNAUTHORIZED', message: '카카오 계정 확인이 이미 사용되었습니다.' });
if (input.action === 'rejoin') {
const [oauthOwner, emailOwner] = await Promise.all([
ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId),
@@ -611,7 +607,7 @@ export const appRouter = router({
ctx.users.findByEmail(oauthSession.email),
ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId),
]);
if (!targetUser || emailOwner?.id !== targetUser.id) {
if (!targetUser || (emailOwner && emailOwner.id !== targetUser.id)) {
throw new TRPCError({
code: 'CONFLICT',
message:
@@ -626,46 +622,40 @@ export const appRouter = router({
}
assertLoginAllowed(targetUser);
const oauthInfo: UserOAuthInfo = {
accessToken: oauthSession.accessToken,
refreshToken: oauthSession.refreshToken,
accessTokenValidUntil: oauthSession.accessTokenValidUntil,
refreshTokenValidUntil: oauthSession.refreshTokenValidUntil,
};
let linked: UserRecord;
try {
const replacement =
targetUser.oauthType === 'KAKAO' &&
Boolean(targetUser.oauthId) &&
targetUser.oauthId !== oauthSession.kakaoId;
linked = replacement
? await ctx.users.replaceKakaoWithApprovedIdentity(targetUser.id, {
oauthId: oauthSession.kakaoId,
email: oauthSession.email,
oauthInfo,
verifiedAt: new Date(),
})
: await ctx.users.relinkKakaoByEmail(targetUser.id, {
oauthId: oauthSession.kakaoId,
email: oauthSession.email,
oauthInfo,
verifiedAt: new Date(),
});
} catch (error) {
return beginPendingKakaoLink(ctx, targetUser, oauthSession);
}),
kakaoLinkWithPassword: procedure
.input(z.object({ oauthSessionId: z.string().uuid(), username: zUsername, credential: zPasswordEnvelope }))
.mutation(async ({ ctx, input }) => {
const oauth = await ctx.oauthSessions.getSession(input.oauthSessionId);
if (!oauth || !['register', 'rejoin', 'link_existing'].includes(oauth.intent ?? 'register'))
throw new TRPCError({
code: targetUser.oauthType === 'KAKAO' ? 'PRECONDITION_FAILED' : 'CONFLICT',
message:
targetUser.oauthType === 'KAKAO'
? '기존 카카오 계정 교체에는 관리자 승인이 필요합니다.'
: '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.',
cause: error,
code: 'UNAUTHORIZED',
message: '카카오 연결 확인이 만료되었습니다. 다시 로그인해 주세요.',
});
}
await ctx.flushPublisher.publishUserFlush(
linked.id,
targetUser.oauthType === 'KAKAO' ? 'kakao-account-replaced' : 'kakao-account-relinked'
);
return finishKakaoLoginOrRequestPasswordSetup(ctx, linked, oauthSession.accessToken, 'login');
await validatePendingKakaoProfile(ctx, oauth);
const user = await ctx.users.findByUsername(input.username);
const password = openPassword(ctx.passwordEnvelope, input.credential);
if (
!user ||
!(await ctx.users.verifyPassword(user, password)) ||
(oauth.targetUserId && oauth.targetUserId !== user.id)
)
throw new TRPCError({
code: 'UNAUTHORIZED',
message: '기존 계정명 또는 비밀번호가 일치하지 않습니다.',
});
const emailOwner = await ctx.users.findByEmail(oauth.email);
if (emailOwner && emailOwner.id !== user.id)
throw new TRPCError({
code: 'CONFLICT',
message: '새 카카오 이메일이 다른 계정에 연결되어 있습니다. 관리자에게 문의해 주세요.',
});
if (!(await ctx.oauthSessions.consumeSession(oauth.id)))
throw new TRPCError({ code: 'UNAUTHORIZED', message: '카카오 연결 확인이 이미 사용되었습니다.' });
const refreshed = await ctx.users.findById(user.id);
if (!refreshed) throw new TRPCError({ code: 'UNAUTHORIZED' });
return beginPendingKakaoLink(ctx, refreshed, oauth, true);
}),
kakaoSetPassword: procedure
.input(
@@ -676,6 +666,19 @@ export const appRouter = router({
)
.mutation(async ({ ctx, input }) => {
const password = openPassword(ctx.passwordEnvelope, input.credential);
const staged = await ctx.oauthSessions.getSession(input.oauthSessionId);
if (staged?.intent === 'identity_link' && staged.identityLink && staged.targetUserId) {
await validatePendingKakaoProfile(ctx, staged);
const user = await ctx.users.findById(staged.targetUserId);
if (!user) throw new TRPCError({ code: 'UNAUTHORIZED' });
if (!(await ctx.oauthSessions.consumeSession(staged.id)))
throw new TRPCError({ code: 'UNAUTHORIZED' });
const next = await ctx.oauthSessions.createSession({
...staged,
identityLink: { ...staged.identityLink, credential: input.credential },
});
return requestPendingKakaoOtp(ctx, next, user);
}
const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId);
if (!oauthSession || oauthSession.intent !== 'password_setup' || !oauthSession.targetUserId) {
throw new TRPCError({
@@ -976,6 +979,13 @@ export const appRouter = router({
oauthSessions: ctx.oauthSessions,
users: ctx.users,
}).catch((error: unknown) => throwKakaoVerificationError(error));
if (verified.pendingLinkSessionId)
return finishPendingKakaoLink(
ctx,
verified.pendingLinkSessionId,
verified.user.id,
verified.validUntil
);
return {
...(await completeGatewayLogin(ctx, verified.user, 'login')),
validUntil: verified.validUntil,
+5 -1
View File
@@ -12,7 +12,11 @@ export const procedure = t.procedure.use(async ({ ctx, path, getRawInput, next }
const raw: unknown = await getRawInput();
const input = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const resolveSubject = async (): Promise<string | undefined> => {
if (path === 'auth.login' && typeof input.username === 'string' && input.username.length <= 64) {
if (
(path === 'auth.login' || path === 'auth.kakaoLinkWithPassword') &&
typeof input.username === 'string' &&
input.username.length <= 64
) {
return `login:${input.username.trim().toLocaleLowerCase('en-US')}`;
}
if (
@@ -2,12 +2,15 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
import { snapshotKakaoLink } from '../src/auth/kakaoLinkPolicy.js';
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const firstUserId = 'f28aa8ce-6bd0-45e5-a127-056de53e3161';
const secondUserId = '8f610116-64bd-4f18-bf7f-eae1737b227c';
const bootstrapId = '00000000-0000-4000-8000-000000000042';
const selfUserId = '00000000-0000-4000-8000-000000000043';
const oldKakaoId = 'account-identity-old-kakao';
const newKakaoId = 'account-identity-new-kakao';
@@ -30,9 +33,34 @@ integration('account identity PostgreSQL transaction', () => {
db = connector.prisma;
closeDb = () => connector.disconnect();
await db.retiredKakaoIdentity.deleteMany({ where: { oauthId: { in: [oldKakaoId, newKakaoId] } } });
await db.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId] } } });
await db.kakaoIdentityLinkHistory.deleteMany({
where: { userId: { in: [firstUserId, secondUserId, selfUserId] } },
});
await db.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId, bootstrapId, selfUserId] } } });
await db.appUser.createMany({
data: [
{
id: bootstrapId,
loginId: 'identity-bootstrap',
displayName: '복구통합관리자',
passwordHash: 'not-used',
passwordSalt: 'not-used',
roles: ['superuser'],
sanctions: {},
createdAt: new Date('2000-01-01'),
},
{
id: selfUserId,
loginId: 'identity-self',
displayName: '직접복구계정',
passwordHash: 'not-used',
passwordSalt: 'not-used',
roles: ['user'],
sanctions: {},
oauthType: 'KAKAO',
oauthId: 'self-db-old',
email: 'self-db@example.test',
},
{
id: firstUserId,
loginId: 'identity-integration-one',
@@ -63,7 +91,10 @@ integration('account identity PostgreSQL transaction', () => {
afterAll(async () => {
await db?.retiredKakaoIdentity.deleteMany({ where: { oauthId: { in: [oldKakaoId, newKakaoId] } } });
await db?.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId] } } });
await db?.kakaoIdentityLinkHistory.deleteMany({
where: { userId: { in: [firstUserId, secondUserId, selfUserId] } },
});
await db?.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId, bootstrapId, selfUserId] } } });
await closeDb?.();
});
@@ -160,4 +191,93 @@ integration('account identity PostgreSQL transaction', () => {
expect(restored?.oauthId).toBe(before?.oauthId);
expect(restored?.sanctions).toEqual(before?.sanctions);
});
it('commits identity, imported password, proof and history together, and restricts former identities to their owner', async () => {
const users = createPostgresUserRepository(db);
await db.appUser.update({ where: { id: selfUserId }, data: { passwordResetRequired: true } });
const before = await users.findById(selfUserId);
const now = new Date();
const input = {
expected: snapshotKakaoLink(before!),
authorization: 'EMAIL' as const,
oauthId: 'self-db-new',
email: 'self-db@example.test',
oauthInfo: {},
verifiedAt: now,
talkVerifiedUntil: new Date(now.getTime() + 86400000),
password: 'new-self-password',
};
const restored = await users.completeKakaoIdentityLink(selfUserId, input);
expect(restored).toMatchObject({
oauthId: 'self-db-new',
passwordResetRequired: false,
authRevision: (before?.authRevision ?? 0) + 1,
});
expect(await users.verifyPassword(restored, 'new-self-password')).toBe(true);
expect(await users.findDetachedKakaoOwner('self-db-old')).toBe(selfUserId);
expect(await users.isKakaoIdentityRetired('self-db-old')).toBe(false);
expect(await db.kakaoIdentityLinkHistory.count({ where: { userId: selfUserId } })).toBe(1);
await expect(users.completeKakaoIdentityLink(selfUserId, input)).rejects.toThrow();
await expect(
users.createUser({
username: 'identity-history-claim',
password: 'secretpass',
oauth: { type: 'KAKAO', id: 'self-db-old', email: 'claim@example.test', info: {} },
})
).rejects.toThrow();
const prior = await users.findById(selfUserId);
await users.completeKakaoIdentityLink(selfUserId, {
...input,
expected: snapshotKakaoLink(prior!),
oauthId: 'self-db-old',
});
expect((await users.findById(selfUserId))?.oauthId).toBe('self-db-old');
const snapshot = await users.findById(selfUserId);
const conflicting = await users.findById(secondUserId);
await expect(
users.completeKakaoIdentityLink(selfUserId, {
...input,
expected: snapshotKakaoLink(snapshot!),
oauthId: conflicting!.oauthId!,
password: 'must-not-be-saved',
})
).rejects.toThrow();
expect((await users.findById(selfUserId))?.passwordHash).toBe(snapshot?.passwordHash);
expect(await db.kakaoIdentityLinkHistory.count({ where: { userId: selfUserId } })).toBe(2);
await expect(
users.markKakaoTalkVerified(selfUserId, new Date(Date.now() + 10000), 0, 'self-db-old')
).rejects.toThrow();
});
it('allows only one concurrent recovery against the same PostgreSQL snapshot', async () => {
const users = createPostgresUserRepository(db);
const user = await users.findById(selfUserId);
const input = {
expected: snapshotKakaoLink(user!),
authorization: 'EMAIL' as const,
email: 'self-db@example.test',
oauthInfo: {},
verifiedAt: new Date(),
talkVerifiedUntil: new Date(Date.now() + 86400000),
};
const results = await Promise.allSettled(
['self-db-race-one', 'self-db-race-two'].map((oauthId) =>
users.completeKakaoIdentityLink(selfUserId, { ...input, oauthId })
)
);
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1);
expect((await users.findById(selfUserId))?.authRevision).toBe((user?.authRevision ?? 0) + 1);
});
it('keeps former identity ownership after the service account is deleted', async () => {
const users = createPostgresUserRepository(db);
await users.deleteUser(selfUserId);
expect(await users.findDetachedKakaoOwner('self-db-old')).toBe(selfUserId);
await expect(
users.createUser({
username: 'deleted-owner-claim',
password: 'secretpass',
oauth: { type: 'KAKAO', id: 'self-db-old', email: 'deleted-owner-claim@example.test', info: {} },
})
).rejects.toThrow();
});
});
+275 -56
View File
@@ -269,6 +269,7 @@ const buildCaller = (
flushPublisher,
userIconUpload,
kakaoProfile,
kakaoClient,
sentTalkMessages,
refreshTokenCalls,
sealPassword,
@@ -640,11 +641,17 @@ describe('gateway auth flow', () => {
});
setSessionHeader(register.sessionToken);
const start = await caller.auth.kakaoStart({ mode: 'verify' });
const verified = await caller.auth.kakaoExchange({
const choice = await caller.auth.kakaoExchange({
code: 'oauth-code',
state: start.state,
});
if (choice.status !== 'account_recovery') throw new Error('Expected link confirmation.');
const verified = await caller.auth.kakaoLinkWithPassword({
oauthSessionId: choice.oauthSessionId,
username: 'verify-user',
credential: sealPassword('verify-password'),
});
expect(verified.status).toBe('otp');
if (verified.status !== 'otp') {
throw new Error('Expected Kakao OTP challenge.');
@@ -685,11 +692,6 @@ describe('gateway auth flow', () => {
});
emailOwner.passwordResetRequired = true;
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
await users.setKakaoReplacementApproval(emailOwner.id, {
until: new Date(Date.now() + 60_000),
approvedByUserId: 'admin-user-id',
reason: '이메일 보존 계정의 교체 승인',
});
kakaoProfile.id = 'different-kakao-id';
const start = await caller.auth.kakaoStart({ mode: 'login' });
@@ -715,15 +717,16 @@ describe('gateway auth flow', () => {
});
expect(passwordSet.status).toBe('otp');
expect(sentTalkMessages).toHaveLength(1);
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toMatchObject({ id: emailOwner.id });
expect((await users.findById(emailOwner.id))?.passwordResetRequired).toBe(true);
if (passwordSet.status !== 'otp') throw new Error('Expected OTP.');
const code = sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1];
await caller.auth.kakaoOtp({ challengeId: passwordSet.challengeId, code: code! });
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
expect(await users.isKakaoIdentityRetired('original-kakao-id')).toBe(true);
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({
id: emailOwner.id,
username: 'email-owner',
email: 'tester@example.com',
});
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-replaced');
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'password-changed');
expect(await users.isKakaoIdentityRetired('original-kakao-id')).toBe(false);
expect(await users.findDetachedKakaoOwner('original-kakao-id')).toBe(emailOwner.id);
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({ id: emailOwner.id });
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked');
});
it('requires a one-time password setup before an imported Kakao account can receive a session', async () => {
@@ -896,42 +899,41 @@ describe('gateway auth flow', () => {
});
});
it('replaces an approved Kakao identity, retires the old ID, and invalidates older sessions', async () => {
const { caller, users, sessions, kakaoProfile, setSessionHeader } = buildCaller({
kakaoId: 'new-kakao-id',
kakaoEmail: 'new-kakao@example.com',
});
const user = await users.createUser({
it('completes an approved administrator replacement only after fresh talk proof and keeps old-ID ownership', async () => {
const f = buildCaller({ kakaoId: 'new-kakao-id', kakaoEmail: 'new-kakao@example.com' });
const user = await f.users.createUser({
username: 'approved-replacement-user',
password: 'replacement-password',
oauth: { type: 'KAKAO', id: 'old-kakao-id', email: 'old-kakao@example.com', info: {} },
});
const oldSession = await sessions.createSession(user);
setSessionHeader(oldSession.sessionToken);
await users.setKakaoReplacementApproval(user.id, {
until: new Date(Date.now() + 60_000),
approvedByUserId: 'admin-user-id',
reason: '사용자 본인 확인 완료',
await f.users.updateRoles(user.id, ['admin']);
await f.users.setKakaoReplacementApproval(user.id, {
until: new Date(Date.now() + 60000),
approvedByUserId: 'operator',
reason: '본인 확인 완료',
});
const start = await caller.auth.kakaoStart({ mode: 'verify', sessionToken: oldSession.sessionToken });
const result = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
expect(result).toMatchObject({ status: 'otp', successStatus: 'verified' });
expect(await users.findById(user.id)).toMatchObject({
const old = await f.sessions.createSession(user);
f.setSessionHeader(old.sessionToken);
const start = await f.caller.auth.kakaoStart({ mode: 'verify' });
const choice = await f.caller.auth.kakaoExchange({ code: 'code', state: start.state });
if (choice.status !== 'account_recovery') throw new Error('Expected confirmation.');
const otp = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: choice.oauthSessionId,
action: 'link_existing',
});
if (otp.status !== 'otp') throw new Error('Expected OTP.');
expect((await f.users.findById(user.id))?.oauthId).toBe('old-kakao-id');
expect(await f.caller.auth.me({ sessionToken: old.sessionToken })).not.toBeNull();
const code = f.sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1];
await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: code! });
expect(await f.users.findById(user.id)).toMatchObject({
oauthId: 'new-kakao-id',
email: 'new-kakao@example.com',
kakaoReplacementApprovedUntil: undefined,
authRevision: 1,
kakaoReplacementApprovedUntil: undefined,
});
await expect(users.isKakaoIdentityRetired('old-kakao-id')).resolves.toBe(true);
await expect(caller.auth.me({ sessionToken: oldSession.sessionToken })).resolves.toBeNull();
kakaoProfile.id = 'old-kakao-id';
kakaoProfile.email = 'old-kakao@example.com';
const retiredStart = await caller.auth.kakaoStart({ mode: 'login' });
await expect(
caller.auth.kakaoExchange({ code: 'oauth-code', state: retiredStart.state })
).rejects.toMatchObject({ code: 'FORBIDDEN', message: expect.stringContaining('영구 폐기') });
expect(await f.users.isKakaoIdentityRetired('old-kakao-id')).toBe(false);
expect(await f.users.findDetachedKakaoOwner('old-kakao-id')).toBe(user.id);
expect(await f.caller.auth.me({ sessionToken: old.sessionToken })).toBeNull();
});
it('synchronizes a changed email by stable Kakao ID during Kakao login', async () => {
@@ -1133,22 +1135,12 @@ describe('gateway auth flow', () => {
});
it('blocks Kakao login while a ban is active', async () => {
const { caller, users, sealPassword, setSessionHeader } = buildCaller();
const register = await caller.auth.registerLocal({
const { caller, users } = buildCaller();
await users.createUser({
username: 'kakao-banned-user',
credential: sealPassword('kakao-banned-password'),
displayName: '카카오제재유저',
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
password: 'kakao-banned-password',
oauth: { type: 'KAKAO', id: '1', email: 'tester@example.com', info: {} },
});
setSessionHeader(register.sessionToken);
const verifyStart = await caller.auth.kakaoStart({ mode: 'verify' });
await caller.auth.kakaoExchange({
code: 'oauth-code',
state: verifyStart.state,
});
const stored = await users.findByUsername('kakao-banned-user');
expect(stored).not.toBeNull();
if (stored) {
@@ -1948,3 +1940,230 @@ describe('account deletion cancellation', () => {
expect(canCancelDeletion({ ...legacy!, legacyMemberNo: undefined }, new Date('2026-11-07'))).toBe(false);
});
});
describe('staged Kakao self recovery', () => {
const prepare = async (
options: { differentEmail?: boolean; resetPassword?: boolean; privileged?: boolean } = {}
) => {
const f = buildCaller({
kakaoId: 'new-self-kakao',
kakaoEmail: options.differentEmail ? 'new@example.test' : 'owner@example.test',
});
const user = await f.users.createUser({
username: 'self-recover',
password: 'originalpass',
oauth: { type: 'KAKAO', id: 'old-self-kakao', email: 'owner@example.test', info: {} },
});
if (options.privileged) await f.users.updateRoles(user.id, ['admin']);
user.passwordResetRequired = options.resetPassword ?? false;
user.kakaoTalkVerifiedUntil = new Date(Date.now() + 86400000).toISOString();
const old = await f.sessions.createSession(user);
const start = await f.caller.auth.kakaoStart({ mode: 'login' });
const choice = await f.caller.auth.kakaoExchange({ code: 'code', state: start.state });
return { ...f, user, old, choice };
};
const otpCode = (f: { sentTalkMessages: string[] }): string =>
f.sentTalkMessages.at(-1)!.match(/인증 코드는 (\d{4})/)![1]!;
it('recovers an ordinary matching-email account without approval and only commits after fresh OTP', async () => {
const f = await prepare();
if (f.choice.status !== 'account_recovery') throw new Error('Expected confirmation.');
const oldChallenge = await f.oauthSessions.createLoginChallenge({
userId: f.user.id,
code: '1111',
attemptsRemaining: 3,
expiresAt: new Date(Date.now() + 180000).toISOString(),
createdAt: new Date().toISOString(),
authRevision: 0,
oauthId: 'old-self-kakao',
});
const otp = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: f.choice.oauthSessionId,
action: 'link_existing',
});
if (otp.status !== 'otp') throw new Error('Expected fresh OTP despite a cached proof.');
expect((await f.users.findById(f.user.id))?.oauthId).toBe('old-self-kakao');
expect(await f.caller.auth.me({ sessionToken: f.old.sessionToken })).not.toBeNull();
expect(await f.users.findDetachedKakaoOwner('old-self-kakao')).toBeNull();
await expect(f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: '0000' })).rejects.toBeDefined();
expect((await f.users.findById(f.user.id))?.oauthId).toBe('old-self-kakao');
expect((await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) })).status).toBe('login');
expect(await f.caller.auth.me({ sessionToken: f.old.sessionToken })).toBeNull();
await expect(f.caller.auth.kakaoOtp({ challengeId: oldChallenge.id, code: '1111' })).rejects.toMatchObject({
code: 'PRECONDITION_FAILED',
});
expect(await f.users.findDetachedKakaoOwner('old-self-kakao')).toBe(f.user.id);
expect(await f.users.isKakaoIdentityRetired('old-self-kakao')).toBe(false);
await expect(f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) })).rejects.toBeDefined();
f.kakaoProfile.id = 'old-self-kakao';
const start = await f.caller.auth.kakaoStart({ mode: 'login' });
const choice = await f.caller.auth.kakaoExchange({ code: 'code', state: start.state });
if (choice.status !== 'account_recovery') throw new Error('Expected same-owner recovery.');
const back = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: choice.oauthSessionId,
action: 'link_existing',
});
if (back.status !== 'otp') throw new Error('Expected OTP.');
await f.caller.auth.kakaoOtp({ challengeId: back.challengeId, code: otpCode(f) });
expect((await f.users.findById(f.user.id))?.oauthId).toBe('old-self-kakao');
await expect(
f.users.createUser({
username: 'claim-other',
password: 'secretpass',
oauth: { type: 'KAKAO', id: 'new-self-kakao', email: 'other@example.test', info: {} },
})
).rejects.toThrow();
});
it('accepts the old password for a different email, keeps retries possible and verifies new talk ownership', async () => {
const f = await prepare({ differentEmail: true });
if (f.choice.status !== 'join') throw new Error('Expected no email match.');
await expect(
f.caller.auth.kakaoLinkWithPassword({
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('wrongpass'),
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
const otp = await f.caller.auth.kakaoLinkWithPassword({
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('originalpass'),
});
if (otp.status !== 'otp') throw new Error('Expected OTP.');
expect((await f.users.findById(f.user.id))?.email).toBe('owner@example.test');
await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) });
expect(await f.users.findById(f.user.id)).toMatchObject({
oauthId: 'new-self-kakao',
email: 'new@example.test',
});
});
it('keeps migrated passwords and old identities intact until the final OTP commit', async () => {
const f = await prepare({ resetPassword: true });
if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.');
const setup = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: f.choice.oauthSessionId,
action: 'link_existing',
});
if (setup.status !== 'password_setup') throw new Error('Expected password setup.');
const hash = f.user.passwordHash;
const otp = await f.caller.auth.kakaoSetPassword({
oauthSessionId: setup.oauthSessionId,
credential: f.sealPassword('new-valid-pass'),
});
if (otp.status !== 'otp') throw new Error('Expected OTP.');
expect(f.user.passwordHash).toBe(hash);
expect(f.user.passwordResetRequired).toBe(true);
await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) });
expect(f.user.passwordResetRequired).toBe(false);
expect(await f.users.verifyPassword(f.user, 'new-valid-pass')).toBe(true);
});
it('still requires approval for privileged accounts even with the password', async () => {
const f = await prepare({ privileged: true });
if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.');
await expect(
f.caller.auth.kakaoLinkWithPassword({
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('originalpass'),
})
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
expect(f.user.oauthId).toBe('old-self-kakao');
});
it.each(['roles', 'password', 'sanction', 'identity', 'expired', 'provider'] as const)(
'does not commit if %s changes during OTP',
async (change) => {
const f = await prepare();
if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.');
const otp = await f.caller.auth.kakaoResolveAccount({
oauthSessionId: f.choice.oauthSessionId,
action: 'link_existing',
});
if (otp.status !== 'otp') throw new Error('Expected OTP.');
if (change === 'roles') await f.users.updateRoles(f.user.id, ['admin']);
if (change === 'password') await f.users.updatePassword(f.user.id, 'changed-elsewhere');
if (change === 'sanction')
await f.users.updateSanctions(f.user.id, {
bannedUntil: new Date(Date.now() + 86400000).toISOString(),
});
if (change === 'identity') f.user.authRevision = 1;
if (change === 'expired') {
const challenge = await f.oauthSessions.getLoginChallengeForUser(f.user.id);
const pending = await f.oauthSessions.getSession(challenge!.oauthSessionId!);
pending!.createdAt = new Date(Date.now() - 301000).toISOString();
}
if (change === 'provider') f.kakaoProfile.id = 'another-kakao';
await expect(
f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) })
).rejects.toBeDefined();
expect(f.user.oauthId).toBe('old-self-kakao');
expect(await f.users.findDetachedKakaoOwner('old-self-kakao')).toBeNull();
}
);
it('does not change any identity when sending the new talk challenge fails', async () => {
const f = await prepare();
if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.');
vi.spyOn(f.kakaoClient, 'sendTalkMessage').mockRejectedValue(new Error('synthetic provider failure'));
await expect(
f.caller.auth.kakaoResolveAccount({ oauthSessionId: f.choice.oauthSessionId, action: 'link_existing' })
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED', message: '카카오톡 인증 코드를 보내지 못했습니다.' });
expect(f.user.oauthId).toBe('old-self-kakao');
expect(await f.caller.auth.me({ sessionToken: f.old.sessionToken })).not.toBeNull();
});
it('checks password-based recovery over real HTTP before issuing the new challenge', async () => {
const f = await prepare({ differentEmail: true });
if (f.choice.status !== 'join') throw new Error('Expected join fallback.');
const app = fastify();
await app.register(fastifyTRPCPlugin, {
prefix: '/gateway/api/trpc',
trpcOptions: { router: appRouter, ...trpcJsonBodyHttpServerOptions, createContext: () => f.context },
});
try {
const address = await app.listen({ host: '127.0.0.1', port: 0 });
const post = (name: string, input: object) =>
fetch(`${address}/gateway/api/trpc/${name}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(input),
});
expect(
(
await post('auth.kakaoLinkWithPassword', {
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('incorrect'),
})
).status
).toBe(401);
expect(
(
await post('auth.kakaoLinkWithPassword', {
oauthSessionId: 'invalid',
username: f.user.username,
credential: f.sealPassword('originalpass'),
})
).status
).toBe(400);
const response = await post('auth.kakaoLinkWithPassword', {
oauthSessionId: f.choice.oauthSessionId,
username: f.user.username,
credential: f.sealPassword('originalpass'),
});
expect(response.status).toBe(200);
const data = (await response.json()) as { result: { data: { status: string; challengeId: string } } };
expect(data.result.data.status).toBe('otp');
expect(f.user.oauthId).toBe('old-self-kakao');
expect(
(await post('auth.kakaoOtp', { challengeId: data.result.data.challengeId, code: otpCode(f) })).status
).toBe(200);
expect(f.user.oauthId).toBe('new-self-kakao');
} finally {
await app.close();
}
});
});
@@ -104,6 +104,34 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
}
});
it('preserves the exact pending OAuth session and old identity revision in a one-use challenge', async () => {
const challenge = await store.createLoginChallenge({
userId: randomUUID(),
code: '1234',
attemptsRemaining: 3,
expiresAt: new Date(Date.now() + 180000).toISOString(),
createdAt: new Date().toISOString(),
oauthSessionId: randomUUID(),
authRevision: 7,
oauthId: 'identity-before-change',
});
userIds.add(challenge.userId);
challengeIds.add(challenge.id);
const results = await Promise.all([
store.verifyLoginChallenge(challenge.id, '1234'),
store.verifyLoginChallenge(challenge.id, '1234'),
]);
expect(results.filter((r) => r.status === 'verified')).toEqual([
{
status: 'verified',
userId: challenge.userId,
oauthSessionId: challenge.oauthSessionId,
authRevision: 7,
oauthId: 'identity-before-change',
},
]);
});
it('atomically consumes a successful code once', async () => {
const challenge = await createChallenge();
+1 -1
View File
@@ -38,7 +38,7 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20261022000000_add_open_requests',
gatewaySchemaHead: '20261107000000_kakao_identity_link_history',
gameSchemaHead: '20260916080000_index_play_audit_decision_month',
});
});