feat: 레거시 재이관과 계정 복구 기반을 추가

중앙 이전 기록 스키마와 버전 정규화기를 도입하고, 재실행 시 현행 계정 상태를 보존한다. 카카오 인증 뒤 이관 비밀번호를 1회 설정하는 흐름과 비카카오 계정용 안전한 CLI 복구 경로를 추가한다.
This commit is contained in:
2026-08-17 15:55:32 +00:00
parent 50e7d894e4
commit fc7de05017
33 changed files with 1720 additions and 150 deletions
+114 -2
View File
@@ -631,7 +631,7 @@ describe('gateway auth flow', () => {
});
it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => {
const { caller, users, kakaoProfile, sentTalkMessages, flushPublisher } = buildCaller();
const { caller, users, kakaoProfile, sealPassword, sentTalkMessages, flushPublisher } = buildCaller();
const emailOwner = await users.createUser({
username: 'email-owner',
password: 'owner-password',
@@ -642,6 +642,7 @@ describe('gateway auth flow', () => {
info: {},
},
});
emailOwner.passwordResetRequired = true;
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
kakaoProfile.id = 'different-kakao-id';
@@ -659,7 +660,14 @@ describe('gateway auth flow', () => {
oauthSessionId: recovery.oauthSessionId,
action: 'link_existing',
});
expect(linked.status).toBe('otp');
expect(linked.status).toBe('password_setup');
if (linked.status !== 'password_setup') throw new Error('Expected migrated password setup.');
expect(sentTalkMessages).toHaveLength(0);
const passwordSet = await caller.auth.kakaoSetPassword({
oauthSessionId: linked.oauthSessionId,
credential: sealPassword('replacement-password'),
});
expect(passwordSet.status).toBe('otp');
expect(sentTalkMessages).toHaveLength(1);
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({
@@ -668,6 +676,108 @@ describe('gateway auth flow', () => {
email: 'tester@example.com',
});
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked');
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'password-changed');
});
it('requires a one-time password setup before an imported Kakao account can receive a session', async () => {
const { caller, users, sessions, kakaoProfile, sealPassword, sentTalkMessages } = buildCaller({
kakaoId: 'imported-kakao-id',
kakaoEmail: 'imported@example.com',
});
const user = await users.createUser({
username: 'imported-kakao-user',
password: 'legacy-password',
oauth: {
type: 'KAKAO',
id: kakaoProfile.id,
email: kakaoProfile.email,
info: {},
},
});
user.passwordResetRequired = true;
const createSession = vi.spyOn(sessions, 'createSession');
const start = await caller.auth.kakaoStart({ mode: 'login' });
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
expect(login).toMatchObject({
status: 'password_setup',
email: 'imported@example.com',
successStatus: 'login',
});
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
expect(login).not.toHaveProperty('sessionToken');
expect(createSession).not.toHaveBeenCalled();
expect(sentTalkMessages).toHaveLength(0);
const setup = await caller.auth.kakaoSetPassword({
oauthSessionId: login.oauthSessionId,
credential: sealPassword('new-imported-password'),
});
expect(setup.status).toBe('otp');
expect((await users.findById(user.id))?.passwordResetRequired).toBe(false);
expect(await users.verifyPassword(user, 'new-imported-password')).toBe(true);
await expect(
caller.auth.kakaoSetPassword({
oauthSessionId: login.oauthSessionId,
credential: sealPassword('another-password'),
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
it('rechecks sanctions before consuming a migrated password setup', async () => {
const { caller, users, kakaoProfile, sealPassword } = buildCaller({
kakaoId: 'sanctioned-setup-id',
kakaoEmail: 'sanctioned-setup@example.com',
});
const user = await users.createUser({
username: 'sanctioned-setup-user',
password: 'legacy-password',
oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} },
});
user.passwordResetRequired = true;
const start = await caller.auth.kakaoStart({ mode: 'login' });
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
await users.updateSanctions(user.id, { bannedUntil: '2099-01-01T00:00:00.000Z' });
await expect(
caller.auth.kakaoSetPassword({
oauthSessionId: login.oauthSessionId,
credential: sealPassword('blocked-password'),
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect((await users.findById(user.id))?.passwordResetRequired).toBe(true);
});
it('consumes password setup when the provider identity changes before submission', async () => {
const { caller, users, kakaoProfile, sealPassword } = buildCaller({
kakaoId: 'setup-target-id',
kakaoEmail: 'setup-target@example.com',
});
const user = await users.createUser({
username: 'setup-target-user',
password: 'legacy-password',
oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} },
});
user.passwordResetRequired = true;
const start = await caller.auth.kakaoStart({ mode: 'login' });
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
kakaoProfile.id = 'changed-provider-id';
await expect(
caller.auth.kakaoSetPassword({
oauthSessionId: login.oauthSessionId,
credential: sealPassword('new-target-password'),
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
expect((await users.findById(user.id))?.passwordResetRequired).toBe(true);
await expect(
caller.auth.kakaoSetPassword({
oauthSessionId: login.oauthSessionId,
credential: sealPassword('new-target-password'),
})
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
it('asks for rejoin confirmation when Kakao is already registered but no retained email owner exists', async () => {
@@ -1147,6 +1257,7 @@ describe('account self service', () => {
username: 'self-service',
password: 'current-password',
});
user.passwordResetRequired = true;
const session = await sessions.createSession(user);
await expect(
@@ -1165,6 +1276,7 @@ describe('account self service', () => {
const refreshed = await users.findById(user.id);
expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true);
expect(refreshed?.passwordResetRequired).toBe(false);
});
it('revokes the session and schedules deletion after 30 days', async () => {
@@ -16,6 +16,7 @@ const buildLocalUser = (graceStartedAt: Date): UserRecord => ({
kakaoGraceStartedAt: graceStartedAt.toISOString(),
passwordHash: 'unused',
passwordSalt: '',
passwordResetRequired: false,
createdAt: graceStartedAt.toISOString(),
});
@@ -90,6 +91,25 @@ describe('local account profile policy', () => {
});
});
it('does not trust a migrated Kakao marker without a valid provider ID', () => {
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
user.oauthType = 'KAKAO';
user.oauthId = ' ';
user.kakaoVerifiedAt = '2026-07-26T00:00:00.000Z';
const policy = resolveLocalAccountProfilePolicy({
profile: 'che',
defaultGraceDays: 0,
user,
now: new Date('2026-07-26T00:00:00.000Z'),
});
expect(policy).toMatchObject({
kakaoVerified: false,
requiresKakaoVerification: true,
accessAllowed: false,
});
});
it('extends account access with an administrator override without widening general creation grace', () => {
const user = buildLocalUser(new Date('2026-07-20T00:00:00.000Z'));
user.kakaoGraceUntil = '2026-08-20T00:00:00.000Z';
@@ -61,13 +61,15 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
});
sessionIds.add(session.id);
await expect(store.consumeSession(session.id)).resolves.toMatchObject({
await expect(client.ttl(`${prefix}:oauth-session:${session.id}`)).resolves.toBeGreaterThan(0);
const consumed = await Promise.all([store.consumeSession(session.id), store.consumeSession(session.id)]);
expect(consumed.filter((value) => value !== null)).toHaveLength(1);
expect(consumed.find((value) => value !== null)).toMatchObject({
id: session.id,
intent: 'link_existing',
targetUserId,
email: 'retained@example.test',
});
await expect(store.consumeSession(session.id)).resolves.toBeNull();
});
it('atomically consumes a successful code once', async () => {
@@ -34,10 +34,12 @@ describe('password credential compatibility', () => {
});
user.passwordSalt = 'core-salt';
user.passwordHash = createHash('sha256').update('core-salt:current-password').digest('hex');
user.passwordResetRequired = true;
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
expect(user.passwordSalt).toBe('');
expect(user.passwordResetRequired).toBe(false);
});
it('upgrades an imported ref double-SHA-512 credential after a successful login', async () => {
@@ -52,10 +54,12 @@ describe('password credential compatibility', () => {
const browserHash = createHash('sha512').update(`${globalSalt}current-password${globalSalt}`).digest('hex');
user.passwordSalt = userSalt;
user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex');
user.passwordResetRequired = true;
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
expect(user.passwordSalt).toBe('');
expect(user.passwordResetRequired).toBe(false);
});
it('does not accept an imported ref credential without the matching global salt', async () => {
+3 -7
View File
@@ -38,8 +38,8 @@ describe('readReleaseManifest', () => {
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
gatewaySchemaHead: '20260813000000_split_gateway_profile_identity',
gameSchemaHead: '20260816000000_add_read_model_change_journal',
gatewaySchemaHead: '20260817000000_add_password_reset_required',
gameSchemaHead: '20260817001000_add_dedicated_legacy_archive',
});
});
@@ -61,11 +61,7 @@ describe('readReleaseManifest', () => {
it('allows only the explicit controller self-upgrade boundary to cross protocol versions', async () => {
const futureProtocol = RELEASE_CONTROLLER_PROTOCOL + 1;
const workspace = await createWorkspace(
'20260801000000_gateway',
'20260801000000_game',
futureProtocol
);
const workspace = await createWorkspace('20260801000000_gateway', '20260801000000_game', futureProtocol);
await expect(readReleaseManifest(workspace)).rejects.toThrow(
`Release requires controller protocol ${futureProtocol}`