feat: 카카오 계정의 본인 복구와 인증 후 연결 교체를 추가한다
This commit is contained in:
1 parent
d1cdf8a85f
commit
c81d304073
22 files changed
+1338
-199
No files matched your search
@@ -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