feat(gateway): verify Kakao account ownership
This commit is contained in:
@@ -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
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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가
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+7
@@ -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})?$';
|
||||
@@ -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
@@ -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,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
@@ -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">×</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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user