fix(dashboard): address persistent auth review
This commit is contained in:
@@ -48,6 +48,30 @@ def _same_origin(request: Request) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _same_origin_navigation(request: Request) -> bool:
|
||||
referer = request.headers.get("referer")
|
||||
host = request.headers.get("host")
|
||||
if not referer or not host:
|
||||
return False
|
||||
try:
|
||||
parsed = urlsplit(referer)
|
||||
authority = urlsplit(f"//{host}")
|
||||
referer_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 referer_port == request_port
|
||||
and parsed.username is None
|
||||
and parsed.password is None
|
||||
)
|
||||
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:
|
||||
@@ -126,6 +150,12 @@ async def logout(request: Request) -> Response:
|
||||
authentication.log_auth_event("logout_origin_rejected", correlation_id)
|
||||
return PlainTextResponse("Forbidden", status_code=403)
|
||||
response = _redirect("/")
|
||||
_delete_auth_cookie(response)
|
||||
authentication.log_auth_event("logout", correlation_id)
|
||||
return response
|
||||
|
||||
|
||||
def _delete_auth_cookie(response: Response) -> None:
|
||||
response.delete_cookie(
|
||||
authentication.AUTH_COOKIE_NAME,
|
||||
path="/",
|
||||
@@ -133,7 +163,16 @@ async def logout(request: Request) -> Response:
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
)
|
||||
authentication.log_auth_event("logout", correlation_id)
|
||||
|
||||
|
||||
async def cleanup(request: Request) -> Response:
|
||||
correlation_id = secrets.token_hex(8)
|
||||
if not _same_origin_navigation(request):
|
||||
authentication.log_auth_event("cleanup_origin_rejected", correlation_id)
|
||||
return PlainTextResponse("Forbidden", status_code=403)
|
||||
response = _redirect("/")
|
||||
_delete_auth_cookie(response)
|
||||
authentication.log_auth_event("cleanup", correlation_id)
|
||||
return response
|
||||
|
||||
|
||||
@@ -141,4 +180,5 @@ def create_auth_routes() -> list[Route]:
|
||||
return [
|
||||
Route("/api/auth/login", login, methods=["POST"]),
|
||||
Route("/api/auth/logout", logout, methods=["POST"]),
|
||||
Route("/api/auth/cleanup", cleanup, methods=["GET"]),
|
||||
]
|
||||
|
||||
+14
-11
@@ -31,6 +31,7 @@ _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)
|
||||
@@ -149,7 +150,7 @@ def log_auth_event(event: str, correlation_id: str) -> 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"),
|
||||
_TOKEN_CONTEXT + settings.admin_id.encode("utf-8") + b"\x00" + settings.admin_password.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
|
||||
@@ -177,7 +178,7 @@ def issue_persistent_token(
|
||||
jti = secrets.token_urlsafe(16) if token_id is None else token_id
|
||||
payload = {
|
||||
"v": _TOKEN_VERSION,
|
||||
"sub": settings.admin_id,
|
||||
"sub": _TOKEN_SUBJECT,
|
||||
"iat": issued_at,
|
||||
"exp": expires_at,
|
||||
"jti": jti,
|
||||
@@ -215,8 +216,7 @@ def verify_persistent_token(
|
||||
if (
|
||||
type(version) is not int
|
||||
or version != _TOKEN_VERSION
|
||||
or not isinstance(subject, str)
|
||||
or not subject
|
||||
or subject != _TOKEN_SUBJECT
|
||||
or type(issued_at) is not int
|
||||
or type(expires_at) is not int
|
||||
or not isinstance(token_id, str)
|
||||
@@ -224,9 +224,6 @@ def verify_persistent_token(
|
||||
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}:
|
||||
@@ -258,7 +255,7 @@ def _query_error() -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _render_login() -> None:
|
||||
def _render_login(*, clear_cookie: bool = False) -> None:
|
||||
st.title("관리자 로그인")
|
||||
st.caption("Dashboard를 사용하려면 관리자 자격 증명이 필요합니다.")
|
||||
if _query_error() == "invalid":
|
||||
@@ -278,6 +275,11 @@ def _render_login() -> None:
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
if clear_cookie:
|
||||
st.markdown(
|
||||
"<meta http-equiv=\"refresh\" content=\"0;url=/api/auth/cleanup\">",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
|
||||
def _render_logout() -> None:
|
||||
@@ -295,17 +297,18 @@ def _render_logout() -> None:
|
||||
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()
|
||||
_render_login(clear_cookie=cookie is not None)
|
||||
st.error(CONFIGURATION_ERROR_MESSAGE)
|
||||
return False
|
||||
|
||||
verified = verify_persistent_token(settings, _request_cookie())
|
||||
verified = verify_persistent_token(settings, cookie)
|
||||
if verified is None:
|
||||
log_auth_event("authentication_required", correlation_id)
|
||||
_render_login()
|
||||
_render_login(clear_cookie=cookie is not None)
|
||||
return False
|
||||
|
||||
log_auth_event("token_accepted", correlation_id)
|
||||
|
||||
Reference in New Issue
Block a user