feat(dashboard): persist administrator login #7

Merged
genichin merged 11 commits from gitea-agent/issue-6-dashboard-persistent-auth into main 2026-10-04 07:13:14 +00:00
11 changed files with 885 additions and 347 deletions
Showing only changes of commit 7bb959859a - Show all commits
+2 -2
View File
@@ -47,9 +47,9 @@ cd /root/projects/jarca-ledger-mcp
uvicorn dashboard.server:app --host 0.0.0.0 --port 8501 uvicorn dashboard.server:app --host 0.0.0.0 --port 8501
``` ```
인증 성공 시 Dashboard의 동일 ASGI 프로세스에 포함된 server-side 인증 endpoint가 `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/` 속성의 서명 cookie를 발급합니다. 기본 절대 만료는 24시간이며 `로그인 상태 유지 (30일)`을 선택하면 30일입니다. 일반 요청이나 재접속은 만료를 연장하지 않습니다. 관리자 password 또는 서명 secret을 교체하면 기존 cookie는 즉시 검증에 실패합니다. `로그아웃`은 browser cookie를 삭제하지만, 이미 탈취·복사된 stateless token을 서버에서 개별 폐기하지는 못하므로 사고 대응 시 password 또는 서명 secret을 교체해야 합니다. 인증 성공 시 Dashboard의 동일 ASGI 프로세스에 포함된 server-side 인증 endpoint가 `__Host-jarca_dashboard_auth` cookie를 발급합니다. Cookie는 `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`, no `Domain`이며 token의 `sub`는 관리자 ID가 아닌 비민감 고정 role `dashboard-admin`입니다. 기본 절대 만료는 24시간이며 `로그인 상태 유지 (30일)`을 선택하면 30일입니다. 일반 요청이나 재접속은 만료를 연장하지 않습니다. 관리자 ID, password 또는 서명 secret을 교체하면 현재 설정에 묶인 서명 검증이 실패합니다. malformed·expired·tampered·rotation-invalid 또는 configuration-failure cookie는 보호 자원 전에 거부되고 same-origin `/api/auth/cleanup`에서 matching deletion 후 미인증 상태로 전이합니다. `로그아웃`도 browser cookie를 삭제하지만 stateless token을 서버에서 개별 폐기하지 않으므로 사고 대응 시 credential 또는 서명 secret을 교체해야 합니다.
`Secure` cookie를 사용하므로 browser가 접근하는 공개 origin은 HTTPS여야 하며 login/logout endpoint는 ASGI request scheme도 HTTPS가 아니면 거부합니다. TLS를 Ingress 또는 reverse proxy에서 종료할 때는 `JARCA_DASHBOARD_FORWARDED_ALLOW_IPS`를 실제 proxy source IP/CIDR allowlist로 설정해야 합니다. 기본값 `127.0.0.1`은 cluster ingress를 신뢰하지 않는 fail-closed 값입니다. `*` 또는 불특정 network를 신뢰하지 말고 proxy가 `Host`, `Origin`, `X-Forwarded-Proto`를 정확히 보존·설정하게 하세요. 인증 cookie의 발급·검증은 Dashboard 프로세스가 직접 수행합니다. 잘못된 ID 또는 password 응답은 매 제출마다 최소 1초 지연되지만 애플리케이션 잠금은 없습니다. 이 지연은 병렬 요청으로 우회할 수 있으므로 외부 노출 경계에 별도 rate limiting을 적용해야 합니다. `Secure` cookie를 사용하므로 browser가 접근하는 공개 origin은 HTTPS여야 하며 login/logout endpoint는 ASGI request scheme도 HTTPS가 아니면 거부합니다. Login과 logout은 same-origin HTTPS POST만 허용하고 cleanup GET은 same-origin HTTPS `Referer`를 검증합니다. TLS를 Ingress 또는 reverse proxy에서 종료할 때는 `JARCA_DASHBOARD_FORWARDED_ALLOW_IPS`를 실제 proxy source IP/CIDR allowlist로 설정해야 합니다. 기본값 `127.0.0.1`은 cluster ingress를 신뢰하지 않는 fail-closed 값입니다. `*` 또는 불특정 network를 신뢰하지 말고 proxy가 `Host`, `Origin`, `Referer`, `X-Forwarded-Proto`를 정확히 보존·설정하게 하세요. 인증 cookie의 발급·검증은 Dashboard 프로세스가 직접 수행합니다. 잘못된 ID 또는 password 응답은 매 제출마다 최소 1초 지연되지만 애플리케이션 잠금은 없습니다. 이 지연은 병렬 요청으로 우회할 수 있으므로 외부 노출 경계에 별도 rate limiting을 적용해야 합니다.
이 로그인은 **Streamlit Dashboard만 보호하며 MCP endpoint를 인증하지 않습니다.** MCP는 별도 요구사항과 적절한 네트워크 경계로 보호해야 합니다. 이 로그인은 **Streamlit Dashboard만 보호하며 MCP endpoint를 인증하지 않습니다.** MCP는 별도 요구사항과 적절한 네트워크 경계로 보호해야 합니다.
+41 -1
View File
@@ -48,6 +48,30 @@ def _same_origin(request: Request) -> bool:
return False return False
def _same_origin_navigation(request: Request) -> bool:
referer = request.headers.get("referer")
host = request.headers.get("host")
if not referer or not host:
return False
try:
parsed = urlsplit(referer)
authority = urlsplit(f"//{host}")
referer_port = parsed.port or 443
request_port = authority.port or 443
return (
request.url.scheme == "https"
and parsed.scheme == "https"
and parsed.hostname is not None
and authority.hostname is not None
and parsed.hostname.lower() == authority.hostname.lower()
and referer_port == request_port
and parsed.username is None
and parsed.password is None
)
except ValueError:
return False
def _one(values: dict[str, list[str]], name: str) -> str | None: def _one(values: dict[str, list[str]], name: str) -> str | None:
candidates = values.get(name) candidates = values.get(name)
if candidates is None or len(candidates) != 1: if candidates is None or len(candidates) != 1:
@@ -126,6 +150,12 @@ async def logout(request: Request) -> Response:
authentication.log_auth_event("logout_origin_rejected", correlation_id) authentication.log_auth_event("logout_origin_rejected", correlation_id)
return PlainTextResponse("Forbidden", status_code=403) return PlainTextResponse("Forbidden", status_code=403)
response = _redirect("/") response = _redirect("/")
_delete_auth_cookie(response)
authentication.log_auth_event("logout", correlation_id)
return response
def _delete_auth_cookie(response: Response) -> None:
response.delete_cookie( response.delete_cookie(
authentication.AUTH_COOKIE_NAME, authentication.AUTH_COOKIE_NAME,
path="/", path="/",
@@ -133,7 +163,16 @@ async def logout(request: Request) -> Response:
httponly=True, httponly=True,
samesite="lax", samesite="lax",
) )
authentication.log_auth_event("logout", correlation_id)
async def cleanup(request: Request) -> Response:
correlation_id = secrets.token_hex(8)
if not _same_origin_navigation(request):
authentication.log_auth_event("cleanup_origin_rejected", correlation_id)
return PlainTextResponse("Forbidden", status_code=403)
response = _redirect("/")
_delete_auth_cookie(response)
authentication.log_auth_event("cleanup", correlation_id)
return response return response
@@ -141,4 +180,5 @@ def create_auth_routes() -> list[Route]:
return [ return [
Route("/api/auth/login", login, methods=["POST"]), Route("/api/auth/login", login, methods=["POST"]),
Route("/api/auth/logout", logout, methods=["POST"]), Route("/api/auth/logout", logout, methods=["POST"]),
Route("/api/auth/cleanup", cleanup, methods=["GET"]),
] ]
+14 -11
View File
@@ -31,6 +31,7 @@ _TOKEN_VERSION = 1
_TOKEN_SALT = "jarca-dashboard-persistent-auth-v1" _TOKEN_SALT = "jarca-dashboard-persistent-auth-v1"
_TOKEN_CONTEXT = b"jarca-dashboard-persistent-auth-v1\x00" _TOKEN_CONTEXT = b"jarca-dashboard-persistent-auth-v1\x00"
_TOKEN_FIELDS = frozenset({"v", "sub", "iat", "exp", "jti"}) _TOKEN_FIELDS = frozenset({"v", "sub", "iat", "exp", "jti"})
_TOKEN_SUBJECT = "dashboard-admin"
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -149,7 +150,7 @@ def log_auth_event(event: str, correlation_id: str) -> None:
def _derived_signing_key(settings: AuthSettings) -> bytes: def _derived_signing_key(settings: AuthSettings) -> bytes:
return hmac.new( return hmac.new(
settings.signing_secret.encode("utf-8"), settings.signing_secret.encode("utf-8"),
_TOKEN_CONTEXT + settings.admin_password.encode("utf-8"), _TOKEN_CONTEXT + settings.admin_id.encode("utf-8") + b"\x00" + settings.admin_password.encode("utf-8"),
hashlib.sha256, hashlib.sha256,
).digest() ).digest()
@@ -177,7 +178,7 @@ def issue_persistent_token(
jti = secrets.token_urlsafe(16) if token_id is None else token_id jti = secrets.token_urlsafe(16) if token_id is None else token_id
payload = { payload = {
"v": _TOKEN_VERSION, "v": _TOKEN_VERSION,
"sub": settings.admin_id, "sub": _TOKEN_SUBJECT,
"iat": issued_at, "iat": issued_at,
"exp": expires_at, "exp": expires_at,
"jti": jti, "jti": jti,
@@ -215,8 +216,7 @@ def verify_persistent_token(
if ( if (
type(version) is not int type(version) is not int
or version != _TOKEN_VERSION or version != _TOKEN_VERSION
or not isinstance(subject, str) or subject != _TOKEN_SUBJECT
or not subject
or type(issued_at) is not int or type(issued_at) is not int
or type(expires_at) is not int or type(expires_at) is not int
or not isinstance(token_id, str) or not isinstance(token_id, str)
@@ -224,9 +224,6 @@ def verify_persistent_token(
or len(token_id) > 256 or len(token_id) > 256
): ):
return None return None
if not hmac.compare_digest(subject, settings.admin_id):
return None
current_time = int(time.time() if now is None else now) current_time = int(time.time() if now is None else now)
ttl = expires_at - issued_at ttl = expires_at - issued_at
if ttl not in {DEFAULT_TOKEN_TTL_SECONDS, REMEMBER_ME_TOKEN_TTL_SECONDS}: if ttl not in {DEFAULT_TOKEN_TTL_SECONDS, REMEMBER_ME_TOKEN_TTL_SECONDS}:
@@ -258,7 +255,7 @@ def _query_error() -> str | None:
return value if isinstance(value, str) else None return value if isinstance(value, str) else None
def _render_login() -> None: def _render_login(*, clear_cookie: bool = False) -> None:
st.title("관리자 로그인") st.title("관리자 로그인")
st.caption("Dashboard를 사용하려면 관리자 자격 증명이 필요합니다.") st.caption("Dashboard를 사용하려면 관리자 자격 증명이 필요합니다.")
if _query_error() == "invalid": if _query_error() == "invalid":
@@ -278,6 +275,11 @@ def _render_login() -> None:
""", """,
unsafe_allow_html=True, unsafe_allow_html=True,
) )
if clear_cookie:
st.markdown(
"<meta http-equiv=\"refresh\" content=\"0;url=/api/auth/cleanup\">",
unsafe_allow_html=True,
)
def _render_logout() -> None: def _render_logout() -> None:
@@ -295,17 +297,18 @@ def _render_logout() -> None:
def require_dashboard_authentication() -> bool: def require_dashboard_authentication() -> bool:
"""Validate the server-signed cookie before allowing any dashboard access.""" """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)
cookie = _request_cookie()
settings = load_auth_settings() settings = load_auth_settings()
if settings is None: if settings is None:
log_auth_event("configuration_error", correlation_id) log_auth_event("configuration_error", correlation_id)
_render_login() _render_login(clear_cookie=cookie is not None)
st.error(CONFIGURATION_ERROR_MESSAGE) st.error(CONFIGURATION_ERROR_MESSAGE)
return False return False
verified = verify_persistent_token(settings, _request_cookie()) verified = verify_persistent_token(settings, cookie)
if verified is None: if verified is None:
log_auth_event("authentication_required", correlation_id) log_auth_event("authentication_required", correlation_id)
_render_login() _render_login(clear_cookie=cookie is not None)
return False return False
log_auth_event("token_accepted", correlation_id) log_auth_event("token_accepted", correlation_id)
+26 -44
View File
@@ -1,57 +1,26 @@
# k3s 배포 # k3s 배포
`base/`는 `jarca-ledger` namespace에 다음 리소스를 배포하는 Kustomize base다. `base/`는 MCP와 Dashboard를 같은 SQLite PVC에 두는 단일 Deployment 구조입니다. Dashboard 인증 secret은 base manifest에 실제 값으로 저장하지 않습니다.
- 단일 `Deployment`와 `Recreate` 전략
- 동일 ARM64 이미지의 MCP·Dashboard 컨테이너
- SQLite 초기화를 담당하는 init container
- 공유 `ReadWriteOnce` PVC
- Dashboard용 내부 `ClusterIP` Service
- Dashboard port `8501` ingress만 허용하는 `NetworkPolicy`
- UID/GID `1000:1000`, `fsGroup: 1000`, read-only root filesystem
## 사전 준비 ## 사전 준비
Base는 실제 Secret 값을 포함하지 않는다. 먼저 namespace를 생성한다. namespace와 승인된 secret manager 절차를 준비합니다.
```bash ```bash
kubectl apply -f deploy/k8s/base/namespace.yaml kubectl apply -f deploy/k8s/base/namespace.yaml
``` ```
그 다음 승인된 secret manager나 Git에 기록되지 않는 운영 절차로 다음 Secret을 `jarca-ledger` namespace에 생성한다. `jarca-ledger` namespace의 Dashboard secret에는 다음 key만 둡니다.
- `jarca-ledger-registry` - `admin-id`
- type: `kubernetes.io/dockerconfigjson` - `admin-password`
- `gitea.jsfamily.quest`에서 image를 pull할 수 있는 credential - `signing-secret` (UTF-8 32 byte 이상의 독립 random value)
- `jarca-ledger-dashboard-auth`
- key `admin-id`
- key `admin-password`
- key `signing-secret` (32 byte 이상의 독립적인 무작위 값)
Registry credential을 application container에 환경변수나 volume으로 전달하지 않는다. `imagePullSecrets`로만 사용한다. Registry credential은 `imagePullSecrets`로만 사용합니다. 실제 secret 값, token, cookie를 Git, manifest, command history 또는 문서에 기록하지 마세요.
## Immutable image 지정
`base/deployment.yaml`의 release version tag는 템플릿 기본값일 뿐이며 운영 배포에 안전한 immutable 참조가 아니다. Release commit 자체에는 최종 image digest를 넣을 수 없다. Image의 revision label이 release commit을 가리키므로, 그 commit에 digest를 기록하면 commit과 image digest가 다시 바뀌는 자기참조가 발생한다.
Release가 완료된 뒤 검증된 digest를 사용하는 환경/인프라 저장소의 overlay를 생성한다. 예를 들어 `environments/production/jarca-ledger/kustomization.yaml`은 다음과 같다.
```yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../../../jarca-ledger-mcp/deploy/k8s/base
images:
- name: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp
newName: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp
digest: sha256:<verified-release-digest>
```
두 application container와 init container는 반드시 같은 digest를 사용해야 한다.
## 배포 ## 배포
Namespace와 Secret 준비가 끝난 뒤, base가 아니라 위의 외부 digest-pinned overlay를 적용한다. 검증된 release digest를 외부 environment overlay에서 고정하고 적용합니다.
```bash ```bash
kubectl apply -k environments/production/jarca-ledger kubectl apply -k environments/production/jarca-ledger
@@ -59,11 +28,24 @@ kubectl -n jarca-ledger rollout status deployment/jarca-ledger
kubectl -n jarca-ledger get pods,svc,pvc kubectl -n jarca-ledger get pods,svc,pvc
``` ```
Dashboard를 외부에 공개하는 Ingress나 NodePort는 environment overlay에서 추가한다. 인증 cookie가 항상 `Secure`이고 endpoint가 ASGI HTTPS scheme을 요구하므로 공개 origin에는 TLS가 필수다. Base의 `FORWARDED_ALLOW_IPS=127.0.0.1`은 ingress forwarded header를 신뢰하지 않는 fail-closed 기본값이다. TLS를 ingress에서 종료하는 overlay는 이 값을 실제 ingress-controller source IP/CIDR allowlist로 교체해야 하며 `*` 또는 불특정 network를 사용해서는 안 된다. TLS 종료 계층은 `Host`와 `Origin`을 보존하고 `X-Forwarded-Proto: https`를 설정해야 한다. 인증 cookie의 발급·검증 자체는 Dashboard 프로세스가 수행한다. Base에는 MCP Service가 없고, `NetworkPolicy`는 workload ingress를 dashboard port `8501`로 제한하여 인증 계층이 없는 MCP port `8000` ingress를 차단한다. Cluster의 network plugin이 `NetworkPolicy`를 enforce하는지 운영 환경에서 확인한다. MCP와 Dashboard container 및 init container는 동일 digest를 사용해야 합니다. SQLite를 공유하므로 replicas는 `1`, update 전략은 `Recreate`입니다.
## Dashboard 인증 운영
Frontend container에만 다음 환경변수를 Kubernetes Secret 또는 동등한 secret store에서 주입합니다.
- `JARCA_DASHBOARD_ADMIN_ID`
- `JARCA_DASHBOARD_ADMIN_PASSWORD`
- `JARCA_DASHBOARD_SIGNING_SECRET`
환경변수가 누락·빈 값·read error·짧은 secret이면 Dashboard는 fail-closed합니다. token subject는 고정 비민감 role `dashboard-admin`이며 server-only configured credential binding을 사용합니다. DB-backed session/token registry와 per-token revocation은 없습니다. Password, signing secret 또는 admin ID rotation은 기존 token을 전체 무효화합니다.
공개 Dashboard origin에는 TLS가 필수입니다. Ingress 또는 reverse proxy는 `Host`, `Origin`, `Referer`, `X-Forwarded-Proto: https`를 보존해야 하며 `FORWARDED_ALLOW_IPS`는 실제 ingress-controller source IP/CIDR로 제한합니다. `*` 또는 불특정 network를 사용하지 마세요. Login과 `/api/auth/logout`은 same-origin HTTPS POST이고 `/api/auth/cleanup` GET은 same-origin HTTPS `Referer`를 검증합니다. Invalid cookie는 cleanup route의 matching `Max-Age=0` deletion 뒤 unauthenticated page로 이동하고, explicit logout도 같은 deletion을 수행합니다. 인증 실패 지연은 rate limiting을 대체하지 않으므로 TLS ingress 경계에 별도 rate limit을 적용합니다.
Base는 인증 계층이 없는 MCP port `8000`을 Service로 공개하지 않고 NetworkPolicy로 ingress를 제한합니다. NetworkPolicy enforcement는 운영 cluster에서 확인해야 합니다.
## SQLite와 PVC ## SQLite와 PVC
- PVC 전체를 `/data`에 mount한다. SQLite journal, WAL, SHM 파일이 DB 옆에 생성되므로 DB 파일 하나만 `subPath`로 mount하지 않는다. - PVC 전체를 `/data`에 mount하여 SQLite journal/WAL/SHM을 함께 보존합니다.
- `replicas`는 `1`, update 전략은 `Recreate`로 유지한다. RollingUpdate는 일시적으로 두 Pod가 같은 SQLite DB를 열 수 있다. - `replicas: 1`과 `Recreate`를 유지하여 두 Pod가 같은 SQLite를 동시에 열지 않게 합니다.
- k3s `local-path` volume에서는 `fsGroup: 1000` 적용 여부를 기존 PVC에서도 확인한다. 권한이 맞지 않으면 rollout 전에 운영 절차로 소유권을 수정하고, 상시 privileged `chown` init container는 두지 않는다. - 별도 저장소로 backup을 내보내며 PVC만 재해 복구로 간주하지 않습니다.
- 같은 PVC 안의 backup은 node 또는 disk 손실에 대한 재해 복구가 아니다. 별도 저장소로 backup을 내보낸다.
+184 -69
View File
@@ -2,102 +2,217 @@
## 목적과 범위 ## 목적과 범위
Dashboard의 인증 설정, server-side login/logout HTTP endpoint, signed cookie와 Streamlit 인증 경계 계약을 정의한다. Dashboard 인증 설정, login/cleanup/logout HTTP route, signed cookie와 Streamlit gate의 외부·내부 계약을 정의한다.
- 포함: 관리자 credential, signing secret, login/logout POST, token/cookie, fail-closed page gate - 포함 범위: 환경변수, HTTPS form POST, cookie 발급·삭제, fail-closed gate
- 제외: MCP 인증, OAuth/OIDC, 다중 사용자, token registry, 외부 인증 middleware - 제외 범위: MCP 인증, OAuth/OIDC, 다중 사용자, DB session/token registry, per-token revocation
- 공개 origin은 HTTPS여야 한다. - 사용 주체: browser, Dashboard ASGI server, Streamlit app, 운영 배포
## 인터페이스 목록 ## 계약
| 인터페이스 | 종류 | 호출 주체 | 제공 주체 | 버전 | 상태 | ### auth-config — 서버 인증 설정
- 책임: 현재 configured credential과 signing secret을 server-only로 제공한다.
- 종류·프로토콜: process environment
- 호출 대상·operation: `load_auth_settings()`
- 버전: 2.0
- 호출 주체: Dashboard server
- 인증 방식: 없음
- 필요 권한: process secret read
#### 입력
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|---|
| `JARCA_DASHBOARD_ADMIN_ID` | environment | `string` | 필수 | 없음 | non-empty | configured admin identifier | 민감 |
| `JARCA_DASHBOARD_ADMIN_PASSWORD` | environment | `string` | 필수 | 없음 | non-empty | configured credential | 민감 |
| `JARCA_DASHBOARD_SIGNING_SECRET` | environment | `string` | 필수 | 없음 | UTF-8 32 byte 이상 | signing input | 민감 |
#### 출력
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|
| `AuthSettings` | server memory | `object` | 성공 시 필수 | current configured values | verifier input; never serialized | 민감 |
#### 오류
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| `dashboard-auth-config` | environment | 배포 시스템 | `dashboard.authentication` | 2.0 | active | | `configuration_unavailable` | configuration | missing/empty/read error/short secret | 수정 후 가능 | generic unavailable | no cookie, no DB |
| `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 |
## 상세 계약 ### login — `POST /api/auth/login`
### dashboard-auth-config - 책임: same-origin HTTPS credential을 검증하고 signed cookie를 발급한다.
- 종류·프로토콜: HTTPS form POST
- 호출 대상·operation: `/api/auth/login`
- 버전: 1.0
- 호출 주체: browser login form
- 인증 방식: submitted credential, server-only comparison
- 필요 권한: same-origin HTTPS
| 이름 | 타입 | 필수 | 제약 | 민감정보 | #### 입력
|---|---|---|---|---|
| `JARCA_DASHBOARD_ADMIN_ID` | string | 예 | non-empty | 예 |
| `JARCA_DASHBOARD_ADMIN_PASSWORD` | string | 예 | non-empty | 예 |
| `JARCA_DASHBOARD_SIGNING_SECRET` | string | 예 | UTF-8 32 byte 이상 | 예 |
누락·빈 값·비문자열·read error·짧은 signing secret은 일반 설정 오류로 fail-closed한다. Secret 원문이나 read-error 원인은 사용자 응답과 log에 포함하지 않는다. | 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|---|
| `admin_id` | form body | `string` | 필수 | 없음 | bounded form value | submitted ID | 민감 |
| `admin_password` | form body | `string` | 필수 | 없음 | bounded form value | submitted password | 민감 |
| `remember_me` | form body | `string` | 선택 | off | `on` or absent | choose 30-day TTL | 비민감 |
| `Origin` | request header | `URL` | 필수 | 없음 | request `Host`와 scheme·hostname·effective port가 같은 HTTPS origin | CSRF origin 검증 | 비민감 |
### POST /api/auth/login #### 출력
- Content-Type: `application/x-www-form-urlencoded` | 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
- Transport: trusted ASGI scope의 request scheme이 `https`여야 함 |---|---|---|---|---|---|---|
- Origin: request host·effective port와 같은 HTTPS origin만 허용 | `Set-Cookie` | response header | `cookie` | success | hardened auth cookie, redacted value | persistent auth | 값은 민감 |
- 최대 body: 4096 bytes | `Location` | response header | `string` | success/failure | `/` or generic error path | browser navigation | 비민감 |
- 입력:
| 필드 | 타입 | 필수 | 의미 | #### 오류
|---|---|---|---|
| `admin_id` | string | 예 | 제출 관리자 ID |
| `admin_password` | string | 예 | 제출 password |
| `remember_me` | string | 아니요 | 값이 `on`이면 30일, 아니면 24시간 |
- 성공: `303 Location: /`, hardened cookie 1개 발급 | 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
- credential 실패: 최소 1초 후 `303 Location: /?auth_error=invalid`, cookie 없음 |---|---|---|---|---|---|
- 설정 실패: `303 Location: /?auth_error=unavailable`, cookie 없음 | `400` | request | malformed/oversized body | 수정 후 가능 | resubmit valid form | no cookie |
- origin 실패: `403`, cookie 없음 | `403` | origin | non-HTTPS or cross-origin | 수정 후 가능 | use same-origin HTTPS | no cookie |
- plain HTTP 또는 신뢰되지 않은 forwarded scheme: `403`, cookie 없음 | `invalid` | authentication | ID/password mismatch | 가능 | generic error and delay | no cookie |
- malformed/oversized request: `400`, cookie 없음 | `unavailable` | configuration | invalid server configuration | config fix 후 | generic unavailable | no cookie |
- ID와 password는 모두 `hmac.compare_digest`로 비교한다.
### dashboard-auth-cookie ### cleanup — `GET /api/auth/cleanup`
| 속성 | 값 | - 책임: invalid cookie를 matching scope로 삭제한 뒤 unauthenticated navigation을 완료한다.
|---|---| - 종류·프로토콜: HTTPS navigation GET
| 이름 | `__Host-jarca_dashboard_auth` | - 호출 대상·operation: `/api/auth/cleanup`
| `Secure` | true | - 버전: 1.0
| `HttpOnly` | true | - 호출 주체: same-origin browser/auth gate
| `SameSite` | `Lax` | - 인증 방식: same-origin HTTPS `Referer`; token validity 불필요
| `Path` | `/` | - 필요 권한: same-origin HTTPS
| `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 | 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|---|
| `Referer` | request header | `URL` | 필수 | 없음 | request origin과 같은 HTTPS authority | navigation origin 검증 | 비민감 |
- Same-origin HTTPS POST만 허용한다. #### 출력
- 성공: `303 Location: /`과 동일 name/path/security 속성의 `Max-Age=0` cookie deletion
- 금융 상태와 DB는 변경하지 않는다.
- Stateless token 복사본은 개별 폐기하지 않는다. 강제 무효화는 password/signing-secret rotation으로 수행한다.
### dashboard-auth-gate | 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|
| `Set-Cookie` | response header | `cookie` | always | same name/path/security, `Max-Age=0` | delete stale cookie | 값은 민감 |
| `Location` | response header | `string` | success | `/` | unauthenticated page | 비민감 |
`dashboard.app.main()`은 page config 직후 gate를 호출한다. Gate는 현재 설정으로 cookie signature와 strict claims를 검증한다. 유효할 때만 DB path, gateway와 page dispatch를 실행한다. Token, credential, signing material은 `st.session_state`에 저장하지 않는다. 비민감 correlation ID만 session state에 둘 수 있다. #### 오류
## 호환성·순서·재시도 | 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|---|---|---|---|---|---|
| `403` | origin | non-HTTPS/cross-origin | 가능 | retry same-origin | no DB |
- 기존 `page` query parameter와 page dispatch는 유효 인증 이후 그대로 유지한다. ### logout — `POST /api/auth/logout`
- REQ-001 revision 2.0이 revision 1.0의 session-only boolean 계약을 signed-cookie 계약으로 대체한다.
- 설정 판정 → cookie 검증 → DB path → page dispatch 순서를 유지한다. - 책임: explicit logout으로 browser cookie를 삭제한다.
- password, signing secret 또는 관리자 ID 변경은 기존 token을 거부한다. - 종류·프로토콜: HTTPS form POST
- 반복 실패는 application lock을 만들지 않으며 각 credential 실패에 최소 1초 지연을 적용한다. - 호출 대상·operation: `/api/auth/logout`
- 버전: 1.0
- 호출 주체: authenticated browser
- 인증 방식: same-origin HTTPS; server-side revocation 없음
- 필요 권한: same-origin HTTPS
#### 입력
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|---|
| `none` | none | `none` | 선택 | 없음 | no credential accepted | explicit logout | 없음 |
| `Origin` | request header | `URL` | 필수 | 없음 | request `Host`와 scheme·hostname·effective port가 같은 HTTPS origin | CSRF origin 검증 | 비민감 |
#### 출력
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|
| `Set-Cookie` | response header | `cookie` | always | matching deletion, `Max-Age=0` | remove browser cookie | 값은 민감 |
| `Location` | response header | `string` | success | `/` | unauthenticated page | 비민감 |
#### 오류
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|---|---|---|---|---|---|
| `403` | origin | non-HTTPS/cross-origin | 가능 | retry same-origin | no DB |
### gate — Dashboard 인증 경계
- 책임: config and cookie verification before any protected work.
- 종류·프로토콜: Python/Streamlit call
- 호출 대상·operation: `require_dashboard_authentication()`
- 버전: 2.0
- 호출 주체: `dashboard.app.main()`
- 인증 방식: current config plus signed cookie
- 필요 권한: valid token with `sub=dashboard-admin`
#### 입력
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|---|
| `auth_cookie` | request cookie | `string` | success path | absent | signed token | browser proof | 민감 |
#### 출력
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|
| `authenticated` | server result | `bool` | always | true/false | gate decision | 비민감 |
#### 오류
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|---|---|---|---|---|---|
| `unauthenticated` | authentication | absent/invalid/expired/config failure | login 가능 | cleanup then show login | no protected work |
## 호환성
- 유지할 기존 호출 방식: authenticated `page` query parameter, page dispatch, gateway와 MCP behavior
- 버전 변경 정책: REQ-001 revision 2.0; session-only boolean을 signed cookie로 대체
- 폐기 정책: client-side token storage, DB-backed session, per-token revocation 없음
- 재시도 계약: credential 실패는 최소 1초 지연; origin/request 오류는 수정 후 재시도
- 중복 처리 계약: login은 새 token 발급 가능; cleanup/logout deletion은 idempotent
- 순서 보장: config → cookie verify → cleanup if invalid → DB/page dispatch only if valid
## 예시 ## 예시
### example-login — 정상 login
- 대상 인터페이스: `login`
#### 호출
```text ```text
POST /api/auth/login (same-origin HTTPS) POST /api/auth/login
Origin: https://dashboard.example
admin_id=<submitted>&admin_password=<submitted>&remember_me=on 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
303 Location: /
Set-Cookie: __Host-jarca_dashboard_auth=<redacted>; Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000
```
### example-cleanup — invalid cookie cleanup
- 대상 인터페이스: `cleanup`
#### 호출
```text
GET /api/auth/cleanup
Referer: https://dashboard.example/
```
#### 결과
```text
303 Location: /
Set-Cookie: __Host-jarca_dashboard_auth=; Max-Age=0; Secure; HttpOnly; SameSite=Lax; Path=/
``` ```
## 검증 ## 검증
- Token: `tests/test_dashboard_persistent_authentication.py` - 정상 계약: authentication/server tests와 loopback HTTPS evidence
- HTTP endpoint/cookie: `tests/test_dashboard_auth_server.py` - 실패 계약: wrong credential, invalid configuration, cross-origin, malformed cookie tests
- Gate/DB ordering: `tests/test_dashboard_authentication_app.py` - 경계 계약: expiry, rotation, oversized token, same-host different-port origin tests
- HTTPS attributes/reconnect/logout: `scripts/persistent_auth_http_evidence.py` - 구현·스키마·예시 정합성: `tests/test_dashboard_auth_server.py`, `tests/test_dashboard_authentication_app.py`, `tests/test_dashboard_persistent_authentication.py`, source/document redaction search
- 실제 secret/token 값은 test output과 evidence에 남기지 않는다.
+81 -66
View File
@@ -1,86 +1,101 @@
# Project Structure — Dashboard Persistent Authentication # Project Structure — Dashboard 관리자 인증
## 목적과 범위 ## 목적과 경계
Issue #6의 signed persistent authentication을 기존 Dashboard page 구조와 분리하여 배치하는 규칙을 정의한다. DB schema와 MCP 구조는 변경하지 않는다. Issue #6 인증을 기존 Dashboard 구조에 배치하고 fail-closed 경계와 server-only secret binding을 유지한다.
## Source 영역 - 포함 경계: `dashboard/` 인증 adapter/gate, tests, 운영 문서
- 제외 경계: MCP transport, 금융 schema, DB migration, external auth middleware
| 경로 | 책임 | 주요 공개 요소 | 금지 의존성 | ## 디렉터리 구조
|---|---|---|---|
| `dashboard/server.py` | Streamlit ASGI app과 인증 route 조립 | `app` | DB/gateway 직접 접근 |
| `dashboard/auth_server.py` | same-origin login/logout HTTP response와 hardened cookie | `create_auth_routes()` | page view, SQLite |
| `dashboard/authentication.py` | 설정, credential 비교, token 발급·검증, Streamlit gate | `require_dashboard_authentication()` | 금융 DB/gateway |
| `dashboard/app.py` | gate 성공 후 기존 page dispatch | `main()` | token signing 세부 구현 |
| `dashboard/common.py` | 기존 DB/UI helper | 기존 API | 인증 결정 |
| `dashboard/page_views/` | 기존 금융 화면 | `page_*` | 인증 transport |
`dashboard.server:app`이 운영 진입점이다. 인증 endpoint는 Streamlit 1.58의 public ASGI `App(routes=...)` 확장 지점에 같은 process로 결합한다. ### dashboard-source — Dashboard source
## Test 영역 ```text
dashboard/
├── server.py
├── auth_server.py
├── authentication.py
├── app.py
└── page_views/
```
| 경로 | 검증 책임 | | 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 |
|---|---| |---|---|---|---|---|---|---|
| `tests/test_dashboard_authentication.py` | 기존 credential 비교·지연·safe log | | `dashboard/server.py` | source | ASGI app 조립 | `app` | route wiring only | source review required | developer |
| `tests/test_dashboard_persistent_authentication.py` | token claims, TTL, tamper, rotation, malformed input | | `dashboard/auth_server.py` | source | login/cleanup/logout response | `create_auth_routes()` | no DB/page imports | source review required | developer |
| `tests/test_dashboard_auth_server.py` | login/logout response, cookie 속성, same-origin, fail-closed | | `dashboard/authentication.py` | source | config, constant-time compare, token verify, gate | `require_dashboard_authentication()` | server-only secret use | source review required | developer |
| `tests/test_dashboard_authentication_app.py` | Streamlit gate, DB 이전 차단, cookie 복원·rotation | | `dashboard/app.py` | source | gate then page dispatch | `main()` | no token signing details | source review required | developer |
| `tests/test_dashboard_app.py` | 인증 이후 기존 핵심 page regression | | `dashboard/page_views/` | source | financial UI pages | `page_*` functions | no auth transport calls | source review required | developer |
| `scripts/persistent_auth_http_evidence.py` | 실제 loopback HTTPS cookie/reconnect/logout evidence |
Test는 synthetic DB와 test-only credential/secret만 사용하고 token·secret 원문을 출력하지 않는다. ### verification-tests — 인증 검증
## Config·배포·문서 영역 ```text
tests/
├── test_dashboard_authentication.py
├── test_dashboard_persistent_authentication.py
├── test_dashboard_auth_server.py
└── test_dashboard_authentication_app.py
```
| 경로 | 책임 | | 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 |
|---|---| |---|---|---|---|---|---|---|
| `pyproject.toml`, `uv.lock` | `itsdangerous`, `starlette`, `uvicorn` 직접 dependency 고정 | | `tests/test_dashboard_authentication.py` | test | credential compare, delay, redaction | pytest tests | test-only settings | source change paired with test | developer |
| `.env.example` | 세 environment 변수의 이름만 제공 | | `tests/test_dashboard_persistent_authentication.py` | test | claims, TTL, tamper, rotation | pytest tests | no real secrets | source change paired with test | developer |
| `docker-compose.yml` | signing secret 전달과 ASGI server command | | `tests/test_dashboard_auth_server.py` | test | HTTP routes and cookie scope | pytest tests | synthetic test credentials | source change paired with test | developer |
| `deploy/k8s/base/deployment.yaml` | Kubernetes Secret `signing-secret` 전달과 ASGI command | | `tests/test_dashboard_authentication_app.py` | test | gate ordering and cleanup/UI | AppTest | fixture DB only | source change paired with test | developer |
| `README.md`, `deploy/k8s/README.md` | HTTPS, rotation, expiry, rate-limit, logout 제약 |
| `docs/requirements/req-001_dashboard-administrator-authentication.md` | Issue #1·#6 통합 canonical 승인 명세 | ### deployment-docs — 운영 문서
| `docs/interface-contract.md` | HTTP/cookie/token 계약 |
| `docs/runtime-flow.md` | 발급·복원·실패·logout 순서 | ```text
docs/requirements/req-001_dashboard-administrator-authentication.md
docs/interface-contract.md
docs/runtime-flow.md
README.md
deploy/k8s/README.md
```
| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 |
|---|---|---|---|---|---|---|
| `docs/requirements/req-001_dashboard-administrator-authentication.md` | requirement | approved REQ-001 revision 2.0 | REQ-001 | canonical traceability | update in place | developer |
| `docs/interface-contract.md` | contract | HTTP/cookie/gate contracts | contract IDs | no implementation secrets | update with API changes | developer |
| `docs/runtime-flow.md` | design | runtime states/transitions | flow IDs | no secret values | update with flow changes | developer |
| `README.md` | operations | local/deployment constraints | operator commands | no secret values | operational review | developer |
| `deploy/k8s/README.md` | operations | k3s secret/TLS procedure | deployment procedure | no secret values | deployment review | developer |
## 의존 방향 ## 의존 방향
```text | 출발 영역 | 대상 영역 | 허용 여부 | 규칙 | 공유 계약 위치 |
browser |---|---|---|---|---|
├─ POST /api/auth/login|logout → dashboard.auth_server | `dashboard.server` | `dashboard.auth_server` | 허용 | compose ASGI routes only | `docs/interface-contract.md` |
└─ Streamlit handshake cookie → dashboard.authentication | `dashboard.server` | `dashboard.app` | 허용 | app remains protected by gate | `docs/runtime-flow.md` |
| `dashboard.app` | `dashboard.authentication` | 허용 | gate precedes DB/page dispatch | `docs/interface-contract.md` |
dashboard.server → dashboard.auth_server + Streamlit App(dashboard/app.py) | `dashboard.authentication` | `dashboard.page_views` | 금지 | auth module never imports financial pages | `docs/project-structure.md` |
dashboard.app → dashboard.authentication → itsdangerous | `dashboard.auth_server` | `jarca_ledger_mcp` DB | 금지 | login/cleanup/logout are DB-free | `docs/requirements/req-001_dashboard-administrator-authentication.md` |
→ (인증 성공 후) dashboard.common/page_views
```
`auth_server`와 `authentication`은 금융 DB를 import하지 않는다. Page module은 cookie와 token module을 직접 호출하지 않는다.
## 상태·데이터 배치
- Credential/signing secret: process environment에서 request/rerun 동안만 참조
- Auth token: browser의 `Secure`/`HttpOnly` cookie에만 저장
- Correlation ID: 비민감 값으로 `st.session_state`에 저장 가능
- 금융 데이터: 기존 SQLite
- Token registry/revocation table/migration: 생성하지 않음
따라서 `docs/data-model.md`는 추가하지 않는다.
## 변경 규칙 ## 변경 규칙
- 인증 환경변수는 `JARCA_DASHBOARD_` prefix를 사용한다. | 구분 | 규칙 |
- Cookie name은 host-only scope를 강제하는 `__Host-` prefix를 사용한다. |---|---|
- Token schema/TTL/cookie attributes 변경은 REQ-001과 interface contract를 함께 변경한다. | 새 모듈·파일 배치 | auth route/gate changes stay in `dashboard/`; no DB session module |
- Secret, token, cookie header, credential 값을 source·fixture·log·문서에 기록하지 않는다. | 이름·패키지 | use existing `dashboard` package and snake_case modules |
- Streamlit private Tornado/WebSocket internals나 client-side cookie component를 사용하지 않는다. | 소스·테스트 대응 | auth source behavior requires focused pytest/AppTest coverage |
| 생성 산출물 | evidence may be redacted; never commit credentials, secrets, tokens, or cookie values |
## 검증 ## 검증
```text - 구조 검사 명령:
uv run --extra dashboard --extra dev pytest
uv run --extra dashboard --extra dev ruff check dashboard tests scripts
uv run python -m compileall -q dashboard tests scripts
```
Diff에서 DB migration과 `docs/data-model.md`가 추가되지 않았는지 확인한다. ```text
python -m compileall -q dashboard tests scripts
```
- 구조 검사 기대 결과: exit 0 and no generated source outside ignored bytecode locations
- 정적 분석 명령:
```text
uv run --extra dashboard --extra dev ruff check dashboard tests scripts
```
- 정적 분석 기대 결과: exit 0 with no diagnostics
- 문서와 실제 트리의 정합성: every listed source/test/document path exists; dependency table has no forbidden reverse edge
@@ -15,145 +15,432 @@
## 1. 목적 ## 1. 목적
Streamlit Dashboard의 모든 금융 데이터 조회·수정·import 기능 앞에 단일 관리자 인증 경계를 두고, 인증되지 않았거나 인증 설정이 유효하지 않은 요청을 fail-closed한다. 인증 성공 후에는 server-signed `Secure`/`HttpOnly` cookie로 같은 browser profile의 새 연결과 새 session에서 인증을 정해진 절대 만료까지 복원한다. Dashboard의 금융 기능 앞에 단일 fail-closed 관리자 인증 경계를 두고, 유효한 인증 cookie가 같은 browser profile의 새 연결에서 복원되도록 한다.
## 2. 배경 ## 2. 배경
Revision 1.0은 Issue #1에서 단일 관리자 credential과 browser-session-only 인증 경계를 도입했다. Issue #6은 sidebar query-link 이동과 browser 재접속에서 반복 로그인이 발생하는 문제를 해결하기 위해 동일한 인증 기능의 session·logout·배포 계약을 persistent cookie 방식으로 확장한다. 새 기능 ID가 아니라 본 canonical 요구사항의 revision으로 관리한다. Issue #1의 관리자 인증을 Issue #6에서 persistent signed cookie와 명시적 logout으로 확장한다. 기존 요구사항의 정체성, 승인 범위, Issue #1과 구현 PR #3의 추적성을 유지하며 revision 2.0으로 갱신한다.
## 3. 범위 ## 3. 범위
### 포함 ### 포함
- 모든 Dashboard 페이지 앞의 공통 관리자 인증과 인증 전 DB 차단 - Dashboard 전 기능 앞의 인증 gate와 인증 전 DB 차단
- 환경변수 기반 관리자 ID/password 및 독립적인 signing secret - `JARCA_DASHBOARD_ADMIN_ID`, `JARCA_DASHBOARD_ADMIN_PASSWORD`, `JARCA_DASHBOARD_SIGNING_SECRET` 기반 server-only 검증
- constant-time credential 비교, 일반 오류, 실패당 최소 1초 지연 - constant-time credential 비교, 일반 오류, 실패당 최소 1초 지연
- 동일 Dashboard ASGI process의 server-side login/logout HTTP endpoint - same-origin HTTPS login, cleanup, logout route
- server-signed `Secure`/`HttpOnly` cookie 발급·검증·삭제 - `Secure`/`HttpOnly`/`SameSite=Lax`/`Path=/` signed cookie와 24시간·30일 absolute TTL
- 기본 24시간, 선택 시 30일인 로그인 성공 시각 기준 절대 만료 - 고정된 비민감 role subject와 설정 credential에 묶인 서명 검증
- sidebar 페이지 이동, 새 탭, browser 종료 후 재접속과 새 Streamlit session 복원 - invalid, expired, tampered, malformed, configuration-failure cookie의 cleanup 및 matching deletion
- password/signing-secret/admin-ID rotation에 따른 기존 token 무효화 - password, signing-secret, admin-ID 변경에 따른 기존 token 거부
- credential·secret·token 비노출과 인증 event 관찰 가능성 - HTTPS, proxy, Kubernetes/Compose 운영 제약과 민감정보 비노출
- Compose, Kubernetes Secret, HTTPS와 운영 제약 문서화
### 제외 ### 제외
- 다중 사용자, 회원가입, RBAC, 사용자별 데이터 분리 - 다중 사용자, RBAC, OAuth/OIDC, SSO, MFA, CAPTCHA, 계정 잠금
- OAuth/OIDC, SSO, 외부 IdP, MFA, CAPTCHA, 계정 잠금 - 외부 인증 proxy로 책임 이전
- 외부 인증 proxy/gateway 또는 외부 session middleware로 인증 책임 이전 - sliding expiration, DB-backed session/token registry, per-token revocation
- sliding expiration 또는 복원 시 만료 재설정 - MCP endpoint 인증 변경, SQLite schema/data migration
- DB-backed token/session registry, 개별 원격 session 폐기 UI
- MCP endpoint 인증과 MCP transport 변경
- SQLite 금융 schema와 데이터 모델 변경
- 다른 browser profile·기기 간 로그인 공유 - 다른 browser profile·기기 간 로그인 공유
## 4. 용어와 기준 ## 4. 용어와 기준
- **persistent auth token**: version, 관리자 subject, 발급 시각, 절대 만료, token ID만 포함하고 서버가 서명한 bearer token - **role subject**: token의 `sub`에 고정 기록하는 비민감 값 `dashboard-admin`; 관리자 ID나 credential이 아니다.
- **remember me**: 기본 미선택 checkbox이며 token TTL을 24시간에서 30일로 선택 - **server-only binding**: signing secret과 현재 설정 credential을 서버에서만 읽어 key를 파생하며 browser에 값을 보내지 않는 방식.
- **rotation**: 관리자 password 또는 signing secret을 교체하여 기존 token 서명 검증을 실패시키는 운영 절차 - **cleanup**: 인증 실패 cookie를 같은 origin의 cleanup route에서 matching name/path/security 속성으로 삭제하는 절차.
- **canonical 근거**: Issue #1, Issue #6, source revisions `069d4c16fd84e871e8cd9dc8d9ae39b4fce4a6c6` 및 `7bea8b368e02e98afd4b2dc463fb270b0e6eb297` - **canonical 근거**: Issue #1, Issue #6, 구현 PR #3 및 #7.
## 5. 요구사항 ## 5. 요구사항
### REQ-001.01 — 공통 인증 경계 ### REQ-001.01 — 공통 인증 경계
Dashboard는 DB path 취득, page dispatch, gateway 호출, upload, rollback 또는 DB-backed 렌더링보다 먼저 현재 설정과 signed cookie를 검증해야 한다. 기존 session boolean만으로 만료·rotation 검증을 우회해서는 안 된다. 실패 시 로그인 화면만 렌더링한다. 모든 DB path, page dispatch, gateway, upload, import보다 먼저 설정과 cookie를 검증하고, 실패하면 로그인 화면만 표시한다.
### REQ-001.02 — 설정과 fail-closed **영향 범위**
Credential source는 `JARCA_DASHBOARD_ADMIN_ID`, `JARCA_DASHBOARD_ADMIN_PASSWORD`뿐이며 signing source는 `JARCA_DASHBOARD_SIGNING_SECRET`뿐이다. 누락·빈 값·비문자열·read error 또는 UTF-8 32 byte 미만 signing secret은 일반 설정 오류로 fail-closed한다. Streamlit secrets나 fallback을 사용하지 않는다. - 인터페이스: `dashboard-auth-gate`
- 영속성: 없음
- 후속 사용 주체: Dashboard page와 gateway
- 마이그레이션: 없음
- 호환성: 인증 후 기존 page/query 동작 유지
- 배포 전제 조건: 유효한 세 환경변수
### REQ-001.03 — Credential 검증과 비노출 ### REQ-001.02 — 설정과 server-only binding
제출 ID와 password는 모두 `hmac.compare_digest`로 비교한다. Credential, signing secret, signing-key material, token, raw cookie와 `Set-Cookie` 값은 UI, traceback, URL/query, application log, 문서 또는 `st.session_state`에 저장하지 않는다. 비민감 correlation ID만 session state에 둘 수 있다. 세 환경변수만 사용하고 누락·빈 값·read error·짧은 signing secret이면 fail-closed한다. signing key는 서버에서 configured credential과 secret으로만 파생한다.
### REQ-001.04 — 실패 로그인 응답 **영향 범위**
잘못된 ID 또는 password의 각 제출은 처리 시작부터 응답까지 최소 1초가 걸려야 한다. 두 실패는 동일한 오류를 표시하고 cookie·DB·gateway·금융 상태를 변경하지 않는다. 성공에는 실패 지연을 적용하지 않으며 application lock은 두지 않는다. - 인터페이스: `dashboard-auth-config`, `dashboard-auth-cookie`
- 영속성: 없음
- 후속 사용 주체: 인증 verifier
- 마이그레이션: 없음
- 호환성: Streamlit secrets/fallback 미사용
- 배포 전제 조건: secret manager 주입
### REQ-001.05 — Persistent session과 절대 만료 ### REQ-001.03 — Credential과 민감정보 비노출
정상 로그인도 24시간, 기본 미선택 `로그인 유지`를 선택한 로그인은 30일의 절대 만료를 사용한다. 발급 시 `iat`와 `exp`를 고정하고 page 이동, rerun, 활동, 새 tab, browser 재시작 또는 인증 복원으로 token이나 만료를 갱신하지 않는다. 같은 browser profile의 유효 cookie는 새 Streamlit session에서 인증을 복원한다. ID와 password는 constant-time 비교한다. credential, secret, signing material, token, raw cookie와 Set-Cookie 값은 UI, URL, log, 문서, session state에 기록하지 않는다.
### REQ-001.06 — Server-side adapter와 cookie **영향 범위**
Streamlit 1.58의 ASGI `App` custom route를 사용해 동일 Dashboard process의 실제 HTTP response에서 cookie를 발급·삭제한다. JavaScript/localStorage/client-side cookie component로 token을 저장하지 않는다. Cookie는 `__Host-` 이름, `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`, no `Domain`이며 `Max-Age`는 token TTL과 같다. - 인터페이스: login form, event log
- 영속성: 없음
- 후속 사용 주체: 운영자와 감사 도구
- 마이그레이션: 없음
- 호환성: 비민감 correlation ID만 유지
- 배포 전제 조건: redacted logging
### REQ-001.07 — Token 서명·claim·rotation ### REQ-001.04 — Login 실패 계약
Token payload는 `v`, 현재 관리자 ID `sub`, server `iat`, `exp`, 무작위 `jti`만 허용한다. Signing key는 독립 signing secret과 현재 관리자 password를 domain-separated HMAC-SHA256으로 결합해 파생하고 SHA-256 signer를 사용한다. Signature를 검증한 뒤 exact schema/type, subject, 허용 TTL, 미래 발급 시각과 server-side 만료를 검증한다. Password, signing secret 또는 관리자 ID 변경은 기존 token을 거부해야 한다. ID 또는 password가 틀리면 동일한 일반 오류와 최소 1초 지연을 적용하고 상태를 변경하지 않는다. 성공에는 지연을 적용하지 않는다.
### REQ-001.08 — Login/logout HTTP 계약 **영향 범위**
Login endpoint는 trusted ASGI request scheme과 Origin이 모두 same-origin HTTPS인 form POST만 받고 성공 시 hardened cookie와 root redirect를 반환한다. Logout endpoint도 같은 HTTPS 검증 후 동일 name/path/security 속성과 `Max-Age=0`으로 browser cookie를 삭제한다. Logout은 금융 DB를 변경하지 않는다. Stateless token 복사본은 개별 폐기하지 않으며 전체 강제 무효화는 password/signing-secret rotation으로 수행한다. - 인터페이스: `POST /api/auth/login`
- 영속성: 없음
- 후속 사용 주체: browser
- 마이그레이션: 없음
- 호환성: 기존 login form 유지
- 배포 전제 조건: 외부 rate limiting
### REQ-001.09 — 보호 범위와 기존 기능 호환성 ### REQ-001.05 — Persistent token
인증 후 기존 page 목록, `page` query parameter, sidebar 이동, gateway와 MCP 동작은 유지한다. 인증은 Dashboard만 보호하고 MCP endpoint 인증을 의미하지 않는다. 금융 SQLite schema/migration은 변경하지 않는다. 성공 login은 기본 24시간, remember-me 선택 시 30일의 absolute TTL token을 발급한다. `iat`와 `exp`는 고정하며 활동·rerun·복원으로 연장하지 않는다. `sub`는 항상 `dashboard-admin`이다.
### REQ-001.10 — 배포·운영 제약 **영향 범위**
`.env.example`, Compose와 Kubernetes는 세 secret setting 이름만 정의하고 실제 값을 저장하지 않는다. 공개 browser origin은 HTTPS여야 한다. TLS proxy를 사용하는 배포는 실제 proxy source IP/CIDR만 Uvicorn forwarded-header allowlist에 넣고 Host/Origin을 보존하며 `X-Forwarded-Proto: https`를 설정한다. 기본 allowlist는 loopback으로 fail-closed하고 wildcard trust를 금지한다. 최소 1초 delay는 병렬 brute-force를 막지 않으므로 외부 노출 경계에 rate limiting이 필요하다. 모든 verifier replica는 동일 secret과 token 규칙을 사용해야 한다. - 인터페이스: `dashboard-auth-cookie`
- 영속성: browser cookie만
- 후속 사용 주체: 새 Streamlit session
- 마이그레이션: 기존 session boolean에서 cookie로 전환
- 호환성: 같은 browser profile 복원
- 배포 전제 조건: HTTPS
### REQ-001.06 — Cookie 보안 속성
Cookie 이름은 `__Host-jarca_dashboard_auth`이며 `Secure`, `HttpOnly`, `SameSite=Lax`, `Path=/`, no `Domain`을 사용한다. 일반 요청은 재발급하지 않는다.
**영향 범위**
- 인터페이스: HTTP response와 browser
- 영속성: browser cookie
- 후속 사용 주체: browser/Streamlit handshake
- 마이그레이션: 없음
- 호환성: host-only cookie
- 배포 전제 조건: 공개 origin HTTPS
### REQ-001.07 — Invalid cookie cleanup
없음·malformed·oversized·tampered·expired·claim-invalid·rotation-invalid 또는 configuration-failure cookie는 보호 자원에 접근하기 전에 거부한다. 같은 origin cleanup route를 통해 동일 name/path/security 속성과 `Max-Age=0` deletion을 반환한 뒤 unauthenticated page를 표시한다.
**영향 범위**
- 인터페이스: `GET /api/auth/cleanup`, `dashboard-auth-gate`
- 영속성: 서버 session/registry 없음
- 후속 사용 주체: browser
- 마이그레이션: 없음
- 호환성: stale cookie가 남지 않음
- 배포 전제 조건: same-origin HTTPS
### REQ-001.08 — Login, cleanup, logout
Login과 logout은 trusted same-origin HTTPS POST만 허용하고 cleanup GET은 same-origin HTTPS `Referer`를 검증한다. logout은 명시적으로 matching deletion을 반환한다. cleanup과 logout은 금융 DB를 변경하지 않는다.
**영향 범위**
- 인터페이스: `POST /api/auth/login`, `GET /api/auth/cleanup`, `POST /api/auth/logout`
- 영속성: 없음
- 후속 사용 주체: browser
- 마이그레이션: 없음
- 호환성: root redirect 유지
- 배포 전제 조건: proxy가 Host/Origin/Proto 보존
### REQ-001.09 — Rotation과 무상태성
Token claim은 `v`, 고정 `sub`, `iat`, `exp`, `jti`만 허용한다. password, signing secret 또는 admin ID 변경은 기존 token을 거부한다. per-token revocation이나 DB session은 만들지 않는다.
**영향 범위**
- 인터페이스: token verifier
- 영속성: revocation/session 저장소 없음
- 후속 사용 주체: 운영 rotation 절차
- 마이그레이션: 없음
- 호환성: 전체 무효화는 rotation으로 수행
- 배포 전제 조건: 모든 replica 동일 설정
### REQ-001.10 — 보호 범위와 운영
인증 후 기존 page, query parameter, gateway와 MCP 동작을 유지한다. Compose/Kubernetes는 실제 secret을 저장하지 않으며 HTTPS, forwarded-header allowlist와 rate limit을 적용한다.
**영향 범위**
- 인터페이스: Dashboard 운영 진입점
- 영속성: 기존 SQLite만
- 후속 사용 주체: 운영 배포
- 마이그레이션: DB migration 없음
- 호환성: MCP endpoint는 별도 계약
- 배포 전제 조건: TLS와 제한된 proxy CIDR
### REQ-001.11 — 관찰 가능성 ### REQ-001.11 — 관찰 가능성
Event log는 event name과 비민감 correlation ID만 기록한다. 허용 event에는 설정 오류, 인증 필요, token 수락, login 성공/실패/reject, logout이 포함된다. ID/password/secret/token/cookie/request body와 구체적인 검증 실패 원인은 기록하지 않는다. 설정 오류, 인증 필요, token 수락, login 성공/실패/reject, cleanup, logout event와 비민감 correlation ID만 기록한다. 민감 값과 구체적 검증 원인은 기록하지 않는다.
## 6. 인터페이스 요약 **영향 범위**
- `POST /api/auth/login`: `admin_id`, `admin_password`, optional `remember_me=on` - 인터페이스: event log
- `POST /api/auth/logout`: 입력 credential 없음 - 영속성: 로그에 비민감 메타데이터만
- Cookie: `__Host-jarca_dashboard_auth` - 후속 사용 주체: 운영 관찰 도구
- 설정: 세 `JARCA_DASHBOARD_*` environment variable - 마이그레이션: 없음
- 상세 계약: `docs/interface-contract.md` - 호환성: 일반 오류 메시지 유지
- 배포 전제 조건: redaction 정책
## 7. 오류 계약 ## 6. 제약과 불변조건
| 조건 | 사용자 결과 | Cookie | DB/gateway/page | - `sub`는 비민감 고정 role 값이며 credential에서 파생하거나 관리자 ID를 담지 않는다.
|---|---|---|---| - credential, secret, token, cookie 값은 저장·출력·문서화하지 않는다.
| 설정 invalid | 일반 설정 오류 | 발급 없음 | 차단 | - 인증 성공 전 DB/gateway/page dispatch는 실행하지 않는다.
| credential invalid | 최소 1초 후 일반 인증 오류 | 발급 없음 | 차단 | - 만료는 absolute이며 활동으로 연장하지 않는다.
| request malformed/cross-site | 400/403 | 발급 없음 | 차단 | - invalid cookie cleanup과 explicit logout의 deletion은 발급 cookie와 matching scope를 사용한다.
| cookie 없음·malformed·tampered·expired | 로그인 화면 | 수락 없음 | 차단 | - 서버는 per-token revocation과 DB-backed session을 유지하지 않는다.
| subject/TTL/time/rotation 불일치 | 로그인 화면 | 수락 없음 | 차단 |
| logout | root redirect | deletion | 금융 상태 무변경 |
## 8. 수용 기준 ## 7. 인수 기준
- [x] AC-001 인증 전 모든 DB/gateway/page 기능이 차단된다. ### REQ-001.01-AC01 — 인증 전 차단
- [x] AC-002 세 environment setting invalid 상태는 fail-closed한다.
- [x] AC-003 ID/password를 constant-time 비교하고 일반 오류와 최소 1초 지연을 적용한다.
- [x] AC-004 정상 login은 24시간 absolute token/cookie를 발급한다.
- [x] AC-005 기본 미선택 remember-me login은 30일 absolute token/cookie를 발급한다.
- [x] AC-006 Cookie는 `__Host-`, Secure, HttpOnly, SameSite=Lax, Path=/, no Domain이다.
- [x] AC-007 일반 요청·page 이동·복원은 token을 재발급하거나 만료를 연장하지 않는다.
- [x] AC-008 유효 cookie가 새 Streamlit session과 sidebar query page에서 인증을 복원한다.
- [x] AC-009 malformed, oversized, tampered, expired, schema-invalid token은 DB 전에 거부된다.
- [x] AC-010 password, signing secret 또는 관리자 ID rotation이 기존 token을 무효화한다.
- [x] AC-011 logout은 browser cookie를 삭제하고 금융 상태를 변경하지 않는다.
- [x] AC-012 credential, secret, token이 session state·URL·log·문서에 노출되지 않는다.
- [x] AC-013 same-origin HTTPS POST가 아닌 login/logout 요청은 거부된다.
- [x] AC-014 인증 후 기존 Dashboard page와 MCP behavior가 유지된다.
- [x] AC-015 실제 loopback HTTPS와 Streamlit WebSocket에서 cookie 발급·복원·삭제를 검증한다.
- [x] AC-016 금융 DB schema와 data model migration을 추가하지 않는다.
- [x] AC-017 HTTPS, rate-limit, rotation과 stateless logout 제약이 운영 문서에 기록된다.
## 9. 검증 매핑 **판정 기준**
- Credential/delay/log: `tests/test_dashboard_authentication.py` 모든 인증 거부 경로에서 DB, gateway, page dispatch가 실행되지 않는다.
- 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. 추적성 **검증**
- 최초 요구 Issue: `jsfamily/jarca-ledger-mcp#1` AppTest와 gate ordering 테스트에서 database access sentinel이 호출되지 않음을 확인한다.
- 지속 로그인 변경 Issue: `jsfamily/jarca-ledger-mcp#6`
- 최초 구현 PR: `#3` **검증 분류**
- Issue #6 구현 PR: `#7`
- Issue #6 기준 revision: `7bea8b368e02e98afd4b2dc463fb270b0e6eb297` - 수준: integration
- Issue #6 승인 snapshot body SHA-256: `4f734169ec896747fe39fd7f3ab75d1fc0d8cdd4903045e665beba40d80eb1fe` - 백엔드: Streamlit AppTest
- 케이스: missing config, no cookie, invalid cookie
**상태 불변조건**
거부 시 금융 상태와 DB는 변경되지 않는다.
### REQ-001.02-AC01 — 설정 fail-closed
**판정 기준**
세 설정 중 하나라도 유효하지 않으면 일반 설정 오류로 인증을 거부한다.
**검증**
설정별 누락·빈 값·짧은 secret 테스트를 실행한다.
**검증 분류**
- 수준: unit/integration
- 백엔드: pytest
- 케이스: 각 환경변수 누락, 빈 값, short secret
**상태 불변조건**
cookie 발급과 보호 자원 접근이 없다.
### REQ-001.03-AC01 — 민감정보 비노출
**판정 기준**
credential, secret, token, cookie가 UI·URL·log·session state에 나타나지 않는다.
**검증**
로그·응답·session state와 문서를 redaction 검색한다.
**검증 분류**
- 수준: static/integration
- 백엔드: pytest, ruff, repository search
- 케이스: 성공·실패 login과 invalid cookie
**상태 불변조건**
비민감 correlation ID 외 인증 비밀 상태가 없다.
### REQ-001.04-AC01 — 실패 지연과 일반 오류
**판정 기준**
잘못된 ID와 잘못된 password가 같은 일반 오류를 반환하고 각각 최소 1초 지연된다.
**검증**
두 실패 경로의 elapsed time과 response를 측정한다.
**검증 분류**
- 수준: integration
- 백엔드: pytest/HTTP client
- 케이스: wrong ID, wrong password
**상태 불변조건**
cookie·DB·gateway 상태가 변하지 않는다.
### REQ-001.05-AC01 — Absolute TTL과 고정 subject
**판정 기준**
기본 24시간과 remember-me 30일 token의 `iat`, `exp`, `sub=dashboard-admin`이 고정된다.
**검증**
token claim, TTL, rerun/non-sliding 테스트를 실행한다.
**검증 분류**
- 수준: unit/integration
- 백엔드: pytest
- 케이스: normal, remember-me, activity, reconnect
**상태 불변조건**
복원은 token을 갱신하지 않는다.
### REQ-001.06-AC01 — Hardened cookie
**판정 기준**
발급 cookie가 이름·Secure·HttpOnly·SameSite·Path·Domain 계약을 모두 만족한다.
**검증**
실제 HTTP `Set-Cookie`를 파싱하고 속성을 검사한다.
**검증 분류**
- 수준: integration
- 백엔드: Starlette TestClient와 loopback HTTPS
- 케이스: login normal/remember
**상태 불변조건**
Domain attribute가 없고 평문 전송에서 발급되지 않는다.
### REQ-001.07-AC01 — Invalid cookie cleanup
**판정 기준**
invalid/expired/tampered/config-failure cookie는 보호 자원 전에 거부되고 same-origin cleanup route의 matching deletion 후 unauthenticated page가 표시된다.
**검증**
실제 browser/HTTP 흐름에서 각 invalid cookie와 cleanup response를 확인한다.
**검증 분류**
- 수준: integration/browser
- 백엔드: HTTPS probe와 Streamlit browser evidence
- 케이스: malformed, expired, tampered, wrong claims, config failure
**상태 불변조건**
cleanup은 DB를 변경하지 않으며 stale cookie가 남지 않는다.
### REQ-001.08-AC01 — Explicit logout
**판정 기준**
logout POST가 matching `Max-Age=0` deletion을 반환하고 다음 접근이 unauthenticated다.
**검증**
login 후 logout response와 fresh request를 확인한다.
**검증 분류**
- 수준: integration/browser
- 백엔드: HTTPS probe
- 케이스: same-origin logout, cross-origin rejection
**상태 불변조건**
금융 DB와 token registry는 변경되지 않는다.
### REQ-001.09-AC01 — Rotation과 무상태성
**판정 기준**
configured credential 변경은 기존 token을 거부하며 per-token revocation/DB session 구현이 없다.
**검증**
rotation 테스트와 source/schema/diff 검색을 실행한다.
**검증 분류**
- 수준: unit/static
- 백엔드: pytest와 repository search
- 케이스: password, signing secret, admin ID rotation
**상태 불변조건**
검증은 현재 설정과 서명만 사용한다.
### REQ-001.10-AC01 — 운영 제약
**판정 기준**
README와 Kubernetes 문서에 HTTPS, proxy allowlist, rate limit, secret 주입, rotation과 stateless logout이 기록된다.
**검증**
문서 검색과 deployment manifest 검토를 실행한다.
**검증 분류**
- 수준: documentation/static
- 백엔드: repository search
- 케이스: README, deploy/k8s/README.md
**상태 불변조건**
실제 credential·secret 값이 문서와 manifest에 없다.
### REQ-001.11-AC01 — 안전한 관찰성
**판정 기준**
허용 event와 correlation ID만 기록되고 민감 값·구체적 검증 원인은 기록되지 않는다.
**검증**
로그 테스트와 redaction 검색을 실행한다.
**검증 분류**
- 수준: unit/static
- 백엔드: pytest와 repository search
- 케이스: config, login, cleanup, logout success/failure
**상태 불변조건**
로그에서 credential·secret·token·cookie가 복원되지 않는다.
## 8. 오류 및 충돌 계약
동일 요청에서 설정 오류가 credential 오류보다 우선하며, 모든 인증 실패는 보호 자원 전에 종료된다. invalid cookie는 cleanup deletion을 시도하고, cleanup route가 거부되거나 실패해도 보호 자원은 열지 않는다. cross-origin/plain HTTP 요청은 403, malformed request는 400이다. 동시 login은 application lock 없이 독립 처리한다.
## 9. 데이터 및 인터페이스 계약
Token은 `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti`와 서명만 가지며 browser cookie에만 저장된다. 세 환경변수는 server process에서만 읽는다. 상세 HTTP, cookie, cleanup, logout 계약은 `docs/interface-contract.md`에 정의한다.
## 10. 하위 호환성
Issue #1의 Dashboard-only 범위, 기존 page/query/gateway/MCP 동작과 구현 PR #3의 추적성을 유지한다. session-only 인증은 persistent cookie로 대체하며 SQLite schema와 MCP transport는 변경하지 않는다.
## 11. 추적성
- 근거 이슈: `#1`, `#6`
- 구현 PR: `#3`, `#7`
- 관련 설계 문서: `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flow.md`, `README.md`, `deploy/k8s/README.md`
- 관련 요구사항: 없음
+84 -61
View File
@@ -1,89 +1,112 @@
# Runtime Flow — Dashboard Persistent Authentication # Runtime Flow — Dashboard 관리자 인증
## 목적과 범위 ## 목적과 범위
Dashboard startup, login token 발급, 새 browser session의 인증 복원, 실패 차단과 logout cookie 삭제 순서를 정의한다. ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit logout까지의 실행 순서와 상태 계약을 정의한다.
- 포함: ASGI startup, config, login/logout POST, signed cookie, Streamlit gate, DB 이전 차단 - 포함 범위: config, HTTPS routes, cookie verification, Streamlit gate, cleanup/deletion
- 제외: MCP 요청, token registry, OAuth/OIDC, external auth middleware - 제외 범위: MCP request authentication, DB session/token registry, per-token revocation
- 시작 조건: trusted ASGI process receives request
- 성공 종료 상태: authenticated Dashboard page executes
- 실패 종료 상태: matching cookie deletion and unauthenticated login page
## 정상 흐름 ## 흐름
### START — ASGI server 시작 ### startup — ASGI startup
| 단계 | 주체 | 처리 | 다음 | - 시작 이벤트: process startup
|---|---|---|---| - 선행조건: deployment injects configuration without recording values
| `START-01` | Uvicorn | `dashboard.server:app` 로드 | `START-02` | - 실행 방식: synchronous startup/request handling
| `START-02` | Streamlit `App` | custom auth routes와 `dashboard/app.py` runtime 조립 | `UNAUTHENTICATED` | - 트랜잭션·영속성 경계: no auth persistence; existing financial SQLite is untouched
운영 command는 `uvicorn dashboard.server:app --host 0.0.0.0 --port 8501`이다. | 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|---|---|---|---|---|---|---|---|
| `startup-01` | Uvicorn | process command | load `dashboard.server:app` | process memory only | none | `startup-02` | `startup-fail` |
| `startup-02` | ASGI app | app loaded | mount auth routes and Streamlit runtime | route table only | none | `gate-01` | `startup-fail` |
### LOGIN — Credential 제출과 token 발급 ### login — Credential login
| 단계 | 주체 | 입력/처리 | 상태·외부 효과 | 다음 | - 시작 이벤트: same-origin HTTPS form POST
|---|---|---|---|---| - 선행조건: valid trusted scheme, origin and bounded body
| `LOGIN-01` | Browser | Streamlit이 렌더링한 HTML form을 same-origin HTTPS POST | credential은 request body에만 존재 | `LOGIN-02` | - 실행 방식: synchronous request
| `LOGIN-02` | `auth_server` | Origin, content type, body size와 field cardinality 검증 | 금융 상태 없음 | `LOGIN-03` 또는 `LOGIN-REJECT` | - 트랜잭션·영속성 경계: no DB transaction; cookie is browser state
| `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` |
기본 `exp-iat`는 86400초, remember-me는 2592000초다. 이 값은 발급 시 고정된다. | 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|---|---|---|---|---|---|---|---|
| `login-01` | auth server | POST `/api/auth/login` | validate scheme/origin/body | request only | none | `login-02` | `login-reject` |
| `login-02` | authentication | validated form | load three settings, fail closed | server memory | secret provider/environment | `login-03` | `config-fail` |
| `login-03` | authentication | configured settings | constant-time compare ID/password | no persistent write | none | `login-04` | `login-fail` |
| `login-04` | authentication | valid credential | issue signed claims with `sub=dashboard-admin` | server memory | none | `login-05` | `login-fail` |
| `login-05` | auth server | signed token | set hardened cookie and redirect | browser cookie write | response | `gate-01` | `login-fail` |
### RESTORE — 새 session·재접속 인증 복원 ### restore — Existing cookie restoration
| 단계 | 주체 | 입력/처리 | 상태 | 다음 | - 시작 이벤트: new request/session with browser cookie
|---|---|---|---|---| - 선행조건: cookie present or absent; current configuration available
| `RESTORE-01` | Browser | Streamlit initial request/WebSocket handshake에 host cookie 전송 | HttpOnly token | `GATE-01` | - 실행 방식: synchronous gate
| `GATE-01` | `dashboard.app` | page config 직후 authentication gate 호출 | DB 미접근 | `GATE-02` | - 트랜잭션·영속성 경계: no server-side session; financial DB begins only after gate success
| `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` |
각 rerun은 cookie를 현재 설정으로 다시 검증한다. 일반 요청은 `iat`, `exp`, cookie `Max-Age`를 연장하지 않는다. | 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|---|---|---|---|---|---|---|---|
| `gate-01` | dashboard app | page startup | call authentication before DB path/page dispatch | no DB access | none | `gate-02` | `unauthenticated` |
| `gate-02` | authentication | current config and cookie | verify signature, exact claims, fixed role subject, TTL and rotation | no session write | none | `dashboard-01` | `cleanup-01` |
| `dashboard-01` | dashboard app | accepted token | resolve DB path and dispatch requested page | existing SQLite reads/writes | gateway/page code | `authenticated` | `dashboard-fail` |
### LOGOUT — Browser cookie 삭제 ### cleanup — Invalid cookie cleanup
| 단계 | 주체 | 처리 | 다음 | - 시작 이벤트: gate rejects malformed, expired, tampered, claim-invalid, or config-failure cookie
|---|---|---|---| - 선행조건: same-origin HTTPS cleanup request
| `LOGOUT-01` | Browser | same-origin HTTPS POST `/api/auth/logout` | `LOGOUT-02` | - 실행 방식: synchronous request
| `LOGOUT-02` | `auth_server` | matching name/path/security 속성과 `Max-Age=0` response | `LOGOUT-03` | - 트랜잭션·영속성 경계: no DB or token registry write
| `LOGOUT-03` | Browser | cookie 삭제 후 `/` 재접속 | `UNAUTHENTICATED` |
Logout은 DB를 변경하지 않는다. 복사된 stateless token의 개별 server-side revocation은 없으며 password/signing-secret rotation이 전체 기존 token을 무효화한다. | 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|---|---|---|---|---|---|---|---|
| `cleanup-01` | browser/auth gate | invalid cookie | navigate to `GET /api/auth/cleanup` | request only | auth route | `cleanup-02` | `cleanup-reject` |
| `cleanup-02` | auth server | same-origin HTTPS `Referer` | emit matching deletion (`Max-Age=0`) and root redirect | browser cookie deletion | response | `unauthenticated` | `cleanup-reject` |
### logout — Explicit logout
- 시작 이벤트: user submits logout form
- 선행조건: same-origin HTTPS POST
- 실행 방식: synchronous request
- 트랜잭션·영속성 경계: no financial DB change and no per-token revocation
| 단계 ID | 실행 주체 | 시작 조건·입력 | 처리 | 읽기·쓰기 상태 | 외부 호출 | 성공 시 다음 단계 | 실패 시 전이 |
|---|---|---|---|---|---|---|---|
| `logout-01` | browser | POST `/api/auth/logout` | validate same-origin HTTPS | request only | none | `logout-02` | `logout-reject` |
| `logout-02` | auth server | validated request | matching deletion and root redirect | browser cookie deletion | response | `unauthenticated` | `logout-reject` |
## 실패와 복구 ## 실패와 복구
| 실패 상태 | 원인 | 차단 범위 | 복구 | | 대상 단계 | 실패 조건 | 추가 쓰기 중단 | 재시도 계약 | 중복 방지 | 보상·수동 복구 | 최종 상태 |
|---|---|---|---| |---|---|---|---|---|---|---|
| `CONFIG-FAIL` | ID/password/signing secret 누락·invalid·read error | DB/gateway/page 전체 | 올바른 secret 주입 후 rerun | | `login-02` | missing/empty/read-error/short secret | yes | fix deployment config | no cookie | operator injects secret | `config-fail` |
| `LOGIN-REJECT` | cross-site, malformed, oversized POST | cookie 발급 | same-origin 정상 form 재시도 | | `login-03` | wrong ID/password | yes | retry after generic error and 1s delay | no lock | external rate limit | `login-fail` |
| `LOGIN-FAIL` | ID 또는 password 불일치 | cookie 발급·DB 접근 | 최소 1초 후 일반 오류, 재시도 | | `gate-02` | invalid/expired/tampered/claim/rotation failure | yes | cleanup then login | matching deletion | user logs in again | `unauthenticated` |
| `TOKEN-FAIL` | absent/malformed/tampered/expired/wrong claims/rotation | DB/gateway/page 전체 | 재로그인 | | `cleanup-02` | non-HTTPS/cross-origin | yes | retry same origin | deletion is idempotent | browser clears manually if needed | `cleanup-reject` |
| `DASHBOARD-FAIL` | 인증 이후 기존 page/DB 오류 | 기존 page 계약 | 기존 운영 복구 | | `logout-02` | non-HTTPS/cross-origin | yes | retry same origin | deletion is idempotent | browser clears manually if needed | `logout-reject` |
Invalid token의 세부 원인을 UI에서 구분하지 않는다. Credential, secret, token, cookie header 또는 exception 원문을 log에 넣지 않는다. ### 동시성과 순서
## 동시성·순서 - 동시 실행 허용 여부: independent requests allowed; no application-wide auth lock
- 잠금·직렬화 단위: none for authentication; existing SQLite rules remain for financial writes
- 서로 다른 browser session은 같은 signed token을 보낼 수 있다. - 순서 보장: config/cookie gate precedes DB path, gateway and page dispatch
- Token verification은 stateless이고 shared mutable authentication state를 만들지 않는다. - 충돌 검출·처리: token is stateless; password/signing-secret/admin-ID rotation invalidates old claims
- 설정 및 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는 event와 비민감 correlation ID만 포함한다. Token, credential, origin 세부값, request body와 cookie header는 기록하지 않는다. | event log | `authentication_required` | `gate-01` rejects | non-sensitive correlation ID | event class only | no cookie/token/reason |
| event log | `login_succeeded` | `login-05` | non-sensitive correlation ID | TTL class only | no credential/token |
| event log | `login_failed` | `login-03` | non-sensitive correlation ID | generic failure class | no ID/password |
| event log | `cleanup` | `cleanup-02` | non-sensitive correlation ID | generic cleanup class | no cookie header |
| event log | `logout` | `logout-02` | non-sensitive correlation ID | operation result | no token/cookie value |
## 검증 ## 검증
- Normal/remember TTL 및 non-sliding: token unit tests - 정상 흐름: auth server tests plus loopback HTTPS login and fresh session restoration
- Tamper/expiry/rotation/schema rejection: token unit tests - 실패 흐름: configuration, wrong credential, malformed/tampered/expired cookie and origin rejection tests
- HTTP response cookie와 logout deletion: auth-server tests - 경계 흐름: TTL boundary, rotation, oversized token, same-host different-port origin
- Fail-closed DB-before-gate ordering: AppTest - 동시성 흐름: independent failed submissions and no application lock; ingress rate-limit configuration review
- 실제 loopback HTTPS login/reconnect/logout와 cookie 속성: HTTP evidence script - 운영 진입점과 기대 결과: `uvicorn dashboard.server:app --host 0.0.0.0 --port 8501`; valid HTTPS reaches login or authenticated page, invalid cookie receives matching deletion before unauthenticated page
- MCP behavior와 DB schema가 변경되지 않았음을 full regression과 diff로 확인
+29
View File
@@ -176,6 +176,35 @@ def test_logout_clears_cookie_with_matching_scope(client: TestClient) -> None:
assert "Path=/" in header assert "Path=/" in header
def test_cleanup_route_clears_cookie_and_redirects(client: TestClient) -> None:
response = client.get(
"/api/auth/cleanup",
headers={"referer": "https://testserver/"},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/"
header = response.headers["set-cookie"]
assert f"{authentication.AUTH_COOKIE_NAME}=" in header
assert "Max-Age=0" in header
assert "HttpOnly" in header
assert "Secure" in header
assert "SameSite=lax" in header
assert "Path=/" in header
def test_cleanup_route_rejects_cross_origin_navigation(client: TestClient) -> None:
response = client.get(
"/api/auth/cleanup",
headers={"referer": "https://attacker.example/"},
follow_redirects=False,
)
assert response.status_code == 403
assert "set-cookie" not in response.headers
def test_cookie_token_round_trip(client: TestClient) -> None: def test_cookie_token_round_trip(client: TestClient) -> None:
response = client.post( response = client.post(
"/api/auth/login", "/api/auth/login",
@@ -108,6 +108,13 @@ def test_invalid_cookie_blocks_database_and_page_dispatch(monkeypatch):
assert not at.exception assert not at.exception
assert "관리자 로그인" in _body_text(at) assert "관리자 로그인" in _body_text(at)
assert "이번 달 수입/지출 현황" not in _body_text(at) assert "이번 달 수입/지출 현황" not in _body_text(at)
assert "/api/auth/cleanup" in _body_text(at)
def test_no_cookie_does_not_render_cookie_cleanup_navigation(monkeypatch):
at = _app_with_cookie(monkeypatch, None)
assert "/api/auth/cleanup" not in _body_text(at)
def test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state( def test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state(
@@ -153,3 +160,24 @@ def test_password_rotation_invalidates_existing_cookie(monkeypatch, tmp_path):
assert "관리자 로그인" in _body_text(at) assert "관리자 로그인" in _body_text(at)
assert "이번 달 수입/지출 현황" not in _body_text(at) assert "이번 달 수입/지출 현황" not in _body_text(at)
def test_signing_secret_rotation_invalidates_existing_cookie(monkeypatch, tmp_path):
from scripts.ci_fixture_db import create_ci_fixture_db
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
settings = _configure(monkeypatch)
token = authentication.issue_persistent_token(settings, remember_me=True).value
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
at = AppTest.from_file(str(APP_PATH), default_timeout=30).run()
assert "이번 달 수입/지출 현황" in _body_text(at)
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "r" * 64)
at = at.run()
assert "관리자 로그인" in _body_text(at)
assert "/api/auth/cleanup" in _body_text(at)
assert "이번 달 수입/지출 현황" not in _body_text(at)
@@ -150,14 +150,30 @@ def test_oversized_and_malformed_tokens_are_rejected() -> None:
def test_payload_does_not_encode_secrets() -> None: def test_payload_does_not_encode_secrets() -> None:
settings = replace(_settings(), admin_id="configured-admin-identity")
issued = authentication.issue_persistent_token( issued = authentication.issue_persistent_token(
_settings(), settings,
remember_me=False, remember_me=False,
now=1_700_000_000, now=1_700_000_000,
token_id="fixed-token-id", token_id="fixed-token-id",
) )
payload = authentication._serializer_for(_settings()).loads(issued.value) payload = authentication._serializer_for(settings).loads(issued.value)
assert set(payload) == {"v", "sub", "iat", "exp", "jti"} assert set(payload) == {"v", "sub", "iat", "exp", "jti"}
assert "password" not in json.dumps(payload).lower() assert "password" not in json.dumps(payload).lower()
assert "secret" not in json.dumps(payload).lower() assert "secret" not in json.dumps(payload).lower()
assert settings.admin_id not in json.dumps(payload)
def test_remember_me_token_expires_at_exact_30_day_boundary() -> None:
settings = _settings()
issued = authentication.issue_persistent_token(
settings, remember_me=True, now=1_700_000_000, token_id="boundary-token-id"
)
assert authentication.verify_persistent_token(
settings, issued.value, now=issued.expires_at - 1
) is not None
assert authentication.verify_persistent_token(
settings, issued.value, now=issued.expires_at
) is None