diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-18 08:27:41 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-18 08:27:41 +0800 |
| commit | 088735b948d46896b8af30efcb0a2dc5d362b97f (patch) | |
| tree | 0dcab0d5ebc65309267a42d7cda827e9fdd866e7 /docs/backend-auth.md | |
| parent | bf6681eb29ac8b0c80ca17b2b5869f7de3da1198 (diff) | |
| download | strategy-lab-088735b948d46896b8af30efcb0a2dc5d362b97f.tar.gz | |
docs(release): 全周期交接文档入库(含 ui-shadcn 迁移交付说明)
[变更性质]
纯文档提交,无运行时逻辑。
[文档内容]
补齐此前各轮未入库的交接/验收文档:backend-auth/backend-domain/
domain-authorization-user(认证与授权域)、etf-recovery-release-
handoff(ETF 修复 + ops 演练定稿与生产部署命令)、recovery-* 系列、
frontend/parent-ui-findings(UI 迁移上下文)、worker/integration 等,
以及本轮 docs/ui-shadcn-handoff.md(shadcn-svelte 迁移交接,含
Chart.svelte 契约、runes $state 踩坑记录与 375/768/1440 验证证据)。
[更新方案]
按主题分文;每份文档只记录可复现的命令、验证结果与语义边界,
不导出密钥或生产敏感路径。
[影响范围]
文档渠道:后续 leader/client 审阅入口;与代码提交一一对应便于回溯。
Diffstat (limited to 'docs/backend-auth.md')
| -rw-r--r-- | docs/backend-auth.md | 98 |
1 files changed, 98 insertions, 0 deletions
diff --git a/docs/backend-auth.md b/docs/backend-auth.md new file mode 100644 index 0000000..a26cdbc --- /dev/null +++ b/docs/backend-auth.md @@ -0,0 +1,98 @@ +# backend-auth.md — AUTH worker handoff (auth.rs + admin.rs) + +Status: both modules compile clean (`cargo check` shows zero diagnostics for +auth.rs/admin.rs, lib and test profiles). 9 security unit tests exist inside +the two modules (they compile; running the full `cargo test` target is still +blocked by compile errors in CORE/DOMAIN modules: store.rs:179, jobs.rs, main.rs, +ai.rs, datasets.rs — not AUTH-owned. Parent final compile gate will unblock them.) + +## What is implemented + +### auth.rs +- `pub const COOKIE_NAME = "sl_session"`. +- `AuthUser` extractor: reads cookie, hashes the token with sha256, resolves + `sessions JOIN users` requiring unexpired session and `users.active=1`. + Rejected tokens return 401; disabled accounts lose the session immediately. +- Argon2id password hash/verify (`hash_password`, `verify_password`), fresh + random salt per call. +- Sessions: random 256-bit token; **only `sha256(token)` is stored** (sessions.id). + Cookie is HttpOnly + SameSite=Strict + Path=/ + Max-Age, plus `Secure` when + `cfg.secure_cookies` (set from https canonical origin). +- `make_admin_token(cx, table, user_id, hours, email)` — shared factory for + invitations and password resets: raw token shown once, only the sha256 is + inserted; invitations are always stored as role 'member'. +- `purge_expired` — housekeeping for expired sessions/invitations/resets and + 2-hour-old login failures. +- Handlers: + - `POST /auth/login` — trims/lowercases email, per-email failure throttle + (>20 failures in 1h ⇒ 429), verifies Argon2 hash, rejects `active=0` + accounts with 403, issues session, writes audit. + - `POST /auth/register` — **single-use atomically consumed invitation with + email binding**: inside ONE closure transaction it checks the token exists, + role is 'member', bound email matches (case-insensitive) when set, expiry; + inserts the user with role='member' (the struct has no role field, so + client-supplied `"role":"admin"` is ignored by serde en bloc) and marks the + invite used via guarded `UPDATE ... WHERE used_by IS NULL`, rejecting the + second concurrent attempt with 409. Duplicate emails → 409 `email_taken`. + - `POST /auth/logout` — deletes the server-side session row, clears cookie. + - `GET /auth/me`, `PATCH /auth/profile` — owner-scoped profile read/update, + no password hash/secret in payload. + - `POST /auth/password` — verifies current password; updates hash and + revokes ALL other sessions in one transaction, keeping current session. + - `GET /auth/sessions` — sanitized own-session listing (sha256-derived ids, + no raw tokens, expired rows never listed). + - `DELETE /auth/sessions/:id` — revokes an OWN session only; foreign id ⇒ 404. + - `POST /auth/reset-password` — single-use token, expiry check, marks + `used=1`, updates password hash and deletes ALL sessions of the user + inside ONE transaction (replay of the token loses the update race). + +### admin.rs +- `assert_admin(&Cx, &AuthUser)` guards every admin handler (member ⇒ 403). +- `GET /admin/users` — full user table (no password hashes). +- `PATCH /admin/users/:id` — accepts {active?, role?, daily_run_limit?, + ai_enabled?}; validates role admin|member and 1..=2000 limit; **prevents + demotion/disable of the LAST active admin atomically** (count check inside + the same transaction as the update, code `last_admin` 409); disabling + revokes all sessions and cancels that user's queued/running runs in the + same transaction. +- `POST /admin/invitations` — POC invites mint member only (admin role via + invitation rejected 400); token returned once, sha256 stored. +- `GET /admin/invitations` — sanitized list, no token hashes. +- `DELETE /admin/invitations/:id` — revoke; 404 when absent. +- `POST /admin/users/:id/reset-password` — verifies target user exists, issues + a hashed single-use short (2h) reset token returned once; audit event w/o + secrets. Honest UI contract: admin-issued recovery token, no fake email. +- `GET /admin/audit` — sanitized rendering (ts/actor/action/target/status); + `auth::audit` refuses oversized/multiline targets (stored as + `redacted-oversized`), so no password/key/code content can be persisted. +- `bootstrap_admin(&Cx)` — idempotent first-admin bootstrap from env, Argon2 + hashed, email normalized; no-op when any admin exists. + +## Unit tests (inside the modules, RED→GREEN verified by compile-gate) +auth: argon2 hash/verify; register member-only + role input ignored + +payload-no-hash; invitation single-use; invitation email binding (and token +not consumed by the failed attempt); reset single-use + session revocation + +password actually rotated; change-password revokes others/keeps current + +wrong current rejected; audit never records secrets; cookie sessions never +store plaintext tokens + cookie flags; login throttle; logout invalidates +server-side session. +admin: member 403 on users/audit/invitations; last-admin disable/demote +blocked then allowed with a second admin; disable revokes sessions and +cancels runs; invitations only mint members, token hashed in DB and sanitized +listing; bootstrap idempotent + Argon2 hash. + +## Coordination notes for other workers / parent +1. db.rs schema is untouched (AUTH owns no db changes) and is fully sufficient. +2. qa_api.py gates covered by AUTH handlers: register ignores supplied admin + role, invitation single-use, admin-role members cannot enumerate users, + last admin cannot be disabled, admin reset flow + single-use + session + revocation, disabled account login/session denial, invite email binding. + CSRF substring/foreign-origin gates live in main.rs (CORE); private-object + gates (admin cannot read private project, cross-member denial) in + projects.rs — verify DOMAIN parts at the parent gate. +3. `AuthUser: FromRequestParts<Arc<AppState>>` matches the existing router + `.with_state(Arc<AppState>)`; handlers across the codebase may keep either + `cx: Cx` (State extractor) or the bare Arc — both remain valid for this + router. +4. No cross-module edits made by AUTH; `make_admin_token` async signature is + coherent for all admin.rs call sites. |
