From bf3725e0406fc2ab50231476b3a886b21395008f Mon Sep 17 00:00:00 2001 From: hided62 Date: Sat, 8 Aug 2026 04:41:16 +0000 Subject: [PATCH] fix(auth): resume Kakao OTP during auto login --- hwe/test-ts/loginFlow.test.ts | 44 +++++ hwe/ts/defs/API/Login.ts | 3 +- hwe/ts/gateway/login.ts | 53 +++--- hwe/ts/gateway/loginFlow.ts | 18 ++ src/sammo/API/Login/LoginByToken.php | 45 ++++- tests/LoginByTokenKakaoFailureTest.php | 36 ++++ tests/browser/auto-login-otp-dialog.mjs | 236 ++++++++++++++++++++++++ 7 files changed, 404 insertions(+), 31 deletions(-) create mode 100644 hwe/test-ts/loginFlow.test.ts create mode 100644 hwe/ts/gateway/loginFlow.ts create mode 100644 tests/LoginByTokenKakaoFailureTest.php create mode 100644 tests/browser/auto-login-otp-dialog.mjs diff --git a/hwe/test-ts/loginFlow.test.ts b/hwe/test-ts/loginFlow.test.ts new file mode 100644 index 00000000..0e7ff076 --- /dev/null +++ b/hwe/test-ts/loginFlow.test.ts @@ -0,0 +1,44 @@ +import { assert } from 'chai'; +import { classifyAutoLoginFailure, OTP_REQUIRED_MESSAGE } from '../ts/gateway/loginFlow'; + +describe('automatic login failure flow', () => { + it('prompts for OTP without treating the challenge as a terminal login failure', () => { + assert.equal( + classifyAutoLoginFailure({ + result: false, + silent: false, + reqOTP: true, + reason: '인증 코드를 입력해주세요', + }, 0), + 'prompt_otp' + ); + assert.equal(OTP_REQUIRED_MESSAGE, '인증 코드 입력이 필요합니다.'); + }); + + it('keeps the existing retry and alert behavior for non-OTP failures', () => { + assert.equal( + classifyAutoLoginFailure({ + result: false, + silent: false, + reason: '자동 로그인: 절차 오류', + }, 0), + 'retry' + ); + assert.equal( + classifyAutoLoginFailure({ + result: false, + silent: false, + reason: '로그인할 수 없습니다.', + }, 1), + 'alert' + ); + assert.equal( + classifyAutoLoginFailure({ + result: false, + silent: true, + reason: 'failed', + }, 1), + 'silent' + ); + }); +}); diff --git a/hwe/ts/defs/API/Login.ts b/hwe/ts/defs/API/Login.ts index 5bf0ca26..18fc5503 100644 --- a/hwe/ts/defs/API/Login.ts +++ b/hwe/ts/defs/API/Login.ts @@ -36,5 +36,6 @@ export type AutoLoginResponse = { export type AutoLoginFailed = { result: false, silent: boolean, + reqOTP?: boolean, reason: string, -} \ No newline at end of file +} diff --git a/hwe/ts/gateway/login.ts b/hwe/ts/gateway/login.ts index 984fadad..adde525c 100644 --- a/hwe/ts/gateway/login.ts +++ b/hwe/ts/gateway/login.ts @@ -12,8 +12,9 @@ import { delay } from '@util/delay'; import { Modal } from 'bootstrap'; import '@/gateway/common'; import { isString } from 'lodash-es'; -import { SammoRootAPI, type InvalidResponse } from '@/SammoRootAPI'; -import type { LoginFailed, LoginResponse, LoginResponseWithKakao, OTPResponse } from '@/defs/API/Login'; +import { SammoRootAPI, type InvalidResponse } from '@/SammoRootAPI'; +import type { LoginFailed, LoginResponse, LoginResponseWithKakao, OTPResponse } from '@/defs/API/Login'; +import { classifyAutoLoginFailure, OTP_REQUIRED_MESSAGE } from '@/gateway/loginFlow'; declare global { interface Window { getOAuthToken: (mode: string, scope_list: string[]) => void; @@ -51,9 +52,20 @@ function getToken(): [number, string] | undefined { return token; } -function resetToken() { - localStorage.removeItem(LOGIN_TOKEN_KEY); -} +function resetToken() { + localStorage.removeItem(LOGIN_TOKEN_KEY); +} + +function showOTPModal() { + const modalEl = unwrap(document.querySelector('#modalOTP')); + if (!modalOTP) { + modalOTP = new Modal(modalEl); + modalEl.addEventListener('shown.bs.modal', function () { + unwrap(document.querySelector('#otp_code')).focus(); + }); + } + modalOTP.show(); +} async function tryAutoLogin() { try { @@ -93,13 +105,18 @@ async function tryAutoLogin() { }, true); if (!loginResult.result) { - if (loginResult.reason === '자동 로그인: 절차 오류' && attempt === 0) { + const failureAction = classifyAutoLoginFailure(loginResult, attempt); + if (failureAction === 'retry') { console.warn('auto login failed by procedure error. retrying once.'); await delay(150); continue; } - - if (!loginResult.silent) { + if (failureAction === 'prompt_otp') { + alert(OTP_REQUIRED_MESSAGE); + showOTPModal(); + return; + } + if (failureAction === 'alert') { alert(loginResult.reason); } console.error(loginResult.reason); @@ -208,14 +225,7 @@ async function doLoginUsingOAuth() { return; } - const modalEl = unwrap(document.querySelector('#modalOTP')) - if (!modalOTP) { - modalOTP = new Modal(modalEl); - modalEl.addEventListener('shown.bs.modal', function () { - unwrap(document.querySelector('#otp_code')).focus(); - }); - } - modalOTP.show(); + showOTPModal(); } function postOAuthResult(mode: string) { @@ -314,14 +324,7 @@ $(async function ($) { return; } - const modalEl = unwrap(document.querySelector('#modalOTP')) - if (!modalOTP) { - modalOTP = new Modal(modalEl); - modalEl.addEventListener('shown.bs.modal', function () { - unwrap(document.querySelector('#otp_code')).focus(); - }); - } - modalOTP.show(); + showOTPModal(); }); $('#otp_form').on('submit', async function (e) { @@ -393,4 +396,4 @@ window.fitIframe = function () { const iframe = unwrap(document.querySelector('#running_map'));//TODO: 근황 여러개 볼 수 있도록? const scrollHeight = unwrap(iframe.contentWindow).document.body.scrollHeight; iframe.style.height = `${scrollHeight}px`; -} \ No newline at end of file +} diff --git a/hwe/ts/gateway/loginFlow.ts b/hwe/ts/gateway/loginFlow.ts new file mode 100644 index 00000000..25a8d5a2 --- /dev/null +++ b/hwe/ts/gateway/loginFlow.ts @@ -0,0 +1,18 @@ +import type { AutoLoginFailed } from '@/defs/API/Login'; + +export const OTP_REQUIRED_MESSAGE = '인증 코드 입력이 필요합니다.'; + +export type AutoLoginFailureAction = 'retry' | 'prompt_otp' | 'alert' | 'silent'; + +export function classifyAutoLoginFailure(result: AutoLoginFailed, attempt: number): AutoLoginFailureAction { + if (result.reason === '자동 로그인: 절차 오류' && attempt === 0) { + return 'retry'; + } + if (result.reqOTP) { + return 'prompt_otp'; + } + if (result.silent) { + return 'silent'; + } + return 'alert'; +} diff --git a/src/sammo/API/Login/LoginByToken.php b/src/sammo/API/Login/LoginByToken.php index d8aa9d44..61092833 100644 --- a/src/sammo/API/Login/LoginByToken.php +++ b/src/sammo/API/Login/LoginByToken.php @@ -19,6 +19,30 @@ class LoginByToken extends LoginByID { static array $sensitiveArgs = ['hashedToken']; + public static function kakaoFailureResponse(bool $reqOTP, string $reason): string | array + { + if (!$reqOTP) { + return $reason; + } + + return [ + 'result' => false, + 'silent' => false, + 'reqOTP' => true, + 'reason' => $reason, + ]; + } + + public static function shouldDiscardTokenAfterKakaoFailure(bool $reqOTP): bool + { + return !$reqOTP; + } + + public static function pendingSessionTokenID(bool $reqOTP, int $tokenID): ?int + { + return $reqOTP ? $tokenID : null; + } + public function getRequiredSessionMode(): int { return \sammo\BaseAPI::NO_LOGIN; @@ -117,13 +141,24 @@ class LoginByToken extends LoginByID if ($userInfo['oauth_type'] == 'KAKAO') { $oauthFailResult = KakaoUtil::kakaoOAuthCheck($userInfo); if ($oauthFailResult !== null) { - $session->login($userInfo['no'], $userInfo['name'], $userInfo['grade'], true, $userInfo['token_valid_until'], null, Json::decode($userInfo['acl'] ?? '{}')); [$oauthReqOTP, $oauthFailReason] = $oauthFailResult; - $RootDB->delete( - 'login_token', - 'id = %i', $token_id + $session->login( + $userInfo['no'], + $userInfo['name'], + $userInfo['grade'], + true, + $userInfo['token_valid_until'], + static::pendingSessionTokenID($oauthReqOTP, $token_id), + Json::decode($userInfo['acl'] ?? '{}') ); - return $oauthFailReason; + if (static::shouldDiscardTokenAfterKakaoFailure($oauthReqOTP)) { + $RootDB->delete( + 'login_token', + 'id = %i', + $token_id + ); + } + return static::kakaoFailureResponse($oauthReqOTP, $oauthFailReason); } } diff --git a/tests/LoginByTokenKakaoFailureTest.php b/tests/LoginByTokenKakaoFailureTest.php new file mode 100644 index 00000000..793ddea0 --- /dev/null +++ b/tests/LoginByTokenKakaoFailureTest.php @@ -0,0 +1,36 @@ + false, + 'silent' => false, + 'reqOTP' => true, + 'reason' => '인증 코드를 입력해주세요', + ], + LoginByToken::kakaoFailureResponse(true, '인증 코드를 입력해주세요') + ); + } + + public function testNonOtpKakaoFailureKeepsLegacyErrorAndDiscardsToken(): void + { + self::assertTrue(LoginByToken::shouldDiscardTokenAfterKakaoFailure(false)); + self::assertNull(LoginByToken::pendingSessionTokenID(false, 321)); + self::assertSame( + '카카오 API 앱이 등록되지 않았습니다.', + LoginByToken::kakaoFailureResponse(false, '카카오 API 앱이 등록되지 않았습니다.') + ); + } +} diff --git a/tests/browser/auto-login-otp-dialog.mjs b/tests/browser/auto-login-otp-dialog.mjs new file mode 100644 index 00000000..8dd0b91a --- /dev/null +++ b/tests/browser/auto-login-otp-dialog.mjs @@ -0,0 +1,236 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const artifactDir = process.env.REF_AUTO_LOGIN_OTP_ARTIFACT_DIR; +if (!artifactDir) { + throw new Error('REF_AUTO_LOGIN_OTP_ARTIFACT_DIR is required'); +} + +const playwrightRequire = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url); +let chromium; +try { + ({chromium} = playwrightRequire('playwright')); +} catch { + ({chromium} = playwrightRequire('@playwright/test')); +} + +const bundleDir = path.join(projectRoot, 'dist_js/gateway'); +const loginToken = [1, [321, 'browser-test-token'], Date.now()]; +let autoLoginCount = 0; +let manualLoginBody = null; +let otpBody = ''; +let otpAccepted = false; + +const loginHTML = ` + + + + + 자동로그인 OTP 검증 + + + + + + + +
+
+ + + + + + +
+ + 비밀번호 초기화 +
+ + +`; + +const json = (response, body) => { + response.writeHead(200, {'content-type': 'application/json; charset=utf-8'}); + response.end(JSON.stringify(body)); +}; + +const readBody = async request => { + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); +}; + +const server = http.createServer(async (request, response) => { + const requestURL = new URL(request.url ?? '/', 'http://127.0.0.1'); + if (requestURL.pathname === '/') { + response.writeHead(200, {'content-type': 'text/html; charset=utf-8'}); + response.end(otpAccepted ? '

로그인 완료

' : loginHTML); + return; + } + if (requestURL.pathname.startsWith('/dist_js/gateway/')) { + const fileName = path.basename(requestURL.pathname); + const filePath = path.join(bundleDir, fileName); + const contentType = fileName.endsWith('.css') ? 'text/css' : 'text/javascript'; + response.writeHead(200, {'content-type': `${contentType}; charset=utf-8`}); + response.end(fs.readFileSync(filePath)); + return; + } + if (requestURL.pathname === '/api.php') { + const apiPath = requestURL.searchParams.get('path'); + const rawBody = await readBody(request); + if (apiPath === 'Login/ReqNonce') { + json(response, {result: true, loginNonce: `nonce-${autoLoginCount + 1}`}); + return; + } + if (apiPath === 'Login/LoginByToken') { + autoLoginCount += 1; + json(response, { + result: false, + silent: false, + reqOTP: true, + reason: '인증 코드를 입력해주세요', + }); + return; + } + if (apiPath === 'Login/LoginByID') { + manualLoginBody = JSON.parse(rawBody); + json(response, {result: false, reqOTP: false, reason: '수동 로그인 요청 확인'}); + return; + } + } + if (requestURL.pathname === '/oauth_kakao/j_check_OTP.php') { + otpBody = await readBody(request); + otpAccepted = true; + json(response, {result: true, reset: false, validUntil: '2026-08-18 00:00:00'}); + return; + } + response.writeHead(404); + response.end('not found'); +}); + +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +const address = server.address(); +assert(address && typeof address !== 'string'); +const baseURL = `http://127.0.0.1:${address.port}/`; + +const browser = await chromium.launch({headless: true}); +try { + const context = await browser.newContext({ + viewport: {width: 1280, height: 960}, + deviceScaleFactor: 1, + locale: 'ko-KR', + }); + await context.addInitScript(token => { + if (!localStorage.getItem('sammo_login_token')) { + localStorage.setItem('sammo_login_token', JSON.stringify(token)); + } + }, loginToken); + const page = await context.newPage(); + + const openAndAcceptOtpPrompt = async navigation => { + const dialogPromise = page.waitForEvent('dialog'); + const navigationPromise = navigation(); + const dialog = await dialogPromise; + assert.equal(dialog.message(), '인증 코드 입력이 필요합니다.'); + await dialog.accept(); + await navigationPromise; + await page.locator('#modalOTP.show').waitFor({state: 'visible'}); + await page.waitForFunction(() => document.activeElement?.id === 'otp_code'); + }; + + await openAndAcceptOtpPrompt(() => page.goto(baseURL, {waitUntil: 'domcontentloaded'})); + assert.equal(autoLoginCount, 1); + assert.deepEqual(JSON.parse(await page.evaluate(() => localStorage.getItem('sammo_login_token'))), loginToken); + + fs.mkdirSync(artifactDir, {recursive: true}); + const screenshotPath = path.join(artifactDir, 'auto-login-otp-modal.png'); + await page.screenshot({path: screenshotPath, fullPage: true}); + const modalGeometry = await page.locator('#modalOTP .modal-dialog').evaluate(element => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: {x: rect.x, y: rect.y, width: rect.width, height: rect.height}, + display: style.display, + opacity: style.opacity, + }; + }); + + await page.getByRole('button', {name: '취소'}).click(); + await page.locator('#modalOTP').waitFor({state: 'hidden'}); + assert.deepEqual(JSON.parse(await page.evaluate(() => localStorage.getItem('sammo_login_token'))), loginToken); + + await openAndAcceptOtpPrompt(() => page.reload({waitUntil: 'domcontentloaded'})); + assert.equal(autoLoginCount, 2); + await page.getByRole('button', {name: '취소'}).click(); + await page.locator('#modalOTP').waitFor({state: 'hidden'}); + await page.waitForTimeout(250); + + await page.locator('#username').fill('manual-user'); + await page.locator('#password').fill('manual-password'); + await page.waitForTimeout(100); + const manualDialogPromise = page.waitForEvent('dialog'); + await page.locator('#main_form').evaluate(form => form.requestSubmit()); + const manualDialog = await manualDialogPromise; + assert.equal(manualDialog.message(), '수동 로그인 요청 확인'); + await manualDialog.accept(); + assert.equal(manualLoginBody?.username, 'manual-user'); + assert.match(manualLoginBody?.password ?? '', /^[0-9a-f]{128}$/); + + await openAndAcceptOtpPrompt(() => page.reload({waitUntil: 'domcontentloaded'})); + assert.equal(autoLoginCount, 3); + await page.locator('#otp_code').fill('1234'); + const successDialogPromise = page.waitForEvent('dialog'); + await page.getByRole('button', {name: '제출'}).click(); + const successDialog = await successDialogPromise; + assert.equal(successDialog.message(), '로그인되었습니다. 2026-08-18 00:00:00까지 유효합니다.'); + await successDialog.accept(); + await page.locator('#logged-in').waitFor({state: 'visible'}); + assert.match(otpBody, /name="otp"/); + assert.match(otpBody, /\r\n1234\r\n/); + + const resultPath = path.join(artifactDir, 'auto-login-otp-result.json'); + fs.writeFileSync(resultPath, `${JSON.stringify({ + url: baseURL, + viewport: {width: 1280, height: 960, deviceScaleFactor: 1}, + autoLoginCount, + preservedTokenAfterCancel: true, + reopenedAfterReload: true, + manualLoginSubmitted: true, + otpSuccessNavigated: true, + modalGeometry, + screenshotPath, + }, null, 2)}\n`, {mode: 0o600}); + + console.log(`automatic-login OTP Chromium flow verified: ${resultPath}`); +} finally { + await browser.close(); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); +}