From c5eaddb4f3562c26d4492498cc29fa6e1b4ee9a7 Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Thu, 1 Oct 2026 13:56:22 +0000 Subject: [PATCH 01/11] feat(dashboard): persist administrator login --- .env.example | 3 + README.md | 17 +- dashboard/auth_server.py | 144 +++++ dashboard/authentication.py | 286 ++++++++-- dashboard/server.py | 10 + deploy/k8s/README.md | 3 +- deploy/k8s/base/deployment.yaml | 23 +- docker-compose.yml | 18 +- .../issue-6-persistent-auth-https.json | 29 + docs/interface-contract.md | 193 +++---- docs/project-structure.md | 147 +++-- ..._dashboard-administrator-authentication.md | 518 ++++-------------- docs/runtime-flow.md | 130 +++-- pyproject.toml | 11 +- scripts/persistent_auth_http_evidence.py | 153 ++++++ tests/test_dashboard_app.py | 15 +- tests/test_dashboard_auth_server.py | 191 +++++++ tests/test_dashboard_authentication_app.py | 159 +++--- ...est_dashboard_persistent_authentication.py | 163 ++++++ uv.lock | 14 +- 20 files changed, 1343 insertions(+), 884 deletions(-) create mode 100644 dashboard/auth_server.py create mode 100644 dashboard/server.py create mode 100644 docs/evidence/issue-6-persistent-auth-https.json create mode 100644 scripts/persistent_auth_http_evidence.py create mode 100644 tests/test_dashboard_auth_server.py create mode 100644 tests/test_dashboard_persistent_authentication.py diff --git a/.env.example b/.env.example index f1ca59b..5b5607b 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,6 @@ JARCA_LEDGER_DB_PATH= # Required for the Streamlit Dashboard. Inject real values from a secret store. JARCA_DASHBOARD_ADMIN_ID= JARCA_DASHBOARD_ADMIN_PASSWORD= +JARCA_DASHBOARD_SIGNING_SECRET= +# Optional Uvicorn proxy trust allowlist. Keep loopback unless a reviewed HTTPS proxy is used. +JARCA_DASHBOARD_FORWARDED_ALLOW_IPS=127.0.0.1 diff --git a/README.md b/README.md index 687a396..30440a5 100644 --- a/README.md +++ b/README.md @@ -31,22 +31,25 @@ hermes mcp test jarca-ledger 로컬 웹 대시보드는 실제 기본 DB `~/.local/share/jarca/ledger.sqlite3`를 읽고 씁니다. -Dashboard는 모든 조회·수정·Import 기능 앞에서 단일 관리자 로그인을 요구합니다. 관리자 ID와 password는 Streamlit secrets가 아니라 배포 secret이 주입하는 다음 환경변수에서만 읽습니다. +Dashboard는 모든 조회·수정·Import 기능 앞에서 단일 관리자 로그인을 요구합니다. 관리자 ID, password와 독립적인 서명 secret은 Streamlit secrets가 아니라 배포 secret이 주입하는 다음 환경변수에서만 읽습니다. ```bash export JARCA_DASHBOARD_ADMIN_ID='' export JARCA_DASHBOARD_ADMIN_PASSWORD='' +export JARCA_DASHBOARD_SIGNING_SECRET='<32 byte 이상의 무작위 secret을 주입>' ``` -두 값 중 하나라도 누락됐거나 비어 있거나 읽을 수 없으면 Dashboard는 fail-closed하여 로그인 설정 오류만 표시하고 DB나 page view에 접근하지 않습니다. 실제 credential을 `.env`, Compose 파일, shell history 또는 repository 문서에 저장하지 마세요. +세 값 중 하나라도 누락됐거나 비어 있거나 읽을 수 없으면 Dashboard는 fail-closed하여 로그인 설정 오류만 표시하고 DB나 page view에 접근하지 않습니다. 서명 secret은 32 byte 이상이어야 합니다. 실제 credential이나 서명 secret을 `.env`, Compose 파일, shell history 또는 repository 문서에 저장하지 마세요. ```bash cd /root/projects/jarca-ledger-mcp . .venv/bin/activate -streamlit run dashboard/app.py --server.address 0.0.0.0 --server.port 8501 +uvicorn dashboard.server:app --host 0.0.0.0 --port 8501 ``` -인증 성공 상태는 현재 Streamlit browser session에서만 유지됩니다. 새 session에는 재로그인이 필요하고 sidebar의 `로그아웃`으로 즉시 인증 상태를 폐기할 수 있습니다. 잘못된 ID 또는 password 응답은 매 제출마다 최소 1초 지연되지만 애플리케이션 잠금은 없습니다. 이 지연은 새 session이나 병렬 요청으로 우회할 수 있으므로 외부에 노출할 때는 Ingress, reverse proxy 또는 동등한 배포 경계에 별도 rate limiting을 적용해야 합니다. +인증 성공 시 Dashboard의 동일 ASGI 프로세스에 포함된 server-side 인증 endpoint가 `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/` 속성의 서명 cookie를 발급합니다. 기본 절대 만료는 24시간이며 `로그인 상태 유지 (30일)`을 선택하면 30일입니다. 일반 요청이나 재접속은 만료를 연장하지 않습니다. 관리자 password 또는 서명 secret을 교체하면 기존 cookie는 즉시 검증에 실패합니다. `로그아웃`은 browser cookie를 삭제하지만, 이미 탈취·복사된 stateless token을 서버에서 개별 폐기하지는 못하므로 사고 대응 시 password 또는 서명 secret을 교체해야 합니다. + +`Secure` cookie를 사용하므로 browser가 접근하는 공개 origin은 HTTPS여야 하며 login/logout endpoint는 ASGI request scheme도 HTTPS가 아니면 거부합니다. TLS를 Ingress 또는 reverse proxy에서 종료할 때는 `JARCA_DASHBOARD_FORWARDED_ALLOW_IPS`를 실제 proxy source IP/CIDR allowlist로 설정해야 합니다. 기본값 `127.0.0.1`은 cluster ingress를 신뢰하지 않는 fail-closed 값입니다. `*` 또는 불특정 network를 신뢰하지 말고 proxy가 `Host`, `Origin`, `X-Forwarded-Proto`를 정확히 보존·설정하게 하세요. 인증 cookie의 발급·검증은 Dashboard 프로세스가 직접 수행합니다. 잘못된 ID 또는 password 응답은 매 제출마다 최소 1초 지연되지만 애플리케이션 잠금은 없습니다. 이 지연은 병렬 요청으로 우회할 수 있으므로 외부 노출 경계에 별도 rate limiting을 적용해야 합니다. 이 로그인은 **Streamlit Dashboard만 보호하며 MCP endpoint를 인증하지 않습니다.** MCP는 별도 요구사항과 적절한 네트워크 경계로 보호해야 합니다. @@ -75,7 +78,7 @@ Deployment: jarca-ledger (replicas: 1) │ └─ containerPort: 8000 ├─ frontend container │ ├─ 동일 릴리즈 이미지 - │ ├─ command: python -m streamlit run dashboard/app.py ... + │ ├─ command: uvicorn dashboard.server:app ... │ └─ containerPort: 8501 └─ shared PVC └─ /data/ledger.sqlite3 @@ -86,7 +89,7 @@ Deployment: jarca-ledger (replicas: 1) - 하나의 Pod 안에서 MCP와 Streamlit을 **별도 컨테이너**로 실행합니다. 한 컨테이너에서 두 프로세스를 supervisor나 background command로 함께 실행하지 않습니다. - 두 컨테이너는 동일한 PVC를 `/data`에 마운트하고 `JARCA_LEDGER_DB_PATH=/data/ledger.sqlite3`를 사용합니다. - 이미지와 Pod는 UID/GID `1000:1000`으로 실행하며, PVC 쓰기 권한은 `fsGroup: 1000`으로 설정합니다. -- frontend 컨테이너에만 `JARCA_DASHBOARD_ADMIN_ID`와 `JARCA_DASHBOARD_ADMIN_PASSWORD`를 Kubernetes Secret 또는 동등한 secret store에서 주입합니다. 실제 값은 manifest나 repository에 기록하지 않습니다. +- frontend 컨테이너에만 `JARCA_DASHBOARD_ADMIN_ID`, `JARCA_DASHBOARD_ADMIN_PASSWORD`, `JARCA_DASHBOARD_SIGNING_SECRET`을 Kubernetes Secret 또는 동등한 secret store에서 주입합니다. 실제 값은 manifest나 repository에 기록하지 않습니다. - SQLite 파일을 공유하므로 Deployment의 replica는 `1`, update 전략은 `Recreate`로 유지합니다. - Base는 Dashboard용 `ClusterIP` Service(`8501`)만 생성합니다. 인증 계층이 없는 MCP port `8000`에는 Service를 만들지 않고 `NetworkPolicy`로 ingress를 차단합니다. MCP 원격 접근이 필요하면 environment overlay에서 인증된 gateway와 제한된 ingress 정책을 함께 정의합니다. - 외부 공개가 필요하면 Dashboard Service만 environment overlay의 Ingress 또는 NodePort에 연결합니다. @@ -97,7 +100,7 @@ Deployment: jarca-ledger (replicas: 1) Streamlit이 SQLite에 직접 접근하지 않고 MCP/API만 호출하도록 변경하거나 네트워크 DB로 전환한 뒤에는 MCP와 frontend를 각각 독립 Deployment로 분리할 수 있습니다. -로컬 `docker-compose.yml`도 동일한 애플리케이션 이미지를 두 컨테이너로 실행하는 구조입니다. 현재 Compose 설정은 로컬에서 빌드한 `jarca-ledger:latest`를 사용하며, MCP는 호스트 `8000` 포트로, Streamlit은 호스트 `40059`에서 컨테이너 `8501` 포트로 연결됩니다. Compose 실행 전 host environment 또는 안전한 `.env` 전달 경계에 두 Dashboard credential 변수를 설정해야 하며 누락 시 Compose가 시작을 거부합니다. Bind mount 대상 `/volume1/docker/jarca-ledger/data`는 UID/GID `1000:1000`이 쓸 수 있어야 합니다. Gitea Container Registry 릴리즈를 배포할 때는 두 서비스의 `image`를 검증된 동일 `gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp` digest로 지정해야 합니다. +로컬 `docker-compose.yml`도 동일한 애플리케이션 이미지를 두 컨테이너로 실행하는 구조입니다. 현재 Compose 설정은 로컬에서 빌드한 `jarca-ledger:latest`를 사용하며, MCP는 호스트 `8000` 포트로, Dashboard는 호스트 `40059`에서 컨테이너 `8501` 포트로 연결됩니다. Compose 실행 전 host environment 또는 안전한 `.env` 전달 경계에 세 Dashboard 인증 변수를 설정해야 하며 누락 시 Compose가 시작을 거부합니다. Browser 접근에는 HTTPS reverse proxy를 앞에 두어야 합니다. Bind mount 대상 `/volume1/docker/jarca-ledger/data`는 UID/GID `1000:1000`이 쓸 수 있어야 합니다. Gitea Container Registry 릴리즈를 배포할 때는 두 서비스의 `image`를 검증된 동일 `gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp` digest로 지정해야 합니다. ## 주요 도구 diff --git a/dashboard/auth_server.py b/dashboard/auth_server.py new file mode 100644 index 0000000..8d9e115 --- /dev/null +++ b/dashboard/auth_server.py @@ -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"]), + ] diff --git a/dashboard/authentication.py b/dashboard/authentication.py index 14205d0..c4670fa 100644 --- a/dashboard/authentication.py +++ b/dashboard/authentication.py @@ -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( + """ +
+ + + + + + +
+""", + unsafe_allow_html=True, + ) + + +def _render_logout() -> None: + with st.sidebar: + st.markdown( + """ +
+ +
+""", + 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 diff --git a/dashboard/server.py b/dashboard/server.py new file mode 100644 index 0000000..3f2bcf4 --- /dev/null +++ b/dashboard/server.py @@ -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()) diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md index c19a3ae..a17830d 100644 --- a/deploy/k8s/README.md +++ b/deploy/k8s/README.md @@ -26,6 +26,7 @@ kubectl apply -f deploy/k8s/base/namespace.yaml - `jarca-ledger-dashboard-auth` - key `admin-id` - key `admin-password` + - key `signing-secret` (32 byte 이상의 독립적인 무작위 값) Registry credential을 application container에 환경변수나 volume으로 전달하지 않는다. `imagePullSecrets`로만 사용한다. @@ -58,7 +59,7 @@ kubectl -n jarca-ledger rollout status deployment/jarca-ledger kubectl -n jarca-ledger get pods,svc,pvc ``` -Dashboard를 외부에 공개하는 Ingress나 NodePort는 environment overlay에서 추가한다. Base에는 MCP Service가 없고, `NetworkPolicy`는 workload ingress를 dashboard port `8501`로 제한하여 인증 계층이 없는 MCP port `8000` ingress를 차단한다. Cluster의 network plugin이 `NetworkPolicy`를 enforce하는지 운영 환경에서 확인한다. +Dashboard를 외부에 공개하는 Ingress나 NodePort는 environment overlay에서 추가한다. 인증 cookie가 항상 `Secure`이고 endpoint가 ASGI HTTPS scheme을 요구하므로 공개 origin에는 TLS가 필수다. Base의 `FORWARDED_ALLOW_IPS=127.0.0.1`은 ingress forwarded header를 신뢰하지 않는 fail-closed 기본값이다. TLS를 ingress에서 종료하는 overlay는 이 값을 실제 ingress-controller source IP/CIDR allowlist로 교체해야 하며 `*` 또는 불특정 network를 사용해서는 안 된다. TLS 종료 계층은 `Host`와 `Origin`을 보존하고 `X-Forwarded-Proto: https`를 설정해야 한다. 인증 cookie의 발급·검증 자체는 Dashboard 프로세스가 수행한다. Base에는 MCP Service가 없고, `NetworkPolicy`는 workload ingress를 dashboard port `8501`로 제한하여 인증 계층이 없는 MCP port `8000` ingress를 차단한다. Cluster의 network plugin이 `NetworkPolicy`를 enforce하는지 운영 환경에서 확인한다. ## SQLite와 PVC diff --git a/deploy/k8s/base/deployment.yaml b/deploy/k8s/base/deployment.yaml index abab0a4..a86fc77 100644 --- a/deploy/k8s/base/deployment.yaml +++ b/deploy/k8s/base/deployment.yaml @@ -107,19 +107,13 @@ spec: image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.2 imagePullPolicy: IfNotPresent command: - - python - - -m - - streamlit - - run - - dashboard/app.py - - --server.address + - uvicorn + - dashboard.server:app + - --host - 0.0.0.0 - - --server.port + - --port - "8501" - - --server.headless - - "true" - - --browser.gatherUsageStats - - "false" + - --proxy-headers env: - name: JARCA_LEDGER_DB_PATH value: /data/ledger.sqlite3 @@ -137,6 +131,13 @@ spec: secretKeyRef: name: jarca-ledger-dashboard-auth key: admin-password + - name: JARCA_DASHBOARD_SIGNING_SECRET + valueFrom: + secretKeyRef: + name: jarca-ledger-dashboard-auth + key: signing-secret + - name: FORWARDED_ALLOW_IPS + value: 127.0.0.1 ports: - name: dashboard containerPort: 8501 diff --git a/docker-compose.yml b/docker-compose.yml index beabeec..f3706c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,24 +31,20 @@ services: JARCA_LEDGER_DB_PATH: /data/ledger.sqlite3 JARCA_DASHBOARD_ADMIN_ID: ${JARCA_DASHBOARD_ADMIN_ID:?set JARCA_DASHBOARD_ADMIN_ID} JARCA_DASHBOARD_ADMIN_PASSWORD: ${JARCA_DASHBOARD_ADMIN_PASSWORD:?set JARCA_DASHBOARD_ADMIN_PASSWORD} + JARCA_DASHBOARD_SIGNING_SECRET: ${JARCA_DASHBOARD_SIGNING_SECRET:?set JARCA_DASHBOARD_SIGNING_SECRET} + FORWARDED_ALLOW_IPS: ${JARCA_DASHBOARD_FORWARDED_ALLOW_IPS:-127.0.0.1} STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false" PYTHONUNBUFFERED: "1" volumes: - /volume1/docker/jarca-ledger/data:/data command: - - python - - -m - - streamlit - - run - - dashboard/app.py - - --server.address + - uvicorn + - dashboard.server:app + - --host - 0.0.0.0 - - --server.port + - --port - "8501" - - --server.headless - - "true" - - --browser.gatherUsageStats - - "false" + - --proxy-headers healthcheck: interval: 30s timeout: 5s diff --git a/docs/evidence/issue-6-persistent-auth-https.json b/docs/evidence/issue-6-persistent-auth-https.json new file mode 100644 index 0000000..4b3d99b --- /dev/null +++ b/docs/evidence/issue-6-persistent-auth-https.json @@ -0,0 +1,29 @@ +{ + "schema": "jarca-ledger.auth-evidence/v1", + "issue": 6, + "verified_at_utc": "2026-10-01T13:51:52Z", + "source_revision": "7bea8b368e02e98afd4b2dc463fb270b0e6eb297", + "transport": "loopback HTTPS with self-signed test certificate", + "client": "HTTP plus Streamlit WebSocket protocol client", + "synthetic_database": true, + "results": { + "login_status": 303, + "cookie_name": "__Host-jarca_dashboard_auth", + "cookie_value": "[REDACTED]", + "secure": true, + "http_only": true, + "same_site": "lax", + "path": "/", + "remember_me_max_age_seconds": 2592000, + "reconnect_status": 200, + "streamlit_websocket_authenticated": true, + "streamlit_messages": 53, + "logout_status": 303, + "logout_max_age_seconds": 0 + }, + "command": "uv run --extra dashboard --extra dev python scripts/persistent_auth_http_evidence.py --base-url https://127.0.0.1:18506", + "notes": [ + "Credentials, signing material, and cookie values were not recorded.", + "This artifact records actual HTTPS and Streamlit WebSocket protocol evidence; it is not a Chromium screenshot artifact." + ] +} diff --git a/docs/interface-contract.md b/docs/interface-contract.md index c157729..39b4e29 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -1,154 +1,103 @@ -# Dashboard 관리자 인증 인터페이스 계약 +# Interface Contract — Dashboard 관리자 인증 ## 목적과 범위 -Streamlit Dashboard의 관리자 credential 입력, 환경 설정 판정, browser session 인증과 로그아웃 호출 규칙을 정의한다. +Dashboard의 인증 설정, server-side login/logout HTTP endpoint, signed cookie와 Streamlit 인증 경계 계약을 정의한다. -- 포함 범위: Dashboard 환경변수 credential, 로그인·로그아웃 UI, 인증 결과와 오류, 기존 page dispatch 경계 -- 제외 범위: MCP 인증, 다중 사용자, 영속 session, OIDC·SSO, TLS와 Ingress -- 사용 주체: Dashboard 사용자, 배포 운영자, `dashboard/app.py`, Dashboard page view +- 포함: 관리자 credential, signing secret, login/logout POST, token/cookie, fail-closed page gate +- 제외: MCP 인증, OAuth/OIDC, 다중 사용자, token registry, 외부 인증 middleware +- 공개 origin은 HTTPS여야 한다. -## 계약 +## 인터페이스 목록 -### dashboard-auth-config — 관리자 인증 설정 - -- 책임: 배포 secret에서 단일 관리자 credential을 fail-closed로 읽는다. -- 종류·프로토콜: process environment -- 호출 대상·operation: `dashboard.authentication.load_admin_credentials` -- 버전: 1.0 -- 호출 주체: Dashboard 인증 경계 -- 인증 방식: 해당 없음; 인증을 구성하는 입력 -- 필요 권한: Dashboard process environment를 설정할 배포 권한 - -#### 입력 - -| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | -|---|---|---|---|---|---|---|---| -| `JARCA_DASHBOARD_ADMIN_ID` | 환경변수 | `string` | 필수 | 없음 | 비어 있지 않은 문자열 | 단일 관리자 ID | 예 | -| `JARCA_DASHBOARD_ADMIN_PASSWORD` | 환경변수 | `string` | 필수 | 없음 | 비어 있지 않은 평문 password secret | 관리자 password | 예 | - -#### 출력 - -| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 | -|---|---|---|---|---|---|---| -| `AdminCredentials` | process memory | `dataclass` | 성공 시 필수 | 두 비어 있지 않은 문자열 | 현재 rerun에서만 사용할 검증 값 | 예 | - -#### 오류 - -| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | +| 인터페이스 | 종류 | 호출 주체 | 제공 주체 | 버전 | 상태 | |---|---|---|---|---|---| -| `AUTH_CONFIGURATION_ERROR` | 설정 | 누락, 빈 값, 비문자열 또는 environment read 실패 | 설정 수정 후 가능 | 일반 설정 오류만 표시하고 Dashboard 중단 | 기존 인증 boolean 폐기, 금융 상태 무변경 | +| `dashboard-auth-config` | environment | 배포 시스템 | `dashboard.authentication` | 2.0 | active | +| `POST /api/auth/login` | HTTPS | browser form | `dashboard.auth_server` | 1.0 | active | +| `POST /api/auth/logout` | HTTPS | browser form | `dashboard.auth_server` | 1.0 | active | +| `dashboard-auth-cookie` | Secure cookie | browser/Streamlit handshake | `dashboard.auth_server` | 1.0 | active | +| `dashboard-auth-gate` | Python/Streamlit | `dashboard.app` | `dashboard.authentication` | 2.0 | active | -### dashboard-login — 관리자 로그인 +## 상세 계약 -- 책임: 제출 credential을 검증하고 현재 browser session의 인증 상태를 설정한다. -- 종류·프로토콜: Streamlit form rerun -- 호출 대상·operation: `dashboard.authentication.require_dashboard_authentication` -- 버전: 1.0 -- 호출 주체: 미인증 Dashboard 사용자 -- 인증 방식: 관리자 ID·password의 constant-time 비교 -- 필요 권한: 없음 +### dashboard-auth-config -#### 입력 +| 이름 | 타입 | 필수 | 제약 | 민감정보 | +|---|---|---|---|---| +| `JARCA_DASHBOARD_ADMIN_ID` | string | 예 | non-empty | 예 | +| `JARCA_DASHBOARD_ADMIN_PASSWORD` | string | 예 | non-empty | 예 | +| `JARCA_DASHBOARD_SIGNING_SECRET` | string | 예 | UTF-8 32 byte 이상 | 예 | -| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | -|---|---|---|---|---|---|---|---| -| `admin_id` | Streamlit text input | `string` | 필수 | 빈 문자열 | 사용자 제출 문자열 | 관리자 ID 후보 | 예 | -| `password` | Streamlit password input | `string` | 필수 | 빈 문자열 | 사용자 제출 문자열 | 관리자 password 후보 | 예 | -| `page` | URL query parameter | `string` | 선택 | `요약` | 기존 Dashboard page 이름 | 인증 성공 후 이동할 page | 아니요 | +누락·빈 값·비문자열·read error·짧은 signing secret은 일반 설정 오류로 fail-closed한다. Secret 원문이나 read-error 원인은 사용자 응답과 log에 포함하지 않는다. -#### 출력 +### POST /api/auth/login -| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 | -|---|---|---|---|---|---|---| -| `_jarca_dashboard_authenticated` | `st.session_state` | `boolean` | 성공 후 필수 | `true` | 현재 session의 인증 여부 | 아니요 | -| 화면 | Streamlit UI | `view` | 필수 | 로그인 또는 인증된 Dashboard | 현재 인증 상태의 사용자 화면 | 금융 화면은 인증 후 민감 | -| 인증 event | application log | `string` | 제출 후 필수 | event와 correlation ID | 운영 진단 신호 | 아니요 | +- Content-Type: `application/x-www-form-urlencoded` +- Transport: trusted ASGI scope의 request scheme이 `https`여야 함 +- Origin: request host·effective port와 같은 HTTPS origin만 허용 +- 최대 body: 4096 bytes +- 입력: -#### 오류 +| 필드 | 타입 | 필수 | 의미 | +|---|---|---|---| +| `admin_id` | string | 예 | 제출 관리자 ID | +| `admin_password` | string | 예 | 제출 password | +| `remember_me` | string | 아니요 | 값이 `on`이면 30일, 아니면 24시간 | -| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | -|---|---|---|---|---|---| -| `INVALID_CREDENTIALS` | 인증 | ID 또는 password 불일치 | 가능 | 최소 1초 후 동일 일반 오류 표시 | 금융 상태 무변경, credential 입력 제거 | -| `AUTH_CONFIGURATION_ERROR` | 설정 | credential source 무효 | 설정 수정 후 가능 | 일반 설정 오류만 표시 | 인증 상태 폐기, 금융 상태 무변경 | +- 성공: `303 Location: /`, hardened cookie 1개 발급 +- credential 실패: 최소 1초 후 `303 Location: /?auth_error=invalid`, cookie 없음 +- 설정 실패: `303 Location: /?auth_error=unavailable`, cookie 없음 +- origin 실패: `403`, cookie 없음 +- plain HTTP 또는 신뢰되지 않은 forwarded scheme: `403`, cookie 없음 +- malformed/oversized request: `400`, cookie 없음 +- ID와 password는 모두 `hmac.compare_digest`로 비교한다. -### dashboard-logout — 관리자 로그아웃 +### dashboard-auth-cookie -- 책임: 현재 browser session의 인증 상태를 즉시 폐기한다. -- 종류·프로토콜: Streamlit button rerun -- 호출 대상·operation: sidebar `로그아웃` -- 버전: 1.0 -- 호출 주체: 인증된 Dashboard 사용자 -- 인증 방식: 현재 session의 인증 boolean -- 필요 권한: 인증된 session +| 속성 | 값 | +|---|---| +| 이름 | `__Host-jarca_dashboard_auth` | +| `Secure` | true | +| `HttpOnly` | true | +| `SameSite` | `Lax` | +| `Path` | `/` | +| `Domain` | 없음 | +| `Max-Age` | 86400 또는 2592000 | -#### 입력 +Token payload는 `v`, `sub`, `iat`, `exp`, `jti`만 허용한다. 서명 key는 signing secret과 현재 admin password를 domain-separated HMAC-SHA256으로 결합해 파생하며 `itsdangerous.URLSafeSerializer` SHA-256 signer를 사용한다. Token은 일반 요청에서 갱신하지 않는다. -| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | -|---|---|---|---|---|---|---|---| -| `로그아웃` | Streamlit button | `click event` | 필수 | 없음 | 단일 click | 인증 종료 요청 | 아니요 | +### POST /api/auth/logout -#### 출력 +- Same-origin HTTPS POST만 허용한다. +- 성공: `303 Location: /`과 동일 name/path/security 속성의 `Max-Age=0` cookie deletion +- 금융 상태와 DB는 변경하지 않는다. +- Stateless token 복사본은 개별 폐기하지 않는다. 강제 무효화는 password/signing-secret rotation으로 수행한다. -| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 | -|---|---|---|---|---|---|---| -| 인증 상태 | `st.session_state` | `absent` | 필수 | key 제거 | 현재 session 재인증 요구 | 아니요 | -| 화면 | Streamlit UI | `view` | 필수 | 로그인 화면 | 로그아웃 완료 | 아니요 | +### dashboard-auth-gate -#### 오류 +`dashboard.app.main()`은 page config 직후 gate를 호출한다. Gate는 현재 설정으로 cookie signature와 strict claims를 검증한다. 유효할 때만 DB path, gateway와 page dispatch를 실행한다. Token, credential, signing material은 `st.session_state`에 저장하지 않는다. 비민감 correlation ID만 session state에 둘 수 있다. -| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | -|---|---|---|---|---|---| -| `없음` | 없음 | 없음 | 해당 없음 | 해당 없음 | 인증 boolean 제거 외 금융 상태 무변경 | +## 호환성·순서·재시도 -## 호환성 - -- 유지할 기존 호출 방식: 인증 성공 후 기존 `page` query parameter와 Dashboard page dispatch를 유지한다. -- 버전 변경 정책: 환경변수 이름 또는 session 계약 변경은 requirement revision과 본 문서를 함께 변경한다. -- 폐기 정책: 두 환경변수는 대체 계약 승인 전까지 폐기하지 않는다. -- 재시도 계약: 설정 오류는 설정 수정 후 rerun, credential 오류는 각 제출마다 독립 재시도한다. -- 중복 처리 계약: 반복 실패는 잠금을 만들지 않으며 각 실패에 최소 1초 지연을 적용한다. 로그아웃 재시도는 금융 상태를 변경하지 않는다. -- 순서 보장: 설정 판정 → session 판정 → 로그인 또는 로그아웃 → 인증 성공 후에만 DB 경로 취득과 page dispatch 순서를 유지한다. +- 기존 `page` query parameter와 page dispatch는 유효 인증 이후 그대로 유지한다. +- REQ-001 revision 2.0이 revision 1.0의 session-only boolean 계약을 signed-cookie 계약으로 대체한다. +- 설정 판정 → cookie 검증 → DB path → page dispatch 순서를 유지한다. +- password, signing secret 또는 관리자 ID 변경은 기존 token을 거부한다. +- 반복 실패는 application lock을 만들지 않으며 각 credential 실패에 최소 1초 지연을 적용한다. ## 예시 -### config-example — 배포 환경변수 이름 - -- 대상 인터페이스: `dashboard-auth-config` - -#### 호출 - ```text -JARCA_DASHBOARD_ADMIN_ID= -JARCA_DASHBOARD_ADMIN_PASSWORD= -``` - -#### 결과 - -```text -유효한 두 값: 로그인 form 제공 -누락 또는 빈 값: 일반 설정 오류와 Dashboard 차단 -``` - -### login-failure-example — 잘못된 credential - -- 대상 인터페이스: `dashboard-login` - -#### 호출 - -```text -관리자 ID와 password 제출 -``` - -#### 결과 - -```text -최소 1초 후 "관리자 ID 또는 비밀번호가 올바르지 않습니다." +POST /api/auth/login (same-origin HTTPS) +admin_id=&admin_password=&remember_me=on +→ 303 / +→ Set-Cookie: __Host-jarca_dashboard_auth=; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000 ``` ## 검증 -- 정상 계약: test-only credential과 synthetic DB를 사용한 AppTest 로그인 후 기존 페이지 렌더링 -- 실패 계약: 누락·빈 값·read error와 잘못된 ID·password의 일반 오류·무변경 검증 -- 경계 계약: query parameter 우회 금지, 새 session 재인증, 로그아웃 직후 차단, credential session 제거 -- 구현·스키마·예시 정합성: `dashboard/authentication.py`, `dashboard/app.py`, `.env.example`, `docker-compose.yml`과 인증 test를 함께 검토하며 DB migration이 없음을 확인 +- Token: `tests/test_dashboard_persistent_authentication.py` +- HTTP endpoint/cookie: `tests/test_dashboard_auth_server.py` +- Gate/DB ordering: `tests/test_dashboard_authentication_app.py` +- HTTPS attributes/reconnect/logout: `scripts/persistent_auth_http_evidence.py` +- 실제 secret/token 값은 test output과 evidence에 남기지 않는다. diff --git a/docs/project-structure.md b/docs/project-structure.md index 7f6c81e..d5dfcc5 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -1,101 +1,86 @@ -# Dashboard 관리자 인증 프로젝트 구조 +# Project Structure — Dashboard Persistent Authentication -## 목적과 경계 +## 목적과 범위 -Dashboard 진입점 앞의 인증 경계, 인증 구현, 기존 page view, test와 배포 설정의 책임 및 단방향 의존을 정의한다. +Issue #6의 signed persistent authentication을 기존 Dashboard page 구조와 분리하여 배치하는 규칙을 정의한다. DB schema와 MCP 구조는 변경하지 않는다. -- 포함 경계: `dashboard/`, Dashboard 인증 test, 환경 예시, Compose와 인증 설계 문서 -- 제외 경계: MCP adapter·server 인증, 가계부 application·SQLite schema 변경 +## Source 영역 -## 디렉터리 구조 +| 경로 | 책임 | 주요 공개 요소 | 금지 의존성 | +|---|---|---|---| +| `dashboard/server.py` | Streamlit ASGI app과 인증 route 조립 | `app` | DB/gateway 직접 접근 | +| `dashboard/auth_server.py` | same-origin login/logout HTTP response와 hardened cookie | `create_auth_routes()` | page view, SQLite | +| `dashboard/authentication.py` | 설정, credential 비교, token 발급·검증, Streamlit gate | `require_dashboard_authentication()` | 금융 DB/gateway | +| `dashboard/app.py` | gate 성공 후 기존 page dispatch | `main()` | token signing 세부 구현 | +| `dashboard/common.py` | 기존 DB/UI helper | 기존 API | 인증 결정 | +| `dashboard/page_views/` | 기존 금융 화면 | `page_*` | 인증 transport | -### dashboard-runtime — Dashboard runtime +`dashboard.server:app`이 운영 진입점이다. 인증 endpoint는 Streamlit 1.58의 public ASGI `App(routes=...)` 확장 지점에 같은 process로 결합한다. -```text -dashboard/ -├── app.py -├── authentication.py -├── common.py -└── page_views/ -``` +## Test 영역 -| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 | -|---|---|---|---|---|---|---| -| `dashboard/app.py` | Streamlit 진입점 | page config 후 인증 gate를 호출하고 인증된 요청만 기존 Dashboard로 dispatch | `main()` | 인증 정책 세부 구현을 포함하지 않음 | DB 경로 취득 전 gate 유지 | 수동 | -| `dashboard/authentication.py` | 인증 모듈 | 환경변수 설정, constant-time 검증, 실패 지연, session·로그아웃과 event log | `require_dashboard_authentication()` | 가계부 DB·gateway·page view에 의존하지 않음 | credential 비보존과 fail-closed 유지 | 수동 | -| `dashboard/common.py` | Dashboard 공통 지원 | DB 경로, UI와 gateway helper | 기존 함수 | 인증 상태를 결정하지 않음 | 인증된 dispatch 이후 사용 | 수동 | -| `dashboard/page_views/` | 화면 모듈 | 기존 금융 조회·쓰기·import UI | 각 `page_*` 함수 | 인증 구현에 역방향 의존하지 않음 | `app.py`의 인증된 dispatch로만 공개 | 수동 | +| 경로 | 검증 책임 | +|---|---| +| `tests/test_dashboard_authentication.py` | 기존 credential 비교·지연·safe log | +| `tests/test_dashboard_persistent_authentication.py` | token claims, TTL, tamper, rotation, malformed input | +| `tests/test_dashboard_auth_server.py` | login/logout response, cookie 속성, same-origin, fail-closed | +| `tests/test_dashboard_authentication_app.py` | Streamlit gate, DB 이전 차단, cookie 복원·rotation | +| `tests/test_dashboard_app.py` | 인증 이후 기존 핵심 page regression | +| `scripts/persistent_auth_http_evidence.py` | 실제 loopback HTTPS cookie/reconnect/logout evidence | -### dashboard-tests — Dashboard 인증·회귀 test +Test는 synthetic DB와 test-only credential/secret만 사용하고 token·secret 원문을 출력하지 않는다. -```text -tests/ -├── test_dashboard_authentication.py -├── test_dashboard_authentication_app.py -└── test_dashboard_app.py -``` +## Config·배포·문서 영역 -| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 | -|---|---|---|---|---|---|---| -| `tests/test_dashboard_authentication.py` | 단위 test | 설정·constant-time 비교·지연·비민감 log 검증 | pytest | 실제 credential과 운영 DB 금지 | test-only 값 사용 | 수동 | -| `tests/test_dashboard_authentication_app.py` | Streamlit 통합 test | fail-closed, 우회 금지, 1초 지연, session과 logout 검증 | pytest AppTest | synthetic fixture만 사용 | acceptance criterion 대응 유지 | 수동 | -| `tests/test_dashboard_app.py` | 기존 기능 회귀 test | 인증 후 핵심 page 호환성 검증 | pytest AppTest | synthetic fixture만 사용 | 로그인 후 기존 assertion 유지 | 수동 | - -### deployment-docs — 배포 설정과 계약 문서 - -```text -.env.example -docker-compose.yml -README.md -docs/ -├── requirements/req-001_dashboard-administrator-authentication.md -├── interface-contract.md -├── project-structure.md -└── runtime-flow.md -``` - -| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 | -|---|---|---|---|---|---|---| -| `.env.example` | 설정 예시 | credential 변수 이름과 용도 | `.env` 복사 | 실제 secret 값 금지 | 빈 값만 제공 | 수동 | -| `docker-compose.yml` | local deployment | Dashboard container로 host secret 환경변수 전달 | Compose service | MCP service credential 계약 변경 금지 | required-variable interpolation으로 누락 시 시작 거부 | 수동 | -| `README.md` | 운영 안내 | 설정, fail-closed, 보호 범위와 rate-limit 제약 | 사용자 문서 | 실제 secret 값 금지 | 구현과 함께 갱신 | 수동 | -| `docs/` 인증 문서 | 정본 계약 | 요구사항·인터페이스·구조·흐름 | Markdown | 데이터 모델 문서 생성 없음 | harness template 구조 유지 | gitea-agent impl workflow | +| 경로 | 책임 | +|---|---| +| `pyproject.toml`, `uv.lock` | `itsdangerous`, `starlette`, `uvicorn` 직접 dependency 고정 | +| `.env.example` | 세 environment 변수의 이름만 제공 | +| `docker-compose.yml` | signing secret 전달과 ASGI server command | +| `deploy/k8s/base/deployment.yaml` | Kubernetes Secret `signing-secret` 전달과 ASGI command | +| `README.md`, `deploy/k8s/README.md` | HTTPS, rotation, expiry, rate-limit, logout 제약 | +| `docs/requirements/req-001_dashboard-administrator-authentication.md` | Issue #1·#6 통합 canonical 승인 명세 | +| `docs/interface-contract.md` | HTTP/cookie/token 계약 | +| `docs/runtime-flow.md` | 발급·복원·실패·logout 순서 | ## 의존 방향 -| 출발 영역 | 대상 영역 | 허용 여부 | 규칙 | 공유 계약 위치 | -|---|---|---|---|---| -| `dashboard-app` | `dashboard-authentication` | 허용 | DB 경로와 page dispatch보다 먼저 gate 호출 | `docs/runtime-flow.md` | -| `dashboard-authentication` | `dashboard-common` | 금지 | 인증은 가계부 DB·gateway와 독립 | `docs/interface-contract.md` | -| `dashboard-authentication` | `dashboard-page-views` | 금지 | 인증 모듈은 page view를 import·호출하지 않음 | `docs/interface-contract.md` | -| `dashboard-app` | `dashboard-common` | 인증 후 허용 | gate가 참일 때만 DB/UI helper 사용 | `docs/runtime-flow.md` | -| `dashboard-app` | `dashboard-page-views` | 인증 후 허용 | gate가 참일 때만 선택 page dispatch | `docs/runtime-flow.md` | -| `dashboard-page-views` | `dashboard-authentication` | 금지 | page별 인증 분기나 역방향 의존을 만들지 않음 | `docs/requirements/req-001_dashboard-administrator-authentication.md` | -| `dashboard-authentication` | `mcp-adapter` | 금지 | 이번 인증은 MCP를 보호하지 않음 | `README.md` | +```text +browser + ├─ POST /api/auth/login|logout → dashboard.auth_server + └─ Streamlit handshake cookie → dashboard.authentication + +dashboard.server → dashboard.auth_server + Streamlit App(dashboard/app.py) +dashboard.app → dashboard.authentication → itsdangerous + → (인증 성공 후) dashboard.common/page_views +``` + +`auth_server`와 `authentication`은 금융 DB를 import하지 않는다. Page module은 cookie와 token module을 직접 호출하지 않는다. + +## 상태·데이터 배치 + +- Credential/signing secret: process environment에서 request/rerun 동안만 참조 +- Auth token: browser의 `Secure`/`HttpOnly` cookie에만 저장 +- Correlation ID: 비민감 값으로 `st.session_state`에 저장 가능 +- 금융 데이터: 기존 SQLite +- Token registry/revocation table/migration: 생성하지 않음 + +따라서 `docs/data-model.md`는 추가하지 않는다. ## 변경 규칙 -| 구분 | 규칙 | -|---|---| -| 새 모듈·파일 배치 | Dashboard 공통 인증은 `dashboard/authentication.py`, page 기능은 기존 `dashboard/page_views/`에 둔다. | -| 이름·패키지 | 환경변수는 `JARCA_DASHBOARD_` prefix, 비공개 session key는 `_jarca_dashboard_` prefix를 사용한다. | -| 소스·테스트 대응 | 인증 pure helper는 `test_dashboard_authentication.py`, Streamlit 경계는 `test_dashboard_authentication_app.py`, 기존 page 호환성은 `test_dashboard_app.py`에서 검증한다. | -| 생성 산출물 | DB schema·migration·credential file을 생성하지 않는다. Requirement와 설계 문서만 `docs/`에 추가한다. | +- 인증 환경변수는 `JARCA_DASHBOARD_` prefix를 사용한다. +- Cookie name은 host-only scope를 강제하는 `__Host-` prefix를 사용한다. +- Token schema/TTL/cookie attributes 변경은 REQ-001과 interface contract를 함께 변경한다. +- Secret, token, cookie header, credential 값을 source·fixture·log·문서에 기록하지 않는다. +- Streamlit private Tornado/WebSocket internals나 client-side cookie component를 사용하지 않는다. ## 검증 -- 구조 검사 명령: +```text +uv run --extra dashboard --extra dev pytest +uv run --extra dashboard --extra dev ruff check dashboard tests scripts +uv run python -m compileall -q dashboard tests scripts +``` - ```text - python -c "from pathlib import Path; required=['dashboard/app.py','dashboard/authentication.py','tests/test_dashboard_authentication.py','tests/test_dashboard_authentication_app.py']; assert all(Path(p).is_file() for p in required)" - ``` - -- 구조 검사 기대 결과: exit code 0이며 모든 인증 source·test 경로가 존재한다. -- 정적 분석 명령: - - ```text - uv run --extra dashboard --extra dev ruff check dashboard tests - ``` - -- 정적 분석 기대 결과: 새 syntax·undefined-name 오류가 없다. -- 문서와 실제 트리의 정합성: base-to-worktree 변경 파일을 본 문서의 세 영역과 대조하고 `docs/data-model.md`와 DB migration이 생성되지 않았는지 확인한다. +Diff에서 DB migration과 `docs/data-model.md`가 추가되지 않았는지 확인한다. diff --git a/docs/requirements/req-001_dashboard-administrator-authentication.md b/docs/requirements/req-001_dashboard-administrator-authentication.md index ffeb662..3a1237f 100644 --- a/docs/requirements/req-001_dashboard-administrator-authentication.md +++ b/docs/requirements/req-001_dashboard-administrator-authentication.md @@ -3,7 +3,7 @@ "schema": "gitea-agent.requirement/v1", "id": "REQ-001", "title": "Dashboard 관리자 인증", - "revision": "1.0", + "revision": "2.0", "status": "approved", "depends_on": [], "applies_to": ["dashboard"], @@ -15,479 +15,145 @@ ## 1. 목적 -Streamlit Dashboard의 모든 금융 데이터 조회·수정·import 기능 앞에 단일 관리자 인증 경계를 두고, 인증되지 않았거나 인증 설정이 유효하지 않은 요청을 fail-closed한다. +Streamlit Dashboard의 모든 금융 데이터 조회·수정·import 기능 앞에 단일 관리자 인증 경계를 두고, 인증되지 않았거나 인증 설정이 유효하지 않은 요청을 fail-closed한다. 인증 성공 후에는 server-signed `Secure`/`HttpOnly` cookie로 같은 browser profile의 새 연결과 새 session에서 인증을 정해진 절대 만료까지 복원한다. ## 2. 배경 -Dashboard는 SQLite 가계부를 직접 읽고 쓰며 거래 수정, 계좌 변경, PDF import와 import rollback을 제공한다. 기존 진입점은 인증 없이 DB 경로를 얻고 page view를 dispatch했으므로, 외부 노출 시 금융 데이터와 운영 기능을 보호할 애플리케이션 수준의 경계가 필요하다. +Revision 1.0은 Issue #1에서 단일 관리자 credential과 browser-session-only 인증 경계를 도입했다. Issue #6은 sidebar query-link 이동과 browser 재접속에서 반복 로그인이 발생하는 문제를 해결하기 위해 동일한 인증 기능의 session·logout·배포 계약을 persistent cookie 방식으로 확장한다. 새 기능 ID가 아니라 본 canonical 요구사항의 revision으로 관리한다. ## 3. 범위 ### 포함 -- 모든 Dashboard 페이지의 공통 관리자 로그인과 로그아웃 -- 환경변수 기반 단일 관리자 credential 검증 -- 인증 전 DB·gateway·page dispatch 차단 -- browser session 범위의 인증 상태 -- 실패 로그인 최소 지연, 일반 오류와 인증 event 관찰 가능성 -- Compose·환경 예시·운영 문서의 secret 주입 계약 +- 모든 Dashboard 페이지 앞의 공통 관리자 인증과 인증 전 DB 차단 +- 환경변수 기반 관리자 ID/password 및 독립적인 signing secret +- constant-time credential 비교, 일반 오류, 실패당 최소 1초 지연 +- 동일 Dashboard ASGI process의 server-side login/logout HTTP endpoint +- server-signed `Secure`/`HttpOnly` cookie 발급·검증·삭제 +- 기본 24시간, 선택 시 30일인 로그인 성공 시각 기준 절대 만료 +- sidebar 페이지 이동, 새 탭, browser 종료 후 재접속과 새 Streamlit session 복원 +- password/signing-secret/admin-ID rotation에 따른 기존 token 무효화 +- credential·secret·token 비노출과 인증 event 관찰 가능성 +- Compose, Kubernetes Secret, HTTPS와 운영 제약 문서화 ### 제외 -- 다중 사용자, 역할 기반 권한과 사용자별 데이터 분리 -- 회원가입, 비밀번호 찾기와 credential 관리 UI -- OIDC, SSO, 외부 IdP와 persistent cookie -- SQLite schema와 가계부 도메인 모델 변경 -- MCP endpoint 인증, TLS, Ingress와 네트워크 경계 전체 구현 +- 다중 사용자, 회원가입, RBAC, 사용자별 데이터 분리 +- OAuth/OIDC, SSO, 외부 IdP, MFA, CAPTCHA, 계정 잠금 +- 외부 인증 proxy/gateway 또는 외부 session middleware로 인증 책임 이전 +- sliding expiration 또는 복원 시 만료 재설정 +- DB-backed token/session registry, 개별 원격 session 폐기 UI +- MCP endpoint 인증과 MCP transport 변경 +- SQLite 금융 schema와 데이터 모델 변경 +- 다른 browser profile·기기 간 로그인 공유 ## 4. 용어와 기준 -- Dashboard: `dashboard/app.py`를 진입점으로 실행되는 Streamlit 애플리케이션 -- 인증 전: 현재 Streamlit browser session의 인증 상태가 참이 아닌 상태 -- credential source: 배포 secret이 주입하는 `JARCA_DASHBOARD_ADMIN_ID`와 `JARCA_DASHBOARD_ADMIN_PASSWORD` 환경변수 -- browser session: Streamlit이 `st.session_state`를 격리하는 단일 클라이언트 session -- 정본 근거: Gitea Issue `#1`과 source revision `069d4c16fd84e871e8cd9dc8d9ae39b4fce4a6c6` +- **persistent auth token**: version, 관리자 subject, 발급 시각, 절대 만료, token ID만 포함하고 서버가 서명한 bearer token +- **remember me**: 기본 미선택 checkbox이며 token TTL을 24시간에서 30일로 선택 +- **rotation**: 관리자 password 또는 signing secret을 교체하여 기존 token 서명 검증을 실패시키는 운영 절차 +- **canonical 근거**: Issue #1, Issue #6, source revisions `069d4c16fd84e871e8cd9dc8d9ae39b4fce4a6c6` 및 `7bea8b368e02e98afd4b2dc463fb270b0e6eb297` ## 5. 요구사항 ### REQ-001.01 — 공통 인증 경계 -Dashboard는 어떤 DB 경로 취득, page dispatch, DB-backed 렌더링, gateway 호출, upload 또는 rollback 처리보다 먼저 인증을 판정해야 한다. 인증 전에는 로그인 화면만 렌더링해야 한다. +Dashboard는 DB path 취득, page dispatch, gateway 호출, upload, rollback 또는 DB-backed 렌더링보다 먼저 현재 설정과 signed cookie를 검증해야 한다. 기존 session boolean만으로 만료·rotation 검증을 우회해서는 안 된다. 실패 시 로그인 화면만 렌더링한다. -**영향 범위** +### REQ-001.02 — 설정과 fail-closed -- 인터페이스: Streamlit Dashboard 진입점과 모든 page view -- 영속성: 인증 전에는 SQLite 읽기·쓰기를 수행하지 않음 -- 후속 사용 주체: Dashboard 사용자와 page view -- 마이그레이션: 없음 -- 호환성: 인증 성공 후 기존 page query parameter와 페이지 기능 유지 -- 배포 전제 조건: 관리자 환경변수 두 개가 유효하게 주입됨 - -### REQ-001.02 — Credential 설정과 fail-closed - -애플리케이션은 `JARCA_DASHBOARD_ADMIN_ID`와 `JARCA_DASHBOARD_ADMIN_PASSWORD`만 credential source로 읽어야 한다. Streamlit secrets 또는 다른 fallback을 사용하지 않으며, 어느 값이든 누락·빈 값·비문자열·읽기 오류이면 일반 설정 오류를 표시하고 인증을 허용하지 않아야 한다. - -**영향 범위** - -- 인터페이스: process environment -- 영속성: credential을 저장하지 않음 -- 후속 사용 주체: Dashboard 인증 경계 -- 마이그레이션: 없음 -- 호환성: 두 환경변수를 설정하지 않은 기존 Dashboard 배포는 의도적으로 fail-closed -- 배포 전제 조건: 배포 secret의 환경변수 주입 +Credential source는 `JARCA_DASHBOARD_ADMIN_ID`, `JARCA_DASHBOARD_ADMIN_PASSWORD`뿐이며 signing source는 `JARCA_DASHBOARD_SIGNING_SECRET`뿐이다. 누락·빈 값·비문자열·read error 또는 UTF-8 32 byte 미만 signing secret은 일반 설정 오류로 fail-closed한다. Streamlit secrets나 fallback을 사용하지 않는다. ### REQ-001.03 — Credential 검증과 비노출 -제출된 관리자 ID와 password는 모두 비교하되 password를 포함한 검증은 constant-time primitive를 사용해야 한다. Credential 원문·verifier는 화면, 일반 오류, traceback, URL/query parameter, application log 또는 로그인 완료 후 `st.session_state`에 남지 않아야 한다. - -**영향 범위** - -- 인터페이스: 관리자 ID·password 입력 form -- 영속성: 없음 -- 후속 사용 주체: 현재 Dashboard session -- 마이그레이션: 없음 -- 호환성: 없음 -- 배포 전제 조건: 평문 password secret은 repository가 아닌 secret store에서 제공 +제출 ID와 password는 모두 `hmac.compare_digest`로 비교한다. Credential, signing secret, signing-key material, token, raw cookie와 `Set-Cookie` 값은 UI, traceback, URL/query, application log, 문서 또는 `st.session_state`에 저장하지 않는다. 비민감 correlation ID만 session state에 둘 수 있다. ### REQ-001.04 — 실패 로그인 응답 -잘못된 ID 또는 password의 각 제출은 제출 처리 시작부터 일반 오류 응답 완료까지 최소 1초가 걸려야 한다. 두 실패는 동일한 오류를 표시하고 DB·gateway·상태 변경을 수행하지 않아야 한다. 성공 로그인에는 실패 지연을 적용하지 않으며 애플리케이션 수준 잠금은 두지 않는다. +잘못된 ID 또는 password의 각 제출은 처리 시작부터 응답까지 최소 1초가 걸려야 한다. 두 실패는 동일한 오류를 표시하고 cookie·DB·gateway·금융 상태를 변경하지 않는다. 성공에는 실패 지연을 적용하지 않으며 application lock은 두지 않는다. -**영향 범위** +### REQ-001.05 — Persistent session과 절대 만료 -- 인터페이스: 로그인 제출과 오류 응답 -- 영속성: 없음 -- 후속 사용 주체: 미인증 Dashboard 사용자 -- 마이그레이션: 없음 -- 호환성: 없음 -- 배포 전제 조건: 새 session·병렬 요청 우회를 제한할 별도 배포 경계 rate limiting 권고 +정상 로그인도 24시간, 기본 미선택 `로그인 유지`를 선택한 로그인은 30일의 절대 만료를 사용한다. 발급 시 `iat`와 `exp`를 고정하고 page 이동, rerun, 활동, 새 tab, browser 재시작 또는 인증 복원으로 token이나 만료를 갱신하지 않는다. 같은 browser profile의 유효 cookie는 새 Streamlit session에서 인증을 복원한다. -### REQ-001.05 — Session과 로그아웃 +### REQ-001.06 — Server-side adapter와 cookie -인증 성공 상태는 현재 Streamlit browser session에서만 유지해야 한다. 같은 session의 rerun은 재로그인 없이 Dashboard를 사용할 수 있고, 명시적 로그아웃은 즉시 인증 상태를 폐기해야 하며 새 session은 다시 인증해야 한다. +Streamlit 1.58의 ASGI `App` custom route를 사용해 동일 Dashboard process의 실제 HTTP response에서 cookie를 발급·삭제한다. JavaScript/localStorage/client-side cookie component로 token을 저장하지 않는다. Cookie는 `__Host-` 이름, `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`, no `Domain`이며 `Max-Age`는 token TTL과 같다. -**영향 범위** +### REQ-001.07 — Token 서명·claim·rotation -- 인터페이스: `st.session_state`, 로그아웃 button -- 영속성: server-side 영속 session 없음 -- 후속 사용 주체: 현재 browser session -- 마이그레이션: 없음 -- 호환성: browser 재시작·새 session의 자동 로그인 없음 -- 배포 전제 조건: 없음 +Token payload는 `v`, 현재 관리자 ID `sub`, server `iat`, `exp`, 무작위 `jti`만 허용한다. Signing key는 독립 signing secret과 현재 관리자 password를 domain-separated HMAC-SHA256으로 결합해 파생하고 SHA-256 signer를 사용한다. Signature를 검증한 뒤 exact schema/type, subject, 허용 TTL, 미래 발급 시각과 server-side 만료를 검증한다. Password, signing secret 또는 관리자 ID 변경은 기존 token을 거부해야 한다. -### REQ-001.06 — 보호 범위와 기존 기능 호환성 +### REQ-001.08 — Login/logout HTTP 계약 -인증 성공 후 요약, 자산/마감, 거래내역, 확인필요 큐, 미분류, 분류 규칙, 카테고리, 계좌와 Import를 포함한 기존 Dashboard 페이지를 사용할 수 있어야 한다. 이 인증은 Dashboard만 보호하며 MCP endpoint가 인증됐다고 간주해서는 안 된다. +Login endpoint는 trusted ASGI request scheme과 Origin이 모두 same-origin HTTPS인 form POST만 받고 성공 시 hardened cookie와 root redirect를 반환한다. Logout endpoint도 같은 HTTPS 검증 후 동일 name/path/security 속성과 `Max-Age=0`으로 browser cookie를 삭제한다. Logout은 금융 DB를 변경하지 않는다. Stateless token 복사본은 개별 폐기하지 않으며 전체 강제 무효화는 password/signing-secret rotation으로 수행한다. -**영향 범위** +### REQ-001.09 — 보호 범위와 기존 기능 호환성 -- 인터페이스: 모든 Dashboard page view -- 영속성: 기존 SQLite schema와 데이터 계약 유지 -- 후속 사용 주체: 인증된 Dashboard 사용자 -- 마이그레이션: 없음 -- 호환성: 인증 후 기존 Dashboard 동작 유지 -- 배포 전제 조건: MCP는 별도의 네트워크 또는 후속 인증 요구사항으로 보호 +인증 후 기존 page 목록, `page` query parameter, sidebar 이동, gateway와 MCP 동작은 유지한다. 인증은 Dashboard만 보호하고 MCP endpoint 인증을 의미하지 않는다. 금융 SQLite schema/migration은 변경하지 않는다. -### REQ-001.07 — 배포 설정과 운영 제약 +### REQ-001.10 — 배포·운영 제약 -환경 예시, Compose와 운영 문서는 두 credential 환경변수의 이름·용도·fail-closed 동작을 정의하되 실제 값을 저장하지 않아야 한다. 운영 문서는 1초 지연에 애플리케이션 잠금이 없고 새 session·병렬 요청으로 우회 가능하므로 배포 경계 rate limiting이 필요함을 명시해야 한다. +`.env.example`, Compose와 Kubernetes는 세 secret setting 이름만 정의하고 실제 값을 저장하지 않는다. 공개 browser origin은 HTTPS여야 한다. TLS proxy를 사용하는 배포는 실제 proxy source IP/CIDR만 Uvicorn forwarded-header allowlist에 넣고 Host/Origin을 보존하며 `X-Forwarded-Proto: https`를 설정한다. 기본 allowlist는 loopback으로 fail-closed하고 wildcard trust를 금지한다. 최소 1초 delay는 병렬 brute-force를 막지 않으므로 외부 노출 경계에 rate limiting이 필요하다. 모든 verifier replica는 동일 secret과 token 규칙을 사용해야 한다. -**영향 범위** +### REQ-001.11 — 관찰 가능성 -- 인터페이스: `.env`, Compose environment와 운영 문서 -- 영속성: repository에 credential 값 저장 금지 -- 후속 사용 주체: 배포 운영자 -- 마이그레이션: 기존 Dashboard 배포에 secret 주입 필요 -- 호환성: MCP container의 설정과 포트는 변경하지 않음 -- 배포 전제 조건: secret manager 또는 동등한 안전한 환경변수 주입 +Event log는 event name과 비민감 correlation ID만 기록한다. 허용 event에는 설정 오류, 인증 필요, token 수락, login 성공/실패/reject, logout이 포함된다. ID/password/secret/token/cookie/request body와 구체적인 검증 실패 원인은 기록하지 않는다. -### REQ-001.08 — 인증 관찰 가능성 +## 6. 인터페이스 요약 -설정 오류, 로그인 성공·실패와 로그아웃은 session-local correlation ID와 event 이름만 application log에 기록해야 한다. 관리자 ID, password, verifier, 제출 값과 query parameter를 진단 속성으로 기록하지 않아야 한다. +- `POST /api/auth/login`: `admin_id`, `admin_password`, optional `remember_me=on` +- `POST /api/auth/logout`: 입력 credential 없음 +- Cookie: `__Host-jarca_dashboard_auth` +- 설정: 세 `JARCA_DASHBOARD_*` environment variable +- 상세 계약: `docs/interface-contract.md` -**영향 범위** +## 7. 오류 계약 -- 인터페이스: application log -- 영속성: correlation ID는 현재 session에만 저장 -- 후속 사용 주체: 운영자 -- 마이그레이션: 없음 -- 호환성: 기존 금융 데이터 log 계약에 영향 없음 -- 배포 전제 조건: log 수집기가 민감정보 비수집 계약을 유지 +| 조건 | 사용자 결과 | Cookie | DB/gateway/page | +|---|---|---|---| +| 설정 invalid | 일반 설정 오류 | 발급 없음 | 차단 | +| credential invalid | 최소 1초 후 일반 인증 오류 | 발급 없음 | 차단 | +| request malformed/cross-site | 400/403 | 발급 없음 | 차단 | +| cookie 없음·malformed·tampered·expired | 로그인 화면 | 수락 없음 | 차단 | +| subject/TTL/time/rotation 불일치 | 로그인 화면 | 수락 없음 | 차단 | +| logout | root redirect | deletion | 금융 상태 무변경 | -## 6. 제약과 불변조건 +## 8. 수용 기준 -- 인증 전과 설정 오류·잘못된 credential 상태에서는 금융 DB와 gateway 상태가 변하지 않는다. -- Credential은 환경변수에서만 읽고 repository 문서·설정·test fixture에는 test-only 값 외의 실제 값을 기록하지 않는다. -- 인증 상태는 boolean 결과만 저장하며 credential 원문 또는 verifier를 session에 보존하지 않는다. -- 잘못된 ID와 password는 동일한 사용자 오류와 최소 지연을 사용한다. -- Dashboard와 MCP의 인증 경계를 혼동하지 않는다. -- 실제 운영 DB 또는 실제 credential을 test에 사용하지 않는다. +- [x] AC-001 인증 전 모든 DB/gateway/page 기능이 차단된다. +- [x] AC-002 세 environment setting invalid 상태는 fail-closed한다. +- [x] AC-003 ID/password를 constant-time 비교하고 일반 오류와 최소 1초 지연을 적용한다. +- [x] AC-004 정상 login은 24시간 absolute token/cookie를 발급한다. +- [x] AC-005 기본 미선택 remember-me login은 30일 absolute token/cookie를 발급한다. +- [x] AC-006 Cookie는 `__Host-`, Secure, HttpOnly, SameSite=Lax, Path=/, no Domain이다. +- [x] AC-007 일반 요청·page 이동·복원은 token을 재발급하거나 만료를 연장하지 않는다. +- [x] AC-008 유효 cookie가 새 Streamlit session과 sidebar query page에서 인증을 복원한다. +- [x] AC-009 malformed, oversized, tampered, expired, schema-invalid token은 DB 전에 거부된다. +- [x] AC-010 password, signing secret 또는 관리자 ID rotation이 기존 token을 무효화한다. +- [x] AC-011 logout은 browser cookie를 삭제하고 금융 상태를 변경하지 않는다. +- [x] AC-012 credential, secret, token이 session state·URL·log·문서에 노출되지 않는다. +- [x] AC-013 same-origin HTTPS POST가 아닌 login/logout 요청은 거부된다. +- [x] AC-014 인증 후 기존 Dashboard page와 MCP behavior가 유지된다. +- [x] AC-015 실제 loopback HTTPS와 Streamlit WebSocket에서 cookie 발급·복원·삭제를 검증한다. +- [x] AC-016 금융 DB schema와 data model migration을 추가하지 않는다. +- [x] AC-017 HTTPS, rate-limit, rotation과 stateless logout 제약이 운영 문서에 기록된다. -## 7. 인수 기준 +## 9. 검증 매핑 -### REQ-001.01-AC01 — 미인증 진입 차단 +- Credential/delay/log: `tests/test_dashboard_authentication.py` +- Token/TTL/tamper/rotation: `tests/test_dashboard_persistent_authentication.py` +- HTTP/cookie/logout: `tests/test_dashboard_auth_server.py` +- Streamlit gate/DB ordering/page 복원: `tests/test_dashboard_authentication_app.py`, `tests/test_dashboard_app.py` +- 실제 HTTPS/WebSocket evidence: `scripts/persistent_auth_http_evidence.py`, `docs/evidence/issue-6-persistent-auth-https.json` +- 전체 regression: `uv run --extra dashboard --extra dev pytest` +- 정적 검사: `uv run --extra dashboard --extra dev ruff check dashboard tests scripts` -**판정 기준** +## 10. 추적성 -미인증 session은 로그인 화면만 표시하며 `get_db_path()`, page dispatch, DB·gateway·upload·rollback을 호출하지 않는다. - -**검증** - -`tests/test_dashboard_authentication_app.py`에서 인증 gate가 거부될 때 DB 경로 함수가 호출되지 않고 Dashboard 본문이 렌더링되지 않는지 검증한다. - -**검증 분류** - -- 수준: 통합 -- 백엔드: Streamlit AppTest와 monkeypatch -- 케이스: 설정 누락, 미인증, 각 Dashboard query page - -**상태 불변조건** - -SQLite와 gateway 상태 무변경 - -### REQ-001.01-AC02 — Query parameter 우회 금지 - -**판정 기준** - -`page=Import` 등 유효한 page query parameter를 제공해도 미인증 session은 해당 page를 렌더링하지 않는다. - -**검증** - -Streamlit AppTest로 Import query parameter를 설정하고 로그인 화면만 존재하는지 확인한다. - -**검증 분류** - -- 수준: 통합 -- 백엔드: Streamlit AppTest -- 케이스: `page=Import` - -**상태 불변조건** - -Upload와 rollback 미호출 - -### REQ-001.02-AC01 — 환경변수 전용 설정 - -**판정 기준** - -두 exact 환경변수가 있을 때만 설정을 생성하며 다른 이름이나 Streamlit secrets 값은 사용하지 않는다. - -**검증** - -`tests/test_dashboard_authentication.py`에서 환경 mapping과 무관한 secrets 형태 값을 함께 제공해도 exact 환경변수 값만 선택하는지 검증한다. - -**검증 분류** - -- 수준: 단위 -- 백엔드: pytest -- 케이스: 두 값 존재, 대체 source 존재 - -**상태 불변조건** - -Credential source 고정 - -### REQ-001.02-AC02 — 설정 오류 fail-closed - -**판정 기준** - -누락·빈 값·읽기 오류는 동일한 일반 설정 오류로 종료되고 Dashboard 기능을 노출하지 않는다. - -**검증** - -단위 test와 AppTest로 각 오류 조합과 로그인 input 미노출을 검증한다. - -**검증 분류** - -- 수준: 단위·통합 -- 백엔드: pytest, Streamlit AppTest -- 케이스: ID 누락, password 누락, 빈 값, environment read error - -**상태 불변조건** - -기존 인증 boolean을 제거하고 DB 미호출 - -### REQ-001.03-AC01 — Constant-time 비교 - -**판정 기준** - -ID와 password 검증이 모두 `hmac.compare_digest`를 호출하며 한 필드 실패로 다른 비교를 생략하지 않는다. - -**검증** - -비교 함수를 계측한 단위 test에서 성공·실패 모두 두 비교가 수행되는지 확인한다. - -**검증 분류** - -- 수준: 단위 -- 백엔드: pytest monkeypatch -- 케이스: 정상 credential, 잘못된 두 필드 - -**상태 불변조건** - -Password 일반 동등 비교 금지 - -### REQ-001.03-AC02 — Credential 비노출 - -**판정 기준** - -로그인 완료 후 제출 ID와 password가 화면·log payload·query parameter 또는 session state에 남지 않는다. - -**검증** - -실패·성공 AppTest의 body와 session state 표현 및 log helper 단위 test를 검사한다. - -**검증 분류** - -- 수준: 단위·통합 -- 백엔드: pytest, Streamlit AppTest -- 케이스: 성공, 잘못된 ID, 잘못된 password, 설정 오류 - -**상태 불변조건** - -인증 boolean과 비민감 correlation ID만 session에 유지 가능 - -### REQ-001.04-AC01 — 실패별 최소 지연과 일반 오류 - -**판정 기준** - -잘못된 ID와 잘못된 password 각각의 AppTest 제출은 1초 전에 완료되지 않고 같은 오류 문자열을 표시한다. - -**검증** - -두 실패 입력을 별도 제출하고 monotonic elapsed time과 화면 오류를 측정한다. - -**검증 분류** - -- 수준: 통합 -- 백엔드: Streamlit AppTest, monotonic clock -- 케이스: 잘못된 ID, 잘못된 password - -**상태 불변조건** - -인증 false, DB·gateway 무변경 - -### REQ-001.04-AC02 — 성공 지연·잠금 없음 - -**판정 기준** - -정상 credential은 실패 지연 함수를 사용하지 않고 인증되며, 실패 횟수나 잠금 만료 상태를 생성하지 않는다. - -**검증** - -단위 test에서 실패 지연의 remaining-time 계산을 검증하고 source·session state 검사에서 failure counter·lock state가 없음을 확인한다. - -**검증 분류** - -- 수준: 단위·정적 -- 백엔드: pytest, source inspection -- 케이스: 정상 credential, 반복 실패 후 재시도 - -**상태 불변조건** - -애플리케이션 잠금 없음 - -### REQ-001.05-AC01 — Session 유지와 새 session 재인증 - -**판정 기준** - -로그인한 AppTest session은 rerun 후 기존 page를 유지하지만 새 AppTest session은 로그인 화면부터 시작한다. - -**검증** - -한 session의 로그인·rerun과 별도 session 생성을 연속 검증한다. - -**검증 분류** - -- 수준: 통합 -- 백엔드: Streamlit AppTest -- 케이스: 동일 session rerun, 새 session - -**상태 불변조건** - -Session 간 인증 상태 격리 - -### REQ-001.05-AC02 — 로그아웃 - -**판정 기준** - -로그아웃 click 직후 인증 boolean이 폐기되고 로그인 화면으로 돌아가며 Dashboard page가 표시되지 않는다. - -**검증** - -인증된 AppTest에서 로그아웃 button을 click하고 화면 전이를 확인한다. - -**검증 분류** - -- 수준: 통합 -- 백엔드: Streamlit AppTest -- 케이스: 인증 후 로그아웃 - -**상태 불변조건** - -인증 상태 폐기, 금융 데이터 무변경 - -### REQ-001.06-AC01 — 인증 후 기존 페이지 호환성 - -**판정 기준** - -Synthetic SQLite fixture와 test-only credential로 인증한 뒤 기존 핵심 페이지가 예외 없이 예상 내용을 렌더링한다. - -**검증** - -`tests/test_dashboard_app.py`에서 요약, 자산/마감, 분류 규칙, 확인필요 큐와 월정산을 인증 후 검증한다. - -**검증 분류** - -- 수준: 회귀 통합 -- 백엔드: Streamlit AppTest, synthetic SQLite -- 케이스: 기존 핵심 page 목록 - -**상태 불변조건** - -기존 SQLite schema와 gateway contract 유지 - -### REQ-001.06-AC02 — MCP 범위 분리 - -**판정 기준** - -Dashboard 인증 구현이 MCP server 진입점·transport·port를 변경하지 않고 운영 문서가 MCP 미보호 경계를 명시한다. - -**검증** - -Base-to-worktree diff와 README를 검사한다. - -**검증 분류** - -- 수준: 정적 -- 백엔드: Git diff, 문서 검사 -- 케이스: MCP source·Compose service - -**상태 불변조건** - -MCP 인증 상태 변경 없음 - -### REQ-001.07-AC01 — Secret 주입 계약 - -**판정 기준** - -`.env.example`과 Dashboard Compose service에 두 exact 변수 이름이 있고 repository에는 실제 값이 없다. - -**검증** - -설정 파일 parsing과 base-to-worktree secret scan을 수행한다. - -**검증 분류** - -- 수준: 정적 -- 백엔드: Python·YAML text validation -- 케이스: 환경 예시, Compose Dashboard environment - -**상태 불변조건** - -실제 credential 비보존 - -### REQ-001.07-AC02 — Rate limiting 운영 문서 - -**판정 기준** - -README가 최소 1초 실패 지연, 애플리케이션 잠금 부재, 새 session·병렬 요청 우회와 배포 경계 rate limiting 필요성을 함께 설명한다. - -**검증** - -README 필수 문구 정적 검사를 수행한다. - -**검증 분류** - -- 수준: 문서 -- 백엔드: text validation -- 케이스: Dashboard 운영 안내 - -**상태 불변조건** - -애플리케이션 내부 잠금 추가 금지 - -### REQ-001.08-AC01 — 비민감 인증 event - -**판정 기준** - -설정 오류, 성공, 실패와 로그아웃 event가 correlation ID와 함께 기록되고 credential은 log argument에 포함되지 않는다. - -**검증** - -Logger를 계측한 단위 test와 source 검사를 수행한다. - -**검증 분류** - -- 수준: 단위·정적 -- 백엔드: pytest monkeypatch -- 케이스: 인증 event logging - -**상태 불변조건** - -Log에 관리자 ID·password·verifier 없음 - -## 8. 오류 및 충돌 계약 - -- 설정 오류: `관리자 인증 설정을 확인할 수 없습니다.`만 표시하고 로그인 form과 Dashboard를 노출하지 않는다. -- Credential 오류: ID와 password 오류를 구분하지 않는 일반 오류를 최소 1초 후 표시한다. -- Issue 또는 base drift: 구현 workflow에서 자동 merge·rebase하지 않고 중단한다. -- 중복 로그인 제출: 각 제출을 독립적으로 검증하며 성공 후 현재 session의 인증 boolean만 유지한다. -- 로그아웃 중복 요청: 인증 상태가 없으면 로그인 화면을 유지하고 금융 상태를 변경하지 않는다. - -## 9. 데이터 및 인터페이스 계약 - -- 입력: 환경변수 두 개, 로그인 form의 ID·password, 로그아웃 button -- 출력: 로그인 화면, 일반 설정/credential 오류, 인증된 기존 Dashboard, session-local 인증 boolean -- 비민감 session 데이터: 인증 boolean, login-error boolean, correlation ID -- 영속 데이터: 인증 기능은 새 DB table, column, migration 또는 credential store를 만들지 않는다. -- 비교: ID와 password 모두 `hmac.compare_digest`를 사용하며 두 비교를 수행한다. - -## 10. 하위 호환성 - -인증 후 기존 Dashboard page query parameter, SQLite schema, gateway와 화면 기능을 유지한다. Dashboard는 새 credential 설정 없이는 의도적으로 fail-closed한다. MCP server API, transport와 인증 상태는 변경하지 않는다. - -## 11. 추적성 - -- 근거 이슈: #1 -- 구현 PR: #3 -- 관련 설계 문서: docs/interface-contract.md, docs/project-structure.md, docs/runtime-flow.md -- 관련 요구사항: 없음 +- 최초 요구 Issue: `jsfamily/jarca-ledger-mcp#1` +- 지속 로그인 변경 Issue: `jsfamily/jarca-ledger-mcp#6` +- 최초 구현 PR: `#3` +- Issue #6 구현 PR: 미정 +- Issue #6 기준 revision: `7bea8b368e02e98afd4b2dc463fb270b0e6eb297` +- Issue #6 승인 snapshot body SHA-256: `4f734169ec896747fe39fd7f3ab75d1fc0d8cdd4903045e665beba40d80eb1fe` diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index e3dc809..0b82f42 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -1,91 +1,89 @@ -# Dashboard 관리자 인증 런타임 흐름 +# Runtime Flow — Dashboard Persistent Authentication ## 목적과 범위 -Streamlit session 시작부터 설정 판정, 로그인 성공·실패, 인증된 page dispatch와 로그아웃까지의 실행 순서와 실패 복구 계약을 정의한다. +Dashboard startup, login token 발급, 새 browser session의 인증 복원, 실패 차단과 logout cookie 삭제 순서를 정의한다. -- 포함 범위: credential 환경변수, login form, 1초 실패 지연, session 인증, logout, 인증 event -- 제외 범위: MCP 요청, 영속 session, application lock, 외부 rate limiter 구현 -- 시작 조건: Streamlit이 `dashboard/app.py`를 새로 실행하거나 rerun함 -- 성공 종료 상태: 인증된 기존 Dashboard page가 렌더링되거나 로그아웃 후 로그인 화면이 렌더링됨 -- 실패 종료 상태: 안전한 설정·credential 오류를 표시하고 DB·gateway를 호출하지 않은 미인증 상태 +- 포함: ASGI startup, config, login/logout POST, signed cookie, Streamlit gate, DB 이전 차단 +- 제외: MCP 요청, token registry, OAuth/OIDC, external auth middleware -## 흐름 +## 정상 흐름 -### dashboard-entry — Session 시작과 인증 경계 +### START — ASGI server 시작 -- 시작 이벤트: 새 browser session 또는 Streamlit rerun -- 선행조건: Dashboard process가 시작돼 있음 -- 실행 방식: 동기 -- 트랜잭션·영속성 경계: 인증 상태는 현재 `st.session_state`에만 존재하고 SQLite transaction을 열지 않음 +| 단계 | 주체 | 처리 | 다음 | +|---|---|---|---| +| `START-01` | Uvicorn | `dashboard.server:app` 로드 | `START-02` | +| `START-02` | Streamlit `App` | custom auth routes와 `dashboard/app.py` runtime 조립 | `UNAUTHENTICATED` | -| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | -|---|---|---|---|---|---|---|---| -| `ENTRY-01` | `dashboard/app.py` | script 실행 | page config 후 인증 gate 호출 | 금융 상태 읽기·쓰기 없음 | 없음 | `ENTRY-02` | `AUTH-FAIL-CONFIG` | -| `ENTRY-02` | 인증 모듈 | `st.session_state` | correlation ID를 조회하거나 비민감 ID 생성 | correlation ID만 session에 기록 | random token 생성 | `ENTRY-03` | `AUTH-FAIL-CONFIG` | -| `ENTRY-03` | 인증 모듈 | 두 environment key | 비어 있지 않은 문자열 두 개를 읽음 | process memory에 현재 rerun 동안만 credential 보유 | process environment | `ENTRY-04` | `AUTH-FAIL-CONFIG` | -| `ENTRY-04` | 인증 모듈 | 인증 boolean | 현재 session 인증 여부 판정 | 인증 상태 읽기 | 없음 | `DASHBOARD-01` 또는 `LOGIN-01` | `AUTH-FAIL-CONFIG` | -| `DASHBOARD-01` | `dashboard/app.py` | 인증 true | DB 경로 취득, CSS와 nav 구성 | DB 경로 읽기 | filesystem path resolution | `DASHBOARD-02` | `DASHBOARD-FAIL` | -| `DASHBOARD-02` | `dashboard/app.py` | 유효한 page query | 허용 목록으로 page를 정규화하고 dispatch | page session key, 기존 금융 읽기·쓰기 | 선택 page gateway | `AUTHENTICATED` | `DASHBOARD-FAIL` | +운영 command는 `uvicorn dashboard.server:app --host 0.0.0.0 --port 8501`이다. -### dashboard-login — Login 제출 +### LOGIN — Credential 제출과 token 발급 -- 시작 이벤트: 미인증 사용자의 로그인 form 제출 -- 선행조건: 유효한 관리자 환경 설정과 로그인 화면 -- 실행 방식: 동기 -- 트랜잭션·영속성 경계: 금융 DB transaction 없음; 성공 시 session boolean만 기록 +| 단계 | 주체 | 입력/처리 | 상태·외부 효과 | 다음 | +|---|---|---|---|---| +| `LOGIN-01` | Browser | Streamlit이 렌더링한 HTML form을 same-origin HTTPS POST | credential은 request body에만 존재 | `LOGIN-02` | +| `LOGIN-02` | `auth_server` | Origin, content type, body size와 field cardinality 검증 | 금융 상태 없음 | `LOGIN-03` 또는 `LOGIN-REJECT` | +| `LOGIN-03` | `authentication` | 세 environment setting을 fail-closed로 읽음 | process memory | `LOGIN-04` 또는 `CONFIG-FAIL` | +| `LOGIN-04` | `authentication` | ID와 password 모두 constant-time 비교 | 실패 시 남은 최소 1초 sleep | `LOGIN-05` 또는 `LOGIN-FAIL` | +| `LOGIN-05` | `authentication` | `v/sub/iat/exp/jti` payload와 derived key로 token 서명 | DB 상태 없음 | `LOGIN-06` | +| `LOGIN-06` | `auth_server` | 303 response에 hardened cookie 설정 | browser cookie 생성 | `RESTORE-01` | -| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | -|---|---|---|---|---|---|---|---| -| `LOGIN-01` | Streamlit | 미인증, 유효 설정 | ID·password form 렌더링 | widget state | 없음 | `LOGIN-02` 또는 `UNAUTHENTICATED` | `AUTH-FAIL-CONFIG` | -| `LOGIN-02` | 인증 모듈 | form 제출 | monotonic 시작 시각 기록, ID와 password 모두 constant-time 비교 | credential은 local variable | monotonic clock | `LOGIN-03` 또는 `LOGIN-FAIL-01` | `LOGIN-FAIL-01` | -| `LOGIN-03` | 인증 모듈 | 두 비교 성공 | 인증 boolean true, 성공 event 기록, rerun | session 인증 상태 쓰기 | application log | `ENTRY-01` | `AUTH-FAIL-CONFIG` | -| `LOGIN-FAIL-01` | 인증 모듈 | 하나 이상 비교 실패 | 제출 처리 경과가 1초가 될 때까지 remaining delay | 금융 상태 무변경 | monotonic clock, sleep | `LOGIN-FAIL-02` | `LOGIN-FAIL-02` | -| `LOGIN-FAIL-02` | 인증 모듈 | 최소 지연 완료 | 실패 event와 안전한 error flag 기록 후 rerun | credential widget state 제거 | application log | `LOGIN-FAIL-03` | `UNAUTHENTICATED` | -| `LOGIN-FAIL-03` | 인증 모듈 | 실패 rerun | ID·password를 구분하지 않는 일반 오류 표시 | 금융 상태 무변경 | 없음 | `UNAUTHENTICATED` | `UNAUTHENTICATED` | +기본 `exp-iat`는 86400초, remember-me는 2592000초다. 이 값은 발급 시 고정된다. -### dashboard-logout — Logout +### RESTORE — 새 session·재접속 인증 복원 -- 시작 이벤트: 인증된 사용자의 sidebar 로그아웃 click -- 선행조건: 현재 session 인증 boolean true -- 실행 방식: 동기 -- 트랜잭션·영속성 경계: 인증 boolean만 제거하며 금융 DB를 변경하지 않음 +| 단계 | 주체 | 입력/처리 | 상태 | 다음 | +|---|---|---|---|---| +| `RESTORE-01` | Browser | Streamlit initial request/WebSocket handshake에 host cookie 전송 | HttpOnly token | `GATE-01` | +| `GATE-01` | `dashboard.app` | page config 직후 authentication gate 호출 | DB 미접근 | `GATE-02` | +| `GATE-02` | `authentication` | 현재 ID/password/signing secret 로드 | secret은 session에 저장하지 않음 | `GATE-03` 또는 `CONFIG-FAIL` | +| `GATE-03` | `authentication` | signature 검증 후 exact claim schema, subject, TTL, time 검증 | token 재발급 없음 | `DASHBOARD-01` 또는 `TOKEN-FAIL` | +| `DASHBOARD-01` | `dashboard.app` | DB path 해석과 기존 page dispatch | 기존 금융 계약 | `AUTHENTICATED` | -| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | -|---|---|---|---|---|---|---|---| -| `LOGOUT-01` | 인증 모듈 | 로그아웃 click | 인증 boolean 제거와 logout event 기록 | session 인증 상태 삭제 | application log | `LOGOUT-02` | `UNAUTHENTICATED` | -| `LOGOUT-02` | Streamlit | 인증 상태 없음 | rerun | 금융 상태 무변경 | 없음 | `ENTRY-01` | `UNAUTHENTICATED` | +각 rerun은 cookie를 현재 설정으로 다시 검증한다. 일반 요청은 `iat`, `exp`, cookie `Max-Age`를 연장하지 않는다. + +### LOGOUT — Browser cookie 삭제 + +| 단계 | 주체 | 처리 | 다음 | +|---|---|---|---| +| `LOGOUT-01` | Browser | same-origin HTTPS POST `/api/auth/logout` | `LOGOUT-02` | +| `LOGOUT-02` | `auth_server` | matching name/path/security 속성과 `Max-Age=0` response | `LOGOUT-03` | +| `LOGOUT-03` | Browser | cookie 삭제 후 `/` 재접속 | `UNAUTHENTICATED` | + +Logout은 DB를 변경하지 않는다. 복사된 stateless token의 개별 server-side revocation은 없으며 password/signing-secret rotation이 전체 기존 token을 무효화한다. ## 실패와 복구 -| 대상 단계 | 실패 조건 | 추가 쓰기 중단 | 재시도 계약 | 중복 방지 | 보상·수동 복구 | 최종 상태 | -|---|---|---|---|---|---|---| -| `ENTRY-03` | 환경변수 누락·빈 값·비문자열·read error | 예; DB·gateway 미호출 | 설정 수정 후 rerun | 기존 인증 boolean 제거 | 운영자가 두 secret을 유효하게 주입 | `AUTH-FAIL-CONFIG` | -| `LOGIN-02` | 잘못된 ID 또는 password | 예; 금융 상태 무변경 | 각 제출 독립 재시도 | lock·failure counter 없음 | 없음 | `UNAUTHENTICATED` | -| `LOGIN-FAIL-01` | sleep interruption | 금융 쓰기 없음 | 다음 제출 가능 | error flag boolean만 사용 | 없음 | `UNAUTHENTICATED` | -| `DASHBOARD-01` | DB 경로·기존 page 오류 | 해당 page의 기존 실패 계약 | 기존 Dashboard 계약에 따름 | 기존 gateway 계약 | 기존 운영 복구 | `DASHBOARD-FAIL` | -| `LOGOUT-01` | 중복 또는 이미 제거됨 | 금융 쓰기 없음 | 로그인 화면 유지 | key 제거는 멱등 | 없음 | `UNAUTHENTICATED` | +| 실패 상태 | 원인 | 차단 범위 | 복구 | +|---|---|---|---| +| `CONFIG-FAIL` | ID/password/signing secret 누락·invalid·read error | DB/gateway/page 전체 | 올바른 secret 주입 후 rerun | +| `LOGIN-REJECT` | cross-site, malformed, oversized POST | cookie 발급 | same-origin 정상 form 재시도 | +| `LOGIN-FAIL` | ID 또는 password 불일치 | cookie 발급·DB 접근 | 최소 1초 후 일반 오류, 재시도 | +| `TOKEN-FAIL` | absent/malformed/tampered/expired/wrong claims/rotation | DB/gateway/page 전체 | 재로그인 | +| `DASHBOARD-FAIL` | 인증 이후 기존 page/DB 오류 | 기존 page 계약 | 기존 운영 복구 | -### 동시성과 순서 +Invalid token의 세부 원인을 UI에서 구분하지 않는다. Credential, secret, token, cookie header 또는 exception 원문을 log에 넣지 않는다. -- 동시 실행 허용 여부: 서로 다른 browser session은 독립적으로 실행되며 같은 session은 Streamlit rerun 순서를 따른다. -- 잠금·직렬화 단위: 애플리케이션 인증 잠금과 실패 횟수 잠금은 없다. Session state는 browser session 단위다. -- 순서 보장: 설정 판정과 인증 gate가 DB 경로 취득·page dispatch보다 항상 먼저 실행된다. 실패 지연 완료 후 오류 rerun이 수행된다. -- 충돌 검출·처리: 새 session과 병렬 요청은 1초 지연을 우회할 수 있으므로 배포 경계에서 별도 rate limiting이 필요하다. +## 동시성·순서 + +- 서로 다른 browser session은 같은 signed token을 보낼 수 있다. +- Token verification은 stateless이고 shared mutable authentication state를 만들지 않는다. +- 설정 및 cookie 검증은 항상 DB path/gateway/page보다 먼저다. +- Password/signing-secret rotation 직후 다음 rerun부터 기존 token이 실패한다. +- 실패당 1초 delay는 병렬 요청을 직렬화하지 않으므로 TLS ingress/reverse-proxy 경계에서 rate limiting이 필요하다. ## 관찰 가능성 -| 신호 종류 | 이름 | 발생 단계·조건 | 상관 식별자 | 진단 속성 | 민감정보 처리 | -|---|---|---|---|---|---| -| log | `dashboard_auth configuration_error` | `ENTRY-03` 설정 오류 | session correlation ID | event 이름 | 환경변수 값·오류 원인 원문 제외 | -| log | `dashboard_auth login_succeeded` | `LOGIN-03` | session correlation ID | event 이름 | ID·password 제외 | -| log | `dashboard_auth login_failed` | `LOGIN-FAIL-02` | session correlation ID | event 이름 | 실패 필드·제출 값 제외 | -| log | `dashboard_auth logout` | `LOGOUT-01` | session correlation ID | event 이름 | 금융 데이터·credential 제외 | +허용 event: `configuration_error`, `authentication_required`, `token_accepted`, `login_succeeded`, `login_failed`, `login_origin_rejected`, `login_request_rejected`, `logout`, `logout_origin_rejected`. + +각 log는 event와 비민감 correlation ID만 포함한다. Token, credential, origin 세부값, request body와 cookie header는 기록하지 않는다. ## 검증 -- 정상 흐름: test-only credential로 로그인하고 synthetic fixture의 기존 Dashboard page를 렌더링한 뒤 같은 session rerun을 검증한다. -- 실패 흐름: 설정 누락·빈 값·read error, 잘못된 ID·password, 일반 오류와 DB 미호출을 단위·AppTest로 검증한다. -- 경계 흐름: Import query parameter 우회 금지, credential session 제거, logout, 새 session 재인증과 MCP 범위 불변을 검증한다. -- 동시성 흐름: application lock·failure counter가 없음을 정적으로 확인하고 새 session 우회 및 배포 경계 rate limiting 요구를 README에서 검증한다. -- 운영 진입점과 기대 결과: `streamlit run dashboard/app.py`; 유효 secret이면 로그인 form, 무효 설정이면 일반 설정 오류, 인증 성공 후에만 기존 Dashboard를 표시한다. +- Normal/remember TTL 및 non-sliding: token unit tests +- Tamper/expiry/rotation/schema rejection: token unit tests +- HTTP response cookie와 logout deletion: auth-server tests +- Fail-closed DB-before-gate ordering: AppTest +- 실제 loopback HTTPS login/reconnect/logout와 cookie 속성: HTTP evidence script +- MCP behavior와 DB schema가 변경되지 않았음을 full regression과 diff로 확인 diff --git a/pyproject.toml b/pyproject.toml index 748ce46..2892966 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,11 +17,14 @@ dependencies = [ ] [project.optional-dependencies] -dev = ["pytest>=8.0.0", "ruff>=0.6.0"] +dev = ["pytest>=8.0.0", "ruff>=0.6.0", "websockets>=15.0,<17.0"] dashboard = [ - "pandas>=2.0.0", - "plotly>=5.0.0", - "streamlit>=1.30.0", + "streamlit>=1.58.0,<1.59.0", + "plotly>=5.20.0", + "pandas>=2.2.0", + "itsdangerous>=2.2.0,<3.0", + "starlette>=0.40.0,<2.0", + "uvicorn>=0.30.0,<1.0", ] [project.scripts] diff --git a/scripts/persistent_auth_http_evidence.py b/scripts/persistent_auth_http_evidence.py new file mode 100644 index 0000000..6a494ea --- /dev/null +++ b/scripts/persistent_auth_http_evidence.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Exercise persistent-auth HTTP responses without printing secrets or tokens.""" + +from __future__ import annotations + +import argparse +import http.client +import json +import os +import ssl +from urllib.parse import urlencode, urlsplit + +from streamlit.proto.BackMsg_pb2 import BackMsg +from streamlit.proto.ClientState_pb2 import ClientState +from streamlit.proto.ForwardMsg_pb2 import ForwardMsg +from websockets.sync.client import connect + +COOKIE_NAME = "__Host-jarca_dashboard_auth" + + +def _args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--base-url", default="https://127.0.0.1:18506") + return parser.parse_args() + + +def _connection(parsed): + if parsed.scheme != "https" or parsed.hostname not in {"127.0.0.1", "localhost"}: + raise SystemExit("Evidence target must be loopback HTTPS") + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return http.client.HTTPSConnection(parsed.hostname, parsed.port, context=context, timeout=10) + + +def _request(parsed, method: str, path: str, *, body: bytes | None = None, headers=None): + connection = _connection(parsed) + connection.request(method, path, body=body, headers=headers or {}) + response = connection.getresponse() + payload = response.read() + result = response.status, {key.lower(): value for key, value in response.getheaders()}, payload + connection.close() + return result + + +def _assert_streamlit_restore(parsed, cookie_pair: str) -> int: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + origin = f"https://{parsed.hostname}:{parsed.port}" + websocket_url = f"wss://{parsed.hostname}:{parsed.port}/_stcore/stream" + with connect( + websocket_url, + ssl=context, + origin=origin, + subprotocols=["streamlit"], + additional_headers={"Cookie": cookie_pair}, + proxy=None, + open_timeout=30, + ) as websocket: + back_message = BackMsg() + back_message.rerun_script.CopyFrom( + ClientState(query_string="page=%EC%9A%94%EC%95%BD") + ) + websocket.send(back_message.SerializeToString()) + messages: list[str] = [] + for _ in range(100): + raw = websocket.recv(timeout=15) + if isinstance(raw, str): + continue + forward_message = ForwardMsg() + forward_message.ParseFromString(raw) + messages.append(str(forward_message)) + if forward_message.HasField("script_finished"): + break + combined = "\n".join(messages) + if "이번 달 수입/지출 현황" not in combined or "관리자 로그인" in combined: + raise SystemExit("Signed cookie did not restore the authenticated Streamlit page") + return len(messages) + + +def main() -> None: + parsed = urlsplit(_args().base_url) + admin_id = os.environ["JARCA_DASHBOARD_ADMIN_ID"] + password = os.environ["JARCA_DASHBOARD_ADMIN_PASSWORD"] + origin = f"https://{parsed.hostname}:{parsed.port}" + + body = urlencode( + {"admin_id": admin_id, "admin_password": password, "remember_me": "on"} + ).encode() + status, headers, _ = _request( + parsed, + "POST", + "/api/auth/login", + body=body, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Content-Length": str(len(body)), + "Origin": origin, + }, + ) + cookie_header = headers.get("set-cookie", "") + required = [COOKIE_NAME + "=", "HttpOnly", "Secure", "SameSite=lax", "Path=/", "Max-Age=2592000"] + if status != 303 or headers.get("location") != "/" or not all( + marker in cookie_header for marker in required + ): + raise SystemExit("Login response did not satisfy the hardened cookie contract") + cookie_pair = cookie_header.split(";", 1)[0] + + status, _, _ = _request( + parsed, + "GET", + "/", + headers={"Cookie": cookie_pair}, + ) + if status != 200: + raise SystemExit("Authenticated reconnect request failed") + websocket_messages = _assert_streamlit_restore(parsed, cookie_pair) + + status, headers, _ = _request( + parsed, + "POST", + "/api/auth/logout", + headers={"Origin": origin, "Cookie": cookie_pair, "Content-Length": "0"}, + ) + deletion = headers.get("set-cookie", "") + if status != 303 or not all( + marker in deletion + for marker in [COOKIE_NAME + "=", "HttpOnly", "Secure", "SameSite=lax", "Path=/", "Max-Age=0"] + ): + raise SystemExit("Logout response did not satisfy the cookie deletion contract") + + print( + json.dumps( + { + "success": True, + "transport": "loopback HTTPS", + "login_status": 303, + "reconnect_status": 200, + "streamlit_websocket_authenticated": True, + "streamlit_messages": websocket_messages, + "logout_status": 303, + "cookie_name": COOKIE_NAME, + "cookie_value": "[REDACTED]", + "remember_me_max_age": 2592000, + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_dashboard_app.py b/tests/test_dashboard_app.py index 9ad520a..b2e9d34 100644 --- a/tests/test_dashboard_app.py +++ b/tests/test_dashboard_app.py @@ -6,7 +6,7 @@ from streamlit.testing.v1 import AppTest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from dashboard.app import get_transfer_review_items -from dashboard.authentication import ADMIN_ID_ENV, ADMIN_PASSWORD_ENV +from dashboard import authentication from scripts.ci_fixture_db import create_ci_fixture_db @@ -44,8 +44,13 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp fixture = tmp_path / "fixture.sqlite3" create_ci_fixture_db(fixture) monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) - monkeypatch.setenv(ADMIN_ID_ENV, "test-admin") - monkeypatch.setenv(ADMIN_PASSWORD_ENV, "test-password") + monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin") + monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password") + monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64) + settings = authentication.load_auth_settings() + assert settings is not None + token = authentication.issue_persistent_token(settings, remember_me=False).value + monkeypatch.setattr(authentication, "_request_cookie", lambda: token) app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py" page_expectations = { @@ -61,10 +66,6 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp if page == "월정산": at.query_params["month"] = "2026-06" at.run() - at.text_input[0].input("test-admin") - at.text_input[1].input("test-password") - next(button for button in at.button if button.label == "로그인").click() - at.run() assert not at.exception body_text = "\n".join(str(item.value) for item in list(at.text) + list(at.markdown) + list(at.caption)) assert expected_text in body_text diff --git a/tests/test_dashboard_auth_server.py b/tests/test_dashboard_auth_server.py new file mode 100644 index 0000000..0b68948 --- /dev/null +++ b/tests/test_dashboard_auth_server.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import sys +from http.cookies import SimpleCookie +from pathlib import Path + +import pytest +from starlette.testclient import TestClient + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from dashboard import authentication +from dashboard.auth_server import create_auth_routes + + +ADMIN_ID = "admin" +ADMIN_PASSWORD = "correct horse battery staple" +SIGNING_SECRET = "s" * 64 + + +@pytest.fixture(autouse=True) +def _configured_auth(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(authentication.ADMIN_ID_ENV, ADMIN_ID) + monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, ADMIN_PASSWORD) + monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, SIGNING_SECRET) + monkeypatch.setattr(authentication.time, "sleep", lambda _seconds: None) + + +@pytest.fixture +def client() -> TestClient: + from starlette.applications import Starlette + + return TestClient( + Starlette(routes=create_auth_routes()), base_url="https://testserver" + ) + + +def _cookie_from(response) -> SimpleCookie[str]: + cookie: SimpleCookie[str] = SimpleCookie() + cookie.load(response.headers["set-cookie"]) + return cookie + + +def test_successful_login_sets_hardened_24_hour_cookie(client: TestClient) -> None: + response = client.post( + "/api/auth/login", + data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD}, + headers={"origin": "https://testserver"}, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/" + header = response.headers["set-cookie"] + assert f"{authentication.AUTH_COOKIE_NAME}=" in header + assert "HttpOnly" in header + assert "Secure" in header + assert "SameSite=lax" in header + assert "Path=/" in header + assert f"Max-Age={authentication.DEFAULT_TOKEN_TTL_SECONDS}" in header + assert ADMIN_PASSWORD not in header + assert SIGNING_SECRET not in header + + +def test_remember_me_sets_30_day_cookie(client: TestClient) -> None: + response = client.post( + "/api/auth/login", + data={ + "admin_id": ADMIN_ID, + "admin_password": ADMIN_PASSWORD, + "remember_me": "on", + }, + headers={"origin": "https://testserver"}, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert ( + f"Max-Age={authentication.REMEMBER_ME_TOKEN_TTL_SECONDS}" + in response.headers["set-cookie"] + ) + + +def test_invalid_credentials_do_not_set_cookie_and_use_general_error( + client: TestClient, +) -> None: + response = client.post( + "/api/auth/login", + data={"admin_id": ADMIN_ID, "admin_password": "wrong"}, + headers={"origin": "https://testserver"}, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/?auth_error=invalid" + assert "set-cookie" not in response.headers + assert "password" not in response.headers["location"] + assert ADMIN_ID not in response.headers["location"] + + +def test_missing_configuration_fails_closed(client: TestClient, monkeypatch) -> None: + monkeypatch.delenv(authentication.SIGNING_SECRET_ENV) + + response = client.post( + "/api/auth/login", + data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD}, + headers={"origin": "https://testserver"}, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/?auth_error=unavailable" + assert "set-cookie" not in response.headers + + +def test_cross_site_login_is_rejected(client: TestClient) -> None: + response = client.post( + "/api/auth/login", + data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD}, + headers={"origin": "https://attacker.example"}, + follow_redirects=False, + ) + + assert response.status_code == 403 + assert "set-cookie" not in response.headers + + +def test_different_port_is_not_same_origin(client: TestClient) -> None: + response = client.post( + "/api/auth/login", + data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD}, + headers={"origin": "https://testserver:444"}, + follow_redirects=False, + ) + + assert response.status_code == 403 + assert "set-cookie" not in response.headers + + +def test_plain_http_is_rejected_even_with_spoofed_https_origin() -> None: + from starlette.applications import Starlette + + with TestClient( + Starlette(routes=create_auth_routes()), base_url="http://testserver" + ) as http_client: + response = http_client.post( + "/api/auth/login", + data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD}, + headers={ + "origin": "https://testserver", + "x-forwarded-proto": "https", + }, + follow_redirects=False, + ) + + assert response.status_code == 403 + assert "set-cookie" not in response.headers + + +def test_logout_clears_cookie_with_matching_scope(client: TestClient) -> None: + response = client.post( + "/api/auth/logout", + headers={"origin": "https://testserver"}, + follow_redirects=False, + ) + + assert response.status_code == 303 + header = response.headers["set-cookie"] + assert f"{authentication.AUTH_COOKIE_NAME}=" in header + assert "Max-Age=0" in header + assert "HttpOnly" in header + assert "Secure" in header + assert "SameSite=lax" in header + assert "Path=/" in header + + +def test_cookie_token_round_trip(client: TestClient) -> None: + response = client.post( + "/api/auth/login", + data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD}, + headers={"origin": "https://testserver"}, + follow_redirects=False, + ) + cookie = _cookie_from(response) + token = cookie[authentication.AUTH_COOKIE_NAME].value + + assert authentication.verify_persistent_token( + authentication.load_auth_settings(), token + ) is not None diff --git a/tests/test_dashboard_authentication_app.py b/tests/test_dashboard_authentication_app.py index 786c259..59fa496 100644 --- a/tests/test_dashboard_authentication_app.py +++ b/tests/test_dashboard_authentication_app.py @@ -2,9 +2,7 @@ from __future__ import annotations from pathlib import Path import sys -import time -import pytest from streamlit.testing.v1 import AppTest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) @@ -14,31 +12,40 @@ from dashboard import authentication ADMIN_ID = "test-admin" ADMIN_PASSWORD = "test-password" +SIGNING_SECRET = "s" * 64 APP_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "app.py" def _body_text(at: AppTest) -> str: - elements = list(at.title) + list(at.header) + list(at.subheader) + list(at.text) + list(at.markdown) + list(at.caption) + list(at.error) + elements = ( + list(at.title) + + list(at.header) + + list(at.subheader) + + list(at.text) + + list(at.markdown) + + list(at.caption) + + list(at.error) + ) return "\n".join(str(item.value) for item in elements) -def _configured_app(monkeypatch, *, page: str = "요약") -> AppTest: +def _configure(monkeypatch) -> authentication.AuthSettings: monkeypatch.setenv(authentication.ADMIN_ID_ENV, ADMIN_ID) monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, ADMIN_PASSWORD) - at = AppTest.from_file(str(APP_PATH), default_timeout=10) + monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, SIGNING_SECRET) + settings = authentication.load_auth_settings() + assert settings is not None + return settings + + +def _app_with_cookie(monkeypatch, token: str | None, *, page: str = "요약") -> AppTest: + _configure(monkeypatch) + monkeypatch.setattr(authentication, "_request_cookie", lambda: token) + at = AppTest.from_file(str(APP_PATH), default_timeout=30) at.query_params["page"] = page return at.run() -def _submit_login(at: AppTest, admin_id: str, password: str) -> AppTest: - assert [field.label for field in at.text_input] == ["관리자 ID", "비밀번호"] - at.text_input[0].input(admin_id) - at.text_input[1].input(password) - login_button = next(button for button in at.button if button.label == "로그인") - login_button.click() - return at.run() - - def test_missing_authentication_configuration_fails_closed(monkeypatch, tmp_path): from scripts.ci_fixture_db import create_ci_fixture_db @@ -46,18 +53,20 @@ def test_missing_authentication_configuration_fails_closed(monkeypatch, tmp_path create_ci_fixture_db(fixture) monkeypatch.delenv(authentication.ADMIN_ID_ENV, raising=False) monkeypatch.delenv(authentication.ADMIN_PASSWORD_ENV, raising=False) + monkeypatch.delenv(authentication.SIGNING_SECRET_ENV, raising=False) monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) - at = AppTest.from_file(str(APP_PATH), default_timeout=10).run() + at = AppTest.from_file(str(APP_PATH), default_timeout=30).run() assert not at.exception assert "관리자 로그인" in _body_text(at) assert authentication.CONFIGURATION_ERROR_MESSAGE in _body_text(at) - assert not at.text_input assert "이번 달 수입/지출 현황" not in _body_text(at) -def test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects(monkeypatch): +def test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects( + monkeypatch, +): from dashboard import app monkeypatch.setattr(app, "require_dashboard_authentication", lambda: False) @@ -71,7 +80,7 @@ def test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects( def test_query_parameter_cannot_bypass_login_or_reach_import_page(monkeypatch): - at = _configured_app(monkeypatch, page="Import") + at = _app_with_cookie(monkeypatch, None, page="Import") assert not at.exception assert "관리자 로그인" in _body_text(at) @@ -79,96 +88,68 @@ def test_query_parameter_cannot_bypass_login_or_reach_import_page(monkeypatch): assert "Import 이력" not in _body_text(at) -@pytest.mark.parametrize( - ("admin_id", "password"), - [ - ("wrong-id", ADMIN_PASSWORD), - (ADMIN_ID, "wrong-password"), - ], -) -def test_invalid_credentials_have_same_error_and_minimum_one_second_delay(monkeypatch, admin_id, password): - at = _configured_app(monkeypatch) +def test_login_form_posts_credentials_to_server_side_adapter(monkeypatch): + at = _app_with_cookie(monkeypatch, None) + body = _body_text(at) - started_at = time.monotonic() - at = _submit_login(at, admin_id, password) - elapsed = time.monotonic() - started_at - - assert elapsed >= authentication.MINIMUM_FAILURE_DELAY_SECONDS - assert authentication.INVALID_CREDENTIALS_MESSAGE in _body_text(at) - assert ADMIN_ID not in _body_text(at) - assert ADMIN_PASSWORD not in _body_text(at) - assert admin_id not in _body_text(at) - assert password not in _body_text(at) + assert 'action="/api/auth/login"' in body + assert 'method="post"' in body + assert 'name="admin_id"' in body + assert 'name="admin_password"' in body + assert 'name="remember_me"' in body + assert ADMIN_ID not in repr(at.session_state) assert ADMIN_PASSWORD not in repr(at.session_state) - assert admin_id not in repr(at.session_state) - assert password not in repr(at.session_state) + assert SIGNING_SECRET not in repr(at.session_state) + + +def test_invalid_cookie_blocks_database_and_page_dispatch(monkeypatch): + at = _app_with_cookie(monkeypatch, "malformed-token") + + assert not at.exception + assert "관리자 로그인" in _body_text(at) assert "이번 달 수입/지출 현황" not in _body_text(at) -def test_successful_login_persists_for_current_session_and_logout_clears_it(monkeypatch, tmp_path): +def test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state( + monkeypatch, tmp_path +): from scripts.ci_fixture_db import create_ci_fixture_db fixture = tmp_path / "fixture.sqlite3" create_ci_fixture_db(fixture) monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) - at = _configured_app(monkeypatch) + settings = _configure(monkeypatch) + token = authentication.issue_persistent_token( + settings, remember_me=False, now=1_700_000_000 + ).value + monkeypatch.setattr(authentication.time, "time", lambda: 1_700_000_001) + monkeypatch.setattr(authentication, "_request_cookie", lambda: token) - at = _submit_login(at, ADMIN_ID, ADMIN_PASSWORD) + at = AppTest.from_file(str(APP_PATH), default_timeout=30).run() assert not at.exception assert "이번 달 수입/지출 현황" in _body_text(at) - assert authentication.INVALID_CREDENTIALS_MESSAGE not in _body_text(at) + assert 'action="/api/auth/logout"' in _body_text(at) + assert token not in repr(at.session_state) assert ADMIN_PASSWORD not in repr(at.session_state) + assert SIGNING_SECRET not in repr(at.session_state) - at = at.run() + +def test_password_rotation_invalidates_existing_cookie(monkeypatch, tmp_path): + from scripts.ci_fixture_db import create_ci_fixture_db + + fixture = tmp_path / "fixture.sqlite3" + create_ci_fixture_db(fixture) + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) + settings = _configure(monkeypatch) + token = authentication.issue_persistent_token(settings, remember_me=True).value + monkeypatch.setattr(authentication, "_request_cookie", lambda: token) + + at = AppTest.from_file(str(APP_PATH), default_timeout=30).run() assert "이번 달 수입/지출 현황" in _body_text(at) - logout_button = next(button for button in at.button if button.label == "로그아웃") - logout_button.click() + monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "rotated-password") at = at.run() assert "관리자 로그인" in _body_text(at) assert "이번 달 수입/지출 현황" not in _body_text(at) - - new_session = AppTest.from_file(str(APP_PATH), default_timeout=10).run() - assert "관리자 로그인" in _body_text(new_session) - assert "이번 달 수입/지출 현황" not in _body_text(new_session) - - -def test_successful_login_does_not_invoke_failure_delay(monkeypatch, tmp_path): - from scripts.ci_fixture_db import create_ci_fixture_db - - fixture = tmp_path / "fixture.sqlite3" - create_ci_fixture_db(fixture) - monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) - - def forbidden_failure_delay(**kwargs): - raise AssertionError("successful login must not invoke the failure delay") - - monkeypatch.setattr(authentication, "enforce_failed_login_delay", forbidden_failure_delay) - at = _configured_app(monkeypatch) - - at = _submit_login(at, ADMIN_ID, ADMIN_PASSWORD) - - assert not at.exception - assert "이번 달 수입/지출 현황" in _body_text(at) - - -def test_configuration_failure_after_login_clears_existing_authentication(monkeypatch, tmp_path): - from scripts.ci_fixture_db import create_ci_fixture_db - - fixture = tmp_path / "fixture.sqlite3" - create_ci_fixture_db(fixture) - monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) - at = _configured_app(monkeypatch) - at = _submit_login(at, ADMIN_ID, ADMIN_PASSWORD) - assert "이번 달 수입/지출 현황" in _body_text(at) - - monkeypatch.delenv(authentication.ADMIN_PASSWORD_ENV) - at = at.run() - - assert authentication.CONFIGURATION_ERROR_MESSAGE in _body_text(at) - assert "이번 달 수입/지출 현황" not in _body_text(at) - assert authentication.AUTHENTICATED_SESSION_KEY not in repr(at.session_state) - assert ADMIN_ID not in repr(at.session_state) - assert ADMIN_PASSWORD not in repr(at.session_state) diff --git a/tests/test_dashboard_persistent_authentication.py b/tests/test_dashboard_persistent_authentication.py new file mode 100644 index 0000000..37b609d --- /dev/null +++ b/tests/test_dashboard_persistent_authentication.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import json +import sys +from dataclasses import replace +from pathlib import Path + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[1] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from dashboard import authentication + + +ADMIN_ID = "admin" +ADMIN_PASSWORD = "correct horse battery staple" +SIGNING_SECRET = "s" * 64 + + +def _settings() -> authentication.AuthSettings: + return authentication.AuthSettings( + admin_id=ADMIN_ID, + admin_password=ADMIN_PASSWORD, + signing_secret=SIGNING_SECRET, + ) + + +def test_load_auth_settings_requires_signing_secret(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(authentication.ADMIN_ID_ENV, ADMIN_ID) + monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, ADMIN_PASSWORD) + monkeypatch.delenv(authentication.SIGNING_SECRET_ENV, raising=False) + + assert authentication.load_auth_settings() is None + + monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, SIGNING_SECRET) + assert authentication.load_auth_settings() == _settings() + + +def test_issue_and_verify_default_token_without_refresh() -> None: + settings = _settings() + issued = authentication.issue_persistent_token( + settings, + remember_me=False, + now=1_700_000_000, + token_id="fixed-token-id", + ) + + verified = authentication.verify_persistent_token( + settings, + issued.value, + now=1_700_000_001, + ) + + assert verified is not None + assert verified.issued_at == 1_700_000_000 + assert verified.expires_at == 1_700_086_400 + assert issued.max_age == authentication.DEFAULT_TOKEN_TTL_SECONDS + assert verified.token_id == "fixed-token-id" + assert ADMIN_PASSWORD not in issued.value + assert SIGNING_SECRET not in issued.value + + +def test_remember_me_token_uses_30_day_absolute_expiry() -> None: + issued = authentication.issue_persistent_token( + _settings(), + remember_me=True, + now=1_700_000_000, + token_id="fixed-token-id", + ) + + assert issued.expires_at == 1_702_592_000 + assert issued.max_age == authentication.REMEMBER_ME_TOKEN_TTL_SECONDS + + +def test_token_expiry_is_absolute_and_not_sliding() -> None: + settings = _settings() + issued = authentication.issue_persistent_token( + settings, + remember_me=False, + now=1_700_000_000, + token_id="fixed-token-id", + ) + + assert authentication.verify_persistent_token( + settings, issued.value, now=issued.expires_at - 1 + ) is not None + assert authentication.verify_persistent_token( + settings, issued.value, now=issued.expires_at + ) is None + + +def test_tampering_and_rotations_invalidate_token() -> None: + settings = _settings() + issued = authentication.issue_persistent_token( + settings, + remember_me=True, + now=1_700_000_000, + token_id="fixed-token-id", + ) + replacement = "A" if issued.value[-1] != "A" else "B" + tampered = issued.value[:-1] + replacement + + assert authentication.verify_persistent_token( + settings, tampered, now=1_700_000_001 + ) is None + assert authentication.verify_persistent_token( + replace(settings, signing_secret="n" * 64), + issued.value, + now=1_700_000_001, + ) is None + assert authentication.verify_persistent_token( + replace(settings, admin_password="rotated password"), + issued.value, + now=1_700_000_001, + ) is None + assert authentication.verify_persistent_token( + replace(settings, admin_id="rotated-admin"), + issued.value, + now=1_700_000_001, + ) is None + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"v": 2, "sub": ADMIN_ID, "iat": 1, "exp": 2, "jti": "x"}, + {"v": 1, "sub": "", "iat": 1, "exp": 2, "jti": "x"}, + {"v": 1, "sub": ADMIN_ID, "iat": "1", "exp": 2, "jti": "x"}, + {"v": 1, "sub": ADMIN_ID, "iat": 2, "exp": 2, "jti": "x"}, + {"v": 1, "sub": ADMIN_ID, "iat": 1, "exp": 2, "jti": ""}, + {"v": 1, "sub": ADMIN_ID, "iat": 1, "exp": 2, "jti": "x", "extra": 1}, + ], +) +def test_claim_schema_is_strict(payload: dict[str, object]) -> None: + token = authentication._serializer_for(_settings()).dumps(payload) + + assert authentication.verify_persistent_token( + _settings(), token, now=1 + ) is None + + +def test_oversized_and_malformed_tokens_are_rejected() -> None: + settings = _settings() + + assert authentication.verify_persistent_token(settings, "x" * 10_000, now=1) is None + assert authentication.verify_persistent_token(settings, "not-a-token", now=1) is None + + +def test_payload_does_not_encode_secrets() -> None: + issued = authentication.issue_persistent_token( + _settings(), + remember_me=False, + now=1_700_000_000, + token_id="fixed-token-id", + ) + payload = authentication._serializer_for(_settings()).loads(issued.value) + + assert set(payload) == {"v", "sub", "iat", "exp", "jti"} + assert "password" not in json.dumps(payload).lower() + assert "secret" not in json.dumps(payload).lower() diff --git a/uv.lock b/uv.lock index 11c5bf8..c92cadb 100644 --- a/uv.lock +++ b/uv.lock @@ -481,27 +481,35 @@ dependencies = [ [package.optional-dependencies] dashboard = [ + { name = "itsdangerous" }, { name = "pandas" }, { name = "plotly" }, + { name = "starlette" }, { name = "streamlit" }, + { name = "uvicorn" }, ] dev = [ { name = "pytest" }, { name = "ruff" }, + { name = "websockets" }, ] [package.metadata] requires-dist = [ + { name = "itsdangerous", marker = "extra == 'dashboard'", specifier = ">=2.2.0,<3.0" }, { name = "mcp", specifier = ">=1.0.0" }, { name = "msoffcrypto-tool", specifier = ">=5.4.0" }, { name = "openpyxl", specifier = ">=3.1.0" }, - { name = "pandas", marker = "extra == 'dashboard'", specifier = ">=2.0.0" }, - { name = "plotly", marker = "extra == 'dashboard'", specifier = ">=5.0.0" }, + { name = "pandas", marker = "extra == 'dashboard'", specifier = ">=2.2.0" }, + { name = "plotly", marker = "extra == 'dashboard'", specifier = ">=5.20.0" }, { name = "pymupdf", specifier = ">=1.24.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.0" }, - { name = "streamlit", marker = "extra == 'dashboard'", specifier = ">=1.30.0" }, + { name = "starlette", marker = "extra == 'dashboard'", specifier = ">=0.40.0,<2.0" }, + { name = "streamlit", marker = "extra == 'dashboard'", specifier = ">=1.58.0,<1.59.0" }, + { name = "uvicorn", marker = "extra == 'dashboard'", specifier = ">=0.30.0,<1.0" }, + { name = "websockets", marker = "extra == 'dev'", specifier = ">=15.0,<17.0" }, ] provides-extras = ["dev", "dashboard"] -- 2.54.0 From 91e106d75c32905e0b9d6c89d04f537c72a7c4f8 Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Thu, 1 Oct 2026 13:58:32 +0000 Subject: [PATCH 02/11] docs(requirements): link persistent auth PR --- .../req-001_dashboard-administrator-authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements/req-001_dashboard-administrator-authentication.md b/docs/requirements/req-001_dashboard-administrator-authentication.md index 3a1237f..53089c0 100644 --- a/docs/requirements/req-001_dashboard-administrator-authentication.md +++ b/docs/requirements/req-001_dashboard-administrator-authentication.md @@ -154,6 +154,6 @@ Event log는 event name과 비민감 correlation ID만 기록한다. 허용 even - 최초 요구 Issue: `jsfamily/jarca-ledger-mcp#1` - 지속 로그인 변경 Issue: `jsfamily/jarca-ledger-mcp#6` - 최초 구현 PR: `#3` -- Issue #6 구현 PR: 미정 +- Issue #6 구현 PR: `#7` - Issue #6 기준 revision: `7bea8b368e02e98afd4b2dc463fb270b0e6eb297` - Issue #6 승인 snapshot body SHA-256: `4f734169ec896747fe39fd7f3ab75d1fc0d8cdd4903045e665beba40d80eb1fe` -- 2.54.0 From 7bb959859a84f1d1293afd1f0270cca18f4cd576 Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Fri, 2 Oct 2026 03:02:23 +0000 Subject: [PATCH 03/11] fix(dashboard): address persistent auth review --- README.md | 6 +- dashboard/auth_server.py | 42 +- dashboard/authentication.py | 25 +- deploy/k8s/README.md | 70 +-- docs/interface-contract.md | 253 +++++++--- docs/project-structure.md | 147 +++--- ..._dashboard-administrator-authentication.md | 467 ++++++++++++++---- docs/runtime-flow.md | 145 +++--- tests/test_dashboard_auth_server.py | 29 ++ tests/test_dashboard_authentication_app.py | 28 ++ ...est_dashboard_persistent_authentication.py | 20 +- 11 files changed, 885 insertions(+), 347 deletions(-) diff --git a/README.md b/README.md index 30440a5..885484d 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,9 @@ cd /root/projects/jarca-ledger-mcp uvicorn dashboard.server:app --host 0.0.0.0 --port 8501 ``` -인증 성공 시 Dashboard의 동일 ASGI 프로세스에 포함된 server-side 인증 endpoint가 `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/` 속성의 서명 cookie를 발급합니다. 기본 절대 만료는 24시간이며 `로그인 상태 유지 (30일)`을 선택하면 30일입니다. 일반 요청이나 재접속은 만료를 연장하지 않습니다. 관리자 password 또는 서명 secret을 교체하면 기존 cookie는 즉시 검증에 실패합니다. `로그아웃`은 browser cookie를 삭제하지만, 이미 탈취·복사된 stateless token을 서버에서 개별 폐기하지는 못하므로 사고 대응 시 password 또는 서명 secret을 교체해야 합니다. +인증 성공 시 Dashboard의 동일 ASGI 프로세스에 포함된 server-side 인증 endpoint가 `__Host-jarca_dashboard_auth` cookie를 발급합니다. Cookie는 `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`, no `Domain`이며 token의 `sub`는 관리자 ID가 아닌 비민감 고정 role `dashboard-admin`입니다. 기본 절대 만료는 24시간이며 `로그인 상태 유지 (30일)`을 선택하면 30일입니다. 일반 요청이나 재접속은 만료를 연장하지 않습니다. 관리자 ID, password 또는 서명 secret을 교체하면 현재 설정에 묶인 서명 검증이 실패합니다. malformed·expired·tampered·rotation-invalid 또는 configuration-failure cookie는 보호 자원 전에 거부되고 same-origin `/api/auth/cleanup`에서 matching deletion 후 미인증 상태로 전이합니다. `로그아웃`도 browser cookie를 삭제하지만 stateless token을 서버에서 개별 폐기하지 않으므로 사고 대응 시 credential 또는 서명 secret을 교체해야 합니다. -`Secure` cookie를 사용하므로 browser가 접근하는 공개 origin은 HTTPS여야 하며 login/logout endpoint는 ASGI request scheme도 HTTPS가 아니면 거부합니다. TLS를 Ingress 또는 reverse proxy에서 종료할 때는 `JARCA_DASHBOARD_FORWARDED_ALLOW_IPS`를 실제 proxy source IP/CIDR allowlist로 설정해야 합니다. 기본값 `127.0.0.1`은 cluster ingress를 신뢰하지 않는 fail-closed 값입니다. `*` 또는 불특정 network를 신뢰하지 말고 proxy가 `Host`, `Origin`, `X-Forwarded-Proto`를 정확히 보존·설정하게 하세요. 인증 cookie의 발급·검증은 Dashboard 프로세스가 직접 수행합니다. 잘못된 ID 또는 password 응답은 매 제출마다 최소 1초 지연되지만 애플리케이션 잠금은 없습니다. 이 지연은 병렬 요청으로 우회할 수 있으므로 외부 노출 경계에 별도 rate limiting을 적용해야 합니다. +`Secure` cookie를 사용하므로 browser가 접근하는 공개 origin은 HTTPS여야 하며 login/logout endpoint는 ASGI request scheme도 HTTPS가 아니면 거부합니다. Login과 logout은 same-origin HTTPS POST만 허용하고 cleanup GET은 same-origin HTTPS `Referer`를 검증합니다. TLS를 Ingress 또는 reverse proxy에서 종료할 때는 `JARCA_DASHBOARD_FORWARDED_ALLOW_IPS`를 실제 proxy source IP/CIDR allowlist로 설정해야 합니다. 기본값 `127.0.0.1`은 cluster ingress를 신뢰하지 않는 fail-closed 값입니다. `*` 또는 불특정 network를 신뢰하지 말고 proxy가 `Host`, `Origin`, `Referer`, `X-Forwarded-Proto`를 정확히 보존·설정하게 하세요. 인증 cookie의 발급·검증은 Dashboard 프로세스가 직접 수행합니다. 잘못된 ID 또는 password 응답은 매 제출마다 최소 1초 지연되지만 애플리케이션 잠금은 없습니다. 이 지연은 병렬 요청으로 우회할 수 있으므로 외부 노출 경계에 별도 rate limiting을 적용해야 합니다. 이 로그인은 **Streamlit Dashboard만 보호하며 MCP endpoint를 인증하지 않습니다.** MCP는 별도 요구사항과 적절한 네트워크 경계로 보호해야 합니다. @@ -129,4 +129,4 @@ Streamlit이 SQLite에 직접 접근하지 않고 MCP/API만 호출하도록 변 - 국민은행 PDF import는 암호화 PDF 비밀번호를 입력받아 PyMuPDF로 표를 추출하고, `transaction_datetime`, `counterparty`, `balance_after`, `branch`, `raw_summary`, `raw_payload`에 원본 세부 정보를 보존합니다. - CSV/PDF/API import는 `import_batches`에 묶음 이력을 남기고, 잘못 가져온 batch는 `rollback_ledger_import_batch`로 되돌릴 수 있습니다. - 카테고리에는 `cashflow_kind`를 저장해 실제 가계부 수입/지출(`income`, `expense`)과 계좌이체/투자이동/분석제외(`transfer`, `investment`, `exclude`, `debt_payment`, `unknown`)를 분리합니다. -- CSV import는 먼저 preview 후 저장하는 흐름으로 확장합니다. +- CSV import는 먼저 preview 후 저장하는 흐름으로 확장합니다. \ No newline at end of file diff --git a/dashboard/auth_server.py b/dashboard/auth_server.py index 8d9e115..6dc7186 100644 --- a/dashboard/auth_server.py +++ b/dashboard/auth_server.py @@ -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"]), ] diff --git a/dashboard/authentication.py b/dashboard/authentication.py index c4670fa..fe09bcd 100644 --- a/dashboard/authentication.py +++ b/dashboard/authentication.py @@ -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( + "", + 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) diff --git a/deploy/k8s/README.md b/deploy/k8s/README.md index a17830d..1c718b3 100644 --- a/deploy/k8s/README.md +++ b/deploy/k8s/README.md @@ -1,57 +1,26 @@ # k3s 배포 -`base/`는 `jarca-ledger` namespace에 다음 리소스를 배포하는 Kustomize base다. - -- 단일 `Deployment`와 `Recreate` 전략 -- 동일 ARM64 이미지의 MCP·Dashboard 컨테이너 -- SQLite 초기화를 담당하는 init container -- 공유 `ReadWriteOnce` PVC -- Dashboard용 내부 `ClusterIP` Service -- Dashboard port `8501` ingress만 허용하는 `NetworkPolicy` -- UID/GID `1000:1000`, `fsGroup: 1000`, read-only root filesystem +`base/`는 MCP와 Dashboard를 같은 SQLite PVC에 두는 단일 Deployment 구조입니다. Dashboard 인증 secret은 base manifest에 실제 값으로 저장하지 않습니다. ## 사전 준비 -Base는 실제 Secret 값을 포함하지 않는다. 먼저 namespace를 생성한다. +namespace와 승인된 secret manager 절차를 준비합니다. ```bash kubectl apply -f deploy/k8s/base/namespace.yaml ``` -그 다음 승인된 secret manager나 Git에 기록되지 않는 운영 절차로 다음 Secret을 `jarca-ledger` namespace에 생성한다. +`jarca-ledger` namespace의 Dashboard secret에는 다음 key만 둡니다. -- `jarca-ledger-registry` - - type: `kubernetes.io/dockerconfigjson` - - `gitea.jsfamily.quest`에서 image를 pull할 수 있는 credential -- `jarca-ledger-dashboard-auth` - - key `admin-id` - - key `admin-password` - - key `signing-secret` (32 byte 이상의 독립적인 무작위 값) +- `admin-id` +- `admin-password` +- `signing-secret` (UTF-8 32 byte 이상의 독립 random value) -Registry credential을 application container에 환경변수나 volume으로 전달하지 않는다. `imagePullSecrets`로만 사용한다. - -## Immutable image 지정 - -`base/deployment.yaml`의 release version tag는 템플릿 기본값일 뿐이며 운영 배포에 안전한 immutable 참조가 아니다. Release commit 자체에는 최종 image digest를 넣을 수 없다. Image의 revision label이 release commit을 가리키므로, 그 commit에 digest를 기록하면 commit과 image digest가 다시 바뀌는 자기참조가 발생한다. - -Release가 완료된 뒤 검증된 digest를 사용하는 환경/인프라 저장소의 overlay를 생성한다. 예를 들어 `environments/production/jarca-ledger/kustomization.yaml`은 다음과 같다. - -```yaml -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: - - ../../../../jarca-ledger-mcp/deploy/k8s/base -images: - - name: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp - newName: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp - digest: sha256: -``` - -두 application container와 init container는 반드시 같은 digest를 사용해야 한다. +Registry credential은 `imagePullSecrets`로만 사용합니다. 실제 secret 값, token, cookie를 Git, manifest, command history 또는 문서에 기록하지 마세요. ## 배포 -Namespace와 Secret 준비가 끝난 뒤, base가 아니라 위의 외부 digest-pinned overlay를 적용한다. +검증된 release digest를 외부 environment overlay에서 고정하고 적용합니다. ```bash kubectl apply -k environments/production/jarca-ledger @@ -59,11 +28,24 @@ kubectl -n jarca-ledger rollout status deployment/jarca-ledger kubectl -n jarca-ledger get pods,svc,pvc ``` -Dashboard를 외부에 공개하는 Ingress나 NodePort는 environment overlay에서 추가한다. 인증 cookie가 항상 `Secure`이고 endpoint가 ASGI HTTPS scheme을 요구하므로 공개 origin에는 TLS가 필수다. Base의 `FORWARDED_ALLOW_IPS=127.0.0.1`은 ingress forwarded header를 신뢰하지 않는 fail-closed 기본값이다. TLS를 ingress에서 종료하는 overlay는 이 값을 실제 ingress-controller source IP/CIDR allowlist로 교체해야 하며 `*` 또는 불특정 network를 사용해서는 안 된다. TLS 종료 계층은 `Host`와 `Origin`을 보존하고 `X-Forwarded-Proto: https`를 설정해야 한다. 인증 cookie의 발급·검증 자체는 Dashboard 프로세스가 수행한다. Base에는 MCP Service가 없고, `NetworkPolicy`는 workload ingress를 dashboard port `8501`로 제한하여 인증 계층이 없는 MCP port `8000` ingress를 차단한다. Cluster의 network plugin이 `NetworkPolicy`를 enforce하는지 운영 환경에서 확인한다. +MCP와 Dashboard container 및 init container는 동일 digest를 사용해야 합니다. SQLite를 공유하므로 replicas는 `1`, update 전략은 `Recreate`입니다. + +## Dashboard 인증 운영 + +Frontend container에만 다음 환경변수를 Kubernetes Secret 또는 동등한 secret store에서 주입합니다. + +- `JARCA_DASHBOARD_ADMIN_ID` +- `JARCA_DASHBOARD_ADMIN_PASSWORD` +- `JARCA_DASHBOARD_SIGNING_SECRET` + +환경변수가 누락·빈 값·read error·짧은 secret이면 Dashboard는 fail-closed합니다. token subject는 고정 비민감 role `dashboard-admin`이며 server-only configured credential binding을 사용합니다. DB-backed session/token registry와 per-token revocation은 없습니다. Password, signing secret 또는 admin ID rotation은 기존 token을 전체 무효화합니다. + +공개 Dashboard origin에는 TLS가 필수입니다. Ingress 또는 reverse proxy는 `Host`, `Origin`, `Referer`, `X-Forwarded-Proto: https`를 보존해야 하며 `FORWARDED_ALLOW_IPS`는 실제 ingress-controller source IP/CIDR로 제한합니다. `*` 또는 불특정 network를 사용하지 마세요. Login과 `/api/auth/logout`은 same-origin HTTPS POST이고 `/api/auth/cleanup` GET은 same-origin HTTPS `Referer`를 검증합니다. Invalid cookie는 cleanup route의 matching `Max-Age=0` deletion 뒤 unauthenticated page로 이동하고, explicit logout도 같은 deletion을 수행합니다. 인증 실패 지연은 rate limiting을 대체하지 않으므로 TLS ingress 경계에 별도 rate limit을 적용합니다. + +Base는 인증 계층이 없는 MCP port `8000`을 Service로 공개하지 않고 NetworkPolicy로 ingress를 제한합니다. NetworkPolicy enforcement는 운영 cluster에서 확인해야 합니다. ## SQLite와 PVC -- PVC 전체를 `/data`에 mount한다. SQLite journal, WAL, SHM 파일이 DB 옆에 생성되므로 DB 파일 하나만 `subPath`로 mount하지 않는다. -- `replicas`는 `1`, update 전략은 `Recreate`로 유지한다. RollingUpdate는 일시적으로 두 Pod가 같은 SQLite DB를 열 수 있다. -- k3s `local-path` volume에서는 `fsGroup: 1000` 적용 여부를 기존 PVC에서도 확인한다. 권한이 맞지 않으면 rollout 전에 운영 절차로 소유권을 수정하고, 상시 privileged `chown` init container는 두지 않는다. -- 같은 PVC 안의 backup은 node 또는 disk 손실에 대한 재해 복구가 아니다. 별도 저장소로 backup을 내보낸다. +- PVC 전체를 `/data`에 mount하여 SQLite journal/WAL/SHM을 함께 보존합니다. +- `replicas: 1`과 `Recreate`를 유지하여 두 Pod가 같은 SQLite를 동시에 열지 않게 합니다. +- 별도 저장소로 backup을 내보내며 PVC만 재해 복구로 간주하지 않습니다. diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 39b4e29..a1dfb0f 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -2,102 +2,217 @@ ## 목적과 범위 -Dashboard의 인증 설정, server-side login/logout HTTP endpoint, signed cookie와 Streamlit 인증 경계 계약을 정의한다. +Dashboard 인증 설정, login/cleanup/logout HTTP route, signed cookie와 Streamlit gate의 외부·내부 계약을 정의한다. -- 포함: 관리자 credential, signing secret, login/logout POST, token/cookie, fail-closed page gate -- 제외: MCP 인증, OAuth/OIDC, 다중 사용자, token registry, 외부 인증 middleware -- 공개 origin은 HTTPS여야 한다. +- 포함 범위: 환경변수, HTTPS form POST, cookie 발급·삭제, fail-closed gate +- 제외 범위: MCP 인증, OAuth/OIDC, 다중 사용자, DB session/token registry, per-token revocation +- 사용 주체: browser, Dashboard ASGI server, Streamlit app, 운영 배포 -## 인터페이스 목록 +## 계약 -| 인터페이스 | 종류 | 호출 주체 | 제공 주체 | 버전 | 상태 | +### auth-config — 서버 인증 설정 + +- 책임: 현재 configured credential과 signing secret을 server-only로 제공한다. +- 종류·프로토콜: process environment +- 호출 대상·operation: `load_auth_settings()` +- 버전: 2.0 +- 호출 주체: Dashboard server +- 인증 방식: 없음 +- 필요 권한: process secret read + +#### 입력 + +| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---|---| +| `JARCA_DASHBOARD_ADMIN_ID` | environment | `string` | 필수 | 없음 | non-empty | configured admin identifier | 민감 | +| `JARCA_DASHBOARD_ADMIN_PASSWORD` | environment | `string` | 필수 | 없음 | non-empty | configured credential | 민감 | +| `JARCA_DASHBOARD_SIGNING_SECRET` | environment | `string` | 필수 | 없음 | UTF-8 32 byte 이상 | signing input | 민감 | + +#### 출력 + +| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---| +| `AuthSettings` | server memory | `object` | 성공 시 필수 | current configured values | verifier input; never serialized | 민감 | + +#### 오류 + +| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | |---|---|---|---|---|---| -| `dashboard-auth-config` | environment | 배포 시스템 | `dashboard.authentication` | 2.0 | active | -| `POST /api/auth/login` | HTTPS | browser form | `dashboard.auth_server` | 1.0 | active | -| `POST /api/auth/logout` | HTTPS | browser form | `dashboard.auth_server` | 1.0 | active | -| `dashboard-auth-cookie` | Secure cookie | browser/Streamlit handshake | `dashboard.auth_server` | 1.0 | active | -| `dashboard-auth-gate` | Python/Streamlit | `dashboard.app` | `dashboard.authentication` | 2.0 | active | +| `configuration_unavailable` | configuration | missing/empty/read error/short secret | 수정 후 가능 | generic unavailable | no cookie, no DB | -## 상세 계약 +### login — `POST /api/auth/login` -### dashboard-auth-config +- 책임: same-origin HTTPS credential을 검증하고 signed cookie를 발급한다. +- 종류·프로토콜: HTTPS form POST +- 호출 대상·operation: `/api/auth/login` +- 버전: 1.0 +- 호출 주체: browser login form +- 인증 방식: submitted credential, server-only comparison +- 필요 권한: same-origin HTTPS -| 이름 | 타입 | 필수 | 제약 | 민감정보 | -|---|---|---|---|---| -| `JARCA_DASHBOARD_ADMIN_ID` | string | 예 | non-empty | 예 | -| `JARCA_DASHBOARD_ADMIN_PASSWORD` | string | 예 | non-empty | 예 | -| `JARCA_DASHBOARD_SIGNING_SECRET` | string | 예 | UTF-8 32 byte 이상 | 예 | +#### 입력 -누락·빈 값·비문자열·read error·짧은 signing secret은 일반 설정 오류로 fail-closed한다. Secret 원문이나 read-error 원인은 사용자 응답과 log에 포함하지 않는다. +| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---|---| +| `admin_id` | form body | `string` | 필수 | 없음 | bounded form value | submitted ID | 민감 | +| `admin_password` | form body | `string` | 필수 | 없음 | bounded form value | submitted password | 민감 | +| `remember_me` | form body | `string` | 선택 | off | `on` or absent | choose 30-day TTL | 비민감 | +| `Origin` | request header | `URL` | 필수 | 없음 | request `Host`와 scheme·hostname·effective port가 같은 HTTPS origin | CSRF origin 검증 | 비민감 | -### POST /api/auth/login +#### 출력 -- Content-Type: `application/x-www-form-urlencoded` -- Transport: trusted ASGI scope의 request scheme이 `https`여야 함 -- Origin: request host·effective port와 같은 HTTPS origin만 허용 -- 최대 body: 4096 bytes -- 입력: +| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---| +| `Set-Cookie` | response header | `cookie` | success | hardened auth cookie, redacted value | persistent auth | 값은 민감 | +| `Location` | response header | `string` | success/failure | `/` or generic error path | browser navigation | 비민감 | -| 필드 | 타입 | 필수 | 의미 | -|---|---|---|---| -| `admin_id` | string | 예 | 제출 관리자 ID | -| `admin_password` | string | 예 | 제출 password | -| `remember_me` | string | 아니요 | 값이 `on`이면 30일, 아니면 24시간 | +#### 오류 -- 성공: `303 Location: /`, hardened cookie 1개 발급 -- credential 실패: 최소 1초 후 `303 Location: /?auth_error=invalid`, cookie 없음 -- 설정 실패: `303 Location: /?auth_error=unavailable`, cookie 없음 -- origin 실패: `403`, cookie 없음 -- plain HTTP 또는 신뢰되지 않은 forwarded scheme: `403`, cookie 없음 -- malformed/oversized request: `400`, cookie 없음 -- ID와 password는 모두 `hmac.compare_digest`로 비교한다. +| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | +|---|---|---|---|---|---| +| `400` | request | malformed/oversized body | 수정 후 가능 | resubmit valid form | no cookie | +| `403` | origin | non-HTTPS or cross-origin | 수정 후 가능 | use same-origin HTTPS | no cookie | +| `invalid` | authentication | ID/password mismatch | 가능 | generic error and delay | no cookie | +| `unavailable` | configuration | invalid server configuration | config fix 후 | generic unavailable | no cookie | -### dashboard-auth-cookie +### cleanup — `GET /api/auth/cleanup` -| 속성 | 값 | -|---|---| -| 이름 | `__Host-jarca_dashboard_auth` | -| `Secure` | true | -| `HttpOnly` | true | -| `SameSite` | `Lax` | -| `Path` | `/` | -| `Domain` | 없음 | -| `Max-Age` | 86400 또는 2592000 | +- 책임: invalid cookie를 matching scope로 삭제한 뒤 unauthenticated navigation을 완료한다. +- 종류·프로토콜: HTTPS navigation GET +- 호출 대상·operation: `/api/auth/cleanup` +- 버전: 1.0 +- 호출 주체: same-origin browser/auth gate +- 인증 방식: same-origin HTTPS `Referer`; token validity 불필요 +- 필요 권한: same-origin HTTPS -Token payload는 `v`, `sub`, `iat`, `exp`, `jti`만 허용한다. 서명 key는 signing secret과 현재 admin password를 domain-separated HMAC-SHA256으로 결합해 파생하며 `itsdangerous.URLSafeSerializer` SHA-256 signer를 사용한다. Token은 일반 요청에서 갱신하지 않는다. +#### 입력 -### POST /api/auth/logout +| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---|---| +| `Referer` | request header | `URL` | 필수 | 없음 | request origin과 같은 HTTPS authority | navigation origin 검증 | 비민감 | -- Same-origin HTTPS POST만 허용한다. -- 성공: `303 Location: /`과 동일 name/path/security 속성의 `Max-Age=0` cookie deletion -- 금융 상태와 DB는 변경하지 않는다. -- Stateless token 복사본은 개별 폐기하지 않는다. 강제 무효화는 password/signing-secret rotation으로 수행한다. +#### 출력 -### dashboard-auth-gate +| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---| +| `Set-Cookie` | response header | `cookie` | always | same name/path/security, `Max-Age=0` | delete stale cookie | 값은 민감 | +| `Location` | response header | `string` | success | `/` | unauthenticated page | 비민감 | -`dashboard.app.main()`은 page config 직후 gate를 호출한다. Gate는 현재 설정으로 cookie signature와 strict claims를 검증한다. 유효할 때만 DB path, gateway와 page dispatch를 실행한다. Token, credential, signing material은 `st.session_state`에 저장하지 않는다. 비민감 correlation ID만 session state에 둘 수 있다. +#### 오류 -## 호환성·순서·재시도 +| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | +|---|---|---|---|---|---| +| `403` | origin | non-HTTPS/cross-origin | 가능 | retry same-origin | no DB | -- 기존 `page` query parameter와 page dispatch는 유효 인증 이후 그대로 유지한다. -- REQ-001 revision 2.0이 revision 1.0의 session-only boolean 계약을 signed-cookie 계약으로 대체한다. -- 설정 판정 → cookie 검증 → DB path → page dispatch 순서를 유지한다. -- password, signing secret 또는 관리자 ID 변경은 기존 token을 거부한다. -- 반복 실패는 application lock을 만들지 않으며 각 credential 실패에 최소 1초 지연을 적용한다. +### logout — `POST /api/auth/logout` + +- 책임: explicit logout으로 browser cookie를 삭제한다. +- 종류·프로토콜: HTTPS form POST +- 호출 대상·operation: `/api/auth/logout` +- 버전: 1.0 +- 호출 주체: authenticated browser +- 인증 방식: same-origin HTTPS; server-side revocation 없음 +- 필요 권한: same-origin HTTPS + +#### 입력 + +| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---|---| +| `none` | none | `none` | 선택 | 없음 | no credential accepted | explicit logout | 없음 | +| `Origin` | request header | `URL` | 필수 | 없음 | request `Host`와 scheme·hostname·effective port가 같은 HTTPS origin | CSRF origin 검증 | 비민감 | + +#### 출력 + +| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---| +| `Set-Cookie` | response header | `cookie` | always | matching deletion, `Max-Age=0` | remove browser cookie | 값은 민감 | +| `Location` | response header | `string` | success | `/` | unauthenticated page | 비민감 | + +#### 오류 + +| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | +|---|---|---|---|---|---| +| `403` | origin | non-HTTPS/cross-origin | 가능 | retry same-origin | no DB | + +### gate — Dashboard 인증 경계 + +- 책임: config and cookie verification before any protected work. +- 종류·프로토콜: Python/Streamlit call +- 호출 대상·operation: `require_dashboard_authentication()` +- 버전: 2.0 +- 호출 주체: `dashboard.app.main()` +- 인증 방식: current config plus signed cookie +- 필요 권한: valid token with `sub=dashboard-admin` + +#### 입력 + +| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---|---| +| `auth_cookie` | request cookie | `string` | success path | absent | signed token | browser proof | 민감 | + +#### 출력 + +| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 | +|---|---|---|---|---|---|---| +| `authenticated` | server result | `bool` | always | true/false | gate decision | 비민감 | + +#### 오류 + +| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | +|---|---|---|---|---|---| +| `unauthenticated` | authentication | absent/invalid/expired/config failure | login 가능 | cleanup then show login | no protected work | + +## 호환성 + +- 유지할 기존 호출 방식: authenticated `page` query parameter, page dispatch, gateway와 MCP behavior +- 버전 변경 정책: REQ-001 revision 2.0; session-only boolean을 signed cookie로 대체 +- 폐기 정책: client-side token storage, DB-backed session, per-token revocation 없음 +- 재시도 계약: credential 실패는 최소 1초 지연; origin/request 오류는 수정 후 재시도 +- 중복 처리 계약: login은 새 token 발급 가능; cleanup/logout deletion은 idempotent +- 순서 보장: config → cookie verify → cleanup if invalid → DB/page dispatch only if valid ## 예시 +### example-login — 정상 login + +- 대상 인터페이스: `login` + +#### 호출 + ```text -POST /api/auth/login (same-origin HTTPS) +POST /api/auth/login +Origin: https://dashboard.example admin_id=&admin_password=&remember_me=on -→ 303 / -→ Set-Cookie: __Host-jarca_dashboard_auth=; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000 +``` + +#### 결과 + +```text +303 Location: / +Set-Cookie: __Host-jarca_dashboard_auth=; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000 +``` + +### example-cleanup — invalid cookie cleanup + +- 대상 인터페이스: `cleanup` + +#### 호출 + +```text +GET /api/auth/cleanup +Referer: https://dashboard.example/ +``` + +#### 결과 + +```text +303 Location: / +Set-Cookie: __Host-jarca_dashboard_auth=; Max-Age=0; Secure; HttpOnly; SameSite=Lax; Path=/ ``` ## 검증 -- Token: `tests/test_dashboard_persistent_authentication.py` -- HTTP endpoint/cookie: `tests/test_dashboard_auth_server.py` -- Gate/DB ordering: `tests/test_dashboard_authentication_app.py` -- HTTPS attributes/reconnect/logout: `scripts/persistent_auth_http_evidence.py` -- 실제 secret/token 값은 test output과 evidence에 남기지 않는다. +- 정상 계약: authentication/server tests와 loopback HTTPS evidence +- 실패 계약: wrong credential, invalid configuration, cross-origin, malformed cookie tests +- 경계 계약: expiry, rotation, oversized token, same-host different-port origin tests +- 구현·스키마·예시 정합성: `tests/test_dashboard_auth_server.py`, `tests/test_dashboard_authentication_app.py`, `tests/test_dashboard_persistent_authentication.py`, source/document redaction search diff --git a/docs/project-structure.md b/docs/project-structure.md index d5dfcc5..60ac861 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -1,86 +1,101 @@ -# Project Structure — Dashboard Persistent Authentication +# Project Structure — Dashboard 관리자 인증 -## 목적과 범위 +## 목적과 경계 -Issue #6의 signed persistent authentication을 기존 Dashboard page 구조와 분리하여 배치하는 규칙을 정의한다. DB schema와 MCP 구조는 변경하지 않는다. +Issue #6 인증을 기존 Dashboard 구조에 배치하고 fail-closed 경계와 server-only secret binding을 유지한다. -## Source 영역 +- 포함 경계: `dashboard/` 인증 adapter/gate, tests, 운영 문서 +- 제외 경계: MCP transport, 금융 schema, DB migration, external auth middleware -| 경로 | 책임 | 주요 공개 요소 | 금지 의존성 | -|---|---|---|---| -| `dashboard/server.py` | Streamlit ASGI app과 인증 route 조립 | `app` | DB/gateway 직접 접근 | -| `dashboard/auth_server.py` | same-origin login/logout HTTP response와 hardened cookie | `create_auth_routes()` | page view, SQLite | -| `dashboard/authentication.py` | 설정, credential 비교, token 발급·검증, Streamlit gate | `require_dashboard_authentication()` | 금융 DB/gateway | -| `dashboard/app.py` | gate 성공 후 기존 page dispatch | `main()` | token signing 세부 구현 | -| `dashboard/common.py` | 기존 DB/UI helper | 기존 API | 인증 결정 | -| `dashboard/page_views/` | 기존 금융 화면 | `page_*` | 인증 transport | +## 디렉터리 구조 -`dashboard.server:app`이 운영 진입점이다. 인증 endpoint는 Streamlit 1.58의 public ASGI `App(routes=...)` 확장 지점에 같은 process로 결합한다. +### dashboard-source — Dashboard source -## Test 영역 +```text +dashboard/ +├── server.py +├── auth_server.py +├── authentication.py +├── app.py +└── page_views/ +``` -| 경로 | 검증 책임 | -|---|---| -| `tests/test_dashboard_authentication.py` | 기존 credential 비교·지연·safe log | -| `tests/test_dashboard_persistent_authentication.py` | token claims, TTL, tamper, rotation, malformed input | -| `tests/test_dashboard_auth_server.py` | login/logout response, cookie 속성, same-origin, fail-closed | -| `tests/test_dashboard_authentication_app.py` | Streamlit gate, DB 이전 차단, cookie 복원·rotation | -| `tests/test_dashboard_app.py` | 인증 이후 기존 핵심 page regression | -| `scripts/persistent_auth_http_evidence.py` | 실제 loopback HTTPS cookie/reconnect/logout evidence | +| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 | +|---|---|---|---|---|---|---| +| `dashboard/server.py` | source | ASGI app 조립 | `app` | route wiring only | source review required | developer | +| `dashboard/auth_server.py` | source | login/cleanup/logout response | `create_auth_routes()` | no DB/page imports | source review required | developer | +| `dashboard/authentication.py` | source | config, constant-time compare, token verify, gate | `require_dashboard_authentication()` | server-only secret use | source review required | developer | +| `dashboard/app.py` | source | gate then page dispatch | `main()` | no token signing details | source review required | developer | +| `dashboard/page_views/` | source | financial UI pages | `page_*` functions | no auth transport calls | source review required | developer | -Test는 synthetic DB와 test-only credential/secret만 사용하고 token·secret 원문을 출력하지 않는다. +### verification-tests — 인증 검증 -## Config·배포·문서 영역 +```text +tests/ +├── test_dashboard_authentication.py +├── test_dashboard_persistent_authentication.py +├── test_dashboard_auth_server.py +└── test_dashboard_authentication_app.py +``` -| 경로 | 책임 | -|---|---| -| `pyproject.toml`, `uv.lock` | `itsdangerous`, `starlette`, `uvicorn` 직접 dependency 고정 | -| `.env.example` | 세 environment 변수의 이름만 제공 | -| `docker-compose.yml` | signing secret 전달과 ASGI server command | -| `deploy/k8s/base/deployment.yaml` | Kubernetes Secret `signing-secret` 전달과 ASGI command | -| `README.md`, `deploy/k8s/README.md` | HTTPS, rotation, expiry, rate-limit, logout 제약 | -| `docs/requirements/req-001_dashboard-administrator-authentication.md` | Issue #1·#6 통합 canonical 승인 명세 | -| `docs/interface-contract.md` | HTTP/cookie/token 계약 | -| `docs/runtime-flow.md` | 발급·복원·실패·logout 순서 | +| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 | +|---|---|---|---|---|---|---| +| `tests/test_dashboard_authentication.py` | test | credential compare, delay, redaction | pytest tests | test-only settings | source change paired with test | developer | +| `tests/test_dashboard_persistent_authentication.py` | test | claims, TTL, tamper, rotation | pytest tests | no real secrets | source change paired with test | developer | +| `tests/test_dashboard_auth_server.py` | test | HTTP routes and cookie scope | pytest tests | synthetic test credentials | source change paired with test | developer | +| `tests/test_dashboard_authentication_app.py` | test | gate ordering and cleanup/UI | AppTest | fixture DB only | source change paired with test | developer | + +### deployment-docs — 운영 문서 + +```text +docs/requirements/req-001_dashboard-administrator-authentication.md +docs/interface-contract.md +docs/runtime-flow.md +README.md +deploy/k8s/README.md +``` + +| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 | +|---|---|---|---|---|---|---| +| `docs/requirements/req-001_dashboard-administrator-authentication.md` | requirement | approved REQ-001 revision 2.0 | REQ-001 | canonical traceability | update in place | developer | +| `docs/interface-contract.md` | contract | HTTP/cookie/gate contracts | contract IDs | no implementation secrets | update with API changes | developer | +| `docs/runtime-flow.md` | design | runtime states/transitions | flow IDs | no secret values | update with flow changes | developer | +| `README.md` | operations | local/deployment constraints | operator commands | no secret values | operational review | developer | +| `deploy/k8s/README.md` | operations | k3s secret/TLS procedure | deployment procedure | no secret values | deployment review | developer | ## 의존 방향 -```text -browser - ├─ POST /api/auth/login|logout → dashboard.auth_server - └─ Streamlit handshake cookie → dashboard.authentication - -dashboard.server → dashboard.auth_server + Streamlit App(dashboard/app.py) -dashboard.app → dashboard.authentication → itsdangerous - → (인증 성공 후) dashboard.common/page_views -``` - -`auth_server`와 `authentication`은 금융 DB를 import하지 않는다. Page module은 cookie와 token module을 직접 호출하지 않는다. - -## 상태·데이터 배치 - -- Credential/signing secret: process environment에서 request/rerun 동안만 참조 -- Auth token: browser의 `Secure`/`HttpOnly` cookie에만 저장 -- Correlation ID: 비민감 값으로 `st.session_state`에 저장 가능 -- 금융 데이터: 기존 SQLite -- Token registry/revocation table/migration: 생성하지 않음 - -따라서 `docs/data-model.md`는 추가하지 않는다. +| 출발 영역 | 대상 영역 | 허용 여부 | 규칙 | 공유 계약 위치 | +|---|---|---|---|---| +| `dashboard.server` | `dashboard.auth_server` | 허용 | compose ASGI routes only | `docs/interface-contract.md` | +| `dashboard.server` | `dashboard.app` | 허용 | app remains protected by gate | `docs/runtime-flow.md` | +| `dashboard.app` | `dashboard.authentication` | 허용 | gate precedes DB/page dispatch | `docs/interface-contract.md` | +| `dashboard.authentication` | `dashboard.page_views` | 금지 | auth module never imports financial pages | `docs/project-structure.md` | +| `dashboard.auth_server` | `jarca_ledger_mcp` DB | 금지 | login/cleanup/logout are DB-free | `docs/requirements/req-001_dashboard-administrator-authentication.md` | ## 변경 규칙 -- 인증 환경변수는 `JARCA_DASHBOARD_` prefix를 사용한다. -- Cookie name은 host-only scope를 강제하는 `__Host-` prefix를 사용한다. -- Token schema/TTL/cookie attributes 변경은 REQ-001과 interface contract를 함께 변경한다. -- Secret, token, cookie header, credential 값을 source·fixture·log·문서에 기록하지 않는다. -- Streamlit private Tornado/WebSocket internals나 client-side cookie component를 사용하지 않는다. +| 구분 | 규칙 | +|---|---| +| 새 모듈·파일 배치 | auth route/gate changes stay in `dashboard/`; no DB session module | +| 이름·패키지 | use existing `dashboard` package and snake_case modules | +| 소스·테스트 대응 | auth source behavior requires focused pytest/AppTest coverage | +| 생성 산출물 | evidence may be redacted; never commit credentials, secrets, tokens, or cookie values | ## 검증 -```text -uv run --extra dashboard --extra dev pytest -uv run --extra dashboard --extra dev ruff check dashboard tests scripts -uv run python -m compileall -q dashboard tests scripts -``` +- 구조 검사 명령: -Diff에서 DB migration과 `docs/data-model.md`가 추가되지 않았는지 확인한다. + ```text + python -m compileall -q dashboard tests scripts + ``` + +- 구조 검사 기대 결과: exit 0 and no generated source outside ignored bytecode locations +- 정적 분석 명령: + + ```text + uv run --extra dashboard --extra dev ruff check dashboard tests scripts + ``` + +- 정적 분석 기대 결과: exit 0 with no diagnostics +- 문서와 실제 트리의 정합성: every listed source/test/document path exists; dependency table has no forbidden reverse edge diff --git a/docs/requirements/req-001_dashboard-administrator-authentication.md b/docs/requirements/req-001_dashboard-administrator-authentication.md index 53089c0..c89ae49 100644 --- a/docs/requirements/req-001_dashboard-administrator-authentication.md +++ b/docs/requirements/req-001_dashboard-administrator-authentication.md @@ -15,145 +15,432 @@ ## 1. 목적 -Streamlit Dashboard의 모든 금융 데이터 조회·수정·import 기능 앞에 단일 관리자 인증 경계를 두고, 인증되지 않았거나 인증 설정이 유효하지 않은 요청을 fail-closed한다. 인증 성공 후에는 server-signed `Secure`/`HttpOnly` cookie로 같은 browser profile의 새 연결과 새 session에서 인증을 정해진 절대 만료까지 복원한다. +Dashboard의 금융 기능 앞에 단일 fail-closed 관리자 인증 경계를 두고, 유효한 인증 cookie가 같은 browser profile의 새 연결에서 복원되도록 한다. ## 2. 배경 -Revision 1.0은 Issue #1에서 단일 관리자 credential과 browser-session-only 인증 경계를 도입했다. Issue #6은 sidebar query-link 이동과 browser 재접속에서 반복 로그인이 발생하는 문제를 해결하기 위해 동일한 인증 기능의 session·logout·배포 계약을 persistent cookie 방식으로 확장한다. 새 기능 ID가 아니라 본 canonical 요구사항의 revision으로 관리한다. +Issue #1의 관리자 인증을 Issue #6에서 persistent signed cookie와 명시적 logout으로 확장한다. 기존 요구사항의 정체성, 승인 범위, Issue #1과 구현 PR #3의 추적성을 유지하며 revision 2.0으로 갱신한다. ## 3. 범위 ### 포함 -- 모든 Dashboard 페이지 앞의 공통 관리자 인증과 인증 전 DB 차단 -- 환경변수 기반 관리자 ID/password 및 독립적인 signing secret +- Dashboard 전 기능 앞의 인증 gate와 인증 전 DB 차단 +- `JARCA_DASHBOARD_ADMIN_ID`, `JARCA_DASHBOARD_ADMIN_PASSWORD`, `JARCA_DASHBOARD_SIGNING_SECRET` 기반 server-only 검증 - constant-time credential 비교, 일반 오류, 실패당 최소 1초 지연 -- 동일 Dashboard ASGI process의 server-side login/logout HTTP endpoint -- server-signed `Secure`/`HttpOnly` cookie 발급·검증·삭제 -- 기본 24시간, 선택 시 30일인 로그인 성공 시각 기준 절대 만료 -- sidebar 페이지 이동, 새 탭, browser 종료 후 재접속과 새 Streamlit session 복원 -- password/signing-secret/admin-ID rotation에 따른 기존 token 무효화 -- credential·secret·token 비노출과 인증 event 관찰 가능성 -- Compose, Kubernetes Secret, HTTPS와 운영 제약 문서화 +- same-origin HTTPS login, cleanup, logout route +- `Secure`/`HttpOnly`/`SameSite=Lax`/`Path=/` signed cookie와 24시간·30일 absolute TTL +- 고정된 비민감 role subject와 설정 credential에 묶인 서명 검증 +- invalid, expired, tampered, malformed, configuration-failure cookie의 cleanup 및 matching deletion +- password, signing-secret, admin-ID 변경에 따른 기존 token 거부 +- HTTPS, proxy, Kubernetes/Compose 운영 제약과 민감정보 비노출 ### 제외 -- 다중 사용자, 회원가입, RBAC, 사용자별 데이터 분리 -- OAuth/OIDC, SSO, 외부 IdP, MFA, CAPTCHA, 계정 잠금 -- 외부 인증 proxy/gateway 또는 외부 session middleware로 인증 책임 이전 -- sliding expiration 또는 복원 시 만료 재설정 -- DB-backed token/session registry, 개별 원격 session 폐기 UI -- MCP endpoint 인증과 MCP transport 변경 -- SQLite 금융 schema와 데이터 모델 변경 +- 다중 사용자, RBAC, OAuth/OIDC, SSO, MFA, CAPTCHA, 계정 잠금 +- 외부 인증 proxy로 책임 이전 +- sliding expiration, DB-backed session/token registry, per-token revocation +- MCP endpoint 인증 변경, SQLite schema/data migration - 다른 browser profile·기기 간 로그인 공유 ## 4. 용어와 기준 -- **persistent auth token**: version, 관리자 subject, 발급 시각, 절대 만료, token ID만 포함하고 서버가 서명한 bearer token -- **remember me**: 기본 미선택 checkbox이며 token TTL을 24시간에서 30일로 선택 -- **rotation**: 관리자 password 또는 signing secret을 교체하여 기존 token 서명 검증을 실패시키는 운영 절차 -- **canonical 근거**: Issue #1, Issue #6, source revisions `069d4c16fd84e871e8cd9dc8d9ae39b4fce4a6c6` 및 `7bea8b368e02e98afd4b2dc463fb270b0e6eb297` +- **role subject**: token의 `sub`에 고정 기록하는 비민감 값 `dashboard-admin`; 관리자 ID나 credential이 아니다. +- **server-only binding**: signing secret과 현재 설정 credential을 서버에서만 읽어 key를 파생하며 browser에 값을 보내지 않는 방식. +- **cleanup**: 인증 실패 cookie를 같은 origin의 cleanup route에서 matching name/path/security 속성으로 삭제하는 절차. +- **canonical 근거**: Issue #1, Issue #6, 구현 PR #3 및 #7. ## 5. 요구사항 ### REQ-001.01 — 공통 인증 경계 -Dashboard는 DB path 취득, page dispatch, gateway 호출, upload, rollback 또는 DB-backed 렌더링보다 먼저 현재 설정과 signed cookie를 검증해야 한다. 기존 session boolean만으로 만료·rotation 검증을 우회해서는 안 된다. 실패 시 로그인 화면만 렌더링한다. +모든 DB path, page dispatch, gateway, upload, import보다 먼저 설정과 cookie를 검증하고, 실패하면 로그인 화면만 표시한다. -### REQ-001.02 — 설정과 fail-closed +**영향 범위** -Credential source는 `JARCA_DASHBOARD_ADMIN_ID`, `JARCA_DASHBOARD_ADMIN_PASSWORD`뿐이며 signing source는 `JARCA_DASHBOARD_SIGNING_SECRET`뿐이다. 누락·빈 값·비문자열·read error 또는 UTF-8 32 byte 미만 signing secret은 일반 설정 오류로 fail-closed한다. Streamlit secrets나 fallback을 사용하지 않는다. +- 인터페이스: `dashboard-auth-gate` +- 영속성: 없음 +- 후속 사용 주체: Dashboard page와 gateway +- 마이그레이션: 없음 +- 호환성: 인증 후 기존 page/query 동작 유지 +- 배포 전제 조건: 유효한 세 환경변수 -### REQ-001.03 — Credential 검증과 비노출 +### REQ-001.02 — 설정과 server-only binding -제출 ID와 password는 모두 `hmac.compare_digest`로 비교한다. Credential, signing secret, signing-key material, token, raw cookie와 `Set-Cookie` 값은 UI, traceback, URL/query, application log, 문서 또는 `st.session_state`에 저장하지 않는다. 비민감 correlation ID만 session state에 둘 수 있다. +세 환경변수만 사용하고 누락·빈 값·read error·짧은 signing secret이면 fail-closed한다. signing key는 서버에서 configured credential과 secret으로만 파생한다. -### REQ-001.04 — 실패 로그인 응답 +**영향 범위** -잘못된 ID 또는 password의 각 제출은 처리 시작부터 응답까지 최소 1초가 걸려야 한다. 두 실패는 동일한 오류를 표시하고 cookie·DB·gateway·금융 상태를 변경하지 않는다. 성공에는 실패 지연을 적용하지 않으며 application lock은 두지 않는다. +- 인터페이스: `dashboard-auth-config`, `dashboard-auth-cookie` +- 영속성: 없음 +- 후속 사용 주체: 인증 verifier +- 마이그레이션: 없음 +- 호환성: Streamlit secrets/fallback 미사용 +- 배포 전제 조건: secret manager 주입 -### REQ-001.05 — Persistent session과 절대 만료 +### REQ-001.03 — Credential과 민감정보 비노출 -정상 로그인도 24시간, 기본 미선택 `로그인 유지`를 선택한 로그인은 30일의 절대 만료를 사용한다. 발급 시 `iat`와 `exp`를 고정하고 page 이동, rerun, 활동, 새 tab, browser 재시작 또는 인증 복원으로 token이나 만료를 갱신하지 않는다. 같은 browser profile의 유효 cookie는 새 Streamlit session에서 인증을 복원한다. +ID와 password는 constant-time 비교한다. credential, secret, signing material, token, raw cookie와 Set-Cookie 값은 UI, URL, log, 문서, session state에 기록하지 않는다. -### REQ-001.06 — Server-side adapter와 cookie +**영향 범위** -Streamlit 1.58의 ASGI `App` custom route를 사용해 동일 Dashboard process의 실제 HTTP response에서 cookie를 발급·삭제한다. JavaScript/localStorage/client-side cookie component로 token을 저장하지 않는다. Cookie는 `__Host-` 이름, `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`, no `Domain`이며 `Max-Age`는 token TTL과 같다. +- 인터페이스: login form, event log +- 영속성: 없음 +- 후속 사용 주체: 운영자와 감사 도구 +- 마이그레이션: 없음 +- 호환성: 비민감 correlation ID만 유지 +- 배포 전제 조건: redacted logging -### REQ-001.07 — Token 서명·claim·rotation +### REQ-001.04 — Login 실패 계약 -Token payload는 `v`, 현재 관리자 ID `sub`, server `iat`, `exp`, 무작위 `jti`만 허용한다. Signing key는 독립 signing secret과 현재 관리자 password를 domain-separated HMAC-SHA256으로 결합해 파생하고 SHA-256 signer를 사용한다. Signature를 검증한 뒤 exact schema/type, subject, 허용 TTL, 미래 발급 시각과 server-side 만료를 검증한다. Password, signing secret 또는 관리자 ID 변경은 기존 token을 거부해야 한다. +ID 또는 password가 틀리면 동일한 일반 오류와 최소 1초 지연을 적용하고 상태를 변경하지 않는다. 성공에는 지연을 적용하지 않는다. -### REQ-001.08 — Login/logout HTTP 계약 +**영향 범위** -Login endpoint는 trusted ASGI request scheme과 Origin이 모두 same-origin HTTPS인 form POST만 받고 성공 시 hardened cookie와 root redirect를 반환한다. Logout endpoint도 같은 HTTPS 검증 후 동일 name/path/security 속성과 `Max-Age=0`으로 browser cookie를 삭제한다. Logout은 금융 DB를 변경하지 않는다. Stateless token 복사본은 개별 폐기하지 않으며 전체 강제 무효화는 password/signing-secret rotation으로 수행한다. +- 인터페이스: `POST /api/auth/login` +- 영속성: 없음 +- 후속 사용 주체: browser +- 마이그레이션: 없음 +- 호환성: 기존 login form 유지 +- 배포 전제 조건: 외부 rate limiting -### REQ-001.09 — 보호 범위와 기존 기능 호환성 +### REQ-001.05 — Persistent token -인증 후 기존 page 목록, `page` query parameter, sidebar 이동, gateway와 MCP 동작은 유지한다. 인증은 Dashboard만 보호하고 MCP endpoint 인증을 의미하지 않는다. 금융 SQLite schema/migration은 변경하지 않는다. +성공 login은 기본 24시간, remember-me 선택 시 30일의 absolute TTL token을 발급한다. `iat`와 `exp`는 고정하며 활동·rerun·복원으로 연장하지 않는다. `sub`는 항상 `dashboard-admin`이다. -### REQ-001.10 — 배포·운영 제약 +**영향 범위** -`.env.example`, Compose와 Kubernetes는 세 secret setting 이름만 정의하고 실제 값을 저장하지 않는다. 공개 browser origin은 HTTPS여야 한다. TLS proxy를 사용하는 배포는 실제 proxy source IP/CIDR만 Uvicorn forwarded-header allowlist에 넣고 Host/Origin을 보존하며 `X-Forwarded-Proto: https`를 설정한다. 기본 allowlist는 loopback으로 fail-closed하고 wildcard trust를 금지한다. 최소 1초 delay는 병렬 brute-force를 막지 않으므로 외부 노출 경계에 rate limiting이 필요하다. 모든 verifier replica는 동일 secret과 token 규칙을 사용해야 한다. +- 인터페이스: `dashboard-auth-cookie` +- 영속성: browser cookie만 +- 후속 사용 주체: 새 Streamlit session +- 마이그레이션: 기존 session boolean에서 cookie로 전환 +- 호환성: 같은 browser profile 복원 +- 배포 전제 조건: HTTPS + +### REQ-001.06 — Cookie 보안 속성 + +Cookie 이름은 `__Host-jarca_dashboard_auth`이며 `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`, no `Domain`을 사용한다. 일반 요청은 재발급하지 않는다. + +**영향 범위** + +- 인터페이스: HTTP response와 browser +- 영속성: browser cookie +- 후속 사용 주체: browser/Streamlit handshake +- 마이그레이션: 없음 +- 호환성: host-only cookie +- 배포 전제 조건: 공개 origin HTTPS + +### REQ-001.07 — Invalid cookie cleanup + +없음·malformed·oversized·tampered·expired·claim-invalid·rotation-invalid 또는 configuration-failure cookie는 보호 자원에 접근하기 전에 거부한다. 같은 origin cleanup route를 통해 동일 name/path/security 속성과 `Max-Age=0` deletion을 반환한 뒤 unauthenticated page를 표시한다. + +**영향 범위** + +- 인터페이스: `GET /api/auth/cleanup`, `dashboard-auth-gate` +- 영속성: 서버 session/registry 없음 +- 후속 사용 주체: browser +- 마이그레이션: 없음 +- 호환성: stale cookie가 남지 않음 +- 배포 전제 조건: same-origin HTTPS + +### REQ-001.08 — Login, cleanup, logout + +Login과 logout은 trusted same-origin HTTPS POST만 허용하고 cleanup GET은 same-origin HTTPS `Referer`를 검증한다. logout은 명시적으로 matching deletion을 반환한다. cleanup과 logout은 금융 DB를 변경하지 않는다. + +**영향 범위** + +- 인터페이스: `POST /api/auth/login`, `GET /api/auth/cleanup`, `POST /api/auth/logout` +- 영속성: 없음 +- 후속 사용 주체: browser +- 마이그레이션: 없음 +- 호환성: root redirect 유지 +- 배포 전제 조건: proxy가 Host/Origin/Proto 보존 + +### REQ-001.09 — Rotation과 무상태성 + +Token claim은 `v`, 고정 `sub`, `iat`, `exp`, `jti`만 허용한다. password, signing secret 또는 admin ID 변경은 기존 token을 거부한다. per-token revocation이나 DB session은 만들지 않는다. + +**영향 범위** + +- 인터페이스: token verifier +- 영속성: revocation/session 저장소 없음 +- 후속 사용 주체: 운영 rotation 절차 +- 마이그레이션: 없음 +- 호환성: 전체 무효화는 rotation으로 수행 +- 배포 전제 조건: 모든 replica 동일 설정 + +### REQ-001.10 — 보호 범위와 운영 + +인증 후 기존 page, query parameter, gateway와 MCP 동작을 유지한다. Compose/Kubernetes는 실제 secret을 저장하지 않으며 HTTPS, forwarded-header allowlist와 rate limit을 적용한다. + +**영향 범위** + +- 인터페이스: Dashboard 운영 진입점 +- 영속성: 기존 SQLite만 +- 후속 사용 주체: 운영 배포 +- 마이그레이션: DB migration 없음 +- 호환성: MCP endpoint는 별도 계약 +- 배포 전제 조건: TLS와 제한된 proxy CIDR ### REQ-001.11 — 관찰 가능성 -Event log는 event name과 비민감 correlation ID만 기록한다. 허용 event에는 설정 오류, 인증 필요, token 수락, login 성공/실패/reject, logout이 포함된다. ID/password/secret/token/cookie/request body와 구체적인 검증 실패 원인은 기록하지 않는다. +설정 오류, 인증 필요, token 수락, login 성공/실패/reject, cleanup, logout event와 비민감 correlation ID만 기록한다. 민감 값과 구체적 검증 원인은 기록하지 않는다. -## 6. 인터페이스 요약 +**영향 범위** -- `POST /api/auth/login`: `admin_id`, `admin_password`, optional `remember_me=on` -- `POST /api/auth/logout`: 입력 credential 없음 -- Cookie: `__Host-jarca_dashboard_auth` -- 설정: 세 `JARCA_DASHBOARD_*` environment variable -- 상세 계약: `docs/interface-contract.md` +- 인터페이스: event log +- 영속성: 로그에 비민감 메타데이터만 +- 후속 사용 주체: 운영 관찰 도구 +- 마이그레이션: 없음 +- 호환성: 일반 오류 메시지 유지 +- 배포 전제 조건: redaction 정책 -## 7. 오류 계약 +## 6. 제약과 불변조건 -| 조건 | 사용자 결과 | Cookie | DB/gateway/page | -|---|---|---|---| -| 설정 invalid | 일반 설정 오류 | 발급 없음 | 차단 | -| credential invalid | 최소 1초 후 일반 인증 오류 | 발급 없음 | 차단 | -| request malformed/cross-site | 400/403 | 발급 없음 | 차단 | -| cookie 없음·malformed·tampered·expired | 로그인 화면 | 수락 없음 | 차단 | -| subject/TTL/time/rotation 불일치 | 로그인 화면 | 수락 없음 | 차단 | -| logout | root redirect | deletion | 금융 상태 무변경 | +- `sub`는 비민감 고정 role 값이며 credential에서 파생하거나 관리자 ID를 담지 않는다. +- credential, secret, token, cookie 값은 저장·출력·문서화하지 않는다. +- 인증 성공 전 DB/gateway/page dispatch는 실행하지 않는다. +- 만료는 absolute이며 활동으로 연장하지 않는다. +- invalid cookie cleanup과 explicit logout의 deletion은 발급 cookie와 matching scope를 사용한다. +- 서버는 per-token revocation과 DB-backed session을 유지하지 않는다. -## 8. 수용 기준 +## 7. 인수 기준 -- [x] AC-001 인증 전 모든 DB/gateway/page 기능이 차단된다. -- [x] AC-002 세 environment setting invalid 상태는 fail-closed한다. -- [x] AC-003 ID/password를 constant-time 비교하고 일반 오류와 최소 1초 지연을 적용한다. -- [x] AC-004 정상 login은 24시간 absolute token/cookie를 발급한다. -- [x] AC-005 기본 미선택 remember-me login은 30일 absolute token/cookie를 발급한다. -- [x] AC-006 Cookie는 `__Host-`, Secure, HttpOnly, SameSite=Lax, Path=/, no Domain이다. -- [x] AC-007 일반 요청·page 이동·복원은 token을 재발급하거나 만료를 연장하지 않는다. -- [x] AC-008 유효 cookie가 새 Streamlit session과 sidebar query page에서 인증을 복원한다. -- [x] AC-009 malformed, oversized, tampered, expired, schema-invalid token은 DB 전에 거부된다. -- [x] AC-010 password, signing secret 또는 관리자 ID rotation이 기존 token을 무효화한다. -- [x] AC-011 logout은 browser cookie를 삭제하고 금융 상태를 변경하지 않는다. -- [x] AC-012 credential, secret, token이 session state·URL·log·문서에 노출되지 않는다. -- [x] AC-013 same-origin HTTPS POST가 아닌 login/logout 요청은 거부된다. -- [x] AC-014 인증 후 기존 Dashboard page와 MCP behavior가 유지된다. -- [x] AC-015 실제 loopback HTTPS와 Streamlit WebSocket에서 cookie 발급·복원·삭제를 검증한다. -- [x] AC-016 금융 DB schema와 data model migration을 추가하지 않는다. -- [x] AC-017 HTTPS, rate-limit, rotation과 stateless logout 제약이 운영 문서에 기록된다. +### REQ-001.01-AC01 — 인증 전 차단 -## 9. 검증 매핑 +**판정 기준** -- Credential/delay/log: `tests/test_dashboard_authentication.py` -- Token/TTL/tamper/rotation: `tests/test_dashboard_persistent_authentication.py` -- HTTP/cookie/logout: `tests/test_dashboard_auth_server.py` -- Streamlit gate/DB ordering/page 복원: `tests/test_dashboard_authentication_app.py`, `tests/test_dashboard_app.py` -- 실제 HTTPS/WebSocket evidence: `scripts/persistent_auth_http_evidence.py`, `docs/evidence/issue-6-persistent-auth-https.json` -- 전체 regression: `uv run --extra dashboard --extra dev pytest` -- 정적 검사: `uv run --extra dashboard --extra dev ruff check dashboard tests scripts` +모든 인증 거부 경로에서 DB, gateway, page dispatch가 실행되지 않는다. -## 10. 추적성 +**검증** -- 최초 요구 Issue: `jsfamily/jarca-ledger-mcp#1` -- 지속 로그인 변경 Issue: `jsfamily/jarca-ledger-mcp#6` -- 최초 구현 PR: `#3` -- Issue #6 구현 PR: `#7` -- Issue #6 기준 revision: `7bea8b368e02e98afd4b2dc463fb270b0e6eb297` -- Issue #6 승인 snapshot body SHA-256: `4f734169ec896747fe39fd7f3ab75d1fc0d8cdd4903045e665beba40d80eb1fe` +AppTest와 gate ordering 테스트에서 database access sentinel이 호출되지 않음을 확인한다. + +**검증 분류** + +- 수준: integration +- 백엔드: Streamlit AppTest +- 케이스: missing config, no cookie, invalid cookie + +**상태 불변조건** + +거부 시 금융 상태와 DB는 변경되지 않는다. + +### REQ-001.02-AC01 — 설정 fail-closed + +**판정 기준** + +세 설정 중 하나라도 유효하지 않으면 일반 설정 오류로 인증을 거부한다. + +**검증** + +설정별 누락·빈 값·짧은 secret 테스트를 실행한다. + +**검증 분류** + +- 수준: unit/integration +- 백엔드: pytest +- 케이스: 각 환경변수 누락, 빈 값, short secret + +**상태 불변조건** + +cookie 발급과 보호 자원 접근이 없다. + +### REQ-001.03-AC01 — 민감정보 비노출 + +**판정 기준** + +credential, secret, token, cookie가 UI·URL·log·session state에 나타나지 않는다. + +**검증** + +로그·응답·session state와 문서를 redaction 검색한다. + +**검증 분류** + +- 수준: static/integration +- 백엔드: pytest, ruff, repository search +- 케이스: 성공·실패 login과 invalid cookie + +**상태 불변조건** + +비민감 correlation ID 외 인증 비밀 상태가 없다. + +### REQ-001.04-AC01 — 실패 지연과 일반 오류 + +**판정 기준** + +잘못된 ID와 잘못된 password가 같은 일반 오류를 반환하고 각각 최소 1초 지연된다. + +**검증** + +두 실패 경로의 elapsed time과 response를 측정한다. + +**검증 분류** + +- 수준: integration +- 백엔드: pytest/HTTP client +- 케이스: wrong ID, wrong password + +**상태 불변조건** + +cookie·DB·gateway 상태가 변하지 않는다. + +### REQ-001.05-AC01 — Absolute TTL과 고정 subject + +**판정 기준** + +기본 24시간과 remember-me 30일 token의 `iat`, `exp`, `sub=dashboard-admin`이 고정된다. + +**검증** + +token claim, TTL, rerun/non-sliding 테스트를 실행한다. + +**검증 분류** + +- 수준: unit/integration +- 백엔드: pytest +- 케이스: normal, remember-me, activity, reconnect + +**상태 불변조건** + +복원은 token을 갱신하지 않는다. + +### REQ-001.06-AC01 — Hardened cookie + +**판정 기준** + +발급 cookie가 이름·Secure·HttpOnly·SameSite·Path·Domain 계약을 모두 만족한다. + +**검증** + +실제 HTTP `Set-Cookie`를 파싱하고 속성을 검사한다. + +**검증 분류** + +- 수준: integration +- 백엔드: Starlette TestClient와 loopback HTTPS +- 케이스: login normal/remember + +**상태 불변조건** + +Domain attribute가 없고 평문 전송에서 발급되지 않는다. + +### REQ-001.07-AC01 — Invalid cookie cleanup + +**판정 기준** + +invalid/expired/tampered/config-failure cookie는 보호 자원 전에 거부되고 same-origin cleanup route의 matching deletion 후 unauthenticated page가 표시된다. + +**검증** + +실제 browser/HTTP 흐름에서 각 invalid cookie와 cleanup response를 확인한다. + +**검증 분류** + +- 수준: integration/browser +- 백엔드: HTTPS probe와 Streamlit browser evidence +- 케이스: malformed, expired, tampered, wrong claims, config failure + +**상태 불변조건** + +cleanup은 DB를 변경하지 않으며 stale cookie가 남지 않는다. + +### REQ-001.08-AC01 — Explicit logout + +**판정 기준** + +logout POST가 matching `Max-Age=0` deletion을 반환하고 다음 접근이 unauthenticated다. + +**검증** + +login 후 logout response와 fresh request를 확인한다. + +**검증 분류** + +- 수준: integration/browser +- 백엔드: HTTPS probe +- 케이스: same-origin logout, cross-origin rejection + +**상태 불변조건** + +금융 DB와 token registry는 변경되지 않는다. + +### REQ-001.09-AC01 — Rotation과 무상태성 + +**판정 기준** + +configured credential 변경은 기존 token을 거부하며 per-token revocation/DB session 구현이 없다. + +**검증** + +rotation 테스트와 source/schema/diff 검색을 실행한다. + +**검증 분류** + +- 수준: unit/static +- 백엔드: pytest와 repository search +- 케이스: password, signing secret, admin ID rotation + +**상태 불변조건** + +검증은 현재 설정과 서명만 사용한다. + +### REQ-001.10-AC01 — 운영 제약 + +**판정 기준** + +README와 Kubernetes 문서에 HTTPS, proxy allowlist, rate limit, secret 주입, rotation과 stateless logout이 기록된다. + +**검증** + +문서 검색과 deployment manifest 검토를 실행한다. + +**검증 분류** + +- 수준: documentation/static +- 백엔드: repository search +- 케이스: README, deploy/k8s/README.md + +**상태 불변조건** + +실제 credential·secret 값이 문서와 manifest에 없다. + +### REQ-001.11-AC01 — 안전한 관찰성 + +**판정 기준** + +허용 event와 correlation ID만 기록되고 민감 값·구체적 검증 원인은 기록되지 않는다. + +**검증** + +로그 테스트와 redaction 검색을 실행한다. + +**검증 분류** + +- 수준: unit/static +- 백엔드: pytest와 repository search +- 케이스: config, login, cleanup, logout success/failure + +**상태 불변조건** + +로그에서 credential·secret·token·cookie가 복원되지 않는다. + +## 8. 오류 및 충돌 계약 + +동일 요청에서 설정 오류가 credential 오류보다 우선하며, 모든 인증 실패는 보호 자원 전에 종료된다. invalid cookie는 cleanup deletion을 시도하고, cleanup route가 거부되거나 실패해도 보호 자원은 열지 않는다. cross-origin/plain HTTP 요청은 403, malformed request는 400이다. 동시 login은 application lock 없이 독립 처리한다. + +## 9. 데이터 및 인터페이스 계약 + +Token은 `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti`와 서명만 가지며 browser cookie에만 저장된다. 세 환경변수는 server process에서만 읽는다. 상세 HTTP, cookie, cleanup, logout 계약은 `docs/interface-contract.md`에 정의한다. + +## 10. 하위 호환성 + +Issue #1의 Dashboard-only 범위, 기존 page/query/gateway/MCP 동작과 구현 PR #3의 추적성을 유지한다. session-only 인증은 persistent cookie로 대체하며 SQLite schema와 MCP transport는 변경하지 않는다. + +## 11. 추적성 + +- 근거 이슈: `#1`, `#6` +- 구현 PR: `#3`, `#7` +- 관련 설계 문서: `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flow.md`, `README.md`, `deploy/k8s/README.md` +- 관련 요구사항: 없음 diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 0b82f42..11190a9 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -1,89 +1,112 @@ -# Runtime Flow — Dashboard Persistent Authentication +# Runtime Flow — Dashboard 관리자 인증 ## 목적과 범위 -Dashboard startup, login token 발급, 새 browser session의 인증 복원, 실패 차단과 logout cookie 삭제 순서를 정의한다. +ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit logout까지의 실행 순서와 상태 계약을 정의한다. -- 포함: ASGI startup, config, login/logout POST, signed cookie, Streamlit gate, DB 이전 차단 -- 제외: MCP 요청, token registry, OAuth/OIDC, external auth middleware +- 포함 범위: config, HTTPS routes, cookie verification, Streamlit gate, cleanup/deletion +- 제외 범위: MCP request authentication, DB session/token registry, per-token revocation +- 시작 조건: trusted ASGI process receives request +- 성공 종료 상태: authenticated Dashboard page executes +- 실패 종료 상태: matching cookie deletion and unauthenticated login page -## 정상 흐름 +## 흐름 -### START — ASGI server 시작 +### startup — ASGI startup -| 단계 | 주체 | 처리 | 다음 | -|---|---|---|---| -| `START-01` | Uvicorn | `dashboard.server:app` 로드 | `START-02` | -| `START-02` | Streamlit `App` | custom auth routes와 `dashboard/app.py` runtime 조립 | `UNAUTHENTICATED` | +- 시작 이벤트: process startup +- 선행조건: deployment injects configuration without recording values +- 실행 방식: synchronous startup/request handling +- 트랜잭션·영속성 경계: no auth persistence; existing financial SQLite is untouched -운영 command는 `uvicorn dashboard.server:app --host 0.0.0.0 --port 8501`이다. +| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | +|---|---|---|---|---|---|---|---| +| `startup-01` | Uvicorn | process command | load `dashboard.server:app` | process memory only | none | `startup-02` | `startup-fail` | +| `startup-02` | ASGI app | app loaded | mount auth routes and Streamlit runtime | route table only | none | `gate-01` | `startup-fail` | -### LOGIN — Credential 제출과 token 발급 +### login — Credential login -| 단계 | 주체 | 입력/처리 | 상태·외부 효과 | 다음 | -|---|---|---|---|---| -| `LOGIN-01` | Browser | Streamlit이 렌더링한 HTML form을 same-origin HTTPS POST | credential은 request body에만 존재 | `LOGIN-02` | -| `LOGIN-02` | `auth_server` | Origin, content type, body size와 field cardinality 검증 | 금융 상태 없음 | `LOGIN-03` 또는 `LOGIN-REJECT` | -| `LOGIN-03` | `authentication` | 세 environment setting을 fail-closed로 읽음 | process memory | `LOGIN-04` 또는 `CONFIG-FAIL` | -| `LOGIN-04` | `authentication` | ID와 password 모두 constant-time 비교 | 실패 시 남은 최소 1초 sleep | `LOGIN-05` 또는 `LOGIN-FAIL` | -| `LOGIN-05` | `authentication` | `v/sub/iat/exp/jti` payload와 derived key로 token 서명 | DB 상태 없음 | `LOGIN-06` | -| `LOGIN-06` | `auth_server` | 303 response에 hardened cookie 설정 | browser cookie 생성 | `RESTORE-01` | +- 시작 이벤트: same-origin HTTPS form POST +- 선행조건: valid trusted scheme, origin and bounded body +- 실행 방식: synchronous request +- 트랜잭션·영속성 경계: no DB transaction; cookie is browser state -기본 `exp-iat`는 86400초, remember-me는 2592000초다. 이 값은 발급 시 고정된다. +| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | +|---|---|---|---|---|---|---|---| +| `login-01` | auth server | POST `/api/auth/login` | validate scheme/origin/body | request only | none | `login-02` | `login-reject` | +| `login-02` | authentication | validated form | load three settings, fail closed | server memory | secret provider/environment | `login-03` | `config-fail` | +| `login-03` | authentication | configured settings | constant-time compare ID/password | no persistent write | none | `login-04` | `login-fail` | +| `login-04` | authentication | valid credential | issue signed claims with `sub=dashboard-admin` | server memory | none | `login-05` | `login-fail` | +| `login-05` | auth server | signed token | set hardened cookie and redirect | browser cookie write | response | `gate-01` | `login-fail` | -### RESTORE — 새 session·재접속 인증 복원 +### restore — Existing cookie restoration -| 단계 | 주체 | 입력/처리 | 상태 | 다음 | -|---|---|---|---|---| -| `RESTORE-01` | Browser | Streamlit initial request/WebSocket handshake에 host cookie 전송 | HttpOnly token | `GATE-01` | -| `GATE-01` | `dashboard.app` | page config 직후 authentication gate 호출 | DB 미접근 | `GATE-02` | -| `GATE-02` | `authentication` | 현재 ID/password/signing secret 로드 | secret은 session에 저장하지 않음 | `GATE-03` 또는 `CONFIG-FAIL` | -| `GATE-03` | `authentication` | signature 검증 후 exact claim schema, subject, TTL, time 검증 | token 재발급 없음 | `DASHBOARD-01` 또는 `TOKEN-FAIL` | -| `DASHBOARD-01` | `dashboard.app` | DB path 해석과 기존 page dispatch | 기존 금융 계약 | `AUTHENTICATED` | +- 시작 이벤트: new request/session with browser cookie +- 선행조건: cookie present or absent; current configuration available +- 실행 방식: synchronous gate +- 트랜잭션·영속성 경계: no server-side session; financial DB begins only after gate success -각 rerun은 cookie를 현재 설정으로 다시 검증한다. 일반 요청은 `iat`, `exp`, cookie `Max-Age`를 연장하지 않는다. +| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | +|---|---|---|---|---|---|---|---| +| `gate-01` | dashboard app | page startup | call authentication before DB path/page dispatch | no DB access | none | `gate-02` | `unauthenticated` | +| `gate-02` | authentication | current config and cookie | verify signature, exact claims, fixed role subject, TTL and rotation | no session write | none | `dashboard-01` | `cleanup-01` | +| `dashboard-01` | dashboard app | accepted token | resolve DB path and dispatch requested page | existing SQLite reads/writes | gateway/page code | `authenticated` | `dashboard-fail` | -### LOGOUT — Browser cookie 삭제 +### cleanup — Invalid cookie cleanup -| 단계 | 주체 | 처리 | 다음 | -|---|---|---|---| -| `LOGOUT-01` | Browser | same-origin HTTPS POST `/api/auth/logout` | `LOGOUT-02` | -| `LOGOUT-02` | `auth_server` | matching name/path/security 속성과 `Max-Age=0` response | `LOGOUT-03` | -| `LOGOUT-03` | Browser | cookie 삭제 후 `/` 재접속 | `UNAUTHENTICATED` | +- 시작 이벤트: gate rejects malformed, expired, tampered, claim-invalid, or config-failure cookie +- 선행조건: same-origin HTTPS cleanup request +- 실행 방식: synchronous request +- 트랜잭션·영속성 경계: no DB or token registry write -Logout은 DB를 변경하지 않는다. 복사된 stateless token의 개별 server-side revocation은 없으며 password/signing-secret rotation이 전체 기존 token을 무효화한다. +| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | +|---|---|---|---|---|---|---|---| +| `cleanup-01` | browser/auth gate | invalid cookie | navigate to `GET /api/auth/cleanup` | request only | auth route | `cleanup-02` | `cleanup-reject` | +| `cleanup-02` | auth server | same-origin HTTPS `Referer` | emit matching deletion (`Max-Age=0`) and root redirect | browser cookie deletion | response | `unauthenticated` | `cleanup-reject` | + +### logout — Explicit logout + +- 시작 이벤트: user submits logout form +- 선행조건: same-origin HTTPS POST +- 실행 방식: synchronous request +- 트랜잭션·영속성 경계: no financial DB change and no per-token revocation + +| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | +|---|---|---|---|---|---|---|---| +| `logout-01` | browser | POST `/api/auth/logout` | validate same-origin HTTPS | request only | none | `logout-02` | `logout-reject` | +| `logout-02` | auth server | validated request | matching deletion and root redirect | browser cookie deletion | response | `unauthenticated` | `logout-reject` | ## 실패와 복구 -| 실패 상태 | 원인 | 차단 범위 | 복구 | -|---|---|---|---| -| `CONFIG-FAIL` | ID/password/signing secret 누락·invalid·read error | DB/gateway/page 전체 | 올바른 secret 주입 후 rerun | -| `LOGIN-REJECT` | cross-site, malformed, oversized POST | cookie 발급 | same-origin 정상 form 재시도 | -| `LOGIN-FAIL` | ID 또는 password 불일치 | cookie 발급·DB 접근 | 최소 1초 후 일반 오류, 재시도 | -| `TOKEN-FAIL` | absent/malformed/tampered/expired/wrong claims/rotation | DB/gateway/page 전체 | 재로그인 | -| `DASHBOARD-FAIL` | 인증 이후 기존 page/DB 오류 | 기존 page 계약 | 기존 운영 복구 | +| 대상 단계 | 실패 조건 | 추가 쓰기 중단 | 재시도 계약 | 중복 방지 | 보상·수동 복구 | 최종 상태 | +|---|---|---|---|---|---|---| +| `login-02` | missing/empty/read-error/short secret | yes | fix deployment config | no cookie | operator injects secret | `config-fail` | +| `login-03` | wrong ID/password | yes | retry after generic error and 1s delay | no lock | external rate limit | `login-fail` | +| `gate-02` | invalid/expired/tampered/claim/rotation failure | yes | cleanup then login | matching deletion | user logs in again | `unauthenticated` | +| `cleanup-02` | non-HTTPS/cross-origin | yes | retry same origin | deletion is idempotent | browser clears manually if needed | `cleanup-reject` | +| `logout-02` | non-HTTPS/cross-origin | yes | retry same origin | deletion is idempotent | browser clears manually if needed | `logout-reject` | -Invalid token의 세부 원인을 UI에서 구분하지 않는다. Credential, secret, token, cookie header 또는 exception 원문을 log에 넣지 않는다. +### 동시성과 순서 -## 동시성·순서 - -- 서로 다른 browser session은 같은 signed token을 보낼 수 있다. -- Token verification은 stateless이고 shared mutable authentication state를 만들지 않는다. -- 설정 및 cookie 검증은 항상 DB path/gateway/page보다 먼저다. -- Password/signing-secret rotation 직후 다음 rerun부터 기존 token이 실패한다. -- 실패당 1초 delay는 병렬 요청을 직렬화하지 않으므로 TLS ingress/reverse-proxy 경계에서 rate limiting이 필요하다. +- 동시 실행 허용 여부: independent requests allowed; no application-wide auth lock +- 잠금·직렬화 단위: none for authentication; existing SQLite rules remain for financial writes +- 순서 보장: config/cookie gate precedes DB path, gateway and page dispatch +- 충돌 검출·처리: token is stateless; password/signing-secret/admin-ID rotation invalidates old claims ## 관찰 가능성 -허용 event: `configuration_error`, `authentication_required`, `token_accepted`, `login_succeeded`, `login_failed`, `login_origin_rejected`, `login_request_rejected`, `logout`, `logout_origin_rejected`. - -각 log는 event와 비민감 correlation ID만 포함한다. Token, credential, origin 세부값, request body와 cookie header는 기록하지 않는다. +| 신호 종류 | 이름 | 발생 단계·조건 | 상관 식별자 | 진단 속성 | 민감정보 처리 | +|---|---|---|---|---|---| +| event log | `authentication_required` | `gate-01` rejects | non-sensitive correlation ID | event class only | no cookie/token/reason | +| event log | `login_succeeded` | `login-05` | non-sensitive correlation ID | TTL class only | no credential/token | +| event log | `login_failed` | `login-03` | non-sensitive correlation ID | generic failure class | no ID/password | +| event log | `cleanup` | `cleanup-02` | non-sensitive correlation ID | generic cleanup class | no cookie header | +| event log | `logout` | `logout-02` | non-sensitive correlation ID | operation result | no token/cookie value | ## 검증 -- Normal/remember TTL 및 non-sliding: token unit tests -- Tamper/expiry/rotation/schema rejection: token unit tests -- HTTP response cookie와 logout deletion: auth-server tests -- Fail-closed DB-before-gate ordering: AppTest -- 실제 loopback HTTPS login/reconnect/logout와 cookie 속성: HTTP evidence script -- MCP behavior와 DB schema가 변경되지 않았음을 full regression과 diff로 확인 +- 정상 흐름: auth server tests plus loopback HTTPS login and fresh session restoration +- 실패 흐름: configuration, wrong credential, malformed/tampered/expired cookie and origin rejection tests +- 경계 흐름: TTL boundary, rotation, oversized token, same-host different-port origin +- 동시성 흐름: independent failed submissions and no application lock; ingress rate-limit configuration review +- 운영 진입점과 기대 결과: `uvicorn dashboard.server:app --host 0.0.0.0 --port 8501`; valid HTTPS reaches login or authenticated page, invalid cookie receives matching deletion before unauthenticated page diff --git a/tests/test_dashboard_auth_server.py b/tests/test_dashboard_auth_server.py index 0b68948..f90de4e 100644 --- a/tests/test_dashboard_auth_server.py +++ b/tests/test_dashboard_auth_server.py @@ -176,6 +176,35 @@ def test_logout_clears_cookie_with_matching_scope(client: TestClient) -> None: assert "Path=/" in header +def test_cleanup_route_clears_cookie_and_redirects(client: TestClient) -> None: + response = client.get( + "/api/auth/cleanup", + headers={"referer": "https://testserver/"}, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/" + header = response.headers["set-cookie"] + assert f"{authentication.AUTH_COOKIE_NAME}=" in header + assert "Max-Age=0" in header + assert "HttpOnly" in header + assert "Secure" in header + assert "SameSite=lax" in header + assert "Path=/" in header + + +def test_cleanup_route_rejects_cross_origin_navigation(client: TestClient) -> None: + response = client.get( + "/api/auth/cleanup", + headers={"referer": "https://attacker.example/"}, + follow_redirects=False, + ) + + assert response.status_code == 403 + assert "set-cookie" not in response.headers + + def test_cookie_token_round_trip(client: TestClient) -> None: response = client.post( "/api/auth/login", diff --git a/tests/test_dashboard_authentication_app.py b/tests/test_dashboard_authentication_app.py index 59fa496..31572d1 100644 --- a/tests/test_dashboard_authentication_app.py +++ b/tests/test_dashboard_authentication_app.py @@ -108,6 +108,13 @@ def test_invalid_cookie_blocks_database_and_page_dispatch(monkeypatch): assert not at.exception assert "관리자 로그인" in _body_text(at) assert "이번 달 수입/지출 현황" not in _body_text(at) + assert "/api/auth/cleanup" in _body_text(at) + + +def test_no_cookie_does_not_render_cookie_cleanup_navigation(monkeypatch): + at = _app_with_cookie(monkeypatch, None) + + assert "/api/auth/cleanup" not in _body_text(at) def test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state( @@ -153,3 +160,24 @@ def test_password_rotation_invalidates_existing_cookie(monkeypatch, tmp_path): assert "관리자 로그인" in _body_text(at) assert "이번 달 수입/지출 현황" not in _body_text(at) + + +def test_signing_secret_rotation_invalidates_existing_cookie(monkeypatch, tmp_path): + from scripts.ci_fixture_db import create_ci_fixture_db + + fixture = tmp_path / "fixture.sqlite3" + create_ci_fixture_db(fixture) + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) + settings = _configure(monkeypatch) + token = authentication.issue_persistent_token(settings, remember_me=True).value + monkeypatch.setattr(authentication, "_request_cookie", lambda: token) + + at = AppTest.from_file(str(APP_PATH), default_timeout=30).run() + assert "이번 달 수입/지출 현황" in _body_text(at) + + monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "r" * 64) + at = at.run() + + assert "관리자 로그인" in _body_text(at) + assert "/api/auth/cleanup" in _body_text(at) + assert "이번 달 수입/지출 현황" not in _body_text(at) diff --git a/tests/test_dashboard_persistent_authentication.py b/tests/test_dashboard_persistent_authentication.py index 37b609d..c285d7d 100644 --- a/tests/test_dashboard_persistent_authentication.py +++ b/tests/test_dashboard_persistent_authentication.py @@ -150,14 +150,30 @@ def test_oversized_and_malformed_tokens_are_rejected() -> None: def test_payload_does_not_encode_secrets() -> None: + settings = replace(_settings(), admin_id="configured-admin-identity") issued = authentication.issue_persistent_token( - _settings(), + settings, remember_me=False, now=1_700_000_000, token_id="fixed-token-id", ) - payload = authentication._serializer_for(_settings()).loads(issued.value) + payload = authentication._serializer_for(settings).loads(issued.value) assert set(payload) == {"v", "sub", "iat", "exp", "jti"} assert "password" not in json.dumps(payload).lower() assert "secret" not in json.dumps(payload).lower() + assert settings.admin_id not in json.dumps(payload) + + +def test_remember_me_token_expires_at_exact_30_day_boundary() -> None: + settings = _settings() + issued = authentication.issue_persistent_token( + settings, remember_me=True, now=1_700_000_000, token_id="boundary-token-id" + ) + + assert authentication.verify_persistent_token( + settings, issued.value, now=issued.expires_at - 1 + ) is not None + assert authentication.verify_persistent_token( + settings, issued.value, now=issued.expires_at + ) is None -- 2.54.0 From 9bc64699cb22380b3557ab5c41952017a21ccc69 Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Fri, 2 Oct 2026 03:04:24 +0000 Subject: [PATCH 04/11] docs(evidence): bind persistent auth browser run --- .../issue-6-persistent-auth-https.json | 111 ++++++++++++++---- 1 file changed, 88 insertions(+), 23 deletions(-) diff --git a/docs/evidence/issue-6-persistent-auth-https.json b/docs/evidence/issue-6-persistent-auth-https.json index 4b3d99b..c03677f 100644 --- a/docs/evidence/issue-6-persistent-auth-https.json +++ b/docs/evidence/issue-6-persistent-auth-https.json @@ -1,29 +1,94 @@ { - "schema": "jarca-ledger.auth-evidence/v1", + "schema": "jarca-ledger.auth-evidence/v2", "issue": 6, - "verified_at_utc": "2026-10-01T13:51:52Z", - "source_revision": "7bea8b368e02e98afd4b2dc463fb270b0e6eb297", - "transport": "loopback HTTPS with self-signed test certificate", - "client": "HTTP plus Streamlit WebSocket protocol client", - "synthetic_database": true, - "results": { - "login_status": 303, - "cookie_name": "__Host-jarca_dashboard_auth", - "cookie_value": "[REDACTED]", - "secure": true, - "http_only": true, - "same_site": "lax", - "path": "/", - "remember_me_max_age_seconds": 2592000, - "reconnect_status": 200, - "streamlit_websocket_authenticated": true, - "streamlit_messages": 53, - "logout_status": 303, - "logout_max_age_seconds": 0 + "pull_request": 7, + "source_review": 44, + "verified_at_utc": "2026-10-02T03:03:47Z", + "source_revision": "7bb959859a84f1d1293afd1f0270cca18f4cd576", + "source_tree_clean_before_evidence_write": true, + "transport": "HTTPS browser origin terminating to a loopback-only Uvicorn test server", + "browser_runner": { + "interface": "Playwright MCP", + "engine": "Chromium 154", + "architecture": "arm", + "bitness": "64", + "platform": "Linux", + "secure_context": true + }, + "environment": { + "synthetic_database": true, + "operating_ledger_used": false, + "credentials": "synthetic test-only values; not recorded", + "signing_secret": "synthetic test-only value; not recorded", + "temporary_https_ingress": true + }, + "local_checks": [ + { + "command": "uv run --extra dashboard --extra dev pytest -q", + "exit_code": 0, + "result": "111 passed, 4 skipped" + }, + { + "command": "uv run --extra dashboard --extra dev ruff check dashboard tests scripts", + "exit_code": 0, + "result": "All checks passed" + }, + { + "command": "uv run --extra dashboard --extra dev python -m compileall -q dashboard scripts tests", + "exit_code": 0, + "result": "passed" + } + ], + "browser_results": { + "initial_login_page_visible": true, + "login": { + "status": 303, + "location": "/", + "cookie_name": "__Host-jarca_dashboard_auth", + "cookie_value": "[REDACTED]", + "secure": true, + "http_only": true, + "same_site": "Lax", + "path": "/", + "domain_attribute_absent": true, + "max_age_seconds": 2592000, + "document_cookie_exposed_auth_cookie": false + }, + "restoration": { + "new_tab": true, + "query_page": "Import", + "authenticated_page_visible": true, + "login_page_visible": false + }, + "invalid_cookie_cleanup": { + "injected_value": "[REDACTED TEST SENTINEL]", + "cleanup_status": 303, + "location": "/", + "matching_cookie_deletion": true, + "max_age_seconds": 0, + "cookie_count_after_cleanup": 0, + "unauthenticated_page_visible": true + }, + "logout": { + "status": 303, + "location": "/", + "matching_cookie_deletion": true, + "max_age_seconds": 0, + "cookie_count_after_logout": 0, + "unauthenticated_page_visible": true, + "document_cookie_exposed_auth_cookie": false + } + }, + "hashes_sha256": { + "scripts/persistent_auth_http_evidence.py": "403bfcd7e281d4b1750841a969a18548a580a5f3f5116abeaf83e71e81832844", + "dashboard/authentication.py": "63df2e33ef10859039c9b8140e92cbf0c7c8c9fe2d19cfcedbf16637c8cc2596", + "dashboard/auth_server.py": "d986e10d3489938c559d4f11ae20a43a448b39aae8a27978571f193f9df0e8ca", + "dashboard/server.py": "633c7fd9ae8a41c656a734be641de7f1733a1636216a9dbe2e50db885ff6ec6e", + "pyproject.toml": "64100d3aa1c2272df2e2139ed435724901f7215ea72e48bee9d73f95e7e53a8d" }, - "command": "uv run --extra dashboard --extra dev python scripts/persistent_auth_http_evidence.py --base-url https://127.0.0.1:18506", "notes": [ - "Credentials, signing material, and cookie values were not recorded.", - "This artifact records actual HTTPS and Streamlit WebSocket protocol evidence; it is not a Chromium screenshot artifact." + "The evidence artifact is an evidence-only descendant of source_revision; application, test, and configuration paths were not changed after source_revision verification.", + "Credentials, signing material, token values, raw cookie values, request bodies, and raw Set-Cookie values were not recorded.", + "The actual ARM64 Chromium browser performed login, HttpOnly storage, new-tab restoration, invalid-cookie cleanup, and logout deletion." ] } -- 2.54.0 From 7056e012a7c51eab4405d2e380f44f20e885170e Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Fri, 2 Oct 2026 04:48:06 +0000 Subject: [PATCH 05/11] docs(auth): complete persistent token contracts --- docs/interface-contract.md | 7 ++++++- docs/runtime-flow.md | 10 ++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/interface-contract.md b/docs/interface-contract.md index a1dfb0f..e47679a 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -66,6 +66,8 @@ Dashboard 인증 설정, login/cleanup/logout HTTP route, signed cookie와 Strea | `Set-Cookie` | response header | `cookie` | success | hardened auth cookie, redacted value | persistent auth | 값은 민감 | | `Location` | response header | `string` | success/failure | `/` or generic error path | browser navigation | 비민감 | +발급 token은 `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti` claim만 가진다. `sub=dashboard-admin`은 관리자 ID가 아닌 고정 비민감 role이고, token 값과 서명은 민감하므로 browser의 hardened cookie에만 저장한다. 성공한 login 시각에 `iat`와 `exp`를 한 번 고정하며 기본 TTL은 24시간, `remember_me=on`이면 30일이다. 활동, 일반 요청, rerun 또는 인증 복원은 token을 재발급하거나 두 시각을 연장하지 않는다. + #### 오류 | 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 | @@ -148,7 +150,7 @@ Dashboard 인증 설정, login/cleanup/logout HTTP route, signed cookie와 Strea | 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 | |---|---|---|---|---|---|---|---| -| `auth_cookie` | request cookie | `string` | success path | absent | signed token | browser proof | 민감 | +| `auth_cookie` | request cookie | `string` | success path | absent | current configuration으로 서명을 검증할 수 있고 claim이 정확히 `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti`인 token | browser proof | 값과 서명은 민감; `sub`는 비민감 | #### 출력 @@ -162,6 +164,8 @@ Dashboard 인증 설정, login/cleanup/logout HTTP route, signed cookie와 Strea |---|---|---|---|---|---| | `unauthenticated` | authentication | absent/invalid/expired/config failure | login 가능 | cleanup then show login | no protected work | +관리자 ID, password 또는 signing secret 변경은 server-only signing key binding을 바꾸므로 기존 token을 모두 무효화하고 cleanup으로 전이한다. DB session, token registry와 per-token revocation은 만들지 않는다. + ## 호환성 - 유지할 기존 호출 방식: authenticated `page` query parameter, page dispatch, gateway와 MCP behavior @@ -170,6 +174,7 @@ Dashboard 인증 설정, login/cleanup/logout HTTP route, signed cookie와 Strea - 재시도 계약: credential 실패는 최소 1초 지연; origin/request 오류는 수정 후 재시도 - 중복 처리 계약: login은 새 token 발급 가능; cleanup/logout deletion은 idempotent - 순서 보장: config → cookie verify → cleanup if invalid → DB/page dispatch only if valid +- 만료 보장: login 성공 시 고정한 `iat`와 `exp`는 활동·일반 요청·rerun·복원에서 변경하거나 연장하지 않음 ## 예시 diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 11190a9..63f9049 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -36,8 +36,8 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo | `login-01` | auth server | POST `/api/auth/login` | validate scheme/origin/body | request only | none | `login-02` | `login-reject` | | `login-02` | authentication | validated form | load three settings, fail closed | server memory | secret provider/environment | `login-03` | `config-fail` | | `login-03` | authentication | configured settings | constant-time compare ID/password | no persistent write | none | `login-04` | `login-fail` | -| `login-04` | authentication | valid credential | issue signed claims with `sub=dashboard-admin` | server memory | none | `login-05` | `login-fail` | -| `login-05` | auth server | signed token | set hardened cookie and redirect | browser cookie write | response | `gate-01` | `login-fail` | +| `login-04` | authentication | valid credential | issue exactly `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti`; fix `iat`/`exp` at login success for default 24 hours or `remember_me=on` 30 days | server memory | none | `login-05` | `login-fail` | +| `login-05` | auth server | signed token | set hardened cookie with selected absolute expiry and redirect; later activity never reissues or extends it | browser cookie write | response | `gate-01` | `login-fail` | ### restore — Existing cookie restoration @@ -49,7 +49,7 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo | 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 | |---|---|---|---|---|---|---|---| | `gate-01` | dashboard app | page startup | call authentication before DB path/page dispatch | no DB access | none | `gate-02` | `unauthenticated` | -| `gate-02` | authentication | current config and cookie | verify signature, exact claims, fixed role subject, TTL and rotation | no session write | none | `dashboard-01` | `cleanup-01` | +| `gate-02` | authentication | current config and cookie | verify signature, exact `v`/`sub=dashboard-admin`/`iat`/`exp`/`jti` claims, current admin-ID/password/signing-secret binding and absolute `exp`; never refresh or extend on restoration, rerun or activity | no session or token write | none | `dashboard-01` | `cleanup-01` | | `dashboard-01` | dashboard app | accepted token | resolve DB path and dispatch requested page | existing SQLite reads/writes | gateway/page code | `authenticated` | `dashboard-fail` | ### cleanup — Invalid cookie cleanup @@ -82,7 +82,7 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo |---|---|---|---|---|---|---| | `login-02` | missing/empty/read-error/short secret | yes | fix deployment config | no cookie | operator injects secret | `config-fail` | | `login-03` | wrong ID/password | yes | retry after generic error and 1s delay | no lock | external rate limit | `login-fail` | -| `gate-02` | invalid/expired/tampered/claim/rotation failure | yes | cleanup then login | matching deletion | user logs in again | `unauthenticated` | +| `gate-02` | expired, malformed/tampered claims, or admin-ID/password/signing-secret rotation failure | yes | cleanup then login | matching deletion | user logs in again | `unauthenticated` | | `cleanup-02` | non-HTTPS/cross-origin | yes | retry same origin | deletion is idempotent | browser clears manually if needed | `cleanup-reject` | | `logout-02` | non-HTTPS/cross-origin | yes | retry same origin | deletion is idempotent | browser clears manually if needed | `logout-reject` | @@ -92,6 +92,8 @@ ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit lo - 잠금·직렬화 단위: none for authentication; existing SQLite rules remain for financial writes - 순서 보장: config/cookie gate precedes DB path, gateway and page dispatch - 충돌 검출·처리: token is stateless; password/signing-secret/admin-ID rotation invalidates old claims +- 만료 불변조건: login 성공 시 고정한 `iat`와 `exp`는 복원·rerun·활동 동안 바뀌지 않으며 token을 재발급하거나 만료를 연장하지 않음 +- rotation 불변조건: admin ID, password 또는 signing secret 변경은 이전 설정으로 발급한 모든 token을 무효화하고 `cleanup-01`로 전이 ## 관찰 가능성 -- 2.54.0 From 8544ea30f4c4cea5489b7a136d6c53a8a538341a Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Fri, 2 Oct 2026 06:09:11 +0000 Subject: [PATCH 06/11] docs(auth): restore legacy acceptance contracts --- ..._dashboard-administrator-authentication.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/docs/requirements/req-001_dashboard-administrator-authentication.md b/docs/requirements/req-001_dashboard-administrator-authentication.md index c89ae49..841c988 100644 --- a/docs/requirements/req-001_dashboard-administrator-authentication.md +++ b/docs/requirements/req-001_dashboard-administrator-authentication.md @@ -266,6 +266,26 @@ credential, secret, token, cookie가 UI·URL·log·session state에 나타나지 비민감 correlation ID 외 인증 비밀 상태가 없다. +### REQ-001.03-AC02 — 두 credential의 constant-time 비교 + +**판정 기준** + +제출 ID와 password는 각각 `hmac.compare_digest`로 비교하며, 한 필드가 일치하지 않아도 다른 필드의 비교를 생략하지 않는다. + +**검증** + +`tests/test_dashboard_authentication.py::test_verify_credentials_uses_constant_time_comparison_for_both_fields`에서 성공 및 두 필드가 모두 잘못된 경우에 두 비교의 호출 순서와 인자를 계측한다. + +**검증 분류** + +- 수준: unit +- 백엔드: pytest monkeypatch +- 케이스: 정상 credential, 잘못된 ID와 password + +**상태 불변조건** + +일반 동등 비교나 한 필드 실패에 따른 short-circuit를 사용하지 않는다. + ### REQ-001.04-AC01 — 실패 지연과 일반 오류 **판정 기준** @@ -406,6 +426,46 @@ README와 Kubernetes 문서에 HTTPS, proxy allowlist, rate limit, secret 주입 실제 credential·secret 값이 문서와 manifest에 없다. +### REQ-001.10-AC02 — 인증 후 기존 페이지 호환성 + +**판정 기준** + +유효한 Dashboard 인증 cookie로 인증하면 기존 page query parameter를 유지한 채 요약, 자산/마감, 분류 규칙, 확인필요 큐와 월정산 핵심 페이지가 예외 없이 예상 내용을 렌더링한다. + +**검증** + +`tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db`와 `tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state`를 실행한다. + +**검증 분류** + +- 수준: regression integration +- 백엔드: Streamlit AppTest, synthetic SQLite +- 케이스: 인증된 핵심 page query와 기존 gateway + +**상태 불변조건** + +기존 SQLite schema, page query와 gateway contract를 변경하지 않는다. + +### REQ-001.10-AC03 — MCP 인증 범위 분리 + +**판정 기준** + +Dashboard cookie 인증은 MCP endpoint의 인증을 의미하지 않으며 MCP server 진입점, transport와 port를 변경하지 않는다. + +**검증** + +Base-to-head diff에서 MCP 인증·진입점·transport·port를 담당하는 `jarca_ledger_mcp/server.py`, `jarca_ledger_mcp/adapters/mcp/**`, `tests/test_mcp_http_runtime.py`의 불변성을 확인하고 기존 stdio, streamable HTTP와 invalid transport 회귀 test를 실행한다. + +**검증 분류** + +- 수준: static/regression +- 백엔드: Git diff, pytest +- 케이스: MCP source, stdio, streamable HTTP, invalid transport + +**상태 불변조건** + +Dashboard와 MCP 인증 경계를 결합하지 않으며 MCP 인증 상태를 변경하지 않는다. + ### REQ-001.11-AC01 — 안전한 관찰성 **판정 기준** -- 2.54.0 From 33118664316707e19146d803fb21d400073c5206 Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Fri, 2 Oct 2026 07:35:01 +0000 Subject: [PATCH 07/11] docs(auth): restore successful login contract --- ..._dashboard-administrator-authentication.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/requirements/req-001_dashboard-administrator-authentication.md b/docs/requirements/req-001_dashboard-administrator-authentication.md index 841c988..204b5e9 100644 --- a/docs/requirements/req-001_dashboard-administrator-authentication.md +++ b/docs/requirements/req-001_dashboard-administrator-authentication.md @@ -306,6 +306,26 @@ credential, secret, token, cookie가 UI·URL·log·session state에 나타나지 cookie·DB·gateway 상태가 변하지 않는다. +### REQ-001.04-AC02 — 성공 지연·잠금 없음 + +**판정 기준** + +정상 credential은 실패 지연 함수를 사용하지 않고 인증되며, 실패 횟수나 잠금 만료 상태를 생성하지 않는다. + +**검증** + +`dashboard/auth_server.py`의 login 경로와 authentication source를 검사하여 `enforce_failed_login_delay`가 credential 실패 분기에만 호출되고 failure counter·lock state가 없음을 확인한다. `tests/test_dashboard_authentication.py`의 `test_failed_login_delay_waits_only_for_remaining_minimum_duration` 및 `test_failed_login_delay_does_not_sleep_after_minimum_duration`, 그리고 `tests/test_dashboard_auth_server.py::test_successful_login_sets_hardened_24_hour_cookie`를 실행한다. + +**검증 분류** + +- 수준: unit/integration/static +- 백엔드: pytest, source inspection +- 케이스: 정상 credential, 실패 지연 helper, 반복 실패 후 재시도 + +**상태 불변조건** + +애플리케이션 failure counter와 lock state를 유지하지 않는다. + ### REQ-001.05-AC01 — Absolute TTL과 고정 subject **판정 기준** -- 2.54.0 From e7242a89f7f1faacbe984713a5ad211bd7bcac2b Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Fri, 2 Oct 2026 12:28:52 +0000 Subject: [PATCH 08/11] fix(auth): bound asynchronous login handling --- dashboard/auth_server.py | 17 +++-- tests/test_dashboard_auth_server.py | 107 +++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 7 deletions(-) diff --git a/dashboard/auth_server.py b/dashboard/auth_server.py index 6dc7186..825ab93 100644 --- a/dashboard/auth_server.py +++ b/dashboard/auth_server.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import secrets import time from urllib.parse import parse_qs, urlsplit @@ -91,13 +92,15 @@ async def login(request: Request) -> Response: 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) + body = bytearray() + async for chunk in request.stream(): + if len(body) + len(chunk) > _MAX_LOGIN_BODY_BYTES: + authentication.log_auth_event("login_request_rejected", correlation_id) + return PlainTextResponse("Bad Request", status_code=400) + body.extend(chunk) try: values = parse_qs( - body.decode("utf-8"), + bytes(body).decode("utf-8"), keep_blank_values=True, strict_parsing=True, max_num_fields=3, @@ -121,7 +124,9 @@ async def login(request: Request) -> Response: if not authentication.verify_credentials( settings.credentials, submitted_id, submitted_password ): - authentication.enforce_failed_login_delay(started_at=started_at) + await asyncio.to_thread( + authentication.enforce_failed_login_delay, started_at=started_at + ) authentication.log_auth_event("login_failed", correlation_id) return _redirect("/?auth_error=invalid") diff --git a/tests/test_dashboard_auth_server.py b/tests/test_dashboard_auth_server.py index f90de4e..c68ebec 100644 --- a/tests/test_dashboard_auth_server.py +++ b/tests/test_dashboard_auth_server.py @@ -1,18 +1,22 @@ from __future__ import annotations +import asyncio import sys +import threading +from collections.abc import Callable from http.cookies import SimpleCookie from pathlib import Path import pytest from starlette.testclient import TestClient +from starlette.requests import Request ROOT_DIR = Path(__file__).resolve().parents[1] if str(ROOT_DIR) not in sys.path: sys.path.insert(0, str(ROOT_DIR)) from dashboard import authentication -from dashboard.auth_server import create_auth_routes +from dashboard.auth_server import create_auth_routes, login ADMIN_ID = "admin" @@ -218,3 +222,104 @@ def test_cookie_token_round_trip(client: TestClient) -> None: assert authentication.verify_persistent_token( authentication.load_auth_settings(), token ) is not None + + +def _streaming_login_request( + chunks: list[bytes], +) -> tuple[Request, Callable[[], int]]: + messages = [ + { + "type": "http.request", + "body": chunk, + "more_body": index < len(chunks) - 1, + } + for index, chunk in enumerate(chunks) + ] + calls = 0 + + async def receive(): + nonlocal calls + calls += 1 + return messages.pop(0) + + scope = { + "type": "http", + "method": "POST", + "scheme": "https", + "path": "/api/auth/login", + "raw_path": b"/api/auth/login", + "query_string": b"", + "headers": [ + (b"host", b"testserver"), + (b"origin", b"https://testserver"), + (b"content-type", b"application/x-www-form-urlencoded"), + ], + "client": ("testclient", 50000), + "server": ("testserver", 443), + } + return Request(scope, receive), lambda: calls + + +def test_login_offloads_failed_login_delay_from_event_loop(monkeypatch) -> None: + entered = threading.Event() + release = threading.Event() + probe_ran = threading.Event() + probe_before_release = threading.Event() + + def blocking_delay(*, started_at): + entered.set() + if probe_ran.wait(timeout=1): + probe_before_release.set() + release.set() + + monkeypatch.setattr(authentication, "enforce_failed_login_delay", blocking_delay) + + async def exercise() -> None: + request, _ = _streaming_login_request([b"admin_id=admin&admin_password=wrong"]) + task = asyncio.create_task(login(request)) + + async def probe() -> None: + probe_ran.set() + + asyncio.create_task(probe()) + assert await asyncio.to_thread(entered.wait, 1) + release.set() + response = await task + assert response.status_code == 303 + assert response.headers["location"] == "/?auth_error=invalid" + assert "set-cookie" not in response.headers + + asyncio.run(exercise()) + assert probe_before_release.is_set() + + +def test_login_rejects_exactly_one_byte_over_streaming_limit(monkeypatch) -> None: + def unexpected_verification(*_args, **_kwargs): + raise AssertionError("oversized body reached credential verification") + + monkeypatch.setattr(authentication, "verify_credentials", unexpected_verification) + request, calls = _streaming_login_request([b"x" * 4097]) + response = asyncio.run(login(request)) + + assert response.status_code == 400 + assert "set-cookie" not in response.headers + assert calls() == 1 + + +def test_login_accepts_body_at_streaming_limit() -> None: + prefix = b"admin_id=admin&admin_password=" + body = prefix + (b"x" * (4096 - len(prefix))) + assert len(body) == 4096 + request, calls = _streaming_login_request([body]) + response = asyncio.run(login(request)) + + assert response.status_code == 303 + assert calls() == 1 + + +def test_login_stops_reading_stream_after_limit_is_exceeded() -> None: + request, calls = _streaming_login_request([b"x" * 4096, b"y", b"z"]) + response = asyncio.run(login(request)) + + assert response.status_code == 400 + assert calls() == 2 -- 2.54.0 From 755c26865c8e111aede8bcbe150a03e305a0ec60 Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Sat, 3 Oct 2026 00:17:44 +0000 Subject: [PATCH 09/11] docs(auth): preserve login runtime evidence --- .../evidence/issue-6-d6-23-login-runtime.json | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/evidence/issue-6-d6-23-login-runtime.json diff --git a/docs/evidence/issue-6-d6-23-login-runtime.json b/docs/evidence/issue-6-d6-23-login-runtime.json new file mode 100644 index 0000000..e58e142 --- /dev/null +++ b/docs/evidence/issue-6-d6-23-login-runtime.json @@ -0,0 +1,73 @@ +{ + "schema": "jarca-ledger.review-evidence/v1", + "issue": 6, + "pull_request": 7, + "source_review": 51, + "finding": "D6-23", + "contract_sha256": "4f734169ec896747fe39fd7f3ab75d1fc0d8cdd4903045e665beba40d80eb1fe", + "verified_at_utc": "2026-10-03T00:10:43Z", + "source_revision": "e7242a89f7f1faacbe984713a5ad211bd7bcac2b", + "source_tree_clean_before_evidence_write": true, + "environment": { + "os": "Linux 6.18.34+rpt-rpi-v8", + "architecture": "aarch64", + "python": "3.13.5", + "starlette": "1.3.1", + "httpx": "0.28.1", + "streamlit": "1.58.0", + "uvicorn": "0.49.0", + "itsdangerous": "2.2.0", + "credentials": "synthetic test-only values; not recorded", + "signing_secret": "synthetic test-only value; not recorded" + }, + "commands": [ + { + "purpose": "Focused route, hardened cookie, event-loop offload, and failed-delay helper verification", + "command": "uv run --extra dashboard --extra dev pytest -q tests/test_dashboard_auth_server.py tests/test_dashboard_authentication.py", + "exit_code": 0, + "result": "29 passed; one Starlette/httpx deprecation warning", + "relevant_output": "............................. [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-6/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html" + }, + { + "purpose": "Real-delay concurrent failed login plus successful login and hardened cookie runtime probe", + "command": "uv run --extra dashboard python - <<'PY'\nimport asyncio\nimport json\nimport os\nimport secrets\nimport time\n\nfrom starlette.requests import Request\n\nfrom dashboard import authentication\nfrom dashboard.auth_server import login\n\nadmin_id = \"synthetic-\" + secrets.token_hex(8)\nadmin_password = secrets.token_hex(16)\nos.environ[authentication.ADMIN_ID_ENV] = admin_id\nos.environ[authentication.ADMIN_PASSWORD_ENV] = admin_password\nos.environ[authentication.SIGNING_SECRET_ENV] = secrets.token_hex(32)\n\n\ndef request(password: str) -> Request:\n body = f\"admin_id={admin_id}&admin_password={password}\".encode()\n sent = False\n\n async def receive():\n nonlocal sent\n if sent:\n return {\"type\": \"http.disconnect\"}\n sent = True\n return {\"type\": \"http.request\", \"body\": body, \"more_body\": False}\n\n scope = {\n \"type\": \"http\",\n \"method\": \"POST\",\n \"scheme\": \"https\",\n \"path\": \"/api/auth/login\",\n \"raw_path\": b\"/api/auth/login\",\n \"query_string\": b\"\",\n \"headers\": [\n (b\"host\", b\"testserver\"),\n (b\"origin\", b\"https://testserver\"),\n (b\"content-type\", b\"application/x-www-form-urlencoded\"),\n ],\n \"client\": (\"testclient\", 50000),\n \"server\": (\"testserver\", 443),\n }\n return Request(scope, receive)\n\n\nasync def main() -> None:\n wrong_password = secrets.token_hex(16)\n started = time.monotonic()\n failures = [asyncio.create_task(login(request(wrong_password))) for _ in range(2)]\n await asyncio.sleep(0.1)\n probe_elapsed = time.monotonic() - started\n success_started = time.monotonic()\n success = await login(request(admin_password))\n success_elapsed = time.monotonic() - success_started\n failed = await asyncio.gather(*failures)\n total_elapsed = time.monotonic() - started\n cookie = success.headers.get(\"set-cookie\", \"\")\n assert success.status_code == 303\n assert success.headers[\"location\"] == \"/\"\n assert all(marker in cookie for marker in [\"HttpOnly\", \"Secure\", \"SameSite=lax\", \"Path=/\", \"Max-Age=86400\"])\n assert all(response.status_code == 303 for response in failed)\n assert all(\"set-cookie\" not in response.headers for response in failed)\n assert probe_elapsed < 0.5\n assert success_elapsed < 0.5\n assert 0.95 <= total_elapsed < 1.75\n print(json.dumps({\n \"success\": True,\n \"source_revision\": \"e7242a89f7f1faacbe984713a5ad211bd7bcac2b\",\n \"successful_login_status\": success.status_code,\n \"successful_login_cookie_hardened\": True,\n \"failed_login_statuses\": [response.status_code for response in failed],\n \"failed_login_cookie_absent\": True,\n \"event_loop_probe_elapsed_seconds\": round(probe_elapsed, 6),\n \"successful_login_elapsed_seconds\": round(success_elapsed, 6),\n \"concurrent_failed_logins_total_elapsed_seconds\": round(total_elapsed, 6),\n \"minimum_failed_login_delay_seconds\": 1.0,\n \"credentials_and_cookie_values_recorded\": False,\n }, indent=2))\n\n\nasyncio.run(main())\nPY", + "exit_code": 0, + "result": { + "success": true, + "source_revision": "e7242a89f7f1faacbe984713a5ad211bd7bcac2b", + "successful_login_status": 303, + "successful_login_cookie_hardened": true, + "failed_login_statuses": [ + 303, + 303 + ], + "failed_login_cookie_absent": true, + "event_loop_probe_elapsed_seconds": 0.10056, + "successful_login_elapsed_seconds": 0.001267, + "concurrent_failed_logins_total_elapsed_seconds": 1.00614, + "minimum_failed_login_delay_seconds": 1.0, + "credentials_and_cookie_values_recorded": false + }, + "relevant_output": "{\n \"success\": true,\n \"source_revision\": \"e7242a89f7f1faacbe984713a5ad211bd7bcac2b\",\n \"successful_login_status\": 303,\n \"successful_login_cookie_hardened\": true,\n \"failed_login_statuses\": [\n 303,\n 303\n ],\n \"failed_login_cookie_absent\": true,\n \"event_loop_probe_elapsed_seconds\": 0.10056,\n \"successful_login_elapsed_seconds\": 0.001267,\n \"concurrent_failed_logins_total_elapsed_seconds\": 1.00614,\n \"minimum_failed_login_delay_seconds\": 1.0,\n \"credentials_and_cookie_values_recorded\": false\n}" + } + ], + "hashes_sha256": { + "dashboard/auth_server.py": "4b991fbe942b573f8be270538b7e9df81bca9ffed3a8982f09db0d95d26e66d1", + "dashboard/authentication.py": "63df2e33ef10859039c9b8140e92cbf0c7c8c9fe2d19cfcedbf16637c8cc2596", + "tests/test_dashboard_auth_server.py": "8b96421af776b613867a8a5ab5dd962033b39d1cc616ba4e91cf3c4de63a1f0d", + "tests/test_dashboard_authentication.py": "1ca7ecc4d4db55c674aa2934d665b121476ff5fa2e1f74071090a67e815af676", + "pyproject.toml": "64100d3aa1c2272df2e2139ed435724901f7215ea72e48bee9d73f95e7e53a8d" + }, + "redaction": { + "credentials_recorded": false, + "signing_secret_recorded": false, + "token_or_cookie_value_recorded": false, + "request_body_recorded": false, + "raw_set_cookie_recorded": false + }, + "notes": [ + "This artifact is an evidence-only descendant of source_revision; the application and test paths hashed above were not modified before these commands ran.", + "The runtime probe exercised the changed streaming login path with synthetic credentials, confirmed hardened 24-hour cookie issuance, retained the real one-second failed-login delay, and showed the event loop and a successful login remained responsive while two failed requests waited.", + "This D6-23 evidence does not replace or claim a fresh browser run; D6-22 remains supported by docs/evidence/issue-6-persistent-auth-https.json and the Review #51 change-impact analysis." + ] +} -- 2.54.0 From d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Sun, 4 Oct 2026 03:53:47 +0000 Subject: [PATCH 10/11] [verified] fix(auth): support Unicode credentials --- dashboard/auth_server.py | 2 + dashboard/authentication.py | 8 +++- tests/test_dashboard_auth_server.py | 61 ++++++++++++++++++++++++++ tests/test_dashboard_authentication.py | 14 ++++-- 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/dashboard/auth_server.py b/dashboard/auth_server.py index 825ab93..0546d58 100644 --- a/dashboard/auth_server.py +++ b/dashboard/auth_server.py @@ -104,6 +104,8 @@ async def login(request: Request) -> Response: keep_blank_values=True, strict_parsing=True, max_num_fields=3, + encoding="utf-8", + errors="strict", ) except (UnicodeDecodeError, ValueError): authentication.log_auth_event("login_request_rejected", correlation_id) diff --git a/dashboard/authentication.py b/dashboard/authentication.py index fe09bcd..006d3c9 100644 --- a/dashboard/authentication.py +++ b/dashboard/authentication.py @@ -113,8 +113,12 @@ def verify_credentials( ) -> 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) + 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 diff --git a/tests/test_dashboard_auth_server.py b/tests/test_dashboard_auth_server.py index c68ebec..0508f54 100644 --- a/tests/test_dashboard_auth_server.py +++ b/tests/test_dashboard_auth_server.py @@ -87,6 +87,50 @@ def test_remember_me_sets_30_day_cookie(client: TestClient) -> None: ) +def test_unicode_credentials_can_login_through_adapter(client: TestClient, monkeypatch) -> None: + unicode_id = "管理员" + unicode_password = "päss🔐" + monkeypatch.setenv(authentication.ADMIN_ID_ENV, unicode_id) + monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, unicode_password) + + response = client.post( + "/api/auth/login", + data={"admin_id": unicode_id, "admin_password": unicode_password}, + headers={"origin": "https://testserver"}, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/" + assert f"{authentication.AUTH_COOKIE_NAME}=" in response.headers["set-cookie"] + + +def test_unicode_credential_mismatch_is_generic_and_delayed( + client: TestClient, monkeypatch +) -> None: + import time as real_time + + unicode_id = "管理员" + unicode_password = "päss🔐" + monkeypatch.setenv(authentication.ADMIN_ID_ENV, unicode_id) + monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, unicode_password) + monkeypatch.setattr(authentication.time, "sleep", real_time.sleep) + + started = real_time.monotonic() + response = client.post( + "/api/auth/login", + data={"admin_id": unicode_id, "admin_password": "неверный🔒"}, + headers={"origin": "https://testserver"}, + follow_redirects=False, + ) + elapsed = real_time.monotonic() - started + + assert response.status_code == 303 + assert response.headers["location"] == "/?auth_error=invalid" + assert "set-cookie" not in response.headers + assert elapsed >= authentication.MINIMUM_FAILURE_DELAY_SECONDS * 0.9 + + def test_invalid_credentials_do_not_set_cookie_and_use_general_error( client: TestClient, ) -> None: @@ -104,6 +148,23 @@ def test_invalid_credentials_do_not_set_cookie_and_use_general_error( assert ADMIN_ID not in response.headers["location"] +def test_login_rejects_malformed_percent_encoded_utf8_without_cookie( + client: TestClient, +) -> None: + response = client.post( + "/api/auth/login", + content=b"admin_id=%FF&admin_password=wrong", + headers={ + "origin": "https://testserver", + "content-type": "application/x-www-form-urlencoded", + }, + follow_redirects=False, + ) + + assert response.status_code == 400 + assert "set-cookie" not in response.headers + + def test_missing_configuration_fails_closed(client: TestClient, monkeypatch) -> None: monkeypatch.delenv(authentication.SIGNING_SECRET_ENV) diff --git a/tests/test_dashboard_authentication.py b/tests/test_dashboard_authentication.py index 47e85ee..4d16765 100644 --- a/tests/test_dashboard_authentication.py +++ b/tests/test_dashboard_authentication.py @@ -63,20 +63,26 @@ def test_load_admin_credentials_fails_closed_when_environment_cannot_be_read(): def test_verify_credentials_uses_constant_time_comparison_for_both_fields(monkeypatch): credentials = authentication.AdminCredentials(ADMIN_ID, ADMIN_PASSWORD) - comparisons: list[tuple[str, str]] = [] + comparisons: list[tuple[bytes, bytes]] = [] - def record_compare(left: str, right: str) -> bool: + def record_compare(left: bytes, right: bytes) -> bool: comparisons.append((left, right)) return left == right monkeypatch.setattr(authentication.hmac, "compare_digest", record_compare) assert authentication.verify_credentials(credentials, ADMIN_ID, ADMIN_PASSWORD) is True - assert comparisons == [(ADMIN_ID, ADMIN_ID), (ADMIN_PASSWORD, ADMIN_PASSWORD)] + assert comparisons == [ + (ADMIN_ID.encode("utf-8"), ADMIN_ID.encode("utf-8")), + (ADMIN_PASSWORD.encode("utf-8"), ADMIN_PASSWORD.encode("utf-8")), + ] comparisons.clear() assert authentication.verify_credentials(credentials, "wrong-id", "wrong-password") is False - assert comparisons == [("wrong-id", ADMIN_ID), ("wrong-password", ADMIN_PASSWORD)] + assert comparisons == [ + (b"wrong-id", ADMIN_ID.encode("utf-8")), + (b"wrong-password", ADMIN_PASSWORD.encode("utf-8")), + ] def test_failed_login_delay_waits_only_for_remaining_minimum_duration(): -- 2.54.0 From 6a35775cca96dfe0168e96c882c8e240b1f1e7b4 Mon Sep 17 00:00:00 2001 From: gitea-impl-agent Date: Sun, 4 Oct 2026 03:56:53 +0000 Subject: [PATCH 11/11] [verified] docs(evidence): record F-003 verification --- .../issue-6-f003-unicode-credentials.json | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 docs/evidence/issue-6-f003-unicode-credentials.json diff --git a/docs/evidence/issue-6-f003-unicode-credentials.json b/docs/evidence/issue-6-f003-unicode-credentials.json new file mode 100644 index 0000000..3e64452 --- /dev/null +++ b/docs/evidence/issue-6-f003-unicode-credentials.json @@ -0,0 +1,227 @@ +{ + "claims": [ + { + "id": "f003-unicode-success", + "pointers": [ + "commands[0].argv", + "commands[0].exit_code", + "commands[0].output", + "commands[0].started_at_utc", + "commands[0].finished_at_utc" + ] + }, + { + "id": "f003-generic-delayed-failure", + "pointers": [ + "commands[0].argv", + "commands[0].exit_code", + "commands[0].output", + "commands[0].started_at_utc", + "commands[0].finished_at_utc" + ] + }, + { + "id": "f003-malformed-utf8-400-no-cookie", + "pointers": [ + "commands[0].argv", + "commands[0].exit_code", + "commands[0].output", + "commands[0].started_at_utc", + "commands[0].finished_at_utc" + ] + }, + { + "id": "full-regression", + "pointers": [ + "commands[1].argv", + "commands[1].exit_code", + "commands[1].output", + "commands[1].started_at_utc", + "commands[1].finished_at_utc" + ] + }, + { + "id": "static-and-source-integrity", + "pointers": [ + "commands[2].argv", + "commands[2].exit_code", + "commands[2].output", + "commands[3].argv", + "commands[3].exit_code", + "commands[3].output", + "commands[4].argv", + "commands[4].exit_code", + "commands[4].output" + ] + } + ], + "commands": [ + { + "argv": [ + ".venv/bin/python", + "-m", + "pytest", + "-o", + "addopts=", + "-q", + "-p", + "no:cacheprovider", + "--basetemp=/tmp/jarca-f003-evidence-focused", + "tests/test_dashboard_auth_server.py", + "tests/test_dashboard_authentication.py" + ], + "duration_seconds": 11.436193, + "exit_code": 0, + "expected_exit_code": 0, + "finished_at_utc": "2026-10-04T03:55:21.516159Z", + "id": "focused-tests", + "output": "................................ [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-6/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n32 passed, 1 warning in 7.81s\n", + "purpose": "F-003 Unicode success, delayed generic mismatch, malformed percent-encoded UTF-8, and credential helper verification", + "started_at_utc": "2026-10-04T03:55:10.080020Z" + }, + { + "argv": [ + ".venv/bin/python", + "-m", + "pytest", + "-o", + "addopts=", + "-q", + "-p", + "no:cacheprovider", + "--basetemp=/tmp/jarca-f003-evidence-full" + ], + "duration_seconds": 54.086422, + "exit_code": 0, + "expected_exit_code": 0, + "finished_at_utc": "2026-10-04T03:56:15.670519Z", + "id": "full-tests", + "output": "........................................................................ [ 59%]\n...................ss........................ss... [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-6/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n118 passed, 4 skipped, 1 warning in 49.12s\n", + "purpose": "Complete repository regression suite", + "started_at_utc": "2026-10-04T03:55:21.584157Z" + }, + { + "argv": [ + ".venv/bin/ruff", + "check", + "--no-cache", + "dashboard/authentication.py", + "dashboard/auth_server.py", + "tests/test_dashboard_auth_server.py", + "tests/test_dashboard_authentication.py" + ], + "duration_seconds": 0.025974, + "exit_code": 0, + "expected_exit_code": 0, + "finished_at_utc": "2026-10-04T03:56:15.770278Z", + "id": "ruff", + "output": "All checks passed!\n", + "purpose": "Static lint for the exact F-003 source and test allowlist", + "started_at_utc": "2026-10-04T03:56:15.744356Z" + }, + { + "argv": [ + "uv", + "lock", + "--check", + "--offline" + ], + "duration_seconds": 0.059792, + "exit_code": 0, + "expected_exit_code": 0, + "finished_at_utc": "2026-10-04T03:56:15.890498Z", + "id": "lock-check", + "output": "Resolved 72 packages in 3ms\n", + "purpose": "Offline dependency lock consistency", + "started_at_utc": "2026-10-04T03:56:15.830762Z" + }, + { + "argv": [ + "git", + "diff", + "--check", + "d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad^", + "d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad" + ], + "duration_seconds": 0.033395, + "exit_code": 0, + "expected_exit_code": 0, + "finished_at_utc": "2026-10-04T03:56:15.992735Z", + "id": "source-diff-check", + "output": "", + "purpose": "Whitespace validation for the exact source commit", + "started_at_utc": "2026-10-04T03:56:15.959398Z" + } + ], + "contract_sha256": "4f734169ec896747fe39fd7f3ab75d1fc0d8cdd4903045e665beba40d80eb1fe", + "environment": { + "architecture": "aarch64", + "child_environment_keys": [ + "HOME", + "NO_COLOR", + "PATH", + "PYTHONDONTWRITEBYTECODE" + ], + "credentials": "synthetic test-only values supplied by tests; not recorded", + "dependencies": { + "httpx": "0.28.1", + "itsdangerous": "2.2.0", + "pytest": "9.1.1", + "starlette": "1.3.1", + "streamlit": "1.58.0", + "uvicorn": "0.49.0" + }, + "os": "Linux-6.18.34+rpt-rpi-v8-aarch64-with-glibc2.41", + "python": "3.13.5", + "signing_secret": "synthetic test-only value supplied by tests; not recorded" + }, + "finding": "F-003", + "issue": 6, + "limitations": [ + "The focused adapter tests use Starlette TestClient rather than a newly executed external browser or deployed Compose/Kubernetes environment.", + "The existing Starlette/httpx deprecation warning is retained and is unrelated to F-003." + ], + "notes": [ + "This evidence-only descendant binds verification to source_revision; the listed source bytes and Git blob OIDs were unchanged before, during, and after every command.", + "No command used credentials, signing secrets, authorization headers, raw cookies, or shell interpretation in argv." + ], + "pull_request": 7, + "redaction": { + "credentials_recorded": false, + "raw_set_cookie_recorded": false, + "request_body_recorded": false, + "signing_secret_recorded": false, + "token_or_cookie_value_recorded": false + }, + "schema": "jarca-ledger.review-evidence/v2", + "source_review": 53, + "source_revision": "d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad", + "source_tree_clean_before_evidence_write": true, + "sources": { + "dashboard/auth_server.py": { + "git_blob_oid": "0546d582805f495c86dcdbb45510431f7699ecfd", + "sha256": "94675feffbc0807a632d51a9feea8aa4d8834a37de2d8f32c50aa273d88ddab3" + }, + "dashboard/authentication.py": { + "git_blob_oid": "006d3c95661b64924a89aa952ff531095d4684db", + "sha256": "260c3419040a883a76ced3fd1a1ff43f86763e608448fff57078c30a54c74213" + }, + "pyproject.toml": { + "git_blob_oid": "2892966feaaa8dfa07bf330997afc8315364a672", + "sha256": "64100d3aa1c2272df2e2139ed435724901f7215ea72e48bee9d73f95e7e53a8d" + }, + "tests/test_dashboard_auth_server.py": { + "git_blob_oid": "0508f54f74185fc4fda341fcdd2574cbadb0d203", + "sha256": "6b226b8d0d207a84f788b090de67fe6ddf9e7c8e05218ce18d97aa4badac95f5" + }, + "tests/test_dashboard_authentication.py": { + "git_blob_oid": "4d16765c4ab64826144d4880ec27b489a02bc53d", + "sha256": "d6758e91b2e275cb22f55929ce0bf4a775feecb23f720fa05151da1ebb4da036" + }, + "uv.lock": { + "git_blob_oid": "c92cadb5bf8ae12e603fa956177c5d7f12e58535", + "sha256": "9582546bc7c97a25b5d40d66ee814203ae46c13e7263942d02008403abd33c73" + } + }, + "verified_at_utc": "2026-10-04T03:56:15.992735Z" +} -- 2.54.0