Use numeric PIN keypad for launcher auth

This commit is contained in:
seo
2026-06-17 19:46:33 +09:00
parent 356b884162
commit 5275aa17c2
3 changed files with 134 additions and 17 deletions
+3
View File
@@ -17,6 +17,7 @@
- 차량 모니터 이동 - 차량 모니터 이동
- 공통 암호 로그인 - 공통 암호 로그인
- 공통 암호 변경 - 공통 암호 변경
- 숫자 전용 PIN 입력과 커스텀 숫자 키패드
- 사이트 관리자 로그인 상태에서만 관리 버튼 표시 - 사이트 관리자 로그인 상태에서만 관리 버튼 표시
- 사이트 관리자 로그인 상태에서는 미니앱 실행 시 공통 암호 생략 - 사이트 관리자 로그인 상태에서는 미니앱 실행 시 공통 암호 생략
- 사이트 관리자 세션으로 미니앱 진입 시 공통 접근 쿠키 자동 발급 - 사이트 관리자 세션으로 미니앱 진입 시 공통 접근 쿠키 자동 발급
@@ -38,4 +39,6 @@
메인 버튼은 기존 4개를 유지합니다. Rhymix 사이트 관리자 로그인 상태에서는 미니앱 실행 시 공통 암호를 묻지 않습니다. 이때 `/custom/launcher/api.php?adminSession=1` 또는 `session=1` 응답에서 공통 접근 쿠키를 함께 발급해, 이동한 미니앱 내부 세션 검사에서도 바로 통과합니다. 메인 버튼은 기존 4개를 유지합니다. Rhymix 사이트 관리자 로그인 상태에서는 미니앱 실행 시 공통 암호를 묻지 않습니다. 이때 `/custom/launcher/api.php?adminSession=1` 또는 `session=1` 응답에서 공통 접근 쿠키를 함께 발급해, 이동한 미니앱 내부 세션 검사에서도 바로 통과합니다.
암호는 숫자만 허용합니다. 로그인과 암호 변경 입력창은 `inputmode=numeric`, `[0-9]*` 패턴, 커스텀 숫자 키패드를 사용하며 Android/iPhone에서 문자 키보드가 뜨지 않도록 `readonly` 입력창에 화면 키패드로 값을 넣습니다. 서버 API도 숫자가 아닌 암호를 거부합니다.
암호 변경은 배너 우하단의 작은 `관리` 링크에서 처리하며, 이 링크는 사이트 관리자 로그인 상태에서만 표시합니다. 암호 변경 시에는 현재 공통 암호 입력을 계속 요구합니다. 암호 변경은 배너 우하단의 작은 `관리` 링크에서 처리하며, 이 링크는 사이트 관리자 로그인 상태에서만 표시합니다. 암호 변경 시에는 현재 공통 암호 입력을 계속 요구합니다.
+13
View File
@@ -8,12 +8,21 @@ header('Cache-Control: no-store');
$config = custom_config(); $config = custom_config();
function launcher_is_digit_password(string $password): bool
{
return $password !== '' && ctype_digit($password);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = custom_read_request_data(); $data = custom_read_request_data();
$action = $data['action'] ?? 'login'; $action = $data['action'] ?? 'login';
if ($action === 'login') { if ($action === 'login') {
$password = (string)($data['password'] ?? ''); $password = (string)($data['password'] ?? '');
if (!launcher_is_digit_password($password)) {
custom_json(['result' => 'fail', 'message' => 'password_digits_only'], 401);
}
if (custom_verify_password($password)) { if (custom_verify_password($password)) {
custom_issue_auth_cookie(); custom_issue_auth_cookie();
custom_json(['result' => 'ok']); custom_json(['result' => 'ok']);
@@ -31,6 +40,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$current = (string)($data['currentPassword'] ?? ''); $current = (string)($data['currentPassword'] ?? '');
$next = (string)($data['newPassword'] ?? ''); $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)) { if (!custom_verify_password($current)) {
custom_json(['result' => 'fail', 'message' => 'current_password_invalid'], 401); custom_json(['result' => 'fail', 'message' => 'current_password_invalid'], 401);
} }
+118 -17
View File
@@ -97,7 +97,7 @@ function showPasswordForm(onSuccess) {
shortcut.innerHTML = ''; shortcut.innerHTML = '';
const title = createFormHeader('비밀번호', renderIcons); const title = createFormHeader('비밀번호', renderIcons);
const input = createPasswordInput('blog_secret', '비밀번호'); const input = createPinInput('blog_secret', '비밀번호');
const button = createButton('확인'); const button = createButton('확인');
const msg = document.createElement('div'); const msg = document.createElement('div');
msg.id = 'failMsg'; msg.id = 'failMsg';
@@ -114,7 +114,8 @@ function showPasswordForm(onSuccess) {
shortcut.appendChild(column); shortcut.appendChild(column);
const submit = async () => { const submit = async () => {
const password = input.value; const password = onlyDigits(input.value);
input.value = password;
try { try {
const messageRes = await fetch(`${apiUrl}?failMessages=1`); 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); button.addEventListener('click', submit);
input.addEventListener('keydown', e => { attachPinInputEvents(input, submit);
if (e.key === 'Enter') submit();
});
input.focus(); input.focus();
} }
@@ -161,9 +162,9 @@ function showPasswordChangeForm() {
shortcut.innerHTML = ''; shortcut.innerHTML = '';
const title = createFormHeader('암호 변경', renderIcons); const title = createFormHeader('암호 변경', renderIcons);
const currentInput = createPasswordInput('current_password', '현재 암호'); const currentInput = createPinInput('current_password', '현재 암호');
const newInput = createPasswordInput('new_password', '새 암호'); const newInput = createPinInput('new_password', '새 암호');
const confirmInput = createPasswordInput('confirm_password', '새 암호 확인'); const confirmInput = createPinInput('confirm_password', '새 암호 확인');
const button = createButton('변경'); const button = createButton('변경');
const msg = document.createElement('div'); const msg = document.createElement('div');
msg.id = 'failMsg'; msg.id = 'failMsg';
@@ -179,14 +180,21 @@ function showPasswordChangeForm() {
shortcut.appendChild(column); shortcut.appendChild(column);
const submit = async () => { const submit = async () => {
const currentPassword = currentInput.value; const currentPassword = onlyDigits(currentInput.value);
const newPassword = newInput.value; const newPassword = onlyDigits(newInput.value);
const confirmPassword = confirmInput.value; const confirmPassword = onlyDigits(confirmInput.value);
currentInput.value = currentPassword;
newInput.value = newPassword;
confirmInput.value = confirmPassword;
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
msg.textContent = '새 암호가 일치하지 않습니다.'; msg.textContent = '새 암호가 일치하지 않습니다.';
return; return;
} }
if (!isDigitPin(currentPassword) || !isDigitPin(newPassword)) {
msg.textContent = '암호는 숫자만 입력할 수 있습니다.';
return;
}
try { try {
const res = await fetch(apiUrl, { const res = await fetch(apiUrl, {
@@ -205,18 +213,20 @@ function showPasswordChangeForm() {
} }
msg.style.color = 'red'; 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 (_) { } catch (_) {
msg.style.color = 'red'; msg.style.color = 'red';
msg.textContent = '요청을 처리하지 못했습니다.'; msg.textContent = '요청을 처리하지 못했습니다.';
} }
}; };
const keypad = createPinKeypad([currentInput, newInput, confirmInput], submit);
column.insertBefore(keypad, msg);
button.addEventListener('click', submit); button.addEventListener('click', submit);
[currentInput, newInput, confirmInput].forEach(input => { [currentInput, newInput, confirmInput].forEach(input => {
input.addEventListener('keydown', e => { attachPinInputEvents(input, submit);
if (e.key === 'Enter') submit();
});
}); });
currentInput.focus(); currentInput.focus();
} }
@@ -350,15 +360,106 @@ function createFormHeader(text, onBack) {
return header; 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'); const input = document.createElement('input');
input.type = 'password'; input.type = 'password';
input.placeholder = placeholder; input.placeholder = placeholder;
input.id = id; 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; 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) { function createButton(text) {
const button = document.createElement('button'); const button = document.createElement('button');
button.type = 'button'; button.type = 'button';