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
+120
View File
@@ -0,0 +1,120 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { chromium } from '@playwright/test';
const refRoot = process.env.REF_SAM_ROOT;
const artifactRoot = process.env.KAKAO_OTP_ARTIFACT_DIR;
if (!refRoot || !artifactRoot) {
throw new Error('REF_SAM_ROOT and KAKAO_OTP_ARTIFACT_DIR are required.');
}
const modalHtml = `
<!doctype html>
<html lang="ko">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body>
<div class="modal show" id="modalOTP" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-modal="true" style="display:block">
<div class="modal-dialog" role="document">
<div class="modal-content">
<form id="otp_form" method="post" action="#">
<div class="modal-header">
<h5 class="modal-title" id="exampleModalLabel">인증 코드 필요</h5>
<button type="button" class="close" aria-label="Close"><span aria-hidden="true">&times;</span></button>
</div>
<div class="modal-body">
<div>인증 코드가 필요합니다.<br><br>카카오톡의 '나와의 채팅'란을 확인해 주세요.<br>(별도의 알림[소리, 진동, 숫자]이 발생하지 않습니다.)</div>
<div class="input-group mt-4" role="group">
<div class="input-group-text">인증 코드</div>
<input type="number" class="form-control" name="otp" id="otp_code" placeholder="인증 코드">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary">취소</button>
<button type="submit" class="btn btn-primary">제출</button>
</div>
</form>
</div>
</div>
</div>
</body>
</html>`;
await mkdir(artifactRoot, { recursive: true });
const browser = await chromium.launch();
try {
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 390, height: 844 },
]) {
const page = await browser.newPage({ viewport });
await page.setContent(modalHtml);
await page.addStyleTag({ path: resolve(refRoot, 'dist_js/gateway/common_ts.css') });
await page.addStyleTag({ path: resolve(refRoot, 'd_shared/common.css') });
await page.addStyleTag({ path: resolve(refRoot, 'dist_js/gateway/login.css') });
await page.addStyleTag({
content: '.modal { background: rgb(0 0 0 / 60%); } body { background: #000; }',
});
const dialog = page.locator('#modalOTP .modal-content');
const input = page.locator('#otp_code');
const submit = page.getByRole('button', { name: '제출' });
const readSubmitStyle = () =>
submit.evaluate((element) => {
const style = getComputedStyle(element);
return {
backgroundColor: style.backgroundColor,
borderColor: style.borderColor,
color: style.color,
outline: style.outline,
boxShadow: style.boxShadow,
opacity: style.opacity,
cursor: style.cursor,
};
});
await page.waitForTimeout(250);
const states = { normal: await readSubmitStyle() };
await submit.hover();
await page.waitForTimeout(250);
states.hover = await readSubmitStyle();
await input.focus();
await input.press('Tab');
await page.keyboard.press('Tab');
await page.waitForTimeout(250);
states.focusVisible = await readSubmitStyle();
const submitBox = await submit.boundingBox();
if (!submitBox) throw new Error('Ref OTP submit button has no geometry.');
await page.mouse.move(submitBox.x + submitBox.width / 2, submitBox.y + submitBox.height / 2);
await page.mouse.down();
await page.waitForTimeout(250);
states.active = await readSubmitStyle();
await page.mouse.move(1, 1);
await page.mouse.up();
await submit.evaluate((element) => {
element.disabled = true;
});
await page.waitForTimeout(250);
states.disabled = await readSubmitStyle();
const geometry = await dialog.evaluate((element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
backgroundColor: style.backgroundColor,
border: style.border,
fontSize: style.fontSize,
};
});
await page.screenshot({ path: resolve(artifactRoot, `kakao-otp-ref-${viewport.name}.png`), fullPage: true });
await writeFile(
resolve(artifactRoot, `kakao-otp-ref-${viewport.name}.json`),
`${JSON.stringify({ ...geometry, submitStates: states }, null, 2)}\n`,
'utf8'
);
await page.close();
}
} finally {
await browser.close();
}
@@ -315,6 +315,9 @@ describe('auction integration flow', () => {
username: user.username,
credential: sealGatewayPassword(user.password, await gatewayClient.auth.passwordKey.query()),
});
if (login.status !== 'login') {
throw new Error('Local integration fixture unexpectedly requires Kakao OTP.');
}
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
profile: 'che:908',
@@ -231,6 +231,9 @@ describe('integration initialization flow', () => {
username: user.username,
credential: sealGatewayPassword(user.password, await gatewayClient.auth.passwordKey.query()),
});
if (login.status !== 'login') {
throw new Error('Local integration fixture unexpectedly requires Kakao OTP.');
}
expect(login.sessionToken).toBeTruthy();
}
@@ -308,6 +311,9 @@ describe('integration initialization flow', () => {
username: user.username,
credential: sealGatewayPassword(user.password, await gatewayClient.auth.passwordKey.query()),
});
if (login.status !== 'login') {
throw new Error('Local integration fixture unexpectedly requires Kakao OTP.');
}
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
profile: 'che:2',
@@ -608,6 +608,9 @@ describe('pm2 orchestrator e2e', () => {
username: 'e2e-user',
credential: sealGatewayPassword('e2e-pass-123', await gatewayClient.auth.passwordKey.query()),
});
if (login.status !== 'login') {
throw new Error('Local integration fixture unexpectedly requires Kakao OTP.');
}
const gameSession = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
@@ -283,6 +283,9 @@ describe('actual tournament lifecycle', () => {
username,
credential: sealGatewayPassword(`${username}-pass`, await gatewayClient.auth.passwordKey.query()),
});
if (login.status !== 'login') {
throw new Error('Local integration fixture unexpectedly requires Kakao OTP.');
}
const gatewayToken = await gatewayClient.auth.issueGameSession.mutate({
sessionToken: login.sessionToken,
profile: 'che:908',
+2
View File
@@ -79,6 +79,8 @@ next turn.
Kakao members retain their OAuth ID, email, and OAuth metadata.
`kakao_verified_at` and `kakao_grace_started_at` are set to the migration time.
The existing `token_valid_until` is copied to `kakao_talk_verified_until` for
Kakao rows so a still-current “send to me” proof remains current after cutover.
Legacy password hashes and salts are retained and upgraded to Argon2id after
the first successful login when
`GATEWAY_LEGACY_PASSWORD_GLOBAL_SALT` is configured in gateway-api.
+4
View File
@@ -64,6 +64,10 @@ const mapMember = (row: SourceRow, migratedAt: Date, lastLoginAt: Date | null):
terms_accepted_at: null,
privacy_accepted_at: null,
kakao_verified_at: oauthType === 'KAKAO' ? migratedAt : null,
kakao_talk_verified_until:
oauthType === 'KAKAO'
? toNullableDate(row.token_valid_until, `member.${memberNo}.token_valid_until`)
: null,
kakao_grace_started_at: migratedAt,
delete_after: toNullableDate(row.delete_after, `member.${memberNo}.delete_after`),
created_at: toDate(row.REG_DATE, `member.${memberNo}.REG_DATE`),