feat: 레거시 재이관과 계정 복구 기반을 추가
중앙 이전 기록 스키마와 버전 정규화기를 도입하고, 재실행 시 현행 계정 상태를 보존한다. 카카오 인증 뒤 이관 비밀번호를 1회 설정하는 흐름과 비카카오 계정용 안전한 CLI 복구 경로를 추가한다.
This commit is contained in:
@@ -133,6 +133,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
kakaoGraceStartedAt: now.toISOString(),
|
||||
passwordSalt: password.salt,
|
||||
passwordHash: password.hash,
|
||||
passwordResetRequired: false,
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
usersByName.set(input.username, user);
|
||||
@@ -150,6 +151,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
const upgraded = await hasher.hash(password);
|
||||
user.passwordSalt = upgraded.salt;
|
||||
user.passwordHash = upgraded.hash;
|
||||
user.passwordResetRequired = false;
|
||||
}
|
||||
return verified.ok;
|
||||
},
|
||||
@@ -159,6 +161,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
|
||||
const next = await hasher.hash(password);
|
||||
user.passwordSalt = next.salt;
|
||||
user.passwordHash = next.hash;
|
||||
user.passwordResetRequired = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ export const hasActiveSpecialAccountGrant = (
|
||||
grants: readonly SpecialAccountAccessGrantRecord[],
|
||||
now: Date = new Date()
|
||||
): boolean =>
|
||||
grants.some((grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime()));
|
||||
grants.some(
|
||||
(grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime())
|
||||
);
|
||||
|
||||
const appliesToProfile = (grant: SpecialAccountAccessGrantRecord, profile: string, profileName: string): boolean =>
|
||||
grant.profiles.length === 0 || grant.profiles.includes(profile) || grant.profiles.includes(profileName);
|
||||
@@ -67,9 +69,7 @@ const resolveSpecialAccess = (options: {
|
||||
const selected = active.find((grant) => grant.allowsGeneralCreation) ?? active[0]!;
|
||||
const expiresAt = active.some((grant) => !grant.expiresAt)
|
||||
? null
|
||||
: active
|
||||
.map((grant) => grant.expiresAt!)
|
||||
.sort((left, right) => right.localeCompare(left))[0] ?? null;
|
||||
: (active.map((grant) => grant.expiresAt!).sort((left, right) => right.localeCompare(left))[0] ?? null);
|
||||
return {
|
||||
kind: selected.kind,
|
||||
grantId: selected.id,
|
||||
@@ -98,7 +98,10 @@ export const resolveLocalAccountProfilePolicy = (options: {
|
||||
'localAccountGeneralCreationGraceDays',
|
||||
generalCreationDefault
|
||||
);
|
||||
const kakaoVerified = options.user.oauthType === 'KAKAO' && Boolean(options.user.kakaoVerifiedAt);
|
||||
const kakaoVerified =
|
||||
options.user.oauthType === 'KAKAO' &&
|
||||
Boolean(options.user.oauthId?.trim()) &&
|
||||
Boolean(options.user.kakaoVerifiedAt);
|
||||
const graceStartedAt = new Date(options.user.kakaoGraceStartedAt);
|
||||
const now = options.now ?? new Date();
|
||||
const specialAccess = resolveSpecialAccess({
|
||||
@@ -116,7 +119,9 @@ export const resolveLocalAccountProfilePolicy = (options: {
|
||||
const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS);
|
||||
const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt;
|
||||
const canCreateGeneral =
|
||||
kakaoVerified || specialAccess?.allowsGeneralCreation === true || (accessAllowed && now < generalCreationEndsAt);
|
||||
kakaoVerified ||
|
||||
specialAccess?.allowsGeneralCreation === true ||
|
||||
(accessAllowed && now < generalCreationEndsAt);
|
||||
|
||||
return {
|
||||
requiresKakaoVerification: !kakaoVerified && specialAccess === null,
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface OAuthPendingState {
|
||||
export interface OAuthSession {
|
||||
id: string;
|
||||
mode: OAuthMode;
|
||||
intent?: 'register' | 'link_existing' | 'rejoin';
|
||||
intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup';
|
||||
targetUserId?: string;
|
||||
kakaoId: string;
|
||||
email: string;
|
||||
@@ -93,6 +93,14 @@ end
|
||||
return cjson.encode({ status = 'verified', userId = challenge.userId })
|
||||
`;
|
||||
|
||||
const consumeOnceScript = `
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if raw then
|
||||
redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return raw
|
||||
`;
|
||||
|
||||
export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||
private readonly client: RedisClientLike;
|
||||
private readonly prefix: string;
|
||||
@@ -161,12 +169,11 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||
|
||||
async consumeSession(sessionId: string): Promise<OAuthSession | null> {
|
||||
const key = this.sessionKey(sessionId);
|
||||
const raw = await this.client.get(key);
|
||||
const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] });
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
await this.client.del(key);
|
||||
return parseJson<OAuthSession>(raw);
|
||||
return typeof raw === 'string' ? parseJson<OAuthSession>(raw) : null;
|
||||
}
|
||||
|
||||
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
|
||||
|
||||
@@ -59,6 +59,7 @@ const mapUser = (row: {
|
||||
displayName: string;
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
passwordResetRequired: boolean;
|
||||
roles: GatewayPrisma.JsonValue;
|
||||
sanctions: GatewayPrisma.JsonValue;
|
||||
oauthType: 'NONE' | 'KAKAO';
|
||||
@@ -107,6 +108,7 @@ const mapUser = (row: {
|
||||
deleteAfter: row.deleteAfter?.toISOString(),
|
||||
passwordHash: row.passwordHash,
|
||||
passwordSalt: row.passwordSalt,
|
||||
passwordResetRequired: row.passwordResetRequired,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
legacyMemberNo: readLegacyMemberNo(row.legacyData),
|
||||
legacyGrade: readLegacyGrade(row.legacyData),
|
||||
@@ -250,6 +252,7 @@ export const createPostgresUserRepository = (
|
||||
displayName: input.displayName ?? input.username,
|
||||
passwordHash: password.hash,
|
||||
passwordSalt: password.salt,
|
||||
passwordResetRequired: false,
|
||||
roles: ['user'] satisfies GatewayPrisma.JsonArray,
|
||||
sanctions: {} satisfies GatewayPrisma.JsonObject,
|
||||
oauthType,
|
||||
@@ -274,10 +277,12 @@ export const createPostgresUserRepository = (
|
||||
data: {
|
||||
passwordHash: upgraded.hash,
|
||||
passwordSalt: upgraded.salt,
|
||||
passwordResetRequired: false,
|
||||
},
|
||||
});
|
||||
user.passwordHash = upgraded.hash;
|
||||
user.passwordSalt = upgraded.salt;
|
||||
user.passwordResetRequired = false;
|
||||
}
|
||||
return verified.ok;
|
||||
},
|
||||
@@ -288,6 +293,7 @@ export const createPostgresUserRepository = (
|
||||
data: {
|
||||
passwordHash: next.hash,
|
||||
passwordSalt: next.salt,
|
||||
passwordResetRequired: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface UserRecord {
|
||||
deleteAfter?: string;
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
passwordResetRequired: boolean;
|
||||
createdAt: string;
|
||||
legacyMemberNo?: number;
|
||||
legacyGrade?: number;
|
||||
@@ -128,7 +129,7 @@ export const toPublicUser = (user: UserRecord): PublicUser => ({
|
||||
displayName: user.displayName,
|
||||
roles: user.roles,
|
||||
picture: user.picture,
|
||||
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt),
|
||||
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.oauthId?.trim()) && Boolean(user.kakaoVerifiedAt),
|
||||
kakaoGraceStartedAt: user.kakaoGraceStartedAt,
|
||||
createdAt: user.createdAt,
|
||||
});
|
||||
|
||||
@@ -91,6 +91,42 @@ const finishKakaoLogin = async <T extends 'login' | 'verified'>(
|
||||
};
|
||||
};
|
||||
|
||||
const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verified'>(
|
||||
ctx: GatewayApiContext,
|
||||
user: UserRecord,
|
||||
accessToken: string,
|
||||
successStatus: T
|
||||
) => {
|
||||
if (!user.passwordResetRequired) {
|
||||
return finishKakaoLogin(ctx, user, accessToken, successStatus);
|
||||
}
|
||||
if (user.oauthType !== 'KAKAO' || !user.oauthId || !user.email) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '카카오 계정 연결 정보가 올바르지 않아 비밀번호를 설정할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
const oauthInfo = user.oauthInfo ?? {};
|
||||
const passwordSetup = await ctx.oauthSessions.createSession({
|
||||
mode: successStatus === 'verified' ? 'verify' : 'login',
|
||||
intent: 'password_setup',
|
||||
targetUserId: user.id,
|
||||
kakaoId: user.oauthId,
|
||||
email: user.email,
|
||||
accessToken,
|
||||
refreshToken: oauthInfo.refreshToken,
|
||||
accessTokenValidUntil: oauthInfo.accessTokenValidUntil ?? new Date().toISOString(),
|
||||
refreshTokenValidUntil: oauthInfo.refreshTokenValidUntil,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return {
|
||||
status: 'password_setup' as const,
|
||||
oauthSessionId: passwordSetup.id,
|
||||
email: passwordSetup.email,
|
||||
successStatus,
|
||||
};
|
||||
};
|
||||
|
||||
export const appRouter = router({
|
||||
health: router({
|
||||
ping: procedure.query(() => ({
|
||||
@@ -348,7 +384,7 @@ export const appRouter = router({
|
||||
}
|
||||
const refreshed = (await ctx.users.findById(verified.id)) ?? verified;
|
||||
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified');
|
||||
return finishKakaoLogin(ctx, refreshed, token.accessToken, 'verified');
|
||||
return finishKakaoLoginOrRequestPasswordSetup(ctx, refreshed, token.accessToken, 'verified');
|
||||
}
|
||||
|
||||
if (pending.mode === 'change_pw') {
|
||||
@@ -415,7 +451,7 @@ export const appRouter = router({
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
return finishKakaoLogin(ctx, synced, token.accessToken, 'login');
|
||||
return finishKakaoLoginOrRequestPasswordSetup(ctx, synced, token.accessToken, 'login');
|
||||
}
|
||||
|
||||
const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
|
||||
@@ -562,7 +598,70 @@ export const appRouter = router({
|
||||
});
|
||||
}
|
||||
await ctx.flushPublisher.publishUserFlush(linked.id, 'kakao-account-relinked');
|
||||
return finishKakaoLogin(ctx, linked, oauthSession.accessToken, 'login');
|
||||
return finishKakaoLoginOrRequestPasswordSetup(ctx, linked, oauthSession.accessToken, 'login');
|
||||
}),
|
||||
kakaoSetPassword: procedure
|
||||
.input(
|
||||
z.object({
|
||||
oauthSessionId: z.string().uuid(),
|
||||
credential: zPasswordEnvelope,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const password = openPassword(ctx.passwordEnvelope, input.credential);
|
||||
const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId);
|
||||
if (!oauthSession || oauthSession.intent !== 'password_setup' || !oauthSession.targetUserId) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '비밀번호 설정 세션이 만료되었습니다. 카카오 로그인을 다시 진행해 주세요.',
|
||||
});
|
||||
}
|
||||
const user = await ctx.users.findById(oauthSession.targetUserId);
|
||||
if (
|
||||
!user ||
|
||||
user.oauthType !== 'KAKAO' ||
|
||||
user.oauthId !== oauthSession.kakaoId ||
|
||||
user.email?.toLowerCase() !== oauthSession.email.toLowerCase() ||
|
||||
!user.passwordResetRequired
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.',
|
||||
});
|
||||
}
|
||||
if (user.deleteAfter) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Account deletion is pending.' });
|
||||
}
|
||||
if (isLoginBanned(user.sanctions)) {
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Account login is blocked.' });
|
||||
}
|
||||
let verifiedProfile;
|
||||
try {
|
||||
verifiedProfile = readVerifiedKakaoProfile(await ctx.kakaoClient.getMe(oauthSession.accessToken));
|
||||
} catch (error) {
|
||||
return throwKakaoVerificationError(error);
|
||||
}
|
||||
if (
|
||||
verifiedProfile.kakaoId !== oauthSession.kakaoId ||
|
||||
verifiedProfile.email !== oauthSession.email.toLowerCase()
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '카카오 계정 정보가 비밀번호 설정 세션과 일치하지 않습니다.',
|
||||
});
|
||||
}
|
||||
await ctx.users.updatePassword(user.id, password);
|
||||
const refreshed = await ctx.users.findById(user.id);
|
||||
if (!refreshed) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '계정을 찾지 못했습니다.' });
|
||||
}
|
||||
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'password-changed');
|
||||
return finishKakaoLogin(
|
||||
ctx,
|
||||
refreshed,
|
||||
oauthSession.accessToken,
|
||||
oauthSession.mode === 'verify' ? 'verified' : 'login'
|
||||
);
|
||||
}),
|
||||
register: procedure
|
||||
.input(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}`
|
||||
|
||||
Reference in New Issue
Block a user