from __future__ import annotations from collections.abc import Callable as CallableABC, Mapping, MutableMapping from dataclasses import dataclass import hashlib import hmac import logging import os import secrets import time from typing import Callable from itsdangerous import BadData, URLSafeSerializer import streamlit as st logger = logging.getLogger(__name__) ADMIN_ID_ENV = "JARCA_DASHBOARD_ADMIN_ID" ADMIN_PASSWORD_ENV = "JARCA_DASHBOARD_ADMIN_PASSWORD" SIGNING_SECRET_ENV = "JARCA_DASHBOARD_SIGNING_SECRET" CONFIGURATION_ERROR_MESSAGE = "관리자 인증 설정을 확인할 수 없습니다." INVALID_CREDENTIALS_MESSAGE = "관리자 ID 또는 비밀번호가 올바르지 않습니다." MINIMUM_FAILURE_DELAY_SECONDS = 1.0 AUTH_CORRELATION_SESSION_KEY = "_jarca_dashboard_auth_correlation_id" AUTH_COOKIE_NAME = "__Host-jarca_dashboard_auth" DEFAULT_TOKEN_TTL_SECONDS = 24 * 60 * 60 REMEMBER_ME_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60 MAX_TOKEN_LENGTH = 4096 CLOCK_SKEW_SECONDS = 30 _TOKEN_VERSION = 1 _TOKEN_SALT = "jarca-dashboard-persistent-auth-v1" _TOKEN_CONTEXT = b"jarca-dashboard-persistent-auth-v1\x00" _TOKEN_FIELDS = frozenset({"v", "sub", "iat", "exp", "jti"}) _TOKEN_SUBJECT = "dashboard-admin" @dataclass(frozen=True) class AdminCredentials: admin_id: str password: str @dataclass(frozen=True) class AuthSettings: admin_id: str admin_password: str signing_secret: str @property def credentials(self) -> AdminCredentials: return AdminCredentials(admin_id=self.admin_id, password=self.admin_password) @dataclass(frozen=True) class IssuedPersistentToken: value: str issued_at: int expires_at: int max_age: int @dataclass(frozen=True) class VerifiedPersistentToken: subject: str issued_at: int expires_at: int token_id: str class AuthenticationConfigurationError(RuntimeError): """Raised without secret details when dashboard authentication is unavailable.""" def _read_required_setting(source: Mapping[str, str], name: str) -> str: try: value = source.get(name) except Exception as exc: raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE) from exc if not isinstance(value, str) or not value: raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE) return value def load_admin_credentials(environ: Mapping[str, str] | None = None) -> AdminCredentials: source = os.environ if environ is None else environ return AdminCredentials( admin_id=_read_required_setting(source, ADMIN_ID_ENV), password=_read_required_setting(source, ADMIN_PASSWORD_ENV), ) def require_auth_settings(environ: Mapping[str, str] | None = None) -> AuthSettings: source = os.environ if environ is None else environ settings = AuthSettings( admin_id=_read_required_setting(source, ADMIN_ID_ENV), admin_password=_read_required_setting(source, ADMIN_PASSWORD_ENV), signing_secret=_read_required_setting(source, SIGNING_SECRET_ENV), ) if len(settings.signing_secret.encode("utf-8")) < 32: raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE) return settings def load_auth_settings(environ: Mapping[str, str] | None = None) -> AuthSettings | None: try: return require_auth_settings(environ) except AuthenticationConfigurationError: return None def verify_credentials( credentials: AdminCredentials, submitted_id: str, submitted_password: str ) -> bool: if not isinstance(submitted_id, str) or not isinstance(submitted_password, str): return False id_matches = hmac.compare_digest( submitted_id.encode("utf-8"), credentials.admin_id.encode("utf-8") ) password_matches = hmac.compare_digest( submitted_password.encode("utf-8"), credentials.password.encode("utf-8") ) return id_matches and password_matches def enforce_failed_login_delay( *, started_at: float, minimum_seconds: float = MINIMUM_FAILURE_DELAY_SECONDS, monotonic: Callable[[], float] = time.monotonic, sleep: Callable[[float], None] = time.sleep, ) -> None: remaining = minimum_seconds - (monotonic() - started_at) if remaining > 0: sleep(remaining) def get_auth_correlation_id( session_state: MutableMapping[str, object], *, token_hex: CallableABC[[int], str] = secrets.token_hex, ) -> str: existing = session_state.get(AUTH_CORRELATION_SESSION_KEY) if isinstance(existing, str) and existing: return existing correlation_id = token_hex(8) session_state[AUTH_CORRELATION_SESSION_KEY] = correlation_id return correlation_id def log_auth_event(event: str, correlation_id: str) -> None: logger.info("dashboard_auth event=%s correlation_id=%s", event, correlation_id) def _derived_signing_key(settings: AuthSettings) -> bytes: return hmac.new( settings.signing_secret.encode("utf-8"), _TOKEN_CONTEXT + settings.admin_id.encode("utf-8") + b"\x00" + settings.admin_password.encode("utf-8"), hashlib.sha256, ).digest() def _serializer_for(settings: AuthSettings) -> URLSafeSerializer: return URLSafeSerializer( secret_key=_derived_signing_key(settings), salt=_TOKEN_SALT, signer_kwargs={"digest_method": hashlib.sha256}, ) def issue_persistent_token( settings: AuthSettings, *, remember_me: bool, now: float | None = None, token_id: str | None = None, ) -> IssuedPersistentToken: issued_at = int(time.time() if now is None else now) ttl = ( REMEMBER_ME_TOKEN_TTL_SECONDS if remember_me else DEFAULT_TOKEN_TTL_SECONDS ) expires_at = issued_at + ttl jti = secrets.token_urlsafe(16) if token_id is None else token_id payload = { "v": _TOKEN_VERSION, "sub": _TOKEN_SUBJECT, "iat": issued_at, "exp": expires_at, "jti": jti, } return IssuedPersistentToken( value=_serializer_for(settings).dumps(payload), issued_at=issued_at, expires_at=expires_at, max_age=ttl, ) def verify_persistent_token( settings: AuthSettings | None, token: object, *, now: float | None = None, ) -> VerifiedPersistentToken | None: if settings is None or not isinstance(token, str) or not token: return None if len(token) > MAX_TOKEN_LENGTH: return None try: payload = _serializer_for(settings).loads(token) except (BadData, TypeError, ValueError): return None if not isinstance(payload, dict) or frozenset(payload) != _TOKEN_FIELDS: return None version = payload.get("v") subject = payload.get("sub") issued_at = payload.get("iat") expires_at = payload.get("exp") token_id = payload.get("jti") if ( type(version) is not int or version != _TOKEN_VERSION or subject != _TOKEN_SUBJECT or type(issued_at) is not int or type(expires_at) is not int or not isinstance(token_id, str) or not token_id or len(token_id) > 256 ): return None current_time = int(time.time() if now is None else now) ttl = expires_at - issued_at if ttl not in {DEFAULT_TOKEN_TTL_SECONDS, REMEMBER_ME_TOKEN_TTL_SECONDS}: return None if issued_at > current_time + CLOCK_SKEW_SECONDS or expires_at <= current_time: return None return VerifiedPersistentToken( subject=subject, issued_at=issued_at, expires_at=expires_at, token_id=token_id, ) def _request_cookie() -> str | None: try: value = st.context.cookies.get(AUTH_COOKIE_NAME) except Exception: return None return value if isinstance(value, str) and value else None def _query_error() -> str | None: try: value = st.query_params.get("auth_error") except Exception: return None return value if isinstance(value, str) else None def _render_login(*, clear_cookie: bool = False) -> None: st.title("관리자 로그인") st.caption("Dashboard를 사용하려면 관리자 자격 증명이 필요합니다.") if _query_error() == "invalid": st.error(INVALID_CREDENTIALS_MESSAGE) elif _query_error() == "unavailable": st.error(CONFIGURATION_ERROR_MESSAGE) st.markdown( """
""", unsafe_allow_html=True, ) if clear_cookie: st.markdown( "", unsafe_allow_html=True, ) def _render_logout() -> None: with st.sidebar: st.markdown( """
""", unsafe_allow_html=True, ) def require_dashboard_authentication() -> bool: """Validate the server-signed cookie before allowing any dashboard access.""" correlation_id = get_auth_correlation_id(st.session_state) cookie = _request_cookie() settings = load_auth_settings() if settings is None: log_auth_event("configuration_error", correlation_id) _render_login(clear_cookie=cookie is not None) st.error(CONFIGURATION_ERROR_MESSAGE) return False verified = verify_persistent_token(settings, cookie) if verified is None: log_auth_event("authentication_required", correlation_id) _render_login(clear_cookie=cookie is not None) return False # The configured server-side admin identity is the only actor source. Do # not derive it from query parameters, client headers, or dashboard forms. st.session_state["authenticated_actor"] = settings.admin_id log_auth_event("token_accepted", correlation_id) _render_logout() return True def get_authenticated_actor() -> str | None: """Return the actor established by successful server-side authentication.""" actor = st.session_state.get("authenticated_actor") return actor if isinstance(actor, str) and actor.strip() else None