feat(dashboard): persist administrator login

This commit is contained in:
2026-10-01 13:56:22 +00:00
parent 7bea8b368e
commit c5eaddb4f3
20 changed files with 1343 additions and 884 deletions
+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
import secrets
import time
from urllib.parse import parse_qs, urlsplit
from starlette.requests import Request
from starlette.responses import PlainTextResponse, RedirectResponse, Response
from starlette.routing import Route
from dashboard import authentication
_MAX_LOGIN_BODY_BYTES = 4096
_MAX_CREDENTIAL_CHARS = 1024
def _redirect(location: str) -> RedirectResponse:
response = RedirectResponse(location, status_code=303)
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
return response
def _same_origin(request: Request) -> bool:
origin = request.headers.get("origin")
host = request.headers.get("host")
if not origin or not host:
return False
try:
parsed = urlsplit(origin)
authority = urlsplit(f"//{host}")
origin_port = parsed.port or 443
request_port = authority.port or 443
return (
request.url.scheme == "https"
and parsed.scheme == "https"
and parsed.hostname is not None
and authority.hostname is not None
and parsed.hostname.lower() == authority.hostname.lower()
and origin_port == request_port
and parsed.username is None
and parsed.password is None
and parsed.path in {"", "/"}
and not parsed.query
and not parsed.fragment
)
except ValueError:
return False
def _one(values: dict[str, list[str]], name: str) -> str | None:
candidates = values.get(name)
if candidates is None or len(candidates) != 1:
return None
value = candidates[0]
if len(value) > _MAX_CREDENTIAL_CHARS:
return None
return value
async def login(request: Request) -> Response:
correlation_id = secrets.token_hex(8)
if not _same_origin(request):
authentication.log_auth_event("login_origin_rejected", correlation_id)
return PlainTextResponse("Forbidden", status_code=403)
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type != "application/x-www-form-urlencoded":
authentication.log_auth_event("login_request_rejected", correlation_id)
return PlainTextResponse("Bad Request", status_code=400)
body = await request.body()
if len(body) > _MAX_LOGIN_BODY_BYTES:
authentication.log_auth_event("login_request_rejected", correlation_id)
return PlainTextResponse("Bad Request", status_code=400)
try:
values = parse_qs(
body.decode("utf-8"),
keep_blank_values=True,
strict_parsing=True,
max_num_fields=3,
)
except (UnicodeDecodeError, ValueError):
authentication.log_auth_event("login_request_rejected", correlation_id)
return PlainTextResponse("Bad Request", status_code=400)
settings = authentication.load_auth_settings()
if settings is None:
authentication.log_auth_event("configuration_error", correlation_id)
return _redirect("/?auth_error=unavailable")
submitted_id = _one(values, "admin_id")
submitted_password = _one(values, "admin_password")
if submitted_id is None or submitted_password is None:
authentication.log_auth_event("login_request_rejected", correlation_id)
return _redirect("/?auth_error=invalid")
started_at = time.monotonic()
if not authentication.verify_credentials(
settings.credentials, submitted_id, submitted_password
):
authentication.enforce_failed_login_delay(started_at=started_at)
authentication.log_auth_event("login_failed", correlation_id)
return _redirect("/?auth_error=invalid")
remember_me = _one(values, "remember_me") == "on"
issued = authentication.issue_persistent_token(
settings,
remember_me=remember_me,
)
response = _redirect("/")
response.set_cookie(
authentication.AUTH_COOKIE_NAME,
issued.value,
max_age=issued.max_age,
path="/",
secure=True,
httponly=True,
samesite="lax",
)
authentication.log_auth_event("login_succeeded", correlation_id)
return response
async def logout(request: Request) -> Response:
correlation_id = secrets.token_hex(8)
if not _same_origin(request):
authentication.log_auth_event("logout_origin_rejected", correlation_id)
return PlainTextResponse("Forbidden", status_code=403)
response = _redirect("/")
response.delete_cookie(
authentication.AUTH_COOKIE_NAME,
path="/",
secure=True,
httponly=True,
samesite="lax",
)
authentication.log_auth_event("logout", correlation_id)
return response
def create_auth_routes() -> list[Route]:
return [
Route("/api/auth/login", login, methods=["POST"]),
Route("/api/auth/logout", logout, methods=["POST"]),
]
+230 -56
View File
@@ -2,6 +2,7 @@ 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
@@ -9,20 +10,27 @@ 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
AUTHENTICATED_SESSION_KEY = "_jarca_dashboard_authenticated"
LOGIN_FAILED_SESSION_KEY = "_jarca_dashboard_login_failed"
LOGIN_ID_SESSION_KEY = "_jarca_dashboard_login_id"
LOGIN_PASSWORD_SESSION_KEY = "_jarca_dashboard_login_password"
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"})
@dataclass(frozen=True)
@@ -31,25 +39,79 @@ class AdminCredentials:
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 load_admin_credentials(environ: Mapping[str, str] | None = None) -> AdminCredentials:
source = os.environ if environ is None else environ
def _read_required_setting(source: Mapping[str, str], name: str) -> str:
try:
admin_id = source.get(ADMIN_ID_ENV)
password = source.get(ADMIN_PASSWORD_ENV)
value = source.get(name)
except Exception as exc:
raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE) from exc
if not isinstance(admin_id, str) or not admin_id or not isinstance(password, str) or not password:
if not isinstance(value, str) or not value:
raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE)
return AdminCredentials(admin_id=admin_id, password=password)
return value
def verify_credentials(credentials: AdminCredentials, submitted_id: str, submitted_password: str) -> bool:
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, credentials.admin_id)
password_matches = hmac.compare_digest(submitted_password, credentials.password)
return id_matches and password_matches
@@ -84,56 +146,168 @@ def log_auth_event(event: str, correlation_id: str) -> None:
logger.info("dashboard_auth event=%s correlation_id=%s", event, correlation_id)
def _clear_submitted_credentials() -> None:
st.session_state.pop(LOGIN_ID_SESSION_KEY, None)
st.session_state.pop(LOGIN_PASSWORD_SESSION_KEY, None)
def _derived_signing_key(settings: AuthSettings) -> bytes:
return hmac.new(
settings.signing_secret.encode("utf-8"),
_TOKEN_CONTEXT + 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": settings.admin_id,
"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 not isinstance(subject, str)
or not 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
if not hmac.compare_digest(subject, settings.admin_id):
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() -> 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(
"""
<form action="/api/auth/login" method="post">
<label for="jarca-admin-id">관리자 ID</label>
<input id="jarca-admin-id" name="admin_id" type="text" autocomplete="username" required>
<label for="jarca-admin-password">비밀번호</label>
<input id="jarca-admin-password" name="admin_password" type="password" autocomplete="current-password" required>
<label><input name="remember_me" type="checkbox"> 로그인 상태 유지 (30일)</label>
<button type="submit">로그인</button>
</form>
""",
unsafe_allow_html=True,
)
def _render_logout() -> None:
with st.sidebar:
st.markdown(
"""
<form action="/api/auth/logout" method="post">
<button type="submit">로그아웃</button>
</form>
""",
unsafe_allow_html=True,
)
def require_dashboard_authentication() -> bool:
"""Render the authentication boundary and return whether access is allowed."""
"""Validate the server-signed cookie before allowing any dashboard access."""
correlation_id = get_auth_correlation_id(st.session_state)
try:
credentials = load_admin_credentials()
except AuthenticationConfigurationError:
st.session_state.pop(AUTHENTICATED_SESSION_KEY, None)
_clear_submitted_credentials()
settings = load_auth_settings()
if settings is None:
log_auth_event("configuration_error", correlation_id)
st.title("관리자 로그인")
_render_login()
st.error(CONFIGURATION_ERROR_MESSAGE)
return False
if st.session_state.get(AUTHENTICATED_SESSION_KEY) is True:
_clear_submitted_credentials()
with st.sidebar:
if st.button("로그아웃", key="dashboard_logout", use_container_width=True):
st.session_state.pop(AUTHENTICATED_SESSION_KEY, None)
log_auth_event("logout", correlation_id)
st.rerun()
return True
st.title("관리자 로그인")
st.caption("Dashboard를 사용하려면 관리자 자격 증명이 필요합니다.")
show_login_error = st.session_state.pop(LOGIN_FAILED_SESSION_KEY, False) is True
if show_login_error:
_clear_submitted_credentials()
with st.form("dashboard_login", clear_on_submit=True):
submitted_id = st.text_input("관리자 ID", key=LOGIN_ID_SESSION_KEY)
submitted_password = st.text_input("비밀번호", type="password", key=LOGIN_PASSWORD_SESSION_KEY)
submitted = st.form_submit_button("로그인", use_container_width=True)
if show_login_error:
st.error(INVALID_CREDENTIALS_MESSAGE)
if not submitted:
verified = verify_persistent_token(settings, _request_cookie())
if verified is None:
log_auth_event("authentication_required", correlation_id)
_render_login()
return False
started_at = time.monotonic()
if verify_credentials(credentials, submitted_id, submitted_password):
st.session_state[AUTHENTICATED_SESSION_KEY] = True
log_auth_event("login_succeeded", correlation_id)
st.rerun()
enforce_failed_login_delay(started_at=started_at)
log_auth_event("login_failed", correlation_id)
st.session_state[LOGIN_FAILED_SESSION_KEY] = True
st.rerun()
log_auth_event("token_accepted", correlation_id)
_render_logout()
return True
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
from pathlib import Path
from streamlit import App
from dashboard.auth_server import create_auth_routes
APP_SCRIPT = Path(__file__).with_name("app.py")
app = App(APP_SCRIPT, routes=create_auth_routes())