From 317508c60fe48a8527cb676cbfc4b3f11da01b01 Mon Sep 17 00:00:00 2001 From: geb Date: Fri, 4 Sep 2026 23:19:22 +0800 Subject: [PATCH] auth: PIN login gate - one-time login issues in-memory session tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/services/sessions.py: module-level token set (secrets.token_urlsafe), no persistence - a restart invalidates every token, and since config loads once at startup a PIN change is a restart, so one rule covers both re-prompt triggers - require_pin additively accepts a valid token in X-Session-Token; the raw X-API-PIN header path is unchanged - new GET /auth/status (always 200; pin_required + session_valid true/false/null) and POST /auth/login (401 wrong PIN, 400 when no PIN is configured); router mounted in app/main.py - web: shared gate overlay (GATE_HTML) on both pages styled as a third notebook page, Remember checkbox picks localStorage vs sessionStorage, the old per-action id="pin" field and its JS read sites are gone in favor of one authHeaders() helper, 401 mid-action re-raises the gate, and checkGate() rides the 30 s health poll for restart re-prompt - docs: LOGIN_PLAN.md decision record (review + as-built), WEBDESIGN_PLAN §12 addendum, SOURCE_OF_TRUTH §8/§11, README/.env.example API_PIN text - tests: new unit (sessions, token path of require_pin), API (/auth/status, /auth/login, token-unlocks-route integration) and web gate tests plus an autouse fresh_sessions fixture; suite 316 -> 378, coverage 95.75 %, ruff clean --- .env.example | 5 +- README.md | 2 +- app/api/auth.py | 53 +++++ app/api/web.py | 233 ++++++++++++++++---- app/main.py | 7 + app/models/auth.py | 28 +++ app/services/auth.py | 17 +- app/services/sessions.py | 48 +++++ docs/LOGIN_PLAN.md | 401 +++++++++++++++++++++++++++++++++++ docs/SOURCE_OF_TRUTH.md | 3 + docs/WEBDESIGN_PLAN.md | 51 ++++- tests/api/test_auth_api.py | 100 +++++++++ tests/api/test_health_web.py | 59 ++++++ tests/conftest.py | 14 +- tests/unit/test_auth.py | 46 +++- tests/unit/test_sessions.py | 36 ++++ 16 files changed, 1047 insertions(+), 56 deletions(-) create mode 100644 app/api/auth.py create mode 100644 app/models/auth.py create mode 100644 app/services/sessions.py create mode 100644 docs/LOGIN_PLAN.md create mode 100644 tests/api/test_auth_api.py create mode 100644 tests/unit/test_sessions.py diff --git a/.env.example b/.env.example index 5514657..78116f8 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,9 @@ PORT=8000 # Max upload size in megabytes (README Section 8) MAX_UPLOAD_MB=25 -# Optional shared PIN the phone must send with each request (README Section 8). -# Leave empty to disable authentication entirely. +# Optional shared PIN (README Section 8). When set, the web UI shows a +# one-time login gate that exchanges it for a session token; raw API +# requests may still send the X-API-PIN header. Empty = auth disabled. API_PIN= # Printer to submit jobs to (Phase 5). Empty = the Windows default printer. diff --git a/README.md b/README.md index 1322383..0230de8 100644 --- a/README.md +++ b/README.md @@ -269,7 +269,7 @@ Copy `.env.example` to `.env` to configure optional server settings. All keys ar | Key | Default | Description | |---|---|---| | `MAX_UPLOAD_MB` | `25` | Maximum allowed upload size in megabytes (larger files rejected with HTTP 413). | -| `API_PIN` | *(empty)* | Optional security PIN. When set, requests require the `X-API-PIN` header (the web interface will display a PIN input field). | +| `API_PIN` | *(empty)* | Optional security PIN. When set, the web interface shows a one-time login gate (the PIN is exchanged for a session token, then never sent again); API clients may still send the raw `X-API-PIN` header. Leave empty to disable authentication entirely. | | `PRINTER_NAME` | *(empty)* | Specific Windows printer name to target. If empty, the system default printer is used. | | `SUMATRA_PATH` | *(empty)* | Custom path to `SumatraPDF.exe`. Leave empty to use automatic standard path detection. | | `PAPER_SIZE` | *(empty)* | Default paper size token passed to driver (e.g. `A4`). Leave empty to let the Windows driver choose. | diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..90c05c1 --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,53 @@ +"""Auth endpoints for the PIN login gate (docs/LOGIN_PLAN.md §4). + +Two endpoints, same conventions as /scanners and /print: + +- GET /auth/status — the endpoint the page calls before rendering + anything. Never requires the PIN itself and never errors (always 200), + mirroring /scanners' "must be checkable before you know whether to show + UI for it" role. +- POST /auth/login — the only place the raw PIN is ever submitted. + Deliberately open (you can't require the PIN to submit the PIN); abuse + throttling is a Phase 4 add-on per LOGIN_PLAN §7/§11. +""" + +import hmac + +from fastapi import APIRouter, Header, HTTPException + +from app.config import API_PIN +from app.models.auth import AuthStatus, LoginRequest, LoginResponse +from app.services import sessions + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.get("/status", response_model=AuthStatus) +def auth_status( + x_session_token: str | None = Header(default=None), +) -> AuthStatus: + """Report whether the gate exists and whether a presented token is + still good. session_valid is None when no token was sent or when no + PIN is configured (sessions can only exist while a PIN does, so the + field is simply not meaningful then).""" + pin_required = bool(API_PIN) + session_valid = None + if pin_required and x_session_token: + session_valid = sessions.is_valid(x_session_token) + return AuthStatus(pin_required=pin_required, session_valid=session_valid) + + +@router.post("/login", response_model=LoginResponse) +def login(request: LoginRequest) -> LoginResponse: + """Exchange the raw PIN for an opaque session token (one per device). + + 401 on a wrong PIN with a clear message; 400 when no PIN is + configured at all, since there is nothing to log in to.""" + if not API_PIN: + raise HTTPException( + status_code=400, + detail="No PIN is configured — the login gate is disabled.", + ) + if not request.pin or not hmac.compare_digest(request.pin, API_PIN): + raise HTTPException(status_code=401, detail="Incorrect PIN.") + return LoginResponse(token=sessions.create_session()) diff --git a/app/api/web.py b/app/api/web.py index 5936b95..c1ab9e2 100644 --- a/app/api/web.py +++ b/app/api/web.py @@ -32,12 +32,16 @@ multipart boundary itself; setting it manually breaks the request. 4. The response is the JSON from POST /print, which we display. -The JS logic (upload, PIN header, polling, scan detection, innerHTML -safety) carries over from the previous page — this revision restyled -presentation and status rendering (emoji status markers are gone: status -is icon + exact API word + pen color), and added a Jobs list (GET /jobs, -already served by app/api/jobs.py) written on the ruled lines with -per-state ink colors and a cancel button for active jobs. +The JS logic (upload, session-token auth, polling, scan detection, +innerHTML safety) carries over from the previous page — this revision +restyled presentation and status rendering (emoji status markers are +gone: status is icon + exact API word + pen color), and added a Jobs list +(GET /jobs, already served by app/api/jobs.py) written on the ruled lines +with per-state ink colors and a cancel button for active jobs. The PIN +login gate (docs/LOGIN_PLAN.md) then replaced the old type-it-every-time +PIN field: the raw PIN is submitted once to POST /auth/login, the opaque +token it returns is stored client-side, and every request sends it in +X-Session-Token via the shared authHeaders() helper. """ import base64 @@ -347,6 +351,17 @@ def _icon_sprite() -> str: text-decoration: none; color: inherit; } .topright { display: flex; align-items: center; gap: 12px; } .btn.nav { min-height: 44px; padding: 6px 16px; font-size: 17px; } + + /* ---- the login gate (LOGIN_PLAN §6): a third page, not a dialog ---- + Full-viewport takeover on the same paper — never a floating card + (WEBDESIGN_PLAN: no cards, shadows, or boxed containers). */ + .gate { position: fixed; inset: 0; z-index: 40; background: var(--paper); + overflow: auto; } + .gate-remember { display: flex; align-items: center; gap: 9px; + margin: 2px 0 16px 8px; font-size: 14px; + color: var(--graphite); cursor: pointer; } + .gate-remember input { width: 18px; height: 18px; flex: none; + accent-color: var(--ink-blue); } @@ -378,12 +393,6 @@ def _icon_sprite() -> str: -
- - -