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}`
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
|
||||
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||
|
||||
const response = (data: unknown) => ({ result: { data } });
|
||||
@@ -6,6 +8,43 @@ const operationNames = (route: Route): string[] => {
|
||||
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
|
||||
};
|
||||
|
||||
const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
||||
|
||||
const installPasswordSetupFixture = async (page: Page) => {
|
||||
const calls: string[] = [];
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
const results = operationNames(route).map((operation) => {
|
||||
calls.push(operation);
|
||||
if (operation === 'me') return response(null);
|
||||
if (operation === 'lobby.notice') return response('');
|
||||
if (operation === 'lobby.profiles') return response([]);
|
||||
if (operation === 'auth.passwordKey') {
|
||||
return response({ keyId: 'password-setup-key', publicKeyPem, algorithm: 'RSA-OAEP-256' });
|
||||
}
|
||||
if (operation === 'auth.kakaoExchange') {
|
||||
return response({
|
||||
status: 'password_setup',
|
||||
oauthSessionId: '11111111-1111-4111-8111-111111111112',
|
||||
email: 'migrated@example.test',
|
||||
successStatus: 'login',
|
||||
});
|
||||
}
|
||||
if (operation === 'auth.kakaoSetPassword') {
|
||||
return response({
|
||||
status: 'otp',
|
||||
challengeId: '11111111-1111-4111-8111-111111111111',
|
||||
expiresAt: '2026-08-17T12:03:00.000Z',
|
||||
attemptsRemaining: 3,
|
||||
});
|
||||
}
|
||||
throw new Error(`Unhandled password setup fixture operation: ${operation}`);
|
||||
});
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
|
||||
});
|
||||
return calls;
|
||||
};
|
||||
|
||||
const installFixture = async (page: Page, action: 'link_existing' | 'rejoin') => {
|
||||
const calls: string[] = [];
|
||||
await page.route('**/gateway/api/trpc/**', async (route) => {
|
||||
@@ -108,4 +147,27 @@ for (const viewport of [
|
||||
expect(calls.filter((operation) => operation === 'auth.kakaoResolveAccount')).toHaveLength(1);
|
||||
expect(geometry.width).toBe(viewport.name === 'desktop' ? 698 : 372);
|
||||
});
|
||||
|
||||
test(`sets a migrated password before opening the OTP dialog on ${viewport.name}`, async ({ page }) => {
|
||||
const calls = await installPasswordSetupFixture(page);
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state');
|
||||
|
||||
const form = page.getByRole('form', { name: '새 비밀번호 설정' });
|
||||
await expect(form).toBeVisible();
|
||||
await expect(form).toContainText('카카오 인증으로 기존 계정을 확인했습니다.');
|
||||
await expect(form.getByLabel('카카오 이메일')).toHaveValue('migrated@example.test');
|
||||
const geometry = await form.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { width: rect.width, right: rect.right };
|
||||
});
|
||||
await form.getByLabel('새 비밀번호').fill('new-password-value');
|
||||
await form.getByLabel('비밀번호 확인').fill('new-password-value');
|
||||
await form.getByRole('button', { name: '새 비밀번호 설정' }).click();
|
||||
|
||||
await expect(page.getByRole('dialog', { name: '인증 코드 필요' })).toBeVisible();
|
||||
expect(calls.filter((operation) => operation === 'auth.kakaoSetPassword')).toHaveLength(1);
|
||||
expect(geometry.width).toBeGreaterThan(300);
|
||||
expect(geometry.right).toBeLessThanOrEqual(viewport.width);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ const submitting = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const infoMessage = ref('');
|
||||
const oauthSessionId = ref('');
|
||||
const passwordSetupSessionId = ref('');
|
||||
const passwordSetupSuccessStatus = ref<'login' | 'verified'>('login');
|
||||
const email = ref('');
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
@@ -60,6 +62,12 @@ const completeExchange = async (): Promise<void> => {
|
||||
infoMessage.value = '카카오톡으로 임시 비밀번호를 보냈습니다.';
|
||||
return;
|
||||
}
|
||||
if (result.status === 'password_setup') {
|
||||
passwordSetupSessionId.value = result.oauthSessionId;
|
||||
passwordSetupSuccessStatus.value = result.successStatus;
|
||||
email.value = result.email;
|
||||
return;
|
||||
}
|
||||
if (result.status === 'account_recovery') {
|
||||
accountRecovery.value = result;
|
||||
email.value = result.email;
|
||||
@@ -94,6 +102,12 @@ const resolveAccount = async (): Promise<void> => {
|
||||
await router.replace('/lobby');
|
||||
return;
|
||||
}
|
||||
if (result.status === 'password_setup') {
|
||||
passwordSetupSessionId.value = result.oauthSessionId;
|
||||
passwordSetupSuccessStatus.value = result.successStatus;
|
||||
email.value = result.email;
|
||||
return;
|
||||
}
|
||||
oauthSessionId.value = result.oauthSessionId;
|
||||
email.value = result.email;
|
||||
} catch (error) {
|
||||
@@ -103,6 +117,36 @@ const resolveAccount = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
const setMigratedPassword = async (): Promise<void> => {
|
||||
errorMessage.value = '';
|
||||
if (password.value !== confirmPassword.value) {
|
||||
errorMessage.value = '비밀번호 확인이 일치하지 않습니다.';
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const credential = await sealPassword(password.value);
|
||||
const result = await trpc.auth.kakaoSetPassword.mutate({
|
||||
oauthSessionId: passwordSetupSessionId.value,
|
||||
credential,
|
||||
});
|
||||
password.value = '';
|
||||
confirmPassword.value = '';
|
||||
passwordSetupSessionId.value = '';
|
||||
if (result.status === 'otp') {
|
||||
otpChallenge.value = result;
|
||||
otpSuccessStatus.value = passwordSetupSuccessStatus.value;
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem('sammo-session-token', result.sessionToken);
|
||||
await router.replace(result.status === 'verified' ? '/lobby?verified=1' : '/lobby');
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '새 비밀번호를 설정하지 못했습니다.';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (): Promise<void> => {
|
||||
errorMessage.value = '';
|
||||
if (password.value !== confirmPassword.value) {
|
||||
@@ -156,7 +200,15 @@ onMounted(() => {
|
||||
<main id="oauth-container">
|
||||
<h1>삼국지 모의전투 HiDCHe</h1>
|
||||
<section class="oauth-card">
|
||||
<h2>{{ accountRecovery ? '카카오 계정 연결 확인' : '회원가입' }}</h2>
|
||||
<h2>
|
||||
{{
|
||||
accountRecovery
|
||||
? '카카오 계정 연결 확인'
|
||||
: passwordSetupSessionId
|
||||
? '새 비밀번호 설정'
|
||||
: '회원가입'
|
||||
}}
|
||||
</h2>
|
||||
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
|
||||
<p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</p>
|
||||
<div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인">
|
||||
@@ -181,6 +233,46 @@ onMounted(() => {
|
||||
<RouterLink class="back-link" to="/">취소</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
v-else-if="passwordSetupSessionId"
|
||||
class="password-setup-form"
|
||||
aria-label="새 비밀번호 설정"
|
||||
@submit.prevent="setMigratedPassword"
|
||||
>
|
||||
<p class="oauth-message">
|
||||
카카오 인증으로 기존 계정을 확인했습니다. 앞으로 사용할 새 비밀번호를 설정해 주세요.
|
||||
</p>
|
||||
<div class="form-row">
|
||||
<label for="migrated-password-email">카카오 이메일</label>
|
||||
<input id="migrated-password-email" :value="email" readonly />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="migrated-password">새 비밀번호</label>
|
||||
<input
|
||||
id="migrated-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="migrated-password-confirm">비밀번호 확인</label>
|
||||
<input
|
||||
id="migrated-password-confirm"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button class="register-button" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '설정 중...' : '새 비밀번호 설정' }}
|
||||
</button>
|
||||
<RouterLink class="back-link" to="/">취소</RouterLink>
|
||||
</form>
|
||||
<form v-else-if="oauthSessionId" @submit.prevent="register">
|
||||
<div class="form-row">
|
||||
<label for="oauth-email">카카오 이메일</label>
|
||||
|
||||
Reference in New Issue
Block a user