feat(gateway): verify Kakao account ownership
This commit is contained in:
+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