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