21 Commits
Author SHA1 Message Date
gitea-release-agent ffd943fe65 chore(release): prepare v0.1.3
Merge the independently reviewed v0.1.3 release preparation.
2026-10-04 07:34:51 +00:00
genichin 28527a0914 [verified] chore(release): prepare v0.1.3 2026-10-04 16:32:23 +09:00
genichin 6fe2594c09 Merge pull request 'feat(dashboard): persist administrator login' (#7) from gitea-agent/issue-6-dashboard-persistent-auth into main
Reviewed-on: jsfamily/jarca-ledger-mcp#7
Reviewed-by: gitea-spec-agent <gitea-spec-agent@mail.jsfamily.quest>
Reviewed-by: gitea-review-agent <gitea-review-agent@mail.jsfamily.quest>
2026-10-04 07:13:11 +00:00
gitea-impl-agent 6a35775cca [verified] docs(evidence): record F-003 verification 2026-10-04 03:56:53 +00:00
gitea-impl-agent d84ed8ff2a [verified] fix(auth): support Unicode credentials 2026-10-04 03:53:47 +00:00
gitea-impl-agent 755c26865c docs(auth): preserve login runtime evidence 2026-10-03 00:17:44 +00:00
gitea-impl-agent e7242a89f7 fix(auth): bound asynchronous login handling 2026-10-02 12:28:52 +00:00
gitea-impl-agent 3311866431 docs(auth): restore successful login contract 2026-10-02 07:35:01 +00:00
gitea-impl-agent 8544ea30f4 docs(auth): restore legacy acceptance contracts 2026-10-02 06:09:11 +00:00
gitea-impl-agent 7056e012a7 docs(auth): complete persistent token contracts 2026-10-02 04:48:06 +00:00
gitea-impl-agent 9bc64699cb docs(evidence): bind persistent auth browser run 2026-10-02 03:04:24 +00:00
gitea-impl-agent 7bb959859a fix(dashboard): address persistent auth review 2026-10-02 03:02:23 +00:00
gitea-impl-agent 91e106d75c docs(requirements): link persistent auth PR 2026-10-01 13:58:32 +00:00
gitea-impl-agent c5eaddb4f3 feat(dashboard): persist administrator login 2026-10-01 13:56:22 +00:00
gitea-release-agent 7bea8b368e feat(deploy): run v0.1.2 as non-root
Merge the independently reviewed non-root image and hardened k3s deployment contract.
2026-09-30 11:44:59 +00:00
genichin ee3e3852ee [verified] feat(deploy): run v0.1.2 as non-root 2026-09-30 20:41:56 +09:00
gitea-release-agent bf746e0cf3 chore(release): reconcile v0.1.1 with main
Integrate the verified v0.1.1 release history before v0.1.2 preparation.
2026-09-30 08:06:38 +00:00
genichin 7a120bb068 chore(release): prepare v0.1.1 2026-09-30 16:03:30 +09:00
genichin 18384c0e0b Merge pull request 'feat(dashboard): add administrator authentication' (#3) from gitea-impl-agent/jarca-ledger-mcp:gitea-agent/issue-1-dashboard-admin-auth into main
Reviewed-on: genichin/jarca-ledger-mcp#3
Reviewed-by: gitea-spec-agent <gitea-spec-agent@mail.jsfamily.quest>
2026-09-28 06:51:10 +00:00
gitea-impl-agent 3e0da954be docs(requirements): link dashboard authentication PR 2026-09-25 14:44:23 +00:00
gitea-impl-agent 7c2a6bdcd3 feat(dashboard): add administrator authentication 2026-09-25 14:40:19 +00:00
34 changed files with 3404 additions and 179 deletions
+7
View File
@@ -1,2 +1,9 @@
# Optional. Defaults to ~/.local/share/jarca/ledger.sqlite3
JARCA_LEDGER_DB_PATH=
# Required for the Streamlit Dashboard. Inject real values from a secret store.
JARCA_DASHBOARD_ADMIN_ID=
JARCA_DASHBOARD_ADMIN_PASSWORD=
JARCA_DASHBOARD_SIGNING_SECRET=
# Optional Uvicorn proxy trust allowlist. Keep loopback unless a reviewed HTTPS proxy is used.
JARCA_DASHBOARD_FORWARDED_ALLOW_IPS=127.0.0.1
+102
View File
@@ -0,0 +1,102 @@
schema: gitea-agent/release/v1
version:
sources:
- path: pyproject.toml
format: toml
selector: project.version
- path: jarca_ledger_mcp/__init__.py
format: python-assignment
selector: __version__
lockfiles:
- path: uv.lock
package: jarca-ledger-mcp
tag_template: "v{version}"
prepare:
runtime:
python: "3.12"
commands:
- name: sync
argv: [uv, sync, --python, "3.12", --frozen, --extra, dev, --extra, dashboard]
- name: lint
argv: [uv, run, ruff, check, .]
- name: test
argv: [uv, run, pytest]
- name: dashboard-http-smoke
argv: [uv, run, python, scripts/ci_dashboard_http_smoke.py, --host, 127.0.0.1, --port, "18501", --timeout, "45"]
- name: db-safety-smoke
argv: [uv, run, python, scripts/ci_db_safety_smoke.py, --timeout, "45"]
artifacts:
- type: container
name: jarca-ledger-mcp
platform: linux/arm64
build:
adapter: docker-buildx
context: .
dockerfile: Dockerfile
pull: true
local_tag_template: "jarca-ledger-mcp:release-{commit}"
labels:
org.opencontainers.image.source: "https://gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp"
org.opencontainers.image.revision: "{commit}"
org.opencontainers.image.version: "{version}"
smoke:
- type: image-platform
expected_os: linux
expected_architecture: arm64
- type: container-command
environment:
JARCA_LEDGER_DB_PATH: /data/ledger.sqlite3
argv:
- python
- -c
- >-
import os, pathlib, platform; from jarca_ledger_mcp import service;
assert os.getuid() == 1000, os.getuid();
assert os.getgid() == 1000, os.getgid();
probe = pathlib.Path("/data/.write-probe");
probe.write_text("ok"); probe.unlink();
result = service.health_check();
assert platform.machine() == "aarch64";
assert pathlib.Path("/data/ledger.sqlite3").is_file();
assert result["success"], result
- type: container-health
name_template: "jarca-mcp-{run_id}"
command: default
expected: healthy
timeout_seconds: 90
- type: container-http
name_template: "jarca-dashboard-{run_id}"
command:
- uvicorn
- dashboard.server:app
- --host
- 0.0.0.0
- --port
- "8501"
- --proxy-headers
container_port: 8501
path: /_stcore/health
expected_status: 200
expected_body: ok
timeout_seconds: 90
publish:
adapter: gitea-container-registry
registry: gitea.jsfamily.quest
repository: jsfamily/jarca-ledger-mcp
credentials:
username: authenticated-gitea-login
token_env: GITEA_TOKEN
tags:
- "{tag}"
- "sha-{commit}"
overwrite: false
verify:
- digest
- platform
- org.opencontainers.image.revision
-134
View File
@@ -1,134 +0,0 @@
name: Verify and publish ARM64 image
on:
pull_request:
paths:
- Dockerfile
- .dockerignore
- pyproject.toml
- uv.lock
- jarca_ledger_mcp/**
- dashboard/**
- .github/workflows/publish-image.yml
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: image-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verify-arm64:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- uses: docker/setup-buildx-action@v3
- name: Build ARM64 verification image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/arm64
load: true
push: false
tags: jarca-ledger-mcp:arm64-verify
cache-from: type=gha,scope=jarca-ledger-arm64
cache-to: type=gha,mode=max,scope=jarca-ledger-arm64
- name: Verify image platform, imports, and SQLite
run: |
set -euo pipefail
test "$(docker image inspect jarca-ledger-mcp:arm64-verify --format '{{.Architecture}}')" = arm64
docker run --rm --platform linux/arm64 \
-e JARCA_LEDGER_DB_PATH=/tmp/ledger.sqlite3 \
jarca-ledger-mcp:arm64-verify \
python -c 'import platform, cryptography, numpy, pandas, pyarrow, pymupdf; from jarca_ledger_mcp import service; result=service.health_check(); assert platform.machine() == "aarch64"; assert result["success"], result; print(result["schema_version"])'
- name: Verify MCP and Dashboard startup
run: |
set -euo pipefail
trap 'docker rm -f jarca-mcp-smoke jarca-dashboard-smoke >/dev/null 2>&1 || true' EXIT
docker run -d --name jarca-mcp-smoke --platform linux/arm64 \
jarca-ledger-mcp:arm64-verify
for _ in $(seq 1 60); do
status=$(docker inspect --format '{{.State.Health.Status}}' jarca-mcp-smoke)
test "$status" = healthy && break
test "$status" = unhealthy && { docker logs jarca-mcp-smoke; exit 1; }
sleep 1
done
test "$(docker inspect --format '{{.State.Health.Status}}' jarca-mcp-smoke)" = healthy
docker run -d --name jarca-dashboard-smoke --platform linux/arm64 \
-p 127.0.0.1::8501 jarca-ledger-mcp:arm64-verify \
python -m streamlit run dashboard/app.py \
--server.address 0.0.0.0 --server.port 8501 \
--server.headless true --browser.gatherUsageStats false
port=$(docker port jarca-dashboard-smoke 8501/tcp | sed 's/.*://')
code=000
for _ in $(seq 1 90); do
code=$(curl -sS -o /tmp/dashboard-health -w '%{http_code}' \
"http://127.0.0.1:${port}/_stcore/health" || true)
test "$code" = 200 && break
sleep 1
done
test "$code" = 200
test "$(tr -d '\n' < /tmp/dashboard-health)" = ok
publish:
if: startsWith(github.ref, 'refs/tags/v')
needs: verify-arm64
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish immutable ARM64 image
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/arm64
push: true
tags: |
ghcr.io/genichin/jarca-ledger-mcp:${{ github.ref_name }}
ghcr.io/genichin/jarca-ledger-mcp:sha-${{ github.sha }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.version=${{ github.ref_name }}
cache-from: type=gha,scope=jarca-ledger-arm64
cache-to: type=gha,mode=max,scope=jarca-ledger-arm64
provenance: mode=max
sbom: true
- name: Report published digest
run: |
echo "ghcr.io/genichin/jarca-ledger-mcp:${GITHUB_REF_NAME}@${{ steps.build.outputs.digest }}"
+13 -3
View File
@@ -11,6 +11,8 @@ ENV PYTHONUNBUFFERED=1 \
JARCA_MCP_HOST=0.0.0.0 \
JARCA_MCP_PORT=8000 \
JARCA_MCP_STREAMABLE_HTTP_PATH=/mcp \
HOME=/home/jarca \
TMPDIR=/tmp \
PATH=/app/.venv/bin:$PATH
COPY --from=uv /uv /uvx /usr/local/bin/
@@ -20,9 +22,15 @@ WORKDIR /app
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-dev --extra dashboard --no-install-project
COPY jarca_ledger_mcp ./jarca_ledger_mcp
COPY dashboard ./dashboard
RUN uv sync --frozen --no-dev --extra dashboard
RUN groupadd --gid 1000 jarca \
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/jarca --shell /usr/sbin/nologin jarca \
&& chown -R 1000:1000 /home/jarca \
&& install -d -o 1000 -g 1000 -m 0770 /data
COPY --chown=1000:1000 jarca_ledger_mcp ./jarca_ledger_mcp
COPY --chown=1000:1000 dashboard ./dashboard
RUN uv sync --frozen --no-dev --extra dashboard \
&& chmod -R a+rX /app
VOLUME ["/data"]
EXPOSE 8000 8501
@@ -30,4 +38,6 @@ EXPOSE 8000 8501
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import os, socket; s=socket.create_connection(('127.0.0.1', int(os.getenv('JARCA_MCP_PORT', '8000'))), timeout=3); s.close()"
USER 1000:1000
CMD ["jarca-ledger-mcp"]
+28 -8
View File
@@ -31,12 +31,28 @@ hermes mcp test jarca-ledger
로컬 웹 대시보드는 실제 기본 DB `~/.local/share/jarca/ledger.sqlite3`를 읽고 씁니다.
Dashboard는 모든 조회·수정·Import 기능 앞에서 단일 관리자 로그인을 요구합니다. 관리자 ID, password와 독립적인 서명 secret은 Streamlit secrets가 아니라 배포 secret이 주입하는 다음 환경변수에서만 읽습니다.
```bash
export JARCA_DASHBOARD_ADMIN_ID='<secret store에서 주입>'
export JARCA_DASHBOARD_ADMIN_PASSWORD='<secret store에서 주입>'
export JARCA_DASHBOARD_SIGNING_SECRET='<32 byte 이상의 무작위 secret을 주입>'
```
세 값 중 하나라도 누락됐거나 비어 있거나 읽을 수 없으면 Dashboard는 fail-closed하여 로그인 설정 오류만 표시하고 DB나 page view에 접근하지 않습니다. 서명 secret은 32 byte 이상이어야 합니다. 실제 credential이나 서명 secret을 `.env`, Compose 파일, shell history 또는 repository 문서에 저장하지 마세요.
```bash
cd /root/projects/jarca-ledger-mcp
. .venv/bin/activate
streamlit run dashboard/app.py --server.address 0.0.0.0 --server.port 8501
uvicorn dashboard.server:app --host 0.0.0.0 --port 8501
```
인증 성공 시 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가 아니면 거부합니다. 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는 별도 요구사항과 적절한 네트워크 경계로 보호해야 합니다.
주요 화면:
- 요약: 실제 수입/지출과 계좌 입출금을 분리 표시, 월별 현금흐름, 지출 카테고리 차트
@@ -62,7 +78,7 @@ Deployment: jarca-ledger (replicas: 1)
│ └─ containerPort: 8000
├─ frontend container
│ ├─ 동일 릴리즈 이미지
│ ├─ command: python -m streamlit run dashboard/app.py ...
│ ├─ command: uvicorn dashboard.server:app ...
│ └─ containerPort: 8501
└─ shared PVC
└─ /data/ledger.sqlite3
@@ -72,15 +88,19 @@ Deployment: jarca-ledger (replicas: 1)
- 하나의 Pod 안에서 MCP와 Streamlit을 **별도 컨테이너**로 실행합니다. 한 컨테이너에서 두 프로세스를 supervisor나 background command로 함께 실행하지 않습니다.
- 두 컨테이너는 동일한 PVC를 `/data`에 마운트하고 `JARCA_LEDGER_DB_PATH=/data/ledger.sqlite3`를 사용합니다.
- SQLite 파일을 공유하므로 Deployment의 replica는 `1`로 유지합니다.
- MCP와 frontend는 목적별 Kubernetes Service로 분리합니다. MCP는 `8000`, frontend는 `8501`을 대상으로 합니다.
- 외부 공개가 필요하면 일반적으로 frontend Service만 Ingress 또는 NodePort에 연결하고, MCP Service는 필요한 범위에서만 노출합니다.
- 이미지와 Pod는 UID/GID `1000:1000`으로 실행하며, PVC 쓰기 권한은 `fsGroup: 1000`으로 설정합니다.
- frontend 컨테이너에만 `JARCA_DASHBOARD_ADMIN_ID`, `JARCA_DASHBOARD_ADMIN_PASSWORD`, `JARCA_DASHBOARD_SIGNING_SECRET`을 Kubernetes Secret 또는 동등한 secret store에서 주입합니다. 실제 값은 manifest나 repository에 기록하지 않습니다.
- SQLite 파일을 공유하므로 Deployment의 replica는 `1`, update 전략은 `Recreate`로 유지합니다.
- Base는 Dashboard용 `ClusterIP` Service(`8501`)만 생성합니다. 인증 계층이 없는 MCP port `8000`에는 Service를 만들지 않고 `NetworkPolicy`로 ingress를 차단합니다. MCP 원격 접근이 필요하면 environment overlay에서 인증된 gateway와 제한된 ingress 정책을 함께 정의합니다.
- 외부 공개가 필요하면 Dashboard Service만 environment overlay의 Ingress 또는 NodePort에 연결합니다.
- ARM64 worker에서만 실행하도록 Pod의 `nodeSelector` 또는 node affinity를 설정합니다.
- 배포 이미지는 가변 태그 대신 검증된 GHCR digest로 고정합니다.
- 배포 이미지는 가변 태그 대신 검증된 `gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp` Gitea Container Registry digest로 고정합니다.
검증된 k3s Kustomize base는 [`deploy/k8s/base`](deploy/k8s/base)에 있으며, Secret 준비·digest pinning·배포 절차는 [`deploy/k8s/README.md`](deploy/k8s/README.md)를 따릅니다. Base에는 실제 Secret, Ingress, TLS와 cluster별 설정을 포함하지 않습니다.
Streamlit이 SQLite에 직접 접근하지 않고 MCP/API만 호출하도록 변경하거나 네트워크 DB로 전환한 뒤에는 MCP와 frontend를 각각 독립 Deployment로 분리할 수 있습니다.
로컬 `docker-compose.yml`도 동일한 애플리케이션 이미지를 두 컨테이너로 실행하는 구조입니다. 현재 Compose 설정은 로컬에서 빌드한 `jarca-ledger:latest`를 사용하며, MCP는 호스트 `8000` 포트로, Streamlit은 호스트 `40059`에서 컨테이너 `8501` 포트로 연결됩니다. GHCR 릴리즈를 배포할 때는 두 서비스의 `image`를 검증된 동일 digest로 지정해야 합니다.
로컬 `docker-compose.yml`도 동일한 애플리케이션 이미지를 두 컨테이너로 실행하는 구조입니다. 현재 Compose 설정은 로컬에서 빌드한 `jarca-ledger:latest`를 사용하며, MCP는 호스트 `8000` 포트로, Dashboard는 호스트 `40059`에서 컨테이너 `8501` 포트로 연결됩니다. Compose 실행 전 host environment 또는 안전한 `.env` 전달 경계에 세 Dashboard 인증 변수를 설정해야 하며 누락 시 Compose가 시작을 거부합니다. Browser 접근에는 HTTPS reverse proxy를 앞에 두어야 합니다. Bind mount 대상 `/volume1/docker/jarca-ledger/data`는 UID/GID `1000:1000`이 쓸 수 있어야 합니다. Gitea Container Registry 릴리즈를 배포할 때는 두 서비스의 `image`를 검증된 동일 `gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp` digest로 지정해야 합니다.
## 주요 도구
@@ -109,4 +129,4 @@ Streamlit이 SQLite에 직접 접근하지 않고 MCP/API만 호출하도록 변
- 국민은행 PDF import는 암호화 PDF 비밀번호를 입력받아 PyMuPDF로 표를 추출하고, `transaction_datetime`, `counterparty`, `balance_after`, `branch`, `raw_summary`, `raw_payload`에 원본 세부 정보를 보존합니다.
- CSV/PDF/API import는 `import_batches`에 묶음 이력을 남기고, 잘못 가져온 batch는 `rollback_ledger_import_batch`로 되돌릴 수 있습니다.
- 카테고리에는 `cashflow_kind`를 저장해 실제 가계부 수입/지출(`income`, `expense`)과 계좌이체/투자이동/분석제외(`transfer`, `investment`, `exclude`, `debt_payment`, `unknown`)를 분리합니다.
- CSV import는 먼저 preview 후 저장하는 흐름으로 확장합니다.
- CSV import는 먼저 preview 후 저장하는 흐름으로 확장합니다.
+4
View File
@@ -13,6 +13,7 @@ if __package__ in (None, ""):
st.set_page_config(page_title="Jarca Ledger", page_icon="💰", layout="wide")
from dashboard.common import clear_cache, get_db_path, get_transfer_review_items, inject_app_css, render_sidebar_nav # noqa: F401
from dashboard.authentication import require_dashboard_authentication
from dashboard.page_views.accounts import page_accounts
from dashboard.page_views.assets import page_assets
from dashboard.page_views.categories import page_categories
@@ -25,6 +26,9 @@ from dashboard.page_views.transactions import page_transactions
from dashboard.page_views.uncategorized import page_uncategorized
def main() -> None:
if not require_dashboard_authentication():
return
db_path = get_db_path()
inject_app_css()
pages = ["월정산", "요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"]
+191
View File
@@ -0,0 +1,191 @@
from __future__ import annotations
import asyncio
import secrets
import time
from urllib.parse import parse_qs, urlsplit
from starlette.requests import Request
from starlette.responses import PlainTextResponse, RedirectResponse, Response
from starlette.routing import Route
from dashboard import authentication
_MAX_LOGIN_BODY_BYTES = 4096
_MAX_CREDENTIAL_CHARS = 1024
def _redirect(location: str) -> RedirectResponse:
response = RedirectResponse(location, status_code=303)
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
return response
def _same_origin(request: Request) -> bool:
origin = request.headers.get("origin")
host = request.headers.get("host")
if not origin or not host:
return False
try:
parsed = urlsplit(origin)
authority = urlsplit(f"//{host}")
origin_port = parsed.port or 443
request_port = authority.port or 443
return (
request.url.scheme == "https"
and parsed.scheme == "https"
and parsed.hostname is not None
and authority.hostname is not None
and parsed.hostname.lower() == authority.hostname.lower()
and origin_port == request_port
and parsed.username is None
and parsed.password is None
and parsed.path in {"", "/"}
and not parsed.query
and not parsed.fragment
)
except ValueError:
return False
def _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:
return None
value = candidates[0]
if len(value) > _MAX_CREDENTIAL_CHARS:
return None
return value
async def login(request: Request) -> Response:
correlation_id = secrets.token_hex(8)
if not _same_origin(request):
authentication.log_auth_event("login_origin_rejected", correlation_id)
return PlainTextResponse("Forbidden", status_code=403)
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type != "application/x-www-form-urlencoded":
authentication.log_auth_event("login_request_rejected", correlation_id)
return PlainTextResponse("Bad Request", status_code=400)
body = bytearray()
async for chunk in request.stream():
if len(body) + len(chunk) > _MAX_LOGIN_BODY_BYTES:
authentication.log_auth_event("login_request_rejected", correlation_id)
return PlainTextResponse("Bad Request", status_code=400)
body.extend(chunk)
try:
values = parse_qs(
bytes(body).decode("utf-8"),
keep_blank_values=True,
strict_parsing=True,
max_num_fields=3,
encoding="utf-8",
errors="strict",
)
except (UnicodeDecodeError, ValueError):
authentication.log_auth_event("login_request_rejected", correlation_id)
return PlainTextResponse("Bad Request", status_code=400)
settings = authentication.load_auth_settings()
if settings is None:
authentication.log_auth_event("configuration_error", correlation_id)
return _redirect("/?auth_error=unavailable")
submitted_id = _one(values, "admin_id")
submitted_password = _one(values, "admin_password")
if submitted_id is None or submitted_password is None:
authentication.log_auth_event("login_request_rejected", correlation_id)
return _redirect("/?auth_error=invalid")
started_at = time.monotonic()
if not authentication.verify_credentials(
settings.credentials, submitted_id, submitted_password
):
await asyncio.to_thread(
authentication.enforce_failed_login_delay, started_at=started_at
)
authentication.log_auth_event("login_failed", correlation_id)
return _redirect("/?auth_error=invalid")
remember_me = _one(values, "remember_me") == "on"
issued = authentication.issue_persistent_token(
settings,
remember_me=remember_me,
)
response = _redirect("/")
response.set_cookie(
authentication.AUTH_COOKIE_NAME,
issued.value,
max_age=issued.max_age,
path="/",
secure=True,
httponly=True,
samesite="lax",
)
authentication.log_auth_event("login_succeeded", correlation_id)
return response
async def logout(request: Request) -> Response:
correlation_id = secrets.token_hex(8)
if not _same_origin(request):
authentication.log_auth_event("logout_origin_rejected", correlation_id)
return PlainTextResponse("Forbidden", status_code=403)
response = _redirect("/")
_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="/",
secure=True,
httponly=True,
samesite="lax",
)
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
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"]),
]
+320
View File
@@ -0,0 +1,320 @@
from __future__ import annotations
from collections.abc import Callable as CallableABC, Mapping, MutableMapping
from dataclasses import dataclass
import hashlib
import hmac
import logging
import os
import secrets
import time
from typing import Callable
from itsdangerous import BadData, URLSafeSerializer
import streamlit as st
logger = logging.getLogger(__name__)
ADMIN_ID_ENV = "JARCA_DASHBOARD_ADMIN_ID"
ADMIN_PASSWORD_ENV = "JARCA_DASHBOARD_ADMIN_PASSWORD"
SIGNING_SECRET_ENV = "JARCA_DASHBOARD_SIGNING_SECRET"
CONFIGURATION_ERROR_MESSAGE = "관리자 인증 설정을 확인할 수 없습니다."
INVALID_CREDENTIALS_MESSAGE = "관리자 ID 또는 비밀번호가 올바르지 않습니다."
MINIMUM_FAILURE_DELAY_SECONDS = 1.0
AUTH_CORRELATION_SESSION_KEY = "_jarca_dashboard_auth_correlation_id"
AUTH_COOKIE_NAME = "__Host-jarca_dashboard_auth"
DEFAULT_TOKEN_TTL_SECONDS = 24 * 60 * 60
REMEMBER_ME_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60
MAX_TOKEN_LENGTH = 4096
CLOCK_SKEW_SECONDS = 30
_TOKEN_VERSION = 1
_TOKEN_SALT = "jarca-dashboard-persistent-auth-v1"
_TOKEN_CONTEXT = b"jarca-dashboard-persistent-auth-v1\x00"
_TOKEN_FIELDS = frozenset({"v", "sub", "iat", "exp", "jti"})
_TOKEN_SUBJECT = "dashboard-admin"
@dataclass(frozen=True)
class AdminCredentials:
admin_id: str
password: str
@dataclass(frozen=True)
class AuthSettings:
admin_id: str
admin_password: str
signing_secret: str
@property
def credentials(self) -> AdminCredentials:
return AdminCredentials(admin_id=self.admin_id, password=self.admin_password)
@dataclass(frozen=True)
class IssuedPersistentToken:
value: str
issued_at: int
expires_at: int
max_age: int
@dataclass(frozen=True)
class VerifiedPersistentToken:
subject: str
issued_at: int
expires_at: int
token_id: str
class AuthenticationConfigurationError(RuntimeError):
"""Raised without secret details when dashboard authentication is unavailable."""
def _read_required_setting(source: Mapping[str, str], name: str) -> str:
try:
value = source.get(name)
except Exception as exc:
raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE) from exc
if not isinstance(value, str) or not value:
raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE)
return value
def load_admin_credentials(environ: Mapping[str, str] | None = None) -> AdminCredentials:
source = os.environ if environ is None else environ
return AdminCredentials(
admin_id=_read_required_setting(source, ADMIN_ID_ENV),
password=_read_required_setting(source, ADMIN_PASSWORD_ENV),
)
def require_auth_settings(environ: Mapping[str, str] | None = None) -> AuthSettings:
source = os.environ if environ is None else environ
settings = AuthSettings(
admin_id=_read_required_setting(source, ADMIN_ID_ENV),
admin_password=_read_required_setting(source, ADMIN_PASSWORD_ENV),
signing_secret=_read_required_setting(source, SIGNING_SECRET_ENV),
)
if len(settings.signing_secret.encode("utf-8")) < 32:
raise AuthenticationConfigurationError(CONFIGURATION_ERROR_MESSAGE)
return settings
def load_auth_settings(environ: Mapping[str, str] | None = None) -> AuthSettings | None:
try:
return require_auth_settings(environ)
except AuthenticationConfigurationError:
return None
def verify_credentials(
credentials: AdminCredentials, submitted_id: str, submitted_password: str
) -> bool:
if not isinstance(submitted_id, str) or not isinstance(submitted_password, str):
return False
id_matches = hmac.compare_digest(
submitted_id.encode("utf-8"), credentials.admin_id.encode("utf-8")
)
password_matches = hmac.compare_digest(
submitted_password.encode("utf-8"), credentials.password.encode("utf-8")
)
return id_matches and password_matches
def enforce_failed_login_delay(
*,
started_at: float,
minimum_seconds: float = MINIMUM_FAILURE_DELAY_SECONDS,
monotonic: Callable[[], float] = time.monotonic,
sleep: Callable[[float], None] = time.sleep,
) -> None:
remaining = minimum_seconds - (monotonic() - started_at)
if remaining > 0:
sleep(remaining)
def get_auth_correlation_id(
session_state: MutableMapping[str, object],
*,
token_hex: CallableABC[[int], str] = secrets.token_hex,
) -> str:
existing = session_state.get(AUTH_CORRELATION_SESSION_KEY)
if isinstance(existing, str) and existing:
return existing
correlation_id = token_hex(8)
session_state[AUTH_CORRELATION_SESSION_KEY] = correlation_id
return correlation_id
def log_auth_event(event: str, correlation_id: str) -> None:
logger.info("dashboard_auth event=%s correlation_id=%s", event, correlation_id)
def _derived_signing_key(settings: AuthSettings) -> bytes:
return hmac.new(
settings.signing_secret.encode("utf-8"),
_TOKEN_CONTEXT + settings.admin_id.encode("utf-8") + b"\x00" + settings.admin_password.encode("utf-8"),
hashlib.sha256,
).digest()
def _serializer_for(settings: AuthSettings) -> URLSafeSerializer:
return URLSafeSerializer(
secret_key=_derived_signing_key(settings),
salt=_TOKEN_SALT,
signer_kwargs={"digest_method": hashlib.sha256},
)
def issue_persistent_token(
settings: AuthSettings,
*,
remember_me: bool,
now: float | None = None,
token_id: str | None = None,
) -> IssuedPersistentToken:
issued_at = int(time.time() if now is None else now)
ttl = (
REMEMBER_ME_TOKEN_TTL_SECONDS if remember_me else DEFAULT_TOKEN_TTL_SECONDS
)
expires_at = issued_at + ttl
jti = secrets.token_urlsafe(16) if token_id is None else token_id
payload = {
"v": _TOKEN_VERSION,
"sub": _TOKEN_SUBJECT,
"iat": issued_at,
"exp": expires_at,
"jti": jti,
}
return IssuedPersistentToken(
value=_serializer_for(settings).dumps(payload),
issued_at=issued_at,
expires_at=expires_at,
max_age=ttl,
)
def verify_persistent_token(
settings: AuthSettings | None,
token: object,
*,
now: float | None = None,
) -> VerifiedPersistentToken | None:
if settings is None or not isinstance(token, str) or not token:
return None
if len(token) > MAX_TOKEN_LENGTH:
return None
try:
payload = _serializer_for(settings).loads(token)
except (BadData, TypeError, ValueError):
return None
if not isinstance(payload, dict) or frozenset(payload) != _TOKEN_FIELDS:
return None
version = payload.get("v")
subject = payload.get("sub")
issued_at = payload.get("iat")
expires_at = payload.get("exp")
token_id = payload.get("jti")
if (
type(version) is not int
or version != _TOKEN_VERSION
or subject != _TOKEN_SUBJECT
or type(issued_at) is not int
or type(expires_at) is not int
or not isinstance(token_id, str)
or not token_id
or len(token_id) > 256
):
return None
current_time = int(time.time() if now is None else now)
ttl = expires_at - issued_at
if ttl not in {DEFAULT_TOKEN_TTL_SECONDS, REMEMBER_ME_TOKEN_TTL_SECONDS}:
return None
if issued_at > current_time + CLOCK_SKEW_SECONDS or expires_at <= current_time:
return None
return VerifiedPersistentToken(
subject=subject,
issued_at=issued_at,
expires_at=expires_at,
token_id=token_id,
)
def _request_cookie() -> str | None:
try:
value = st.context.cookies.get(AUTH_COOKIE_NAME)
except Exception:
return None
return value if isinstance(value, str) and value else None
def _query_error() -> str | None:
try:
value = st.query_params.get("auth_error")
except Exception:
return None
return value if isinstance(value, str) else None
def _render_login(*, clear_cookie: bool = False) -> None:
st.title("관리자 로그인")
st.caption("Dashboard를 사용하려면 관리자 자격 증명이 필요합니다.")
if _query_error() == "invalid":
st.error(INVALID_CREDENTIALS_MESSAGE)
elif _query_error() == "unavailable":
st.error(CONFIGURATION_ERROR_MESSAGE)
st.markdown(
"""
<form action="/api/auth/login" method="post">
<label for="jarca-admin-id">관리자 ID</label>
<input id="jarca-admin-id" name="admin_id" type="text" autocomplete="username" required>
<label for="jarca-admin-password">비밀번호</label>
<input id="jarca-admin-password" name="admin_password" type="password" autocomplete="current-password" required>
<label><input name="remember_me" type="checkbox"> 로그인 상태 유지 (30일)</label>
<button type="submit">로그인</button>
</form>
""",
unsafe_allow_html=True,
)
if clear_cookie:
st.markdown(
"<meta http-equiv=\"refresh\" content=\"0;url=/api/auth/cleanup\">",
unsafe_allow_html=True,
)
def _render_logout() -> None:
with st.sidebar:
st.markdown(
"""
<form action="/api/auth/logout" method="post">
<button type="submit">로그아웃</button>
</form>
""",
unsafe_allow_html=True,
)
def require_dashboard_authentication() -> bool:
"""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(clear_cookie=cookie is not None)
st.error(CONFIGURATION_ERROR_MESSAGE)
return False
verified = verify_persistent_token(settings, cookie)
if verified is None:
log_auth_event("authentication_required", correlation_id)
_render_login(clear_cookie=cookie is not None)
return False
log_auth_event("token_accepted", correlation_id)
_render_logout()
return True
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
from pathlib import Path
from streamlit import App
from dashboard.auth_server import create_auth_routes
APP_SCRIPT = Path(__file__).with_name("app.py")
app = App(APP_SCRIPT, routes=create_auth_routes())
+51
View File
@@ -0,0 +1,51 @@
# k3s 배포
`base/`는 MCP와 Dashboard를 같은 SQLite PVC에 두는 단일 Deployment 구조입니다. Dashboard 인증 secret은 base manifest에 실제 값으로 저장하지 않습니다.
## 사전 준비
namespace와 승인된 secret manager 절차를 준비합니다.
```bash
kubectl apply -f deploy/k8s/base/namespace.yaml
```
`jarca-ledger` namespace의 Dashboard secret에는 다음 key만 둡니다.
- `admin-id`
- `admin-password`
- `signing-secret` (UTF-8 32 byte 이상의 독립 random value)
Registry credential은 `imagePullSecrets`로만 사용합니다. 실제 secret 값, token, cookie를 Git, manifest, command history 또는 문서에 기록하지 마세요.
## 배포
검증된 release digest를 외부 environment overlay에서 고정하고 적용합니다.
```bash
kubectl apply -k environments/production/jarca-ledger
kubectl -n jarca-ledger rollout status deployment/jarca-ledger
kubectl -n jarca-ledger get pods,svc,pvc
```
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을 함께 보존합니다.
- `replicas: 1`과 `Recreate`를 유지하여 두 Pod가 같은 SQLite를 동시에 열지 않게 합니다.
- 별도 저장소로 backup을 내보내며 PVC만 재해 복구로 간주하지 않습니다.
+185
View File
@@ -0,0 +1,185 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: jarca-ledger
labels:
app.kubernetes.io/name: jarca-ledger
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: jarca-ledger
template:
metadata:
labels:
app.kubernetes.io/name: jarca-ledger
spec:
automountServiceAccountToken: false
enableServiceLinks: false
terminationGracePeriodSeconds: 30
imagePullSecrets:
- name: jarca-ledger-registry
nodeSelector:
kubernetes.io/os: linux
kubernetes.io/arch: arm64
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
initContainers:
- name: init-db
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.3
imagePullPolicy: IfNotPresent
command:
- python
- -c
- >-
from jarca_ledger_mcp.service import health_check;
result = health_check();
assert result["success"], result
env:
- name: JARCA_LEDGER_DB_PATH
value: /data/ledger.sqlite3
- name: HOME
value: /tmp
- name: TMPDIR
value: /tmp
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: data
mountPath: /data
- name: init-tmp
mountPath: /tmp
containers:
- name: mcp
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.3
imagePullPolicy: IfNotPresent
env:
- name: JARCA_LEDGER_DB_PATH
value: /data/ledger.sqlite3
- name: HOME
value: /tmp
- name: TMPDIR
value: /tmp
ports:
- name: mcp
containerPort: 8000
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
startupProbe:
tcpSocket:
port: mcp
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 24
readinessProbe:
tcpSocket:
port: mcp
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
tcpSocket:
port: mcp
periodSeconds: 20
timeoutSeconds: 2
failureThreshold: 3
volumeMounts:
- name: data
mountPath: /data
- name: mcp-tmp
mountPath: /tmp
- name: dashboard
image: gitea.jsfamily.quest/jsfamily/jarca-ledger-mcp:v0.1.3
imagePullPolicy: IfNotPresent
command:
- uvicorn
- dashboard.server:app
- --host
- 0.0.0.0
- --port
- "8501"
- --proxy-headers
env:
- name: JARCA_LEDGER_DB_PATH
value: /data/ledger.sqlite3
- name: HOME
value: /tmp
- name: TMPDIR
value: /tmp
- name: JARCA_DASHBOARD_ADMIN_ID
valueFrom:
secretKeyRef:
name: jarca-ledger-dashboard-auth
key: admin-id
- name: JARCA_DASHBOARD_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: jarca-ledger-dashboard-auth
key: admin-password
- name: JARCA_DASHBOARD_SIGNING_SECRET
valueFrom:
secretKeyRef:
name: jarca-ledger-dashboard-auth
key: signing-secret
- name: FORWARDED_ALLOW_IPS
value: 127.0.0.1
ports:
- name: dashboard
containerPort: 8501
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
startupProbe:
httpGet:
path: /_stcore/health
port: dashboard
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 24
readinessProbe:
httpGet:
path: /_stcore/health
port: dashboard
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /_stcore/health
port: dashboard
periodSeconds: 20
timeoutSeconds: 2
failureThreshold: 3
volumeMounts:
- name: data
mountPath: /data
- name: dashboard-tmp
mountPath: /tmp
volumes:
- name: data
persistentVolumeClaim:
claimName: jarca-ledger-data
- name: init-tmp
emptyDir: {}
- name: mcp-tmp
emptyDir: {}
- name: dashboard-tmp
emptyDir: {}
+9
View File
@@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: jarca-ledger
resources:
- namespace.yaml
- pvc.yaml
- deployment.yaml
- services.yaml
- networkpolicy.yaml
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: jarca-ledger
+14
View File
@@ -0,0 +1,14 @@
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: jarca-ledger-ingress
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: jarca-ledger
policyTypes:
- Ingress
ingress:
- ports:
- protocol: TCP
port: 8501
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jarca-ledger-data
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 1Gi
+13
View File
@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: jarca-ledger-dashboard
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: jarca-ledger
ports:
- name: http
port: 8501
targetPort: dashboard
appProtocol: http
+9 -11
View File
@@ -29,24 +29,22 @@ services:
- "40059:8501"
environment:
JARCA_LEDGER_DB_PATH: /data/ledger.sqlite3
JARCA_DASHBOARD_ADMIN_ID: ${JARCA_DASHBOARD_ADMIN_ID:?set JARCA_DASHBOARD_ADMIN_ID}
JARCA_DASHBOARD_ADMIN_PASSWORD: ${JARCA_DASHBOARD_ADMIN_PASSWORD:?set JARCA_DASHBOARD_ADMIN_PASSWORD}
JARCA_DASHBOARD_SIGNING_SECRET: ${JARCA_DASHBOARD_SIGNING_SECRET:?set JARCA_DASHBOARD_SIGNING_SECRET}
FORWARDED_ALLOW_IPS: ${JARCA_DASHBOARD_FORWARDED_ALLOW_IPS:-127.0.0.1}
STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false"
PYTHONUNBUFFERED: "1"
volumes:
- /volume1/docker/jarca-ledger/data:/data
command:
- python
- -m
- streamlit
- run
- dashboard/app.py
- --server.address
- uvicorn
- dashboard.server:app
- --host
- 0.0.0.0
- --server.port
- --port
- "8501"
- --server.headless
- "true"
- --browser.gatherUsageStats
- "false"
- --proxy-headers
healthcheck:
interval: 30s
timeout: 5s
@@ -0,0 +1,73 @@
{
"schema": "jarca-ledger.review-evidence/v1",
"issue": 6,
"pull_request": 7,
"source_review": 51,
"finding": "D6-23",
"contract_sha256": "4f734169ec896747fe39fd7f3ab75d1fc0d8cdd4903045e665beba40d80eb1fe",
"verified_at_utc": "2026-10-03T00:10:43Z",
"source_revision": "e7242a89f7f1faacbe984713a5ad211bd7bcac2b",
"source_tree_clean_before_evidence_write": true,
"environment": {
"os": "Linux 6.18.34+rpt-rpi-v8",
"architecture": "aarch64",
"python": "3.13.5",
"starlette": "1.3.1",
"httpx": "0.28.1",
"streamlit": "1.58.0",
"uvicorn": "0.49.0",
"itsdangerous": "2.2.0",
"credentials": "synthetic test-only values; not recorded",
"signing_secret": "synthetic test-only value; not recorded"
},
"commands": [
{
"purpose": "Focused route, hardened cookie, event-loop offload, and failed-delay helper verification",
"command": "uv run --extra dashboard --extra dev pytest -q tests/test_dashboard_auth_server.py tests/test_dashboard_authentication.py",
"exit_code": 0,
"result": "29 passed; one Starlette/httpx deprecation warning",
"relevant_output": "............................. [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-6/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html"
},
{
"purpose": "Real-delay concurrent failed login plus successful login and hardened cookie runtime probe",
"command": "uv run --extra dashboard python - <<'PY'\nimport asyncio\nimport json\nimport os\nimport secrets\nimport time\n\nfrom starlette.requests import Request\n\nfrom dashboard import authentication\nfrom dashboard.auth_server import login\n\nadmin_id = \"synthetic-\" + secrets.token_hex(8)\nadmin_password = secrets.token_hex(16)\nos.environ[authentication.ADMIN_ID_ENV] = admin_id\nos.environ[authentication.ADMIN_PASSWORD_ENV] = admin_password\nos.environ[authentication.SIGNING_SECRET_ENV] = secrets.token_hex(32)\n\n\ndef request(password: str) -> Request:\n body = f\"admin_id={admin_id}&admin_password={password}\".encode()\n sent = False\n\n async def receive():\n nonlocal sent\n if sent:\n return {\"type\": \"http.disconnect\"}\n sent = True\n return {\"type\": \"http.request\", \"body\": body, \"more_body\": False}\n\n scope = {\n \"type\": \"http\",\n \"method\": \"POST\",\n \"scheme\": \"https\",\n \"path\": \"/api/auth/login\",\n \"raw_path\": b\"/api/auth/login\",\n \"query_string\": b\"\",\n \"headers\": [\n (b\"host\", b\"testserver\"),\n (b\"origin\", b\"https://testserver\"),\n (b\"content-type\", b\"application/x-www-form-urlencoded\"),\n ],\n \"client\": (\"testclient\", 50000),\n \"server\": (\"testserver\", 443),\n }\n return Request(scope, receive)\n\n\nasync def main() -> None:\n wrong_password = secrets.token_hex(16)\n started = time.monotonic()\n failures = [asyncio.create_task(login(request(wrong_password))) for _ in range(2)]\n await asyncio.sleep(0.1)\n probe_elapsed = time.monotonic() - started\n success_started = time.monotonic()\n success = await login(request(admin_password))\n success_elapsed = time.monotonic() - success_started\n failed = await asyncio.gather(*failures)\n total_elapsed = time.monotonic() - started\n cookie = success.headers.get(\"set-cookie\", \"\")\n assert success.status_code == 303\n assert success.headers[\"location\"] == \"/\"\n assert all(marker in cookie for marker in [\"HttpOnly\", \"Secure\", \"SameSite=lax\", \"Path=/\", \"Max-Age=86400\"])\n assert all(response.status_code == 303 for response in failed)\n assert all(\"set-cookie\" not in response.headers for response in failed)\n assert probe_elapsed < 0.5\n assert success_elapsed < 0.5\n assert 0.95 <= total_elapsed < 1.75\n print(json.dumps({\n \"success\": True,\n \"source_revision\": \"e7242a89f7f1faacbe984713a5ad211bd7bcac2b\",\n \"successful_login_status\": success.status_code,\n \"successful_login_cookie_hardened\": True,\n \"failed_login_statuses\": [response.status_code for response in failed],\n \"failed_login_cookie_absent\": True,\n \"event_loop_probe_elapsed_seconds\": round(probe_elapsed, 6),\n \"successful_login_elapsed_seconds\": round(success_elapsed, 6),\n \"concurrent_failed_logins_total_elapsed_seconds\": round(total_elapsed, 6),\n \"minimum_failed_login_delay_seconds\": 1.0,\n \"credentials_and_cookie_values_recorded\": False,\n }, indent=2))\n\n\nasyncio.run(main())\nPY",
"exit_code": 0,
"result": {
"success": true,
"source_revision": "e7242a89f7f1faacbe984713a5ad211bd7bcac2b",
"successful_login_status": 303,
"successful_login_cookie_hardened": true,
"failed_login_statuses": [
303,
303
],
"failed_login_cookie_absent": true,
"event_loop_probe_elapsed_seconds": 0.10056,
"successful_login_elapsed_seconds": 0.001267,
"concurrent_failed_logins_total_elapsed_seconds": 1.00614,
"minimum_failed_login_delay_seconds": 1.0,
"credentials_and_cookie_values_recorded": false
},
"relevant_output": "{\n \"success\": true,\n \"source_revision\": \"e7242a89f7f1faacbe984713a5ad211bd7bcac2b\",\n \"successful_login_status\": 303,\n \"successful_login_cookie_hardened\": true,\n \"failed_login_statuses\": [\n 303,\n 303\n ],\n \"failed_login_cookie_absent\": true,\n \"event_loop_probe_elapsed_seconds\": 0.10056,\n \"successful_login_elapsed_seconds\": 0.001267,\n \"concurrent_failed_logins_total_elapsed_seconds\": 1.00614,\n \"minimum_failed_login_delay_seconds\": 1.0,\n \"credentials_and_cookie_values_recorded\": false\n}"
}
],
"hashes_sha256": {
"dashboard/auth_server.py": "4b991fbe942b573f8be270538b7e9df81bca9ffed3a8982f09db0d95d26e66d1",
"dashboard/authentication.py": "63df2e33ef10859039c9b8140e92cbf0c7c8c9fe2d19cfcedbf16637c8cc2596",
"tests/test_dashboard_auth_server.py": "8b96421af776b613867a8a5ab5dd962033b39d1cc616ba4e91cf3c4de63a1f0d",
"tests/test_dashboard_authentication.py": "1ca7ecc4d4db55c674aa2934d665b121476ff5fa2e1f74071090a67e815af676",
"pyproject.toml": "64100d3aa1c2272df2e2139ed435724901f7215ea72e48bee9d73f95e7e53a8d"
},
"redaction": {
"credentials_recorded": false,
"signing_secret_recorded": false,
"token_or_cookie_value_recorded": false,
"request_body_recorded": false,
"raw_set_cookie_recorded": false
},
"notes": [
"This artifact is an evidence-only descendant of source_revision; the application and test paths hashed above were not modified before these commands ran.",
"The runtime probe exercised the changed streaming login path with synthetic credentials, confirmed hardened 24-hour cookie issuance, retained the real one-second failed-login delay, and showed the event loop and a successful login remained responsive while two failed requests waited.",
"This D6-23 evidence does not replace or claim a fresh browser run; D6-22 remains supported by docs/evidence/issue-6-persistent-auth-https.json and the Review #51 change-impact analysis."
]
}
@@ -0,0 +1,227 @@
{
"claims": [
{
"id": "f003-unicode-success",
"pointers": [
"commands[0].argv",
"commands[0].exit_code",
"commands[0].output",
"commands[0].started_at_utc",
"commands[0].finished_at_utc"
]
},
{
"id": "f003-generic-delayed-failure",
"pointers": [
"commands[0].argv",
"commands[0].exit_code",
"commands[0].output",
"commands[0].started_at_utc",
"commands[0].finished_at_utc"
]
},
{
"id": "f003-malformed-utf8-400-no-cookie",
"pointers": [
"commands[0].argv",
"commands[0].exit_code",
"commands[0].output",
"commands[0].started_at_utc",
"commands[0].finished_at_utc"
]
},
{
"id": "full-regression",
"pointers": [
"commands[1].argv",
"commands[1].exit_code",
"commands[1].output",
"commands[1].started_at_utc",
"commands[1].finished_at_utc"
]
},
{
"id": "static-and-source-integrity",
"pointers": [
"commands[2].argv",
"commands[2].exit_code",
"commands[2].output",
"commands[3].argv",
"commands[3].exit_code",
"commands[3].output",
"commands[4].argv",
"commands[4].exit_code",
"commands[4].output"
]
}
],
"commands": [
{
"argv": [
".venv/bin/python",
"-m",
"pytest",
"-o",
"addopts=",
"-q",
"-p",
"no:cacheprovider",
"--basetemp=/tmp/jarca-f003-evidence-focused",
"tests/test_dashboard_auth_server.py",
"tests/test_dashboard_authentication.py"
],
"duration_seconds": 11.436193,
"exit_code": 0,
"expected_exit_code": 0,
"finished_at_utc": "2026-10-04T03:55:21.516159Z",
"id": "focused-tests",
"output": "................................ [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-6/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n32 passed, 1 warning in 7.81s\n",
"purpose": "F-003 Unicode success, delayed generic mismatch, malformed percent-encoded UTF-8, and credential helper verification",
"started_at_utc": "2026-10-04T03:55:10.080020Z"
},
{
"argv": [
".venv/bin/python",
"-m",
"pytest",
"-o",
"addopts=",
"-q",
"-p",
"no:cacheprovider",
"--basetemp=/tmp/jarca-f003-evidence-full"
],
"duration_seconds": 54.086422,
"exit_code": 0,
"expected_exit_code": 0,
"finished_at_utc": "2026-10-04T03:56:15.670519Z",
"id": "full-tests",
"output": "........................................................................ [ 59%]\n...................ss........................ss... [100%]\n=============================== warnings summary ===============================\ntests/test_dashboard_auth_server.py:11\n /opt/data/gitea-agent/repositories/jsfamily/jarca-ledger-mcp/worktrees/issue-6/checkout/tests/test_dashboard_auth_server.py:11: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.\n from starlette.testclient import TestClient\n\n-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html\n118 passed, 4 skipped, 1 warning in 49.12s\n",
"purpose": "Complete repository regression suite",
"started_at_utc": "2026-10-04T03:55:21.584157Z"
},
{
"argv": [
".venv/bin/ruff",
"check",
"--no-cache",
"dashboard/authentication.py",
"dashboard/auth_server.py",
"tests/test_dashboard_auth_server.py",
"tests/test_dashboard_authentication.py"
],
"duration_seconds": 0.025974,
"exit_code": 0,
"expected_exit_code": 0,
"finished_at_utc": "2026-10-04T03:56:15.770278Z",
"id": "ruff",
"output": "All checks passed!\n",
"purpose": "Static lint for the exact F-003 source and test allowlist",
"started_at_utc": "2026-10-04T03:56:15.744356Z"
},
{
"argv": [
"uv",
"lock",
"--check",
"--offline"
],
"duration_seconds": 0.059792,
"exit_code": 0,
"expected_exit_code": 0,
"finished_at_utc": "2026-10-04T03:56:15.890498Z",
"id": "lock-check",
"output": "Resolved 72 packages in 3ms\n",
"purpose": "Offline dependency lock consistency",
"started_at_utc": "2026-10-04T03:56:15.830762Z"
},
{
"argv": [
"git",
"diff",
"--check",
"d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad^",
"d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad"
],
"duration_seconds": 0.033395,
"exit_code": 0,
"expected_exit_code": 0,
"finished_at_utc": "2026-10-04T03:56:15.992735Z",
"id": "source-diff-check",
"output": "",
"purpose": "Whitespace validation for the exact source commit",
"started_at_utc": "2026-10-04T03:56:15.959398Z"
}
],
"contract_sha256": "4f734169ec896747fe39fd7f3ab75d1fc0d8cdd4903045e665beba40d80eb1fe",
"environment": {
"architecture": "aarch64",
"child_environment_keys": [
"HOME",
"NO_COLOR",
"PATH",
"PYTHONDONTWRITEBYTECODE"
],
"credentials": "synthetic test-only values supplied by tests; not recorded",
"dependencies": {
"httpx": "0.28.1",
"itsdangerous": "2.2.0",
"pytest": "9.1.1",
"starlette": "1.3.1",
"streamlit": "1.58.0",
"uvicorn": "0.49.0"
},
"os": "Linux-6.18.34+rpt-rpi-v8-aarch64-with-glibc2.41",
"python": "3.13.5",
"signing_secret": "synthetic test-only value supplied by tests; not recorded"
},
"finding": "F-003",
"issue": 6,
"limitations": [
"The focused adapter tests use Starlette TestClient rather than a newly executed external browser or deployed Compose/Kubernetes environment.",
"The existing Starlette/httpx deprecation warning is retained and is unrelated to F-003."
],
"notes": [
"This evidence-only descendant binds verification to source_revision; the listed source bytes and Git blob OIDs were unchanged before, during, and after every command.",
"No command used credentials, signing secrets, authorization headers, raw cookies, or shell interpretation in argv."
],
"pull_request": 7,
"redaction": {
"credentials_recorded": false,
"raw_set_cookie_recorded": false,
"request_body_recorded": false,
"signing_secret_recorded": false,
"token_or_cookie_value_recorded": false
},
"schema": "jarca-ledger.review-evidence/v2",
"source_review": 53,
"source_revision": "d84ed8ff2a53cd2735eceaaf0c54cb40c7c2c3ad",
"source_tree_clean_before_evidence_write": true,
"sources": {
"dashboard/auth_server.py": {
"git_blob_oid": "0546d582805f495c86dcdbb45510431f7699ecfd",
"sha256": "94675feffbc0807a632d51a9feea8aa4d8834a37de2d8f32c50aa273d88ddab3"
},
"dashboard/authentication.py": {
"git_blob_oid": "006d3c95661b64924a89aa952ff531095d4684db",
"sha256": "260c3419040a883a76ced3fd1a1ff43f86763e608448fff57078c30a54c74213"
},
"pyproject.toml": {
"git_blob_oid": "2892966feaaa8dfa07bf330997afc8315364a672",
"sha256": "64100d3aa1c2272df2e2139ed435724901f7215ea72e48bee9d73f95e7e53a8d"
},
"tests/test_dashboard_auth_server.py": {
"git_blob_oid": "0508f54f74185fc4fda341fcdd2574cbadb0d203",
"sha256": "6b226b8d0d207a84f788b090de67fe6ddf9e7c8e05218ce18d97aa4badac95f5"
},
"tests/test_dashboard_authentication.py": {
"git_blob_oid": "4d16765c4ab64826144d4880ec27b489a02bc53d",
"sha256": "d6758e91b2e275cb22f55929ce0bf4a775feecb23f720fa05151da1ebb4da036"
},
"uv.lock": {
"git_blob_oid": "c92cadb5bf8ae12e603fa956177c5d7f12e58535",
"sha256": "9582546bc7c97a25b5d40d66ee814203ae46c13e7263942d02008403abd33c73"
}
},
"verified_at_utc": "2026-10-04T03:56:15.992735Z"
}
@@ -0,0 +1,94 @@
{
"schema": "jarca-ledger.auth-evidence/v2",
"issue": 6,
"pull_request": 7,
"source_review": 44,
"verified_at_utc": "2026-10-02T03:03:47Z",
"source_revision": "7bb959859a84f1d1293afd1f0270cca18f4cd576",
"source_tree_clean_before_evidence_write": true,
"transport": "HTTPS browser origin terminating to a loopback-only Uvicorn test server",
"browser_runner": {
"interface": "Playwright MCP",
"engine": "Chromium 154",
"architecture": "arm",
"bitness": "64",
"platform": "Linux",
"secure_context": true
},
"environment": {
"synthetic_database": true,
"operating_ledger_used": false,
"credentials": "synthetic test-only values; not recorded",
"signing_secret": "synthetic test-only value; not recorded",
"temporary_https_ingress": true
},
"local_checks": [
{
"command": "uv run --extra dashboard --extra dev pytest -q",
"exit_code": 0,
"result": "111 passed, 4 skipped"
},
{
"command": "uv run --extra dashboard --extra dev ruff check dashboard tests scripts",
"exit_code": 0,
"result": "All checks passed"
},
{
"command": "uv run --extra dashboard --extra dev python -m compileall -q dashboard scripts tests",
"exit_code": 0,
"result": "passed"
}
],
"browser_results": {
"initial_login_page_visible": true,
"login": {
"status": 303,
"location": "/",
"cookie_name": "__Host-jarca_dashboard_auth",
"cookie_value": "[REDACTED]",
"secure": true,
"http_only": true,
"same_site": "Lax",
"path": "/",
"domain_attribute_absent": true,
"max_age_seconds": 2592000,
"document_cookie_exposed_auth_cookie": false
},
"restoration": {
"new_tab": true,
"query_page": "Import",
"authenticated_page_visible": true,
"login_page_visible": false
},
"invalid_cookie_cleanup": {
"injected_value": "[REDACTED TEST SENTINEL]",
"cleanup_status": 303,
"location": "/",
"matching_cookie_deletion": true,
"max_age_seconds": 0,
"cookie_count_after_cleanup": 0,
"unauthenticated_page_visible": true
},
"logout": {
"status": 303,
"location": "/",
"matching_cookie_deletion": true,
"max_age_seconds": 0,
"cookie_count_after_logout": 0,
"unauthenticated_page_visible": true,
"document_cookie_exposed_auth_cookie": false
}
},
"hashes_sha256": {
"scripts/persistent_auth_http_evidence.py": "403bfcd7e281d4b1750841a969a18548a580a5f3f5116abeaf83e71e81832844",
"dashboard/authentication.py": "63df2e33ef10859039c9b8140e92cbf0c7c8c9fe2d19cfcedbf16637c8cc2596",
"dashboard/auth_server.py": "d986e10d3489938c559d4f11ae20a43a448b39aae8a27978571f193f9df0e8ca",
"dashboard/server.py": "633c7fd9ae8a41c656a734be641de7f1733a1636216a9dbe2e50db885ff6ec6e",
"pyproject.toml": "64100d3aa1c2272df2e2139ed435724901f7215ea72e48bee9d73f95e7e53a8d"
},
"notes": [
"The evidence artifact is an evidence-only descendant of source_revision; application, test, and configuration paths were not changed after source_revision verification.",
"Credentials, signing material, token values, raw cookie values, request bodies, and raw Set-Cookie values were not recorded.",
"The actual ARM64 Chromium browser performed login, HttpOnly storage, new-tab restoration, invalid-cookie cleanup, and logout deletion."
]
}
+223
View File
@@ -0,0 +1,223 @@
# Interface Contract — Dashboard 관리자 인증
## 목적과 범위
Dashboard 인증 설정, login/cleanup/logout HTTP route, signed cookie와 Streamlit gate의 외부·내부 계약을 정의한다.
- 포함 범위: 환경변수, 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 | 민감 |
#### 오류
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|---|---|---|---|---|---|
| `configuration_unavailable` | configuration | missing/empty/read error/short secret | 수정 후 가능 | generic unavailable | no cookie, no DB |
### login — `POST /api/auth/login`
- 책임: 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
#### 입력
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|---|
| `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 검증 | 비민감 |
#### 출력
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|
| `Set-Cookie` | response header | `cookie` | success | hardened auth cookie, redacted value | persistent auth | 값은 민감 |
| `Location` | response header | `string` | success/failure | `/` or generic error path | browser navigation | 비민감 |
발급 token은 `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti` claim만 가진다. `sub=dashboard-admin`은 관리자 ID가 아닌 고정 비민감 role이고, token 값과 서명은 민감하므로 browser의 hardened cookie에만 저장한다. 성공한 login 시각에 `iat`와 `exp`를 한 번 고정하며 기본 TTL은 24시간, `remember_me=on`이면 30일이다. 활동, 일반 요청, rerun 또는 인증 복원은 token을 재발급하거나 두 시각을 연장하지 않는다.
#### 오류
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|---|---|---|---|---|---|
| `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 |
### cleanup — `GET /api/auth/cleanup`
- 책임: 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
#### 입력
| 필드 | 위치 | 타입 | 필수 여부 | 기본값 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|---|
| `Referer` | request header | `URL` | 필수 | 없음 | request origin과 같은 HTTPS authority | navigation origin 검증 | 비민감 |
#### 출력
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|
| `Set-Cookie` | response header | `cookie` | always | same name/path/security, `Max-Age=0` | delete stale cookie | 값은 민감 |
| `Location` | response header | `string` | success | `/` | unauthenticated page | 비민감 |
#### 오류
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|---|---|---|---|---|---|
| `403` | origin | non-HTTPS/cross-origin | 가능 | retry same-origin | no DB |
### 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 | current configuration으로 서명을 검증할 수 있고 claim이 정확히 `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti`인 token | browser proof | 값과 서명은 민감; `sub`는 비민감 |
#### 출력
| 필드 | 위치 | 타입 | 필수 여부 | 허용 값·형식 | 의미 | 민감정보 |
|---|---|---|---|---|---|---|
| `authenticated` | server result | `bool` | always | true/false | gate decision | 비민감 |
#### 오류
| 코드 | 분류 | 발생 조건 | 재시도 가능 여부 | 호출자 대응 | 실패 시 상태 변경 |
|---|---|---|---|---|---|
| `unauthenticated` | authentication | absent/invalid/expired/config failure | login 가능 | cleanup then show login | no protected work |
관리자 ID, password 또는 signing secret 변경은 server-only signing key binding을 바꾸므로 기존 token을 모두 무효화하고 cleanup으로 전이한다. DB session, token registry와 per-token revocation은 만들지 않는다.
## 호환성
- 유지할 기존 호출 방식: authenticated `page` query parameter, page dispatch, gateway와 MCP behavior
- 버전 변경 정책: 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
- 만료 보장: login 성공 시 고정한 `iat`와 `exp`는 활동·일반 요청·rerun·복원에서 변경하거나 연장하지 않음
## 예시
### example-login — 정상 login
- 대상 인터페이스: `login`
#### 호출
```text
POST /api/auth/login
Origin: https://dashboard.example
admin_id=<submitted>&admin_password=<submitted>&remember_me=on
```
#### 결과
```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=/
```
## 검증
- 정상 계약: 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
+101
View File
@@ -0,0 +1,101 @@
# Project Structure — Dashboard 관리자 인증
## 목적과 경계
Issue #6 인증을 기존 Dashboard 구조에 배치하고 fail-closed 경계와 server-only secret binding을 유지한다.
- 포함 경계: `dashboard/` 인증 adapter/gate, tests, 운영 문서
- 제외 경계: MCP transport, 금융 schema, DB migration, external auth middleware
## 디렉터리 구조
### dashboard-source — Dashboard source
```text
dashboard/
├── server.py
├── auth_server.py
├── authentication.py
├── app.py
└── page_views/
```
| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 |
|---|---|---|---|---|---|---|
| `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 |
### verification-tests — 인증 검증
```text
tests/
├── test_dashboard_authentication.py
├── test_dashboard_persistent_authentication.py
├── test_dashboard_auth_server.py
└── test_dashboard_authentication_app.py
```
| 경로 | 종류 | 책임 | 공개 진입점 | 내부 구현 경계 | 편집 정책 | 생성 주체 |
|---|---|---|---|---|---|---|
| `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 |
## 의존 방향
| 출발 영역 | 대상 영역 | 허용 여부 | 규칙 | 공유 계약 위치 |
|---|---|---|---|---|
| `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` |
## 변경 규칙
| 구분 | 규칙 |
|---|---|
| 새 모듈·파일 배치 | 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
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
@@ -0,0 +1,526 @@
---
{
"schema": "gitea-agent.requirement/v1",
"id": "REQ-001",
"title": "Dashboard 관리자 인증",
"revision": "2.0",
"status": "approved",
"depends_on": [],
"applies_to": ["dashboard"],
"language": "ko"
}
---
# REQ-001 — Dashboard 관리자 인증
## 1. 목적
Dashboard의 금융 기능 앞에 단일 fail-closed 관리자 인증 경계를 두고, 유효한 인증 cookie가 같은 browser profile의 새 연결에서 복원되도록 한다.
## 2. 배경
Issue #1의 관리자 인증을 Issue #6에서 persistent signed cookie와 명시적 logout으로 확장한다. 기존 요구사항의 정체성, 승인 범위, Issue #1과 구현 PR #3의 추적성을 유지하며 revision 2.0으로 갱신한다.
## 3. 범위
### 포함
- Dashboard 전 기능 앞의 인증 gate와 인증 전 DB 차단
- `JARCA_DASHBOARD_ADMIN_ID`, `JARCA_DASHBOARD_ADMIN_PASSWORD`, `JARCA_DASHBOARD_SIGNING_SECRET` 기반 server-only 검증
- constant-time credential 비교, 일반 오류, 실패당 최소 1초 지연
- 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, MFA, CAPTCHA, 계정 잠금
- 외부 인증 proxy로 책임 이전
- sliding expiration, DB-backed session/token registry, per-token revocation
- MCP endpoint 인증 변경, SQLite schema/data migration
- 다른 browser profile·기기 간 로그인 공유
## 4. 용어와 기준
- **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 — 공통 인증 경계
모든 DB path, page dispatch, gateway, upload, import보다 먼저 설정과 cookie를 검증하고, 실패하면 로그인 화면만 표시한다.
**영향 범위**
- 인터페이스: `dashboard-auth-gate`
- 영속성: 없음
- 후속 사용 주체: Dashboard page와 gateway
- 마이그레이션: 없음
- 호환성: 인증 후 기존 page/query 동작 유지
- 배포 전제 조건: 유효한 세 환경변수
### REQ-001.02 — 설정과 server-only binding
세 환경변수만 사용하고 누락·빈 값·read error·짧은 signing secret이면 fail-closed한다. signing key는 서버에서 configured credential과 secret으로만 파생한다.
**영향 범위**
- 인터페이스: `dashboard-auth-config`, `dashboard-auth-cookie`
- 영속성: 없음
- 후속 사용 주체: 인증 verifier
- 마이그레이션: 없음
- 호환성: Streamlit secrets/fallback 미사용
- 배포 전제 조건: secret manager 주입
### REQ-001.03 — Credential과 민감정보 비노출
ID와 password는 constant-time 비교한다. credential, secret, signing material, token, raw cookie와 Set-Cookie 값은 UI, URL, log, 문서, session state에 기록하지 않는다.
**영향 범위**
- 인터페이스: login form, event log
- 영속성: 없음
- 후속 사용 주체: 운영자와 감사 도구
- 마이그레이션: 없음
- 호환성: 비민감 correlation ID만 유지
- 배포 전제 조건: redacted logging
### REQ-001.04 — Login 실패 계약
ID 또는 password가 틀리면 동일한 일반 오류와 최소 1초 지연을 적용하고 상태를 변경하지 않는다. 성공에는 지연을 적용하지 않는다.
**영향 범위**
- 인터페이스: `POST /api/auth/login`
- 영속성: 없음
- 후속 사용 주체: browser
- 마이그레이션: 없음
- 호환성: 기존 login form 유지
- 배포 전제 조건: 외부 rate limiting
### REQ-001.05 — Persistent token
성공 login은 기본 24시간, remember-me 선택 시 30일의 absolute TTL token을 발급한다. `iat`와 `exp`는 고정하며 활동·rerun·복원으로 연장하지 않는다. `sub`는 항상 `dashboard-admin`이다.
**영향 범위**
- 인터페이스: `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 — 관찰 가능성
설정 오류, 인증 필요, token 수락, login 성공/실패/reject, cleanup, logout event와 비민감 correlation ID만 기록한다. 민감 값과 구체적 검증 원인은 기록하지 않는다.
**영향 범위**
- 인터페이스: event log
- 영속성: 로그에 비민감 메타데이터만
- 후속 사용 주체: 운영 관찰 도구
- 마이그레이션: 없음
- 호환성: 일반 오류 메시지 유지
- 배포 전제 조건: redaction 정책
## 6. 제약과 불변조건
- `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을 유지하지 않는다.
## 7. 인수 기준
### REQ-001.01-AC01 — 인증 전 차단
**판정 기준**
모든 인증 거부 경로에서 DB, gateway, page dispatch가 실행되지 않는다.
**검증**
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.03-AC02 — 두 credential의 constant-time 비교
**판정 기준**
제출 ID와 password는 각각 `hmac.compare_digest`로 비교하며, 한 필드가 일치하지 않아도 다른 필드의 비교를 생략하지 않는다.
**검증**
`tests/test_dashboard_authentication.py::test_verify_credentials_uses_constant_time_comparison_for_both_fields`에서 성공 및 두 필드가 모두 잘못된 경우에 두 비교의 호출 순서와 인자를 계측한다.
**검증 분류**
- 수준: unit
- 백엔드: pytest monkeypatch
- 케이스: 정상 credential, 잘못된 ID와 password
**상태 불변조건**
일반 동등 비교나 한 필드 실패에 따른 short-circuit를 사용하지 않는다.
### REQ-001.04-AC01 — 실패 지연과 일반 오류
**판정 기준**
잘못된 ID와 잘못된 password가 같은 일반 오류를 반환하고 각각 최소 1초 지연된다.
**검증**
두 실패 경로의 elapsed time과 response를 측정한다.
**검증 분류**
- 수준: integration
- 백엔드: pytest/HTTP client
- 케이스: wrong ID, wrong password
**상태 불변조건**
cookie·DB·gateway 상태가 변하지 않는다.
### REQ-001.04-AC02 — 성공 지연·잠금 없음
**판정 기준**
정상 credential은 실패 지연 함수를 사용하지 않고 인증되며, 실패 횟수나 잠금 만료 상태를 생성하지 않는다.
**검증**
`dashboard/auth_server.py`의 login 경로와 authentication source를 검사하여 `enforce_failed_login_delay`가 credential 실패 분기에만 호출되고 failure counter·lock state가 없음을 확인한다. `tests/test_dashboard_authentication.py`의 `test_failed_login_delay_waits_only_for_remaining_minimum_duration` 및 `test_failed_login_delay_does_not_sleep_after_minimum_duration`, 그리고 `tests/test_dashboard_auth_server.py::test_successful_login_sets_hardened_24_hour_cookie`를 실행한다.
**검증 분류**
- 수준: unit/integration/static
- 백엔드: pytest, source inspection
- 케이스: 정상 credential, 실패 지연 helper, 반복 실패 후 재시도
**상태 불변조건**
애플리케이션 failure counter와 lock state를 유지하지 않는다.
### REQ-001.05-AC01 — Absolute TTL과 고정 subject
**판정 기준**
기본 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.10-AC02 — 인증 후 기존 페이지 호환성
**판정 기준**
유효한 Dashboard 인증 cookie로 인증하면 기존 page query parameter를 유지한 채 요약, 자산/마감, 분류 규칙, 확인필요 큐와 월정산 핵심 페이지가 예외 없이 예상 내용을 렌더링한다.
**검증**
`tests/test_dashboard_app.py::test_streamlit_apptest_core_pages_use_synthetic_fixture_db`와 `tests/test_dashboard_authentication_app.py::test_valid_cookie_allows_dashboard_and_is_not_stored_in_session_state`를 실행한다.
**검증 분류**
- 수준: regression integration
- 백엔드: Streamlit AppTest, synthetic SQLite
- 케이스: 인증된 핵심 page query와 기존 gateway
**상태 불변조건**
기존 SQLite schema, page query와 gateway contract를 변경하지 않는다.
### REQ-001.10-AC03 — MCP 인증 범위 분리
**판정 기준**
Dashboard cookie 인증은 MCP endpoint의 인증을 의미하지 않으며 MCP server 진입점, transport와 port를 변경하지 않는다.
**검증**
Base-to-head diff에서 MCP 인증·진입점·transport·port를 담당하는 `jarca_ledger_mcp/server.py`, `jarca_ledger_mcp/adapters/mcp/**`, `tests/test_mcp_http_runtime.py`의 불변성을 확인하고 기존 stdio, streamable HTTP와 invalid transport 회귀 test를 실행한다.
**검증 분류**
- 수준: static/regression
- 백엔드: Git diff, pytest
- 케이스: MCP source, stdio, streamable HTTP, invalid transport
**상태 불변조건**
Dashboard와 MCP 인증 경계를 결합하지 않으며 MCP 인증 상태를 변경하지 않는다.
### REQ-001.11-AC01 — 안전한 관찰성
**판정 기준**
허용 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`
- 관련 요구사항: 없음
+114
View File
@@ -0,0 +1,114 @@
# Runtime Flow — Dashboard 관리자 인증
## 목적과 범위
ASGI startup부터 login, token restoration, invalid-cookie cleanup, explicit logout까지의 실행 순서와 상태 계약을 정의한다.
- 포함 범위: 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
## 흐름
### startup — ASGI startup
- 시작 이벤트: process startup
- 선행조건: deployment injects configuration without recording values
- 실행 방식: synchronous startup/request handling
- 트랜잭션·영속성 경계: no auth persistence; existing financial SQLite is untouched
| 단계 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 login
- 시작 이벤트: same-origin HTTPS form POST
- 선행조건: valid trusted scheme, origin and bounded body
- 실행 방식: synchronous request
- 트랜잭션·영속성 경계: no DB transaction; cookie is browser state
| 단계 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 exactly `v`, `sub=dashboard-admin`, `iat`, `exp`, `jti`; fix `iat`/`exp` at login success for default 24 hours or `remember_me=on` 30 days | server memory | none | `login-05` | `login-fail` |
| `login-05` | auth server | signed token | set hardened cookie with selected absolute expiry and redirect; later activity never reissues or extends it | browser cookie write | response | `gate-01` | `login-fail` |
### restore — Existing cookie restoration
- 시작 이벤트: 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
| 단계 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 `v`/`sub=dashboard-admin`/`iat`/`exp`/`jti` claims, current admin-ID/password/signing-secret binding and absolute `exp`; never refresh or extend on restoration, rerun or activity | no session or token write | none | `dashboard-01` | `cleanup-01` |
| `dashboard-01` | dashboard app | accepted token | resolve DB path and dispatch requested page | existing SQLite reads/writes | gateway/page code | `authenticated` | `dashboard-fail` |
### cleanup — Invalid cookie cleanup
- 시작 이벤트: gate rejects malformed, expired, tampered, claim-invalid, or config-failure cookie
- 선행조건: same-origin HTTPS cleanup request
- 실행 방식: synchronous request
- 트랜잭션·영속성 경계: no DB or token registry write
| 단계 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` |
## 실패와 복구
| 대상 단계 | 실패 조건 | 추가 쓰기 중단 | 재시도 계약 | 중복 방지 | 보상·수동 복구 | 최종 상태 |
|---|---|---|---|---|---|---|
| `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` | expired, malformed/tampered claims, or admin-ID/password/signing-secret rotation failure | yes | cleanup then login | matching deletion | user logs in again | `unauthenticated` |
| `cleanup-02` | non-HTTPS/cross-origin | yes | retry same origin | deletion is idempotent | browser clears manually if needed | `cleanup-reject` |
| `logout-02` | non-HTTPS/cross-origin | yes | retry same origin | deletion is idempotent | browser clears manually if needed | `logout-reject` |
### 동시성과 순서
- 동시 실행 허용 여부: 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
- 만료 불변조건: login 성공 시 고정한 `iat`와 `exp`는 복원·rerun·활동 동안 바뀌지 않으며 token을 재발급하거나 만료를 연장하지 않음
- rotation 불변조건: admin ID, password 또는 signing secret 변경은 이전 설정으로 발급한 모든 token을 무효화하고 `cleanup-01`로 전이
## 관찰 가능성
| 신호 종류 | 이름 | 발생 단계·조건 | 상관 식별자 | 진단 속성 | 민감정보 처리 |
|---|---|---|---|---|---|
| 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 |
## 검증
- 정상 흐름: 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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Jarca Ledger MCP server."""
__version__ = "0.1.0"
__version__ = "0.1.3"
+8 -5
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jarca-ledger-mcp"
version = "0.1.0"
version = "0.1.3"
description = "Jarca household ledger MCP server for transactions, accounts, budgets, cashflow, and investable cashflow analysis"
readme = "README.md"
requires-python = ">=3.11"
@@ -17,11 +17,14 @@ dependencies = [
]
[project.optional-dependencies]
dev = ["pytest>=8.0.0", "ruff>=0.6.0"]
dev = ["pytest>=8.0.0", "ruff>=0.6.0", "websockets>=15.0,<17.0"]
dashboard = [
"pandas>=2.0.0",
"plotly>=5.0.0",
"streamlit>=1.30.0",
"streamlit>=1.58.0,<1.59.0",
"plotly>=5.20.0",
"pandas>=2.2.0",
"itsdangerous>=2.2.0,<3.0",
"starlette>=0.40.0,<2.0",
"uvicorn>=0.30.0,<1.0",
]
[project.scripts]
+14 -13
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
"""CI wrapper for the Streamlit dashboard HTTP smoke gate.
"""CI wrapper for the production Dashboard ASGI HTTP smoke gate.
Creates a synthetic fixture DB, starts Streamlit against it, waits for HTTP 200,
and always attempts process cleanup. Output is intentionally status-only.
Creates a synthetic fixture DB, starts the authenticated Streamlit ASGI wrapper,
waits for HTTP 200, and always attempts process cleanup. Output is intentionally
status-only.
"""
from __future__ import annotations
@@ -26,7 +27,7 @@ from scripts.ci_fixture_db import create_ci_fixture_db
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run Streamlit dashboard HTTP smoke against a synthetic CI fixture DB.")
parser = argparse.ArgumentParser(description="Run the Dashboard ASGI HTTP smoke against a synthetic CI fixture DB.")
parser.add_argument("--host", default="127.0.0.1", help="Loopback host to bind")
parser.add_argument("--port", type=int, default=18501, help="Deterministic smoke port")
parser.add_argument("--timeout", type=int, default=45, help="Readiness timeout in seconds")
@@ -75,21 +76,21 @@ def main() -> int:
fixture = create_ci_fixture_db(fixture_path)
env = os.environ.copy()
env["JARCA_LEDGER_DB_PATH"] = str(fixture_path)
env["JARCA_DASHBOARD_ADMIN_ID"] = "a"
env["JARCA_DASHBOARD_ADMIN_PASSWORD"] = "p"
env["JARCA_DASHBOARD_SIGNING_SECRET"] = "s" * 32
env["FORWARDED_ALLOW_IPS"] = "127.0.0.1"
env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false"
cmd = [
sys.executable,
"-m",
"streamlit",
"run",
"dashboard/app.py",
"--server.address",
"uvicorn",
"dashboard.server:app",
"--host",
args.host,
"--server.port",
"--port",
str(args.port),
"--server.headless",
"true",
"--browser.gatherUsageStats",
"false",
"--proxy-headers",
]
proc = subprocess.Popen(
cmd,
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Exercise persistent-auth HTTP responses without printing secrets or tokens."""
from __future__ import annotations
import argparse
import http.client
import json
import os
import ssl
from urllib.parse import urlencode, urlsplit
from streamlit.proto.BackMsg_pb2 import BackMsg
from streamlit.proto.ClientState_pb2 import ClientState
from streamlit.proto.ForwardMsg_pb2 import ForwardMsg
from websockets.sync.client import connect
COOKIE_NAME = "__Host-jarca_dashboard_auth"
def _args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--base-url", default="https://127.0.0.1:18506")
return parser.parse_args()
def _connection(parsed):
if parsed.scheme != "https" or parsed.hostname not in {"127.0.0.1", "localhost"}:
raise SystemExit("Evidence target must be loopback HTTPS")
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return http.client.HTTPSConnection(parsed.hostname, parsed.port, context=context, timeout=10)
def _request(parsed, method: str, path: str, *, body: bytes | None = None, headers=None):
connection = _connection(parsed)
connection.request(method, path, body=body, headers=headers or {})
response = connection.getresponse()
payload = response.read()
result = response.status, {key.lower(): value for key, value in response.getheaders()}, payload
connection.close()
return result
def _assert_streamlit_restore(parsed, cookie_pair: str) -> int:
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
origin = f"https://{parsed.hostname}:{parsed.port}"
websocket_url = f"wss://{parsed.hostname}:{parsed.port}/_stcore/stream"
with connect(
websocket_url,
ssl=context,
origin=origin,
subprotocols=["streamlit"],
additional_headers={"Cookie": cookie_pair},
proxy=None,
open_timeout=30,
) as websocket:
back_message = BackMsg()
back_message.rerun_script.CopyFrom(
ClientState(query_string="page=%EC%9A%94%EC%95%BD")
)
websocket.send(back_message.SerializeToString())
messages: list[str] = []
for _ in range(100):
raw = websocket.recv(timeout=15)
if isinstance(raw, str):
continue
forward_message = ForwardMsg()
forward_message.ParseFromString(raw)
messages.append(str(forward_message))
if forward_message.HasField("script_finished"):
break
combined = "\n".join(messages)
if "이번 달 수입/지출 현황" not in combined or "관리자 로그인" in combined:
raise SystemExit("Signed cookie did not restore the authenticated Streamlit page")
return len(messages)
def main() -> None:
parsed = urlsplit(_args().base_url)
admin_id = os.environ["JARCA_DASHBOARD_ADMIN_ID"]
password = os.environ["JARCA_DASHBOARD_ADMIN_PASSWORD"]
origin = f"https://{parsed.hostname}:{parsed.port}"
body = urlencode(
{"admin_id": admin_id, "admin_password": password, "remember_me": "on"}
).encode()
status, headers, _ = _request(
parsed,
"POST",
"/api/auth/login",
body=body,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": str(len(body)),
"Origin": origin,
},
)
cookie_header = headers.get("set-cookie", "")
required = [COOKIE_NAME + "=", "HttpOnly", "Secure", "SameSite=lax", "Path=/", "Max-Age=2592000"]
if status != 303 or headers.get("location") != "/" or not all(
marker in cookie_header for marker in required
):
raise SystemExit("Login response did not satisfy the hardened cookie contract")
cookie_pair = cookie_header.split(";", 1)[0]
status, _, _ = _request(
parsed,
"GET",
"/",
headers={"Cookie": cookie_pair},
)
if status != 200:
raise SystemExit("Authenticated reconnect request failed")
websocket_messages = _assert_streamlit_restore(parsed, cookie_pair)
status, headers, _ = _request(
parsed,
"POST",
"/api/auth/logout",
headers={"Origin": origin, "Cookie": cookie_pair, "Content-Length": "0"},
)
deletion = headers.get("set-cookie", "")
if status != 303 or not all(
marker in deletion
for marker in [COOKIE_NAME + "=", "HttpOnly", "Secure", "SameSite=lax", "Path=/", "Max-Age=0"]
):
raise SystemExit("Logout response did not satisfy the cookie deletion contract")
print(
json.dumps(
{
"success": True,
"transport": "loopback HTTPS",
"login_status": 303,
"reconnect_status": 200,
"streamlit_websocket_authenticated": True,
"streamlit_messages": websocket_messages,
"logout_status": 303,
"cookie_name": COOKIE_NAME,
"cookie_value": "[REDACTED]",
"remember_me_max_age": 2592000,
},
indent=2,
)
)
if __name__ == "__main__":
main()
+8
View File
@@ -6,6 +6,7 @@ from streamlit.testing.v1 import AppTest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dashboard.app import get_transfer_review_items
from dashboard import authentication
from scripts.ci_fixture_db import create_ci_fixture_db
@@ -43,6 +44,13 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
monkeypatch.setenv(authentication.ADMIN_ID_ENV, "test-admin")
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "test-password")
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, "s" * 64)
settings = authentication.load_auth_settings()
assert settings is not None
token = authentication.issue_persistent_token(settings, remember_me=False).value
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
page_expectations = {
+386
View File
@@ -0,0 +1,386 @@
from __future__ import annotations
import asyncio
import sys
import threading
from collections.abc import Callable
from http.cookies import SimpleCookie
from pathlib import Path
import pytest
from starlette.testclient import TestClient
from starlette.requests import Request
ROOT_DIR = Path(__file__).resolve().parents[1]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from dashboard import authentication
from dashboard.auth_server import create_auth_routes, login
ADMIN_ID = "admin"
ADMIN_PASSWORD = "correct horse battery staple"
SIGNING_SECRET = "s" * 64
@pytest.fixture(autouse=True)
def _configured_auth(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(authentication.ADMIN_ID_ENV, ADMIN_ID)
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, ADMIN_PASSWORD)
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, SIGNING_SECRET)
monkeypatch.setattr(authentication.time, "sleep", lambda _seconds: None)
@pytest.fixture
def client() -> TestClient:
from starlette.applications import Starlette
return TestClient(
Starlette(routes=create_auth_routes()), base_url="https://testserver"
)
def _cookie_from(response) -> SimpleCookie[str]:
cookie: SimpleCookie[str] = SimpleCookie()
cookie.load(response.headers["set-cookie"])
return cookie
def test_successful_login_sets_hardened_24_hour_cookie(client: TestClient) -> None:
response = client.post(
"/api/auth/login",
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
headers={"origin": "https://testserver"},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/"
header = response.headers["set-cookie"]
assert f"{authentication.AUTH_COOKIE_NAME}=" in header
assert "HttpOnly" in header
assert "Secure" in header
assert "SameSite=lax" in header
assert "Path=/" in header
assert f"Max-Age={authentication.DEFAULT_TOKEN_TTL_SECONDS}" in header
assert ADMIN_PASSWORD not in header
assert SIGNING_SECRET not in header
def test_remember_me_sets_30_day_cookie(client: TestClient) -> None:
response = client.post(
"/api/auth/login",
data={
"admin_id": ADMIN_ID,
"admin_password": ADMIN_PASSWORD,
"remember_me": "on",
},
headers={"origin": "https://testserver"},
follow_redirects=False,
)
assert response.status_code == 303
assert (
f"Max-Age={authentication.REMEMBER_ME_TOKEN_TTL_SECONDS}"
in response.headers["set-cookie"]
)
def test_unicode_credentials_can_login_through_adapter(client: TestClient, monkeypatch) -> None:
unicode_id = "管理员"
unicode_password = "päss🔐"
monkeypatch.setenv(authentication.ADMIN_ID_ENV, unicode_id)
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, unicode_password)
response = client.post(
"/api/auth/login",
data={"admin_id": unicode_id, "admin_password": unicode_password},
headers={"origin": "https://testserver"},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/"
assert f"{authentication.AUTH_COOKIE_NAME}=" in response.headers["set-cookie"]
def test_unicode_credential_mismatch_is_generic_and_delayed(
client: TestClient, monkeypatch
) -> None:
import time as real_time
unicode_id = "管理员"
unicode_password = "päss🔐"
monkeypatch.setenv(authentication.ADMIN_ID_ENV, unicode_id)
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, unicode_password)
monkeypatch.setattr(authentication.time, "sleep", real_time.sleep)
started = real_time.monotonic()
response = client.post(
"/api/auth/login",
data={"admin_id": unicode_id, "admin_password": "неверный🔒"},
headers={"origin": "https://testserver"},
follow_redirects=False,
)
elapsed = real_time.monotonic() - started
assert response.status_code == 303
assert response.headers["location"] == "/?auth_error=invalid"
assert "set-cookie" not in response.headers
assert elapsed >= authentication.MINIMUM_FAILURE_DELAY_SECONDS * 0.9
def test_invalid_credentials_do_not_set_cookie_and_use_general_error(
client: TestClient,
) -> None:
response = client.post(
"/api/auth/login",
data={"admin_id": ADMIN_ID, "admin_password": "wrong"},
headers={"origin": "https://testserver"},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/?auth_error=invalid"
assert "set-cookie" not in response.headers
assert "password" not in response.headers["location"]
assert ADMIN_ID not in response.headers["location"]
def test_login_rejects_malformed_percent_encoded_utf8_without_cookie(
client: TestClient,
) -> None:
response = client.post(
"/api/auth/login",
content=b"admin_id=%FF&admin_password=wrong",
headers={
"origin": "https://testserver",
"content-type": "application/x-www-form-urlencoded",
},
follow_redirects=False,
)
assert response.status_code == 400
assert "set-cookie" not in response.headers
def test_missing_configuration_fails_closed(client: TestClient, monkeypatch) -> None:
monkeypatch.delenv(authentication.SIGNING_SECRET_ENV)
response = client.post(
"/api/auth/login",
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
headers={"origin": "https://testserver"},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/?auth_error=unavailable"
assert "set-cookie" not in response.headers
def test_cross_site_login_is_rejected(client: TestClient) -> None:
response = client.post(
"/api/auth/login",
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
headers={"origin": "https://attacker.example"},
follow_redirects=False,
)
assert response.status_code == 403
assert "set-cookie" not in response.headers
def test_different_port_is_not_same_origin(client: TestClient) -> None:
response = client.post(
"/api/auth/login",
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
headers={"origin": "https://testserver:444"},
follow_redirects=False,
)
assert response.status_code == 403
assert "set-cookie" not in response.headers
def test_plain_http_is_rejected_even_with_spoofed_https_origin() -> None:
from starlette.applications import Starlette
with TestClient(
Starlette(routes=create_auth_routes()), base_url="http://testserver"
) as http_client:
response = http_client.post(
"/api/auth/login",
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
headers={
"origin": "https://testserver",
"x-forwarded-proto": "https",
},
follow_redirects=False,
)
assert response.status_code == 403
assert "set-cookie" not in response.headers
def test_logout_clears_cookie_with_matching_scope(client: TestClient) -> None:
response = client.post(
"/api/auth/logout",
headers={"origin": "https://testserver"},
follow_redirects=False,
)
assert response.status_code == 303
header = response.headers["set-cookie"]
assert f"{authentication.AUTH_COOKIE_NAME}=" in header
assert "Max-Age=0" in header
assert "HttpOnly" in header
assert "Secure" in header
assert "SameSite=lax" in header
assert "Path=/" in header
def test_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",
data={"admin_id": ADMIN_ID, "admin_password": ADMIN_PASSWORD},
headers={"origin": "https://testserver"},
follow_redirects=False,
)
cookie = _cookie_from(response)
token = cookie[authentication.AUTH_COOKIE_NAME].value
assert authentication.verify_persistent_token(
authentication.load_auth_settings(), token
) is not None
def _streaming_login_request(
chunks: list[bytes],
) -> tuple[Request, Callable[[], int]]:
messages = [
{
"type": "http.request",
"body": chunk,
"more_body": index < len(chunks) - 1,
}
for index, chunk in enumerate(chunks)
]
calls = 0
async def receive():
nonlocal calls
calls += 1
return messages.pop(0)
scope = {
"type": "http",
"method": "POST",
"scheme": "https",
"path": "/api/auth/login",
"raw_path": b"/api/auth/login",
"query_string": b"",
"headers": [
(b"host", b"testserver"),
(b"origin", b"https://testserver"),
(b"content-type", b"application/x-www-form-urlencoded"),
],
"client": ("testclient", 50000),
"server": ("testserver", 443),
}
return Request(scope, receive), lambda: calls
def test_login_offloads_failed_login_delay_from_event_loop(monkeypatch) -> None:
entered = threading.Event()
release = threading.Event()
probe_ran = threading.Event()
probe_before_release = threading.Event()
def blocking_delay(*, started_at):
entered.set()
if probe_ran.wait(timeout=1):
probe_before_release.set()
release.set()
monkeypatch.setattr(authentication, "enforce_failed_login_delay", blocking_delay)
async def exercise() -> None:
request, _ = _streaming_login_request([b"admin_id=admin&admin_password=wrong"])
task = asyncio.create_task(login(request))
async def probe() -> None:
probe_ran.set()
asyncio.create_task(probe())
assert await asyncio.to_thread(entered.wait, 1)
release.set()
response = await task
assert response.status_code == 303
assert response.headers["location"] == "/?auth_error=invalid"
assert "set-cookie" not in response.headers
asyncio.run(exercise())
assert probe_before_release.is_set()
def test_login_rejects_exactly_one_byte_over_streaming_limit(monkeypatch) -> None:
def unexpected_verification(*_args, **_kwargs):
raise AssertionError("oversized body reached credential verification")
monkeypatch.setattr(authentication, "verify_credentials", unexpected_verification)
request, calls = _streaming_login_request([b"x" * 4097])
response = asyncio.run(login(request))
assert response.status_code == 400
assert "set-cookie" not in response.headers
assert calls() == 1
def test_login_accepts_body_at_streaming_limit() -> None:
prefix = b"admin_id=admin&admin_password="
body = prefix + (b"x" * (4096 - len(prefix)))
assert len(body) == 4096
request, calls = _streaming_login_request([body])
response = asyncio.run(login(request))
assert response.status_code == 303
assert calls() == 1
def test_login_stops_reading_stream_after_limit_is_exceeded() -> None:
request, calls = _streaming_login_request([b"x" * 4096, b"y", b"z"])
response = asyncio.run(login(request))
assert response.status_code == 400
assert calls() == 2
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
from pathlib import Path
import sys
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dashboard import authentication
ADMIN_ID = "test-admin"
ADMIN_PASSWORD = "test-password"
def test_load_admin_credentials_uses_only_required_environment_variables():
credentials = authentication.load_admin_credentials(
{
"JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID,
"JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD,
"auth.admin_id": "ignored-streamlit-id",
"auth.admin_password": "ignored-streamlit-password",
}
)
assert credentials.admin_id == ADMIN_ID
assert credentials.password == ADMIN_PASSWORD
@pytest.mark.parametrize(
"environ",
[
{},
{"JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID},
{"JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD},
{"JARCA_DASHBOARD_ADMIN_ID": "", "JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD},
{"JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID, "JARCA_DASHBOARD_ADMIN_PASSWORD": ""},
{"JARCA_DASHBOARD_ADMIN_ID": 123, "JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD},
{"JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID, "JARCA_DASHBOARD_ADMIN_PASSWORD": object()},
],
)
def test_load_admin_credentials_fails_closed_for_missing_empty_or_nonstring_values(environ):
with pytest.raises(authentication.AuthenticationConfigurationError) as exc_info:
authentication.load_admin_credentials(environ)
assert str(exc_info.value) == authentication.CONFIGURATION_ERROR_MESSAGE
assert ADMIN_ID not in str(exc_info.value)
assert ADMIN_PASSWORD not in str(exc_info.value)
def test_load_admin_credentials_fails_closed_when_environment_cannot_be_read():
class UnreadableEnvironment:
def get(self, key):
raise OSError("secret backend unavailable")
with pytest.raises(authentication.AuthenticationConfigurationError) as exc_info:
authentication.load_admin_credentials(UnreadableEnvironment())
assert str(exc_info.value) == authentication.CONFIGURATION_ERROR_MESSAGE
assert "secret backend" not in str(exc_info.value)
def test_verify_credentials_uses_constant_time_comparison_for_both_fields(monkeypatch):
credentials = authentication.AdminCredentials(ADMIN_ID, ADMIN_PASSWORD)
comparisons: list[tuple[bytes, bytes]] = []
def record_compare(left: bytes, right: bytes) -> bool:
comparisons.append((left, right))
return left == right
monkeypatch.setattr(authentication.hmac, "compare_digest", record_compare)
assert authentication.verify_credentials(credentials, ADMIN_ID, ADMIN_PASSWORD) is True
assert comparisons == [
(ADMIN_ID.encode("utf-8"), ADMIN_ID.encode("utf-8")),
(ADMIN_PASSWORD.encode("utf-8"), ADMIN_PASSWORD.encode("utf-8")),
]
comparisons.clear()
assert authentication.verify_credentials(credentials, "wrong-id", "wrong-password") is False
assert comparisons == [
(b"wrong-id", ADMIN_ID.encode("utf-8")),
(b"wrong-password", ADMIN_PASSWORD.encode("utf-8")),
]
def test_failed_login_delay_waits_only_for_remaining_minimum_duration():
sleeps: list[float] = []
authentication.enforce_failed_login_delay(
started_at=10.0,
minimum_seconds=1.0,
monotonic=lambda: 10.25,
sleep=sleeps.append,
)
assert sleeps == [pytest.approx(0.75)]
def test_failed_login_delay_does_not_sleep_after_minimum_duration():
sleeps: list[float] = []
authentication.enforce_failed_login_delay(
started_at=10.0,
minimum_seconds=1.0,
monotonic=lambda: 11.25,
sleep=sleeps.append,
)
assert sleeps == []
def test_authentication_correlation_id_is_noncredential_session_state():
session_state: dict[str, object] = {}
correlation_id = authentication.get_auth_correlation_id(session_state, token_hex=lambda size: "safe-correlation")
assert correlation_id == "safe-correlation"
assert session_state == {authentication.AUTH_CORRELATION_SESSION_KEY: "safe-correlation"}
def test_authentication_event_log_contains_only_event_and_correlation(monkeypatch):
calls: list[tuple[str, tuple[object, ...]]] = []
monkeypatch.setattr(authentication.logger, "info", lambda message, *args: calls.append((message, args)))
authentication.log_auth_event("login_failed", "safe-correlation")
assert calls == [("dashboard_auth event=%s correlation_id=%s", ("login_failed", "safe-correlation"))]
assert ADMIN_ID not in repr(calls)
assert ADMIN_PASSWORD not in repr(calls)
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
from pathlib import Path
import sys
from streamlit.testing.v1 import AppTest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from dashboard import authentication
ADMIN_ID = "test-admin"
ADMIN_PASSWORD = "test-password"
SIGNING_SECRET = "s" * 64
APP_PATH = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
def _body_text(at: AppTest) -> str:
elements = (
list(at.title)
+ list(at.header)
+ list(at.subheader)
+ list(at.text)
+ list(at.markdown)
+ list(at.caption)
+ list(at.error)
)
return "\n".join(str(item.value) for item in elements)
def _configure(monkeypatch) -> authentication.AuthSettings:
monkeypatch.setenv(authentication.ADMIN_ID_ENV, ADMIN_ID)
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, ADMIN_PASSWORD)
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, SIGNING_SECRET)
settings = authentication.load_auth_settings()
assert settings is not None
return settings
def _app_with_cookie(monkeypatch, token: str | None, *, page: str = "요약") -> AppTest:
_configure(monkeypatch)
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
at = AppTest.from_file(str(APP_PATH), default_timeout=30)
at.query_params["page"] = page
return at.run()
def test_missing_authentication_configuration_fails_closed(monkeypatch, tmp_path):
from scripts.ci_fixture_db import create_ci_fixture_db
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.delenv(authentication.ADMIN_ID_ENV, raising=False)
monkeypatch.delenv(authentication.ADMIN_PASSWORD_ENV, raising=False)
monkeypatch.delenv(authentication.SIGNING_SECRET_ENV, raising=False)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
at = AppTest.from_file(str(APP_PATH), default_timeout=30).run()
assert not at.exception
assert "관리자 로그인" in _body_text(at)
assert authentication.CONFIGURATION_ERROR_MESSAGE in _body_text(at)
assert "이번 달 수입/지출 현황" not in _body_text(at)
def test_main_does_not_resolve_database_or_dispatch_when_authentication_rejects(
monkeypatch,
):
from dashboard import app
monkeypatch.setattr(app, "require_dashboard_authentication", lambda: False)
def forbidden_database_access():
raise AssertionError("get_db_path must not run before authentication")
monkeypatch.setattr(app, "get_db_path", forbidden_database_access)
app.main()
def test_query_parameter_cannot_bypass_login_or_reach_import_page(monkeypatch):
at = _app_with_cookie(monkeypatch, None, page="Import")
assert not at.exception
assert "관리자 로그인" in _body_text(at)
assert "PDF Import" not in _body_text(at)
assert "Import 이력" not in _body_text(at)
def test_login_form_posts_credentials_to_server_side_adapter(monkeypatch):
at = _app_with_cookie(monkeypatch, None)
body = _body_text(at)
assert 'action="/api/auth/login"' in body
assert 'method="post"' in body
assert 'name="admin_id"' in body
assert 'name="admin_password"' in body
assert 'name="remember_me"' in body
assert ADMIN_ID not in repr(at.session_state)
assert ADMIN_PASSWORD not in repr(at.session_state)
assert SIGNING_SECRET not in repr(at.session_state)
def test_invalid_cookie_blocks_database_and_page_dispatch(monkeypatch):
at = _app_with_cookie(monkeypatch, "malformed-token")
assert not at.exception
assert "관리자 로그인" in _body_text(at)
assert "이번 달 수입/지출 현황" not in _body_text(at)
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(
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=False, now=1_700_000_000
).value
monkeypatch.setattr(authentication.time, "time", lambda: 1_700_000_001)
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
at = AppTest.from_file(str(APP_PATH), default_timeout=30).run()
assert not at.exception
assert "이번 달 수입/지출 현황" in _body_text(at)
assert 'action="/api/auth/logout"' in _body_text(at)
assert token not in repr(at.session_state)
assert ADMIN_PASSWORD not in repr(at.session_state)
assert SIGNING_SECRET not in repr(at.session_state)
def test_password_rotation_invalidates_existing_cookie(monkeypatch, tmp_path):
from scripts.ci_fixture_db import create_ci_fixture_db
fixture = tmp_path / "fixture.sqlite3"
create_ci_fixture_db(fixture)
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture))
settings = _configure(monkeypatch)
token = authentication.issue_persistent_token(settings, remember_me=True).value
monkeypatch.setattr(authentication, "_request_cookie", lambda: token)
at = AppTest.from_file(str(APP_PATH), default_timeout=30).run()
assert "이번 달 수입/지출 현황" in _body_text(at)
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, "rotated-password")
at = at.run()
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)
@@ -0,0 +1,179 @@
from __future__ import annotations
import json
import sys
from dataclasses import replace
from pathlib import Path
import pytest
ROOT_DIR = Path(__file__).resolve().parents[1]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from dashboard import authentication
ADMIN_ID = "admin"
ADMIN_PASSWORD = "correct horse battery staple"
SIGNING_SECRET = "s" * 64
def _settings() -> authentication.AuthSettings:
return authentication.AuthSettings(
admin_id=ADMIN_ID,
admin_password=ADMIN_PASSWORD,
signing_secret=SIGNING_SECRET,
)
def test_load_auth_settings_requires_signing_secret(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv(authentication.ADMIN_ID_ENV, ADMIN_ID)
monkeypatch.setenv(authentication.ADMIN_PASSWORD_ENV, ADMIN_PASSWORD)
monkeypatch.delenv(authentication.SIGNING_SECRET_ENV, raising=False)
assert authentication.load_auth_settings() is None
monkeypatch.setenv(authentication.SIGNING_SECRET_ENV, SIGNING_SECRET)
assert authentication.load_auth_settings() == _settings()
def test_issue_and_verify_default_token_without_refresh() -> None:
settings = _settings()
issued = authentication.issue_persistent_token(
settings,
remember_me=False,
now=1_700_000_000,
token_id="fixed-token-id",
)
verified = authentication.verify_persistent_token(
settings,
issued.value,
now=1_700_000_001,
)
assert verified is not None
assert verified.issued_at == 1_700_000_000
assert verified.expires_at == 1_700_086_400
assert issued.max_age == authentication.DEFAULT_TOKEN_TTL_SECONDS
assert verified.token_id == "fixed-token-id"
assert ADMIN_PASSWORD not in issued.value
assert SIGNING_SECRET not in issued.value
def test_remember_me_token_uses_30_day_absolute_expiry() -> None:
issued = authentication.issue_persistent_token(
_settings(),
remember_me=True,
now=1_700_000_000,
token_id="fixed-token-id",
)
assert issued.expires_at == 1_702_592_000
assert issued.max_age == authentication.REMEMBER_ME_TOKEN_TTL_SECONDS
def test_token_expiry_is_absolute_and_not_sliding() -> None:
settings = _settings()
issued = authentication.issue_persistent_token(
settings,
remember_me=False,
now=1_700_000_000,
token_id="fixed-token-id",
)
assert authentication.verify_persistent_token(
settings, issued.value, now=issued.expires_at - 1
) is not None
assert authentication.verify_persistent_token(
settings, issued.value, now=issued.expires_at
) is None
def test_tampering_and_rotations_invalidate_token() -> None:
settings = _settings()
issued = authentication.issue_persistent_token(
settings,
remember_me=True,
now=1_700_000_000,
token_id="fixed-token-id",
)
replacement = "A" if issued.value[-1] != "A" else "B"
tampered = issued.value[:-1] + replacement
assert authentication.verify_persistent_token(
settings, tampered, now=1_700_000_001
) is None
assert authentication.verify_persistent_token(
replace(settings, signing_secret="n" * 64),
issued.value,
now=1_700_000_001,
) is None
assert authentication.verify_persistent_token(
replace(settings, admin_password="rotated password"),
issued.value,
now=1_700_000_001,
) is None
assert authentication.verify_persistent_token(
replace(settings, admin_id="rotated-admin"),
issued.value,
now=1_700_000_001,
) is None
@pytest.mark.parametrize(
"payload",
[
{},
{"v": 2, "sub": ADMIN_ID, "iat": 1, "exp": 2, "jti": "x"},
{"v": 1, "sub": "", "iat": 1, "exp": 2, "jti": "x"},
{"v": 1, "sub": ADMIN_ID, "iat": "1", "exp": 2, "jti": "x"},
{"v": 1, "sub": ADMIN_ID, "iat": 2, "exp": 2, "jti": "x"},
{"v": 1, "sub": ADMIN_ID, "iat": 1, "exp": 2, "jti": ""},
{"v": 1, "sub": ADMIN_ID, "iat": 1, "exp": 2, "jti": "x", "extra": 1},
],
)
def test_claim_schema_is_strict(payload: dict[str, object]) -> None:
token = authentication._serializer_for(_settings()).dumps(payload)
assert authentication.verify_persistent_token(
_settings(), token, now=1
) is None
def test_oversized_and_malformed_tokens_are_rejected() -> None:
settings = _settings()
assert authentication.verify_persistent_token(settings, "x" * 10_000, now=1) is None
assert authentication.verify_persistent_token(settings, "not-a-token", now=1) is None
def test_payload_does_not_encode_secrets() -> None:
settings = replace(_settings(), admin_id="configured-admin-identity")
issued = authentication.issue_persistent_token(
settings,
remember_me=False,
now=1_700_000_000,
token_id="fixed-token-id",
)
payload = authentication._serializer_for(settings).loads(issued.value)
assert set(payload) == {"v", "sub", "iat", "exp", "jti"}
assert "password" not in json.dumps(payload).lower()
assert "secret" not in json.dumps(payload).lower()
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
Generated
+12 -4
View File
@@ -469,7 +469,7 @@ wheels = [
[[package]]
name = "jarca-ledger-mcp"
version = "0.1.0"
version = "0.1.3"
source = { editable = "." }
dependencies = [
{ name = "mcp" },
@@ -481,27 +481,35 @@ dependencies = [
[package.optional-dependencies]
dashboard = [
{ name = "itsdangerous" },
{ name = "pandas" },
{ name = "plotly" },
{ name = "starlette" },
{ name = "streamlit" },
{ name = "uvicorn" },
]
dev = [
{ name = "pytest" },
{ name = "ruff" },
{ name = "websockets" },
]
[package.metadata]
requires-dist = [
{ name = "itsdangerous", marker = "extra == 'dashboard'", specifier = ">=2.2.0,<3.0" },
{ name = "mcp", specifier = ">=1.0.0" },
{ name = "msoffcrypto-tool", specifier = ">=5.4.0" },
{ name = "openpyxl", specifier = ">=3.1.0" },
{ name = "pandas", marker = "extra == 'dashboard'", specifier = ">=2.0.0" },
{ name = "plotly", marker = "extra == 'dashboard'", specifier = ">=5.0.0" },
{ name = "pandas", marker = "extra == 'dashboard'", specifier = ">=2.2.0" },
{ name = "plotly", marker = "extra == 'dashboard'", specifier = ">=5.20.0" },
{ name = "pymupdf", specifier = ">=1.24.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.0" },
{ name = "streamlit", marker = "extra == 'dashboard'", specifier = ">=1.30.0" },
{ name = "starlette", marker = "extra == 'dashboard'", specifier = ">=0.40.0,<2.0" },
{ name = "streamlit", marker = "extra == 'dashboard'", specifier = ">=1.58.0,<1.59.0" },
{ name = "uvicorn", marker = "extra == 'dashboard'", specifier = ">=0.30.0,<1.0" },
{ name = "websockets", marker = "extra == 'dev'", specifier = ">=15.0,<17.0" },
]
provides-extras = ["dev", "dashboard"]