feat: 카카오 계정의 본인 복구와 인증 후 연결 교체를 추가한다
This commit is contained in:
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: '이메일이 다른 계정은 기존 비밀번호로 확인하거나 관리자에게 복구를 요청해 주세요.',
|
||||
});
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user