feat(dashboard): persist administrator login
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user