어디야 관리자 권한 장기 검증
This commit is contained in:
1 parent
62d25a286b
commit
1b9e69692e
2 files changed
+74
-18
No files matched your search
@@ -45,9 +45,9 @@ Rhymix 사이트 관리자 로그인 상태에서는 첫 줄에 나의 찾기,
|
|||||||
|
|
||||||
공통 암호로 로그인한 일반 인증 쿠키는 2시간 유지합니다. 사이트 관리자 세션이 확인되어 발급된 관리자 인증 쿠키는 반영구 운영값으로 10년 유지합니다. `/custom/launcher/api.php?session=1`은 현재 브라우저가 가진 공통 쿠키 또는 사이트 관리자 세션을 확인하고, `/custom/launcher/api.php?adminSession=1`은 관리 버튼 노출처럼 실제 Rhymix 관리자 세션이 필요한 화면에서만 사용합니다. `adminSession=1`은 일반 공통 쿠키를 인증으로 섞지 않고 실제 관리자 여부만 내려줍니다. 런처가 관리자 아이콘을 표시한 뒤에도 브라우저 포커스가 돌아오거나 탭이 다시 보이면 관리자 세션을 재확인하고, 이미 관리자 로그인이 풀렸으면 일반 사용자 화면으로 다시 그립니다.
|
공통 암호로 로그인한 일반 인증 쿠키는 2시간 유지합니다. 사이트 관리자 세션이 확인되어 발급된 관리자 인증 쿠키는 반영구 운영값으로 10년 유지합니다. `/custom/launcher/api.php?session=1`은 현재 브라우저가 가진 공통 쿠키 또는 사이트 관리자 세션을 확인하고, `/custom/launcher/api.php?adminSession=1`은 관리 버튼 노출처럼 실제 Rhymix 관리자 세션이 필요한 화면에서만 사용합니다. `adminSession=1`은 일반 공통 쿠키를 인증으로 섞지 않고 실제 관리자 여부만 내려줍니다. 런처가 관리자 아이콘을 표시한 뒤에도 브라우저 포커스가 돌아오거나 탭이 다시 보이면 관리자 세션을 재확인하고, 이미 관리자 로그인이 풀렸으면 일반 사용자 화면으로 다시 그립니다.
|
||||||
|
|
||||||
`eodiya.net`은 Rhymix 관리자 세션을 확인할 때 런처 API를 사용합니다. 서로 다른 사이트의 CORS 요청에는 Rhymix host-only/SameSite 쿠키가 실리지 않으므로, 일반 웹 첫 진입과 `라이믹스 관리자 확인` 버튼은 `adminBridge=traffic-signal-pass`를 최상위 화면으로 한 번 거칩니다. 브리지는 실제 사이트 관리자 세션을 확인한 뒤 `https://eodiya.net`으로만 복귀시키며, 관리자인 경우 5분짜리 서명 티켓을 URL fragment에 담습니다. 다른 scheme, host, port, 사용자 정보, fragment가 들어간 복귀 주소는 거부합니다.
|
`eodiya.net`은 Rhymix 관리자 세션을 확인할 때 런처 API를 사용합니다. 서로 다른 사이트의 CORS 요청에는 Rhymix host-only/SameSite 쿠키가 실리지 않으므로, 일반 웹 첫 진입과 `라이믹스 관리자 확인` 버튼은 `adminBridge=traffic-signal-pass`를 최상위 화면으로 한 번 거칩니다. 브리지는 실제 사이트 관리자 세션을 확인한 뒤 `https://eodiya.net`으로만 복귀시키며, 관리자인 경우 회원 번호와 대상 서비스를 묶은 400일짜리 서명 티켓을 URL fragment에 담습니다. 다른 scheme, host, port, 사용자 정보, fragment가 들어간 복귀 주소는 거부합니다.
|
||||||
|
|
||||||
기존 `adminSession=1&adminTicket=traffic-signal-pass` CORS 응답과 앱 사용자별 관리자 티켓도 호환용으로 유지합니다. 어디야? 서버는 어떤 경로로 받은 티켓이든 `validateAdminTicket=1`로 다시 검증한 뒤 위치 기록 관리자 화면을 열며, 클라이언트의 관리자 표시값만으로 위치 기록을 열지 않습니다.
|
기존 `adminSession=1&adminTicket=traffic-signal-pass` CORS 응답과 앱 사용자별 관리자 티켓도 호환용으로 유지합니다. 어디야? 서버는 어떤 경로로 받은 티켓이든 `validateAdminTicket=1`로 다시 검증한 뒤 위치 기록 관리자 화면을 열며, 클라이언트의 관리자 표시값만으로 위치 기록을 열지 않습니다. 검증할 때 티켓에 서명된 회원 번호의 현재 사이트 관리자 권한을 회원 DB에서 다시 확인하고 유효하면 티켓 만료를 갱신합니다. 따라서 어디야?가 10초 주기로 검증하거나 ADMIN API를 호출했을 때 관리자 권한이 해제되어 있으면 즉시 거부됩니다. 브라우저가 허용하는 장기 쿠키 상한을 고려해 400일 슬라이딩 방식으로 유지하며, 사용 중에는 검증 때마다 다시 연장됩니다.
|
||||||
|
|
||||||
Rhymix 로그인 실패 차단 세션이 살아 있으면 `session=1` 응답이 `loginBlocked`를 내려줍니다. 이때 런처는 아이콘, 관리 버튼, PIN 입력을 숨기고 빨간 제한 안내만 표시합니다.
|
Rhymix 로그인 실패 차단 세션이 살아 있으면 `session=1` 응답이 `loginBlocked`를 내려줍니다. 이때 런처는 아이콘, 관리 버튼, PIN 입력을 숨기고 빨간 제한 안내만 표시합니다.
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|||||||
|
|
||||||
$config = custom_config();
|
$config = custom_config();
|
||||||
|
|
||||||
|
const LAUNCHER_ADMIN_TICKET_TTL = 400 * 24 * 60 * 60;
|
||||||
|
|
||||||
function launcher_is_digit_password(string $password): bool
|
function launcher_is_digit_password(string $password): bool
|
||||||
{
|
{
|
||||||
return preg_match('/^\d{4}$/', $password) === 1;
|
return preg_match('/^\d{4}$/', $password) === 1;
|
||||||
@@ -40,33 +42,79 @@ function launcher_admin_ticket_audience(string $audience): string
|
|||||||
return $audience;
|
return $audience;
|
||||||
}
|
}
|
||||||
|
|
||||||
function launcher_issue_admin_ticket(string $audience): string
|
function launcher_current_site_admin_member_srl(): int
|
||||||
{
|
{
|
||||||
|
if (!custom_is_site_admin() || !class_exists('Context')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$loggedInfo = Context::get('logged_info');
|
||||||
|
return max(0, (int)($loggedInfo->member_srl ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function launcher_member_is_site_admin(int $memberSrl): bool
|
||||||
|
{
|
||||||
|
if ($memberSrl <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$autoload = dirname(__DIR__, 2) . '/common/autoload.php';
|
||||||
|
if (!is_file($autoload)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
require_once $autoload;
|
||||||
|
if (!class_exists('Context') || !class_exists('MemberModel')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Context::init();
|
||||||
|
$memberInfo = MemberModel::getMemberInfoByMemberSrl($memberSrl);
|
||||||
|
return is_object($memberInfo)
|
||||||
|
&& (($memberInfo->is_site_admin ?? false) === true || ($memberInfo->is_admin ?? 'N') === 'Y');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function launcher_issue_admin_ticket(string $audience, int $memberSrl): string
|
||||||
|
{
|
||||||
|
if ($memberSrl <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
return custom_signed_cookie_payload([
|
return custom_signed_cookie_payload([
|
||||||
'scope' => 'launcher_admin_ticket',
|
'scope' => 'launcher_admin_ticket',
|
||||||
'aud' => $audience,
|
'aud' => $audience,
|
||||||
'admin' => true,
|
'admin' => true,
|
||||||
|
'member_srl' => $memberSrl,
|
||||||
'iat' => time(),
|
'iat' => time(),
|
||||||
'exp' => time() + 300,
|
'exp' => time() + LAUNCHER_ADMIN_TICKET_TTL,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function launcher_valid_admin_ticket(string $ticket, string $audience): bool
|
function launcher_validate_admin_ticket(string $ticket, string $audience): ?array
|
||||||
{
|
{
|
||||||
if ($ticket === '' || strlen($ticket) > 4096 || $audience === '') {
|
if ($ticket === '' || strlen($ticket) > 4096 || $audience === '') {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = custom_verify_signed_cookie_payload($ticket);
|
$payload = custom_verify_signed_cookie_payload($ticket);
|
||||||
if (!is_array($payload)) {
|
if (!is_array($payload)) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ($payload['scope'] ?? '') === 'launcher_admin_ticket'
|
$memberSrl = (int)($payload['member_srl'] ?? 0);
|
||||||
|
$valid = ($payload['scope'] ?? '') === 'launcher_admin_ticket'
|
||||||
&& ($payload['aud'] ?? '') === $audience
|
&& ($payload['aud'] ?? '') === $audience
|
||||||
&& !empty($payload['admin'])
|
&& !empty($payload['admin'])
|
||||||
|
&& $memberSrl > 0
|
||||||
&& (int)($payload['iat'] ?? 0) <= time() + 30
|
&& (int)($payload['iat'] ?? 0) <= time() + 30
|
||||||
&& (int)($payload['exp'] ?? 0) >= time();
|
&& (int)($payload['exp'] ?? 0) >= time()
|
||||||
|
&& launcher_member_is_site_admin($memberSrl);
|
||||||
|
|
||||||
|
return $valid ? $payload : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
@@ -156,7 +204,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
custom_json(['error' => 'invalid_admin_bridge_return'], 400);
|
custom_json(['error' => 'invalid_admin_bridge_return'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$isAdmin = custom_is_site_admin();
|
$memberSrl = launcher_current_site_admin_member_srl();
|
||||||
|
$isAdmin = $memberSrl > 0;
|
||||||
if ($isAdmin) {
|
if ($isAdmin) {
|
||||||
custom_issue_auth_cookie(true);
|
custom_issue_auth_cookie(true);
|
||||||
}
|
}
|
||||||
@@ -164,8 +213,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
'rhymixAdmin' => $isAdmin ? '1' : '0',
|
'rhymixAdmin' => $isAdmin ? '1' : '0',
|
||||||
];
|
];
|
||||||
if ($isAdmin) {
|
if ($isAdmin) {
|
||||||
$fragment['rhymixAdminTicket'] = launcher_issue_admin_ticket($audience);
|
$fragment['rhymixAdminTicket'] = launcher_issue_admin_ticket($audience, $memberSrl);
|
||||||
$fragment['rhymixAdminTicketExpiresIn'] = '300';
|
$fragment['rhymixAdminTicketExpiresIn'] = (string)LAUNCHER_ADMIN_TICKET_TTL;
|
||||||
}
|
}
|
||||||
header('Location: ' . $returnUrl . '#' . http_build_query($fragment, '', '&', PHP_QUERY_RFC3986), true, 303);
|
header('Location: ' . $returnUrl . '#' . http_build_query($fragment, '', '&', PHP_QUERY_RFC3986), true, 303);
|
||||||
exit;
|
exit;
|
||||||
@@ -174,11 +223,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
if (isset($_GET['validateAdminTicket'])) {
|
if (isset($_GET['validateAdminTicket'])) {
|
||||||
$audience = launcher_admin_ticket_audience((string)($_GET['aud'] ?? ''));
|
$audience = launcher_admin_ticket_audience((string)($_GET['aud'] ?? ''));
|
||||||
$ticket = (string)($_GET['ticket'] ?? '');
|
$ticket = (string)($_GET['ticket'] ?? '');
|
||||||
$valid = launcher_valid_admin_ticket($ticket, $audience);
|
$payload = launcher_validate_admin_ticket($ticket, $audience);
|
||||||
custom_json([
|
$valid = is_array($payload);
|
||||||
|
$response = [
|
||||||
'valid' => $valid,
|
'valid' => $valid,
|
||||||
'admin' => $valid,
|
'admin' => $valid,
|
||||||
]);
|
];
|
||||||
|
if ($valid) {
|
||||||
|
$response['adminTicket'] = launcher_issue_admin_ticket($audience, (int)$payload['member_srl']);
|
||||||
|
$response['adminTicketExpiresIn'] = LAUNCHER_ADMIN_TICKET_TTL;
|
||||||
|
}
|
||||||
|
custom_json($response);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($_GET['session'])) {
|
if (isset($_GET['session'])) {
|
||||||
@@ -230,7 +285,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isset($_GET['adminSession'])) {
|
if (isset($_GET['adminSession'])) {
|
||||||
$isAdmin = custom_is_site_admin();
|
$memberSrl = launcher_current_site_admin_member_srl();
|
||||||
|
$isAdmin = $memberSrl > 0;
|
||||||
if ($isAdmin) {
|
if ($isAdmin) {
|
||||||
custom_issue_auth_cookie(true);
|
custom_issue_auth_cookie(true);
|
||||||
}
|
}
|
||||||
@@ -240,8 +296,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|||||||
];
|
];
|
||||||
$audience = launcher_admin_ticket_audience((string)($_GET['adminTicket'] ?? ''));
|
$audience = launcher_admin_ticket_audience((string)($_GET['adminTicket'] ?? ''));
|
||||||
if ($isAdmin && $audience !== '') {
|
if ($isAdmin && $audience !== '') {
|
||||||
$response['adminTicket'] = launcher_issue_admin_ticket($audience);
|
$response['adminTicket'] = launcher_issue_admin_ticket($audience, $memberSrl);
|
||||||
$response['adminTicketExpiresIn'] = 300;
|
$response['adminTicketExpiresIn'] = LAUNCHER_ADMIN_TICKET_TTL;
|
||||||
}
|
}
|
||||||
custom_json($response);
|
custom_json($response);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user