feat(dashboard): persist administrator login

This commit is contained in:
2026-10-01 13:56:22 +00:00
parent 7bea8b368e
commit c5eaddb4f3
20 changed files with 1343 additions and 884 deletions
+3
View File
@@ -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
+10 -7
View File
@@ -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='<secret store에서 주입>'
export JARCA_DASHBOARD_ADMIN_PASSWORD='<secret store에서 주입>'
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로 지정해야 합니다.
## 주요 도구
+144
View File
@@ -0,0 +1,144 @@
from __future__ import annotations
import secrets
import time
from urllib.parse import parse_qs, urlsplit
from starlette.requests import Request
from starlette.responses import PlainTextResponse, RedirectResponse, Response
from starlette.routing import Route
from dashboard import authentication
_MAX_LOGIN_BODY_BYTES = 4096
_MAX_CREDENTIAL_CHARS = 1024
def _redirect(location: str) -> RedirectResponse:
response = RedirectResponse(location, status_code=303)
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
return response
def _same_origin(request: Request) -> bool:
origin = request.headers.get("origin")
host = request.headers.get("host")
if not origin or not host:
return False
try:
parsed = urlsplit(origin)
authority = urlsplit(f"//{host}")
origin_port = parsed.port or 443
request_port = authority.port or 443
return (
request.url.scheme == "https"
and parsed.scheme == "https"
and parsed.hostname is not None
and authority.hostname is not None
and parsed.hostname.lower() == authority.hostname.lower()
and origin_port == request_port
and parsed.username is None
and parsed.password is None
and parsed.path in {"", "/"}
and not parsed.query
and not parsed.fragment
)
except ValueError:
return False
def _one(values: dict[str, list[str]], name: str) -> str | None:
candidates = values.get(name)
if candidates is None or len(candidates) != 1:
return None
value = candidates[0]
if len(value) > _MAX_CREDENTIAL_CHARS:
return None
return value
async def login(request: Request) -> Response:
correlation_id = secrets.token_hex(8)
if not _same_origin(request):
authentication.log_auth_event("login_origin_rejected", correlation_id)
return PlainTextResponse("Forbidden", status_code=403)
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type != "application/x-www-form-urlencoded":
authentication.log_auth_event("login_request_rejected", correlation_id)
return PlainTextResponse("Bad Request", status_code=400)
body = await request.body()
if len(body) > _MAX_LOGIN_BODY_BYTES:
authentication.log_auth_event("login_request_rejected", correlation_id)
return PlainTextResponse("Bad Request", status_code=400)
try:
values = parse_qs(
body.decode("utf-8"),
keep_blank_values=True,
strict_parsing=True,
max_num_fields=3,
)
except (UnicodeDecodeError, ValueError):
authentication.log_auth_event("login_request_rejected", correlation_id)
return PlainTextResponse("Bad Request", status_code=400)
settings = authentication.load_auth_settings()
if settings is None:
authentication.log_auth_event("configuration_error", correlation_id)
return _redirect("/?auth_error=unavailable")
submitted_id = _one(values, "admin_id")
submitted_password = _one(values, "admin_password")
if submitted_id is None or submitted_password is None:
authentication.log_auth_event("login_request_rejected", correlation_id)
return _redirect("/?auth_error=invalid")
started_at = time.monotonic()
if not authentication.verify_credentials(
settings.credentials, submitted_id, submitted_password
):
authentication.enforce_failed_login_delay(started_at=started_at)
authentication.log_auth_event("login_failed", correlation_id)
return _redirect("/?auth_error=invalid")
remember_me = _one(values, "remember_me") == "on"
issued = authentication.issue_persistent_token(
settings,
remember_me=remember_me,
)
response = _redirect("/")
response.set_cookie(
authentication.AUTH_COOKIE_NAME,
issued.value,
max_age=issued.max_age,
path="/",
secure=True,
httponly=True,
samesite="lax",
)
authentication.log_auth_event("login_succeeded", correlation_id)
return response
async def logout(request: Request) -> Response:
correlation_id = secrets.token_hex(8)
if not _same_origin(request):
authentication.log_auth_event("logout_origin_rejected", correlation_id)
return PlainTextResponse("Forbidden", status_code=403)
response = _redirect("/")
response.delete_cookie(
authentication.AUTH_COOKIE_NAME,
path="/",
secure=True,
httponly=True,
samesite="lax",
)
authentication.log_auth_event("logout", correlation_id)
return response
def create_auth_routes() -> list[Route]:
return [
Route("/api/auth/login", login, methods=["POST"]),
Route("/api/auth/logout", logout, methods=["POST"]),
]
+230 -56
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from collections.abc import Callable as CallableABC, Mapping, MutableMapping
from dataclasses import dataclass
import hashlib
import hmac
import logging
import os
@@ -9,20 +10,27 @@ import secrets
import time
from typing import Callable
from itsdangerous import BadData, URLSafeSerializer
import streamlit as st
logger = logging.getLogger(__name__)
ADMIN_ID_ENV = "JARCA_DASHBOARD_ADMIN_ID"
ADMIN_PASSWORD_ENV = "JARCA_DASHBOARD_ADMIN_PASSWORD"
SIGNING_SECRET_ENV = "JARCA_DASHBOARD_SIGNING_SECRET"
CONFIGURATION_ERROR_MESSAGE = "관리자 인증 설정을 확인할 수 없습니다."
INVALID_CREDENTIALS_MESSAGE = "관리자 ID 또는 비밀번호가 올바르지 않습니다."
MINIMUM_FAILURE_DELAY_SECONDS = 1.0
AUTHENTICATED_SESSION_KEY = "_jarca_dashboard_authenticated"
LOGIN_FAILED_SESSION_KEY = "_jarca_dashboard_login_failed"
LOGIN_ID_SESSION_KEY = "_jarca_dashboard_login_id"
LOGIN_PASSWORD_SESSION_KEY = "_jarca_dashboard_login_password"
AUTH_CORRELATION_SESSION_KEY = "_jarca_dashboard_auth_correlation_id"
AUTH_COOKIE_NAME = "__Host-jarca_dashboard_auth"
DEFAULT_TOKEN_TTL_SECONDS = 24 * 60 * 60
REMEMBER_ME_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60
MAX_TOKEN_LENGTH = 4096
CLOCK_SKEW_SECONDS = 30
_TOKEN_VERSION = 1
_TOKEN_SALT = "jarca-dashboard-persistent-auth-v1"
_TOKEN_CONTEXT = b"jarca-dashboard-persistent-auth-v1\x00"
_TOKEN_FIELDS = frozenset({"v", "sub", "iat", "exp", "jti"})
@dataclass(frozen=True)
@@ -31,25 +39,79 @@ class AdminCredentials:
password: str
@dataclass(frozen=True)
class AuthSettings:
admin_id: str
admin_password: str
signing_secret: str
@property
def credentials(self) -> AdminCredentials:
return AdminCredentials(admin_id=self.admin_id, password=self.admin_password)
@dataclass(frozen=True)
class IssuedPersistentToken:
value: str
issued_at: int
expires_at: int
max_age: int
@dataclass(frozen=True)
class VerifiedPersistentToken:
subject: str
issued_at: int
expires_at: int
token_id: str
class AuthenticationConfigurationError(RuntimeError):
"""Raised without secret details when dashboard authentication is unavailable."""
def load_admin_credentials(environ: Mapping[str, str] | None = None) -> AdminCredentials:
source = os.environ if environ is None else environ
def _read_required_setting(source: Mapping[str, str], name: str) -> str:
try:
admin_id = source.get(ADMIN_ID_ENV)
password = source.get(ADMIN_PASSWORD_ENV)
value = source.get(name)
except Exception as exc:
raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE) from exc
if not isinstance(admin_id, str) or not admin_id or not isinstance(password, str) or not password:
if not isinstance(value, str) or not value:
raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE)
return AdminCredentials(admin_id=admin_id, password=password)
return value
def verify_credentials(credentials: AdminCredentials, submitted_id: str, submitted_password: str) -> bool:
def load_admin_credentials(environ: Mapping[str, str] | None = None) -> AdminCredentials:
source = os.environ if environ is None else environ
return AdminCredentials(
admin_id=_read_required_setting(source, ADMIN_ID_ENV),
password=_read_required_setting(source, ADMIN_PASSWORD_ENV),
)
def require_auth_settings(environ: Mapping[str, str] | None = None) -> AuthSettings:
source = os.environ if environ is None else environ
settings = AuthSettings(
admin_id=_read_required_setting(source, ADMIN_ID_ENV),
admin_password=_read_required_setting(source, ADMIN_PASSWORD_ENV),
signing_secret=_read_required_setting(source, SIGNING_SECRET_ENV),
)
if len(settings.signing_secret.encode("utf-8")) < 32:
raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE)
return settings
def load_auth_settings(environ: Mapping[str, str] | None = None) -> AuthSettings | None:
try:
return require_auth_settings(environ)
except AuthenticationConfigurationError:
return None
def verify_credentials(
credentials: AdminCredentials, submitted_id: str, submitted_password: str
) -> bool:
if not isinstance(submitted_id, str) or not isinstance(submitted_password, str):
return False
id_matches = hmac.compare_digest(submitted_id, credentials.admin_id)
password_matches = hmac.compare_digest(submitted_password, credentials.password)
return id_matches and password_matches
@@ -84,56 +146,168 @@ def log_auth_event(event: str, correlation_id: str) -> None:
logger.info("dashboard_auth event=%s correlation_id=%s", event, correlation_id)
def _clear_submitted_credentials() -> None:
st.session_state.pop(LOGIN_ID_SESSION_KEY, None)
st.session_state.pop(LOGIN_PASSWORD_SESSION_KEY, None)
def _derived_signing_key(settings: AuthSettings) -> bytes:
return hmac.new(
settings.signing_secret.encode("utf-8"),
_TOKEN_CONTEXT + settings.admin_password.encode("utf-8"),
hashlib.sha256,
).digest()
def _serializer_for(settings: AuthSettings) -> URLSafeSerializer:
return URLSafeSerializer(
secret_key=_derived_signing_key(settings),
salt=_TOKEN_SALT,
signer_kwargs={"digest_method": hashlib.sha256},
)
def issue_persistent_token(
settings: AuthSettings,
*,
remember_me: bool,
now: float | None = None,
token_id: str | None = None,
) -> IssuedPersistentToken:
issued_at = int(time.time() if now is None else now)
ttl = (
REMEMBER_ME_TOKEN_TTL_SECONDS if remember_me else DEFAULT_TOKEN_TTL_SECONDS
)
expires_at = issued_at + ttl
jti = secrets.token_urlsafe(16) if token_id is None else token_id
payload = {
"v": _TOKEN_VERSION,
"sub": settings.admin_id,
"iat": issued_at,
"exp": expires_at,
"jti": jti,
}
return IssuedPersistentToken(
value=_serializer_for(settings).dumps(payload),
issued_at=issued_at,
expires_at=expires_at,
max_age=ttl,
)
def verify_persistent_token(
settings: AuthSettings | None,
token: object,
*,
now: float | None = None,
) -> VerifiedPersistentToken | None:
if settings is None or not isinstance(token, str) or not token:
return None
if len(token) > MAX_TOKEN_LENGTH:
return None
try:
payload = _serializer_for(settings).loads(token)
except (BadData, TypeError, ValueError):
return None
if not isinstance(payload, dict) or frozenset(payload) != _TOKEN_FIELDS:
return None
version = payload.get("v")
subject = payload.get("sub")
issued_at = payload.get("iat")
expires_at = payload.get("exp")
token_id = payload.get("jti")
if (
type(version) is not int
or version != _TOKEN_VERSION
or not isinstance(subject, str)
or not subject
or type(issued_at) is not int
or type(expires_at) is not int
or not isinstance(token_id, str)
or not token_id
or len(token_id) > 256
):
return None
if not hmac.compare_digest(subject, settings.admin_id):
return None
current_time = int(time.time() if now is None else now)
ttl = expires_at - issued_at
if ttl not in {DEFAULT_TOKEN_TTL_SECONDS, REMEMBER_ME_TOKEN_TTL_SECONDS}:
return None
if issued_at > current_time + CLOCK_SKEW_SECONDS or expires_at <= current_time:
return None
return VerifiedPersistentToken(
subject=subject,
issued_at=issued_at,
expires_at=expires_at,
token_id=token_id,
)
def _request_cookie() -> str | None:
try:
value = st.context.cookies.get(AUTH_COOKIE_NAME)
except Exception:
return None
return value if isinstance(value, str) and value else None
def _query_error() -> str | None:
try:
value = st.query_params.get("auth_error")
except Exception:
return None
return value if isinstance(value, str) else None
def _render_login() -> None:
st.title("관리자 로그인")
st.caption("Dashboard를 사용하려면 관리자 자격 증명이 필요합니다.")
if _query_error() == "invalid":
st.error(INVALID_CREDENTIALS_MESSAGE)
elif _query_error() == "unavailable":
st.error(CONFIGURATION_ERROR_MESSAGE)
st.markdown(
"""
<form action="/api/auth/login" method="post">
<label for="jarca-admin-id">관리자 ID</label>
<input id="jarca-admin-id" name="admin_id" type="text" autocomplete="username" required>
<label for="jarca-admin-password">비밀번호</label>
<input id="jarca-admin-password" name="admin_password" type="password" autocomplete="current-password" required>
<label><input name="remember_me" type="checkbox"> 로그인 상태 유지 (30일)</label>
<button type="submit">로그인</button>
</form>
""",
unsafe_allow_html=True,
)
def _render_logout() -> None:
with st.sidebar:
st.markdown(
"""
<form action="/api/auth/logout" method="post">
<button type="submit">로그아웃</button>
</form>
""",
unsafe_allow_html=True,
)
def require_dashboard_authentication() -> bool:
"""Render the authentication boundary and return whether access is allowed."""
"""Validate the server-signed cookie before allowing any dashboard access."""
correlation_id = get_auth_correlation_id(st.session_state)
try:
credentials = load_admin_credentials()
except AuthenticationConfigurationError:
st.session_state.pop(AUTHENTICATED_SESSION_KEY, None)
_clear_submitted_credentials()
settings = load_auth_settings()
if settings is None:
log_auth_event("configuration_error", correlation_id)
st.title("관리자 로그인")
_render_login()
st.error(CONFIGURATION_ERROR_MESSAGE)
return False
if st.session_state.get(AUTHENTICATED_SESSION_KEY) is True:
_clear_submitted_credentials()
with st.sidebar:
if st.button("로그아웃", key="dashboard_logout", use_container_width=True):
st.session_state.pop(AUTHENTICATED_SESSION_KEY, None)
log_auth_event("logout", correlation_id)
st.rerun()
return True
st.title("관리자 로그인")
st.caption("Dashboard를 사용하려면 관리자 자격 증명이 필요합니다.")
show_login_error = st.session_state.pop(LOGIN_FAILED_SESSION_KEY, False) is True
if show_login_error:
_clear_submitted_credentials()
with st.form("dashboard_login", clear_on_submit=True):
submitted_id = st.text_input("관리자 ID", key=LOGIN_ID_SESSION_KEY)
submitted_password = st.text_input("비밀번호", type="password", key=LOGIN_PASSWORD_SESSION_KEY)
submitted = st.form_submit_button("로그인", use_container_width=True)
if show_login_error:
st.error(INVALID_CREDENTIALS_MESSAGE)
if not submitted:
verified = verify_persistent_token(settings, _request_cookie())
if verified is None:
log_auth_event("authentication_required", correlation_id)
_render_login()
return False
started_at = time.monotonic()
if verify_credentials(credentials, submitted_id, submitted_password):
st.session_state[AUTHENTICATED_SESSION_KEY] = True
log_auth_event("login_succeeded", correlation_id)
st.rerun()
enforce_failed_login_delay(started_at=started_at)
log_auth_event("login_failed", correlation_id)
st.session_state[LOGIN_FAILED_SESSION_KEY] = True
st.rerun()
log_auth_event("token_accepted", correlation_id)
_render_logout()
return True
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
from pathlib import Path
from streamlit import App
from dashboard.auth_server import create_auth_routes
APP_SCRIPT = Path(__file__).with_name("app.py")
app = App(APP_SCRIPT, routes=create_auth_routes())
+2 -1
View File
@@ -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
+12 -11
View File
@@ -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
+7 -11
View File
@@ -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
@@ -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."
]
}
+71 -122
View File
@@ -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=<secret-injected-admin-id>
JARCA_DASHBOARD_ADMIN_PASSWORD=<secret-injected-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=<submitted>&admin_password=<submitted>&remember_me=on
→ 303 /
→ Set-Cookie: __Host-jarca_dashboard_auth=<redacted>; 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에 남기지 않는다.
+66 -81
View File
@@ -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`가 추가되지 않았는지 확인한다.
@@ -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`
+64 -66
View File
@@ -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로 확인
+7 -4
View File
@@ -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]
+153
View File
@@ -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()
+8 -7
View File
@@ -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
+191
View File
@@ -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
+70 -89
View File
@@ -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)
@@ -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()
Generated
+11 -3
View File
@@ -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"]