Use numeric PIN keypad for launcher auth
This commit is contained in:
@@ -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도 숫자가 아닌 암호를 거부합니다.
|
||||||
|
|
||||||
암호 변경은 배너 우하단의 작은 `관리` 링크에서 처리하며, 이 링크는 사이트 관리자 로그인 상태에서만 표시합니다. 암호 변경 시에는 현재 공통 암호 입력을 계속 요구합니다.
|
암호 변경은 배너 우하단의 작은 `관리` 링크에서 처리하며, 이 링크는 사이트 관리자 로그인 상태에서만 표시합니다. 암호 변경 시에는 현재 공통 암호 입력을 계속 요구합니다.
|
||||||
|
|||||||
@@ -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
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user