feat(dashboard): persist administrator login
This commit is contained in:
@@ -4,3 +4,6 @@ JARCA_LEDGER_DB_PATH=
|
|||||||
# Required for the Streamlit Dashboard. Inject real values from a secret store.
|
# Required for the Streamlit Dashboard. Inject real values from a secret store.
|
||||||
JARCA_DASHBOARD_ADMIN_ID=
|
JARCA_DASHBOARD_ADMIN_ID=
|
||||||
JARCA_DASHBOARD_ADMIN_PASSWORD=
|
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
|
||||||
|
|||||||
@@ -31,22 +31,25 @@ hermes mcp test jarca-ledger
|
|||||||
|
|
||||||
로컬 웹 대시보드는 실제 기본 DB `~/.local/share/jarca/ledger.sqlite3`를 읽고 씁니다.
|
로컬 웹 대시보드는 실제 기본 DB `~/.local/share/jarca/ledger.sqlite3`를 읽고 씁니다.
|
||||||
|
|
||||||
Dashboard는 모든 조회·수정·Import 기능 앞에서 단일 관리자 로그인을 요구합니다. 관리자 ID와 password는 Streamlit secrets가 아니라 배포 secret이 주입하는 다음 환경변수에서만 읽습니다.
|
Dashboard는 모든 조회·수정·Import 기능 앞에서 단일 관리자 로그인을 요구합니다. 관리자 ID, password와 독립적인 서명 secret은 Streamlit secrets가 아니라 배포 secret이 주입하는 다음 환경변수에서만 읽습니다.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export JARCA_DASHBOARD_ADMIN_ID='<secret store에서 주입>'
|
export JARCA_DASHBOARD_ADMIN_ID='<secret store에서 주입>'
|
||||||
export JARCA_DASHBOARD_ADMIN_PASSWORD='<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
|
```bash
|
||||||
cd /root/projects/jarca-ledger-mcp
|
cd /root/projects/jarca-ledger-mcp
|
||||||
. .venv/bin/activate
|
. .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는 별도 요구사항과 적절한 네트워크 경계로 보호해야 합니다.
|
이 로그인은 **Streamlit Dashboard만 보호하며 MCP endpoint를 인증하지 않습니다.** MCP는 별도 요구사항과 적절한 네트워크 경계로 보호해야 합니다.
|
||||||
|
|
||||||
@@ -75,7 +78,7 @@ Deployment: jarca-ledger (replicas: 1)
|
|||||||
│ └─ containerPort: 8000
|
│ └─ containerPort: 8000
|
||||||
├─ frontend container
|
├─ frontend container
|
||||||
│ ├─ 동일 릴리즈 이미지
|
│ ├─ 동일 릴리즈 이미지
|
||||||
│ ├─ command: python -m streamlit run dashboard/app.py ...
|
│ ├─ command: uvicorn dashboard.server:app ...
|
||||||
│ └─ containerPort: 8501
|
│ └─ containerPort: 8501
|
||||||
└─ shared PVC
|
└─ shared PVC
|
||||||
└─ /data/ledger.sqlite3
|
└─ /data/ledger.sqlite3
|
||||||
@@ -86,7 +89,7 @@ Deployment: jarca-ledger (replicas: 1)
|
|||||||
- 하나의 Pod 안에서 MCP와 Streamlit을 **별도 컨테이너**로 실행합니다. 한 컨테이너에서 두 프로세스를 supervisor나 background command로 함께 실행하지 않습니다.
|
- 하나의 Pod 안에서 MCP와 Streamlit을 **별도 컨테이너**로 실행합니다. 한 컨테이너에서 두 프로세스를 supervisor나 background command로 함께 실행하지 않습니다.
|
||||||
- 두 컨테이너는 동일한 PVC를 `/data`에 마운트하고 `JARCA_LEDGER_DB_PATH=/data/ledger.sqlite3`를 사용합니다.
|
- 두 컨테이너는 동일한 PVC를 `/data`에 마운트하고 `JARCA_LEDGER_DB_PATH=/data/ledger.sqlite3`를 사용합니다.
|
||||||
- 이미지와 Pod는 UID/GID `1000:1000`으로 실행하며, PVC 쓰기 권한은 `fsGroup: 1000`으로 설정합니다.
|
- 이미지와 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`로 유지합니다.
|
- SQLite 파일을 공유하므로 Deployment의 replica는 `1`, update 전략은 `Recreate`로 유지합니다.
|
||||||
- Base는 Dashboard용 `ClusterIP` Service(`8501`)만 생성합니다. 인증 계층이 없는 MCP port `8000`에는 Service를 만들지 않고 `NetworkPolicy`로 ingress를 차단합니다. MCP 원격 접근이 필요하면 environment overlay에서 인증된 gateway와 제한된 ingress 정책을 함께 정의합니다.
|
- Base는 Dashboard용 `ClusterIP` Service(`8501`)만 생성합니다. 인증 계층이 없는 MCP port `8000`에는 Service를 만들지 않고 `NetworkPolicy`로 ingress를 차단합니다. MCP 원격 접근이 필요하면 environment overlay에서 인증된 gateway와 제한된 ingress 정책을 함께 정의합니다.
|
||||||
- 외부 공개가 필요하면 Dashboard Service만 environment overlay의 Ingress 또는 NodePort에 연결합니다.
|
- 외부 공개가 필요하면 Dashboard Service만 environment overlay의 Ingress 또는 NodePort에 연결합니다.
|
||||||
@@ -97,7 +100,7 @@ Deployment: jarca-ledger (replicas: 1)
|
|||||||
|
|
||||||
Streamlit이 SQLite에 직접 접근하지 않고 MCP/API만 호출하도록 변경하거나 네트워크 DB로 전환한 뒤에는 MCP와 frontend를 각각 독립 Deployment로 분리할 수 있습니다.
|
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로 지정해야 합니다.
|
||||||
|
|
||||||
## 주요 도구
|
## 주요 도구
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import PlainTextResponse, RedirectResponse, Response
|
||||||
|
from starlette.routing import Route
|
||||||
|
|
||||||
|
from dashboard import authentication
|
||||||
|
|
||||||
|
_MAX_LOGIN_BODY_BYTES = 4096
|
||||||
|
_MAX_CREDENTIAL_CHARS = 1024
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect(location: str) -> RedirectResponse:
|
||||||
|
response = RedirectResponse(location, status_code=303)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _same_origin(request: Request) -> bool:
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
host = request.headers.get("host")
|
||||||
|
if not origin or not host:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(origin)
|
||||||
|
authority = urlsplit(f"//{host}")
|
||||||
|
origin_port = parsed.port or 443
|
||||||
|
request_port = authority.port or 443
|
||||||
|
return (
|
||||||
|
request.url.scheme == "https"
|
||||||
|
and parsed.scheme == "https"
|
||||||
|
and parsed.hostname is not None
|
||||||
|
and authority.hostname is not None
|
||||||
|
and parsed.hostname.lower() == authority.hostname.lower()
|
||||||
|
and origin_port == request_port
|
||||||
|
and parsed.username is None
|
||||||
|
and parsed.password is None
|
||||||
|
and parsed.path in {"", "/"}
|
||||||
|
and not parsed.query
|
||||||
|
and not parsed.fragment
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _one(values: dict[str, list[str]], name: str) -> str | None:
|
||||||
|
candidates = values.get(name)
|
||||||
|
if candidates is None or len(candidates) != 1:
|
||||||
|
return None
|
||||||
|
value = candidates[0]
|
||||||
|
if len(value) > _MAX_CREDENTIAL_CHARS:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
async def login(request: Request) -> Response:
|
||||||
|
correlation_id = secrets.token_hex(8)
|
||||||
|
if not _same_origin(request):
|
||||||
|
authentication.log_auth_event("login_origin_rejected", correlation_id)
|
||||||
|
return PlainTextResponse("Forbidden", status_code=403)
|
||||||
|
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||||
|
if content_type != "application/x-www-form-urlencoded":
|
||||||
|
authentication.log_auth_event("login_request_rejected", correlation_id)
|
||||||
|
return PlainTextResponse("Bad Request", status_code=400)
|
||||||
|
body = await request.body()
|
||||||
|
if len(body) > _MAX_LOGIN_BODY_BYTES:
|
||||||
|
authentication.log_auth_event("login_request_rejected", correlation_id)
|
||||||
|
return PlainTextResponse("Bad Request", status_code=400)
|
||||||
|
try:
|
||||||
|
values = parse_qs(
|
||||||
|
body.decode("utf-8"),
|
||||||
|
keep_blank_values=True,
|
||||||
|
strict_parsing=True,
|
||||||
|
max_num_fields=3,
|
||||||
|
)
|
||||||
|
except (UnicodeDecodeError, ValueError):
|
||||||
|
authentication.log_auth_event("login_request_rejected", correlation_id)
|
||||||
|
return PlainTextResponse("Bad Request", status_code=400)
|
||||||
|
|
||||||
|
settings = authentication.load_auth_settings()
|
||||||
|
if settings is None:
|
||||||
|
authentication.log_auth_event("configuration_error", correlation_id)
|
||||||
|
return _redirect("/?auth_error=unavailable")
|
||||||
|
|
||||||
|
submitted_id = _one(values, "admin_id")
|
||||||
|
submitted_password = _one(values, "admin_password")
|
||||||
|
if submitted_id is None or submitted_password is None:
|
||||||
|
authentication.log_auth_event("login_request_rejected", correlation_id)
|
||||||
|
return _redirect("/?auth_error=invalid")
|
||||||
|
|
||||||
|
started_at = time.monotonic()
|
||||||
|
if not authentication.verify_credentials(
|
||||||
|
settings.credentials, submitted_id, submitted_password
|
||||||
|
):
|
||||||
|
authentication.enforce_failed_login_delay(started_at=started_at)
|
||||||
|
authentication.log_auth_event("login_failed", correlation_id)
|
||||||
|
return _redirect("/?auth_error=invalid")
|
||||||
|
|
||||||
|
remember_me = _one(values, "remember_me") == "on"
|
||||||
|
issued = authentication.issue_persistent_token(
|
||||||
|
settings,
|
||||||
|
remember_me=remember_me,
|
||||||
|
)
|
||||||
|
response = _redirect("/")
|
||||||
|
response.set_cookie(
|
||||||
|
authentication.AUTH_COOKIE_NAME,
|
||||||
|
issued.value,
|
||||||
|
max_age=issued.max_age,
|
||||||
|
path="/",
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
authentication.log_auth_event("login_succeeded", correlation_id)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def logout(request: Request) -> Response:
|
||||||
|
correlation_id = secrets.token_hex(8)
|
||||||
|
if not _same_origin(request):
|
||||||
|
authentication.log_auth_event("logout_origin_rejected", correlation_id)
|
||||||
|
return PlainTextResponse("Forbidden", status_code=403)
|
||||||
|
response = _redirect("/")
|
||||||
|
response.delete_cookie(
|
||||||
|
authentication.AUTH_COOKIE_NAME,
|
||||||
|
path="/",
|
||||||
|
secure=True,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
authentication.log_auth_event("logout", correlation_id)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def create_auth_routes() -> list[Route]:
|
||||||
|
return [
|
||||||
|
Route("/api/auth/login", login, methods=["POST"]),
|
||||||
|
Route("/api/auth/logout", logout, methods=["POST"]),
|
||||||
|
]
|
||||||
+230
-56
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable as CallableABC, Mapping, MutableMapping
|
from collections.abc import Callable as CallableABC, Mapping, MutableMapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -9,20 +10,27 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
from itsdangerous import BadData, URLSafeSerializer
|
||||||
import streamlit as st
|
import streamlit as st
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ADMIN_ID_ENV = "JARCA_DASHBOARD_ADMIN_ID"
|
ADMIN_ID_ENV = "JARCA_DASHBOARD_ADMIN_ID"
|
||||||
ADMIN_PASSWORD_ENV = "JARCA_DASHBOARD_ADMIN_PASSWORD"
|
ADMIN_PASSWORD_ENV = "JARCA_DASHBOARD_ADMIN_PASSWORD"
|
||||||
|
SIGNING_SECRET_ENV = "JARCA_DASHBOARD_SIGNING_SECRET"
|
||||||
CONFIGURATION_ERROR_MESSAGE = "관리자 인증 설정을 확인할 수 없습니다."
|
CONFIGURATION_ERROR_MESSAGE = "관리자 인증 설정을 확인할 수 없습니다."
|
||||||
INVALID_CREDENTIALS_MESSAGE = "관리자 ID 또는 비밀번호가 올바르지 않습니다."
|
INVALID_CREDENTIALS_MESSAGE = "관리자 ID 또는 비밀번호가 올바르지 않습니다."
|
||||||
MINIMUM_FAILURE_DELAY_SECONDS = 1.0
|
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_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)
|
@dataclass(frozen=True)
|
||||||
@@ -31,25 +39,79 @@ class AdminCredentials:
|
|||||||
password: str
|
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):
|
class AuthenticationConfigurationError(RuntimeError):
|
||||||
"""Raised without secret details when dashboard authentication is unavailable."""
|
"""Raised without secret details when dashboard authentication is unavailable."""
|
||||||
|
|
||||||
|
|
||||||
def load_admin_credentials(environ: Mapping[str, str] | None = None) -> AdminCredentials:
|
def _read_required_setting(source: Mapping[str, str], name: str) -> str:
|
||||||
source = os.environ if environ is None else environ
|
|
||||||
try:
|
try:
|
||||||
admin_id = source.get(ADMIN_ID_ENV)
|
value = source.get(name)
|
||||||
password = source.get(ADMIN_PASSWORD_ENV)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE) from exc
|
raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE) from exc
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
if not isinstance(admin_id, str) or not admin_id or not isinstance(password, str) or not password:
|
|
||||||
raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE)
|
raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE)
|
||||||
|
return value
|
||||||
return AdminCredentials(admin_id=admin_id, password=password)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
id_matches = hmac.compare_digest(submitted_id, credentials.admin_id)
|
||||||
password_matches = hmac.compare_digest(submitted_password, credentials.password)
|
password_matches = hmac.compare_digest(submitted_password, credentials.password)
|
||||||
return id_matches and password_matches
|
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)
|
logger.info("dashboard_auth event=%s correlation_id=%s", event, correlation_id)
|
||||||
|
|
||||||
|
|
||||||
def _clear_submitted_credentials() -> None:
|
def _derived_signing_key(settings: AuthSettings) -> bytes:
|
||||||
st.session_state.pop(LOGIN_ID_SESSION_KEY, None)
|
return hmac.new(
|
||||||
st.session_state.pop(LOGIN_PASSWORD_SESSION_KEY, None)
|
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:
|
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)
|
correlation_id = get_auth_correlation_id(st.session_state)
|
||||||
try:
|
settings = load_auth_settings()
|
||||||
credentials = load_admin_credentials()
|
if settings is None:
|
||||||
except AuthenticationConfigurationError:
|
|
||||||
st.session_state.pop(AUTHENTICATED_SESSION_KEY, None)
|
|
||||||
_clear_submitted_credentials()
|
|
||||||
log_auth_event("configuration_error", correlation_id)
|
log_auth_event("configuration_error", correlation_id)
|
||||||
st.title("관리자 로그인")
|
_render_login()
|
||||||
st.error(CONFIGURATION_ERROR_MESSAGE)
|
st.error(CONFIGURATION_ERROR_MESSAGE)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if st.session_state.get(AUTHENTICATED_SESSION_KEY) is True:
|
verified = verify_persistent_token(settings, _request_cookie())
|
||||||
_clear_submitted_credentials()
|
if verified is None:
|
||||||
with st.sidebar:
|
log_auth_event("authentication_required", correlation_id)
|
||||||
if st.button("로그아웃", key="dashboard_logout", use_container_width=True):
|
_render_login()
|
||||||
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:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
started_at = time.monotonic()
|
log_auth_event("token_accepted", correlation_id)
|
||||||
if verify_credentials(credentials, submitted_id, submitted_password):
|
_render_logout()
|
||||||
st.session_state[AUTHENTICATED_SESSION_KEY] = True
|
return 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()
|
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -26,6 +26,7 @@ kubectl apply -f deploy/k8s/base/namespace.yaml
|
|||||||
- `jarca-ledger-dashboard-auth`
|
- `jarca-ledger-dashboard-auth`
|
||||||
- key `admin-id`
|
- key `admin-id`
|
||||||
- key `admin-password`
|
- key `admin-password`
|
||||||
|
- key `signing-secret` (32 byte 이상의 독립적인 무작위 값)
|
||||||
|
|
||||||
Registry credential을 application container에 환경변수나 volume으로 전달하지 않는다. `imagePullSecrets`로만 사용한다.
|
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
|
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
|
## SQLite와 PVC
|
||||||
|
|
||||||
|
|||||||
@@ -107,19 +107,13 @@ spec:
|
|||||||
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.2
|
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.2
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command:
|
command:
|
||||||
- python
|
- uvicorn
|
||||||
- -m
|
- dashboard.server:app
|
||||||
- streamlit
|
- --host
|
||||||
- run
|
|
||||||
- dashboard/app.py
|
|
||||||
- --server.address
|
|
||||||
- 0.0.0.0
|
- 0.0.0.0
|
||||||
- --server.port
|
- --port
|
||||||
- "8501"
|
- "8501"
|
||||||
- --server.headless
|
- --proxy-headers
|
||||||
- "true"
|
|
||||||
- --browser.gatherUsageStats
|
|
||||||
- "false"
|
|
||||||
env:
|
env:
|
||||||
- name: JARCA_LEDGER_DB_PATH
|
- name: JARCA_LEDGER_DB_PATH
|
||||||
value: /data/ledger.sqlite3
|
value: /data/ledger.sqlite3
|
||||||
@@ -137,6 +131,13 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: jarca-ledger-dashboard-auth
|
name: jarca-ledger-dashboard-auth
|
||||||
key: admin-password
|
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:
|
ports:
|
||||||
- name: dashboard
|
- name: dashboard
|
||||||
containerPort: 8501
|
containerPort: 8501
|
||||||
|
|||||||
+7
-11
@@ -31,24 +31,20 @@ services:
|
|||||||
JARCA_LEDGER_DB_PATH: /data/ledger.sqlite3
|
JARCA_LEDGER_DB_PATH: /data/ledger.sqlite3
|
||||||
JARCA_DASHBOARD_ADMIN_ID: ${JARCA_DASHBOARD_ADMIN_ID:?set JARCA_DASHBOARD_ADMIN_ID}
|
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_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"
|
STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false"
|
||||||
PYTHONUNBUFFERED: "1"
|
PYTHONUNBUFFERED: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- /volume1/docker/jarca-ledger/data:/data
|
- /volume1/docker/jarca-ledger/data:/data
|
||||||
command:
|
command:
|
||||||
- python
|
- uvicorn
|
||||||
- -m
|
- dashboard.server:app
|
||||||
- streamlit
|
- --host
|
||||||
- run
|
|
||||||
- dashboard/app.py
|
|
||||||
- --server.address
|
|
||||||
- 0.0.0.0
|
- 0.0.0.0
|
||||||
- --server.port
|
- --port
|
||||||
- "8501"
|
- "8501"
|
||||||
- --server.headless
|
- --proxy-headers
|
||||||
- "true"
|
|
||||||
- --browser.gatherUsageStats
|
|
||||||
- "false"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
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
@@ -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 경계
|
- 포함: 관리자 credential, signing secret, login/logout POST, token/cookie, fail-closed page gate
|
||||||
- 제외 범위: MCP 인증, 다중 사용자, 영속 session, OIDC·SSO, TLS와 Ingress
|
- 제외: MCP 인증, OAuth/OIDC, 다중 사용자, token registry, 외부 인증 middleware
|
||||||
- 사용 주체: Dashboard 사용자, 배포 운영자, `dashboard/app.py`, Dashboard page view
|
- 공개 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의 인증 상태를 설정한다.
|
### dashboard-auth-config
|
||||||
- 종류·프로토콜: Streamlit form rerun
|
|
||||||
- 호출 대상·operation: `dashboard.authentication.require_dashboard_authentication`
|
|
||||||
- 버전: 1.0
|
|
||||||
- 호출 주체: 미인증 Dashboard 사용자
|
|
||||||
- 인증 방식: 관리자 ID·password의 constant-time 비교
|
|
||||||
- 필요 권한: 없음
|
|
||||||
|
|
||||||
#### 입력
|
| 이름 | 타입 | 필수 | 제약 | 민감정보 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `JARCA_DASHBOARD_ADMIN_ID` | string | 예 | non-empty | 예 |
|
||||||
|
| `JARCA_DASHBOARD_ADMIN_PASSWORD` | string | 예 | non-empty | 예 |
|
||||||
|
| `JARCA_DASHBOARD_SIGNING_SECRET` | string | 예 | UTF-8 32 byte 이상 | 예 |
|
||||||
|
|
||||||
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|
누락·빈 값·비문자열·read error·짧은 signing secret은 일반 설정 오류로 fail-closed한다. Secret 원문이나 read-error 원인은 사용자 응답과 log에 포함하지 않는다.
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| `admin_id` | Streamlit text input | `string` | 필수 | 빈 문자열 | 사용자 제출 문자열 | 관리자 ID 후보 | 예 |
|
|
||||||
| `password` | Streamlit password input | `string` | 필수 | 빈 문자열 | 사용자 제출 문자열 | 관리자 password 후보 | 예 |
|
|
||||||
| `page` | URL query parameter | `string` | 선택 | `요약` | 기존 Dashboard page 이름 | 인증 성공 후 이동할 page | 아니요 |
|
|
||||||
|
|
||||||
#### 출력
|
### POST /api/auth/login
|
||||||
|
|
||||||
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|
- Content-Type: `application/x-www-form-urlencoded`
|
||||||
|---|---|---|---|---|---|---|
|
- Transport: trusted ASGI scope의 request scheme이 `https`여야 함
|
||||||
| `_jarca_dashboard_authenticated` | `st.session_state` | `boolean` | 성공 후 필수 | `true` | 현재 session의 인증 여부 | 아니요 |
|
- Origin: request host·effective port와 같은 HTTPS origin만 허용
|
||||||
| 화면 | Streamlit UI | `view` | 필수 | 로그인 또는 인증된 Dashboard | 현재 인증 상태의 사용자 화면 | 금융 화면은 인증 후 민감 |
|
- 최대 body: 4096 bytes
|
||||||
| 인증 event | application log | `string` | 제출 후 필수 | event와 correlation ID | 운영 진단 신호 | 아니요 |
|
- 입력:
|
||||||
|
|
||||||
#### 오류
|
| 필드 | 타입 | 필수 | 의미 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `admin_id` | string | 예 | 제출 관리자 ID |
|
||||||
|
| `admin_password` | string | 예 | 제출 password |
|
||||||
|
| `remember_me` | string | 아니요 | 값이 `on`이면 30일, 아니면 24시간 |
|
||||||
|
|
||||||
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|
- 성공: `303 Location: /`, hardened cookie 1개 발급
|
||||||
|---|---|---|---|---|---|
|
- credential 실패: 최소 1초 후 `303 Location: /?auth_error=invalid`, cookie 없음
|
||||||
| `INVALID_CREDENTIALS` | 인증 | ID 또는 password 불일치 | 가능 | 최소 1초 후 동일 일반 오류 표시 | 금융 상태 무변경, credential 입력 제거 |
|
- 설정 실패: `303 Location: /?auth_error=unavailable`, cookie 없음
|
||||||
| `AUTH_CONFIGURATION_ERROR` | 설정 | credential source 무효 | 설정 수정 후 가능 | 일반 설정 오류만 표시 | 인증 상태 폐기, 금융 상태 무변경 |
|
- 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 `로그아웃`
|
| 이름 | `__Host-jarca_dashboard_auth` |
|
||||||
- 버전: 1.0
|
| `Secure` | true |
|
||||||
- 호출 주체: 인증된 Dashboard 사용자
|
| `HttpOnly` | true |
|
||||||
- 인증 방식: 현재 session의 인증 boolean
|
| `SameSite` | `Lax` |
|
||||||
- 필요 권한: 인증된 session
|
| `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은 일반 요청에서 갱신하지 않는다.
|
||||||
|
|
||||||
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|
### POST /api/auth/logout
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| `로그아웃` | Streamlit button | `click event` | 필수 | 없음 | 단일 click | 인증 종료 요청 | 아니요 |
|
|
||||||
|
|
||||||
#### 출력
|
- Same-origin HTTPS POST만 허용한다.
|
||||||
|
- 성공: `303 Location: /`과 동일 name/path/security 속성의 `Max-Age=0` cookie deletion
|
||||||
|
- 금융 상태와 DB는 변경하지 않는다.
|
||||||
|
- Stateless token 복사본은 개별 폐기하지 않는다. 강제 무효화는 password/signing-secret rotation으로 수행한다.
|
||||||
|
|
||||||
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|
### dashboard-auth-gate
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| 인증 상태 | `st.session_state` | `absent` | 필수 | key 제거 | 현재 session 재인증 요구 | 아니요 |
|
|
||||||
| 화면 | Streamlit UI | `view` | 필수 | 로그인 화면 | 로그아웃 완료 | 아니요 |
|
|
||||||
|
|
||||||
#### 오류
|
`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와 page dispatch는 유효 인증 이후 그대로 유지한다.
|
||||||
|
- REQ-001 revision 2.0이 revision 1.0의 session-only boolean 계약을 signed-cookie 계약으로 대체한다.
|
||||||
- 유지할 기존 호출 방식: 인증 성공 후 기존 `page` query parameter와 Dashboard page dispatch를 유지한다.
|
- 설정 판정 → cookie 검증 → DB path → page dispatch 순서를 유지한다.
|
||||||
- 버전 변경 정책: 환경변수 이름 또는 session 계약 변경은 requirement revision과 본 문서를 함께 변경한다.
|
- password, signing secret 또는 관리자 ID 변경은 기존 token을 거부한다.
|
||||||
- 폐기 정책: 두 환경변수는 대체 계약 승인 전까지 폐기하지 않는다.
|
- 반복 실패는 application lock을 만들지 않으며 각 credential 실패에 최소 1초 지연을 적용한다.
|
||||||
- 재시도 계약: 설정 오류는 설정 수정 후 rerun, credential 오류는 각 제출마다 독립 재시도한다.
|
|
||||||
- 중복 처리 계약: 반복 실패는 잠금을 만들지 않으며 각 실패에 최소 1초 지연을 적용한다. 로그아웃 재시도는 금융 상태를 변경하지 않는다.
|
|
||||||
- 순서 보장: 설정 판정 → session 판정 → 로그인 또는 로그아웃 → 인증 성공 후에만 DB 경로 취득과 page dispatch 순서를 유지한다.
|
|
||||||
|
|
||||||
## 예시
|
## 예시
|
||||||
|
|
||||||
### config-example — 배포 환경변수 이름
|
|
||||||
|
|
||||||
- 대상 인터페이스: `dashboard-auth-config`
|
|
||||||
|
|
||||||
#### 호출
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
JARCA_DASHBOARD_ADMIN_ID=<secret-injected-admin-id>
|
POST /api/auth/login (same-origin HTTPS)
|
||||||
JARCA_DASHBOARD_ADMIN_PASSWORD=<secret-injected-password>
|
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
|
||||||
#### 결과
|
|
||||||
|
|
||||||
```text
|
|
||||||
유효한 두 값: 로그인 form 제공
|
|
||||||
누락 또는 빈 값: 일반 설정 오류와 Dashboard 차단
|
|
||||||
```
|
|
||||||
|
|
||||||
### login-failure-example — 잘못된 credential
|
|
||||||
|
|
||||||
- 대상 인터페이스: `dashboard-login`
|
|
||||||
|
|
||||||
#### 호출
|
|
||||||
|
|
||||||
```text
|
|
||||||
관리자 ID와 password 제출
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 결과
|
|
||||||
|
|
||||||
```text
|
|
||||||
최소 1초 후 "관리자 ID 또는 비밀번호가 올바르지 않습니다."
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 검증
|
## 검증
|
||||||
|
|
||||||
- 정상 계약: test-only credential과 synthetic DB를 사용한 AppTest 로그인 후 기존 페이지 렌더링
|
- Token: `tests/test_dashboard_persistent_authentication.py`
|
||||||
- 실패 계약: 누락·빈 값·read error와 잘못된 ID·password의 일반 오류·무변경 검증
|
- HTTP endpoint/cookie: `tests/test_dashboard_auth_server.py`
|
||||||
- 경계 계약: query parameter 우회 금지, 새 session 재인증, 로그아웃 직후 차단, credential session 제거
|
- Gate/DB ordering: `tests/test_dashboard_authentication_app.py`
|
||||||
- 구현·스키마·예시 정합성: `dashboard/authentication.py`, `dashboard/app.py`, `.env.example`, `docker-compose.yml`과 인증 test를 함께 검토하며 DB migration이 없음을 확인
|
- HTTPS attributes/reconnect/logout: `scripts/persistent_auth_http_evidence.py`
|
||||||
|
- 실제 secret/token 값은 test output과 evidence에 남기지 않는다.
|
||||||
|
|||||||
+66
-81
@@ -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와 인증 설계 문서
|
## Source 영역
|
||||||
- 제외 경계: MCP adapter·server 인증, 가계부 application·SQLite schema 변경
|
|
||||||
|
|
||||||
## 디렉터리 구조
|
| 경로 | 책임 | 주요 공개 요소 | 금지 의존성 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `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
|
## Test 영역
|
||||||
dashboard/
|
|
||||||
├── app.py
|
|
||||||
├── authentication.py
|
|
||||||
├── common.py
|
|
||||||
└── page_views/
|
|
||||||
```
|
|
||||||
|
|
||||||
| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 |
|
| 경로 | 검증 책임 |
|
||||||
|---|---|---|---|---|---|---|
|
|---|---|
|
||||||
| `dashboard/app.py` | Streamlit 진입점 | page config 후 인증 gate를 호출하고 인증된 요청만 기존 Dashboard로 dispatch | `main()` | 인증 정책 세부 구현을 포함하지 않음 | DB 경로 취득 전 gate 유지 | 수동 |
|
| `tests/test_dashboard_authentication.py` | 기존 credential 비교·지연·safe log |
|
||||||
| `dashboard/authentication.py` | 인증 모듈 | 환경변수 설정, constant-time 검증, 실패 지연, session·로그아웃과 event log | `require_dashboard_authentication()` | 가계부 DB·gateway·page view에 의존하지 않음 | credential 비보존과 fail-closed 유지 | 수동 |
|
| `tests/test_dashboard_persistent_authentication.py` | token claims, TTL, tamper, rotation, malformed input |
|
||||||
| `dashboard/common.py` | Dashboard 공통 지원 | DB 경로, UI와 gateway helper | 기존 함수 | 인증 상태를 결정하지 않음 | 인증된 dispatch 이후 사용 | 수동 |
|
| `tests/test_dashboard_auth_server.py` | login/logout response, cookie 속성, same-origin, fail-closed |
|
||||||
| `dashboard/page_views/` | 화면 모듈 | 기존 금융 조회·쓰기·import UI | 각 `page_*` 함수 | 인증 구현에 역방향 의존하지 않음 | `app.py`의 인증된 dispatch로만 공개 | 수동 |
|
| `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
|
## Config·배포·문서 영역
|
||||||
tests/
|
|
||||||
├── test_dashboard_authentication.py
|
|
||||||
├── test_dashboard_authentication_app.py
|
|
||||||
└── test_dashboard_app.py
|
|
||||||
```
|
|
||||||
|
|
||||||
| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 |
|
| 경로 | 책임 |
|
||||||
|---|---|---|---|---|---|---|
|
|---|---|
|
||||||
| `tests/test_dashboard_authentication.py` | 단위 test | 설정·constant-time 비교·지연·비민감 log 검증 | pytest | 실제 credential과 운영 DB 금지 | test-only 값 사용 | 수동 |
|
| `pyproject.toml`, `uv.lock` | `itsdangerous`, `starlette`, `uvicorn` 직접 dependency 고정 |
|
||||||
| `tests/test_dashboard_authentication_app.py` | Streamlit 통합 test | fail-closed, 우회 금지, 1초 지연, session과 logout 검증 | pytest AppTest | synthetic fixture만 사용 | acceptance criterion 대응 유지 | 수동 |
|
| `.env.example` | 세 environment 변수의 이름만 제공 |
|
||||||
| `tests/test_dashboard_app.py` | 기존 기능 회귀 test | 인증 후 핵심 page 호환성 검증 | pytest AppTest | synthetic fixture만 사용 | 로그인 후 기존 assertion 유지 | 수동 |
|
| `docker-compose.yml` | signing secret 전달과 ASGI server command |
|
||||||
|
| `deploy/k8s/base/deployment.yaml` | Kubernetes Secret `signing-secret` 전달과 ASGI command |
|
||||||
### deployment-docs — 배포 설정과 계약 문서
|
| `README.md`, `deploy/k8s/README.md` | HTTPS, rotation, expiry, rate-limit, logout 제약 |
|
||||||
|
| `docs/requirements/req-001_dashboard-administrator-authentication.md` | Issue #1·#6 통합 canonical 승인 명세 |
|
||||||
```text
|
| `docs/interface-contract.md` | HTTP/cookie/token 계약 |
|
||||||
.env.example
|
| `docs/runtime-flow.md` | 발급·복원·실패·logout 순서 |
|
||||||
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 |
|
|
||||||
|
|
||||||
## 의존 방향
|
## 의존 방향
|
||||||
|
|
||||||
| 출발 영역 | 대상 영역 | 허용 여부 | 규칙 | 공유 계약 위치 |
|
```text
|
||||||
|---|---|---|---|---|
|
browser
|
||||||
| `dashboard-app` | `dashboard-authentication` | 허용 | DB 경로와 page dispatch보다 먼저 gate 호출 | `docs/runtime-flow.md` |
|
├─ POST /api/auth/login|logout → dashboard.auth_server
|
||||||
| `dashboard-authentication` | `dashboard-common` | 금지 | 인증은 가계부 DB·gateway와 독립 | `docs/interface-contract.md` |
|
└─ Streamlit handshake cookie → dashboard.authentication
|
||||||
| `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.server → dashboard.auth_server + Streamlit App(dashboard/app.py)
|
||||||
| `dashboard-app` | `dashboard-page-views` | 인증 후 허용 | gate가 참일 때만 선택 page dispatch | `docs/runtime-flow.md` |
|
dashboard.app → dashboard.authentication → itsdangerous
|
||||||
| `dashboard-page-views` | `dashboard-authentication` | 금지 | page별 인증 분기나 역방향 의존을 만들지 않음 | `docs/requirements/req-001_dashboard-administrator-authentication.md` |
|
→ (인증 성공 후) dashboard.common/page_views
|
||||||
| `dashboard-authentication` | `mcp-adapter` | 금지 | 이번 인증은 MCP를 보호하지 않음 | `README.md` |
|
```
|
||||||
|
|
||||||
|
`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`는 추가하지 않는다.
|
||||||
|
|
||||||
## 변경 규칙
|
## 변경 규칙
|
||||||
|
|
||||||
| 구분 | 규칙 |
|
- 인증 환경변수는 `JARCA_DASHBOARD_` prefix를 사용한다.
|
||||||
|---|---|
|
- Cookie name은 host-only scope를 강제하는 `__Host-` prefix를 사용한다.
|
||||||
| 새 모듈·파일 배치 | Dashboard 공통 인증은 `dashboard/authentication.py`, page 기능은 기존 `dashboard/page_views/`에 둔다. |
|
- Token schema/TTL/cookie attributes 변경은 REQ-001과 interface contract를 함께 변경한다.
|
||||||
| 이름·패키지 | 환경변수는 `JARCA_DASHBOARD_` prefix, 비공개 session key는 `_jarca_dashboard_` prefix를 사용한다. |
|
- Secret, token, cookie header, credential 값을 source·fixture·log·문서에 기록하지 않는다.
|
||||||
| 소스·테스트 대응 | 인증 pure helper는 `test_dashboard_authentication.py`, Streamlit 경계는 `test_dashboard_authentication_app.py`, 기존 page 호환성은 `test_dashboard_app.py`에서 검증한다. |
|
- Streamlit private Tornado/WebSocket internals나 client-side cookie component를 사용하지 않는다.
|
||||||
| 생성 산출물 | DB schema·migration·credential file을 생성하지 않는다. Requirement와 설계 문서만 `docs/`에 추가한다. |
|
|
||||||
|
|
||||||
## 검증
|
## 검증
|
||||||
|
|
||||||
- 구조 검사 명령:
|
```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
|
Diff에서 DB migration과 `docs/data-model.md`가 추가되지 않았는지 확인한다.
|
||||||
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이 생성되지 않았는지 확인한다.
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schema": "gitea-agent.requirement/v1",
|
"schema": "gitea-agent.requirement/v1",
|
||||||
"id": "REQ-001",
|
"id": "REQ-001",
|
||||||
"title": "Dashboard 관리자 인증",
|
"title": "Dashboard 관리자 인증",
|
||||||
"revision": "1.0",
|
"revision": "2.0",
|
||||||
"status": "approved",
|
"status": "approved",
|
||||||
"depends_on": [],
|
"depends_on": [],
|
||||||
"applies_to": ["dashboard"],
|
"applies_to": ["dashboard"],
|
||||||
@@ -15,479 +15,145 @@
|
|||||||
|
|
||||||
## 1. 목적
|
## 1. 목적
|
||||||
|
|
||||||
Streamlit Dashboard의 모든 금융 데이터 조회·수정·import 기능 앞에 단일 관리자 인증 경계를 두고, 인증되지 않았거나 인증 설정이 유효하지 않은 요청을 fail-closed한다.
|
Streamlit Dashboard의 모든 금융 데이터 조회·수정·import 기능 앞에 단일 관리자 인증 경계를 두고, 인증되지 않았거나 인증 설정이 유효하지 않은 요청을 fail-closed한다. 인증 성공 후에는 server-signed `Secure`/`HttpOnly` cookie로 같은 browser profile의 새 연결과 새 session에서 인증을 정해진 절대 만료까지 복원한다.
|
||||||
|
|
||||||
## 2. 배경
|
## 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. 범위
|
## 3. 범위
|
||||||
|
|
||||||
### 포함
|
### 포함
|
||||||
|
|
||||||
- 모든 Dashboard 페이지의 공통 관리자 로그인과 로그아웃
|
- 모든 Dashboard 페이지 앞의 공통 관리자 인증과 인증 전 DB 차단
|
||||||
- 환경변수 기반 단일 관리자 credential 검증
|
- 환경변수 기반 관리자 ID/password 및 독립적인 signing secret
|
||||||
- 인증 전 DB·gateway·page dispatch 차단
|
- constant-time credential 비교, 일반 오류, 실패당 최소 1초 지연
|
||||||
- browser session 범위의 인증 상태
|
- 동일 Dashboard ASGI process의 server-side login/logout HTTP endpoint
|
||||||
- 실패 로그인 최소 지연, 일반 오류와 인증 event 관찰 가능성
|
- server-signed `Secure`/`HttpOnly` cookie 발급·검증·삭제
|
||||||
- Compose·환경 예시·운영 문서의 secret 주입 계약
|
- 기본 24시간, 선택 시 30일인 로그인 성공 시각 기준 절대 만료
|
||||||
|
- sidebar 페이지 이동, 새 탭, browser 종료 후 재접속과 새 Streamlit session 복원
|
||||||
|
- password/signing-secret/admin-ID rotation에 따른 기존 token 무효화
|
||||||
|
- credential·secret·token 비노출과 인증 event 관찰 가능성
|
||||||
|
- Compose, Kubernetes Secret, HTTPS와 운영 제약 문서화
|
||||||
|
|
||||||
### 제외
|
### 제외
|
||||||
|
|
||||||
- 다중 사용자, 역할 기반 권한과 사용자별 데이터 분리
|
- 다중 사용자, 회원가입, RBAC, 사용자별 데이터 분리
|
||||||
- 회원가입, 비밀번호 찾기와 credential 관리 UI
|
- OAuth/OIDC, SSO, 외부 IdP, MFA, CAPTCHA, 계정 잠금
|
||||||
- OIDC, SSO, 외부 IdP와 persistent cookie
|
- 외부 인증 proxy/gateway 또는 외부 session middleware로 인증 책임 이전
|
||||||
- SQLite schema와 가계부 도메인 모델 변경
|
- sliding expiration 또는 복원 시 만료 재설정
|
||||||
- MCP endpoint 인증, TLS, Ingress와 네트워크 경계 전체 구현
|
- DB-backed token/session registry, 개별 원격 session 폐기 UI
|
||||||
|
- MCP endpoint 인증과 MCP transport 변경
|
||||||
|
- SQLite 금융 schema와 데이터 모델 변경
|
||||||
|
- 다른 browser profile·기기 간 로그인 공유
|
||||||
|
|
||||||
## 4. 용어와 기준
|
## 4. 용어와 기준
|
||||||
|
|
||||||
- Dashboard: `dashboard/app.py`를 진입점으로 실행되는 Streamlit 애플리케이션
|
- **persistent auth token**: version, 관리자 subject, 발급 시각, 절대 만료, token ID만 포함하고 서버가 서명한 bearer token
|
||||||
- 인증 전: 현재 Streamlit browser session의 인증 상태가 참이 아닌 상태
|
- **remember me**: 기본 미선택 checkbox이며 token TTL을 24시간에서 30일로 선택
|
||||||
- credential source: 배포 secret이 주입하는 `JARCA_DASHBOARD_ADMIN_ID`와 `JARCA_DASHBOARD_ADMIN_PASSWORD` 환경변수
|
- **rotation**: 관리자 password 또는 signing secret을 교체하여 기존 token 서명 검증을 실패시키는 운영 절차
|
||||||
- browser session: Streamlit이 `st.session_state`를 격리하는 단일 클라이언트 session
|
- **canonical 근거**: Issue #1, Issue #6, source revisions `069d4c16fd84e871e8cd9dc8d9ae39b4fce4a6c6` 및 `7bea8b368e02e98afd4b2dc463fb270b0e6eb297`
|
||||||
- 정본 근거: Gitea Issue `#1`과 source revision `069d4c16fd84e871e8cd9dc8d9ae39b4fce4a6c6`
|
|
||||||
|
|
||||||
## 5. 요구사항
|
## 5. 요구사항
|
||||||
|
|
||||||
### REQ-001.01 — 공통 인증 경계
|
### 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
|
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을 사용하지 않는다.
|
||||||
- 영속성: 인증 전에는 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의 환경변수 주입
|
|
||||||
|
|
||||||
### REQ-001.03 — Credential 검증과 비노출
|
### REQ-001.03 — Credential 검증과 비노출
|
||||||
|
|
||||||
제출된 관리자 ID와 password는 모두 비교하되 password를 포함한 검증은 constant-time primitive를 사용해야 한다. Credential 원문·verifier는 화면, 일반 오류, traceback, URL/query parameter, application log 또는 로그인 완료 후 `st.session_state`에 남지 않아야 한다.
|
제출 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에 둘 수 있다.
|
||||||
|
|
||||||
**영향 범위**
|
|
||||||
|
|
||||||
- 인터페이스: 관리자 ID·password 입력 form
|
|
||||||
- 영속성: 없음
|
|
||||||
- 후속 사용 주체: 현재 Dashboard session
|
|
||||||
- 마이그레이션: 없음
|
|
||||||
- 호환성: 없음
|
|
||||||
- 배포 전제 조건: 평문 password secret은 repository가 아닌 secret store에서 제공
|
|
||||||
|
|
||||||
### REQ-001.04 — 실패 로그인 응답
|
### REQ-001.04 — 실패 로그인 응답
|
||||||
|
|
||||||
잘못된 ID 또는 password의 각 제출은 제출 처리 시작부터 일반 오류 응답 완료까지 최소 1초가 걸려야 한다. 두 실패는 동일한 오류를 표시하고 DB·gateway·상태 변경을 수행하지 않아야 한다. 성공 로그인에는 실패 지연을 적용하지 않으며 애플리케이션 수준 잠금은 두지 않는다.
|
잘못된 ID 또는 password의 각 제출은 처리 시작부터 응답까지 최소 1초가 걸려야 한다. 두 실패는 동일한 오류를 표시하고 cookie·DB·gateway·금융 상태를 변경하지 않는다. 성공에는 실패 지연을 적용하지 않으며 application lock은 두지 않는다.
|
||||||
|
|
||||||
**영향 범위**
|
### REQ-001.05 — Persistent session과 절대 만료
|
||||||
|
|
||||||
- 인터페이스: 로그인 제출과 오류 응답
|
정상 로그인도 24시간, 기본 미선택 `로그인 유지`를 선택한 로그인은 30일의 절대 만료를 사용한다. 발급 시 `iat`와 `exp`를 고정하고 page 이동, rerun, 활동, 새 tab, browser 재시작 또는 인증 복원으로 token이나 만료를 갱신하지 않는다. 같은 browser profile의 유효 cookie는 새 Streamlit session에서 인증을 복원한다.
|
||||||
- 영속성: 없음
|
|
||||||
- 후속 사용 주체: 미인증 Dashboard 사용자
|
|
||||||
- 마이그레이션: 없음
|
|
||||||
- 호환성: 없음
|
|
||||||
- 배포 전제 조건: 새 session·병렬 요청 우회를 제한할 별도 배포 경계 rate limiting 권고
|
|
||||||
|
|
||||||
### 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
|
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을 거부해야 한다.
|
||||||
- 영속성: server-side 영속 session 없음
|
|
||||||
- 후속 사용 주체: 현재 browser session
|
|
||||||
- 마이그레이션: 없음
|
|
||||||
- 호환성: browser 재시작·새 session의 자동 로그인 없음
|
|
||||||
- 배포 전제 조건: 없음
|
|
||||||
|
|
||||||
### 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
|
인증 후 기존 page 목록, `page` query parameter, sidebar 이동, gateway와 MCP 동작은 유지한다. 인증은 Dashboard만 보호하고 MCP endpoint 인증을 의미하지 않는다. 금융 SQLite schema/migration은 변경하지 않는다.
|
||||||
- 영속성: 기존 SQLite schema와 데이터 계약 유지
|
|
||||||
- 후속 사용 주체: 인증된 Dashboard 사용자
|
|
||||||
- 마이그레이션: 없음
|
|
||||||
- 호환성: 인증 후 기존 Dashboard 동작 유지
|
|
||||||
- 배포 전제 조건: MCP는 별도의 네트워크 또는 후속 인증 요구사항으로 보호
|
|
||||||
|
|
||||||
### 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와 운영 문서
|
Event log는 event name과 비민감 correlation ID만 기록한다. 허용 event에는 설정 오류, 인증 필요, token 수락, login 성공/실패/reject, logout이 포함된다. ID/password/secret/token/cookie/request body와 구체적인 검증 실패 원인은 기록하지 않는다.
|
||||||
- 영속성: repository에 credential 값 저장 금지
|
|
||||||
- 후속 사용 주체: 배포 운영자
|
|
||||||
- 마이그레이션: 기존 Dashboard 배포에 secret 주입 필요
|
|
||||||
- 호환성: MCP container의 설정과 포트는 변경하지 않음
|
|
||||||
- 배포 전제 조건: secret manager 또는 동등한 안전한 환경변수 주입
|
|
||||||
|
|
||||||
### 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
|
| 조건 | 사용자 결과 | Cookie | DB/gateway/page |
|
||||||
- 영속성: correlation ID는 현재 session에만 저장
|
|---|---|---|---|
|
||||||
- 후속 사용 주체: 운영자
|
| 설정 invalid | 일반 설정 오류 | 발급 없음 | 차단 |
|
||||||
- 마이그레이션: 없음
|
| credential invalid | 최소 1초 후 일반 인증 오류 | 발급 없음 | 차단 |
|
||||||
- 호환성: 기존 금융 데이터 log 계약에 영향 없음
|
| request malformed/cross-site | 400/403 | 발급 없음 | 차단 |
|
||||||
- 배포 전제 조건: log 수집기가 민감정보 비수집 계약을 유지
|
| cookie 없음·malformed·tampered·expired | 로그인 화면 | 수락 없음 | 차단 |
|
||||||
|
| subject/TTL/time/rotation 불일치 | 로그인 화면 | 수락 없음 | 차단 |
|
||||||
|
| logout | root redirect | deletion | 금융 상태 무변경 |
|
||||||
|
|
||||||
## 6. 제약과 불변조건
|
## 8. 수용 기준
|
||||||
|
|
||||||
- 인증 전과 설정 오류·잘못된 credential 상태에서는 금융 DB와 gateway 상태가 변하지 않는다.
|
- [x] AC-001 인증 전 모든 DB/gateway/page 기능이 차단된다.
|
||||||
- Credential은 환경변수에서만 읽고 repository 문서·설정·test fixture에는 test-only 값 외의 실제 값을 기록하지 않는다.
|
- [x] AC-002 세 environment setting invalid 상태는 fail-closed한다.
|
||||||
- 인증 상태는 boolean 결과만 저장하며 credential 원문 또는 verifier를 session에 보존하지 않는다.
|
- [x] AC-003 ID/password를 constant-time 비교하고 일반 오류와 최소 1초 지연을 적용한다.
|
||||||
- 잘못된 ID와 password는 동일한 사용자 오류와 최소 지연을 사용한다.
|
- [x] AC-004 정상 login은 24시간 absolute token/cookie를 발급한다.
|
||||||
- Dashboard와 MCP의 인증 경계를 혼동하지 않는다.
|
- [x] AC-005 기본 미선택 remember-me login은 30일 absolute token/cookie를 발급한다.
|
||||||
- 실제 운영 DB 또는 실제 credential을 test에 사용하지 않는다.
|
- [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을 호출하지 않는다.
|
- 최초 요구 Issue: `jsfamily/jarca-ledger-mcp#1`
|
||||||
|
- 지속 로그인 변경 Issue: `jsfamily/jarca-ledger-mcp#6`
|
||||||
**검증**
|
- 최초 구현 PR: `#3`
|
||||||
|
- Issue #6 구현 PR: 미정
|
||||||
`tests/test_dashboard_authentication_app.py`에서 인증 gate가 거부될 때 DB 경로 함수가 호출되지 않고 Dashboard 본문이 렌더링되지 않는지 검증한다.
|
- Issue #6 기준 revision: `7bea8b368e02e98afd4b2dc463fb270b0e6eb297`
|
||||||
|
- Issue #6 승인 snapshot body SHA-256: `4f734169ec896747fe39fd7f3ab75d1fc0d8cdd4903045e665beba40d80eb1fe`
|
||||||
**검증 분류**
|
|
||||||
|
|
||||||
- 수준: 통합
|
|
||||||
- 백엔드: 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
|
|
||||||
- 관련 요구사항: 없음
|
|
||||||
|
|||||||
+64
-66
@@ -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
|
- 포함: ASGI startup, config, login/logout POST, signed cookie, Streamlit gate, DB 이전 차단
|
||||||
- 제외 범위: MCP 요청, 영속 session, application lock, 외부 rate limiter 구현
|
- 제외: MCP 요청, token registry, OAuth/OIDC, external auth middleware
|
||||||
- 시작 조건: Streamlit이 `dashboard/app.py`를 새로 실행하거나 rerun함
|
|
||||||
- 성공 종료 상태: 인증된 기존 Dashboard page가 렌더링되거나 로그아웃 후 로그인 화면이 렌더링됨
|
|
||||||
- 실패 종료 상태: 안전한 설정·credential 오류를 표시하고 DB·gateway를 호출하지 않은 미인증 상태
|
|
||||||
|
|
||||||
## 흐름
|
## 정상 흐름
|
||||||
|
|
||||||
### dashboard-entry — Session 시작과 인증 경계
|
### START — ASGI server 시작
|
||||||
|
|
||||||
- 시작 이벤트: 새 browser session 또는 Streamlit rerun
|
| 단계 | 주체 | 처리 | 다음 |
|
||||||
- 선행조건: Dashboard process가 시작돼 있음
|
|---|---|---|---|
|
||||||
- 실행 방식: 동기
|
| `START-01` | Uvicorn | `dashboard.server:app` 로드 | `START-02` |
|
||||||
- 트랜잭션·영속성 경계: 인증 상태는 현재 `st.session_state`에만 존재하고 SQLite transaction을 열지 않음
|
| `START-02` | Streamlit `App` | custom auth routes와 `dashboard/app.py` runtime 조립 | `UNAUTHENTICATED` |
|
||||||
|
|
||||||
| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|
운영 command는 `uvicorn dashboard.server:app --host 0.0.0.0 --port 8501`이다.
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| `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` |
|
|
||||||
|
|
||||||
### dashboard-login — Login 제출
|
### LOGIN — Credential 제출과 token 발급
|
||||||
|
|
||||||
- 시작 이벤트: 미인증 사용자의 로그인 form 제출
|
| 단계 | 주체 | 입력/처리 | 상태·외부 효과 | 다음 |
|
||||||
- 선행조건: 유효한 관리자 환경 설정과 로그인 화면
|
|---|---|---|---|---|
|
||||||
- 실행 방식: 동기
|
| `LOGIN-01` | Browser | Streamlit이 렌더링한 HTML form을 same-origin HTTPS POST | credential은 request body에만 존재 | `LOGIN-02` |
|
||||||
- 트랜잭션·영속성 경계: 금융 DB transaction 없음; 성공 시 session boolean만 기록
|
| `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 | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|
기본 `exp-iat`는 86400초, remember-me는 2592000초다. 이 값은 발급 시 고정된다.
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| `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` |
|
|
||||||
|
|
||||||
### dashboard-logout — Logout
|
### RESTORE — 새 session·재접속 인증 복원
|
||||||
|
|
||||||
- 시작 이벤트: 인증된 사용자의 sidebar 로그아웃 click
|
| 단계 | 주체 | 입력/처리 | 상태 | 다음 |
|
||||||
- 선행조건: 현재 session 인증 boolean true
|
|---|---|---|---|---|
|
||||||
- 실행 방식: 동기
|
| `RESTORE-01` | Browser | Streamlit initial request/WebSocket handshake에 host cookie 전송 | HttpOnly token | `GATE-01` |
|
||||||
- 트랜잭션·영속성 경계: 인증 boolean만 제거하며 금융 DB를 변경하지 않음
|
| `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 | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|
각 rerun은 cookie를 현재 설정으로 다시 검증한다. 일반 요청은 `iat`, `exp`, cookie `Max-Age`를 연장하지 않는다.
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| `LOGOUT-01` | 인증 모듈 | 로그아웃 click | 인증 boolean 제거와 logout event 기록 | session 인증 상태 삭제 | application log | `LOGOUT-02` | `UNAUTHENTICATED` |
|
### LOGOUT — Browser cookie 삭제
|
||||||
| `LOGOUT-02` | Streamlit | 인증 상태 없음 | rerun | 금융 상태 무변경 | 없음 | `ENTRY-01` | `UNAUTHENTICATED` |
|
|
||||||
|
| 단계 | 주체 | 처리 | 다음 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `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` |
|
| `CONFIG-FAIL` | ID/password/signing secret 누락·invalid·read error | DB/gateway/page 전체 | 올바른 secret 주입 후 rerun |
|
||||||
| `LOGIN-02` | 잘못된 ID 또는 password | 예; 금융 상태 무변경 | 각 제출 독립 재시도 | lock·failure counter 없음 | 없음 | `UNAUTHENTICATED` |
|
| `LOGIN-REJECT` | cross-site, malformed, oversized POST | cookie 발급 | same-origin 정상 form 재시도 |
|
||||||
| `LOGIN-FAIL-01` | sleep interruption | 금융 쓰기 없음 | 다음 제출 가능 | error flag boolean만 사용 | 없음 | `UNAUTHENTICATED` |
|
| `LOGIN-FAIL` | ID 또는 password 불일치 | cookie 발급·DB 접근 | 최소 1초 후 일반 오류, 재시도 |
|
||||||
| `DASHBOARD-01` | DB 경로·기존 page 오류 | 해당 page의 기존 실패 계약 | 기존 Dashboard 계약에 따름 | 기존 gateway 계약 | 기존 운영 복구 | `DASHBOARD-FAIL` |
|
| `TOKEN-FAIL` | absent/malformed/tampered/expired/wrong claims/rotation | DB/gateway/page 전체 | 재로그인 |
|
||||||
| `LOGOUT-01` | 중복 또는 이미 제거됨 | 금융 쓰기 없음 | 로그인 화면 유지 | key 제거는 멱등 | 없음 | `UNAUTHENTICATED` |
|
| `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이 수행된다.
|
- 서로 다른 browser session은 같은 signed token을 보낼 수 있다.
|
||||||
- 충돌 검출·처리: 새 session과 병렬 요청은 1초 지연을 우회할 수 있으므로 배포 경계에서 별도 rate limiting이 필요하다.
|
- 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이 필요하다.
|
||||||
|
|
||||||
## 관찰 가능성
|
## 관찰 가능성
|
||||||
|
|
||||||
| 신호 종류 | 이름 | 발생 단계·조건 | 상관 식별자 | 진단 속성 | 민감정보 처리 |
|
허용 event: `configuration_error`, `authentication_required`, `token_accepted`, `login_succeeded`, `login_failed`, `login_origin_rejected`, `login_request_rejected`, `logout`, `logout_origin_rejected`.
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| log | `dashboard_auth configuration_error` | `ENTRY-03` 설정 오류 | session correlation ID | event 이름 | 환경변수 값·오류 원인 원문 제외 |
|
각 log는 event와 비민감 correlation ID만 포함한다. Token, credential, origin 세부값, request body와 cookie header는 기록하지 않는다.
|
||||||
| 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 제외 |
|
|
||||||
|
|
||||||
## 검증
|
## 검증
|
||||||
|
|
||||||
- 정상 흐름: test-only credential로 로그인하고 synthetic fixture의 기존 Dashboard page를 렌더링한 뒤 같은 session rerun을 검증한다.
|
- Normal/remember TTL 및 non-sliding: token unit tests
|
||||||
- 실패 흐름: 설정 누락·빈 값·read error, 잘못된 ID·password, 일반 오류와 DB 미호출을 단위·AppTest로 검증한다.
|
- Tamper/expiry/rotation/schema rejection: token unit tests
|
||||||
- 경계 흐름: Import query parameter 우회 금지, credential session 제거, logout, 새 session 재인증과 MCP 범위 불변을 검증한다.
|
- HTTP response cookie와 logout deletion: auth-server tests
|
||||||
- 동시성 흐름: application lock·failure counter가 없음을 정적으로 확인하고 새 session 우회 및 배포 경계 rate limiting 요구를 README에서 검증한다.
|
- Fail-closed DB-before-gate ordering: AppTest
|
||||||
- 운영 진입점과 기대 결과: `streamlit run dashboard/app.py`; 유효 secret이면 로그인 form, 무효 설정이면 일반 설정 오류, 인증 성공 후에만 기존 Dashboard를 표시한다.
|
- 실제 loopback HTTPS login/reconnect/logout와 cookie 속성: HTTP evidence script
|
||||||
|
- MCP behavior와 DB schema가 변경되지 않았음을 full regression과 diff로 확인
|
||||||
|
|||||||
+7
-4
@@ -17,11 +17,14 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-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 = [
|
dashboard = [
|
||||||
"pandas>=2.0.0",
|
"streamlit>=1.58.0,<1.59.0",
|
||||||
"plotly>=5.0.0",
|
"plotly>=5.20.0",
|
||||||
"streamlit>=1.30.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]
|
[project.scripts]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -6,7 +6,7 @@ from streamlit.testing.v1 import AppTest
|
|||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
from dashboard.app import get_transfer_review_items
|
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
|
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"
|
fixture = tmp_path / "fixture.sqlite3"
|
||||||
create_ci_fixture_db(fixture)
|
create_ci_fixture_db(fixture)
|
||||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
|
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
|
||||||
monkeypatch.setenv(ADMIN_ID_ENV, "test-admin")
|
monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin")
|
||||||
monkeypatch.setenv(ADMIN_PASSWORD_ENV, "test-password")
|
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"
|
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
|
||||||
page_expectations = {
|
page_expectations = {
|
||||||
@@ -61,10 +66,6 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
|
|||||||
if page == "월정산":
|
if page == "월정산":
|
||||||
at.query_params["month"] = "2026-06"
|
at.query_params["month"] = "2026-06"
|
||||||
at.run()
|
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
|
assert not at.exception
|
||||||
body_text = "\n".join(str(item.value) for item in list(at.text) + list(at.markdown) + list(at.caption))
|
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
|
assert expected_text in body_text
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,9 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
import time
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from streamlit.testing.v1 import AppTest
|
from streamlit.testing.v1 import AppTest
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
@@ -14,31 +12,40 @@ from dashboard import authentication
|
|||||||
|
|
||||||
ADMIN_ID = "test-admin"
|
ADMIN_ID = "test-admin"
|
||||||
ADMIN_PASSWORD = "test-password"
|
ADMIN_PASSWORD = "test-password"
|
||||||
|
SIGNING_SECRET = "s" * 64
|
||||||
APP_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
|
APP_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
|
||||||
|
|
||||||
|
|
||||||
def _body_text(at: AppTest) -> str:
|
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)
|
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_ID_ENV, ADMIN_ID)
|
||||||
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, ADMIN_PASSWORD)
|
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
|
at.query_params["page"] = page
|
||||||
return at.run()
|
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):
|
def test_missing_authentication_configuration_fails_closed(monkeypatch, tmp_path):
|
||||||
from scripts.ci_fixture_db import create_ci_fixture_db
|
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)
|
create_ci_fixture_db(fixture)
|
||||||
monkeypatch.delenv(authentication.ADMIN_ID_ENV, raising=False)
|
monkeypatch.delenv(authentication.ADMIN_ID_ENV, raising=False)
|
||||||
monkeypatch.delenv(authentication.ADMIN_PASSWORD_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))
|
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 not at.exception
|
||||||
assert "관리자 로그인" in _body_text(at)
|
assert "관리자 로그인" in _body_text(at)
|
||||||
assert authentication.CONFIGURATION_ERROR_MESSAGE in _body_text(at)
|
assert authentication.CONFIGURATION_ERROR_MESSAGE in _body_text(at)
|
||||||
assert not at.text_input
|
|
||||||
assert "이번 달 수입/지출 현황" not in _body_text(at)
|
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
|
from dashboard import app
|
||||||
|
|
||||||
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: False)
|
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):
|
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 not at.exception
|
||||||
assert "관리자 로그인" in _body_text(at)
|
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)
|
assert "Import 이력" not in _body_text(at)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
def test_login_form_posts_credentials_to_server_side_adapter(monkeypatch):
|
||||||
("admin_id", "password"),
|
at = _app_with_cookie(monkeypatch, None)
|
||||||
[
|
body = _body_text(at)
|
||||||
("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)
|
|
||||||
|
|
||||||
started_at = time.monotonic()
|
assert 'action="/api/auth/login"' in body
|
||||||
at = _submit_login(at, admin_id, password)
|
assert 'method="post"' in body
|
||||||
elapsed = time.monotonic() - started_at
|
assert 'name="admin_id"' in body
|
||||||
|
assert 'name="admin_password"' in body
|
||||||
assert elapsed >= authentication.MINIMUM_FAILURE_DELAY_SECONDS
|
assert 'name="remember_me"' in body
|
||||||
assert authentication.INVALID_CREDENTIALS_MESSAGE in _body_text(at)
|
assert ADMIN_ID not in repr(at.session_state)
|
||||||
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 ADMIN_PASSWORD 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 SIGNING_SECRET not in repr(at.session_state)
|
||||||
assert password 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)
|
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
|
from scripts.ci_fixture_db import create_ci_fixture_db
|
||||||
|
|
||||||
fixture = tmp_path / "fixture.sqlite3"
|
fixture = tmp_path / "fixture.sqlite3"
|
||||||
create_ci_fixture_db(fixture)
|
create_ci_fixture_db(fixture)
|
||||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(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 not at.exception
|
||||||
assert "이번 달 수입/지출 현황" in _body_text(at)
|
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 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)
|
assert "이번 달 수입/지출 현황" in _body_text(at)
|
||||||
|
|
||||||
logout_button = next(button for button in at.button if button.label == "로그아웃")
|
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "rotated-password")
|
||||||
logout_button.click()
|
|
||||||
at = at.run()
|
at = at.run()
|
||||||
|
|
||||||
assert "관리자 로그인" in _body_text(at)
|
assert "관리자 로그인" in _body_text(at)
|
||||||
assert "이번 달 수입/지출 현황" not 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()
|
||||||
@@ -481,27 +481,35 @@ dependencies = [
|
|||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
dashboard = [
|
dashboard = [
|
||||||
|
{ name = "itsdangerous" },
|
||||||
{ name = "pandas" },
|
{ name = "pandas" },
|
||||||
{ name = "plotly" },
|
{ name = "plotly" },
|
||||||
|
{ name = "starlette" },
|
||||||
{ name = "streamlit" },
|
{ name = "streamlit" },
|
||||||
|
{ name = "uvicorn" },
|
||||||
]
|
]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "itsdangerous", marker = "extra == 'dashboard'", specifier = ">=2.2.0,<3.0" },
|
||||||
{ name = "mcp", specifier = ">=1.0.0" },
|
{ name = "mcp", specifier = ">=1.0.0" },
|
||||||
{ name = "msoffcrypto-tool", specifier = ">=5.4.0" },
|
{ name = "msoffcrypto-tool", specifier = ">=5.4.0" },
|
||||||
{ name = "openpyxl", specifier = ">=3.1.0" },
|
{ name = "openpyxl", specifier = ">=3.1.0" },
|
||||||
{ name = "pandas", marker = "extra == 'dashboard'", specifier = ">=2.0.0" },
|
{ name = "pandas", marker = "extra == 'dashboard'", specifier = ">=2.2.0" },
|
||||||
{ name = "plotly", marker = "extra == 'dashboard'", specifier = ">=5.0.0" },
|
{ name = "plotly", marker = "extra == 'dashboard'", specifier = ">=5.20.0" },
|
||||||
{ name = "pymupdf", specifier = ">=1.24.0" },
|
{ name = "pymupdf", specifier = ">=1.24.0" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.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"]
|
provides-extras = ["dev", "dashboard"]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user