feat(gateway): verify Kakao account ownership

This commit is contained in:
2026-08-08 06:23:16 +00:00
parent 2d92114aa9
commit 3654adbe13
33 changed files with 2047 additions and 138 deletions
@@ -31,6 +31,9 @@ test('reserves an argument command in the real game API and reads it back from P
).toString('base64'),
},
});
if (login.status !== 'login') {
throw new Error('Local live fixture unexpectedly requires Kakao OTP.');
}
const issued = await gateway.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
profile: 'che:2',
@@ -95,19 +98,15 @@ test('reserves an argument command in the real game API and reads it back from P
await form.getByRole('button', { name: '쌀', exact: true }).click();
await form.locator('input[type=number]').fill('1');
const generalSelect = form.locator('select');
const generalValues = await generalSelect
.locator('option')
.evaluateAll((options) =>
options.map((option) => ({
value: (option as HTMLOptionElement).value,
label: option.textContent ?? '',
}))
);
const generalValues = await generalSelect.locator('option').evaluateAll((options) =>
options.map((option) => ({
value: (option as HTMLOptionElement).value,
label: option.textContent ?? '',
}))
);
const targetGeneralId = Number(
generalValues.find(
(option) =>
Number(option.value) !== generalId &&
option.label.includes(`(${nationName} ·`)
(option) => Number(option.value) !== generalId && option.label.includes(`(${nationName} ·`)
)?.value
);
expect(targetGeneralId).toBeGreaterThan(0);
@@ -53,6 +53,13 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
if (usersByName.has(input.username)) {
throw new Error('User already exists.');
}
if (
input.oauth &&
(usersByOauthId.has(`${input.oauth.type}:${input.oauth.id}`) ||
usersByEmail.has(input.oauth.email.toLowerCase()))
) {
throw new Error('Kakao account already linked.');
}
for (const existing of usersByName.values()) {
if ((input.displayName ?? input.username) === existing.displayName) {
throw new Error('Display name already exists.');
@@ -120,6 +127,39 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
}
throw new Error('User not found.');
},
async syncKakaoIdentity(
userId: string,
email: string,
oauthInfo: UserRecord['oauthInfo']
): Promise<UserRecord> {
const normalizedEmail = email.toLowerCase();
const owner = usersByEmail.get(normalizedEmail);
if (owner && owner.id !== userId) {
throw new Error('Kakao email already linked.');
}
for (const user of usersByName.values()) {
if (user.id !== userId) {
continue;
}
if (user.email) {
usersByEmail.delete(user.email.toLowerCase());
}
user.email = normalizedEmail;
user.oauthInfo = oauthInfo;
usersByEmail.set(normalizedEmail, user);
return user;
}
throw new Error('User not found.');
},
async markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord> {
for (const user of usersByName.values()) {
if (user.id === userId) {
user.kakaoTalkVerifiedUntil = validUntil.toISOString();
return user;
}
}
throw new Error('User not found.');
},
async linkKakao(userId, input): Promise<UserRecord> {
if (usersByOauthId.has(`KAKAO:${input.oauthId}`) || usersByEmail.has(input.email.toLowerCase())) {
throw new Error('Kakao account already linked.');
@@ -0,0 +1,279 @@
import { randomInt } from 'node:crypto';
import { addDays, addSeconds, isAfter, isValid, parseISO } from 'date-fns';
import type { KakaoOAuthClient, KakaoOAuthToken, KakaoUserInfo } from './kakaoClient.js';
import type { OAuthSessionStore } from './oauthSessionStore.js';
import type { UserOAuthInfo, UserRecord, UserRepository } from './userRepository.js';
export const KAKAO_LOGIN_SCOPES = ['account_email', 'talk_message'] as const;
export const KAKAO_OTP_TTL_SECONDS = 180;
export const KAKAO_OTP_ATTEMPTS = 3;
export const KAKAO_TALK_VERIFICATION_DAYS = 10;
export type KakaoVerificationErrorCode =
| 'EMAIL_REQUIRED'
| 'EMAIL_UNVERIFIED'
| 'EMAIL_CONFLICT'
| 'IDENTITY_MISMATCH'
| 'REAUTH_REQUIRED'
| 'MESSAGE_FAILED';
export class KakaoVerificationError extends Error {
readonly verificationCode: KakaoVerificationErrorCode;
constructor(verificationCode: KakaoVerificationErrorCode, message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'KakaoVerificationError';
this.verificationCode = verificationCode;
}
}
export interface VerifiedKakaoProfile {
kakaoId: string;
email: string;
}
export interface KakaoLoginReady {
user: UserRecord;
accessToken: string;
oauthInfo: UserOAuthInfo;
}
export interface KakaoOtpRequired {
status: 'otp';
challengeId: string;
expiresAt: string;
attemptsRemaining: number;
}
const parseDate = (value: string | undefined): Date | null => {
if (!value) {
return null;
}
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(value)
? `${value.replace(' ', 'T')}Z`
: value;
const parsed = parseISO(normalized);
return isValid(parsed) ? parsed : null;
};
export const readVerifiedKakaoProfile = (me: KakaoUserInfo): VerifiedKakaoProfile => {
const account = me.kakaoAccount;
if (!account.hasEmail || !account.email) {
throw new KakaoVerificationError('EMAIL_REQUIRED', '이메일 정보 제공에 동의해야 합니다.');
}
if (!account.isEmailValid || !account.isEmailVerified) {
throw new KakaoVerificationError('EMAIL_UNVERIFIED', '카카오 계정 이메일이 인증되지 않았습니다.');
}
if (!me.id) {
throw new KakaoVerificationError('IDENTITY_MISMATCH', '카카오 계정 고유 ID를 확인하지 못했습니다.');
}
return {
kakaoId: me.id,
email: account.email.trim().toLocaleLowerCase('en-US'),
};
};
export const oauthInfoFromToken = (
token: KakaoOAuthToken,
issuedAt: Date,
previous: UserOAuthInfo = {}
): UserOAuthInfo => ({
...previous,
accessToken: token.accessToken,
refreshToken: token.refreshToken ?? previous.refreshToken,
accessTokenValidUntil: addSeconds(issuedAt, token.accessTokenExpiresIn).toISOString(),
refreshTokenValidUntil: token.refreshTokenExpiresIn
? addSeconds(issuedAt, token.refreshTokenExpiresIn).toISOString()
: previous.refreshTokenValidUntil,
});
const resolveStoredAccessToken = async (
user: UserRecord,
kakaoClient: KakaoOAuthClient,
now: Date
): Promise<{ accessToken: string; oauthInfo: UserOAuthInfo }> => {
const oauthInfo = user.oauthInfo ?? {};
const accessValidUntil = parseDate(oauthInfo.accessTokenValidUntil);
if (oauthInfo.accessToken && accessValidUntil && isAfter(accessValidUntil, now)) {
return { accessToken: oauthInfo.accessToken, oauthInfo };
}
const refreshValidUntil = parseDate(oauthInfo.refreshTokenValidUntil);
if (!oauthInfo.refreshToken || !refreshValidUntil || !isAfter(refreshValidUntil, now)) {
throw new KakaoVerificationError(
'REAUTH_REQUIRED',
'카카오 로그인 토큰이 만료되었습니다. 카카오 로그인을 다시 수행해 주세요.'
);
}
let refreshed: KakaoOAuthToken;
try {
refreshed = await kakaoClient.refreshToken(oauthInfo.refreshToken);
} catch (error) {
throw new KakaoVerificationError(
'REAUTH_REQUIRED',
'카카오 로그인 토큰을 갱신하지 못했습니다. 카카오 로그인을 다시 수행해 주세요.',
{ cause: error }
);
}
if (!refreshed.accessToken || refreshed.accessTokenExpiresIn <= 0) {
throw new KakaoVerificationError(
'REAUTH_REQUIRED',
'카카오 로그인 토큰을 갱신하지 못했습니다. 카카오 로그인을 다시 수행해 주세요.'
);
}
return {
accessToken: refreshed.accessToken,
oauthInfo: oauthInfoFromToken(refreshed, now, oauthInfo),
};
};
export const verifyStoredKakaoIdentity = async (options: {
user: UserRecord;
users: UserRepository;
kakaoClient: KakaoOAuthClient;
now?: Date;
}): Promise<KakaoLoginReady> => {
const { user, users, kakaoClient } = options;
const now = options.now ?? new Date();
if (user.oauthType !== 'KAKAO' || !user.oauthId) {
throw new KakaoVerificationError('REAUTH_REQUIRED', '카카오 계정 연결 정보가 올바르지 않습니다.');
}
const resolved = await resolveStoredAccessToken(user, kakaoClient, now);
let profile: VerifiedKakaoProfile;
try {
profile = readVerifiedKakaoProfile(await kakaoClient.getMe(resolved.accessToken));
} catch (error) {
if (error instanceof KakaoVerificationError) {
throw error;
}
throw new KakaoVerificationError(
'REAUTH_REQUIRED',
'카카오 계정 정보를 확인하지 못했습니다. 카카오 로그인을 다시 수행해 주세요.',
{ cause: error }
);
}
if (profile.kakaoId !== user.oauthId) {
throw new KakaoVerificationError(
'IDENTITY_MISMATCH',
'저장된 카카오 계정과 현재 카카오 계정이 일치하지 않습니다.'
);
}
const emailOwner = await users.findByEmail(profile.email);
if (emailOwner && emailOwner.id !== user.id) {
throw new KakaoVerificationError(
'EMAIL_CONFLICT',
'변경된 카카오 이메일이 이미 다른 계정에서 사용 중입니다. 관리자에게 문의해 주세요.'
);
}
try {
const synced = await users.syncKakaoIdentity(user.id, profile.email, resolved.oauthInfo);
return {
user: synced,
accessToken: resolved.accessToken,
oauthInfo: resolved.oauthInfo,
};
} catch (error) {
throw new KakaoVerificationError(
'EMAIL_CONFLICT',
'변경된 카카오 이메일이 이미 다른 계정에서 사용 중입니다. 관리자에게 문의해 주세요.',
{ cause: error }
);
}
};
export const requireKakaoTalkProof = async (options: {
user: UserRecord;
accessToken: string;
kakaoClient: KakaoOAuthClient;
oauthSessions: OAuthSessionStore;
publicBaseUrl: string;
now?: Date;
}): Promise<KakaoOtpRequired | null> => {
const now = options.now ?? new Date();
const validUntil = parseDate(options.user.kakaoTalkVerifiedUntil);
if (validUntil && isAfter(validUntil, now)) {
return null;
}
const active = await options.oauthSessions.getLoginChallengeForUser(options.user.id);
if (active) {
return {
status: 'otp',
challengeId: active.id,
expiresAt: active.expiresAt,
attemptsRemaining: active.attemptsRemaining,
};
}
const expiresAt = addSeconds(now, KAKAO_OTP_TTL_SECONDS);
const challenge = await options.oauthSessions.createLoginChallenge({
userId: options.user.id,
code: String(randomInt(1000, 10_000)),
attemptsRemaining: KAKAO_OTP_ATTEMPTS,
expiresAt: expiresAt.toISOString(),
createdAt: now.toISOString(),
});
try {
await options.kakaoClient.sendTalkMessage(
options.accessToken,
`인증 코드는 ${challenge.code} 입니다. ${challenge.expiresAt} 이내에 입력해 주세요.`,
options.publicBaseUrl
);
} catch (error) {
await options.oauthSessions.verifyLoginChallenge(challenge.id, challenge.code);
throw new KakaoVerificationError('MESSAGE_FAILED', '카카오톡 인증 코드를 보내지 못했습니다.', {
cause: error,
});
}
return {
status: 'otp',
challengeId: challenge.id,
expiresAt: challenge.expiresAt,
attemptsRemaining: challenge.attemptsRemaining,
};
};
export const verifyKakaoTalkChallenge = async (options: {
challengeId: string;
code: string;
oauthSessions: OAuthSessionStore;
users: UserRepository;
now?: Date;
}): Promise<{ user: UserRecord; validUntil: string }> => {
const now = options.now ?? new Date();
const result = await options.oauthSessions.verifyLoginChallenge(options.challengeId, options.code, now);
if (result.status === 'expired') {
throw new KakaoVerificationError('REAUTH_REQUIRED', '인증 기한이 만료되었습니다. 다시 로그인해 주세요.');
}
if (result.status === 'locked') {
throw new KakaoVerificationError(
'IDENTITY_MISMATCH',
`인증 실패 횟수를 초과했습니다. ${result.expiresAt}까지 기다려 주세요.`
);
}
if (result.status === 'mismatch') {
throw new KakaoVerificationError(
'IDENTITY_MISMATCH',
result.attemptsRemaining > 0
? `인증 번호가 틀렸습니다. ${result.attemptsRemaining}회 더 시도할 수 있습니다.`
: '인증 실패 횟수를 초과했습니다. 다시 로그인해 주세요.'
);
}
const user = await options.users.findById(result.userId);
if (!user || user.oauthType !== 'KAKAO' || !user.oauthId) {
throw new KakaoVerificationError('REAUTH_REQUIRED', '카카오 계정 연결 정보를 찾지 못했습니다.');
}
const proofValidUntil = addDays(now, KAKAO_TALK_VERIFICATION_DAYS);
const verified = await options.users.markKakaoTalkVerified(user.id, proofValidUntil);
return { user: verified, validUntil: proofValidUntil.toISOString() };
};
export const mergeRequiredKakaoScopes = (requested?: string[]): string[] => [
...new Set([...(requested ?? []), ...KAKAO_LOGIN_SCOPES]),
];
@@ -23,11 +23,29 @@ export interface OAuthSession {
createdAt: string;
}
export interface KakaoLoginChallenge {
id: string;
userId: string;
code: string;
attemptsRemaining: number;
expiresAt: string;
createdAt: string;
}
export type KakaoLoginChallengeResult =
| { status: 'verified'; userId: string }
| { status: 'mismatch'; attemptsRemaining: number; expiresAt: string }
| { status: 'locked'; expiresAt: string }
| { status: 'expired' };
export interface OAuthSessionStore {
createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState>;
consumePendingState(state: string): Promise<OAuthPendingState | null>;
createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession>;
consumeSession(sessionId: string): Promise<OAuthSession | null>;
getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null>;
createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge>;
verifyLoginChallenge(challengeId: string, code: string, now?: Date): Promise<KakaoLoginChallengeResult>;
}
interface RedisPipeline {
@@ -40,9 +58,39 @@ interface RedisClientLike {
get(key: string): Promise<string | null>;
set(key: string, value: string, options?: { EX?: number }): Promise<unknown>;
del(key: string): Promise<number>;
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
multi(): RedisPipeline;
}
const verifyLoginChallengeScript = `
local raw = redis.call('GET', KEYS[1])
if not raw then
return '{"status":"expired"}'
end
local challenge = cjson.decode(raw)
if challenge.attemptsRemaining <= 0 then
return cjson.encode({ status = 'locked', expiresAt = challenge.expiresAt })
end
if tostring(challenge.code) ~= ARGV[1] then
challenge.attemptsRemaining = challenge.attemptsRemaining - 1
redis.call('SET', KEYS[1], cjson.encode(challenge), 'KEEPTTL')
return cjson.encode({
status = 'mismatch',
attemptsRemaining = challenge.attemptsRemaining,
expiresAt = challenge.expiresAt
})
end
redis.call('DEL', KEYS[1])
local userKey = ARGV[2] .. challenge.userId
if redis.call('GET', userKey) == challenge.id then
redis.call('DEL', userKey)
end
return cjson.encode({ status = 'verified', userId = challenge.userId })
`;
export class RedisOAuthSessionStore implements OAuthSessionStore {
private readonly client: RedisClientLike;
private readonly prefix: string;
@@ -62,6 +110,18 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
return `${this.prefix}:oauth-session:${sessionId}`;
}
private loginChallengeKey(challengeId: string): string {
return `${this.prefix}:kakao-login-challenge:${challengeId}`;
}
private userLoginChallengeKey(userId: string): string {
return `${this.prefix}:kakao-login-challenge-user:${userId}`;
}
private challengeTtlSeconds(expiresAt: string): number {
return Math.max(1, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 1000));
}
async createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState> {
const state: OAuthPendingState = {
state: randomUUID(),
@@ -106,12 +166,63 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
await this.client.del(key);
return parseJson<OAuthSession>(raw);
}
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
const challengeId = await this.client.get(this.userLoginChallengeKey(userId));
if (!challengeId) {
return null;
}
const raw = await this.client.get(this.loginChallengeKey(challengeId));
if (!raw) {
await this.client.del(this.userLoginChallengeKey(userId));
return null;
}
const challenge = parseJson<KakaoLoginChallenge>(raw);
if (!challenge) {
await this.client.del(this.userLoginChallengeKey(userId));
return null;
}
if (new Date(challenge.expiresAt).getTime() <= Date.now()) {
await this.client
.multi()
.del(this.loginChallengeKey(challenge.id))
.del(this.userLoginChallengeKey(userId))
.exec();
return null;
}
return challenge;
}
async createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge> {
const stored: KakaoLoginChallenge = {
...challenge,
id: randomUUID(),
};
const ttlSeconds = this.challengeTtlSeconds(stored.expiresAt);
await this.client
.multi()
.set(this.loginChallengeKey(stored.id), JSON.stringify(stored), { EX: ttlSeconds })
.set(this.userLoginChallengeKey(stored.userId), stored.id, { EX: ttlSeconds })
.exec();
return stored;
}
async verifyLoginChallenge(challengeId: string, code: string): Promise<KakaoLoginChallengeResult> {
const raw = await this.client.eval(verifyLoginChallengeScript, {
keys: [this.loginChallengeKey(challengeId)],
arguments: [code, `${this.prefix}:kakao-login-challenge-user:`],
});
const result = typeof raw === 'string' ? parseJson<KakaoLoginChallengeResult>(raw) : null;
return result ?? { status: 'expired' };
}
}
// 테스트용 인메모리 OAuth 세션 저장소.
export class InMemoryOAuthSessionStore implements OAuthSessionStore {
private readonly pendingStates = new Map<string, OAuthPendingState>();
private readonly sessions = new Map<string, OAuthSession>();
private readonly loginChallenges = new Map<string, KakaoLoginChallenge>();
private readonly userLoginChallenges = new Map<string, string>();
async createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState> {
const pending: OAuthPendingState = {
@@ -149,4 +260,60 @@ export class InMemoryOAuthSessionStore implements OAuthSessionStore {
}
return session;
}
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
const challengeId = this.userLoginChallenges.get(userId);
const challenge = challengeId ? this.loginChallenges.get(challengeId) : undefined;
if (!challenge || new Date(challenge.expiresAt).getTime() <= Date.now()) {
if (challengeId) {
this.loginChallenges.delete(challengeId);
}
this.userLoginChallenges.delete(userId);
return null;
}
return challenge;
}
async createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge> {
const stored: KakaoLoginChallenge = {
...challenge,
id: randomUUID(),
};
this.loginChallenges.set(stored.id, stored);
this.userLoginChallenges.set(stored.userId, stored.id);
return stored;
}
async verifyLoginChallenge(
challengeId: string,
code: string,
now = new Date()
): Promise<KakaoLoginChallengeResult> {
const challenge = this.loginChallenges.get(challengeId);
if (!challenge || new Date(challenge.expiresAt).getTime() <= now.getTime()) {
if (challenge) {
this.loginChallenges.delete(challengeId);
if (this.userLoginChallenges.get(challenge.userId) === challengeId) {
this.userLoginChallenges.delete(challenge.userId);
}
}
return { status: 'expired' };
}
if (challenge.attemptsRemaining <= 0) {
return { status: 'locked', expiresAt: challenge.expiresAt };
}
if (challenge.code !== code) {
challenge.attemptsRemaining -= 1;
return {
status: 'mismatch',
attemptsRemaining: challenge.attemptsRemaining,
expiresAt: challenge.expiresAt,
};
}
this.loginChallenges.delete(challengeId);
if (this.userLoginChallenges.get(challenge.userId) === challengeId) {
this.userLoginChallenges.delete(challenge.userId);
}
return { status: 'verified', userId: challenge.userId };
}
}
@@ -58,6 +58,7 @@ const mapUser = (row: {
termsAcceptedAt: Date | null;
privacyAcceptedAt: Date | null;
kakaoVerifiedAt: Date | null;
kakaoTalkVerifiedUntil: Date | null;
kakaoGraceStartedAt: Date;
kakaoGraceUntil: Date | null;
deleteAfter: Date | null;
@@ -83,6 +84,7 @@ const mapUser = (row: {
termsAcceptedAt: row.termsAcceptedAt?.toISOString(),
privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(),
kakaoVerifiedAt: row.kakaoVerifiedAt?.toISOString(),
kakaoTalkVerifiedUntil: row.kakaoTalkVerifiedUntil?.toISOString(),
kakaoGraceStartedAt: row.kakaoGraceStartedAt.toISOString(),
kakaoGraceUntil: row.kakaoGraceUntil?.toISOString(),
deleteAfter: row.deleteAfter?.toISOString(),
@@ -223,6 +225,23 @@ export const createPostgresUserRepository = (
},
});
},
async syncKakaoIdentity(userId: string, email: string, oauthInfo: UserOAuthInfo): Promise<UserRecord> {
const row = await prisma.appUser.update({
where: { id: userId },
data: {
email: email.toLowerCase(),
oauthInfo: oauthInfo as GatewayPrisma.JsonObject,
},
});
return mapUser(row);
},
async markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord> {
const row = await prisma.appUser.update({
where: { id: userId },
data: { kakaoTalkVerifiedUntil: validUntil },
});
return mapUser(row);
},
async linkKakao(userId, input): Promise<UserRecord> {
const row = await prisma.appUser.update({
where: { id: userId },
@@ -18,6 +18,7 @@ export interface UserRecord {
termsAcceptedAt?: string;
privacyAcceptedAt?: string;
kakaoVerifiedAt?: string;
kakaoTalkVerifiedUntil?: string;
kakaoGraceStartedAt: string;
kakaoGraceUntil?: string;
deleteAfter?: string;
@@ -110,6 +111,8 @@ export interface UserRepository {
verifyPassword(user: UserRecord, password: string): Promise<boolean>;
updatePassword(userId: string, password: string): Promise<void>;
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
syncKakaoIdentity(userId: string, email: string, oauthInfo: UserOAuthInfo): Promise<UserRecord>;
markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord>;
linkKakao(
userId: string,
input: {
+175 -63
View File
@@ -9,13 +9,23 @@ import { isGameAccessBlocked, isLoginBanned } from '@sammo-ts/common/auth/sancti
import { procedure, router } from './trpc.js';
import { toPublicUser } from './auth/userRepository.js';
import type { UserOAuthInfo } from './auth/userRepository.js';
import type { UserOAuthInfo, UserRecord } from './auth/userRepository.js';
import { adminRouter } from './adminRouter.js';
import { accountRouter } from './account/router.js';
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
import type { GatewayApiContext } from './context.js';
import {
KakaoVerificationError,
mergeRequiredKakaoScopes,
oauthInfoFromToken,
readVerifiedKakaoProfile,
requireKakaoTalkProof,
verifyKakaoTalkChallenge,
verifyStoredKakaoIdentity,
} from './auth/kakaoAccountVerification.js';
const zUsername = z
.string()
@@ -32,6 +42,50 @@ const parseDate = (value: string): Date | null => {
return isValid(parsed) ? parsed : null;
};
const throwKakaoVerificationError = (error: unknown): never => {
if (!(error instanceof KakaoVerificationError)) {
throw error;
}
const code =
error.verificationCode === 'EMAIL_CONFLICT'
? 'CONFLICT'
: error.verificationCode === 'IDENTITY_MISMATCH'
? 'UNAUTHORIZED'
: error.verificationCode === 'EMAIL_REQUIRED' || error.verificationCode === 'EMAIL_UNVERIFIED'
? 'BAD_REQUEST'
: 'PRECONDITION_FAILED';
throw new TRPCError({ code, message: error.message, cause: error });
};
const finishKakaoLogin = async <T extends 'login' | 'verified'>(
ctx: GatewayApiContext,
user: UserRecord,
accessToken: string,
successStatus: T
) => {
try {
const challenge = await requireKakaoTalkProof({
user,
accessToken,
kakaoClient: ctx.kakaoClient,
oauthSessions: ctx.oauthSessions,
publicBaseUrl: ctx.publicBaseUrl,
});
if (challenge) {
return { ...challenge, successStatus };
}
} catch (error) {
throwKakaoVerificationError(error);
}
const session = await ctx.sessions.createSession(user);
return {
status: successStatus,
user: toPublicUser(user),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
};
};
export const appRouter = router({
health: router({
ping: procedure.query(() => ({
@@ -157,7 +211,7 @@ export const appRouter = router({
)
.query(async ({ ctx, input }) => {
const mode = input?.mode ?? 'login';
const scopes = input?.scopes ?? ['account_email'];
const scopes = mergeRequiredKakaoScopes(input?.scopes);
let userId: string | undefined;
if (mode === 'verify') {
const sessionToken =
@@ -196,10 +250,6 @@ export const appRouter = router({
}
const token = await ctx.kakaoClient.exchangeCode(input.code);
const tokenIssuedAt = new Date();
const accessTokenValidUntil = addSeconds(tokenIssuedAt, token.accessTokenExpiresIn).toISOString();
const refreshTokenValidUntil = token.refreshTokenExpiresIn
? addSeconds(tokenIssuedAt, token.refreshTokenExpiresIn).toISOString()
: undefined;
const signupResult = await ctx.kakaoClient.signup(token.accessToken);
if (!signupResult.id && signupResult.msg !== 'already registered') {
@@ -209,30 +259,23 @@ export const appRouter = router({
});
}
const me = await ctx.kakaoClient.getMe(token.accessToken);
const kakaoAccount = me.kakaoAccount;
if (!kakaoAccount.hasEmail || !kakaoAccount.email) {
const profile = (() => {
try {
return readVerifiedKakaoProfile(me);
} catch (error) {
return throwKakaoVerificationError(error);
}
})();
const [existingById, existingByEmail] = await Promise.all([
ctx.users.findByOauthId('KAKAO', profile.kakaoId),
ctx.users.findByEmail(profile.email),
]);
if (existingByEmail && existingByEmail.id !== existingById?.id) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '이메일 정보 제공에 동의해야 합니다.',
code: 'CONFLICT',
message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.',
});
}
if (!kakaoAccount.isEmailValid || !kakaoAccount.isEmailVerified) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: '카카오 계정 이메일이 인증되지 않았습니다.',
});
}
const oauthInfo: UserOAuthInfo = {
accessToken: token.accessToken,
refreshToken: token.refreshToken,
accessTokenValidUntil,
refreshTokenValidUntil,
};
const existing =
(await ctx.users.findByOauthId('KAKAO', me.id)) ??
(await ctx.users.findByEmail(kakaoAccount.email));
if (pending.mode === 'verify') {
if (!pending.userId) {
@@ -254,18 +297,31 @@ export const appRouter = router({
message: 'Account login is blocked.',
});
}
if (existing && existing.id !== localUser.id) {
if (existingById && existingById.id !== localUser.id) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 계정에 연결된 카카오 계정입니다.',
});
}
let verified = localUser;
if (existing?.id !== localUser.id) {
if (existingByEmail && existingByEmail.id !== localUser.id) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.',
});
}
if (localUser.oauthType === 'KAKAO' && localUser.oauthId !== profile.kakaoId) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 카카오 계정에 연결된 사용자입니다.',
});
}
const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, localUser.oauthInfo);
let verified: UserRecord;
if (existingById?.id !== localUser.id && localUser.oauthType !== 'KAKAO') {
try {
verified = await ctx.users.linkKakao(localUser.id, {
oauthId: me.id,
email: kakaoAccount.email,
oauthId: profile.kakaoId,
email: profile.email,
oauthInfo,
verifiedAt: new Date(),
});
@@ -276,28 +332,40 @@ export const appRouter = router({
cause: error,
});
}
}
if (existing?.id === localUser.id) {
await ctx.users.updateOAuthInfo(localUser.id, oauthInfo);
} else {
try {
verified = await ctx.users.syncKakaoIdentity(localUser.id, profile.email, oauthInfo);
} catch (error) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.',
cause: error,
});
}
}
const refreshed = (await ctx.users.findById(verified.id)) ?? verified;
const session = await ctx.sessions.createSession(refreshed);
await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified');
return {
status: 'verified' as const,
user: toPublicUser(refreshed),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
};
return finishKakaoLogin(ctx, refreshed, token.accessToken, 'verified');
}
if (pending.mode === 'change_pw') {
if (!existing) {
if (!existingById) {
throw new TRPCError({
code: 'NOT_FOUND',
message: '카카오 계정에 연결된 사용자를 찾지 못했습니다.',
});
}
const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, existingById.oauthInfo);
let existing: UserRecord;
try {
existing = await ctx.users.syncKakaoIdentity(existingById.id, profile.email, oauthInfo);
} catch (error) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.',
cause: error,
});
}
const nextPasswordChange = existing.oauthInfo?.nextPasswordChange
? parseDate(existing.oauthInfo.nextPasswordChange)
: null;
@@ -326,31 +394,36 @@ export const appRouter = router({
};
}
if (existing) {
if (isLoginBanned(existing.sanctions)) {
if (existingById) {
if (isLoginBanned(existingById.sanctions)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account login is blocked.',
});
}
await ctx.users.updateOAuthInfo(existing.id, oauthInfo);
const session = await ctx.sessions.createSession(existing);
return {
status: 'login' as const,
user: toPublicUser(existing),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
};
const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, existingById.oauthInfo);
let synced: UserRecord;
try {
synced = await ctx.users.syncKakaoIdentity(existingById.id, profile.email, oauthInfo);
} catch (error) {
throw new TRPCError({
code: 'CONFLICT',
message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.',
cause: error,
});
}
return finishKakaoLogin(ctx, synced, token.accessToken, 'login');
}
const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
const stored = await ctx.oauthSessions.createSession({
mode: pending.mode,
kakaoId: me.id,
email: kakaoAccount.email,
kakaoId: profile.kakaoId,
email: profile.email,
accessToken: token.accessToken,
refreshToken: token.refreshToken,
accessTokenValidUntil,
refreshTokenValidUntil,
accessTokenValidUntil: joinOauthInfo.accessTokenValidUntil!,
refreshTokenValidUntil: joinOauthInfo.refreshTokenValidUntil,
createdAt: new Date().toISOString(),
});
@@ -440,12 +513,7 @@ export const appRouter = router({
cause: error,
});
}
const session = await ctx.sessions.createSession(created);
return {
user: toPublicUser(created),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
};
return finishKakaoLogin(ctx, created, oauthSession.accessToken, 'login');
}),
passwordKey: procedure.query(({ ctx }) => ctx.passwordEnvelope.getPublicKey()),
checkRegistrationField: procedure
@@ -566,13 +634,57 @@ export const appRouter = router({
message: 'Account login is blocked.',
});
}
if (user.oauthType === 'KAKAO') {
const ready = await verifyStoredKakaoIdentity({
user,
users: ctx.users,
kakaoClient: ctx.kakaoClient,
}).catch((error: unknown) => throwKakaoVerificationError(error));
return finishKakaoLogin(ctx, ready.user, ready.accessToken, 'login');
}
const session = await ctx.sessions.createSession(user);
return {
status: 'login' as const,
user: toPublicUser(user),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
};
}),
kakaoOtp: procedure
.input(
z.object({
challengeId: z.string().uuid(),
code: z.string().regex(/^\d{4}$/, '인증 코드는 숫자 4자리입니다.'),
})
)
.mutation(async ({ ctx, input }) => {
const verified = await verifyKakaoTalkChallenge({
challengeId: input.challengeId,
code: input.code,
oauthSessions: ctx.oauthSessions,
users: ctx.users,
}).catch((error: unknown) => throwKakaoVerificationError(error));
if (verified.user.deleteAfter) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account deletion is pending.',
});
}
if (isLoginBanned(verified.user.sanctions)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account login is blocked.',
});
}
const session = await ctx.sessions.createSession(verified.user);
return {
status: 'login' as const,
user: toPublicUser(verified.user),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
validUntil: verified.validUntil,
};
}),
me: procedure
.input(
z.object({
+275 -15
View File
@@ -22,6 +22,9 @@ const buildCaller = (
localAccountGraceDays?: number;
flushError?: Error;
profileListError?: Error;
kakaoId?: string;
kakaoEmail?: string;
allowKakaoRefresh?: boolean;
} = {}
) => {
const users = createInMemoryUserRepository();
@@ -37,32 +40,48 @@ const buildCaller = (
}),
};
const oauthSessions = new InMemoryOAuthSessionStore();
const kakaoProfile = {
id: options.kakaoId ?? '1',
email: options.kakaoEmail ?? 'tester@example.com',
};
const sentTalkMessages: string[] = [];
const refreshTokenCalls: string[] = [];
const kakaoClient = {
restKey: '',
redirectUri: '',
oauthHost: '',
apiHost: '',
buildAuthUrl: (state: string) => `https://kauth.example.test/authorize?state=${state}`,
buildAuthUrl: (state: string, scopes: string[]) =>
`https://kauth.example.test/authorize?state=${state}&scope=${scopes.join(',')}`,
exchangeCode: async () => ({
accessToken: 'access-token',
accessTokenExpiresIn: 3600,
refreshToken: 'refresh-token',
refreshTokenExpiresIn: 86400,
}),
refreshToken: async () => {
throw new Error('not used');
refreshToken: async (refreshToken: string) => {
refreshTokenCalls.push(refreshToken);
if (!options.allowKakaoRefresh) {
throw new Error('not used');
}
return {
accessToken: 'refreshed-access-token',
accessTokenExpiresIn: 3600,
};
},
signup: async () => ({ id: '1' }),
getMe: async () => ({
id: '1',
id: kakaoProfile.id,
kakaoAccount: {
hasEmail: true,
email: 'tester@example.com',
email: kakaoProfile.email,
isEmailValid: true,
isEmailVerified: true,
},
}),
sendTalkMessage: async () => {},
sendTalkMessage: async (_accessToken: string, message: string) => {
sentTalkMessages.push(message);
},
};
const profileRows = [
{
@@ -213,6 +232,9 @@ const buildCaller = (
sessions,
flushPublisher,
userIconUpload,
kakaoProfile,
sentTalkMessages,
refreshTokenCalls,
sealPassword,
setSessionHeader: (sessionToken: string) => {
requestHeaders['x-session-token'] = sessionToken;
@@ -247,6 +269,10 @@ describe('gateway auth flow', () => {
username: 'LOCAL-USER',
credential: sealPassword('비밀번호-password'),
});
expect(login.status).toBe('login');
if (login.status !== 'login') {
throw new Error('Expected completed login.');
}
expect(login.user.username).toBe('local-user');
});
@@ -405,7 +431,7 @@ describe('gateway auth flow', () => {
});
it('links Kakao to the logged-in local account instead of creating a second user', async () => {
const { caller, users, sealPassword, setSessionHeader } = buildCaller();
const { caller, users, sealPassword, setSessionHeader, sentTalkMessages } = buildCaller();
const register = await caller.auth.registerLocal({
username: 'verify-user',
credential: sealPassword('verify-password'),
@@ -421,11 +447,14 @@ describe('gateway auth flow', () => {
state: start.state,
});
expect(verified.status).toBe('verified');
if (verified.status !== 'verified') {
throw new Error('Expected verified result.');
expect(verified.status).toBe('otp');
if (verified.status !== 'otp') {
throw new Error('Expected Kakao OTP challenge.');
}
expect(verified.user.kakaoVerified).toBe(true);
const code = sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1];
expect(code).toBeTruthy();
const completed = await caller.auth.kakaoOtp({ challengeId: verified.challengeId, code: code! });
expect(completed.user.kakaoVerified).toBe(true);
const stored = await users.findByUsername('verify-user');
expect(stored).toMatchObject({
oauthType: 'KAKAO',
@@ -435,6 +464,230 @@ describe('gateway auth flow', () => {
expect(stored?.kakaoVerifiedAt).toBeTruthy();
});
it('always requests both email and KakaoTalk message consent', async () => {
const { caller } = buildCaller();
const start = await caller.auth.kakaoStart({ mode: 'login', scopes: [] });
expect(decodeURIComponent(start.authUrl)).toContain('scope=account_email,talk_message');
});
it('rejects a new Kakao identity when its verified email is already registered', async () => {
const { caller, users, kakaoProfile } = buildCaller();
await users.createUser({
username: 'email-owner',
password: 'owner-password',
oauth: {
type: 'KAKAO',
id: 'original-kakao-id',
email: 'tester@example.com',
info: {},
},
});
kakaoProfile.id = 'different-kakao-id';
const start = await caller.auth.kakaoStart({ mode: 'login' });
await expect(caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state })).rejects.toMatchObject({
code: 'CONFLICT',
message: expect.stringContaining('이미 다른 계정에서 사용 중인 카카오 이메일'),
});
});
it('synchronizes a changed email by stable Kakao ID during Kakao login', async () => {
const { caller, users, kakaoProfile } = buildCaller({
kakaoId: 'stable-kakao-id',
kakaoEmail: 'changed@example.com',
});
const user = await users.createUser({
username: 'kakao-email-change',
password: 'email-change-password',
oauth: {
type: 'KAKAO',
id: 'stable-kakao-id',
email: 'before@example.com',
info: {},
},
});
await users.markKakaoTalkVerified(user.id, new Date(Date.now() + 60_000));
const start = await caller.auth.kakaoStart({ mode: 'login' });
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
expect(login.status).toBe('login');
expect((await users.findById(user.id))?.email).toBe(kakaoProfile.email);
expect(await users.findByEmail('before@example.com')).toBeNull();
});
it('checks Kakao identity and changed email on password login, then verifies the talk OTP', async () => {
const { caller, users, sealPassword, kakaoProfile, sentTalkMessages } = buildCaller({
kakaoId: 'password-login-kakao-id',
kakaoEmail: 'after-password-login@example.com',
});
const user = await users.createUser({
username: 'kakao-password-login',
password: 'kakao-password',
oauth: {
type: 'KAKAO',
id: kakaoProfile.id,
email: 'before-password-login@example.com',
info: {
accessToken: 'stored-access-token',
refreshToken: 'stored-refresh-token',
accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(),
refreshTokenValidUntil: new Date(Date.now() + 86_400_000).toISOString(),
},
},
});
const login = await caller.auth.login({
username: user.username,
credential: sealPassword('kakao-password'),
});
expect(login.status).toBe('otp');
if (login.status !== 'otp') {
throw new Error('Expected Kakao OTP challenge.');
}
expect((await users.findById(user.id))?.email).toBe(kakaoProfile.email);
expect(sentTalkMessages).toHaveLength(1);
await expect(caller.auth.kakaoOtp({ challengeId: login.challengeId, code: '0000' })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
message: expect.stringContaining('2회 더 시도'),
});
const code = sentTalkMessages[0]?.match(/인증 코드는 (\d{4})/)?.[1];
expect(code).toBeTruthy();
const completed = await caller.auth.kakaoOtp({ challengeId: login.challengeId, code: code! });
expect(completed.validUntil).toBeTruthy();
expect((await users.findById(user.id))?.kakaoTalkVerifiedUntil).toBe(completed.validUntil);
const nextLogin = await caller.auth.login({
username: user.username,
credential: sealPassword('kakao-password'),
});
expect(nextLogin.status).toBe('login');
expect(sentTalkMessages).toHaveLength(1);
});
it('refreshes an expired access token before the password-login identity check', async () => {
const { caller, users, sealPassword, kakaoProfile, refreshTokenCalls } = buildCaller({
kakaoId: 'refresh-kakao-id',
kakaoEmail: 'refreshed-email@example.com',
allowKakaoRefresh: true,
});
const user = await users.createUser({
username: 'refresh-kakao-user',
password: 'refresh-kakao-password',
oauth: {
type: 'KAKAO',
id: kakaoProfile.id,
email: 'old-refresh-email@example.com',
info: {
accessToken: 'expired-access-token',
refreshToken: 'usable-refresh-token',
accessTokenValidUntil: new Date(Date.now() - 60_000).toISOString(),
refreshTokenValidUntil: new Date(Date.now() + 86_400_000).toISOString(),
},
},
});
const login = await caller.auth.login({
username: user.username,
credential: sealPassword('refresh-kakao-password'),
});
expect(login.status).toBe('otp');
expect(refreshTokenCalls).toEqual(['usable-refresh-token']);
expect(await users.findById(user.id)).toMatchObject({
email: 'refreshed-email@example.com',
oauthInfo: {
accessToken: 'refreshed-access-token',
refreshToken: 'usable-refresh-token',
},
});
});
it('rejects password-login email synchronization when the changed email belongs to another user', async () => {
const { caller, users, sealPassword, kakaoProfile } = buildCaller({
kakaoId: 'conflicting-email-kakao-id',
kakaoEmail: 'occupied@example.com',
});
const user = await users.createUser({
username: 'conflicting-email-user',
password: 'conflicting-email-password',
oauth: {
type: 'KAKAO',
id: kakaoProfile.id,
email: 'previous@example.com',
info: {
accessToken: 'stored-access-token',
accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(),
},
},
});
await users.createUser({
username: 'occupied-email-owner',
password: 'occupied-email-password',
oauth: {
type: 'KAKAO',
id: 'other-kakao-id',
email: kakaoProfile.email,
info: {},
},
});
await expect(
caller.auth.login({
username: user.username,
credential: sealPassword('conflicting-email-password'),
})
).rejects.toMatchObject({
code: 'CONFLICT',
message: expect.stringContaining('이미 다른 계정에서 사용 중'),
});
expect((await users.findById(user.id))?.email).toBe('previous@example.com');
});
it('reuses the active challenge and blocks retries after three wrong OTP values', async () => {
const { caller, users, sealPassword, kakaoProfile, sentTalkMessages } = buildCaller({
kakaoId: 'attempt-limit-kakao-id',
});
const user = await users.createUser({
username: 'attempt-limit-user',
password: 'attempt-limit-password',
oauth: {
type: 'KAKAO',
id: kakaoProfile.id,
email: kakaoProfile.email,
info: {
accessToken: 'stored-access-token',
accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(),
},
},
});
const login = await caller.auth.login({
username: user.username,
credential: sealPassword('attempt-limit-password'),
});
expect(login.status).toBe('otp');
if (login.status !== 'otp') {
throw new Error('Expected Kakao OTP challenge.');
}
for (const remaining of [2, 1, 0]) {
await expect(caller.auth.kakaoOtp({ challengeId: login.challengeId, code: '0000' })).rejects.toMatchObject({
code: 'UNAUTHORIZED',
message:
remaining > 0 ? expect.stringContaining(`${remaining}회 더 시도`) : expect.stringContaining('초과'),
});
}
const retried = await caller.auth.login({
username: user.username,
credential: sealPassword('attempt-limit-password'),
});
expect(retried).toMatchObject({ status: 'otp', challengeId: login.challengeId, attemptsRemaining: 0 });
expect(sentTalkMessages).toHaveLength(1);
});
it('blocks Kakao login while a ban is active', async () => {
const { caller, users, sealPassword, setSessionHeader } = buildCaller();
const register = await caller.auth.registerLocal({
@@ -503,7 +756,7 @@ describe('gateway auth flow', () => {
});
it('registers and issues a game session', async () => {
const { caller, oauthSessions, sealPassword } = buildCaller();
const { caller, oauthSessions, sealPassword, sentTalkMessages } = buildCaller();
const oauthSession = await oauthSessions.createSession({
mode: 'login',
kakaoId: '1',
@@ -524,11 +777,18 @@ describe('gateway auth flow', () => {
thirdPartyUse: false,
});
expect(register.user.username).toBe('tester');
expect(register.sessionToken).toBeTruthy();
expect(register.status).toBe('otp');
if (register.status !== 'otp') {
throw new Error('Expected Kakao OTP challenge.');
}
const code = sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1];
expect(code).toBeTruthy();
const completed = await caller.auth.kakaoOtp({ challengeId: register.challengeId, code: code! });
expect(completed.user.username).toBe('tester');
expect(completed.sessionToken).toBeTruthy();
const issued = await caller.auth.issueGameSession({
sessionToken: register.sessionToken,
sessionToken: completed.sessionToken,
profile: 'che:default',
});
@@ -0,0 +1,78 @@
import { randomUUID } from 'node:crypto';
import { createClient } from 'redis';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { RedisOAuthSessionStore } from '../src/auth/oauthSessionStore.js';
const redisUrl = process.env.GATEWAY_OAUTH_REDIS_TEST_URL;
describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () => {
const prefix = `gateway-oauth-test:${randomUUID()}`;
const client = createClient({ url: redisUrl });
const store = new RedisOAuthSessionStore(client, prefix, 300);
const userIds = new Set<string>();
const challengeIds = new Set<string>();
beforeAll(async () => {
await client.connect();
});
afterAll(async () => {
const keys = [
...[...challengeIds].map((id) => `${prefix}:kakao-login-challenge:${id}`),
...[...userIds].map((id) => `${prefix}:kakao-login-challenge-user:${id}`),
];
if (keys.length > 0) {
await client.del(keys);
}
await client.quit();
});
const createChallenge = async () => {
const userId = randomUUID();
userIds.add(userId);
const challenge = await store.createLoginChallenge({
userId,
code: '4321',
attemptsRemaining: 3,
expiresAt: new Date(Date.now() + 180_000).toISOString(),
createdAt: new Date().toISOString(),
});
challengeIds.add(challenge.id);
return challenge;
};
it('atomically consumes a successful code once', async () => {
const challenge = await createChallenge();
const results = await Promise.all([
store.verifyLoginChallenge(challenge.id, challenge.code),
store.verifyLoginChallenge(challenge.id, challenge.code),
]);
expect(results.filter((result) => result.status === 'verified')).toHaveLength(1);
expect(results.filter((result) => result.status === 'expired')).toHaveLength(1);
expect(await store.getLoginChallengeForUser(challenge.userId)).toBeNull();
});
it('atomically limits parallel wrong-code submissions to three attempts', async () => {
const challenge = await createChallenge();
const results = await Promise.all(
Array.from({ length: 4 }, () => store.verifyLoginChallenge(challenge.id, '0000'))
);
expect(results.filter((result) => result.status === 'mismatch')).toHaveLength(3);
expect(results.filter((result) => result.status === 'locked')).toHaveLength(1);
expect(
results
.filter((result) => result.status === 'mismatch')
.map((result) => result.attemptsRemaining)
.sort()
).toEqual([0, 1, 2]);
await expect(store.verifyLoginChallenge(challenge.id, challenge.code)).resolves.toMatchObject({
status: 'locked',
});
});
});
+1 -1
View File
@@ -37,7 +37,7 @@ describe('readReleaseManifest', () => {
const workspaceRoot = path.resolve(import.meta.dirname, '../../..');
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
gatewaySchemaHead: '20260806000000_add_admin_audit_and_kakao_grace',
gatewaySchemaHead: '20260808000000_add_kakao_talk_verification',
gameSchemaHead: '20260803000000_add_logical_game_clock',
});
});
+218
View File
@@ -0,0 +1,218 @@
import { generateKeyPairSync } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { expect, test, type Page, type Route } from '@playwright/test';
const artifactRoot = process.env.KAKAO_OTP_ARTIFACT_DIR ? resolve(process.env.KAKAO_OTP_ARTIFACT_DIR) : null;
const response = (data: unknown) => ({ result: { data } });
const errorResponse = (path: string, message: string) => ({
error: {
message,
code: -32001,
data: {
code: 'UNAUTHORIZED',
httpStatus: 401,
path,
},
},
});
const operationNames = (route: Route): string[] => {
const url = new URL(route.request().url());
return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(',');
};
const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
const challenge = {
status: 'otp' as const,
challengeId: '11111111-1111-4111-8111-111111111111',
expiresAt: '2026-08-08T06:00:00.000Z',
attemptsRemaining: 3,
};
const installFixture = async (page: Page, source: 'password' | 'oauth') => {
let loggedIn = false;
let otpAttempts = 0;
await page.route('**/gateway/api/trpc/**', async (route) => {
const operations = operationNames(route);
const results = await Promise.all(
operations.map(async (operation) => {
if (operation === 'me') {
return response(
loggedIn
? {
id: 'kakao-otp-user',
username: 'kakao-otp-user',
displayName: '카카오 인증 사용자',
roles: [],
picture: 'default.jpg',
kakaoVerified: true,
kakaoGraceStartedAt: '2026-08-08T00:00:00.000Z',
createdAt: '2026-08-08T00:00:00.000Z',
}
: null
);
}
if (operation === 'lobby.notice') return response('');
if (operation === 'lobby.profiles') return response([]);
if (operation === 'auth.passwordKey') {
return response({ keyId: 'playwright-key', publicKeyPem, algorithm: 'RSA-OAEP-256' });
}
if (operation === 'auth.login') return response({ ...challenge, successStatus: 'login' });
if (operation === 'auth.kakaoExchange') {
return response({ ...challenge, successStatus: source === 'oauth' ? 'verified' : 'login' });
}
if (operation === 'auth.kakaoOtp') {
otpAttempts += 1;
await new Promise((resolveDelay) => setTimeout(resolveDelay, 800));
if (otpAttempts === 1) {
return errorResponse(operation, '인증 번호가 틀렸습니다. 2회 더 시도할 수 있습니다.');
}
loggedIn = true;
return response({
status: 'login',
user: {
id: 'kakao-otp-user',
username: 'kakao-otp-user',
displayName: '카카오 인증 사용자',
roles: [],
picture: 'default.jpg',
kakaoVerified: true,
kakaoGraceStartedAt: '2026-08-08T00:00:00.000Z',
createdAt: '2026-08-08T00:00:00.000Z',
},
sessionToken: 'verified-session-token',
issuedAt: '2026-08-08T05:57:00.000Z',
validUntil: '2026-08-18T05:57:00.000Z',
});
}
throw new Error(`Unhandled Kakao OTP fixture operation: ${operation}`);
})
);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
return { otpAttempts: () => otpAttempts };
};
const verifyDialog = async (page: Page, artifactName: string) => {
const dialog = page.getByRole('dialog', { name: '인증 코드 필요' });
const input = dialog.getByLabel('인증 코드');
const submit = dialog.getByRole('button', { name: '제출' });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText("카카오톡의 '나와의 채팅'란을 확인해 주세요.");
await expect(input).toBeFocused();
const geometry = await dialog.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
backgroundColor: style.backgroundColor,
border: style.border,
fontSize: style.fontSize,
};
});
expect(geometry.width).toBeLessThanOrEqual(500);
expect(geometry.width).toBeGreaterThan(350);
expect(geometry.backgroundColor).toBe('rgb(48, 48, 48)');
expect(geometry.border).toBe('1px solid rgb(68, 68, 68)');
await submit.hover();
await page.waitForTimeout(200);
expect(await submit.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(55, 90, 127)');
await input.press('Tab');
await page.keyboard.press('Tab');
await expect(submit).toBeFocused();
await page.waitForTimeout(200);
expect(await submit.evaluate((element) => getComputedStyle(element).boxShadow)).toMatch(
/^rgba\(85, 115, 146, 0\.49\d\) 0px 0px 0px 4px$/
);
if (artifactRoot) {
await mkdir(artifactRoot, { recursive: true });
await page.screenshot({ path: resolve(artifactRoot, `${artifactName}.png`), fullPage: true });
await writeFile(
resolve(artifactRoot, `${artifactName}.json`),
`${JSON.stringify(geometry, null, 2)}\n`,
'utf8'
);
}
const verifyPointerActive = async () => {
const box = await submit.boundingBox();
if (!box) throw new Error('OTP submit button has no rendered geometry.');
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
expect(await submit.evaluate((element) => element.matches(':active'))).toBe(true);
await page.waitForTimeout(200);
expect(await submit.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(44, 72, 102)');
await page.mouse.move(1, 1);
await page.mouse.up();
};
const submitAndObserveDisabled = () =>
submit.evaluate(
(element) =>
new Promise<{ disabled: boolean; opacity: string }>((resolveDisabled) => {
(element as HTMLButtonElement).click();
setTimeout(
() =>
resolveDisabled({
disabled: (element as HTMLButtonElement).disabled,
opacity: getComputedStyle(element).opacity,
}),
200
);
})
);
await input.fill('0000');
await verifyPointerActive();
expect(await submitAndObserveDisabled()).toEqual({ disabled: true, opacity: '0.65' });
await expect(dialog.getByRole('alert')).toContainText('2회 더 시도');
await expect(input).toBeFocused();
await input.fill('1234');
expect(await submitAndObserveDisabled()).toEqual({ disabled: true, opacity: '0.65' });
await expect(page).toHaveURL(/\/gateway\/lobby(?:\?verified=1)?$/);
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem('sammo-session-token')))
.toBe('verified-session-token');
return geometry;
};
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 390, height: 844 },
] as const) {
test(`completes password-login KakaoTalk OTP on ${viewport.name}`, async ({ page }) => {
const fixture = await installFixture(page, 'password');
await page.setViewportSize(viewport);
await page.goto('/gateway/');
await page.getByLabel('계정명').fill('kakao-otp-user');
await page.getByLabel('비밀번호').fill('password-for-browser-fixture');
await page.getByRole('button', { name: '로그인', exact: true }).click();
const geometry = await verifyDialog(page, `kakao-otp-password-${viewport.name}`);
expect(geometry.width).toBe(viewport.name === 'desktop' ? 500 : 374);
expect(geometry.y).toBe(viewport.name === 'desktop' ? 28 : 8);
expect(fixture.otpAttempts()).toBe(2);
});
}
test('completes the same KakaoTalk OTP flow after OAuth callback', async ({ page }) => {
const fixture = await installFixture(page, 'oauth');
await page.setViewportSize({ width: 1200, height: 900 });
await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state');
await verifyDialog(page, 'kakao-otp-oauth-callback');
await expect(page).toHaveURL(/\/gateway\/lobby\?verified=1$/);
expect(fixture.otpAttempts()).toBe(2);
});
@@ -60,6 +60,9 @@ const loginGame = async (username: string, password: string) => {
).toString('base64'),
};
const login = await gateway.auth.login.mutate({ username, credential });
if (login.status === 'otp') {
throw new Error('Live lifecycle fixture requires a currently verified KakaoTalk login.');
}
gatewaySession.token = login.sessionToken;
const issued = await gateway.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
@@ -16,6 +16,7 @@ export default defineConfig({
'account-icon-sync.spec.ts',
'legacy-log-html.spec.ts',
'gateway-notice-html.spec.ts',
'kakao-otp.spec.ts',
],
fullyParallel: false,
workers: 1,
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import { trpc } from '../utils/trpc';
const props = defineProps<{
challengeId: string;
}>();
const emit = defineEmits<{
verified: [sessionToken: string, validUntil: string];
cancel: [];
}>();
const code = ref('');
const errorMessage = ref('');
const submitting = ref(false);
const codeInput = ref<HTMLInputElement | null>(null);
watch(
() => props.challengeId,
async () => {
code.value = '';
errorMessage.value = '';
await nextTick();
codeInput.value?.focus();
},
{ immediate: true }
);
const submit = async (): Promise<void> => {
errorMessage.value = '';
submitting.value = true;
try {
const result = await trpc.auth.kakaoOtp.mutate({
challengeId: props.challengeId,
code: code.value,
});
emit('verified', result.sessionToken, result.validUntil);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '인증 코드를 확인하지 못했습니다.';
code.value = '';
await nextTick();
codeInput.value?.focus();
} finally {
submitting.value = false;
}
};
</script>
<template>
<Teleport to="body">
<div class="otp-backdrop" @keydown.esc="emit('cancel')">
<section
class="otp-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="kakao-otp-title"
aria-describedby="kakao-otp-description"
>
<header>
<h2 id="kakao-otp-title">인증 코드 필요</h2>
<button class="close-button" type="button" aria-label="닫기" @click="emit('cancel')">×</button>
</header>
<form @submit.prevent="submit">
<div id="kakao-otp-description" class="otp-copy">
인증 코드가 필요합니다.<br /><br />
카카오톡의 '나와의 채팅'란을 확인해 주세요.<br />
(별도의 알림[소리, 진동, 숫자] 발생하지 않습니다.)
</div>
<label class="otp-input-row" for="kakao-otp-code">
<span>인증 코드</span>
<input
id="kakao-otp-code"
ref="codeInput"
v-model="code"
type="text"
inputmode="numeric"
pattern="[0-9]{4}"
maxlength="4"
autocomplete="one-time-code"
placeholder="인증 코드"
required
/>
</label>
<p v-if="errorMessage" class="otp-error" role="alert">{{ errorMessage }}</p>
<footer>
<button class="cancel-button" type="button" @click="emit('cancel')">취소</button>
<button class="submit-button" type="submit" :disabled="submitting">
{{ submitting ? '확인 중…' : '제출' }}
</button>
</footer>
</form>
</section>
</div>
</Teleport>
</template>
<style scoped>
.otp-backdrop {
position: fixed;
z-index: 1000;
inset: 0;
overflow-y: auto;
background: rgb(0 0 0 / 60%);
}
.otp-dialog {
width: min(calc(100% - 16px), 500px);
margin: 28px auto;
overflow: hidden;
border: 1px solid #444;
border-radius: 5px;
background: #303030;
color: #fff;
box-shadow: 0 8px 24px rgb(0 0 0 / 45%);
}
@media (max-width: 575px) {
.otp-dialog {
margin-top: 8px;
}
}
.otp-dialog header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #555;
padding: 16px;
}
.otp-dialog h2 {
margin: 0;
font-size: 20px;
font-weight: 500;
}
.close-button {
width: 26px;
height: 30px;
border: 1px solid #aaa;
background: #fff;
color: #000;
font-size: 16px;
line-height: 1;
cursor: pointer;
}
.otp-copy {
padding: 18px 18px 0;
line-height: 1.5;
}
.otp-input-row {
display: grid;
grid-template-columns: auto 1fr;
margin: 22px 16px 0;
}
.otp-input-row span,
.otp-input-row input {
border: 1px solid #000;
padding: 6px 12px;
}
.otp-input-row span {
border-radius: 4px 0 0 4px;
background: #303030;
color: #adb5bd;
}
.otp-input-row input {
min-width: 0;
border-left: 0;
border-radius: 0 4px 4px 0;
background: #ddd;
color: #303030;
}
.otp-input-row input:focus-visible {
outline: 0;
border-color: #9a9a9a;
box-shadow: 0 0 0 4px rgb(55 90 127 / 25%);
}
.close-button:focus-visible {
outline: 2px solid #375a7f;
outline-offset: 1px;
}
.otp-error {
margin: 8px 18px 0;
font-size: 13px;
}
.otp-error {
color: #ff8a80;
}
.otp-dialog footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
border-top: 1px solid #555;
padding: 15px 16px;
}
.otp-dialog footer button {
min-width: 64px;
border: 1px solid transparent;
border-radius: 4px;
padding: 7px 12px;
color: #fff;
cursor: pointer;
transition:
color 0.15s ease-in-out,
background-color 0.15s ease-in-out,
border-color 0.15s ease-in-out,
box-shadow 0.15s ease-in-out;
}
.cancel-button {
border-color: #444 !important;
background: #444;
}
.cancel-button:active {
background: #363636;
}
.submit-button {
border-color: #325172 !important;
background: #375a7f;
}
.submit-button:hover {
background: #375a7f;
}
.submit-button:focus-visible {
outline: 0;
box-shadow: 0 0 0 4px rgb(85 115 146 / 50%);
}
.submit-button:active {
background: #2c4866;
}
.submit-button:disabled {
border-color: #375a7f !important;
background: #375a7f;
cursor: pointer;
opacity: 0.65;
}
</style>
@@ -5,6 +5,7 @@ import type { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@sammo-ts/gateway-api';
import MapPreview from '../components/MapPreview.vue';
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import { createGameTrpc, type GameRouter } from '../utils/gameTrpc';
import { trpc } from '../utils/trpc';
@@ -24,6 +25,7 @@ const username = ref('');
const password = ref('');
const loginError = ref('');
const loginLoading = ref(false);
const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null);
const statusLoading = ref(false);
const statusError = ref('');
const profile = ref<LobbyProfile | null>(null);
@@ -92,6 +94,10 @@ const handleLogin = async (): Promise<void> => {
username: username.value,
credential,
});
if (result.status === 'otp') {
otpChallenge.value = result;
return;
}
window.localStorage.setItem('sammo-session-token', result.sessionToken);
await router.push('/lobby');
} catch (error) {
@@ -101,6 +107,12 @@ const handleLogin = async (): Promise<void> => {
}
};
const handleOtpVerified = async (sessionToken: string): Promise<void> => {
window.localStorage.setItem('sammo-session-token', sessionToken);
otpChallenge.value = null;
await router.push('/lobby');
};
const handleKakao = async (): Promise<void> => {
loginError.value = '';
try {
@@ -191,6 +203,12 @@ const handlePasswordReset = async (): Promise<void> => {
</section>
</div>
</DefaultLayout>
<KakaoOtpDialog
v-if="otpChallenge"
:challenge-id="otpChallenge.challengeId"
@verified="handleOtpVerified"
@cancel="otpChallenge = null"
/>
</template>
<style scoped>
@@ -3,6 +3,7 @@ import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
import { trpc } from '../utils/trpc';
import { sealPassword } from '../utils/passwordEnvelope';
@@ -21,6 +22,8 @@ const displayName = ref('');
const termsAgreed = ref(false);
const privacyAgreed = ref(false);
const thirdPartyUse = ref(false);
const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null);
const otpSuccessStatus = ref<'login' | 'verified'>('login');
const appBase = import.meta.env.BASE_URL;
const completeExchange = async (): Promise<void> => {
@@ -33,6 +36,11 @@ const completeExchange = async (): Promise<void> => {
}
try {
const result = await trpc.auth.kakaoExchange.mutate({ code, state });
if (result.status === 'otp') {
otpChallenge.value = result;
otpSuccessStatus.value = result.successStatus;
return;
}
if (result.status === 'login') {
window.localStorage.setItem('sammo-session-token', result.sessionToken);
await router.replace('/lobby');
@@ -78,6 +86,12 @@ const register = async (): Promise<void> => {
privacyAgreed: true,
thirdPartyUse: thirdPartyUse.value,
});
if (result.status === 'otp') {
otpChallenge.value = result;
otpSuccessStatus.value = result.successStatus;
oauthSessionId.value = '';
return;
}
window.localStorage.setItem('sammo-session-token', result.sessionToken);
await router.replace('/lobby');
} catch (error) {
@@ -87,6 +101,12 @@ const register = async (): Promise<void> => {
}
};
const handleOtpVerified = async (sessionToken: string): Promise<void> => {
window.localStorage.setItem('sammo-session-token', sessionToken);
otpChallenge.value = null;
await router.replace(otpSuccessStatus.value === 'verified' ? '/lobby?verified=1' : '/lobby');
};
onMounted(() => {
void completeExchange();
});
@@ -120,7 +140,13 @@ onMounted(() => {
<div class="form-row">
<label for="oauth-display-name">닉네임</label>
<div>
<input id="oauth-display-name" v-model="displayName" minlength="2" maxlength="40" required />
<input
id="oauth-display-name"
v-model="displayName"
minlength="2"
maxlength="40"
required
/>
<small>깃수가 종료될 공개됩니다. 계속 사용할 이름이므로 신중하게 정해주세요.</small>
</div>
</div>
@@ -154,6 +180,12 @@ onMounted(() => {
</section>
</main>
</DefaultLayout>
<KakaoOtpDialog
v-if="otpChallenge"
:challenge-id="otpChallenge.challengeId"
@verified="handleOtpVerified"
@cancel="otpChallenge = null"
/>
</template>
<style scoped>