merge: Kakao account verification

This commit is contained in:
2026-08-08 06:23:48 +00:00
33 changed files with 2047 additions and 138 deletions
+7
View File
@@ -41,6 +41,13 @@ orchestrator가 commit별 worktree와 PM2 process를 조정합니다.
Gateway 자체 릴리스는 Gateway 프로세스 밖의 `release-controller`가 별도
`GatewayReleaseOperation` queue를 처리합니다.
Kakao 계정은 OAuth callback과 일반 비밀번호 로그인 모두에서 Kakao 고유 ID와
현재 인증 이메일을 다시 확인합니다. 새 Kakao ID의 이메일이 기존 계정에 있거나
기존 Kakao ID의 변경 이메일이 다른 계정에 있으면 로그인을 거부합니다. 확인된
이메일은 `AppUser.email`에 동기화하며, 10일마다 `talk_message` scope로
“나와의 채팅” 숫자 코드를 보내 소유 증명을 완료한 뒤에만 새 Gateway session을
발급합니다.
각 game profile은 별도 PostgreSQL schema를 사용합니다. `game-api`는 인증된
요청을 검증하고 직접 처리할 mutation 또는 daemon 입력을
`InputEvent`에 기록합니다. `game-engine`은 DB lease와 fencing token을 확보한
@@ -31,6 +31,9 @@ test('reserves an argument command in the real game API and reads it back from P
).toString('base64'),
},
});
if (login.status !== 'login') {
throw new Error('Local live fixture unexpectedly requires Kakao OTP.');
}
const issued = await gateway.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
profile: 'che:2',
@@ -95,19 +98,15 @@ test('reserves an argument command in the real game API and reads it back from P
await form.getByRole('button', { name: '쌀', exact: true }).click();
await form.locator('input[type=number]').fill('1');
const generalSelect = form.locator('select');
const generalValues = await generalSelect
.locator('option')
.evaluateAll((options) =>
options.map((option) => ({
value: (option as HTMLOptionElement).value,
label: option.textContent ?? '',
}))
);
const generalValues = await generalSelect.locator('option').evaluateAll((options) =>
options.map((option) => ({
value: (option as HTMLOptionElement).value,
label: option.textContent ?? '',
}))
);
const targetGeneralId = Number(
generalValues.find(
(option) =>
Number(option.value) !== generalId &&
option.label.includes(`(${nationName} ·`)
(option) => Number(option.value) !== generalId && option.label.includes(`(${nationName} ·`)
)?.value
);
expect(targetGeneralId).toBeGreaterThan(0);
@@ -53,6 +53,13 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
if (usersByName.has(input.username)) {
throw new Error('User already exists.');
}
if (
input.oauth &&
(usersByOauthId.has(`${input.oauth.type}:${input.oauth.id}`) ||
usersByEmail.has(input.oauth.email.toLowerCase()))
) {
throw new Error('Kakao account already linked.');
}
for (const existing of usersByName.values()) {
if ((input.displayName ?? input.username) === existing.displayName) {
throw new Error('Display name already exists.');
@@ -120,6 +127,39 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
}
throw new Error('User not found.');
},
async syncKakaoIdentity(
userId: string,
email: string,
oauthInfo: UserRecord['oauthInfo']
): Promise<UserRecord> {
const normalizedEmail = email.toLowerCase();
const owner = usersByEmail.get(normalizedEmail);
if (owner && owner.id !== userId) {
throw new Error('Kakao email already linked.');
}
for (const user of usersByName.values()) {
if (user.id !== userId) {
continue;
}
if (user.email) {
usersByEmail.delete(user.email.toLowerCase());
}
user.email = normalizedEmail;
user.oauthInfo = oauthInfo;
usersByEmail.set(normalizedEmail, user);
return user;
}
throw new Error('User not found.');
},
async markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord> {
for (const user of usersByName.values()) {
if (user.id === userId) {
user.kakaoTalkVerifiedUntil = validUntil.toISOString();
return user;
}
}
throw new Error('User not found.');
},
async linkKakao(userId, input): Promise<UserRecord> {
if (usersByOauthId.has(`KAKAO:${input.oauthId}`) || usersByEmail.has(input.email.toLowerCase())) {
throw new Error('Kakao account already linked.');
@@ -0,0 +1,279 @@
import { randomInt } from 'node:crypto';
import { addDays, addSeconds, isAfter, isValid, parseISO } from 'date-fns';
import type { KakaoOAuthClient, KakaoOAuthToken, KakaoUserInfo } from './kakaoClient.js';
import type { OAuthSessionStore } from './oauthSessionStore.js';
import type { UserOAuthInfo, UserRecord, UserRepository } from './userRepository.js';
export const KAKAO_LOGIN_SCOPES = ['account_email', 'talk_message'] as const;
export const KAKAO_OTP_TTL_SECONDS = 180;
export const KAKAO_OTP_ATTEMPTS = 3;
export const KAKAO_TALK_VERIFICATION_DAYS = 10;
export type KakaoVerificationErrorCode =
| 'EMAIL_REQUIRED'
| 'EMAIL_UNVERIFIED'
| 'EMAIL_CONFLICT'
| 'IDENTITY_MISMATCH'
| 'REAUTH_REQUIRED'
| 'MESSAGE_FAILED';
export class KakaoVerificationError extends Error {
readonly verificationCode: KakaoVerificationErrorCode;
constructor(verificationCode: KakaoVerificationErrorCode, message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'KakaoVerificationError';
this.verificationCode = verificationCode;
}
}
export interface VerifiedKakaoProfile {
kakaoId: string;
email: string;
}
export interface KakaoLoginReady {
user: UserRecord;
accessToken: string;
oauthInfo: UserOAuthInfo;
}
export interface KakaoOtpRequired {
status: 'otp';
challengeId: string;
expiresAt: string;
attemptsRemaining: number;
}
const parseDate = (value: string | undefined): Date | null => {
if (!value) {
return null;
}
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(value)
? `${value.replace(' ', 'T')}Z`
: value;
const parsed = parseISO(normalized);
return isValid(parsed) ? parsed : null;
};
export const readVerifiedKakaoProfile = (me: KakaoUserInfo): VerifiedKakaoProfile => {
const account = me.kakaoAccount;
if (!account.hasEmail || !account.email) {
throw new KakaoVerificationError('EMAIL_REQUIRED', '이메일 정보 제공에 동의해야 합니다.');
}
if (!account.isEmailValid || !account.isEmailVerified) {
throw new KakaoVerificationError('EMAIL_UNVERIFIED', '카카오 계정 이메일이 인증되지 않았습니다.');
}
if (!me.id) {
throw new KakaoVerificationError('IDENTITY_MISMATCH', '카카오 계정 고유 ID를 확인하지 못했습니다.');
}
return {
kakaoId: me.id,
email: account.email.trim().toLocaleLowerCase('en-US'),
};
};
export const oauthInfoFromToken = (
token: KakaoOAuthToken,
issuedAt: Date,
previous: UserOAuthInfo = {}
): UserOAuthInfo => ({
...previous,
accessToken: token.accessToken,
refreshToken: token.refreshToken ?? previous.refreshToken,
accessTokenValidUntil: addSeconds(issuedAt, token.accessTokenExpiresIn).toISOString(),
refreshTokenValidUntil: token.refreshTokenExpiresIn
? addSeconds(issuedAt, token.refreshTokenExpiresIn).toISOString()
: previous.refreshTokenValidUntil,
});
const resolveStoredAccessToken = async (
user: UserRecord,
kakaoClient: KakaoOAuthClient,
now: Date
): Promise<{ accessToken: string; oauthInfo: UserOAuthInfo }> => {
const oauthInfo = user.oauthInfo ?? {};
const accessValidUntil = parseDate(oauthInfo.accessTokenValidUntil);
if (oauthInfo.accessToken && accessValidUntil && isAfter(accessValidUntil, now)) {
return { accessToken: oauthInfo.accessToken, oauthInfo };
}
const refreshValidUntil = parseDate(oauthInfo.refreshTokenValidUntil);
if (!oauthInfo.refreshToken || !refreshValidUntil || !isAfter(refreshValidUntil, now)) {
throw new KakaoVerificationError(
'REAUTH_REQUIRED',
'카카오 로그인 토큰이 만료되었습니다. 카카오 로그인을 다시 수행해 주세요.'
);
}
let refreshed: KakaoOAuthToken;
try {
refreshed = await kakaoClient.refreshToken(oauthInfo.refreshToken);
} catch (error) {
throw new KakaoVerificationError(
'REAUTH_REQUIRED',
'카카오 로그인 토큰을 갱신하지 못했습니다. 카카오 로그인을 다시 수행해 주세요.',
{ cause: error }
);
}
if (!refreshed.accessToken || refreshed.accessTokenExpiresIn <= 0) {
throw new KakaoVerificationError(
'REAUTH_REQUIRED',
'카카오 로그인 토큰을 갱신하지 못했습니다. 카카오 로그인을 다시 수행해 주세요.'
);
}
return {
accessToken: refreshed.accessToken,
oauthInfo: oauthInfoFromToken(refreshed, now, oauthInfo),
};
};
export const verifyStoredKakaoIdentity = async (options: {
user: UserRecord;
users: UserRepository;
kakaoClient: KakaoOAuthClient;
now?: Date;
}): Promise<KakaoLoginReady> => {
const { user, users, kakaoClient } = options;
const now = options.now ?? new Date();
if (user.oauthType !== 'KAKAO' || !user.oauthId) {
throw new KakaoVerificationError('REAUTH_REQUIRED', '카카오 계정 연결 정보가 올바르지 않습니다.');
}
const resolved = await resolveStoredAccessToken(user, kakaoClient, now);
let profile: VerifiedKakaoProfile;
try {
profile = readVerifiedKakaoProfile(await kakaoClient.getMe(resolved.accessToken));
} catch (error) {
if (error instanceof KakaoVerificationError) {
throw error;
}
throw new KakaoVerificationError(
'REAUTH_REQUIRED',
'카카오 계정 정보를 확인하지 못했습니다. 카카오 로그인을 다시 수행해 주세요.',
{ cause: error }
);
}
if (profile.kakaoId !== user.oauthId) {
throw new KakaoVerificationError(
'IDENTITY_MISMATCH',
'저장된 카카오 계정과 현재 카카오 계정이 일치하지 않습니다.'
);
}
const emailOwner = await users.findByEmail(profile.email);
if (emailOwner && emailOwner.id !== user.id) {
throw new KakaoVerificationError(
'EMAIL_CONFLICT',
'변경된 카카오 이메일이 이미 다른 계정에서 사용 중입니다. 관리자에게 문의해 주세요.'
);
}
try {
const synced = await users.syncKakaoIdentity(user.id, profile.email, resolved.oauthInfo);
return {
user: synced,
accessToken: resolved.accessToken,
oauthInfo: resolved.oauthInfo,
};
} catch (error) {
throw new KakaoVerificationError(
'EMAIL_CONFLICT',
'변경된 카카오 이메일이 이미 다른 계정에서 사용 중입니다. 관리자에게 문의해 주세요.',
{ cause: error }
);
}
};
export const requireKakaoTalkProof = async (options: {
user: UserRecord;
accessToken: string;
kakaoClient: KakaoOAuthClient;
oauthSessions: OAuthSessionStore;
publicBaseUrl: string;
now?: Date;
}): Promise<KakaoOtpRequired | null> => {
const now = options.now ?? new Date();
const validUntil = parseDate(options.user.kakaoTalkVerifiedUntil);
if (validUntil && isAfter(validUntil, now)) {
return null;
}
const active = await options.oauthSessions.getLoginChallengeForUser(options.user.id);
if (active) {
return {
status: 'otp',
challengeId: active.id,
expiresAt: active.expiresAt,
attemptsRemaining: active.attemptsRemaining,
};
}
const expiresAt = addSeconds(now, KAKAO_OTP_TTL_SECONDS);
const challenge = await options.oauthSessions.createLoginChallenge({
userId: options.user.id,
code: String(randomInt(1000, 10_000)),
attemptsRemaining: KAKAO_OTP_ATTEMPTS,
expiresAt: expiresAt.toISOString(),
createdAt: now.toISOString(),
});
try {
await options.kakaoClient.sendTalkMessage(
options.accessToken,
`인증 코드는 ${challenge.code} 입니다. ${challenge.expiresAt} 이내에 입력해 주세요.`,
options.publicBaseUrl
);
} catch (error) {
await options.oauthSessions.verifyLoginChallenge(challenge.id, challenge.code);
throw new KakaoVerificationError('MESSAGE_FAILED', '카카오톡 인증 코드를 보내지 못했습니다.', {
cause: error,
});
}
return {
status: 'otp',
challengeId: challenge.id,
expiresAt: challenge.expiresAt,
attemptsRemaining: challenge.attemptsRemaining,
};
};
export const verifyKakaoTalkChallenge = async (options: {
challengeId: string;
code: string;
oauthSessions: OAuthSessionStore;
users: UserRepository;
now?: Date;
}): Promise<{ user: UserRecord; validUntil: string }> => {
const now = options.now ?? new Date();
const result = await options.oauthSessions.verifyLoginChallenge(options.challengeId, options.code, now);
if (result.status === 'expired') {
throw new KakaoVerificationError('REAUTH_REQUIRED', '인증 기한이 만료되었습니다. 다시 로그인해 주세요.');
}
if (result.status === 'locked') {
throw new KakaoVerificationError(
'IDENTITY_MISMATCH',
`인증 실패 횟수를 초과했습니다. ${result.expiresAt}까지 기다려 주세요.`
);
}
if (result.status === 'mismatch') {
throw new KakaoVerificationError(
'IDENTITY_MISMATCH',
result.attemptsRemaining > 0
? `인증 번호가 틀렸습니다. ${result.attemptsRemaining}회 더 시도할 수 있습니다.`
: '인증 실패 횟수를 초과했습니다. 다시 로그인해 주세요.'
);
}
const user = await options.users.findById(result.userId);
if (!user || user.oauthType !== 'KAKAO' || !user.oauthId) {
throw new KakaoVerificationError('REAUTH_REQUIRED', '카카오 계정 연결 정보를 찾지 못했습니다.');
}
const proofValidUntil = addDays(now, KAKAO_TALK_VERIFICATION_DAYS);
const verified = await options.users.markKakaoTalkVerified(user.id, proofValidUntil);
return { user: verified, validUntil: proofValidUntil.toISOString() };
};
export const mergeRequiredKakaoScopes = (requested?: string[]): string[] => [
...new Set([...(requested ?? []), ...KAKAO_LOGIN_SCOPES]),
];
@@ -23,11 +23,29 @@ export interface OAuthSession {
createdAt: string;
}
export interface KakaoLoginChallenge {
id: string;
userId: string;
code: string;
attemptsRemaining: number;
expiresAt: string;
createdAt: string;
}
export type KakaoLoginChallengeResult =
| { status: 'verified'; userId: string }
| { status: 'mismatch'; attemptsRemaining: number; expiresAt: string }
| { status: 'locked'; expiresAt: string }
| { status: 'expired' };
export interface OAuthSessionStore {
createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState>;
consumePendingState(state: string): Promise<OAuthPendingState | null>;
createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession>;
consumeSession(sessionId: string): Promise<OAuthSession | null>;
getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null>;
createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge>;
verifyLoginChallenge(challengeId: string, code: string, now?: Date): Promise<KakaoLoginChallengeResult>;
}
interface RedisPipeline {
@@ -40,9 +58,39 @@ interface RedisClientLike {
get(key: string): Promise<string | null>;
set(key: string, value: string, options?: { EX?: number }): Promise<unknown>;
del(key: string): Promise<number>;
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
multi(): RedisPipeline;
}
const verifyLoginChallengeScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return '{"status":"expired"}'
end
local challenge = cjson.decode(raw)
if challenge.attemptsRemaining <= 0 then
return cjson.encode({ status = 'locked', expiresAt = challenge.expiresAt })
end
if tostring(challenge.code) ~= ARGV[1] then
challenge.attemptsRemaining = challenge.attemptsRemaining - 1
redis.call('SET', KEYS[1], cjson.encode(challenge), 'KEEPTTL')
return cjson.encode({
status = 'mismatch',
attemptsRemaining = challenge.attemptsRemaining,
expiresAt = challenge.expiresAt
})
end
redis.call('DEL', KEYS[1])
local userKey = ARGV[2] .. challenge.userId
if redis.call('GET', userKey) == challenge.id then
redis.call('DEL', userKey)
end
return cjson.encode({ status = 'verified', userId = challenge.userId })
`;
export class RedisOAuthSessionStore implements OAuthSessionStore {
private readonly client: RedisClientLike;
private readonly prefix: string;
@@ -62,6 +110,18 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
return `${this.prefix}:oauth-session:${sessionId}`;
}
private loginChallengeKey(challengeId: string): string {
return `${this.prefix}:kakao-login-challenge:${challengeId}`;
}
private userLoginChallengeKey(userId: string): string {
return `${this.prefix}:kakao-login-challenge-user:${userId}`;
}
private challengeTtlSeconds(expiresAt: string): number {
return Math.max(1, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 1000));
}
async createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState> {
const state: OAuthPendingState = {
state: randomUUID(),
@@ -106,12 +166,63 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
await this.client.del(key);
return parseJson<OAuthSession>(raw);
}
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
const challengeId = await this.client.get(this.userLoginChallengeKey(userId));
if (!challengeId) {
return null;
}
const raw = await this.client.get(this.loginChallengeKey(challengeId));
if (!raw) {
await this.client.del(this.userLoginChallengeKey(userId));
return null;
}
const challenge = parseJson<KakaoLoginChallenge>(raw);
if (!challenge) {
await this.client.del(this.userLoginChallengeKey(userId));
return null;
}
if (new Date(challenge.expiresAt).getTime() <= Date.now()) {
await this.client
.multi()
.del(this.loginChallengeKey(challenge.id))
.del(this.userLoginChallengeKey(userId))
.exec();
return null;
}
return challenge;
}
async createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge> {
const stored: KakaoLoginChallenge = {
...challenge,
id: randomUUID(),
};
const ttlSeconds = this.challengeTtlSeconds(stored.expiresAt);
await this.client
.multi()
.set(this.loginChallengeKey(stored.id), JSON.stringify(stored), { EX: ttlSeconds })
.set(this.userLoginChallengeKey(stored.userId), stored.id, { EX: ttlSeconds })
.exec();
return stored;
}
async verifyLoginChallenge(challengeId: string, code: string): Promise<KakaoLoginChallengeResult> {
const raw = await this.client.eval(verifyLoginChallengeScript, {
keys: [this.loginChallengeKey(challengeId)],
arguments: [code, `${this.prefix}:kakao-login-challenge-user:`],
});
const result = typeof raw === 'string' ? parseJson<KakaoLoginChallengeResult>(raw) : null;
return result ?? { status: 'expired' };
}
}
// 테스트용 인메모리 OAuth 세션 저장소.
export class InMemoryOAuthSessionStore implements OAuthSessionStore {
private readonly pendingStates = new Map<string, OAuthPendingState>();
private readonly sessions = new Map<string, OAuthSession>();
private readonly loginChallenges = new Map<string, KakaoLoginChallenge>();
private readonly userLoginChallenges = new Map<string, string>();
async createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState> {
const pending: OAuthPendingState = {
@@ -149,4 +260,60 @@ export class InMemoryOAuthSessionStore implements OAuthSessionStore {
}
return session;
}
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
const challengeId = this.userLoginChallenges.get(userId);
const challenge = challengeId ? this.loginChallenges.get(challengeId) : undefined;
if (!challenge || new Date(challenge.expiresAt).getTime() <= Date.now()) {
if (challengeId) {
this.loginChallenges.delete(challengeId);
}
this.userLoginChallenges.delete(userId);
return null;
}
return challenge;
}
async createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge> {
const stored: KakaoLoginChallenge = {
...challenge,
id: randomUUID(),
};
this.loginChallenges.set(stored.id, stored);
this.userLoginChallenges.set(stored.userId, stored.id);
return stored;
}
async verifyLoginChallenge(
challengeId: string,
code: string,
now = new Date()
): Promise<KakaoLoginChallengeResult> {
const challenge = this.loginChallenges.get(challengeId);
if (!challenge || new Date(challenge.expiresAt).getTime() <= now.getTime()) {
if (challenge) {
this.loginChallenges.delete(challengeId);
if (this.userLoginChallenges.get(challenge.userId) === challengeId) {
this.userLoginChallenges.delete(challenge.userId);
}
}
return { status: 'expired' };
}
if (challenge.attemptsRemaining <= 0) {
return { status: 'locked', expiresAt: challenge.expiresAt };
}
if (challenge.code !== code) {
challenge.attemptsRemaining -= 1;
return {
status: 'mismatch',
attemptsRemaining: challenge.attemptsRemaining,
expiresAt: challenge.expiresAt,
};
}
this.loginChallenges.delete(challengeId);
if (this.userLoginChallenges.get(challenge.userId) === challengeId) {
this.userLoginChallenges.delete(challenge.userId);
}
return { status: 'verified', userId: challenge.userId };
}
}
@@ -58,6 +58,7 @@ const mapUser = (row: {
termsAcceptedAt: Date | null;
privacyAcceptedAt: Date | null;
kakaoVerifiedAt: Date | null;
kakaoTalkVerifiedUntil: Date | null;
kakaoGraceStartedAt: Date;
kakaoGraceUntil: Date | null;
deleteAfter: Date | null;
@@ -83,6 +84,7 @@ const mapUser = (row: {
termsAcceptedAt: row.termsAcceptedAt?.toISOString(),
privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(),
kakaoVerifiedAt: row.kakaoVerifiedAt?.toISOString(),
kakaoTalkVerifiedUntil: row.kakaoTalkVerifiedUntil?.toISOString(),
kakaoGraceStartedAt: row.kakaoGraceStartedAt.toISOString(),
kakaoGraceUntil: row.kakaoGraceUntil?.toISOString(),
deleteAfter: row.deleteAfter?.toISOString(),
@@ -223,6 +225,23 @@ export const createPostgresUserRepository = (
},
});
},
async syncKakaoIdentity(userId: string, email: string, oauthInfo: UserOAuthInfo): Promise<UserRecord> {
const row = await prisma.appUser.update({
where: { id: userId },
data: {
email: email.toLowerCase(),
oauthInfo: oauthInfo as GatewayPrisma.JsonObject,
},
});
return mapUser(row);
},
async markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord> {
const row = await prisma.appUser.update({
where: { id: userId },
data: { kakaoTalkVerifiedUntil: validUntil },
});
return mapUser(row);
},
async linkKakao(userId, input): Promise<UserRecord> {
const row = await prisma.appUser.update({
where: { id: userId },
@@ -18,6 +18,7 @@ export interface UserRecord {
termsAcceptedAt?: string;
privacyAcceptedAt?: string;
kakaoVerifiedAt?: string;
kakaoTalkVerifiedUntil?: string;
kakaoGraceStartedAt: string;
kakaoGraceUntil?: string;
deleteAfter?: string;
@@ -110,6 +111,8 @@ export interface UserRepository {
verifyPassword(user: UserRecord, password: string): Promise<boolean>;
updatePassword(userId: string, password: string): Promise<void>;
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
syncKakaoIdentity(userId: string, email: string, oauthInfo: UserOAuthInfo): Promise<UserRecord>;
markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord>;
linkKakao(
userId: string,
input: {
+175 -63
View File
@@ -9,13 +9,23 @@ import { isGameAccessBlocked, isLoginBanned } from '@sammo-ts/common/auth/sancti
import { procedure, router } from './trpc.js';
import { toPublicUser } from './auth/userRepository.js';
import type { UserOAuthInfo } from './auth/userRepository.js';
import type { UserOAuthInfo, UserRecord } from './auth/userRepository.js';
import { adminRouter } from './adminRouter.js';
import { accountRouter } from './account/router.js';
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
import type { GatewayApiContext } from './context.js';
import {
KakaoVerificationError,
mergeRequiredKakaoScopes,
oauthInfoFromToken,
readVerifiedKakaoProfile,
requireKakaoTalkProof,
verifyKakaoTalkChallenge,
verifyStoredKakaoIdentity,
} from './auth/kakaoAccountVerification.js';
const zUsername = z
.string()
@@ -32,6 +42,50 @@ const parseDate = (value: string): Date | null => {
return isValid(parsed) ? parsed : null;
};
const throwKakaoVerificationError = (error: unknown): never => {
if (!(error instanceof KakaoVerificationError)) {
throw error;
}
const code =
error.verificationCode === 'EMAIL_CONFLICT'
? 'CONFLICT'
: error.verificationCode === 'IDENTITY_MISMATCH'
? 'UNAUTHORIZED'
: error.verificationCode === 'EMAIL_REQUIRED' || error.verificationCode === 'EMAIL_UNVERIFIED'
? 'BAD_REQUEST'
: 'PRECONDITION_FAILED';
throw new TRPCError({ code, message: error.message, cause: error });
};
const finishKakaoLogin = async <T extends 'login' | 'verified'>(
ctx: GatewayApiContext,
user: UserRecord,
accessToken: string,
successStatus: T
) => {
try {
const challenge = await requireKakaoTalkProof({
user,
accessToken,
kakaoClient: ctx.kakaoClient,
oauthSessions: ctx.oauthSessions,
publicBaseUrl: ctx.publicBaseUrl,
});
if (challenge) {
return { ...challenge, successStatus };
}
} catch (error) {
throwKakaoVerificationError(error);
}
const session = await ctx.sessions.createSession(user);
return {
status: successStatus,
user: toPublicUser(user),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
};
};
export const appRouter = router({
health: router({
ping: procedure.query(() => ({
@@ -157,7 +211,7 @@ export const appRouter = router({
)
.query(async ({ ctx, input }) => {
const mode = input?.mode ?? 'login';
const scopes = input?.scopes ?? ['account_email'];
const scopes = mergeRequiredKakaoScopes(input?.scopes);
let userId: string | undefined;
if (mode === 'verify') {
const sessionToken =
@@ -196,10 +250,6 @@ export const appRouter = router({
}
const token = await ctx.kakaoClient.exchangeCode(input.code);
const tokenIssuedAt = new Date();
const accessTokenValidUntil = addSeconds(tokenIssuedAt, token.accessTokenExpiresIn).toISOString();
const refreshTokenValidUntil = token.refreshTokenExpiresIn
? addSeconds(tokenIssuedAt, token.refreshTokenExpiresIn).toISOString()
: undefined;
const signupResult = await ctx.kakaoClient.signup(token.accessToken);
if (!signupResult.id && signupResult.msg !== 'already registered') {
@@ -209,30 +259,23 @@ export const appRouter = router({
});
}
const me = await ctx.kakaoClient.getMe(token.accessToken);
const kakaoAccount = me.kakaoAccount;
if (!kakaoAccount.hasEmail || !kakaoAccount.email) {
const profile = (() => {
try {
return readVerifiedKakaoProfile(me);
} catch (error) {
return throwKakaoVerificationError(error);
}
})();
const [existingById, existingByEmail] = await Promise.all([
ctx.users.findByOauthId('KAKAO', profile.kakaoId),
ctx.users.findByEmail(profile.email),
]);
if (existingByEmail && existingByEmail.id !== existingById?.id) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '이메일 정보 제공에 동의해야 합니다.',
code: 'CONFLICT',
message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.',
});
}
if (!kakaoAccount.isEmailValid || !kakaoAccount.isEmailVerified) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '카카오 계정 이메일이 인증되지 않았습니다.',
});
}
const oauthInfo: UserOAuthInfo = {
accessToken: token.accessToken,
refreshToken: token.refreshToken,
accessTokenValidUntil,
refreshTokenValidUntil,
};
const existing =
(await ctx.users.findByOauthId('KAKAO', me.id)) ??
(await ctx.users.findByEmail(kakaoAccount.email));
if (pending.mode === 'verify') {
if (!pending.userId) {
@@ -254,18 +297,31 @@ export const appRouter = router({
message: 'Account login is blocked.',
});
}
if (existing && existing.id !== localUser.id) {
if (existingById && existingById.id !== localUser.id) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 계정에 연결된 카카오 계정입니다.',
});
}
let verified = localUser;
if (existing?.id !== localUser.id) {
if (existingByEmail && existingByEmail.id !== localUser.id) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.',
});
}
if (localUser.oauthType === 'KAKAO' && localUser.oauthId !== profile.kakaoId) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 카카오 계정에 연결된 사용자입니다.',
});
}
const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, localUser.oauthInfo);
let verified: UserRecord;
if (existingById?.id !== localUser.id && localUser.oauthType !== 'KAKAO') {
try {
verified = await ctx.users.linkKakao(localUser.id, {
oauthId: me.id,
email: kakaoAccount.email,
oauthId: profile.kakaoId,
email: profile.email,
oauthInfo,
verifiedAt: new Date(),
});
@@ -276,28 +332,40 @@ export const appRouter = router({
cause: error,
});
}
}
if (existing?.id === localUser.id) {
await ctx.users.updateOAuthInfo(localUser.id, oauthInfo);
} else {
try {
verified = await ctx.users.syncKakaoIdentity(localUser.id, profile.email, oauthInfo);
} catch (error) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.',
cause: error,
});
}
}
const refreshed = (await ctx.users.findById(verified.id)) ?? verified;
const session = await ctx.sessions.createSession(refreshed);
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified');
return {
status: 'verified' as const,
user: toPublicUser(refreshed),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
};
return finishKakaoLogin(ctx, refreshed, token.accessToken, 'verified');
}
if (pending.mode === 'change_pw') {
if (!existing) {
if (!existingById) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '카카오 계정에 연결된 사용자를 찾지 못했습니다.',
});
}
const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, existingById.oauthInfo);
let existing: UserRecord;
try {
existing = await ctx.users.syncKakaoIdentity(existingById.id, profile.email, oauthInfo);
} catch (error) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.',
cause: error,
});
}
const nextPasswordChange = existing.oauthInfo?.nextPasswordChange
? parseDate(existing.oauthInfo.nextPasswordChange)
: null;
@@ -326,31 +394,36 @@ export const appRouter = router({
};
}
if (existing) {
if (isLoginBanned(existing.sanctions)) {
if (existingById) {
if (isLoginBanned(existingById.sanctions)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account login is blocked.',
});
}
await ctx.users.updateOAuthInfo(existing.id, oauthInfo);
const session = await ctx.sessions.createSession(existing);
return {
status: 'login' as const,
user: toPublicUser(existing),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
};
const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, existingById.oauthInfo);
let synced: UserRecord;
try {
synced = await ctx.users.syncKakaoIdentity(existingById.id, profile.email, oauthInfo);
} catch (error) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.',
cause: error,
});
}
return finishKakaoLogin(ctx, synced, token.accessToken, 'login');
}
const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
const stored = await ctx.oauthSessions.createSession({
mode: pending.mode,
kakaoId: me.id,
email: kakaoAccount.email,
kakaoId: profile.kakaoId,
email: profile.email,
accessToken: token.accessToken,
refreshToken: token.refreshToken,
accessTokenValidUntil,
refreshTokenValidUntil,
accessTokenValidUntil: joinOauthInfo.accessTokenValidUntil!,
refreshTokenValidUntil: joinOauthInfo.refreshTokenValidUntil,
createdAt: new Date().toISOString(),
});
@@ -440,12 +513,7 @@ export const appRouter = router({
cause: error,
});
}
const session = await ctx.sessions.createSession(created);
return {
user: toPublicUser(created),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
};
return finishKakaoLogin(ctx, created, oauthSession.accessToken, 'login');
}),
passwordKey: procedure.query(({ ctx }) => ctx.passwordEnvelope.getPublicKey()),
checkRegistrationField: procedure
@@ -566,13 +634,57 @@ export const appRouter = router({
message: 'Account login is blocked.',
});
}
if (user.oauthType === 'KAKAO') {
const ready = await verifyStoredKakaoIdentity({
user,
users: ctx.users,
kakaoClient: ctx.kakaoClient,
}).catch((error: unknown) => throwKakaoVerificationError(error));
return finishKakaoLogin(ctx, ready.user, ready.accessToken, 'login');
}
const session = await ctx.sessions.createSession(user);
return {
status: 'login' as const,
user: toPublicUser(user),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
};
}),
kakaoOtp: procedure
.input(
z.object({
challengeId: z.string().uuid(),
code: z.string().regex(/^\d{4}$/, '인증 코드는 숫자 4자리입니다.'),
})
)
.mutation(async ({ ctx, input }) => {
const verified = await verifyKakaoTalkChallenge({
challengeId: input.challengeId,
code: input.code,
oauthSessions: ctx.oauthSessions,
users: ctx.users,
}).catch((error: unknown) => throwKakaoVerificationError(error));
if (verified.user.deleteAfter) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account deletion is pending.',
});
}
if (isLoginBanned(verified.user.sanctions)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account login is blocked.',
});
}
const session = await ctx.sessions.createSession(verified.user);
return {
status: 'login' as const,
user: toPublicUser(verified.user),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
validUntil: verified.validUntil,
};
}),
me: procedure
.input(
z.object({
+275 -15
View File
@@ -22,6 +22,9 @@ const buildCaller = (
localAccountGraceDays?: number;
flushError?: Error;
profileListError?: Error;
kakaoId?: string;
kakaoEmail?: string;
allowKakaoRefresh?: boolean;
} = {}
) => {
const users = createInMemoryUserRepository();
@@ -37,32 +40,48 @@ const buildCaller = (
}),
};
const oauthSessions = new InMemoryOAuthSessionStore();
const kakaoProfile = {
id: options.kakaoId ?? '1',
email: options.kakaoEmail ?? 'tester@example.com',
};
const sentTalkMessages: string[] = [];
const refreshTokenCalls: string[] = [];
const kakaoClient = {
restKey: '',
redirectUri: '',
oauthHost: '',
apiHost: '',
buildAuthUrl: (state: string) => `https://kauth.example.test/authorize?state=${state}`,
buildAuthUrl: (state: string, scopes: string[]) =>
`https://kauth.example.test/authorize?state=${state}&scope=${scopes.join(',')}`,
exchangeCode: async () => ({
accessToken: 'access-token',
accessTokenExpiresIn: 3600,
refreshToken: 'refresh-token',
refreshTokenExpiresIn: 86400,
}),
refreshToken: async () => {
throw new Error('not used');
refreshToken: async (refreshToken: string) => {
refreshTokenCalls.push(refreshToken);
if (!options.allowKakaoRefresh) {
throw new Error('not used');
}
return {
accessToken: 'refreshed-access-token',
accessTokenExpiresIn: 3600,
};
},
signup: async () => ({ id: '1' }),
getMe: async () => ({
id: '1',
id: kakaoProfile.id,
kakaoAccount: {
hasEmail: true,
email: 'tester@example.com',
email: kakaoProfile.email,
isEmailValid: true,
isEmailVerified: true,
},
}),
sendTalkMessage: async () => {},
sendTalkMessage: async (_accessToken: string, message: string) => {
sentTalkMessages.push(message);
},
};
const profileRows = [
{
@@ -213,6 +232,9 @@ const buildCaller = (
sessions,
flushPublisher,
userIconUpload,
kakaoProfile,
sentTalkMessages,
refreshTokenCalls,
sealPassword,
setSessionHeader: (sessionToken: string) => {
requestHeaders['x-session-token'] = sessionToken;
@@ -247,6 +269,10 @@ describe('gateway auth flow', () => {
username: 'LOCAL-USER',
credential: sealPassword('비밀번호-password'),
});
expect(login.status).toBe('login');
if (login.status !== 'login') {
throw new Error('Expected completed login.');
}
expect(login.user.username).toBe('local-user');
});
@@ -405,7 +431,7 @@ describe('gateway auth flow', () => {
});
it('links Kakao to the logged-in local account instead of creating a second user', async () => {
const { caller, users, sealPassword, setSessionHeader } = buildCaller();
const { caller, users, sealPassword, setSessionHeader, sentTalkMessages } = buildCaller();
const register = await caller.auth.registerLocal({
username: 'verify-user',
credential: sealPassword('verify-password'),
@@ -421,11 +447,14 @@ describe('gateway auth flow', () => {
state: start.state,
});
expect(verified.status).toBe('verified');
if (verified.status !== 'verified') {
throw new Error('Expected verified result.');
expect(verified.status).toBe('otp');
if (verified.status !== 'otp') {
throw new Error('Expected Kakao OTP challenge.');
}
expect(verified.user.kakaoVerified).toBe(true);
const code = sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1];
expect(code).toBeTruthy();
const completed = await caller.auth.kakaoOtp({ challengeId: verified.challengeId, code: code! });
expect(completed.user.kakaoVerified).toBe(true);
const stored = await users.findByUsername('verify-user');
expect(stored).toMatchObject({
oauthType: 'KAKAO',
@@ -435,6 +464,230 @@ describe('gateway auth flow', () => {
expect(stored?.kakaoVerifiedAt).toBeTruthy();
});
it('always requests both email and KakaoTalk message consent', async () => {
const { caller } = buildCaller();
const start = await caller.auth.kakaoStart({ mode: 'login', scopes: [] });
expect(decodeURIComponent(start.authUrl)).toContain('scope=account_email,talk_message');
});
it('rejects a new Kakao identity when its verified email is already registered', async () => {
const { caller, users, kakaoProfile } = buildCaller();
await users.createUser({
username: 'email-owner',
password: 'owner-password',
oauth: {
type: 'KAKAO',
id: 'original-kakao-id',
email: 'tester@example.com',
info: {},
},
});
kakaoProfile.id = 'different-kakao-id';
const start = await caller.auth.kakaoStart({ mode: 'login' });
await expect(caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state })).rejects.toMatchObject({
code: 'CONFLICT',
message: expect.stringContaining('이미 다른 계정에서 사용 중인 카카오 이메일'),
});
});
it('synchronizes a changed email by stable Kakao ID during Kakao login', async () => {
const { caller, users, kakaoProfile } = buildCaller({
kakaoId: 'stable-kakao-id',
kakaoEmail: 'changed@example.com',
});
const user = await users.createUser({
username: 'kakao-email-change',
password: 'email-change-password',
oauth: {
type: 'KAKAO',
id: 'stable-kakao-id',
email: 'before@example.com',
info: {},
},
});
await users.markKakaoTalkVerified(user.id, new Date(Date.now() + 60_000));
const start = await caller.auth.kakaoStart({ mode: 'login' });
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
expect(login.status).toBe('login');
expect((await users.findById(user.id))?.email).toBe(kakaoProfile.email);
expect(await users.findByEmail('before@example.com')).toBeNull();
});
it('checks Kakao identity and changed email on password login, then verifies the talk OTP', async () => {
const { caller, users, sealPassword, kakaoProfile, sentTalkMessages } = buildCaller({
kakaoId: 'password-login-kakao-id',
kakaoEmail: 'after-password-login@example.com',
});
const user = await users.createUser({
username: 'kakao-password-login',
password: 'kakao-password',
oauth: {
type: 'KAKAO',
id: kakaoProfile.id,
email: 'before-password-login@example.com',
info: {
accessToken: 'stored-access-token',
refreshToken: 'stored-refresh-token',
accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(),
refreshTokenValidUntil: new Date(Date.now() + 86_400_000).toISOString(),
},
},
});
const login = await caller.auth.login({
username: user.username,
credential: sealPassword('kakao-password'),
});
expect(login.status).toBe('otp');
if (login.status !== 'otp') {
throw new Error('Expected Kakao OTP challenge.');
}
expect((await users.findById(user.id))?.email).toBe(kakaoProfile.email);
expect(sentTalkMessages).toHaveLength(1);
await expect(caller.auth.kakaoOtp({ challengeId: login.challengeId, code: '0000' })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
message: expect.stringContaining('2회 더 시도'),
});
const code = sentTalkMessages[0]?.match(/인증 코드는 (\d{4})/)?.[1];
expect(code).toBeTruthy();
const completed = await caller.auth.kakaoOtp({ challengeId: login.challengeId, code: code! });
expect(completed.validUntil).toBeTruthy();
expect((await users.findById(user.id))?.kakaoTalkVerifiedUntil).toBe(completed.validUntil);
const nextLogin = await caller.auth.login({
username: user.username,
credential: sealPassword('kakao-password'),
});
expect(nextLogin.status).toBe('login');
expect(sentTalkMessages).toHaveLength(1);
});
it('refreshes an expired access token before the password-login identity check', async () => {
const { caller, users, sealPassword, kakaoProfile, refreshTokenCalls } = buildCaller({
kakaoId: 'refresh-kakao-id',
kakaoEmail: 'refreshed-email@example.com',
allowKakaoRefresh: true,
});
const user = await users.createUser({
username: 'refresh-kakao-user',
password: 'refresh-kakao-password',
oauth: {
type: 'KAKAO',
id: kakaoProfile.id,
email: 'old-refresh-email@example.com',
info: {
accessToken: 'expired-access-token',
refreshToken: 'usable-refresh-token',
accessTokenValidUntil: new Date(Date.now() - 60_000).toISOString(),
refreshTokenValidUntil: new Date(Date.now() + 86_400_000).toISOString(),
},
},
});
const login = await caller.auth.login({
username: user.username,
credential: sealPassword('refresh-kakao-password'),
});
expect(login.status).toBe('otp');
expect(refreshTokenCalls).toEqual(['usable-refresh-token']);
expect(await users.findById(user.id)).toMatchObject({
email: 'refreshed-email@example.com',
oauthInfo: {
accessToken: 'refreshed-access-token',
refreshToken: 'usable-refresh-token',
},
});
});
it('rejects password-login email synchronization when the changed email belongs to another user', async () => {
const { caller, users, sealPassword, kakaoProfile } = buildCaller({
kakaoId: 'conflicting-email-kakao-id',
kakaoEmail: 'occupied@example.com',
});
const user = await users.createUser({
username: 'conflicting-email-user',
password: 'conflicting-email-password',
oauth: {
type: 'KAKAO',
id: kakaoProfile.id,
email: 'previous@example.com',
info: {
accessToken: 'stored-access-token',
accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(),
},
},
});
await users.createUser({
username: 'occupied-email-owner',
password: 'occupied-email-password',
oauth: {
type: 'KAKAO',
id: 'other-kakao-id',
email: kakaoProfile.email,
info: {},
},
});
await expect(
caller.auth.login({
username: user.username,
credential: sealPassword('conflicting-email-password'),
})
).rejects.toMatchObject({
code: 'CONFLICT',
message: expect.stringContaining('이미 다른 계정에서 사용 중'),
});
expect((await users.findById(user.id))?.email).toBe('previous@example.com');
});
it('reuses the active challenge and blocks retries after three wrong OTP values', async () => {
const { caller, users, sealPassword, kakaoProfile, sentTalkMessages } = buildCaller({
kakaoId: 'attempt-limit-kakao-id',
});
const user = await users.createUser({
username: 'attempt-limit-user',
password: 'attempt-limit-password',
oauth: {
type: 'KAKAO',
id: kakaoProfile.id,
email: kakaoProfile.email,
info: {
accessToken: 'stored-access-token',
accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(),
},
},
});
const login = await caller.auth.login({
username: user.username,
credential: sealPassword('attempt-limit-password'),
});
expect(login.status).toBe('otp');
if (login.status !== 'otp') {
throw new Error('Expected Kakao OTP challenge.');
}
for (const remaining of [2, 1, 0]) {
await expect(caller.auth.kakaoOtp({ challengeId: login.challengeId, code: '0000' })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
message:
remaining > 0 ? expect.stringContaining(`${remaining}회 더 시도`) : expect.stringContaining('초과'),
});
}
const retried = await caller.auth.login({
username: user.username,
credential: sealPassword('attempt-limit-password'),
});
expect(retried).toMatchObject({ status: 'otp', challengeId: login.challengeId, attemptsRemaining: 0 });
expect(sentTalkMessages).toHaveLength(1);
});
it('blocks Kakao login while a ban is active', async () => {
const { caller, users, sealPassword, setSessionHeader } = buildCaller();
const register = await caller.auth.registerLocal({
@@ -503,7 +756,7 @@ describe('gateway auth flow', () => {
});
it('registers and issues a game session', async () => {
const { caller, oauthSessions, sealPassword } = buildCaller();
const { caller, oauthSessions, sealPassword, sentTalkMessages } = buildCaller();
const oauthSession = await oauthSessions.createSession({
mode: 'login',
kakaoId: '1',
@@ -524,11 +777,18 @@ describe('gateway auth flow', () => {
thirdPartyUse: false,
});
expect(register.user.username).toBe('tester');
expect(register.sessionToken).toBeTruthy();
expect(register.status).toBe('otp');
if (register.status !== 'otp') {
throw new Error('Expected Kakao OTP challenge.');
}
const code = sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1];
expect(code).toBeTruthy();
const completed = await caller.auth.kakaoOtp({ challengeId: register.challengeId, code: code! });
expect(completed.user.username).toBe('tester');
expect(completed.sessionToken).toBeTruthy();
const issued = await caller.auth.issueGameSession({
sessionToken: register.sessionToken,
sessionToken: completed.sessionToken,
profile: 'che:default',
});
@@ -0,0 +1,78 @@
import { randomUUID } from 'node:crypto';
import { createClient } from 'redis';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { RedisOAuthSessionStore } from '../src/auth/oauthSessionStore.js';
const redisUrl = process.env.GATEWAY_OAUTH_REDIS_TEST_URL;
describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () => {
const prefix = `gateway-oauth-test:${randomUUID()}`;
const client = createClient({ url: redisUrl });
const store = new RedisOAuthSessionStore(client, prefix, 300);
const userIds = new Set<string>();
const challengeIds = new Set<string>();
beforeAll(async () => {
await client.connect();
});
afterAll(async () => {
const keys = [
...[...challengeIds].map((id) => `${prefix}:kakao-login-challenge:${id}`),
...[...userIds].map((id) => `${prefix}:kakao-login-challenge-user:${id}`),
];
if (keys.length > 0) {
await client.del(keys);
}
await client.quit();
});
const createChallenge = async () => {
const userId = randomUUID();
userIds.add(userId);
const challenge = await store.createLoginChallenge({
userId,
code: '4321',
attemptsRemaining: 3,
expiresAt: new Date(Date.now() + 180_000).toISOString(),
createdAt: new Date().toISOString(),
});
challengeIds.add(challenge.id);
return challenge;
};
it('atomically consumes a successful code once', async () => {
const challenge = await createChallenge();
const results = await Promise.all([
store.verifyLoginChallenge(challenge.id, challenge.code),
store.verifyLoginChallenge(challenge.id, challenge.code),
]);
expect(results.filter((result) => result.status === 'verified')).toHaveLength(1);
expect(results.filter((result) => result.status === 'expired')).toHaveLength(1);
expect(await store.getLoginChallengeForUser(challenge.userId)).toBeNull();
});
it('atomically limits parallel wrong-code submissions to three attempts', async () => {
const challenge = await createChallenge();
const results = await Promise.all(
Array.from({ length: 4 }, () => store.verifyLoginChallenge(challenge.id, '0000'))
);
expect(results.filter((result) => result.status === 'mismatch')).toHaveLength(3);
expect(results.filter((result) => result.status === 'locked')).toHaveLength(1);
expect(
results
.filter((result) => result.status === 'mismatch')
.map((result) => result.attemptsRemaining)
.sort()
).toEqual([0, 1, 2]);
await expect(store.verifyLoginChallenge(challenge.id, challenge.code)).resolves.toMatchObject({
status: 'locked',
});
});
});
+1 -1
View File
@@ -37,7 +37,7 @@ describe('readReleaseManifest', () => {
const workspaceRoot = path.resolve(import.meta.dirname, '../../..');
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
gatewaySchemaHead: '20260806000000_add_admin_audit_and_kakao_grace',
gatewaySchemaHead: '20260808000000_add_kakao_talk_verification',
gameSchemaHead: '20260803000000_add_logical_game_clock',
});
});
+218
View File
@@ -0,0 +1,218 @@
import { generateKeyPairSync } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test';
const artifactRoot = process.env.KAKAO_OTP_ARTIFACT_DIR ? resolve(process.env.KAKAO_OTP_ARTIFACT_DIR) : null;
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string) => ({
error: {
message,
code: -32001,
data: {
code: 'UNAUTHORIZED',
httpStatus: 401,
path,
},
},
});
const operationNames = (route: Route): string[] => {
const url = new URL(route.request().url());
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 challenge = {
status: 'otp' as const,
challengeId: '11111111-1111-4111-8111-111111111111',
expiresAt: '2026-08-08T06:00:00.000Z',
attemptsRemaining: 3,
};
const installFixture = async (page: Page, source: 'password' | 'oauth') => {
let loggedIn = false;
let otpAttempts = 0;
await page.route('**/gateway/api/trpc/**', async (route) => {
const operations = operationNames(route);
const results = await Promise.all(
operations.map(async (operation) => {
if (operation === 'me') {
return response(
loggedIn
? {
id: 'kakao-otp-user',
username: 'kakao-otp-user',
displayName: '카카오 인증 사용자',
roles: [],
picture: 'default.jpg',
kakaoVerified: true,
kakaoGraceStartedAt: '2026-08-08T00:00:00.000Z',
createdAt: '2026-08-08T00:00:00.000Z',
}
: null
);
}
if (operation === 'lobby.notice') return response('');
if (operation === 'lobby.profiles') return response([]);
if (operation === 'auth.passwordKey') {
return response({ keyId: 'playwright-key', publicKeyPem, algorithm: 'RSA-OAEP-256' });
}
if (operation === 'auth.login') return response({ ...challenge, successStatus: 'login' });
if (operation === 'auth.kakaoExchange') {
return response({ ...challenge, successStatus: source === 'oauth' ? 'verified' : 'login' });
}
if (operation === 'auth.kakaoOtp') {
otpAttempts += 1;
await new Promise((resolveDelay) => setTimeout(resolveDelay, 800));
if (otpAttempts === 1) {
return errorResponse(operation, '인증 번호가 틀렸습니다. 2회 더 시도할 수 있습니다.');
}
loggedIn = true;
return response({
status: 'login',
user: {
id: 'kakao-otp-user',
username: 'kakao-otp-user',
displayName: '카카오 인증 사용자',
roles: [],
picture: 'default.jpg',
kakaoVerified: true,
kakaoGraceStartedAt: '2026-08-08T00:00:00.000Z',
createdAt: '2026-08-08T00:00:00.000Z',
},
sessionToken: 'verified-session-token',
issuedAt: '2026-08-08T05:57:00.000Z',
validUntil: '2026-08-18T05:57:00.000Z',
});
}
throw new Error(`Unhandled Kakao OTP fixture operation: ${operation}`);
})
);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
return { otpAttempts: () => otpAttempts };
};
const verifyDialog = async (page: Page, artifactName: string) => {
const dialog = page.getByRole('dialog', { name: '인증 코드 필요' });
const input = dialog.getByLabel('인증 코드');
const submit = dialog.getByRole('button', { name: '제출' });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText("카카오톡의 '나와의 채팅'란을 확인해 주세요.");
await expect(input).toBeFocused();
const geometry = await dialog.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
backgroundColor: style.backgroundColor,
border: style.border,
fontSize: style.fontSize,
};
});
expect(geometry.width).toBeLessThanOrEqual(500);
expect(geometry.width).toBeGreaterThan(350);
expect(geometry.backgroundColor).toBe('rgb(48, 48, 48)');
expect(geometry.border).toBe('1px solid rgb(68, 68, 68)');
await submit.hover();
await page.waitForTimeout(200);
expect(await submit.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(55, 90, 127)');
await input.press('Tab');
await page.keyboard.press('Tab');
await expect(submit).toBeFocused();
await page.waitForTimeout(200);
expect(await submit.evaluate((element) => getComputedStyle(element).boxShadow)).toMatch(
/^rgba\(85, 115, 146, 0\.49\d\) 0px 0px 0px 4px$/
);
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
await page.screenshot({ path: resolve(artifactRoot, `${artifactName}.png`), fullPage: true });
await writeFile(
resolve(artifactRoot, `${artifactName}.json`),
`${JSON.stringify(geometry, null, 2)}\n`,
'utf8'
);
}
const verifyPointerActive = async () => {
const box = await submit.boundingBox();
if (!box) throw new Error('OTP submit button has no rendered geometry.');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
expect(await submit.evaluate((element) => element.matches(':active'))).toBe(true);
await page.waitForTimeout(200);
expect(await submit.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(44, 72, 102)');
await page.mouse.move(1, 1);
await page.mouse.up();
};
const submitAndObserveDisabled = () =>
submit.evaluate(
(element) =>
new Promise<{ disabled: boolean; opacity: string }>((resolveDisabled) => {
(element as HTMLButtonElement).click();
setTimeout(
() =>
resolveDisabled({
disabled: (element as HTMLButtonElement).disabled,
opacity: getComputedStyle(element).opacity,
}),
200
);
})
);
await input.fill('0000');
await verifyPointerActive();
expect(await submitAndObserveDisabled()).toEqual({ disabled: true, opacity: '0.65' });
await expect(dialog.getByRole('alert')).toContainText('2회 더 시도');
await expect(input).toBeFocused();
await input.fill('1234');
expect(await submitAndObserveDisabled()).toEqual({ disabled: true, opacity: '0.65' });
await expect(page).toHaveURL(/\/gateway\/lobby(?:\?verified=1)?$/);
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem('sammo-session-token')))
.toBe('verified-session-token');
return geometry;
};
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 390, height: 844 },
] as const) {
test(`completes password-login KakaoTalk OTP on ${viewport.name}`, async ({ page }) => {
const fixture = await installFixture(page, 'password');
await page.setViewportSize(viewport);
await page.goto('/gateway/');
await page.getByLabel('계정명').fill('kakao-otp-user');
await page.getByLabel('비밀번호').fill('password-for-browser-fixture');
await page.getByRole('button', { name: '로그인', exact: true }).click();
const geometry = await verifyDialog(page, `kakao-otp-password-${viewport.name}`);
expect(geometry.width).toBe(viewport.name === 'desktop' ? 500 : 374);
expect(geometry.y).toBe(viewport.name === 'desktop' ? 28 : 8);
expect(fixture.otpAttempts()).toBe(2);
});
}
test('completes the same KakaoTalk OTP flow after OAuth callback', async ({ page }) => {
const fixture = await installFixture(page, 'oauth');
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state');
await verifyDialog(page, 'kakao-otp-oauth-callback');
await expect(page).toHaveURL(/\/gateway\/lobby\?verified=1$/);
expect(fixture.otpAttempts()).toBe(2);
});
@@ -60,6 +60,9 @@ const loginGame = async (username: string, password: string) => {
).toString('base64'),
};
const login = await gateway.auth.login.mutate({ username, credential });
if (login.status === 'otp') {
throw new Error('Live lifecycle fixture requires a currently verified KakaoTalk login.');
}
gatewaySession.token = login.sessionToken;
const issued = await gateway.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
@@ -16,6 +16,7 @@ export default defineConfig({
'account-icon-sync.spec.ts',
'legacy-log-html.spec.ts',
'gateway-notice-html.spec.ts',
'kakao-otp.spec.ts',
],
fullyParallel: false,
workers: 1,
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { trpc } from '../utils/trpc';
const props = defineProps<{
challengeId: string;
}>();
const emit = defineEmits<{
verified: [sessionToken: string, validUntil: string];
cancel: [];
}>();
const code = ref('');
const errorMessage = ref('');
const submitting = ref(false);
const codeInput = ref<HTMLInputElement | null>(null);
watch(
() => props.challengeId,
async () => {
code.value = '';
errorMessage.value = '';
await nextTick();
codeInput.value?.focus();
},
{ immediate: true }
);
const submit = async (): Promise<void> => {
errorMessage.value = '';
submitting.value = true;
try {
const result = await trpc.auth.kakaoOtp.mutate({
challengeId: props.challengeId,
code: code.value,
});
emit('verified', result.sessionToken, result.validUntil);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '인증 코드를 확인하지 못했습니다.';
code.value = '';
await nextTick();
codeInput.value?.focus();
} finally {
submitting.value = false;
}
};
</script>
<template>
<Teleport to="body">
<div class="otp-backdrop" @keydown.esc="emit('cancel')">
<section
class="otp-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="kakao-otp-title"
aria-describedby="kakao-otp-description"
>
<header>
<h2 id="kakao-otp-title">인증 코드 필요</h2>
<button class="close-button" type="button" aria-label="닫기" @click="emit('cancel')">×</button>
</header>
<form @submit.prevent="submit">
<div id="kakao-otp-description" class="otp-copy">
인증 코드가 필요합니다.<br /><br />
카카오톡의 '나와의 채팅'란을 확인해 주세요.<br />
(별도의 알림[소리, 진동, 숫자] 발생하지 않습니다.)
</div>
<label class="otp-input-row" for="kakao-otp-code">
<span>인증 코드</span>
<input
id="kakao-otp-code"
ref="codeInput"
v-model="code"
type="text"
inputmode="numeric"
pattern="[0-9]{4}"
maxlength="4"
autocomplete="one-time-code"
placeholder="인증 코드"
required
/>
</label>
<p v-if="errorMessage" class="otp-error" role="alert">{{ errorMessage }}</p>
<footer>
<button class="cancel-button" type="button" @click="emit('cancel')">취소</button>
<button class="submit-button" type="submit" :disabled="submitting">
{{ submitting ? '확인 중…' : '제출' }}
</button>
</footer>
</form>
</section>
</div>
</Teleport>
</template>
<style scoped>
.otp-backdrop {
position: fixed;
z-index: 1000;
inset: 0;
overflow-y: auto;
background: rgb(0 0 0 / 60%);
}
.otp-dialog {
width: min(calc(100% - 16px), 500px);
margin: 28px auto;
overflow: hidden;
border: 1px solid #444;
border-radius: 5px;
background: #303030;
color: #fff;
box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
}
@media (max-width: 575px) {
.otp-dialog {
margin-top: 8px;
}
}
.otp-dialog header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #555;
padding: 16px;
}
.otp-dialog h2 {
margin: 0;
font-size: 20px;
font-weight: 500;
}
.close-button {
width: 26px;
height: 30px;
border: 1px solid #aaa;
background: #fff;
color: #000;
font-size: 16px;
line-height: 1;
cursor: pointer;
}
.otp-copy {
padding: 18px 18px 0;
line-height: 1.5;
}
.otp-input-row {
display: grid;
grid-template-columns: auto 1fr;
margin: 22px 16px 0;
}
.otp-input-row span,
.otp-input-row input {
border: 1px solid #000;
padding: 6px 12px;
}
.otp-input-row span {
border-radius: 4px 0 0 4px;
background: #303030;
color: #adb5bd;
}
.otp-input-row input {
min-width: 0;
border-left: 0;
border-radius: 0 4px 4px 0;
background: #ddd;
color: #303030;
}
.otp-input-row input:focus-visible {
outline: 0;
border-color: #9a9a9a;
box-shadow: 0 0 0 4px rgb(55 90 127 / 25%);
}
.close-button:focus-visible {
outline: 2px solid #375a7f;
outline-offset: 1px;
}
.otp-error {
margin: 8px 18px 0;
font-size: 13px;
}
.otp-error {
color: #ff8a80;
}
.otp-dialog footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
border-top: 1px solid #555;
padding: 15px 16px;
}
.otp-dialog footer button {
min-width: 64px;
border: 1px solid transparent;
border-radius: 4px;
padding: 7px 12px;
color: #fff;
cursor: pointer;
transition:
color 0.15s ease-in-out,
background-color 0.15s ease-in-out,
border-color 0.15s ease-in-out,
box-shadow 0.15s ease-in-out;
}
.cancel-button {
border-color: #444 !important;
background: #444;
}
.cancel-button:active {
background: #363636;
}
.submit-button {
border-color: #325172 !important;
background: #375a7f;
}
.submit-button:hover {
background: #375a7f;
}
.submit-button:focus-visible {
outline: 0;
box-shadow: 0 0 0 4px rgb(85 115 146 / 50%);
}
.submit-button:active {
background: #2c4866;
}
.submit-button:disabled {
border-color: #375a7f !important;
background: #375a7f;
cursor: pointer;
opacity: 0.65;
}
</style>
@@ -5,6 +5,7 @@ import type { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@sammo-ts/gateway-api';
import MapPreview from '../components/MapPreview.vue';
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import { createGameTrpc, type GameRouter } from '../utils/gameTrpc';
import { trpc } from '../utils/trpc';
@@ -24,6 +25,7 @@ const username = ref('');
const password = ref('');
const loginError = ref('');
const loginLoading = ref(false);
const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null);
const statusLoading = ref(false);
const statusError = ref('');
const profile = ref<LobbyProfile | null>(null);
@@ -92,6 +94,10 @@ const handleLogin = async (): Promise<void> => {
username: username.value,
credential,
});
if (result.status === 'otp') {
otpChallenge.value = result;
return;
}
window.localStorage.setItem('sammo-session-token', result.sessionToken);
await router.push('/lobby');
} catch (error) {
@@ -101,6 +107,12 @@ const handleLogin = async (): Promise<void> => {
}
};
const handleOtpVerified = async (sessionToken: string): Promise<void> => {
window.localStorage.setItem('sammo-session-token', sessionToken);
otpChallenge.value = null;
await router.push('/lobby');
};
const handleKakao = async (): Promise<void> => {
loginError.value = '';
try {
@@ -191,6 +203,12 @@ const handlePasswordReset = async (): Promise<void> => {
</section>
</div>
</DefaultLayout>
<KakaoOtpDialog
v-if="otpChallenge"
:challenge-id="otpChallenge.challengeId"
@verified="handleOtpVerified"
@cancel="otpChallenge = null"
/>
</template>
<style scoped>
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
import { trpc } from '../utils/trpc';
import { sealPassword } from '../utils/passwordEnvelope';
@@ -21,6 +22,8 @@ const displayName = ref('');
const termsAgreed = ref(false);
const privacyAgreed = ref(false);
const thirdPartyUse = ref(false);
const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null);
const otpSuccessStatus = ref<'login' | 'verified'>('login');
const appBase = import.meta.env.BASE_URL;
const completeExchange = async (): Promise<void> => {
@@ -33,6 +36,11 @@ const completeExchange = async (): Promise<void> => {
}
try {
const result = await trpc.auth.kakaoExchange.mutate({ code, state });
if (result.status === 'otp') {
otpChallenge.value = result;
otpSuccessStatus.value = result.successStatus;
return;
}
if (result.status === 'login') {
window.localStorage.setItem('sammo-session-token', result.sessionToken);
await router.replace('/lobby');
@@ -78,6 +86,12 @@ const register = async (): Promise<void> => {
privacyAgreed: true,
thirdPartyUse: thirdPartyUse.value,
});
if (result.status === 'otp') {
otpChallenge.value = result;
otpSuccessStatus.value = result.successStatus;
oauthSessionId.value = '';
return;
}
window.localStorage.setItem('sammo-session-token', result.sessionToken);
await router.replace('/lobby');
} catch (error) {
@@ -87,6 +101,12 @@ const register = async (): Promise<void> => {
}
};
const handleOtpVerified = async (sessionToken: string): Promise<void> => {
window.localStorage.setItem('sammo-session-token', sessionToken);
otpChallenge.value = null;
await router.replace(otpSuccessStatus.value === 'verified' ? '/lobby?verified=1' : '/lobby');
};
onMounted(() => {
void completeExchange();
});
@@ -120,7 +140,13 @@ onMounted(() => {
<div class="form-row">
<label for="oauth-display-name">닉네임</label>
<div>
<input id="oauth-display-name" v-model="displayName" minlength="2" maxlength="40" required />
<input
id="oauth-display-name"
v-model="displayName"
minlength="2"
maxlength="40"
required
/>
<small>깃수가 종료될 공개됩니다. 계속 사용할 이름이므로 신중하게 정해주세요.</small>
</div>
</div>
@@ -154,6 +180,12 @@ onMounted(() => {
</section>
</main>
</DefaultLayout>
<KakaoOtpDialog
v-if="otpChallenge"
:challenge-id="otpChallenge.challengeId"
@verified="handleOtpVerified"
@cancel="otpChallenge = null"
/>
</template>
<style scoped>
+22 -1
View File
@@ -30,7 +30,28 @@ Gateway API는 다음 저장 경계를 사용합니다.
- `GatewayOperation`: build/reset/open/close 등 실행 요청과 결과
- `GatewayReleaseOperation`, `GatewayReleaseState`: Gateway 전체 릴리스 queue와 현재·이전 commit
- `GatewayRuntimeAction`: profile별 시간 가속·연기 요청, 부분 적용과 최종 결과
- Redis: gateway session, OAuth 임시 상태, flush channel
- Redis: gateway session, OAuth 임시 상태, KakaoTalk 로그인 challenge, flush channel
Kakao 로그인은 URL 이름만으로 사용자를 연결하지 않습니다. `account_email`
`talk_message` scope를 항상 요청하고 callback의 `/v2/user/me` 응답에서 고유 ID,
이메일 보유·유효·인증 상태를 확인합니다. 고유 ID가 다른 계정의 같은 이메일로
접근하는 경우와 변경 이메일이 이미 다른 `AppUser`에 속한 경우는 `CONFLICT`
끝나며 session을 만들지 않습니다. 기존 Kakao 계정이면 stable OAuth ID로
사용자를 찾은 뒤 이메일과 갱신된 token metadata를 함께 저장합니다.
일반 비밀번호 로그인도 `oauth_type=KAKAO`이면 저장 access token을 사용하고,
필요하면 아직 유효한 refresh token으로 갱신한 뒤 `/v2/user/me`를 호출합니다.
provider ID가 저장 `oauth_id`와 다르면 session을 발급하지 않고, 확인된 이메일은
unique constraint 아래에서 동기화합니다. provider나 refresh 호출 실패는 Kakao
재로그인을 요구하며 저장된 identity를 임의로 바꾸지 않습니다.
`AppUser.kakaoTalkVerifiedUntil`이 지났으면 Gateway는 4자리 코드를 생성해 Kakao
“나와의 채팅”에 한 번 보내고 Redis에 사용자별 180초 challenge를 둡니다. 유효한
challenge는 재로그인에서도 재사용하여 중복 메시지를 보내지 않습니다. 제출은
Redis script가 원자적으로 성공 소비 또는 실패 횟수 차감(최대 3회)을 수행합니다.
성공하면 유효 기한을 10일 뒤로 저장한 후에만 Gateway session을 만듭니다.
challenge와 OAuth pending state에는 TTL이 있으며 Redis 장애나 메시지 발송 실패는
로그인 실패로 끝납니다.
Orchestrator는 `GatewayOperation`을 claim하고 source ref를 commit으로
해결합니다. `WorkspaceManager`가 commit별 worktree를 준비하고 build runner가
+16
View File
@@ -79,6 +79,7 @@ storage, route guards, and image loading.
| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset |
| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows |
| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus |
| gateway Kakao OTP | `index.php#modalOTP` | 동일 문구·500px modal, desktop/mobile geometry와 색상·typography, password/OAuth 진입, autofocus·focus-visible·active·disabled·오류 재시도·session 저장 |
| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` |
| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite |
| current city | `hwe/b_currentCity.php` | ref-specific 16px Times New Roman, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation |
@@ -169,3 +170,18 @@ screenshot only when `CITY_PARITY_ARTIFACT_DIR` is set.
For a review run that also writes full-page screenshots, create an ignored
artifact directory and set `FRONTEND_PARITY_ARTIFACT_DIR` before invoking the
suite. The ordinary CI run does not write screenshots after successful tests.
Kakao OTP 화면만 실제 Chromium으로 재검증하고 선택적으로 artifact를 남기려면 다음
명령을 사용합니다. Ref helper는 checked-out `index.php` markup과 실제 빌드 CSS를
사용하므로 live Ref service가 없어도 정적 geometry 기준을 재현하지만, OAuth
callback 자체의 provider 검증을 대신하지는 않습니다.
```sh
KAKAO_OTP_ARTIFACT_DIR=/path/to/ignored/artifacts \
pnpm exec playwright test --config app/gateway-frontend/e2e/playwright.config.mjs \
app/gateway-frontend/e2e/kakao-otp.spec.ts
REF_SAM_ROOT=/path/to/ref/sam \
KAKAO_OTP_ARTIFACT_DIR=/path/to/ignored/artifacts \
node tools/frontend-legacy-parity/kakao-otp-ref-geometry.mjs
```
+8 -5
View File
@@ -28,10 +28,13 @@ Legacy member numbers map to deterministic UUIDs. Existing rows are updated by
that UUID, so references such as `ng_old_generals.owner` remain stable even
when an old account was deleted before the dump.
Kakao members retain `oauth_id`, email and metadata. Cutover sets
`kakao_verified_at` and `kakao_grace_started_at` to the migration time and starts
the verification grace period there. Source rows without an OAuth ID retain
their metadata, but the importer does not invent a provider identifier.
Kakao members retain `oauth_id`, email and metadata. A parseable legacy
`token_valid_until` is copied to `kakao_talk_verified_until`, preserving the
remaining KakaoTalk ownership-proof interval instead of forcing an immediate
message at cutover. Cutover also sets `kakao_verified_at` and
`kakao_grace_started_at` to the migration time and starts the local-account
verification grace period there. Source rows without an OAuth ID retain their
metadata, but the importer does not invent a provider identifier.
Legacy password hashes remain usable when gateway-api has
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT`; a successful login upgrades the stored
@@ -131,7 +134,7 @@ browser.
5. Put the affected target in maintenance mode, take a PostgreSQL backup, then
run the same commands with `--apply`.
6. Repeat each apply. Counts must remain unchanged.
7. Verify Kakao migration timestamps, password-hash shapes, archive ownership,
7. Verify Kakao migration timestamps including `kakao_talk_verified_until`, password-hash shapes, archive ownership,
old-nation/history duplicate preservation, `/past-plays` list/detail access,
foreign-owner denial and the dynasty link.
8. Retain the MariaDB dumps as rollback evidence. Rollback restores the
+1
View File
@@ -19,6 +19,7 @@
"prisma:migrate:deploy:gateway": "PRISMA_SCHEMA=prisma/gateway.prisma prisma migrate deploy --schema prisma/gateway.prisma --config prisma.gateway.config.ts",
"prisma:migrate:status:game": "prisma migrate status --schema prisma/game.prisma",
"verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh",
"verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh",
"verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh",
"prisma:db:push:game": "prisma db push --schema prisma/game.prisma",
"prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma"
@@ -0,0 +1,7 @@
ALTER TABLE "app_user"
ADD COLUMN "kakao_talk_verified_until" TIMESTAMP(3);
UPDATE "app_user"
SET "kakao_talk_verified_until" = NULLIF("legacy_data" ->> 'tokenValidUntil', '')::TIMESTAMP(3)
WHERE "oauth_type" = 'KAKAO'
AND "legacy_data" ->> 'tokenValidUntil' ~ '^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?$';
+41 -40
View File
@@ -71,35 +71,36 @@ enum GatewaySourceMode {
}
model AppUser {
id String @id @default(uuid())
loginId String @unique @map("login_id")
displayName String @unique @map("display_name")
passwordHash String @map("password_hash")
passwordSalt String @map("password_salt")
roles Json @default(dbgenerated("'[]'::jsonb"))
sanctions Json @default(dbgenerated("'{}'::jsonb"))
oauthType OAuthType @default(NONE) @map("oauth_type")
oauthId String? @unique @map("oauth_id")
email String? @unique
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
picture String @default("default.jpg")
imageServer Int @default(0) @map("image_server")
iconUpdatedAt DateTime? @map("icon_updated_at")
iconRevision DateTime? @map("icon_revision")
profileIconResetAt DateTime? @map("profile_icon_reset_at")
iconRetiredAt DateTime? @map("icon_retired_at")
thirdPartyUse Boolean @default(true) @map("third_party_use")
termsAcceptedAt DateTime? @map("terms_accepted_at")
privacyAcceptedAt DateTime? @map("privacy_accepted_at")
kakaoVerifiedAt DateTime? @map("kakao_verified_at")
kakaoGraceStartedAt DateTime @default(now()) @map("kakao_grace_started_at")
kakaoGraceUntil DateTime? @map("kakao_grace_until")
deleteAfter DateTime? @map("delete_after")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at")
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
icons UserIcon[]
id String @id @default(uuid())
loginId String @unique @map("login_id")
displayName String @unique @map("display_name")
passwordHash String @map("password_hash")
passwordSalt String @map("password_salt")
roles Json @default(dbgenerated("'[]'::jsonb"))
sanctions Json @default(dbgenerated("'{}'::jsonb"))
oauthType OAuthType @default(NONE) @map("oauth_type")
oauthId String? @unique @map("oauth_id")
email String? @unique
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
picture String @default("default.jpg")
imageServer Int @default(0) @map("image_server")
iconUpdatedAt DateTime? @map("icon_updated_at")
iconRevision DateTime? @map("icon_revision")
profileIconResetAt DateTime? @map("profile_icon_reset_at")
iconRetiredAt DateTime? @map("icon_retired_at")
thirdPartyUse Boolean @default(true) @map("third_party_use")
termsAcceptedAt DateTime? @map("terms_accepted_at")
privacyAcceptedAt DateTime? @map("privacy_accepted_at")
kakaoVerifiedAt DateTime? @map("kakao_verified_at")
kakaoTalkVerifiedUntil DateTime? @map("kakao_talk_verified_until")
kakaoGraceStartedAt DateTime @default(now()) @map("kakao_grace_started_at")
kakaoGraceUntil DateTime? @map("kakao_grace_until")
deleteAfter DateTime? @map("delete_after")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at")
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
icons UserIcon[]
@@map("app_user")
}
@@ -260,14 +261,14 @@ model GatewayOperation {
}
model GatewayReleaseState {
id String @id @default("gateway")
activeCommitSha String? @map("active_commit_sha")
activeWorkspace String? @map("active_workspace")
previousCommitSha String? @map("previous_commit_sha")
previousWorkspace String? @map("previous_workspace")
lastSuccessfulAt DateTime? @map("last_successful_at")
lastError String? @map("last_error")
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default("gateway")
activeCommitSha String? @map("active_commit_sha")
activeWorkspace String? @map("active_workspace")
previousCommitSha String? @map("previous_commit_sha")
previousWorkspace String? @map("previous_workspace")
lastSuccessfulAt DateTime? @map("last_successful_at")
lastError String? @map("last_error")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("gateway_release_state")
}
@@ -278,9 +279,9 @@ model GatewayReleaseOperation {
id String @id @default(uuid())
type GatewayReleaseOperationType
status GatewayOperationStatus @default(QUEUED)
sourceMode GatewaySourceMode? @map("source_mode")
sourceRef String? @map("source_ref")
resolvedCommitSha String? @map("resolved_commit_sha")
sourceMode GatewaySourceMode? @map("source_mode")
sourceRef String? @map("source_ref")
resolvedCommitSha String? @map("resolved_commit_sha")
payload Json @default(dbgenerated("'{}'::jsonb"))
reason String?
requestedBy String @map("requested_by")
@@ -0,0 +1,7 @@
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: './gateway.prisma',
migrations: { path: './gateway-migrations' },
datasource: { url: process.env.GATEWAY_DATABASE_URL },
});
+219
View File
@@ -0,0 +1,219 @@
#!/bin/sh
set -eu
: "${GATEWAY_MIGRATION_TEST_DATABASE_URL:?GATEWAY_MIGRATION_TEST_DATABASE_URL is required}"
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
package_dir=$(dirname "$script_dir")
prisma_dir="$package_dir/prisma"
target_migration=20260808000000_add_kakao_talk_verification
run_id=$(date -u +%m%d%H%M%S)_$$
predecessor_schema="gateway_kakao_predecessor_$run_id"
fresh_schema="gateway_kakao_fresh_$run_id"
ownership_token="sammo-gateway-kakao-migration:$run_id"
work_dir=$(mktemp -d "$package_dir/.gateway-kakao-migration.XXXXXX")
cleanup() {
cleanup_status=0
OWNERSHIP_TOKEN=$ownership_token \
SCHEMA_NAMES="$predecessor_schema,$fresh_schema" \
DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
pnpm --dir "$package_dir" exec node --input-type=module -e '
import pg from "pg";
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
await client.connect();
try {
for (const schema of process.env.SCHEMA_NAMES.split(",")) {
const ownership = await client.query(
"SELECT obj_description(oid, $$pg_namespace$$) AS owner FROM pg_namespace WHERE nspname = $1",
[schema]
);
if (ownership.rowCount === 0) continue;
if (ownership.rows[0]?.owner !== process.env.OWNERSHIP_TOKEN) {
throw new Error(`refusing to drop unowned schema: ${schema}`);
}
await client.query(`DROP SCHEMA ${quoteIdentifier(schema)} CASCADE`);
}
} finally {
await client.end();
}
' >/dev/null 2>&1 || cleanup_status=1
case "$work_dir" in
"$package_dir"/.gateway-kakao-migration.*)
rm -r -- "$work_dir" || cleanup_status=1
;;
*)
echo "refusing to remove unsafe migration work directory: $work_dir" >&2
cleanup_status=1
;;
esac
return "$cleanup_status"
}
handle_exit() {
exit_status=$?
trap - EXIT HUP INT TERM
if ! cleanup && [ "$exit_status" -eq 0 ]; then
exit_status=1
fi
exit "$exit_status"
}
trap handle_exit EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
[ -d "$prisma_dir/gateway-migrations/$target_migration" ] || {
echo "target migration is missing: $target_migration" >&2
exit 66
}
build_database_url() {
SCHEMA_NAME=$1 DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
pnpm --dir "$package_dir" exec node --input-type=module -e '
const url = new URL(process.env.DATABASE_URL);
url.searchParams.set("schema", process.env.SCHEMA_NAME);
process.stdout.write(url.href);
'
}
predecessor_url=$(build_database_url "$predecessor_schema")
fresh_url=$(build_database_url "$fresh_schema")
OWNERSHIP_TOKEN=$ownership_token \
SCHEMA_NAMES="$predecessor_schema,$fresh_schema" \
DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
pnpm --dir "$package_dir" exec node --input-type=module -e '
import pg from "pg";
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
const quoteLiteral = (value) => `$$${value}$$`;
await client.connect();
try {
for (const schema of process.env.SCHEMA_NAMES.split(",")) {
await client.query(`CREATE SCHEMA ${quoteIdentifier(schema)}`);
await client.query(
`COMMENT ON SCHEMA ${quoteIdentifier(schema)} IS ${quoteLiteral(process.env.OWNERSHIP_TOKEN)}`
);
}
} finally {
await client.end();
}
'
stage_dir="$work_dir/stage"
mkdir -p "$stage_dir/gateway-migrations"
cp "$prisma_dir/gateway.prisma" "$stage_dir/gateway.prisma"
cp "$script_dir/fixtures/gateway-migration.config.mjs" "$stage_dir/prisma.config.mjs"
found_target=0
for migration_dir in "$prisma_dir"/gateway-migrations/[0-9]*; do
migration_name=$(basename "$migration_dir")
if [ "$migration_name" = "$target_migration" ]; then
found_target=1
break
fi
cp -R "$migration_dir" "$stage_dir/gateway-migrations/$migration_name"
done
[ "$found_target" -eq 1 ] || {
echo "target migration was not found in migration order" >&2
exit 1
}
cd "$package_dir"
GATEWAY_DATABASE_URL=$predecessor_url \
pnpm exec prisma migrate deploy --schema "$stage_dir/gateway.prisma" --config "$stage_dir/prisma.config.mjs" \
>"$work_dir/predecessor-deploy.log"
SCHEMA_NAME=$predecessor_schema DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
pnpm exec node --input-type=module -e '
import pg from "pg";
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
await client.connect();
try {
await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
await client.query(`
INSERT INTO app_user (
id, login_id, display_name, password_hash, password_salt,
oauth_type, oauth_id, email, legacy_data, created_at, updated_at
) VALUES
($1, $1, $1, $1, $1, $$KAKAO$$, $$kakao-valid$$, $$valid@example.com$$,
$2::jsonb, $3, $3),
($4, $4, $4, $4, $4, $$KAKAO$$, $$kakao-malformed$$, $$malformed@example.com$$,
$5::jsonb, $3, $3),
($6, $6, $6, $6, $6, $$NONE$$, NULL, NULL,
$7::jsonb, $3, $3)
`, [
"valid-kakao-user",
JSON.stringify({ tokenValidUntil: "2026-08-18 05:57:00" }),
"2026-08-01T00:00:00.000Z",
"malformed-kakao-user",
JSON.stringify({ tokenValidUntil: "not-a-date" }),
"ordinary-user",
JSON.stringify({ tokenValidUntil: "2026-08-18 05:57:00" }),
]);
} finally {
await client.end();
}
'
GATEWAY_DATABASE_URL=$predecessor_url \
pnpm exec prisma migrate deploy --schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
>"$work_dir/incremental-deploy.log"
GATEWAY_DATABASE_URL=$predecessor_url \
pnpm exec prisma migrate deploy --schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
>"$work_dir/noop-deploy.log"
grep -Fq 'No pending migrations to apply' "$work_dir/noop-deploy.log"
SCHEMA_NAME=$predecessor_schema DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
pnpm exec node --input-type=module -e '
import pg from "pg";
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
await client.connect();
try {
await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
const rows = await client.query(`
SELECT id, kakao_talk_verified_until AS "validUntil"
FROM app_user
ORDER BY id
`);
const values = Object.fromEntries(rows.rows.map((row) => [row.id, row.validUntil?.toISOString() ?? null]));
if (values["valid-kakao-user"] !== "2026-08-18T05:57:00.000Z") {
throw new Error(`legacy validity was not preserved: ${JSON.stringify(values)}`);
}
if (values["malformed-kakao-user"] !== null || values["ordinary-user"] !== null) {
throw new Error(`unsafe legacy validity was imported: ${JSON.stringify(values)}`);
}
} finally {
await client.end();
}
'
GATEWAY_DATABASE_URL=$fresh_url \
pnpm exec prisma migrate deploy --schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
>"$work_dir/fresh-deploy.log"
SCHEMA_NAME=$fresh_schema TARGET_MIGRATION=$target_migration DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
pnpm exec node --input-type=module -e '
import pg from "pg";
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
try {
const result = await client.query(`
SELECT count(*)::int AS count
FROM information_schema.columns
WHERE table_schema = $1
AND table_name = $$app_user$$
AND column_name = $$kakao_talk_verified_until$$
`, [process.env.SCHEMA_NAME]);
if (result.rows[0]?.count !== 1) throw new Error("fresh migration column is missing");
} finally {
await client.end();
}
'
echo "KakaoTalk verification migration checks passed."
+1 -1
View File
@@ -1,7 +1,7 @@
{
"formatVersion": 1,
"controllerProtocol": 1,
"gatewaySchemaHead": "20260806000000_add_admin_audit_and_kakao_grace",
"gatewaySchemaHead": "20260808000000_add_kakao_talk_verification",
"gameSchemaHead": "20260803000000_add_logical_game_clock",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
}
+120
View File
@@ -0,0 +1,120 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { chromium } from '@playwright/test';
const refRoot = process.env.REF_SAM_ROOT;
const artifactRoot = process.env.KAKAO_OTP_ARTIFACT_DIR;
if (!refRoot || !artifactRoot) {
throw new Error('REF_SAM_ROOT and KAKAO_OTP_ARTIFACT_DIR are required.');
}
const modalHtml = `
<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body>
<div class="modal show" id="modalOTP" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-modal="true" style="display:block">
<div class="modal-dialog" role="document">
<div class="modal-content">
<form id="otp_form" method="post" action="#">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel">인증 코드 필요</h5>
<button type="button" class="close" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body">
<div>인증 코드가 필요합니다.<br><br>카카오톡의 '나와의 채팅'란을 확인해 주세요.<br>(별도의 알림[소리, 진동, 숫자] 발생하지 않습니다.)</div>
<div class="input-group mt-4" role="group">
<div class="input-group-text">인증 코드</div>
<input type="number" class="form-control" name="otp" id="otp_code" placeholder="인증 코드">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary">취소</button>
<button type="submit" class="btn btn-primary">제출</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>`;
await mkdir(artifactRoot, { recursive: true });
const browser = await chromium.launch();
try {
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 390, height: 844 },
]) {
const page = await browser.newPage({ viewport });
await page.setContent(modalHtml);
await page.addStyleTag({ path: resolve(refRoot, 'dist_js/gateway/common_ts.css') });
await page.addStyleTag({ path: resolve(refRoot, 'd_shared/common.css') });
await page.addStyleTag({ path: resolve(refRoot, 'dist_js/gateway/login.css') });
await page.addStyleTag({
content: '.modal { background: rgb(0 0 0 / 60%); } body { background: #000; }',
});
const dialog = page.locator('#modalOTP .modal-content');
const input = page.locator('#otp_code');
const submit = page.getByRole('button', { name: '제출' });
const readSubmitStyle = () =>
submit.evaluate((element) => {
const style = getComputedStyle(element);
return {
backgroundColor: style.backgroundColor,
borderColor: style.borderColor,
color: style.color,
outline: style.outline,
boxShadow: style.boxShadow,
opacity: style.opacity,
cursor: style.cursor,
};
});
await page.waitForTimeout(250);
const states = { normal: await readSubmitStyle() };
await submit.hover();
await page.waitForTimeout(250);
states.hover = await readSubmitStyle();
await input.focus();
await input.press('Tab');
await page.keyboard.press('Tab');
await page.waitForTimeout(250);
states.focusVisible = await readSubmitStyle();
const submitBox = await submit.boundingBox();
if (!submitBox) throw new Error('Ref OTP submit button has no geometry.');
await page.mouse.move(submitBox.x + submitBox.width / 2, submitBox.y + submitBox.height / 2);
await page.mouse.down();
await page.waitForTimeout(250);
states.active = await readSubmitStyle();
await page.mouse.move(1, 1);
await page.mouse.up();
await submit.evaluate((element) => {
element.disabled = true;
});
await page.waitForTimeout(250);
states.disabled = await readSubmitStyle();
const geometry = await dialog.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
backgroundColor: style.backgroundColor,
border: style.border,
fontSize: style.fontSize,
};
});
await page.screenshot({ path: resolve(artifactRoot, `kakao-otp-ref-${viewport.name}.png`), fullPage: true });
await writeFile(
resolve(artifactRoot, `kakao-otp-ref-${viewport.name}.json`),
`${JSON.stringify({ ...geometry, submitStates: states }, null, 2)}\n`,
'utf8'
);
await page.close();
}
} finally {
await browser.close();
}
@@ -315,6 +315,9 @@ describe('auction integration flow', () => {
username: user.username,
credential: sealGatewayPassword(user.password, await gatewayClient.auth.passwordKey.query()),
});
if (login.status !== 'login') {
throw new Error('Local integration fixture unexpectedly requires Kakao OTP.');
}
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
profile: 'che:908',
@@ -231,6 +231,9 @@ describe('integration initialization flow', () => {
username: user.username,
credential: sealGatewayPassword(user.password, await gatewayClient.auth.passwordKey.query()),
});
if (login.status !== 'login') {
throw new Error('Local integration fixture unexpectedly requires Kakao OTP.');
}
expect(login.sessionToken).toBeTruthy();
}
@@ -308,6 +311,9 @@ describe('integration initialization flow', () => {
username: user.username,
credential: sealGatewayPassword(user.password, await gatewayClient.auth.passwordKey.query()),
});
if (login.status !== 'login') {
throw new Error('Local integration fixture unexpectedly requires Kakao OTP.');
}
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
profile: 'che:2',
@@ -608,6 +608,9 @@ describe('pm2 orchestrator e2e', () => {
username: 'e2e-user',
credential: sealGatewayPassword('e2e-pass-123', await gatewayClient.auth.passwordKey.query()),
});
if (login.status !== 'login') {
throw new Error('Local integration fixture unexpectedly requires Kakao OTP.');
}
const gameSession = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
@@ -283,6 +283,9 @@ describe('actual tournament lifecycle', () => {
username,
credential: sealGatewayPassword(`${username}-pass`, await gatewayClient.auth.passwordKey.query()),
});
if (login.status !== 'login') {
throw new Error('Local integration fixture unexpectedly requires Kakao OTP.');
}
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
profile: 'che:908',
+2
View File
@@ -79,6 +79,8 @@ next turn.
Kakao members retain their OAuth ID, email, and OAuth metadata.
`kakao_verified_at` and `kakao_grace_started_at` are set to the migration time.
The existing `token_valid_until` is copied to `kakao_talk_verified_until` for
Kakao rows so a still-current “send to me” proof remains current after cutover.
Legacy password hashes and salts are retained and upgraded to Argon2id after
the first successful login when
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is configured in gateway-api.
+4
View File
@@ -64,6 +64,10 @@ const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null):
terms_accepted_at: null,
privacy_accepted_at: null,
kakao_verified_at: oauthType === 'KAKAO' ? migratedAt : null,
kakao_talk_verified_until:
oauthType === 'KAKAO'
? toNullableDate(row.token_valid_until, `member.${memberNo}.token_valid_until`)
: null,
kakao_grace_started_at: migratedAt,
delete_after: toNullableDate(row.delete_after, `member.${memberNo}.delete_after`),
created_at: toDate(row.REG_DATE, `member.${memberNo}.REG_DATE`),