feat(gateway): verify Kakao account ownership
This commit is contained in:
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user