From 5275aa17c2b96ea279ce10217573ea8560e6061c Mon Sep 17 00:00:00 2001 From: seo Date: Wed, 17 Jun 2026 19:46:33 +0900 Subject: [PATCH] Use numeric PIN keypad for launcher auth --- README.md | 3 ++ api.php | 13 ++++++ js/main.js | 135 ++++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 134 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 4fae8ae..7244315 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ - 차량 모니터 이동 - 공통 암호 로그인 - 공통 암호 변경 +- 숫자 전용 PIN 입력과 커스텀 숫자 키패드 - 사이트 관리자 로그인 상태에서만 관리 버튼 표시 - 사이트 관리자 로그인 상태에서는 미니앱 실행 시 공통 암호 생략 - 사이트 관리자 세션으로 미니앱 진입 시 공통 접근 쿠키 자동 발급 @@ -38,4 +39,6 @@ 메인 버튼은 기존 4개를 유지합니다. Rhymix 사이트 관리자 로그인 상태에서는 미니앱 실행 시 공통 암호를 묻지 않습니다. 이때 `/custom/launcher/api.php?adminSession=1` 또는 `session=1` 응답에서 공통 접근 쿠키를 함께 발급해, 이동한 미니앱 내부 세션 검사에서도 바로 통과합니다. +암호는 숫자만 허용합니다. 로그인과 암호 변경 입력창은 `inputmode=numeric`, `[0-9]*` 패턴, 커스텀 숫자 키패드를 사용하며 Android/iPhone에서 문자 키보드가 뜨지 않도록 `readonly` 입력창에 화면 키패드로 값을 넣습니다. 서버 API도 숫자가 아닌 암호를 거부합니다. + 암호 변경은 배너 우하단의 작은 `관리` 링크에서 처리하며, 이 링크는 사이트 관리자 로그인 상태에서만 표시합니다. 암호 변경 시에는 현재 공통 암호 입력을 계속 요구합니다. diff --git a/api.php b/api.php index 5baa1a9..4b0cf3d 100644 --- a/api.php +++ b/api.php @@ -8,12 +8,21 @@ header('Cache-Control: no-store'); $config = custom_config(); +function launcher_is_digit_password(string $password): bool +{ + return $password !== '' && ctype_digit($password); +} + if ($_SERVER['REQUEST_METHOD'] === 'POST') { $data = custom_read_request_data(); $action = $data['action'] ?? 'login'; if ($action === 'login') { $password = (string)($data['password'] ?? ''); + if (!launcher_is_digit_password($password)) { + custom_json(['result' => 'fail', 'message' => 'password_digits_only'], 401); + } + if (custom_verify_password($password)) { custom_issue_auth_cookie(); custom_json(['result' => 'ok']); @@ -31,6 +40,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $current = (string)($data['currentPassword'] ?? ''); $next = (string)($data['newPassword'] ?? ''); + if (!launcher_is_digit_password($current) || !launcher_is_digit_password($next)) { + custom_json(['result' => 'fail', 'message' => 'password_digits_only'], 422); + } + if (!custom_verify_password($current)) { custom_json(['result' => 'fail', 'message' => 'current_password_invalid'], 401); } diff --git a/js/main.js b/js/main.js index 20772c7..c73e34f 100644 --- a/js/main.js +++ b/js/main.js @@ -97,7 +97,7 @@ function showPasswordForm(onSuccess) { shortcut.innerHTML = ''; const title = createFormHeader('비밀번호', renderIcons); - const input = createPasswordInput('blog_secret', '비밀번호'); + const input = createPinInput('blog_secret', '비밀번호'); const button = createButton('확인'); const msg = document.createElement('div'); msg.id = 'failMsg'; @@ -114,7 +114,8 @@ function showPasswordForm(onSuccess) { shortcut.appendChild(column); const submit = async () => { - const password = input.value; + const password = onlyDigits(input.value); + input.value = password; try { const messageRes = await fetch(`${apiUrl}?failMessages=1`); @@ -149,10 +150,10 @@ function showPasswordForm(onSuccess) { } }; + const keypad = createPinKeypad([input], submit); + column.appendChild(keypad); button.addEventListener('click', submit); - input.addEventListener('keydown', e => { - if (e.key === 'Enter') submit(); - }); + attachPinInputEvents(input, submit); input.focus(); } @@ -161,9 +162,9 @@ function showPasswordChangeForm() { shortcut.innerHTML = ''; const title = createFormHeader('암호 변경', renderIcons); - const currentInput = createPasswordInput('current_password', '현재 암호'); - const newInput = createPasswordInput('new_password', '새 암호'); - const confirmInput = createPasswordInput('confirm_password', '새 암호 확인'); + const currentInput = createPinInput('current_password', '현재 암호'); + const newInput = createPinInput('new_password', '새 암호'); + const confirmInput = createPinInput('confirm_password', '새 암호 확인'); const button = createButton('변경'); const msg = document.createElement('div'); msg.id = 'failMsg'; @@ -179,14 +180,21 @@ function showPasswordChangeForm() { shortcut.appendChild(column); const submit = async () => { - const currentPassword = currentInput.value; - const newPassword = newInput.value; - const confirmPassword = confirmInput.value; + const currentPassword = onlyDigits(currentInput.value); + const newPassword = onlyDigits(newInput.value); + const confirmPassword = onlyDigits(confirmInput.value); + currentInput.value = currentPassword; + newInput.value = newPassword; + confirmInput.value = confirmPassword; if (newPassword !== confirmPassword) { msg.textContent = '새 암호가 일치하지 않습니다.'; return; } + if (!isDigitPin(currentPassword) || !isDigitPin(newPassword)) { + msg.textContent = '암호는 숫자만 입력할 수 있습니다.'; + return; + } try { const res = await fetch(apiUrl, { @@ -205,18 +213,20 @@ function showPasswordChangeForm() { } msg.style.color = 'red'; - msg.textContent = data.message === 'password_too_short' ? '새 암호는 4자 이상이어야 합니다.' : '현재 암호가 틀립니다.'; + msg.textContent = data.message === 'password_too_short' + ? '새 암호는 4자리 이상이어야 합니다.' + : (data.message === 'password_digits_only' ? '암호는 숫자만 입력할 수 있습니다.' : '현재 암호가 틀립니다.'); } catch (_) { msg.style.color = 'red'; msg.textContent = '요청을 처리하지 못했습니다.'; } }; + const keypad = createPinKeypad([currentInput, newInput, confirmInput], submit); + column.insertBefore(keypad, msg); button.addEventListener('click', submit); [currentInput, newInput, confirmInput].forEach(input => { - input.addEventListener('keydown', e => { - if (e.key === 'Enter') submit(); - }); + attachPinInputEvents(input, submit); }); currentInput.focus(); } @@ -350,15 +360,106 @@ function createFormHeader(text, onBack) { return header; } -function createPasswordInput(id, placeholder) { +function onlyDigits(value) { + return String(value || '').replace(/\D+/g, ''); +} + +function isDigitPin(value) { + return /^\d+$/.test(String(value || '')); +} + +function createPinInput(id, placeholder) { const input = document.createElement('input'); input.type = 'password'; input.placeholder = placeholder; input.id = id; - input.autocomplete = 'current-password'; + input.inputMode = 'numeric'; + input.pattern = '[0-9]*'; + input.autocomplete = 'off'; + input.enterKeyHint = 'done'; + input.readOnly = true; + input.className = 'launcher-pin-input'; + input.setAttribute('aria-label', placeholder); return input; } +function attachPinInputEvents(input, onEnter) { + input.addEventListener('focus', () => { + document.querySelectorAll('.launcher-pin-input').forEach(el => el.classList.toggle('active', el === input)); + }); + input.addEventListener('input', () => { + input.value = onlyDigits(input.value); + }); + input.addEventListener('paste', e => { + e.preventDefault(); + input.value += onlyDigits(e.clipboardData?.getData('text') || ''); + }); + input.addEventListener('keydown', e => { + if (/^\d$/.test(e.key)) { + e.preventDefault(); + input.value += e.key; + return; + } + if (e.key === 'Backspace') { + e.preventDefault(); + input.value = input.value.slice(0, -1); + return; + } + if (e.key === 'Delete') { + e.preventDefault(); + input.value = ''; + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + onEnter(); + } + }); +} + +function createPinKeypad(inputs, onEnter) { + let activeInput = inputs[0]; + const keypad = document.createElement('div'); + keypad.className = 'launcher-pin-keypad'; + inputs.forEach(input => { + input.addEventListener('focus', () => { + activeInput = input; + }); + input.addEventListener('click', () => { + activeInput = input; + input.focus(); + }); + }); + + ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'clear', '0', 'back'].forEach(key => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `launcher-pin-key ${key === 'clear' || key === 'back' ? 'launcher-pin-action' : ''}`; + btn.textContent = key === 'clear' ? 'C' : (key === 'back' ? '⌫' : key); + btn.setAttribute('aria-label', key === 'clear' ? '전체 지우기' : (key === 'back' ? '한 글자 지우기' : `${key} 입력`)); + btn.addEventListener('click', () => { + activeInput.focus(); + if (key === 'clear') { + activeInput.value = ''; + } else if (key === 'back') { + activeInput.value = activeInput.value.slice(0, -1); + } else { + activeInput.value += key; + } + }); + keypad.appendChild(btn); + }); + + const done = document.createElement('button'); + done.type = 'button'; + done.className = 'launcher-pin-key launcher-pin-done'; + done.textContent = '확인'; + done.addEventListener('click', onEnter); + keypad.appendChild(done); + + return keypad; +} + function createButton(text) { const button = document.createElement('button'); button.type = 'button';