Bladeren bron

feat(backend): 完成 TODO 第 2 章鉴权核心

- internal/auth:凭证生成与 SHA-256 哈希、bcrypt、TOTP、两步登录
- 授权中间件:身份识别(Bearer 优先于 cookie)、RequireLogin/Write/TotpVerified/Session、可见性条件注入
- 会话 / 授信设备 / API Token 的领域逻辑与 handler,对照 api.md 第三至五节
- 会话的 TOTP 标记用 token 后缀承载(sessions 表无该列)
- internal/httpx:统一错误响应,auth 通过回调注入避免包循环
- internal/audit:只追加写入器,失败只记日志
- cmd/haven:router 装配认证路由;端到端测试覆盖 api.md 第二至五、十一节
- 集成测试改为每用例独立 schema,可并行且不留残留
kairo 4 uur geleden
bovenliggende
commit
9dae2e7482
34 gewijzigde bestanden met toevoegingen van 5107 en 34 verwijderingen
  1. 19 9
      AGENTS.md
  2. 13 10
      TODO.md
  3. 6 5
      backend/cmd/haven-init-user/main.go
  4. 11 10
      backend/cmd/haven/main.go
  5. 41 0
      backend/cmd/haven/router.go
  6. 619 0
      backend/cmd/haven/router_test.go
  7. 134 0
      backend/internal/audit/audit.go
  8. 121 0
      backend/internal/audit/audit_test.go
  9. 150 0
      backend/internal/auth/audit_hooks.go
  10. 8 0
      backend/internal/auth/auditconst.go
  11. 128 0
      backend/internal/auth/auth.go
  12. 30 0
      backend/internal/auth/consts.go
  13. 89 0
      backend/internal/auth/devices.go
  14. 69 0
      backend/internal/auth/errors.go
  15. 187 0
      backend/internal/auth/handler.go
  16. 257 0
      backend/internal/auth/handler_auth.go
  17. 163 0
      backend/internal/auth/handler_manage.go
  18. 250 0
      backend/internal/auth/middleware.go
  19. 273 0
      backend/internal/auth/middleware_test.go
  20. 52 0
      backend/internal/auth/routes.go
  21. 152 0
      backend/internal/auth/secret.go
  22. 170 0
      backend/internal/auth/secret_test.go
  23. 450 0
      backend/internal/auth/service.go
  24. 468 0
      backend/internal/auth/service_test.go
  25. 40 0
      backend/internal/auth/session_token.go
  26. 93 0
      backend/internal/auth/sessions.go
  27. 112 0
      backend/internal/auth/sqlstore.go
  28. 139 0
      backend/internal/auth/store_test.go
  29. 127 0
      backend/internal/auth/testdb_test.go
  30. 138 0
      backend/internal/auth/token.go
  31. 180 0
      backend/internal/auth/token_test.go
  32. 167 0
      backend/internal/auth/tokens.go
  33. 162 0
      backend/internal/httpx/httpx.go
  34. 89 0
      backend/internal/httpx/httpx_test.go

+ 19 - 9
AGENTS.md

@@ -35,14 +35,14 @@
 
 ## 当前代码状态(先看这里再动手,别假设已实现)
 
-仓库处于脚手架阶段(TODO.md 第 0 章):
-
-- `backend/cmd/haven/` 已有可启动入口:`main.go` / `logger.go`,读 `HAVEN_*` 环境变量、缺失即拒绝启动,启动时连库并 ping,只注册了 `GET /api/health`。
-- `backend/internal/config` 与 `backend/internal/db` 已可用;`backend/internal/{auth,notes,tags,search,portfolio,audit,httpx,llm}` 仍是空目录。
-- `backend/migrations/0001_init.up.sql` 已建全部表与索引;`cmd/haven-init-user` 用于创建唯一账号。
-- `deploy/` 尚不存在(migration 执行方式待第 7 章补)。
-- `frontend/` 是清理过的 Vite 模板:`main.ts` 已接 `vue-router`(History,路由表暂空)与 `pinia`,`App.vue` 只渲染 `RouterView`;`src/{api,views,stores}` 是空目录。
-- `frontend/src/style.css` 只剩全局基础样式,页面样式未开始。
+仓库进度见 `TODO.md`:已完成第 0、1、2 章。
+
+- `backend/internal/auth`:凭证、TOTP、密码、登录两步、会话/授信设备/Token 管理、授权中间件、路由注册。`*gin.Context` 只在本包 handler 与中间件里出现。
+- `backend/internal/httpx`:统一错误响应;`auth` 通过 `auth.SetErrorCallbacks` 注入,避免包循环。
+- `backend/internal/audit`:只追加的审计写入器;`cmd/haven` 用 `auth.UseAudit` 接线。
+- `backend/cmd/haven`:入口 + `router.go` 路由装配;`cmd/haven-init-user` 建唯一账号。
+- `backend/internal/{notes,tags,search,portfolio,llm}` 仍是空目录;`migrations/` 已有 `0001_init`;`deploy/` 尚不存在。
+- `frontend/` 仍是清理过的 Vite 模板(`main.ts` 已接 router/pinia,路由表暂空)。
 - `frontend/dist/` 是构建产物,已被 gitignore,不要手改、不要提交。
 
 ## 常用命令
@@ -57,7 +57,13 @@ go test ./internal/auth -run TestLoginTotp -v
 go test ./internal/db -v        # 需要 HAVEN_TEST_DB_DSN,见下
 ```
 
-集成测试(目前只有 `internal/db`)在未设置 `HAVEN_TEST_DB_DSN` 时自动 skip。手动初始化本地库:
+集成测试在未设置 `HAVEN_TEST_DB_DSN` 时自动 skip;设置后**必须看结果**,skip 不算通过。每个用例自建独立 schema(`test_*` / `it_*`),跑完自动删除,所以可以直接并行跑:
+
+```bash
+HAVEN_TEST_DB_DSN='postgres://haven:...@127.0.0.1:5432/haven_dev?sslmode=disable' go test ./...
+```
+
+手动初始化本地库:
 
 ```bash
 psql "$HAVEN_DB_DSN" -f migrations/0001_init.up.sql        # 建表(启动时不会自动跑)
@@ -89,6 +95,10 @@ pnpm build                      # = vue-tsc -b && vite build → frontend/dist/
 - **凭证类操作**(Token / 授信设备 / 会话 / TOTP / 密码)Token 一律 403,只允许 Web 会话;其中 TOTP 关闭、改密码、增删改查 Token 需要会话已通过 TOTP。
 - cookie 名、有效期、bcrypt cost 等是代码常量,**不从环境变量读取**(清单在 `docs/architecture.md` 第四节)。
 - 审计只在业务成功后写、失败只打日志不影响响应;笔记 CRUD 不写审计,只有 publish/unpublish 写。
+- **`sessions` 表没有「是否通过过 TOTP」列**(`auth.md` 6.2 却要求 `totp_verified`)。实现用 token 后缀承载:会话明文 `<随机串>:totp`,查库前先 `splitSessionToken` 再哈希。`pending_logins` 的 `trust_device` 同理(`:1`)。改这两处先改文档。
+- **`gin.Context.Cookie(name)` 返回 `(string, error)`**,没有 cookie 时 err 非空、返回空串。不要写成 `raw, ok := c.Cookie(...)`。
+- `auth` 与 `httpx` 故意互相不 import:`auth` 通过 `auth.SetErrorCallbacks`、`auth.InstallAuthErrorHandlers` 注入错误响应,`main`/`router` 负责接线。
+- 新增凭证类接口(会话 / 授信设备 / Token / TOTP / 密码)必须挂 `RequireSession`;Token 一律 403(`auth.md` 第七节)。
 
 ## Git 规范
 

+ 13 - 10
TODO.md

@@ -3,7 +3,7 @@
 > 执行清单,不是事实来源。所有实现细节以 `docs/` 为准;本文件与 `docs/` 冲突时以 `docs/` 为准。
 > `docs/` 只读,需求变更请先改文档,再勾选这里的条目。
 
-当前阶段:**第 1 章(配置与数据库基座)基本完成**,仅剩 migration 执行脚本(依赖第 7 章的 `deploy/`)。下一步进入第 2 章(鉴权核心)。
+当前阶段:**第 2 章(鉴权核心)已完成**。第 1 章仅剩 migration 执行脚本(依赖第 7 章的 `deploy/`)。下一步进入第 3 章(审计)。
 
 图例:`[ ]` 未做 · `[x]` 已完成 · `[~]` 部分完成
 
@@ -32,15 +32,18 @@
 
 ## 2. 鉴权核心(`internal/auth`,按 `auth.md`)
 
-- [ ] 凭证生成与哈希工具:32 字节随机 + base64url,落库只存 SHA-256
-- [ ] 身份识别(auth.md 6.1):`Authorization: Bearer` 优先于 cookie;`sessions` / `tokens` 两张表;`pending_logins` 不参与
-- [ ] 授权中间件(auth.md 6.2–6.4):`anonymous_ok` / `login_required` / `write` / `totp_verified` 四档,并把可见性条件注入 context
-- [ ] cookie 下发:`haven_session` / `haven_trusted_device`,`HttpOnly`、`SameSite=Lax`、`Domain=HAVEN_COOKIE_DOMAIN`、`Secure` 跟随 `HAVEN_COOKIE_SECURE`
-- [ ] 登录两步:账密 → (未启用 TOTP / 授信设备有效则直接建会话)→ 否则 `pending_logins` + `temp_token`(5 分钟)
-- [ ] 每次请求更新 `sessions.last_used_at`,不延长 `expires_at`
-- [ ] TOTP:setup 不落库、verify 通过才落库并置 `totp_enabled`;disable 同时删除该用户全部授信设备
-- [ ] 改密码:同事务内删全部 `sessions` + `trusted_devices`,**不删** `tokens`,当前会话也失效
-- [ ] bcrypt cost 12;密码 ≥12 位且不能与旧密码相同
+- [x] 凭证生成与哈希工具:32 字节随机 + base64url,落库只存 SHA-256
+- [x] 身份识别(auth.md 6.1):`Authorization: Bearer` 优先于 cookie;`sessions` / `tokens` 两张表;`pending_logins` 不参与
+- [x] 授权中间件(auth.md 6.2–6.4):`anonymous_ok` / `login_required` / `write` / `totp_verified` 四档,并把可见性条件注入 context
+- [x] cookie 下发:`haven_session` / `haven_trusted_device`,`HttpOnly`、`SameSite=Lax`、`Domain=HAVEN_COOKIE_DOMAIN`、`Secure` 跟随 `HAVEN_COOKIE_SECURE`
+- [x] 登录两步:账密 → (未启用 TOTP / 授信设备有效则直接建会话)→ 否则 `pending_logins` + `temp_token`(5 分钟)
+- [x] 每次请求更新 `sessions.last_used_at`,不延长 `expires_at`
+- [x] TOTP:setup 不落库、verify 通过才落库并置 `totp_enabled`;disable 同时删除该用户全部授信设备
+- [x] 改密码:同事务内删全部 `sessions` + `trusted_devices`,**不删** `tokens`,当前会话也失效
+- [x] bcrypt cost 12;密码 ≥12 位且不能与旧密码相同
+
+> 补充实现(在章节内一并完成):会话/授信设备/Token 的 `internal/auth` 业务与 handler、`internal/httpx` 统一错误响应、`cmd/haven` 路由装配。
+> 会话「是否通过过 TOTP」用 token 后缀 `:totp` 承载(`auth.md` 6.2 需要、`sessions` 表无该列),与 `pending_logins` 的 `trust_device` 后缀手法一致。
 
 ## 3. 审计(`internal/audit`,按 `audit.md`)
 

+ 6 - 5
backend/cmd/haven-init-user/main.go

@@ -26,14 +26,15 @@ import (
 	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/term"
 
+	"github.com/kairo/haven/internal/auth"
 	"github.com/kairo/haven/internal/db"
 )
 
-// bcryptCost 固定 12,见 docs/backend/auth.md 8.1 与 architecture.md 第四节常量表。
-const bcryptCost = 12
-
-// minPasswordLen 见 docs/api.md 2.7:新密码 ≥ 12 位。
-const minPasswordLen = 12
+// 与认证包保持一致:bcrypt cost 与密码最小长度只在一个地方定义。
+const (
+	bcryptCost     = auth.BcryptCost
+	minPasswordLen = auth.MinPasswordLen
+)
 
 func main() {
 	if err := run(); err != nil {

+ 11 - 10
backend/cmd/haven/main.go

@@ -5,8 +5,8 @@ import (
 	"log/slog"
 	"os"
 
-	"github.com/gin-gonic/gin"
-
+	"github.com/kairo/haven/internal/audit"
+	"github.com/kairo/haven/internal/auth"
 	"github.com/kairo/haven/internal/config"
 	"github.com/kairo/haven/internal/db"
 )
@@ -14,8 +14,8 @@ import (
 // main 是后端入口。
 //
 // 配置全部来自环境变量,见 docs/architecture.md 第四节;
-// 本期只完成启动骨架:读取配置、初始化日志、注册健康检查。
-// 数据库连接、鉴权中间件与各领域路由按 TODO.md 后续条目接入。
+// 当前已接入:配置、日志、数据库、认证与授权(docs/api.md 第二至五、十一节)。
+// 静态托管与其余领域路由按 TODO.md 后续条目接入。
 func main() {
 	if err := run(); err != nil {
 		slog.Error("服务启动失败", "err", err)
@@ -39,13 +39,14 @@ func run() error {
 	defer conn.Close()
 	logger.Info("数据库连接就绪")
 
-	gin.SetMode(gin.ReleaseMode)
-	r := gin.New()
-	r.Use(gin.Recovery())
+	store := auth.NewSQLStore(conn)
+	svc := auth.NewService(conn)
+	handler := auth.NewHandler(svc, store, cfg.TotpIssuer, cfg.CookieDomain, cfg.CookieSecure)
+
+	// 审计事件在业务成功后写,失败只记日志(audit.md 第五节)
+	auth.UseAudit(handler, audit.NewWriter(conn))
 
-	r.GET("/api/health", func(c *gin.Context) {
-		c.JSON(200, gin.H{"status": "ok"})
-	})
+	r := newRouter(handler, store)
 
 	logger.Info("后端启动", "listen_addr", cfg.ListenAddr)
 	return r.Run(cfg.ListenAddr)

+ 41 - 0
backend/cmd/haven/router.go

@@ -0,0 +1,41 @@
+package main
+
+import (
+	"github.com/gin-gonic/gin"
+
+	"github.com/kairo/haven/internal/auth"
+	"github.com/kairo/haven/internal/httpx"
+)
+
+// newRouter 组装 Gin 路由。
+//
+// 依据 docs/architecture.md 5.3:中间件顺序为 日志 → 鉴权 → 领域 handler。
+// 本期接入认证相关路由(api.md 第二、三、四、五、十一节)。
+func newRouter(h *auth.Handler, store auth.Store) *gin.Engine {
+	// 鉴权中间件通过回调使用统一错误响应,避免 auth ↔ httpx 包循环
+	httpx.InstallAuthErrorHandlers()
+	auth.SetErrorCallbacks(
+		httpx.FailBadRequest,
+		httpx.FailValidation,
+		httpx.FailInternal,
+		httpx.FailNotFound,
+		httpx.FailConflict,
+		httpx.FailUnauthorizedWith,
+		httpx.FailUnprocessable,
+	)
+
+	gin.SetMode(gin.ReleaseMode)
+	r := gin.New()
+	r.Use(gin.Recovery())
+	r.Use(auth.Authenticate(store))
+
+	r.GET("/api/health", func(c *gin.Context) {
+		c.JSON(200, gin.H{"status": "ok"})
+	})
+
+	auth.RegisterMeRoute(r, h)
+	auth.RegisterRoutes(r, h)
+	auth.RegisterManageRoutes(r, h)
+
+	return r
+}

+ 619 - 0
backend/cmd/haven/router_test.go

@@ -0,0 +1,619 @@
+package main
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/kairo/haven/internal/audit"
+	"github.com/kairo/haven/internal/auth"
+	"github.com/kairo/haven/internal/config"
+	"github.com/kairo/haven/internal/db"
+)
+
+// 端到端测试:真实 PostgreSQL + 完整路由栈,逐条对照 docs/api.md。
+//
+//	HAVEN_TEST_DB_DSN='postgres://haven:...@127.0.0.1:5432/haven_dev?sslmode=disable' \
+//	  go test ./cmd/haven -v
+
+type testEnv struct {
+	t      *testing.T
+	router *gin.Engine
+	store  *auth.SQLStore
+	svc    *auth.Service
+	cfg    config.Config
+	userID string
+}
+
+func newTestEnv(t *testing.T) *testEnv {
+	t.Helper()
+	dsn := os.Getenv("HAVEN_TEST_DB_DSN")
+	if dsn == "" {
+		t.Skip("未设置 HAVEN_TEST_DB_DSN,跳过端到端测试")
+	}
+
+	admin, err := db.Open(t.Context(), dsn)
+	if err != nil {
+		t.Fatalf("连接测试库失败: %v", err)
+	}
+
+	schema := "it_" + strings.ToLower(strings.NewReplacer("/", "_").Replace(t.Name()))
+	if len(schema) > 40 {
+		schema = schema[:40]
+	}
+	schema += "_" + fmt.Sprintf("%d", time.Now().UnixNano()%1e9)
+
+	ctx := t.Context()
+	if _, err := admin.ExecContext(ctx, `DROP SCHEMA IF EXISTS `+schema+` CASCADE`); err != nil {
+		t.Fatalf("清理 schema 失败: %v", err)
+	}
+	if _, err := admin.ExecContext(ctx, `CREATE SCHEMA `+schema); err != nil {
+		t.Fatalf("创建 schema 失败: %v", err)
+	}
+
+	// 在独立 schema 里建表:直接复用 migration 文件,保证与生产结构一致
+	migration, err := os.ReadFile("../../migrations/0001_init.up.sql")
+	if err != nil {
+		t.Fatalf("读取 migration 失败: %v", err)
+	}
+
+	conn, err := db.Open(ctx, appendSearchPath(dsn, schema+",public"))
+	if err != nil {
+		t.Fatalf("连接测试 schema 失败: %v", err)
+	}
+	t.Cleanup(func() {
+		conn.Close()
+		admin.ExecContext(context.Background(), `DROP SCHEMA IF EXISTS `+schema+` CASCADE`)
+		admin.Close()
+	})
+
+	if _, err := conn.ExecContext(ctx, string(migration)); err != nil {
+		t.Fatalf("建表失败: %v", err)
+	}
+
+	cfg := config.Config{
+		ListenAddr:   "127.0.0.1:0",
+		DBDSN:        dsn,
+		FrontendDir:  t.TempDir(),
+		GogsBaseURL:  "https://gogs.example.com",
+		CookieDomain: "",
+		CookieSecure: false,
+		TotpIssuer:   "Haven",
+		LogLevel:     "error",
+	}
+
+	store := auth.NewSQLStore(conn)
+	svc := auth.NewService(conn)
+	h := auth.NewHandler(svc, store, cfg.TotpIssuer, cfg.CookieDomain, cfg.CookieSecure)
+	auth.UseAudit(h, audit.NewWriter(conn))
+
+	return &testEnv{t: t, router: newRouter(h, store), store: store, svc: svc, cfg: cfg}
+}
+
+// appendSearchPath 在 DSN 追加 search_path,用于隔离集成测试的 schema。
+func appendSearchPath(dsn, searchPath string) string {
+	sep := "?"
+	if strings.Contains(dsn, "?") {
+		sep = "&"
+	}
+	return dsn + sep + "search_path=" + url.QueryEscape(searchPath)
+}
+
+// createUser 直接插库建用户。
+func (e *testEnv) createUser(username, password string) string {
+	e.t.Helper()
+	hash, err := auth.HashPassword(password)
+	if err != nil {
+		e.t.Fatal(err)
+	}
+	var id string
+	if err := e.store.DB().QueryRow(
+		`INSERT INTO users (username, password_hash) VALUES ($1, $2) RETURNING id`,
+		username, hash).Scan(&id); err != nil {
+		e.t.Fatalf("插入用户失败: %v", err)
+	}
+	e.userID = id
+	return id
+}
+
+// do 发一个 JSON 请求,返回响应与 cookie。
+func (e *testEnv) do(method, path string, body any, cookies ...*http.Cookie) *httptest.ResponseRecorder {
+	e.t.Helper()
+	var reader *bytes.Reader
+	if body == nil {
+		reader = bytes.NewReader(nil)
+	} else {
+		raw, err := json.Marshal(body)
+		if err != nil {
+			e.t.Fatal(err)
+		}
+		reader = bytes.NewReader(raw)
+	}
+
+	req := httptest.NewRequest(method, path, reader)
+	req.Header.Set("Content-Type", "application/json")
+	for _, c := range cookies {
+		req.AddCookie(c)
+	}
+
+	w := httptest.NewRecorder()
+	e.router.ServeHTTP(w, req)
+	return w
+}
+
+func (e *testEnv) doWithToken(method, path, token string) *httptest.ResponseRecorder {
+	e.t.Helper()
+	req := httptest.NewRequest(method, path, nil)
+	req.Header.Set("Authorization", "Bearer "+token)
+	w := httptest.NewRecorder()
+	e.router.ServeHTTP(w, req)
+	return w
+}
+
+func sessionCookie(t *testing.T, w *httptest.ResponseRecorder) *http.Cookie {
+	t.Helper()
+	for _, c := range w.Result().Cookies() {
+		if c.Name == auth.CookieSession && c.Value != "" {
+			return c
+		}
+	}
+	t.Fatal("响应里没有会话 cookie")
+	return nil
+}
+
+func deviceCookie(t *testing.T, w *httptest.ResponseRecorder) *http.Cookie {
+	t.Helper()
+	for _, c := range w.Result().Cookies() {
+		if c.Name == auth.CookieTrustedDevice && c.Value != "" {
+			return c
+		}
+	}
+	return nil
+}
+
+// loginNoTotp 用账密登录并返回会话 cookie。
+func (e *testEnv) loginNoTotp(username, password string) *http.Cookie {
+	e.t.Helper()
+	w := e.do(http.MethodPost, "/api/auth/login",
+		map[string]any{"username": username, "password": password})
+	if w.Code != http.StatusOK {
+		e.t.Fatalf("登录失败,状态码 %d: %s", w.Code, w.Body.String())
+	}
+	return sessionCookie(e.t, w)
+}
+
+// --- /api/me ---
+
+func TestMeAnonymous(t *testing.T) {
+	e := newTestEnv(t)
+	w := e.do(http.MethodGet, "/api/me", nil)
+	if w.Code != http.StatusOK {
+		t.Fatalf("状态码 = %d", w.Code)
+	}
+	var got map[string]any
+	_ = json.Unmarshal(w.Body.Bytes(), &got)
+	if got["anonymous"] != true {
+		t.Errorf("匿名响应应含 anonymous=true,实际 %v", got)
+	}
+}
+
+func TestMeSessionAndToken(t *testing.T) {
+	e := newTestEnv(t)
+	e.createUser("kairo", "correct-horse-battery")
+	cookie := e.loginNoTotp("kairo", "correct-horse-battery")
+
+	w := e.do(http.MethodGet, "/api/me", nil, cookie)
+	var got map[string]any
+	_ = json.Unmarshal(w.Body.Bytes(), &got)
+	if got["anonymous"] != false || got["auth_type"] != "session" {
+		t.Errorf("会话身份响应异常: %v", got)
+	}
+	if _, ok := got["scope"]; ok {
+		t.Error("会话用户不应返回 scope")
+	}
+}
+
+// --- 登录 ---
+
+func TestLoginErrors(t *testing.T) {
+	e := newTestEnv(t)
+	e.createUser("kairo", "correct-horse-battery")
+
+	// 密码错误:401,且 message 不区分用户名/密码(api.md 2.1)
+	w := e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": "kairo", "password": "wrong"})
+	if w.Code != http.StatusUnauthorized {
+		t.Errorf("密码错误状态码 = %d", w.Code)
+	}
+	wrongPwBody := w.Body.String()
+
+	// 用户不存在:也是同样的 message,防枚举
+	w = e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": "nobody", "password": "wrong"})
+	if w.Code != http.StatusUnauthorized {
+		t.Errorf("用户不存在状态码 = %d", w.Code)
+	}
+	if w.Body.String() != wrongPwBody {
+		t.Errorf("两种失败应返回相同响应防枚举: %q vs %q", wrongPwBody, w.Body.String())
+	}
+
+	// 缺字段:400
+	w = e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": "kairo"})
+	if w.Code != http.StatusBadRequest {
+		t.Errorf("缺字段状态码 = %d,期望 400", w.Code)
+	}
+}
+
+func TestLoginWritesAudit(t *testing.T) {
+	e := newTestEnv(t)
+	e.createUser("kairo", "correct-horse-battery")
+
+	e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": "kairo", "password": "wrong"})
+	e.loginNoTotp("kairo", "correct-horse-battery")
+
+	var count int
+	var reason string
+	if err := e.store.DB().QueryRow(
+		`SELECT count(*) FROM audit_events WHERE event_type = 'login_failure'`).Scan(&count); err != nil {
+		t.Fatal(err)
+	}
+	if count != 1 {
+		t.Errorf("login_failure 条数 = %d", count)
+	}
+	if err := e.store.DB().QueryRow(
+		`SELECT metadata->>'reason' FROM audit_events WHERE event_type = 'login_failure'`).Scan(&reason); err != nil {
+		t.Fatal(err)
+	}
+	if reason != "bad_password" {
+		t.Errorf("login_failure reason = %q", reason)
+	}
+
+	if err := e.store.DB().QueryRow(
+		`SELECT count(*) FROM audit_events WHERE event_type = 'login_success'`).Scan(&count); err != nil {
+		t.Fatal(err)
+	}
+	if count != 1 {
+		t.Errorf("login_success 条数 = %d", count)
+	}
+}
+
+// --- 会话管理 ---
+
+func TestSessionsRoutes(t *testing.T) {
+	e := newTestEnv(t)
+	e.createUser("kairo", "correct-horse-battery")
+
+	c1 := e.loginNoTotp("kairo", "correct-horse-battery")
+	_ = e.loginNoTotp("kairo", "correct-horse-battery")
+
+	// 列表
+	w := e.do(http.MethodGet, "/api/sessions", nil, c1)
+	if w.Code != http.StatusOK {
+		t.Fatalf("状态码 = %d", w.Code)
+	}
+	var list struct {
+		Items []map[string]any `json:"items"`
+		Total int              `json:"total"`
+	}
+	_ = json.Unmarshal(w.Body.Bytes(), &list)
+	if list.Total != 2 || len(list.Items) != 2 {
+		t.Fatalf("应有 2 条会话,实际 %+v", list)
+	}
+	current := 0
+	for _, it := range list.Items {
+		if it["is_current"] == true {
+			current++
+		}
+	}
+	if current != 1 {
+		t.Errorf("应恰好一条 is_current,实际 %d", current)
+	}
+
+	// 撤销另一台设备
+	var otherID string
+	for _, it := range list.Items {
+		if it["is_current"] != true {
+			otherID, _ = it["id"].(string)
+		}
+	}
+	if w := e.do(http.MethodDelete, "/api/sessions/"+otherID, nil, c1); w.Code != http.StatusNoContent {
+		t.Errorf("撤销其他会话状态码 = %d", w.Code)
+	}
+
+	// 撤销当前会话:422
+	var currentID string
+	for _, it := range list.Items {
+		if it["is_current"] == true {
+			currentID, _ = it["id"].(string)
+		}
+	}
+	w = e.do(http.MethodDelete, "/api/sessions/"+currentID, nil, c1)
+	if w.Code != http.StatusUnprocessableEntity {
+		t.Errorf("撤销当前会话应 422,实际 %d", w.Code)
+	}
+
+	// Token 调用列表:403
+	createdTok, err := e.svc.CreateAPIToken(t.Context(), e.userID, "probe", auth.ScopeRead, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	tok := createdTok.Token
+	if w := e.doWithToken(http.MethodGet, "/api/sessions", tok); w.Code != http.StatusForbidden {
+		t.Errorf("Token 查会话应 403,实际 %d", w.Code)
+	}
+}
+
+func TestLogoutClearsSession(t *testing.T) {
+	e := newTestEnv(t)
+	e.createUser("kairo", "correct-horse-battery")
+	cookie := e.loginNoTotp("kairo", "correct-horse-battery")
+
+	if w := e.do(http.MethodPost, "/api/auth/logout", nil, cookie); w.Code != http.StatusNoContent {
+		t.Fatalf("登出状态码 = %d", w.Code)
+	}
+	// 旧 cookie 失效
+	w := e.do(http.MethodGet, "/api/me", nil, cookie)
+	var got map[string]any
+	_ = json.Unmarshal(w.Body.Bytes(), &got)
+	if got["anonymous"] != true {
+		t.Error("登出后旧会话 cookie 应失效")
+	}
+
+	var count int
+	if err := e.store.DB().QueryRow(`SELECT count(*) FROM audit_events WHERE event_type = 'logout'`).Scan(&count); err != nil {
+		t.Fatal(err)
+	}
+	if count != 1 {
+		t.Errorf("应写一条 logout 审计,实际 %d", count)
+	}
+}
+
+// --- Token 管理 ---
+
+func TestTokensRoutes(t *testing.T) {
+	e := newTestEnv(t)
+	e.createUser("kairo", "correct-horse-battery")
+	cookie := e.loginNoTotp("kairo", "correct-horse-battery")
+
+	// 未启用 TOTP 的会话不能管理 Token:403(api.md 1.6)
+	if w := e.do(http.MethodGet, "/api/tokens", nil, cookie); w.Code != http.StatusForbidden {
+		t.Fatalf("未通过 TOTP 应 403,实际 %d", w.Code)
+	}
+
+	// 启用 TOTP 后,旧会话仍未「通过过」TOTP(auth.md 6.2),需重新登录走完整两步
+	secret := enableTotp(t, e, cookie)
+	cookie = e.loginWithTotp("kairo", "correct-horse-battery", secret)
+
+	// 创建
+	w := e.do(http.MethodPost, "/api/tokens",
+		map[string]any{"name": "cli", "scope": "read"}, cookie)
+	if w.Code != http.StatusCreated {
+		t.Fatalf("创建 Token 状态码 = %d: %s", w.Code, w.Body.String())
+	}
+	var created map[string]any
+	_ = json.Unmarshal(w.Body.Bytes(), &created)
+	plain, _ := created["token"].(string)
+	if plain == "" {
+		t.Fatal("创建响应应含一次性明文")
+	}
+
+	// 列表不返回明文
+	w = e.do(http.MethodGet, "/api/tokens", nil, cookie)
+	if bytes.Contains(w.Body.Bytes(), []byte(plain)) {
+		t.Error("列表不应返回 Token 明文")
+	}
+	if !bytes.Contains(w.Body.Bytes(), []byte("token_prefix")) {
+		t.Error("列表应含 token_prefix")
+	}
+
+	// 非法 scope:422
+	if w := e.do(http.MethodPost, "/api/tokens",
+		map[string]any{"name": "x", "scope": "admin"}, cookie); w.Code != http.StatusUnprocessableEntity {
+		t.Errorf("非法 scope 应 422,实际 %d", w.Code)
+	}
+
+	// 用该 Token 访问只读接口
+	if w := e.doWithToken(http.MethodGet, "/api/me", plain); w.Code != http.StatusOK {
+		t.Errorf("Token 调 /api/me 应 200,实际 %d", w.Code)
+	}
+
+	// 撤销
+	id, _ := created["id"].(string)
+	if w := e.do(http.MethodDelete, "/api/tokens/"+id, nil, cookie); w.Code != http.StatusNoContent {
+		t.Errorf("撤销 Token 状态码 = %d", w.Code)
+	}
+	// 已撤销的 Token 不能再用
+	if w := e.doWithToken(http.MethodGet, "/api/me", plain); w.Code != http.StatusUnauthorized {
+		t.Errorf("已撤销 Token 应 401,实际 %d", w.Code)
+	}
+	// 重复撤销:422
+	if w := e.do(http.MethodDelete, "/api/tokens/"+id, nil, cookie); w.Code != http.StatusUnprocessableEntity {
+		t.Errorf("重复撤销应 422,实际 %d", w.Code)
+	}
+
+	// 撤销是写 revoked_at,不删记录
+	var revokedAt *time.Time
+	if err := e.store.DB().QueryRow(`SELECT revoked_at FROM tokens WHERE id = $1`, id).Scan(&revokedAt); err != nil {
+		t.Fatal(err)
+	}
+	if revokedAt == nil {
+		t.Error("撤销应写 revoked_at 且保留记录")
+	}
+}
+
+// loginWithTotp 走完整两步登录,返回通过 TOTP 的会话 cookie。
+func (e *testEnv) loginWithTotp(username, password, secret string) *http.Cookie {
+	e.t.Helper()
+	w := e.do(http.MethodPost, "/api/auth/login", map[string]any{"username": username, "password": password})
+	if w.Code != http.StatusOK {
+		e.t.Fatalf("第一步登录状态码 = %d", w.Code)
+	}
+	var step1 map[string]any
+	_ = json.Unmarshal(w.Body.Bytes(), &step1)
+	temp, _ := step1["temp_token"].(string)
+	if temp == "" {
+		e.t.Fatal("应返回 temp_token")
+	}
+
+	code, err := auth.GenerateTotpCode(secret, time.Now())
+	if err != nil {
+		e.t.Fatal(err)
+	}
+	w = e.do(http.MethodPost, "/api/auth/login/totp", map[string]any{"temp_token": temp, "code": code})
+	if w.Code != http.StatusOK {
+		e.t.Fatalf("第二步登录状态码 = %d: %s", w.Code, w.Body.String())
+	}
+	return sessionCookie(e.t, w)
+}
+
+// enableTotp 走 setup + verify 两步,返回 secret。
+func enableTotp(t *testing.T, e *testEnv, cookie *http.Cookie) string {
+	t.Helper()
+	w := e.do(http.MethodPost, "/api/auth/totp/setup", nil, cookie)
+	if w.Code != http.StatusOK {
+		t.Fatalf("setup 状态码 = %d: %s", w.Code, w.Body.String())
+	}
+	var setup map[string]any
+	_ = json.Unmarshal(w.Body.Bytes(), &setup)
+	secret, _ := setup["secret"].(string)
+	if secret == "" {
+		t.Fatal("setup 应返回 secret")
+	}
+
+	code, err := auth.GenerateTotpCode(secret, time.Now())
+	if err != nil {
+		t.Fatal(err)
+	}
+	w = e.do(http.MethodPost, "/api/auth/totp/verify",
+		map[string]any{"secret": secret, "code": code}, cookie)
+	if w.Code != http.StatusOK {
+		t.Fatalf("verify 状态码 = %d: %s", w.Code, w.Body.String())
+	}
+	return secret
+}
+
+func TestTrustedDevicesRoutes(t *testing.T) {
+	e := newTestEnv(t)
+	e.createUser("kairo", "correct-horse-battery")
+
+	// 登录时请求授信设备
+	w := e.do(http.MethodPost, "/api/auth/login",
+		map[string]any{"username": "kairo", "password": "correct-horse-battery", "trust_device": true})
+	if w.Code != http.StatusOK {
+		t.Fatalf("登录状态码 = %d", w.Code)
+	}
+	cookie := sessionCookie(t, w)
+	if deviceCookie(t, w) == nil {
+		t.Fatal("trust_device=true 应下发授信设备 cookie")
+	}
+
+	// 列表
+	w = e.do(http.MethodGet, "/api/trusted-devices", nil, cookie)
+	if w.Code != http.StatusOK {
+		t.Fatalf("状态码 = %d", w.Code)
+	}
+	var list struct {
+		Items []map[string]any `json:"items"`
+		Total int              `json:"total"`
+	}
+	_ = json.Unmarshal(w.Body.Bytes(), &list)
+	if list.Total != 1 {
+		t.Fatalf("应有 1 台授信设备,实际 %+v", list)
+	}
+
+	// 不提供创建接口(api.md 4.2 说明)
+	if w := e.do(http.MethodPost, "/api/trusted-devices", map[string]any{}, cookie); w.Code != http.StatusNotFound && w.Code != http.StatusMethodNotAllowed {
+		t.Errorf("不应存在创建设备接口,实际状态码 %d", w.Code)
+	}
+
+	// 撤销
+	id, _ := list.Items[0]["id"].(string)
+	if w := e.do(http.MethodDelete, "/api/trusted-devices/"+id, nil, cookie); w.Code != http.StatusNoContent {
+		t.Errorf("撤销设备状态码 = %d", w.Code)
+	}
+
+	var count int
+	if err := e.store.DB().QueryRow(`SELECT count(*) FROM trusted_devices`).Scan(&count); err != nil {
+		t.Fatal(err)
+	}
+	if count != 0 {
+		t.Error("撤销授信设备应硬删除")
+	}
+}
+
+func TestLoginWithTotpEndToEnd(t *testing.T) {
+	e := newTestEnv(t)
+	e.createUser("kairo", "correct-horse-battery")
+	cookie := e.loginNoTotp("kairo", "correct-horse-battery")
+	secret := enableTotp(t, e, cookie)
+
+	// 新登录要求 TOTP
+	w := e.do(http.MethodPost, "/api/auth/login",
+		map[string]any{"username": "kairo", "password": "correct-horse-battery"})
+	if w.Code != http.StatusOK {
+		t.Fatalf("状态码 = %d", w.Code)
+	}
+	var step1 map[string]any
+	_ = json.Unmarshal(w.Body.Bytes(), &step1)
+	if step1["totp_required"] != true {
+		t.Fatalf("应要求 TOTP: %v", step1)
+	}
+	if _, ok := step1["temp_token"]; !ok {
+		t.Fatal("应返回 temp_token")
+	}
+	if len(w.Result().Cookies()) != 0 {
+		t.Error("需要 TOTP 时不应下发会话 cookie")
+	}
+
+	tempToken, _ := step1["temp_token"].(string)
+
+	// 错误码
+	if w := e.do(http.MethodPost, "/api/auth/login/totp",
+		map[string]any{"temp_token": tempToken, "code": "000000"}); w.Code != http.StatusUnauthorized {
+		t.Errorf("错误动态码应 401,实际 %d", w.Code)
+	}
+
+	code, _ := auth.GenerateTotpCode(secret, time.Now())
+	w = e.do(http.MethodPost, "/api/auth/login/totp",
+		map[string]any{"temp_token": tempToken, "code": code})
+	if w.Code != http.StatusOK {
+		t.Fatalf("第二步状态码 = %d: %s", w.Code, w.Body.String())
+	}
+	if sessionCookie(t, w) == nil {
+		t.Fatal("第二步成功应下发会话")
+	}
+
+	// 不再要求 TOTP 的会话:拒绝管理 Token,但可看会话列表
+	newCookie := sessionCookie(t, w)
+	if w := e.do(http.MethodGet, "/api/sessions", nil, newCookie); w.Code != http.StatusOK {
+		t.Errorf("会话列表状态码 = %d", w.Code)
+	}
+}
+
+func TestPasswordChangeRequiresTotp(t *testing.T) {
+	e := newTestEnv(t)
+	e.createUser("kairo", "correct-horse-battery")
+	cookie := e.loginNoTotp("kairo", "correct-horse-battery")
+
+	// 未启用 TOTP 的会话改密:403(api.md 2.7 要求 登录 + TOTP)
+	if w := e.do(http.MethodPost, "/api/auth/password",
+		map[string]any{"old_password": "correct-horse-battery", "new_password": "brand-new-password"}, cookie); w.Code != http.StatusForbidden {
+		t.Errorf("未通过 TOTP 改密应 403,实际 %d", w.Code)
+	}
+}
+
+func TestUnknownRoute(t *testing.T) {
+	e := newTestEnv(t)
+	w := e.do(http.MethodGet, "/api/health", nil)
+	if w.Code != http.StatusOK {
+		t.Errorf("健康检查状态码 = %d", w.Code)
+	}
+}

+ 134 - 0
backend/internal/audit/audit.go

@@ -0,0 +1,134 @@
+// Package audit 负责写审计事件。
+//
+// 依据 docs/backend/audit.md:
+//   - 只追加,不修改不删除;
+//   - 写入发生在业务成功之后、响应返回之前;
+//   - 写入失败只打错误日志,不影响业务结果(第五节)。
+package audit
+
+import (
+	"context"
+	"database/sql"
+	"encoding/json"
+	"fmt"
+	"log/slog"
+	"time"
+)
+
+// 事件类型,取值必须与 audit_events.event_type 的 CHECK 约束一致(audit.md 第三节)。
+const (
+	EventLoginSuccess        = "login_success"
+	EventLoginFailure        = "login_failure"
+	EventLogout              = "logout"
+	EventTotpBound           = "totp_bound"
+	EventTotpDisabled        = "totp_disabled"
+	EventPasswordChanged     = "password_changed"
+	EventSessionRevoked      = "session_revoked"
+	EventTrustedDeviceAdded  = "trusted_device_added"
+	EventTrustedDeviceRevoke = "trusted_device_revoked"
+	EventTokenCreated        = "token_created"
+	EventTokenRevoked        = "token_revoked"
+	EventNotePublished       = "note_published"
+	EventNoteUnpublished     = "note_unpublished"
+)
+
+// target_type 取值(audit.md 第三节说明)。'null' 是字符串字面量,表示无对象。
+const (
+	TargetNull          = "null"
+	TargetUser          = "user"
+	TargetSession       = "session"
+	TargetTrustedDevice = "trusted_device"
+	TargetToken         = "token"
+	TargetNote          = "note"
+)
+
+// login_failure 的 reason 取值(audit.md 第三节)。
+const (
+	ReasonBadPassword = "bad_password"
+	ReasonBadTotp     = "bad_totp"
+	ReasonNoSuchUser  = "no_such_user"
+)
+
+// Event 是一条待写入的审计事件。
+type Event struct {
+	// ActorUserID 可为空(登录失败且用户名不存在时)。
+	ActorUserID string
+	// ActorUsername 是用户名快照,用户被删除后仍保留。
+	ActorUsername string
+	EventType     string
+	// TargetType 为空时写入 'null'。
+	TargetType string
+	TargetID   string
+	Metadata   map[string]any
+	IP         string
+	UserAgent  string
+}
+
+// Writer 写审计事件。
+type Writer struct {
+	db *sql.DB
+}
+
+// NewWriter 构造 Writer。
+func NewWriter(db *sql.DB) *Writer {
+	return &Writer{db: db}
+}
+
+// Record 写入一条审计事件。
+//
+// 依据 audit.md 第五节:失败只打错误日志,不返回错误、不影响业务结果。
+// 因此本方法故意不返回 error。
+func (w *Writer) Record(ctx context.Context, e Event) {
+	if err := w.record(ctx, e); err != nil {
+		// 完整事件进日志,便于事后人工补录(audit.md 5.1)
+		slog.Error("写审计事件失败",
+			"event_type", e.EventType,
+			"actor_user_id", e.ActorUserID,
+			"actor_username", e.ActorUsername,
+			"target_type", e.TargetType,
+			"target_id", e.TargetID,
+			"metadata", e.Metadata,
+			"err", err)
+	}
+}
+
+func (w *Writer) record(ctx context.Context, e Event) error {
+	if w == nil || w.db == nil {
+		return fmt.Errorf("audit writer 未初始化")
+	}
+
+	targetType := e.TargetType
+	if targetType == "" {
+		targetType = TargetNull
+	}
+
+	meta := e.Metadata
+	if meta == nil {
+		meta = map[string]any{}
+	}
+	rawMeta, err := json.Marshal(meta)
+	if err != nil {
+		return fmt.Errorf("序列化审计 metadata 失败: %w", err)
+	}
+
+	var actorUserID any
+	if e.ActorUserID != "" {
+		actorUserID = e.ActorUserID
+	}
+	var targetID any
+	if e.TargetID != "" {
+		targetID = e.TargetID
+	}
+
+	const q = `
+		INSERT INTO audit_events
+			(actor_user_id, actor_username, event_type, target_type, target_id, metadata, ip, user_agent, created_at)
+		VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`
+
+	if _, err := w.db.ExecContext(ctx, q,
+		actorUserID, e.ActorUsername, e.EventType, targetType, targetID,
+		rawMeta, e.IP, e.UserAgent, time.Now()); err != nil {
+		return fmt.Errorf("写入审计事件失败: %w", err)
+	}
+	return nil
+}

+ 121 - 0
backend/internal/audit/audit_test.go

@@ -0,0 +1,121 @@
+package audit
+
+import (
+	"context"
+	"os"
+	"testing"
+
+	"github.com/kairo/haven/internal/db"
+)
+
+func TestRecordRequiresDB(t *testing.T) {
+	// 未初始化数据库时 record 应返回错误,而 Record 只记日志不 panic
+	w := NewWriter(nil)
+	if err := w.record(context.Background(), Event{EventType: EventLogout}); err == nil {
+		t.Error("未初始化时应返回错误")
+	}
+	w.Record(context.Background(), Event{EventType: EventLogout})
+}
+
+// 集成测试:需要真实的 PostgreSQL(已跑 0001_init.up.sql)。
+//
+//	HAVEN_TEST_DB_DSN='postgres://haven:...@127.0.0.1:5432/haven_dev?sslmode=disable' \
+//	  go test ./internal/audit/ -v
+func openTestWriter(t *testing.T) (*Writer, func()) {
+	t.Helper()
+	dsn := os.Getenv("HAVEN_TEST_DB_DSN")
+	if dsn == "" {
+		t.Skip("未设置 HAVEN_TEST_DB_DSN,跳过数据库集成测试")
+	}
+
+	conn, err := db.Open(context.Background(), dsn)
+	if err != nil {
+		t.Fatalf("连接测试库失败: %v", err)
+	}
+	if _, err := conn.Exec(`TRUNCATE audit_events CASCADE`); err != nil {
+		t.Fatalf("清理审计表失败: %v", err)
+	}
+	return NewWriter(conn), func() { conn.Close() }
+}
+
+func TestRecordAppendsEvents(t *testing.T) {
+	w, closeDB := openTestWriter(t)
+	defer closeDB()
+	ctx := context.Background()
+
+	// 无对象事件:target_type 应写成字符串 'null'
+	w.Record(ctx, Event{
+		ActorUsername: "kairo",
+		EventType:     EventLoginSuccess,
+		Metadata:      map[string]any{"via": "password+totp"},
+		IP:            "127.0.0.1",
+		UserAgent:     "test-agent",
+	})
+
+	if err := w.record(ctx, Event{
+		ActorUsername: "kairo",
+		EventType:     EventLogout,
+	}); err != nil {
+		t.Fatalf("record 返回错误: %v", err)
+	}
+
+	var count int
+	var targetType string
+	var ip string
+	if err := w.db.QueryRow(`SELECT count(*) FROM audit_events`).Scan(&count); err != nil {
+		t.Fatal(err)
+	}
+	if count != 2 {
+		t.Errorf("应写入 2 条事件,实际 %d", count)
+	}
+
+	if err := w.db.QueryRow(
+		`SELECT target_type, ip FROM audit_events WHERE event_type = $1`, EventLoginSuccess).
+		Scan(&targetType, &ip); err != nil {
+		t.Fatal(err)
+	}
+	if targetType != TargetNull {
+		t.Errorf("无对象的 target_type 应为 %q,实际 %q", TargetNull, targetType)
+	}
+	if ip != "127.0.0.1" {
+		t.Errorf("ip = %q", ip)
+	}
+}
+
+func TestRecordMetadataJSON(t *testing.T) {
+	w, closeDB := openTestWriter(t)
+	defer closeDB()
+
+	if err := w.record(context.Background(), Event{
+		EventType:     EventTokenCreated,
+		TargetType:    TargetToken,
+		ActorUsername: "kairo",
+		Metadata: map[string]any{
+			"name":         "cli",
+			"scope":        "read",
+			"token_prefix": "haven_ro",
+		},
+	}); err != nil {
+		t.Fatalf("record 返回错误: %v", err)
+	}
+
+	var name, scope string
+	if err := w.db.QueryRow(
+		`SELECT metadata->>'name', metadata->>'scope' FROM audit_events WHERE event_type = $1`,
+		EventTokenCreated).Scan(&name, &scope); err != nil {
+		t.Fatal(err)
+	}
+	if name != "cli" || scope != "read" {
+		t.Errorf("metadata 解析异常: name=%q scope=%q", name, scope)
+	}
+}
+
+func TestRecordRejectsUnknownEventType(t *testing.T) {
+	w, closeDB := openTestWriter(t)
+	defer closeDB()
+
+	// CHECK 约束兜底:文档未列的事件类型必须写不进去(audit.md 第三节「新增事件类型必须同步更新 CHECK」)
+	if err := w.record(context.Background(), Event{EventType: "bogus_event"}); err == nil {
+		t.Error("未定义的事件类型应被 CHECK 约束拒绝")
+	}
+}

+ 150 - 0
backend/internal/auth/audit_hooks.go

@@ -0,0 +1,150 @@
+package auth
+
+import (
+	"github.com/gin-gonic/gin"
+
+	"github.com/kairo/haven/internal/audit"
+)
+
+// UseAudit 把审计写入器接到 Handler 的各个钩子上。
+//
+// 依据 docs/backend/audit.md:事件在业务成功后写,失败只记日志,
+// 因此这些钩子内部不返回错误、不影响响应。
+func UseAudit(h *Handler, w *audit.Writer) {
+	if w == nil {
+		return
+	}
+
+	record := func(c *gin.Context, e audit.Event) {
+		w.Record(c.Request.Context(), e)
+	}
+
+	h.OnLoginSuccess = func(c *gin.Context, e LoginAudit) {
+		record(c, audit.Event{
+			ActorUserID:   e.UserID,
+			ActorUsername: e.Username,
+			EventType:     audit.EventLoginSuccess,
+			Metadata:      map[string]any{"via": e.Via},
+			IP:            c.ClientIP(),
+			UserAgent:     c.Request.UserAgent(),
+		})
+	}
+
+	h.OnLoginFailure = func(c *gin.Context, username, reason string) {
+		record(c, audit.Event{
+			ActorUsername: username,
+			EventType:     audit.EventLoginFailure,
+			Metadata:      map[string]any{"reason": reason},
+			IP:            c.ClientIP(),
+			UserAgent:     c.Request.UserAgent(),
+		})
+	}
+
+	h.OnLogout = func(c *gin.Context, ac AuditContext) {
+		record(c, audit.Event{
+			ActorUserID:   ac.UserID,
+			ActorUsername: ac.Username,
+			EventType:     audit.EventLogout,
+			IP:            ac.IP,
+			UserAgent:     ac.UA,
+		})
+	}
+
+	h.OnTotpBound = func(c *gin.Context, ac AuditContext) {
+		record(c, audit.Event{
+			ActorUserID:   ac.UserID,
+			ActorUsername: ac.Username,
+			EventType:     audit.EventTotpBound,
+			TargetType:    audit.TargetUser,
+			TargetID:      ac.UserID,
+			IP:            ac.IP,
+			UserAgent:     ac.UA,
+		})
+	}
+
+	h.OnTotpDisabled = func(c *gin.Context, ac AuditContext) {
+		record(c, audit.Event{
+			ActorUserID:   ac.UserID,
+			ActorUsername: ac.Username,
+			EventType:     audit.EventTotpDisabled,
+			TargetType:    audit.TargetUser,
+			TargetID:      ac.UserID,
+			IP:            ac.IP,
+			UserAgent:     ac.UA,
+		})
+	}
+
+	h.OnPasswordChanged = func(c *gin.Context, ac AuditContext) {
+		record(c, audit.Event{
+			ActorUserID:   ac.UserID,
+			ActorUsername: ac.Username,
+			EventType:     audit.EventPasswordChanged,
+			TargetType:    audit.TargetUser,
+			TargetID:      ac.UserID,
+			IP:            ac.IP,
+			UserAgent:     ac.UA,
+		})
+	}
+
+	h.OnTrustedDeviceAdd = func(c *gin.Context, ac AuditContext, deviceName string) {
+		record(c, audit.Event{
+			ActorUserID:   ac.UserID,
+			ActorUsername: ac.Username,
+			EventType:     audit.EventTrustedDeviceAdded,
+			TargetType:    audit.TargetTrustedDevice,
+			Metadata:      map[string]any{"device_name": deviceName},
+			IP:            ac.IP,
+			UserAgent:     ac.UA,
+		})
+	}
+
+	h.OnSessionRevoked = func(c *gin.Context, ac AuditContext, sessionID, createdAt string) {
+		record(c, audit.Event{
+			ActorUserID:   ac.UserID,
+			ActorUsername: ac.Username,
+			EventType:     audit.EventSessionRevoked,
+			TargetType:    audit.TargetSession,
+			TargetID:      sessionID,
+			Metadata:      map[string]any{"session_created_at": createdAt},
+			IP:            ac.IP,
+			UserAgent:     ac.UA,
+		})
+	}
+
+	h.OnDeviceRevoked = func(c *gin.Context, ac AuditContext, deviceName string) {
+		record(c, audit.Event{
+			ActorUserID:   ac.UserID,
+			ActorUsername: ac.Username,
+			EventType:     audit.EventTrustedDeviceRevoke,
+			TargetType:    audit.TargetTrustedDevice,
+			Metadata:      map[string]any{"device_name": deviceName},
+			IP:            ac.IP,
+			UserAgent:     ac.UA,
+		})
+	}
+
+	h.OnTokenCreated = func(c *gin.Context, ac AuditContext, name, scope, prefix string) {
+		record(c, audit.Event{
+			ActorUserID:   ac.UserID,
+			ActorUsername: ac.Username,
+			EventType:     audit.EventTokenCreated,
+			TargetType:    audit.TargetToken,
+			// audit.md 第三节:metadata 不含明文
+			Metadata:  map[string]any{"name": name, "scope": scope, "token_prefix": prefix},
+			IP:        ac.IP,
+			UserAgent: ac.UA,
+		})
+	}
+
+	h.OnTokenRevoked = func(c *gin.Context, ac AuditContext, name, prefix string) {
+		record(c, audit.Event{
+			ActorUserID:   ac.UserID,
+			ActorUsername: ac.Username,
+			EventType:     audit.EventTokenRevoked,
+			TargetType:    audit.TargetToken,
+			Metadata:      map[string]any{"name": name, "token_prefix": prefix},
+			IP:            ac.IP,
+			UserAgent:     ac.UA,
+		})
+	}
+}

+ 8 - 0
backend/internal/auth/auditconst.go

@@ -0,0 +1,8 @@
+package auth
+
+// 登录失败的审计原因,取值必须与 docs/backend/audit.md 第三节一致。
+const (
+	ReasonBadPassword = "bad_password"
+	ReasonBadTotp     = "bad_totp"
+	ReasonNoSuchUser  = "no_such_user"
+)

+ 128 - 0
backend/internal/auth/auth.go

@@ -0,0 +1,128 @@
+// Package auth 实现认证与授权。
+//
+// 依据 docs/backend/auth.md:
+//   - 凭证生成与哈希(token.go)
+//   - 身份识别与授权中间件
+//   - 会话、待验证登录、授信设备、API Token 的存取
+//
+// 约定:*gin.Context 只出现在中间件与 handler 层;
+// 业务函数只接受标准 context.Context(docs/architecture.md 5.2)。
+package auth
+
+import (
+	"context"
+
+	"errors"
+	"time"
+)
+
+// 与 database/sql 交互时统一的错误。
+var (
+	// ErrNotFound 表示记录不存在(或已过期、已撤销),调用方按 404 / 401 处理。
+	ErrNotFound = errors.New("auth: 记录不存在")
+)
+
+// Identity 是授权中间件识别出的请求身份(auth.md 6.1)。
+//
+// 三个维度:身份种类、Token 的 scope、会话是否通过过 TOTP。
+type Identity struct {
+	Kind   IdentityKind
+	UserID string
+	// Scope 仅对 TokenUser 有意义,取值 read / read_write。
+	Scope string
+	// TotpVerified 仅对 SessionUser 有意义:会话创建时是否通过过 TOTP(auth.md 6.2)。
+	TotpVerified bool
+}
+
+// IdentityKind 对应 auth.md 6.1 的三种身份。
+type IdentityKind int
+
+const (
+	// Anonymous 未登录。零值,便于在 context 缺失时安全降级。
+	Anonymous IdentityKind = iota
+	// SessionUser 通过会话 cookie 登录的用户。
+	SessionUser
+	// TokenUser 通过 Authorization: Bearer 鉴权的 API Token 用户。
+	TokenUser
+)
+
+func (k IdentityKind) String() string {
+	switch k {
+	case SessionUser:
+		return "session"
+	case TokenUser:
+		return "token"
+	default:
+		return "anonymous"
+	}
+}
+
+// IsLoggedIn 表示该身份是否已知(会话或 Token)。
+func (i Identity) IsLoggedIn() bool {
+	return i.Kind == SessionUser || i.Kind == TokenUser
+}
+
+// String 返回身份种类的可读名称,用于日志与 /api/me 的 auth_type。
+func (i Identity) String() string {
+	return i.Kind.String()
+}
+
+// Session 是 sessions 表的一行(auth.md 第三节)。
+type Session struct {
+	ID         string
+	UserID     string
+	TokenHash  string
+	ExpiresAt  time.Time
+	LastUsedAt time.Time
+	UserAgent  string
+	CreatedAt  time.Time
+	// TotpVerified 记录会话创建时是否通过过 TOTP。
+	// auth.md 6.2 要求「仅 SessionUser 且会话创建时通过过 TOTP」才满足 totp_verified。
+	TotpVerified bool
+}
+
+// TrustedDevice 是 trusted_devices 表的一行(auth.md 第四节)。
+type TrustedDevice struct {
+	ID         string
+	UserID     string
+	TokenHash  string
+	DeviceName string
+	ExpiresAt  time.Time
+	LastUsedAt time.Time
+	CreatedAt  time.Time
+}
+
+// APIToken 是 tokens 表的一行(auth.md 第五节)。
+type APIToken struct {
+	ID          string
+	UserID      string
+	Name        string
+	TokenHash   string
+	TokenPrefix string
+	Scope       string
+	ExpiresAt   *time.Time
+	LastUsedAt  *time.Time
+	RevokedAt   *time.Time
+	CreatedAt   time.Time
+}
+
+// Store 是鉴权相关的数据访问接口。
+//
+// 实现放在本包的 sqlstore.go,手写 SQL(architecture.md 5.1)。
+// 单独抽接口是为了让中间件与业务逻辑可以在不连库的情况下测试。
+type Store interface {
+	// FindSessionByHash 查未过期的会话;不存在或已过期返回 ErrNotFound。
+	FindSessionByHash(ctx context.Context, tokenHash string) (Session, error)
+	// TouchSession 更新 last_used_at,不延长 expires_at(auth.md 第三节规则 5)。
+	TouchSession(ctx context.Context, id string, at time.Time) error
+	// DeleteSession 撤销会话:硬删除(auth.md 第三节规则 7)。
+	DeleteSession(ctx context.Context, id string) error
+
+	// FindTokenByHash 查已撤销 / 过期时间也一并返回,由调用方判定。
+	FindTokenByHash(ctx context.Context, tokenHash string) (APIToken, error)
+	// TouchToken 更新 tokens.last_used_at。
+	TouchToken(ctx context.Context, id string, at time.Time) error
+
+	// GetUser 按 id 取用户;不存在返回 ErrNotFound。
+	GetUser(ctx context.Context, id string) (User, error)
+}

+ 30 - 0
backend/internal/auth/consts.go

@@ -0,0 +1,30 @@
+package auth
+
+// 认证相关的常量。依据 docs/architecture.md 第四节「常量(不进配置)」:
+// 这些值直接写在代码里,不通过环境变量暴露,避免误配。
+
+import "time"
+
+const (
+	// CookieSession 是会话 cookie 名(auth.md 第三节)。
+	CookieSession = "haven_session"
+	// CookieTrustedDevice 是授信设备 cookie 名(auth.md 第四节)。
+	CookieTrustedDevice = "haven_trusted_device"
+
+	// SessionTTL 是会话有效期,固定 7 天,不做滑动续期(auth.md 第三节规则 4)。
+	SessionTTL = 7 * 24 * time.Hour
+	// TrustedDeviceTTL 是授信设备有效期,30 天(auth.md 第四节规则 3)。
+	TrustedDeviceTTL = 30 * 24 * time.Hour
+	// PendingLoginTTL 是待验证登录有效期,5 分钟(auth.md 3.1 规则 2)。
+	PendingLoginTTL = 5 * time.Minute
+
+	// Token scope 两档(auth.md 第五节规则 4)。
+	ScopeRead      = "read"
+	ScopeReadWrite = "read_write"
+)
+
+// BcryptCost 固定 12(auth.md 8.1)。
+const BcryptCost = 12
+
+// MinPasswordLen 是新密码最小长度(api.md 2.7)。
+const MinPasswordLen = 12

+ 89 - 0
backend/internal/auth/devices.go

@@ -0,0 +1,89 @@
+package auth
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"time"
+)
+
+// 授信设备管理(api.md 第四节 / auth.md 第四节)。
+//
+// 不提供创建设备接口:授信只能在登录时通过 trust_device=true 在那台
+// 浏览器上创建(api.md 4.2 说明)。
+
+// ErrTrustedDeviceNotFound 表示设备不存在或不属于当前用户(404)。
+var ErrTrustedDeviceNotFound = errors.New("授信设备不存在")
+
+// TrustedDeviceSummary 是 GET /api/trusted-devices 的列表项(api.md 4.1)。
+type TrustedDeviceSummary struct {
+	ID         string    `json:"id"`
+	DeviceName string    `json:"device_name"`
+	CreatedAt  time.Time `json:"created_at"`
+	LastUsedAt time.Time `json:"last_used_at"`
+	ExpiresAt  time.Time `json:"expires_at"`
+}
+
+// ListTrustedDevices 返回当前用户的全部授信设备,按最近使用倒序。
+func (s *Service) ListTrustedDevices(ctx context.Context, userID string) ([]TrustedDeviceSummary, error) {
+	const q = `
+		SELECT id, device_name, created_at, last_used_at, expires_at
+		FROM trusted_devices
+		WHERE user_id = $1
+		ORDER BY last_used_at DESC`
+
+	rows, err := s.db.QueryContext(ctx, q, userID)
+	if err != nil {
+		return nil, fmt.Errorf("查询授信设备失败: %w", err)
+	}
+	defer rows.Close()
+
+	out := make([]TrustedDeviceSummary, 0, 4)
+	for rows.Next() {
+		var item TrustedDeviceSummary
+		if err := rows.Scan(&item.ID, &item.DeviceName, &item.CreatedAt, &item.LastUsedAt, &item.ExpiresAt); err != nil {
+			return nil, fmt.Errorf("扫描授信设备失败: %w", err)
+		}
+		out = append(out, item)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, fmt.Errorf("读取授信设备失败: %w", err)
+	}
+	return out, nil
+}
+
+// FindTrustedDevice 返回设备的展示名,用于撤销时写审计事件(audit.md 第三节)。
+func (s *Service) FindTrustedDevice(ctx context.Context, userID, deviceID string) (TrustedDeviceSummary, error) {
+	const q = `
+		SELECT id, device_name, created_at, last_used_at, expires_at
+		FROM trusted_devices WHERE id = $1 AND user_id = $2`
+
+	var item TrustedDeviceSummary
+	err := s.db.QueryRowContext(ctx, q, deviceID, userID).Scan(
+		&item.ID, &item.DeviceName, &item.CreatedAt, &item.LastUsedAt, &item.ExpiresAt)
+	if errors.Is(err, sql.ErrNoRows) {
+		return TrustedDeviceSummary{}, ErrTrustedDeviceNotFound
+	}
+	if err != nil {
+		return TrustedDeviceSummary{}, fmt.Errorf("查询授信设备失败: %w", err)
+	}
+	return item, nil
+}
+
+// RevokeTrustedDevice 撤销即硬删除(api.md 4.2)。
+func (s *Service) RevokeTrustedDevice(ctx context.Context, userID, deviceID string) error {
+	const q = `DELETE FROM trusted_devices WHERE id = $1 AND user_id = $2`
+	res, err := s.db.ExecContext(ctx, q, deviceID, userID)
+	if err != nil {
+		return fmt.Errorf("删除授信设备失败: %w", err)
+	}
+	n, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("读取删除结果失败: %w", err)
+	}
+	if n == 0 {
+		return ErrTrustedDeviceNotFound
+	}
+	return nil
+}

+ 69 - 0
backend/internal/auth/errors.go

@@ -0,0 +1,69 @@
+package auth
+
+import (
+	"github.com/gin-gonic/gin"
+)
+
+// SetErrorCallbacks 注入统一错误响应。
+//
+// auth 包不直接依赖 internal/httpx(httpx 反过来要引用 auth 的错误值),
+// 由 main 在启动时调用一次完成接线。任一回调为 nil 时保留默认实现。
+func SetErrorCallbacks(
+	badRequest func(c *gin.Context, err error),
+	validationFailed func(c *gin.Context, fields map[string]string),
+	internalError func(c *gin.Context, err error),
+	notFound func(c *gin.Context),
+	conflict func(c *gin.Context, message string),
+	unauthorized func(c *gin.Context, message string),
+	unprocessable func(c *gin.Context, message string),
+) {
+	if badRequest != nil {
+		BadRequest = badRequest
+	}
+	if validationFailed != nil {
+		ValidationFailed = validationFailed
+	}
+	if internalError != nil {
+		InternalError = internalError
+	}
+	if notFound != nil {
+		NotFound = notFound
+	}
+	if conflict != nil {
+		Conflict = conflict
+	}
+	if unauthorized != nil {
+		Unauthorized = unauthorized
+	}
+	if unprocessable != nil {
+		Unprocessable = unprocessable
+	}
+}
+
+// 默认实现:返回 api.md 1.4 的最小错误体。main 会用 httpx 覆盖成完整实现。
+
+var (
+	BadRequest       = defaultErrorCallback(400, "bad_request", "请求体格式错误")
+	ValidationFailed = func(c *gin.Context, fields map[string]string) {
+		c.AbortWithStatusJSON(422, gin.H{"error": "validation_failed", "message": "字段校验失败", "fields": fields})
+	}
+	InternalError = defaultErrorCallback(500, "internal_error", "服务端错误")
+	NotFound      = func(c *gin.Context) {
+		c.AbortWithStatusJSON(404, gin.H{"error": "not_found", "message": "资源不存在"})
+	}
+	Conflict = func(c *gin.Context, message string) {
+		c.AbortWithStatusJSON(409, gin.H{"error": "conflict", "message": message})
+	}
+	Unauthorized = func(c *gin.Context, message string) {
+		c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized", "message": message})
+	}
+	Unprocessable = func(c *gin.Context, message string) {
+		c.AbortWithStatusJSON(422, gin.H{"error": "validation_failed", "message": message})
+	}
+)
+
+func defaultErrorCallback(status int, code, message string) func(*gin.Context, error) {
+	return func(c *gin.Context, _ error) {
+		c.AbortWithStatusJSON(status, gin.H{"error": code, "message": message})
+	}
+}

+ 187 - 0
backend/internal/auth/handler.go

@@ -0,0 +1,187 @@
+package auth
+
+import (
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+)
+
+// 认证相关的 HTTP handler。
+//
+// 依据 docs/api.md 第二节。约定(architecture.md 5.2):
+// *gin.Context 只出现在这一层,业务逻辑在本包的 Service 里。
+
+// 注入 context 的键,供审计与 handler 使用。
+const (
+	ctxKeySessionID = "session_id"
+	ctxKeyTokenID   = "token_id"
+	ctxKeyClientIP  = "client_ip"
+	ctxKeyUserAgent = "user_agent"
+)
+
+// Handler 承载认证相关的 HTTP handler。
+type Handler struct {
+	svc    *Service
+	store  Store
+	issuer string // HAVEN_TOTP_ISSUER
+
+	// cookie 下发的 Domain / Secure,来自 HAVEN_COOKIE_DOMAIN 与 HAVEN_COOKIE_SECURE。
+	CookieDomain string
+	CookieSecure bool
+
+	// 下面的钩子由 main 注入,避免本包依赖 audit(审计包反过来要用它的常量)。
+	OnLoginSuccess     func(c *gin.Context, e LoginAudit)
+	OnLoginFailure     func(c *gin.Context, username, reason string)
+	OnLogout           func(c *gin.Context, ac AuditContext)
+	OnTotpBound        func(c *gin.Context, ac AuditContext)
+	OnTotpDisabled     func(c *gin.Context, ac AuditContext)
+	OnPasswordChanged  func(c *gin.Context, ac AuditContext)
+	OnTrustedDeviceAdd func(c *gin.Context, ac AuditContext, deviceName string)
+	OnSessionRevoked   func(c *gin.Context, ac AuditContext, sessionID, sessionCreatedAt string)
+	OnDeviceRevoked    func(c *gin.Context, ac AuditContext, deviceName string)
+	OnTokenCreated     func(c *gin.Context, ac AuditContext, name, scope, prefix string)
+	OnTokenRevoked     func(c *gin.Context, ac AuditContext, name, prefix string)
+}
+
+// NewHandler 构造 Handler。
+func NewHandler(svc *Service, store Store, issuer, cookieDomain string, cookieSecure bool) *Handler {
+	return &Handler{
+		svc:          svc,
+		store:        store,
+		issuer:       issuer,
+		CookieDomain: cookieDomain,
+		CookieSecure: cookieSecure,
+	}
+}
+
+// auditContext 是写审计事件所需的调用方信息。
+type AuditContext struct {
+	UserID   string
+	Username string
+	IP       string
+	UA       string
+}
+
+// LoginAudit 是登录成功时的审计信息。
+type LoginAudit struct {
+	UserID   string
+	Username string
+	Via      string
+}
+
+// 请求体(api.md 2.1 – 2.7)。
+
+type loginRequest struct {
+	Username    string `json:"username" binding:"required"`
+	Password    string `json:"password" binding:"required"`
+	TrustDevice bool   `json:"trust_device"`
+}
+
+type loginTotpRequest struct {
+	TempToken string `json:"temp_token" binding:"required"`
+	Code      string `json:"code" binding:"required"`
+}
+
+type totpVerifyRequest struct {
+	Secret string `json:"secret" binding:"required"`
+	Code   string `json:"code" binding:"required"`
+}
+
+type totpDisableRequest struct {
+	Code string `json:"code" binding:"required"`
+}
+
+type passwordChangeRequest struct {
+	OldPassword string `json:"old_password" binding:"required"`
+	NewPassword string `json:"new_password" binding:"required"`
+	TotpCode    string `json:"totp_code"`
+}
+
+// 响应体。
+
+type userBriefResponse struct {
+	ID       string `json:"id"`
+	Username string `json:"username"`
+}
+
+type loginResponse struct {
+	User         userBriefResponse `json:"user"`
+	TotpRequired bool              `json:"totp_required"`
+	TempToken    string            `json:"temp_token,omitempty"`
+}
+
+type meResponse struct {
+	Anonymous bool               `json:"anonymous"`
+	AuthType  string             `json:"auth_type,omitempty"`
+	Scope     string             `json:"scope,omitempty"`
+	User      *userBriefResponse `json:"user,omitempty"`
+}
+
+func (h *Handler) auditContext(c *gin.Context) AuditContext {
+	id := CurrentIdentity(c)
+	ac := AuditContext{IP: c.ClientIP(), UA: c.Request.UserAgent()}
+	if id.IsLoggedIn() {
+		ac.UserID = id.UserID
+		// 用户名只是审计快照,取不到时留空串(audit_events.actor_username 允许空)
+		if u, err := h.svc.findUserByID(c.Request.Context(), id.UserID); err == nil {
+			ac.Username = u.Username
+		}
+	}
+	return ac
+}
+
+// setSessionCookie 下发会话 cookie。属性见 auth.md 第三节规则 2:
+// HttpOnly、SameSite=Lax、Domain=配置值;Secure 由 HAVEN_COOKIE_SECURE 决定。
+func setSessionCookie(c *gin.Context, domain string, secure bool, token string) {
+	http.SetCookie(c.Writer, &http.Cookie{
+		Name:     CookieSession,
+		Value:    token,
+		Path:     "/",
+		Domain:   domain,
+		MaxAge:   int(SessionTTL.Seconds()),
+		HttpOnly: true,
+		Secure:   secure,
+		SameSite: http.SameSiteLaxMode,
+	})
+}
+
+func setTrustedDeviceCookie(c *gin.Context, domain string, secure bool, token string) {
+	http.SetCookie(c.Writer, &http.Cookie{
+		Name:     CookieTrustedDevice,
+		Value:    token,
+		Path:     "/",
+		Domain:   domain,
+		MaxAge:   int(TrustedDeviceTTL.Seconds()),
+		HttpOnly: true,
+		Secure:   secure,
+		SameSite: http.SameSiteLaxMode,
+	})
+}
+
+func clearSessionCookie(c *gin.Context, domain string, secure bool) {
+	http.SetCookie(c.Writer, &http.Cookie{
+		Name:     CookieSession,
+		Value:    "",
+		Path:     "/",
+		Domain:   domain,
+		MaxAge:   -1,
+		HttpOnly: true,
+		Secure:   secure,
+		SameSite: http.SameSiteLaxMode,
+	})
+}
+
+// SessionID 返回当前请求所用会话的 id;Token 或匿名返回空串。
+func SessionID(c *gin.Context) string {
+	if v, ok := c.Get(ctxKeySessionID); ok {
+		if s, ok := v.(string); ok {
+			return s
+		}
+	}
+	return ""
+}
+
+// UserID 返回当前身份的用户 id。
+func UserID(c *gin.Context) string {
+	return CurrentIdentity(c).UserID
+}

+ 257 - 0
backend/internal/auth/handler_auth.go

@@ -0,0 +1,257 @@
+package auth
+
+import (
+	"context"
+	"errors"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+)
+
+// 认证接口的 handler 实现,对照 docs/api.md 第二节。
+
+// bind 解析 JSON 请求体。
+func bind(c *gin.Context, dst any) bool {
+	if err := c.ShouldBindJSON(dst); err != nil {
+		BadRequest(c, err)
+		return false
+	}
+	return true
+}
+
+// Login 实现 POST /api/auth/login(api.md 2.1)。
+func (h *Handler) Login(c *gin.Context) {
+	var req loginRequest
+	if !bind(c, &req) {
+		return
+	}
+
+	deviceCookie, _ := c.Cookie(CookieTrustedDevice)
+
+	res, err := h.svc.Login(c.Request.Context(), req.Username, req.Password,
+		req.TrustDevice, deviceCookie, c.Request.UserAgent())
+	if err != nil {
+		switch {
+		case errors.Is(err, ErrInvalidCredentials):
+			// 失败审计(audit.md 第三节)
+			h.failLogin(c, req.Username, ReasonBadPassword)
+			Unauthorized(c, ErrInvalidCredentials.Error())
+		default:
+			InternalError(c, err)
+		}
+		return
+	}
+
+	if res.TotpRequired {
+		c.JSON(http.StatusOK, loginResponse{
+			User:         userBriefResponse{ID: res.User.ID, Username: res.User.Username},
+			TotpRequired: true,
+			TempToken:    res.TempToken,
+		})
+		return
+	}
+
+	h.succeedLogin(c, res)
+}
+
+// LoginTotp 实现 POST /api/auth/login/totp(api.md 2.2)。
+func (h *Handler) LoginTotp(c *gin.Context) {
+	var req loginTotpRequest
+	if !bind(c, &req) {
+		return
+	}
+
+	res, err := h.svc.LoginTotp(c.Request.Context(), req.TempToken, req.Code, c.Request.UserAgent())
+	if err != nil {
+		switch {
+		case errors.Is(err, ErrPendingExpired):
+			Unauthorized(c, ErrPendingExpired.Error())
+		case errors.Is(err, ErrBadTotp):
+			h.failLogin(c, h.usernameFromPending(c.Request.Context(), req.TempToken), ReasonBadTotp)
+			Unauthorized(c, ErrBadTotp.Error())
+		default:
+			InternalError(c, err)
+		}
+		return
+	}
+
+	h.succeedLogin(c, res)
+}
+
+// succeedLogin 下发 cookie 并写 login_success 审计。
+func (h *Handler) succeedLogin(c *gin.Context, res LoginResult) {
+	setSessionCookie(c, h.CookieDomain, h.CookieSecure, res.SessionToken)
+	if res.DeviceToken != "" {
+		setTrustedDeviceCookie(c, h.CookieDomain, h.CookieSecure, res.DeviceToken)
+	}
+
+	if h.OnLoginSuccess != nil {
+		h.OnLoginSuccess(c, LoginAudit{UserID: res.User.ID, Username: res.User.Username, Via: res.AuditVia})
+	}
+	if res.DeviceToken != "" && h.OnTrustedDeviceAdd != nil {
+		h.OnTrustedDeviceAdd(c, AuditContext{UserID: res.User.ID, Username: res.User.Username,
+			IP: c.ClientIP(), UA: c.Request.UserAgent()}, res.DeviceName)
+	}
+
+	c.JSON(http.StatusOK, loginResponse{
+		User:         userBriefResponse{ID: res.User.ID, Username: res.User.Username},
+		TotpRequired: false,
+	})
+}
+
+func (h *Handler) failLogin(c *gin.Context, username, reason string) {
+	if h.OnLoginFailure != nil {
+		h.OnLoginFailure(c, username, reason)
+	}
+}
+
+// Logout 实现 POST /api/auth/logout(api.md 2.3)。
+func (h *Handler) Logout(c *gin.Context) {
+	ac := h.auditContext(c)
+
+	if err := h.svc.Logout(c.Request.Context(), SessionID(c)); err != nil {
+		InternalError(c, err)
+		return
+	}
+	clearSessionCookie(c, h.CookieDomain, h.CookieSecure)
+	// 授信设备 cookie 保留(api.md 2.3)
+
+	if SessionID(c) != "" && h.OnLogout != nil {
+		h.OnLogout(c, ac)
+	}
+	c.Status(http.StatusNoContent)
+}
+
+// SetupTotp 实现 POST /api/auth/totp/setup(api.md 2.4)。
+func (h *Handler) SetupTotp(c *gin.Context) {
+	userID := UserID(c)
+
+	secret, uri, err := h.svc.SetupTotp(userID, h.issuer)
+	if err != nil {
+		switch {
+		case errors.Is(err, ErrTotpAlreadyEnabled):
+			Conflict(c, ErrTotpAlreadyEnabled.Error())
+		default:
+			InternalError(c, err)
+		}
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{"secret": secret, "otpauth_uri": uri})
+}
+
+// VerifyTotp 实现 POST /api/auth/totp/verify(api.md 2.5)。
+func (h *Handler) VerifyTotp(c *gin.Context) {
+	var req totpVerifyRequest
+	if !bind(c, &req) {
+		return
+	}
+
+	err := h.svc.VerifyTotp(c.Request.Context(), UserID(c), req.Secret, req.Code)
+	if err != nil {
+		switch {
+		case errors.Is(err, ErrBadTotp):
+			ValidationFailed(c, map[string]string{"code": ErrBadTotp.Error()})
+		case errors.Is(err, ErrTotpAlreadyEnabled):
+			Conflict(c, ErrTotpAlreadyEnabled.Error())
+		default:
+			InternalError(c, err)
+		}
+		return
+	}
+
+	if h.OnTotpBound != nil {
+		h.OnTotpBound(c, h.auditContext(c))
+	}
+	c.JSON(http.StatusOK, gin.H{"totp_enabled": true})
+}
+
+// DisableTotp 实现 POST /api/auth/totp/disable(api.md 2.6)。
+func (h *Handler) DisableTotp(c *gin.Context) {
+	var req totpDisableRequest
+	if !bind(c, &req) {
+		return
+	}
+
+	err := h.svc.DisableTotp(c.Request.Context(), UserID(c), req.Code)
+	if err != nil {
+		switch {
+		case errors.Is(err, ErrBadTotp):
+			Unauthorized(c, ErrBadTotp.Error())
+		case errors.Is(err, ErrTotpNotEnabled):
+			ValidationFailed(c, map[string]string{"code": ErrTotpNotEnabled.Error()})
+		default:
+			InternalError(c, err)
+		}
+		return
+	}
+
+	if h.OnTotpDisabled != nil {
+		h.OnTotpDisabled(c, h.auditContext(c))
+	}
+	c.JSON(http.StatusOK, gin.H{"totp_enabled": false})
+}
+
+// ChangePassword 实现 POST /api/auth/password(api.md 2.7)。
+func (h *Handler) ChangePassword(c *gin.Context) {
+	var req passwordChangeRequest
+	if !bind(c, &req) {
+		return
+	}
+
+	ac := h.auditContext(c)
+	err := h.svc.ChangePassword(c.Request.Context(), UserID(c), req.OldPassword, req.NewPassword, req.TotpCode)
+	if err != nil {
+		switch {
+		case errors.Is(err, ErrInvalidCredentials):
+			Unauthorized(c, ErrInvalidCredentials.Error())
+		case errors.Is(err, ErrBadTotp):
+			Unauthorized(c, ErrBadTotp.Error())
+		case IsPasswordPolicyError(err):
+			ValidationFailed(c, map[string]string{"new_password": err.Error()})
+		default:
+			InternalError(c, err)
+		}
+		return
+	}
+
+	if h.OnPasswordChanged != nil {
+		h.OnPasswordChanged(c, ac)
+	}
+	// 当前会话也被删除,客户端需要重新登录(api.md 2.7 备注)
+	clearSessionCookie(c, h.CookieDomain, h.CookieSecure)
+	c.Status(http.StatusNoContent)
+}
+
+// Me 实现 GET /api/me(api.md 11.1)。
+func (h *Handler) Me(c *gin.Context) {
+	id := CurrentIdentity(c)
+	if !id.IsLoggedIn() {
+		c.JSON(http.StatusOK, meResponse{Anonymous: true})
+		return
+	}
+
+	user, err := h.store.GetUser(c.Request.Context(), id.UserID)
+	if err != nil {
+		InternalError(c, err)
+		return
+	}
+
+	c.JSON(http.StatusOK, meResponse{
+		Anonymous: false,
+		AuthType:  id.String(),
+		Scope:     id.Scope,
+		User:      &userBriefResponse{ID: user.ID, Username: user.Username},
+	})
+}
+
+// usernameFromPending 用临时 token 反查用户名,供 login_failure 审计使用。
+// 查不到时返回空串(audit_events.actor_username 允许空串)。
+func (h *Handler) usernameFromPending(ctx context.Context, tempToken string) string {
+	base, _ := splitPendingToken(tempToken)
+	username, err := h.svc.pendingUsername(ctx, HashToken(base))
+	if err != nil {
+		return ""
+	}
+	return username
+}

+ 163 - 0
backend/internal/auth/handler_manage.go

@@ -0,0 +1,163 @@
+package auth
+
+import (
+	"errors"
+	"net/http"
+	"time"
+
+	"github.com/gin-gonic/gin"
+)
+
+// 会话、授信设备、Token 的 handler,对照 docs/api.md 第三、四、五节。
+// 这三个资源都只允许会话身份操作(Token 一律 403),在路由层挂 RequireSession。
+
+type listResponse struct {
+	Items any `json:"items"`
+	Total int `json:"total"`
+}
+
+// ListSessions 实现 GET /api/sessions(api.md 3.1)。
+func (h *Handler) ListSessions(c *gin.Context) {
+	items, err := h.svc.ListSessions(c.Request.Context(), UserID(c), SessionID(c))
+	if err != nil {
+		InternalError(c, err)
+		return
+	}
+	c.JSON(http.StatusOK, listResponse{Items: items, Total: len(items)})
+}
+
+// DeleteSession 实现 DELETE /api/sessions/:id(api.md 3.2)。
+func (h *Handler) DeleteSession(c *gin.Context) {
+	id := c.Param("id")
+
+	createdAt, err := h.svc.FindSessionOwner(c.Request.Context(), UserID(c), id)
+	if err != nil {
+		if errors.Is(err, ErrSessionNotFound) {
+			NotFound(c)
+			return
+		}
+		InternalError(c, err)
+		return
+	}
+
+	err = h.svc.RevokeSession(c.Request.Context(), UserID(c), id, SessionID(c))
+	if err != nil {
+		switch {
+		case errors.Is(err, ErrCannotRevokeCurrentSession):
+			ValidationFailed(c, map[string]string{"id": ErrCannotRevokeCurrentSession.Error()})
+		case errors.Is(err, ErrSessionNotFound):
+			NotFound(c)
+		default:
+			InternalError(c, err)
+		}
+		return
+	}
+
+	if h.OnSessionRevoked != nil {
+		h.OnSessionRevoked(c, h.auditContext(c), id, createdAt.Format(time.RFC3339))
+	}
+	c.Status(http.StatusNoContent)
+}
+
+// ListTrustedDevices 实现 GET /api/trusted-devices(api.md 4.1)。
+func (h *Handler) ListTrustedDevices(c *gin.Context) {
+	items, err := h.svc.ListTrustedDevices(c.Request.Context(), UserID(c))
+	if err != nil {
+		InternalError(c, err)
+		return
+	}
+	c.JSON(http.StatusOK, listResponse{Items: items, Total: len(items)})
+}
+
+// DeleteTrustedDevice 实现 DELETE /api/trusted-devices/:id(api.md 4.2)。
+func (h *Handler) DeleteTrustedDevice(c *gin.Context) {
+	id := c.Param("id")
+
+	device, err := h.svc.FindTrustedDevice(c.Request.Context(), UserID(c), id)
+	if err != nil {
+		if errors.Is(err, ErrTrustedDeviceNotFound) {
+			NotFound(c)
+			return
+		}
+		InternalError(c, err)
+		return
+	}
+
+	if err := h.svc.RevokeTrustedDevice(c.Request.Context(), UserID(c), id); err != nil {
+		if errors.Is(err, ErrTrustedDeviceNotFound) {
+			NotFound(c)
+			return
+		}
+		InternalError(c, err)
+		return
+	}
+
+	if h.OnDeviceRevoked != nil {
+		h.OnDeviceRevoked(c, h.auditContext(c), device.DeviceName)
+	}
+	c.Status(http.StatusNoContent)
+}
+
+// ListTokens 实现 GET /api/tokens(api.md 5.1)。
+func (h *Handler) ListTokens(c *gin.Context) {
+	items, err := h.svc.ListAPITokens(c.Request.Context(), UserID(c))
+	if err != nil {
+		InternalError(c, err)
+		return
+	}
+	c.JSON(http.StatusOK, listResponse{Items: items, Total: len(items)})
+}
+
+type createTokenRequest struct {
+	Name      string     `json:"name" binding:"required"`
+	Scope     string     `json:"scope" binding:"required"`
+	ExpiresAt *time.Time `json:"expires_at"`
+}
+
+// CreateToken 实现 POST /api/tokens(api.md 5.2)。
+func (h *Handler) CreateToken(c *gin.Context) {
+	var req createTokenRequest
+	if !bind(c, &req) {
+		return
+	}
+	if err := ValidateScope(req.Scope); err != nil {
+		ValidationFailed(c, map[string]string{"scope": err.Error()})
+		return
+	}
+
+	created, err := h.svc.CreateAPIToken(c.Request.Context(), UserID(c), req.Name, req.Scope, req.ExpiresAt)
+	if err != nil {
+		InternalError(c, err)
+		return
+	}
+
+	if h.OnTokenCreated != nil {
+		h.OnTokenCreated(c, h.auditContext(c), created.Name, created.Scope, created.TokenPrefix)
+	}
+
+	// 明文只在这里返回一次(api.md 5.2 / auth.md 第五节规则 1)
+	c.JSON(http.StatusCreated, created)
+}
+
+// DeleteToken 实现 DELETE /api/tokens/:id(api.md 5.3)。
+func (h *Handler) DeleteToken(c *gin.Context) {
+	id := c.Param("id")
+
+	token, err := h.svc.RevokeAPIToken(c.Request.Context(), UserID(c), id)
+	if err != nil {
+		switch {
+		case errors.Is(err, ErrTokenNotFound):
+			NotFound(c)
+		case errors.Is(err, ErrTokenAlreadyRevoked):
+			ValidationFailed(c, map[string]string{"id": ErrTokenAlreadyRevoked.Error()})
+		default:
+			InternalError(c, err)
+		}
+		return
+	}
+
+	if h.OnTokenRevoked != nil {
+		h.OnTokenRevoked(c, h.auditContext(c), token.Name, token.TokenPrefix)
+	}
+	c.Status(http.StatusNoContent)
+}

+ 250 - 0
backend/internal/auth/middleware.go

@@ -0,0 +1,250 @@
+package auth
+
+import (
+	"errors"
+	"time"
+
+	"github.com/gin-gonic/gin"
+)
+
+// 可见性过滤条件(auth.md 6.3)。
+//
+// 所有读接口必须使用这里算出的条件,不得各自拼 visibility 判断
+// (conventions.md 第四节、auth.md 6.3)。
+const (
+	// VisibilityPublic 是「公开可见」的唯一判定条件。
+	VisibilityPublic = `visibility = 'public' AND published_at IS NOT NULL`
+
+	// VisibilityOwnedOrPublic 供已登录用户使用:自己的 OR 公开。
+	// 使用方需把 $1 绑定为当前用户 id。
+	VisibilityOwnedOrPublic = `(user_id = $1) OR (` + VisibilityPublic + `)`
+)
+
+// 注入 *gin.Context 的键。
+const (
+	ctxKeyIdentity   = "identity"
+	ctxKeyVisibility = "visibility"
+)
+
+// Visibility 是中间件算出的数据可见性条件。
+//
+// Condition 是可直接拼进 WHERE 的 SQL 片段,Args 是对应的参数。
+// 私有读与写操作在此基础上还要叠加 user_id 过滤(auth.md 6.4)。
+type Visibility struct {
+	// Condition 为空的含义是「不可见任何数据」,调用方应直接返回空列表或 404。
+	Condition string
+	Args      []any
+}
+
+// Visibility 从 gin context 取出可见性条件。
+func CurrentVisibility(c *gin.Context) Visibility {
+	if v, ok := c.Get(ctxKeyVisibility); ok {
+		if vis, ok := v.(Visibility); ok {
+			return vis
+		}
+	}
+	return Visibility{}
+}
+
+// CurrentIdentity 从 gin context 取出身份,缺失时视为匿名。
+func CurrentIdentity(c *gin.Context) Identity {
+	if v, ok := c.Get(ctxKeyIdentity); ok {
+		if id, ok := v.(Identity); ok {
+			return id
+		}
+	}
+	return Identity{Kind: Anonymous}
+}
+
+// visibilityFor 按身份计算可见性条件(auth.md 6.3)。
+func visibilityFor(id Identity) Visibility {
+	if !id.IsLoggedIn() {
+		return Visibility{Condition: VisibilityPublic}
+	}
+	return Visibility{
+		Condition: VisibilityOwnedOrPublic,
+		Args:      []any{id.UserID},
+	}
+}
+
+// Authenticate 是身份识别中间件(auth.md 6.1)。
+//
+// 顺序:
+//  1. Authorization: Bearer 优先于 cookie,Token 匹配失败直接 401;
+//  2. 否则查会话 cookie;会话无效视为未登录,继续往后走;
+//  3. 否则匿名。
+//
+// 本中间件只识别身份并注入 context,不做接口级权限判定,
+// 权限由 RequireLogin / RequireWrite / RequireTotpVerified 负责(auth.md 6.2)。
+func Authenticate(store Store) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		ctx := c.Request.Context()
+		identity := Identity{Kind: Anonymous}
+
+		if raw := bearerToken(c); raw != "" {
+			tok, err := store.FindTokenByHash(ctx, HashToken(raw))
+			switch {
+			case errors.Is(err, ErrNotFound):
+				// Token 无效:直接拒绝,不退回 cookie 身份(auth.md 6.1 第 1 步)
+				abortUnauthorized(c)
+				return
+			case err != nil:
+				abortInternal(c, err)
+				return
+			}
+			if !tokenUsable(tok, time.Now()) {
+				abortUnauthorized(c)
+				return
+			}
+			identity = Identity{Kind: TokenUser, UserID: tok.UserID, Scope: tok.Scope}
+			// 刷新使用时间属于尽力而为,失败不影响请求
+			_ = store.TouchToken(ctx, tok.ID, time.Now())
+			c.Set(ctxKeyTokenID, tok.ID)
+		} else {
+			// gin 的 Context.Cookie 返回 (string, error):err 非空表示没有该 cookie
+			raw, cookieErr := c.Cookie(CookieSession)
+			if cookieErr == nil && raw != "" {
+				// 会话 token 可能带 TOTP 标记,必须先剥离再哈希(见 session_token.go)
+				base, totpVerified := splitSessionToken(raw)
+				sess, loadErr := store.FindSessionByHash(ctx, HashToken(base))
+				switch {
+				case errors.Is(loadErr, ErrNotFound):
+					// 会话无效:视为未登录(auth.md 6.1 第 2 步)
+				case loadErr != nil:
+					abortInternal(c, loadErr)
+					return
+				default:
+					identity = Identity{
+						Kind:         SessionUser,
+						UserID:       sess.UserID,
+						TotpVerified: totpVerified,
+					}
+					_ = store.TouchSession(ctx, sess.ID, time.Now())
+					// 会话 id 需注入 context:撤销当前会话、标记 is_current 与登出都依赖它
+					c.Set(ctxKeySessionID, sess.ID)
+				}
+			}
+		}
+
+		c.Set(ctxKeyIdentity, identity)
+		c.Set(ctxKeyVisibility, visibilityFor(identity))
+		c.Next()
+	}
+}
+
+// RequireLogin 要求已登录(会话或任意 Token),否则 401(auth.md 6.2)。
+func RequireLogin() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		if !CurrentIdentity(c).IsLoggedIn() {
+			abortUnauthorized(c)
+			return
+		}
+		c.Next()
+	}
+}
+
+// RequireWrite 要求写权限:会话,或 scope = read_write 的 Token;
+// read Token 返回 403(auth.md 6.2)。
+func RequireWrite() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		id := CurrentIdentity(c)
+		if !id.IsLoggedIn() {
+			abortUnauthorized(c)
+			return
+		}
+		if id.Kind == TokenUser && id.Scope != ScopeReadWrite {
+			abortForbidden(c)
+			return
+		}
+		c.Next()
+	}
+}
+
+// RequireTotpVerified 要求会话在创建时通过过 TOTP。
+// Token 一律 403;会话未通过 TOTP 也 403(auth.md 6.2、api.md 1.6)。
+func RequireTotpVerified() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		id := CurrentIdentity(c)
+		if !id.IsLoggedIn() {
+			abortUnauthorized(c)
+			return
+		}
+		if id.Kind != SessionUser || !id.TotpVerified {
+			abortForbidden(c)
+			return
+		}
+		c.Next()
+	}
+}
+
+// RequireSession 要求调用方是会话身份,Token 一律 403。
+//
+// 凭证类资源(会话 / 授信设备 / Token / TOTP / 密码)只允许 Web 会话操作
+// (auth.md 第七节说明;api.md 三、四、五节各接口的「Token 调用返回 403」)。
+func RequireSession() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		id := CurrentIdentity(c)
+		if !id.IsLoggedIn() {
+			abortUnauthorized(c)
+			return
+		}
+		if id.Kind != SessionUser {
+			abortForbidden(c)
+			return
+		}
+		c.Next()
+	}
+}
+
+// bearerToken 取出 Authorization: Bearer 的值;格式不对视为未提供。
+func bearerToken(c *gin.Context) string {
+	header := c.GetHeader("Authorization")
+	const prefix = "Bearer "
+	if len(header) <= len(prefix) || header[:len(prefix)] != prefix {
+		return ""
+	}
+	return header[len(prefix):]
+}
+
+// tokenUsable 判断 Token 是否可用于鉴权(auth.md 6.1):
+// 未撤销且未过期。
+func tokenUsable(t APIToken, now time.Time) bool {
+	if t.RevokedAt != nil {
+		return false
+	}
+	if t.ExpiresAt != nil && !t.ExpiresAt.After(now) {
+		return false
+	}
+	return true
+}
+
+// abortUnauthorized / abortForbidden / abortInternal 走统一错误响应。
+// 具体响应构造函数在 internal/httpx,这里通过注册的回调解耦,避免包循环。
+var (
+	unauthorizedHandler = defaultAbort(401, "unauthorized", "未登录或凭证无效")
+	forbiddenHandler    = defaultAbort(403, "forbidden", "权限不足")
+	internalHandler     = defaultAbort(500, "internal_error", "服务端错误")
+)
+
+// SetErrorHandlers 注入统一错误响应(由 internal/httpx 在启动时调用)。
+func SetErrorHandlers(unauthorized, forbidden, internal func(c *gin.Context, err error)) {
+	if unauthorized != nil {
+		unauthorizedHandler = func(c *gin.Context, err error) { unauthorized(c, err) }
+	}
+	if forbidden != nil {
+		forbiddenHandler = func(c *gin.Context, err error) { forbidden(c, err) }
+	}
+	if internal != nil {
+		internalHandler = func(c *gin.Context, err error) { internal(c, err) }
+	}
+}
+
+func abortUnauthorized(c *gin.Context)        { unauthorizedHandler(c, nil) }
+func abortForbidden(c *gin.Context)           { forbiddenHandler(c, nil) }
+func abortInternal(c *gin.Context, err error) { internalHandler(c, err) }
+
+func defaultAbort(status int, code, msg string) func(*gin.Context, error) {
+	return func(c *gin.Context, _ error) {
+		c.AbortWithStatusJSON(status, gin.H{"error": code, "message": msg})
+	}
+}

+ 273 - 0
backend/internal/auth/middleware_test.go

@@ -0,0 +1,273 @@
+package auth
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+	"time"
+
+	"github.com/gin-gonic/gin"
+)
+
+func init() { gin.SetMode(gin.TestMode) }
+
+func newTestRouter(store Store) *gin.Engine {
+	r := gin.New()
+	r.Use(Authenticate(store))
+
+	r.GET("/whoami", func(c *gin.Context) {
+		id := CurrentIdentity(c)
+		c.JSON(http.StatusOK, gin.H{
+			"kind":          id.String(),
+			"user_id":       id.UserID,
+			"scope":         id.Scope,
+			"totp_verified": id.TotpVerified,
+		})
+	})
+	return r
+}
+
+func do(r *gin.Engine, req *http.Request) *httptest.ResponseRecorder {
+	w := httptest.NewRecorder()
+	r.ServeHTTP(w, req)
+	return w
+}
+
+func TestAuthenticateAnonymous(t *testing.T) {
+	store := newFakeStore()
+	r := newTestRouter(store)
+
+	w := do(r, httptest.NewRequest(http.MethodGet, "/whoami", nil))
+	if w.Code != http.StatusOK {
+		t.Fatalf("状态码 = %d", w.Code)
+	}
+	if body := w.Body.String(); body != `{"kind":"anonymous","scope":"","totp_verified":false,"user_id":""}` {
+		t.Errorf("匿名身份响应异常: %s", body)
+	}
+}
+
+func TestAuthenticateSession(t *testing.T) {
+	store := newFakeStore()
+	plain, _ := newSessionToken()
+	store.sessions[HashToken(plain)] = Session{
+		ID: "sess-1", UserID: "user-1", ExpiresAt: time.Now().Add(time.Hour),
+	}
+
+	r := newTestRouter(store)
+	req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
+	req.AddCookie(&http.Cookie{Name: CookieSession, Value: plain})
+
+	w := do(r, req)
+	if w.Code != http.StatusOK {
+		t.Fatalf("状态码 = %d", w.Code)
+	}
+	if len(store.touchedSessions) != 1 || store.touchedSessions[0] != "sess-1" {
+		t.Error("有效会话应刷新 last_used_at")
+	}
+}
+
+func TestAuthenticateInvalidSessionIsAnonymous(t *testing.T) {
+	store := newFakeStore()
+	r := newTestRouter(store)
+
+	req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
+	req.AddCookie(&http.Cookie{Name: CookieSession, Value: "garbage"})
+
+	w := do(r, req)
+	if w.Code != http.StatusOK {
+		t.Fatalf("无效会话应视为匿名,状态码 = %d", w.Code)
+	}
+	if got := w.Body.String(); got != `{"kind":"anonymous","scope":"","totp_verified":false,"user_id":""}` {
+		t.Errorf("无效会话应降级为匿名: %s", got)
+	}
+}
+
+func TestAuthenticateToken(t *testing.T) {
+	store := newFakeStore()
+	plain, _, _ := newAPIToken(ScopeRead)
+	store.tokens[HashToken(plain)] = APIToken{
+		ID: "tok-1", UserID: "user-1", Scope: ScopeRead,
+	}
+
+	r := newTestRouter(store)
+	req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
+	req.Header.Set("Authorization", "Bearer "+plain)
+
+	w := do(r, req)
+	if w.Code != http.StatusOK {
+		t.Fatalf("状态码 = %d", w.Code)
+	}
+	if len(store.touchedTokens) != 1 {
+		t.Error("有效 Token 应刷新 last_used_at")
+	}
+}
+
+func TestAuthenticateRevokedOrExpiredToken(t *testing.T) {
+	past := time.Now().Add(-time.Hour)
+	future := time.Now().Add(time.Hour)
+
+	cases := []struct {
+		name  string
+		token APIToken
+	}{
+		{name: "已撤销", token: APIToken{RevokedAt: &past}},
+		{name: "已过期", token: APIToken{ExpiresAt: &past}},
+		{name: "撤销时间为零值以外", token: APIToken{ExpiresAt: &future, RevokedAt: &past}},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			store := newFakeStore()
+			plain, _, _ := newAPIToken(ScopeRead)
+			tc.token.ID, tc.token.UserID, tc.token.Scope = "tok-1", "user-1", ScopeRead
+			store.tokens[HashToken(plain)] = tc.token
+
+			r := newTestRouter(store)
+			req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
+			req.Header.Set("Authorization", "Bearer "+plain)
+
+			w := do(r, req)
+			if w.Code != http.StatusUnauthorized {
+				t.Errorf("状态码 = %d,期望 401", w.Code)
+			}
+		})
+	}
+}
+
+func TestAuthenticateInvalidTokenDoesNotFallbackToCookie(t *testing.T) {
+	store := newFakeStore()
+	sessToken, _ := newSessionToken()
+	store.sessions[HashToken(sessToken)] = Session{ID: "sess-1", UserID: "user-1"}
+
+	r := newTestRouter(store)
+	req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
+	req.Header.Set("Authorization", "Bearer bogus")
+	req.AddCookie(&http.Cookie{Name: CookieSession, Value: sessToken})
+
+	w := do(r, req)
+	// auth.md 6.1:Token 匹配失败返回 401,不退回 cookie
+	if w.Code != http.StatusUnauthorized {
+		t.Errorf("非法 Bearer 应立即 401,实际 %d", w.Code)
+	}
+}
+
+func TestBearerOverridesCookie(t *testing.T) {
+	store := newFakeStore()
+	sessToken, _ := newSessionToken()
+	store.sessions[HashToken(sessToken)] = Session{ID: "sess-1", UserID: "session-user"}
+	token, _, _ := newAPIToken(ScopeReadWrite)
+	store.tokens[HashToken(token)] = APIToken{ID: "tok-1", UserID: "token-user", Scope: ScopeReadWrite}
+
+	r := newTestRouter(store)
+	req := httptest.NewRequest(http.MethodGet, "/whoami", nil)
+	req.Header.Set("Authorization", "Bearer "+token)
+	req.AddCookie(&http.Cookie{Name: CookieSession, Value: sessToken})
+
+	w := do(r, req)
+	if got := w.Body.String(); !contains(got, `"user_id":"token-user"`) || !contains(got, `"kind":"token"`) {
+		t.Errorf("Bearer 应优先于 cookie: %s", got)
+	}
+}
+
+func contains(s, sub string) bool {
+	return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
+}
+
+func indexOf(s, sub string) int {
+	for i := 0; i+len(sub) <= len(s); i++ {
+		if s[i:i+len(sub)] == sub {
+			return i
+		}
+	}
+	return -1
+}
+
+func TestRequireHelpers(t *testing.T) {
+	store := newFakeStore()
+	// 通过过 TOTP 的会话 token 带 :totp 标记,查库时先剥离再哈希(session_token.go)
+	verified, _ := newSessionTokenFor(true)
+	store.sessions[HashToken(HashTokenForTest(verified))] = Session{ID: "s", UserID: "u", ExpiresAt: time.Now().Add(time.Hour)}
+	notVerified, _ := newSessionToken()
+	store.sessions[HashToken(notVerified)] = Session{ID: "s2", UserID: "u", TotpVerified: false, ExpiresAt: time.Now().Add(time.Hour)}
+	readToken, _, _ := newAPIToken(ScopeRead)
+	store.tokens[HashToken(readToken)] = APIToken{ID: "t1", UserID: "u", Scope: ScopeRead}
+
+	newRouter := func(mw gin.HandlerFunc) *gin.Engine {
+		r := gin.New()
+		r.Use(Authenticate(store), mw)
+		r.GET("/p", func(c *gin.Context) { c.Status(http.StatusOK) })
+		return r
+	}
+
+	withCookie := func(name string) *http.Request {
+		req := httptest.NewRequest(http.MethodGet, "/p", nil)
+		req.AddCookie(&http.Cookie{Name: CookieSession, Value: name})
+		return req
+	}
+
+	withToken := func(tok string) *http.Request {
+		req := httptest.NewRequest(http.MethodGet, "/p", nil)
+		req.Header.Set("Authorization", "Bearer "+tok)
+		return req
+	}
+
+	t.Run("RequireLogin", func(t *testing.T) {
+		r := newRouter(RequireLogin())
+		if c := do(r, httptest.NewRequest(http.MethodGet, "/p", nil)).Code; c != http.StatusUnauthorized {
+			t.Errorf("匿名应 401,实际 %d", c)
+		}
+		if c := do(r, withCookie(verified)).Code; c != http.StatusOK {
+			t.Errorf("会话应放行,实际 %d", c)
+		}
+		if c := do(r, withToken(readToken)).Code; c != http.StatusOK {
+			t.Errorf("Token 应放行,实际 %d", c)
+		}
+	})
+
+	t.Run("RequireWrite", func(t *testing.T) {
+		r := newRouter(RequireWrite())
+		if c := do(r, httptest.NewRequest(http.MethodGet, "/p", nil)).Code; c != http.StatusUnauthorized {
+			t.Errorf("匿名应 401,实际 %d", c)
+		}
+		if c := do(r, withToken(readToken)).Code; c != http.StatusForbidden {
+			t.Errorf("read Token 写操作应 403,实际 %d", c)
+		}
+		if c := do(r, withCookie(notVerified)).Code; c != http.StatusOK {
+			t.Errorf("会话写操作应放行,实际 %d", c)
+		}
+	})
+
+	t.Run("RequireTotpVerified", func(t *testing.T) {
+		r := newRouter(RequireTotpVerified())
+		if c := do(r, withCookie(notVerified)).Code; c != http.StatusForbidden {
+			t.Errorf("未通过 TOTP 的会话应 403,实际 %d", c)
+		}
+		if c := do(r, withToken(readToken)).Code; c != http.StatusForbidden {
+			t.Errorf("Token 应 403,实际 %d", c)
+		}
+		if c := do(r, withCookie(verified)).Code; c != http.StatusOK {
+			t.Errorf("通过 TOTP 的会话应放行,实际 %d", c)
+		}
+	})
+}
+
+func TestVisibilityFor(t *testing.T) {
+	anon := visibilityFor(Identity{Kind: Anonymous})
+	if anon.Condition != VisibilityPublic || len(anon.Args) != 0 {
+		t.Errorf("匿名可见性条件异常: %+v", anon)
+	}
+
+	user := visibilityFor(Identity{Kind: SessionUser, UserID: "u1"})
+	if user.Condition != VisibilityOwnedOrPublic {
+		t.Errorf("登录用户可见性条件异常: %+v", user)
+	}
+	if len(user.Args) != 1 || user.Args[0] != "u1" {
+		t.Errorf("登录用户可见性参数异常: %+v", user.Args)
+	}
+
+	// Token 与会话的可见性规则相同(auth.md 6.3)
+	tok := visibilityFor(Identity{Kind: TokenUser, UserID: "u1", Scope: ScopeRead})
+	if tok.Condition != user.Condition {
+		t.Error("Token 与 Session 的可见性条件应一致")
+	}
+}

+ 52 - 0
backend/internal/auth/routes.go

@@ -0,0 +1,52 @@
+package auth
+
+import (
+	"github.com/gin-gonic/gin"
+)
+
+// Routes 注册认证相关路由。
+//
+// 鉴权归属见 api.md 第二节:
+//   - /login、/login/totp:匿名;
+//   - /logout、/totp/setup、/totp/verify:登录(Token 不可用,凭证管理只允许会话);
+//   - /totp/disable、/password:登录 + TOTP(api.md 2.6、2.7)。
+//
+// base 提供 Authenticate 中间件已经注入的身份与可见性,本函数只负责权限档位。
+func RegisterRoutes(r gin.IRouter, h *Handler) {
+	r.POST("/api/auth/login", h.Login)
+	r.POST("/api/auth/login/totp", h.LoginTotp)
+
+	sessionOnly := r.Group("/api/auth", RequireSession())
+	sessionOnly.POST("/logout", h.Logout)
+	sessionOnly.POST("/totp/setup", h.SetupTotp)
+	sessionOnly.POST("/totp/verify", h.VerifyTotp)
+
+	totpOnly := r.Group("/api/auth", RequireSession(), RequireTotpVerified())
+	totpOnly.POST("/totp/disable", h.DisableTotp)
+	totpOnly.POST("/password", h.ChangePassword)
+}
+
+// RegisterManageRoutes 注册会话 / 授信设备 / Token 路由(api.md 第三、四、五节)。
+//
+// 全部要求会话身份:
+//   - 会话与授信设备的列表/撤销:会话即可;
+//   - Token 的增删查:会话 + TOTP(api.md 5.1–5.3)。
+func RegisterManageRoutes(r gin.IRouter, h *Handler) {
+	s := r.Group("/api/sessions", RequireSession())
+	s.GET("", h.ListSessions)
+	s.DELETE("/:id", h.DeleteSession)
+
+	d := r.Group("/api/trusted-devices", RequireSession())
+	d.GET("", h.ListTrustedDevices)
+	d.DELETE("/:id", h.DeleteTrustedDevice)
+
+	t := r.Group("/api/tokens", RequireSession(), RequireTotpVerified())
+	t.GET("", h.ListTokens)
+	t.POST("", h.CreateToken)
+	t.DELETE("/:id", h.DeleteToken)
+}
+
+// RegisterMeRoute 注册 GET /api/me(api.md 11.1),匿名可访问。
+func RegisterMeRoute(r gin.IRouter, h *Handler) {
+	r.GET("/api/me", h.Me)
+}

+ 152 - 0
backend/internal/auth/secret.go

@@ -0,0 +1,152 @@
+package auth
+
+import (
+	"crypto/hmac"
+	"crypto/rand"
+	"crypto/sha1"
+	"encoding/base32"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"net/url"
+	"strings"
+	"time"
+
+	"golang.org/x/crypto/bcrypt"
+)
+
+// 密码与 TOTP 的编解码与校验。依据 docs/backend/auth.md 第八节。
+//
+//   - 密码用 bcrypt,cost 固定 12;
+//   - TOTP 按 RFC 6238(HMAC-SHA1、6 位、30 秒步长),secret 明文存储(auth.md 8.2);
+//   - 校验允许前后各一个时间窗,容忍轻微时钟漂移;这不改变 30 秒的有效期语义。
+
+const (
+	totpDigits = 6
+	totpPeriod = 30 * time.Second
+	// totpSkew 允许的时间窗漂移,单位:步长。1 表示接受上一个和下一个 30 秒窗口。
+	totpSkew = 1
+)
+
+// MinPasswordLen 见 docs/api.md 2.7(已定义在 consts.go)。
+
+// 审计用的登录途径(audit.md 第三节 login_success 的 metadata.via)。
+const (
+	auditViaTotp          = "password+totp"
+	auditViaTrustedDevice = "password+trusted_device"
+)
+
+// HashPassword 用 bcrypt 生成密码哈希,cost 固定 12(auth.md 8.1)。
+func HashPassword(password string) (string, error) {
+	hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
+	if err != nil {
+		return "", fmt.Errorf("生成密码哈希失败: %w", err)
+	}
+	return string(hash), nil
+}
+
+// passwordMatches 用恒定时间比较校验密码。
+func passwordMatches(hash, password string) bool {
+	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
+}
+
+// ErrPasswordTooShort / ErrPasswordSameAsOld 是密码策略错误,
+// handler 层据此返回 422 validation_failed(api.md 2.7)。
+var (
+	ErrPasswordTooShort  = fmt.Errorf("新密码至少 %d 位", MinPasswordLen)
+	ErrPasswordSameAsOld = errors.New("新密码不能与旧密码相同")
+)
+
+// ValidatePassword 校验新密码:至少 MinPasswordLen 位,且不能与旧密码相同(api.md 2.7)。
+func ValidatePassword(newPassword, oldPassword string) error {
+	if len([]rune(newPassword)) < MinPasswordLen {
+		return ErrPasswordTooShort
+	}
+	if newPassword == oldPassword {
+		return ErrPasswordSameAsOld
+	}
+	return nil
+}
+
+// IsPasswordPolicyError 判断错误是否属于密码策略问题(应返回 422)。
+func IsPasswordPolicyError(err error) bool {
+	return errors.Is(err, ErrPasswordTooShort) || errors.Is(err, ErrPasswordSameAsOld)
+}
+
+// NewTotpSecret 生成新的 TOTP 密钥(base32 无填充,20 字节 = 160 位,RFC 4226 推荐长度),
+// 并返回可被验证器扫描的 otpauth URI(api.md 2.4)。
+func NewTotpSecret(issuer, username string) (secret, uri string, err error) {
+	buf := make([]byte, 20)
+	if _, err := rand.Read(buf); err != nil {
+		return "", "", fmt.Errorf("生成 TOTP 密钥失败: %w", err)
+	}
+
+	secret = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)
+
+	// otpauth://totp/<issuer>:<username>?secret=...&issuer=<issuer>
+	label := url.PathEscape(issuer + ":" + username)
+	q := url.Values{}
+	q.Set("secret", secret)
+	q.Set("issuer", issuer)
+	q.Set("algorithm", "SHA1")
+	q.Set("digits", fmt.Sprint(totpDigits))
+	q.Set("period", fmt.Sprint(int(totpPeriod.Seconds())))
+
+	return secret, "otpauth://totp/" + label + "?" + q.Encode(), nil
+}
+
+// TotpValid 校验 6 位动态码,允许 totpSkew 个步长的时间漂移。
+func TotpValid(secret, code string, now time.Time) bool {
+	code = strings.TrimSpace(code)
+	if len(code) != totpDigits {
+		return false
+	}
+	if secret == "" {
+		return false
+	}
+
+	key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
+	if err != nil {
+		return false
+	}
+
+	counter := now.Unix() / int64(totpPeriod.Seconds())
+	for offset := -int64(totpSkew); offset <= int64(totpSkew); offset++ {
+		want, err := hotp(key, uint64(counter+offset))
+		if err != nil {
+			return false
+		}
+		// 恒定时间比较,避免通过耗时差异逐位猜码
+		if secureEqual(want, code) {
+			return true
+		}
+	}
+	return false
+}
+
+// hotp 按 RFC 4226 计算 HOTP 值,格式化为 6 位十进制字符串。
+func hotp(key []byte, counter uint64) (string, error) {
+	var buf [8]byte
+	binary.BigEndian.PutUint64(buf[:], counter)
+
+	mac := hmac.New(sha1.New, key)
+	if _, err := mac.Write(buf[:]); err != nil {
+		return "", fmt.Errorf("计算 TOTP 失败: %w", err)
+	}
+	sum := mac.Sum(nil)
+
+	// 动态截断(RFC 4226 5.3)
+	offset := sum[len(sum)-1] & 0x0f
+	value := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff
+
+	return fmt.Sprintf("%0*d", totpDigits, value%1_000_000), nil
+}
+
+// GenerateTotpCode 生成指定时刻的动态码,仅供测试与本地调试使用。
+func GenerateTotpCode(secret string, at time.Time) (string, error) {
+	key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret))
+	if err != nil {
+		return "", fmt.Errorf("解码 TOTP 密钥失败: %w", err)
+	}
+	return hotp(key, uint64(at.Unix()/int64(totpPeriod.Seconds())))
+}

+ 170 - 0
backend/internal/auth/secret_test.go

@@ -0,0 +1,170 @@
+package auth
+
+import (
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestHashPassword(t *testing.T) {
+	hash, err := HashPassword("correct-horse-battery")
+	if err != nil {
+		t.Fatalf("HashPassword 返回错误: %v", err)
+	}
+	if !strings.HasPrefix(hash, "$2a$12$") {
+		t.Errorf("应为 bcrypt cost 12 哈希,实际 %q", hash)
+	}
+	if !passwordMatches(hash, "correct-horse-battery") {
+		t.Error("正确密码应校验通过")
+	}
+	if passwordMatches(hash, "wrong-password") {
+		t.Error("错误密码不应通过")
+	}
+}
+
+func TestValidatePassword(t *testing.T) {
+	cases := []struct {
+		name    string
+		newPw   string
+		oldPw   string
+		wantErr bool
+	}{
+		{name: "太短", newPw: "short", oldPw: "x", wantErr: true},
+		{name: "刚好12位", newPw: "123456789012", oldPw: "old", wantErr: false},
+		{name: "与旧密码相同", newPw: "123456789012", oldPw: "123456789012", wantErr: true},
+		{name: "中文12字", newPw: "密码密码密码密码密码密码", oldPw: "old", wantErr: false},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			err := ValidatePassword(tc.newPw, tc.oldPw)
+			if tc.wantErr && err == nil {
+				t.Error("应返回错误")
+			}
+			if !tc.wantErr && err != nil {
+				t.Errorf("不应返回错误: %v", err)
+			}
+		})
+	}
+}
+
+func TestNewTotpSecret(t *testing.T) {
+	secret, uri, err := NewTotpSecret("Haven", "kairo")
+	if err != nil {
+		t.Fatalf("NewTotpSecret 返回错误: %v", err)
+	}
+	if len(secret) != 32 {
+		t.Errorf("20 字节 base32 无填充应为 32 字符,实际 %d (%q)", len(secret), secret)
+	}
+	if strings.Contains(secret, "=") {
+		t.Errorf("secret 不应含 padding: %q", secret)
+	}
+	for _, want := range []string{
+		"otpauth://totp/Haven:kairo",
+		"secret=" + secret,
+		"issuer=Haven",
+		"digits=6",
+		"period=30",
+	} {
+		if !strings.Contains(uri, want) {
+			t.Errorf("otpauth URI 缺少 %q: %s", want, uri)
+		}
+	}
+}
+
+// RFC 6238 附录 B 的 SHA1 测试向量(secret 为 "12345678901234567890")。
+func TestTotpRFC6238Vectors(t *testing.T) {
+	// base32("12345678901234567890")
+	const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
+
+	cases := []struct {
+		unix int64
+		want string
+	}{
+		{unix: 59, want: "287082"},
+		{unix: 1111111109, want: "081804"},
+		{unix: 1111111111, want: "050471"},
+		{unix: 1234567890, want: "005924"},
+		{unix: 2000000000, want: "279037"},
+	}
+
+	for _, tc := range cases {
+		at := time.Unix(tc.unix, 0).UTC()
+		if !TotpValid(secret, tc.want, at) {
+			t.Errorf("TOTP(%d) 应校验通过 %s", tc.unix, tc.want)
+		}
+		code, err := GenerateTotpCode(secret, at)
+		if err != nil {
+			t.Fatalf("GenerateTotpCode 返回错误: %v", err)
+		}
+		if code != tc.want {
+			t.Errorf("GenerateTotpCode(%d) = %s,期望 %s", tc.unix, code, tc.want)
+		}
+	}
+}
+
+func TestTotpSkewWindow(t *testing.T) {
+	const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
+	base := time.Unix(1111111109, 0).UTC()
+
+	prev, _ := GenerateTotpCode(secret, base.Add(-totpPeriod))
+	next, _ := GenerateTotpCode(secret, base.Add(totpPeriod))
+	far, _ := GenerateTotpCode(secret, base.Add(5*totpPeriod))
+
+	if !TotpValid(secret, prev, base) {
+		t.Error("上一个时间窗的码应通过(容忍漂移)")
+	}
+	if !TotpValid(secret, next, base) {
+		t.Error("下一个时间窗的码应通过(容忍漂移)")
+	}
+	if TotpValid(secret, far, base) {
+		t.Error("超出漂移窗口的码不应通过")
+	}
+}
+
+func TestTotpInvalidInputs(t *testing.T) {
+	const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
+	now := time.Now()
+
+	cases := []struct{ name, secret, code string }{
+		{name: "码太短", secret: secret, code: "12345"},
+		{name: "码太长", secret: secret, code: "1234567"},
+		{name: "非数字", secret: secret, code: "abcdef"},
+		{name: "空 secret", secret: "", code: "123456"},
+		{name: "非法 base32", secret: "!!!!", code: "123456"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if TotpValid(tc.secret, tc.code, now) {
+				t.Error("非法输入不应通过")
+			}
+		})
+	}
+
+	// 前后空白应被容忍
+	code, _ := GenerateTotpCode(secret, now)
+	if !TotpValid(secret, " "+code+" ", now) {
+		t.Error("码前后空白应被裁剪")
+	}
+}
+
+func TestCookieConstants(t *testing.T) {
+	// 这些是文档规定的常量,改动需要先改 docs(architecture.md 第四节)
+	if CookieSession != "haven_session" {
+		t.Errorf("会话 cookie 名 = %q", CookieSession)
+	}
+	if CookieTrustedDevice != "haven_trusted_device" {
+		t.Errorf("授信设备 cookie 名 = %q", CookieTrustedDevice)
+	}
+	if SessionTTL != 7*24*time.Hour {
+		t.Errorf("会话有效期 = %v", SessionTTL)
+	}
+	if TrustedDeviceTTL != 30*24*time.Hour {
+		t.Errorf("授信设备有效期 = %v", TrustedDeviceTTL)
+	}
+	if PendingLoginTTL != 5*time.Minute {
+		t.Errorf("待验证登录有效期 = %v", PendingLoginTTL)
+	}
+	if BcryptCost != 12 {
+		t.Errorf("bcrypt cost = %d", BcryptCost)
+	}
+}

+ 450 - 0
backend/internal/auth/service.go

@@ -0,0 +1,450 @@
+package auth
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+)
+
+// 登录 / TOTP / 密码相关的业务逻辑。
+//
+// 依据 docs/backend/auth.md:
+//   - 第二节:两步登录流程;
+//   - 第三节:会话,7 天,硬删除;
+//   - 3.1:pending_logins,5 分钟;trust_device 拼在临时 token 明文末尾,不落库;
+//   - 第四节:授信设备,30 天,免 TOTP 不免密;
+//   - 第八节:bcrypt、TOTP secret 明文存储、改密后的级联。
+
+// 业务错误类型,由 handler 层映射成 api.md 1.4 的 HTTP 状态码。
+var (
+	// ErrInvalidCredentials 用户名或密码错误(401)。message 统一,防枚举(api.md 2.1)。
+	ErrInvalidCredentials = errors.New("用户名或密码错误")
+	// ErrPendingExpired 临时 token 无效或已过期(401)。
+	ErrPendingExpired = errors.New("登录已超时,请重新登录")
+	// ErrBadTotp 动态码错误(401)。
+	ErrBadTotp = errors.New("动态码错误")
+	// ErrTotpAlreadyEnabled TOTP 已启用(409)。
+	ErrTotpAlreadyEnabled = errors.New("已启用 TOTP")
+	// ErrTotpNotEnabled 尚未启用 TOTP(422)。
+	ErrTotpNotEnabled = errors.New("尚未启用 TOTP")
+	// ErrSessionNotFound 会话不存在或不属于当前用户(404)。
+	ErrSessionNotFound = errors.New("会话不存在")
+	// ErrCannotRevokeCurrentSession 不能撤销当前会话(422,api.md 3.2)。
+	ErrCannotRevokeCurrentSession = errors.New("请使用登出接口撤销当前会话")
+)
+
+// User 是 users 表在鉴权层的完整视图(auth.md 第九节)。
+type User struct {
+	ID           string
+	Username     string
+	PasswordHash string
+	TotpSecret   string
+	TotpEnabled  bool
+}
+
+// LoginResult 是 POST /api/auth/login 的领域结果(api.md 2.1)。
+type LoginResult struct {
+	User         UserBrief
+	TotpRequired bool
+	// TempToken 仅在 TotpRequired 时非空。
+	TempToken string
+	// SessionToken / DeviceToken 是下发 cookie 的明文,仅在不需 TOTP 时非空。
+	SessionToken string
+	DeviceToken  string
+	// DeviceName 是新建授信设备时使用的展示名,供写 trusted_device_added 审计。
+	DeviceName string
+	// AuditVia 用于写 login_success 审计(audit.md 第三节)。
+	// 不需要 TOTP 时也要写,值为 password+totp 或 password+trusted_device。
+	AuditVia string
+}
+
+// UserBrief 是响应里的 {id, username}(api.md 2.1)。
+type UserBrief struct {
+	ID       string `json:"id"`
+	Username string `json:"username"`
+}
+
+// Service 承载认证业务逻辑,不依赖 gin。
+type Service struct {
+	db  *sql.DB
+	now func() time.Time
+}
+
+// NewService 构造 Service。
+func NewService(db *sql.DB) *Service {
+	return &Service{db: db, now: time.Now}
+}
+
+// Login 执行第二步之前的第一步:校验账密,再决定是否直接建会话。
+//
+// 流程见 auth.md 2.1:
+//  1. 账密错误 → ErrInvalidCredentials;
+//  2. 未启用 TOTP → 直接建会话;
+//  3. 启用 TOTP 但授信设备有效 → 跳过 TOTP,直接建会话;
+//  4. 否则 → 建 pending_logins 并返回临时 token。
+func (s *Service) Login(ctx context.Context, username, password string, trustDevice bool, trustedDeviceCookie, userAgent string) (LoginResult, error) {
+	user, err := s.findUserByUsername(ctx, username)
+	if errors.Is(err, ErrNotFound) {
+		return LoginResult{}, ErrInvalidCredentials
+	}
+	if err != nil {
+		return LoginResult{}, err
+	}
+
+	if !passwordMatches(user.PasswordHash, password) {
+		return LoginResult{}, ErrInvalidCredentials
+	}
+
+	now := s.now()
+
+	// 未启用 TOTP:第一步即完成登录
+	if !user.TotpEnabled {
+		res, err := s.issueSession(ctx, user, trustDevice, userAgent, now)
+		if err != nil {
+			return LoginResult{}, err
+		}
+		res.AuditVia = auditViaTotp
+		return res, nil
+	}
+
+	// 启用 TOTP:先看授信设备是否有效。授信设备不免密,只免 TOTP(auth.md 2.3)
+	if trustedDeviceCookie != "" {
+		ok, err := s.trustedDeviceValid(ctx, user.ID, trustedDeviceCookie, now)
+		if err != nil {
+			return LoginResult{}, err
+		}
+		if ok {
+			res, err := s.issueSession(ctx, user, trustDevice, userAgent, now)
+			if err != nil {
+				return LoginResult{}, err
+			}
+			res.AuditVia = auditViaTrustedDevice
+			return res, nil
+		}
+	}
+
+	// 需要 TOTP:建 pending_logins,trust_device 编进临时 token 明文
+	tempToken, err := newPendingToken(trustDevice)
+	if err != nil {
+		return LoginResult{}, err
+	}
+	hashedToken, _ := splitPendingToken(tempToken)
+
+	// 顺手清理该用户已过期的待验证记录(auth.md 3.1 规则 3 允许)
+	if _, err := s.db.ExecContext(ctx,
+		`DELETE FROM pending_logins WHERE user_id = $1 AND expires_at <= now()`, user.ID); err != nil {
+		return LoginResult{}, fmt.Errorf("清理过期待验证登录失败: %w", err)
+	}
+
+	if _, err := s.db.ExecContext(ctx,
+		`INSERT INTO pending_logins (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
+		user.ID, HashToken(hashedToken), now.Add(PendingLoginTTL)); err != nil {
+		return LoginResult{}, fmt.Errorf("创建待验证登录失败: %w", err)
+	}
+
+	return LoginResult{
+		User:         UserBrief{ID: user.ID, Username: user.Username},
+		TotpRequired: true,
+		TempToken:    tempToken,
+	}, nil
+}
+
+// LoginTotp 执行第二步:校验临时 token 与动态码(api.md 2.2 / auth.md 2.2)。
+func (s *Service) LoginTotp(ctx context.Context, tempToken, code, userAgent string) (LoginResult, error) {
+	// auth.md 3.1 规则 1:先剥离 trust_device 后缀,再哈希查库
+	base, trustDevice := splitPendingToken(tempToken)
+
+	now := s.now()
+	var userID string
+	err := s.db.QueryRowContext(ctx,
+		`SELECT user_id FROM pending_logins WHERE token_hash = $1 AND expires_at > now()`,
+		HashToken(base),
+	).Scan(&userID)
+	if errors.Is(err, sql.ErrNoRows) {
+		return LoginResult{}, ErrPendingExpired
+	}
+	if err != nil {
+		return LoginResult{}, fmt.Errorf("查询待验证登录失败: %w", err)
+	}
+
+	user, err := s.findUserByID(ctx, userID)
+	if err != nil {
+		return LoginResult{}, err
+	}
+
+	if !TotpValid(user.TotpSecret, code, now) {
+		// auth.md 3.1 规则 5:不做失败计数,记录保留到过期
+		return LoginResult{}, ErrBadTotp
+	}
+
+	if _, err := s.db.ExecContext(ctx,
+		`DELETE FROM pending_logins WHERE token_hash = $1`, HashToken(base)); err != nil {
+		return LoginResult{}, fmt.Errorf("删除待验证登录失败: %w", err)
+	}
+
+	res, err := s.issueSessionWithFlags(ctx, user, trustDevice, userAgent, true, now)
+	if err != nil {
+		return LoginResult{}, err
+	}
+	res.AuditVia = auditViaTotp
+	return res, nil
+}
+
+// Logout 删除当前会话;Token 调用无实际操作(api.md 2.3)。
+func (s *Service) Logout(ctx context.Context, sessionID string) error {
+	if sessionID == "" {
+		return nil
+	}
+	const q = `DELETE FROM sessions WHERE id = $1`
+	if _, err := s.db.ExecContext(ctx, q, sessionID); err != nil {
+		return fmt.Errorf("删除会话失败: %w", err)
+	}
+	return nil
+}
+
+// SetupTotp 生成新的 TOTP secret 与 otpauth URI,但**不落库**(api.md 2.4)。
+func (s *Service) SetupTotp(userID, issuer string) (secret, uri string, err error) {
+	user, err := s.findUserByID(context.Background(), userID)
+	if err != nil {
+		return "", "", err
+	}
+	if user.TotpEnabled {
+		return "", "", ErrTotpAlreadyEnabled
+	}
+
+	secret, uri, err = NewTotpSecret(issuer, user.Username)
+	if err != nil {
+		return "", "", err
+	}
+	return secret, uri, nil
+}
+
+// VerifyTotp 校验并落库 secret,置 totp_enabled = true(api.md 2.5)。
+func (s *Service) VerifyTotp(ctx context.Context, userID, secret, code string) error {
+	user, err := s.findUserByID(ctx, userID)
+	if err != nil {
+		return err
+	}
+	if user.TotpEnabled {
+		return ErrTotpAlreadyEnabled
+	}
+	if !TotpValid(secret, code, s.now()) {
+		return ErrBadTotp
+	}
+
+	const q = `UPDATE users SET totp_secret = $2, totp_enabled = true, updated_at = now() WHERE id = $1`
+	if _, err := s.db.ExecContext(ctx, q, userID, secret); err != nil {
+		return fmt.Errorf("保存 TOTP secret 失败: %w", err)
+	}
+	return nil
+}
+
+// DisableTotp 关闭 TOTP 并删除该用户全部授信设备(api.md 2.6 / auth.md)。
+func (s *Service) DisableTotp(ctx context.Context, userID, code string) error {
+	user, err := s.findUserByID(ctx, userID)
+	if err != nil {
+		return err
+	}
+	if !user.TotpEnabled {
+		return ErrTotpNotEnabled
+	}
+	if !TotpValid(user.TotpSecret, code, s.now()) {
+		return ErrBadTotp
+	}
+
+	tx, err := s.db.BeginTx(ctx, nil)
+	if err != nil {
+		return fmt.Errorf("开启事务失败: %w", err)
+	}
+	defer tx.Rollback()
+
+	if _, err := tx.ExecContext(ctx,
+		`UPDATE users SET totp_secret = NULL, totp_enabled = false, updated_at = now() WHERE id = $1`,
+		userID); err != nil {
+		return fmt.Errorf("关闭 TOTP 失败: %w", err)
+	}
+	// 授信设备的存在意义是免 TOTP,关闭后必须一并删除(api.md 2.6 备注)
+	if _, err := tx.ExecContext(ctx,
+		`DELETE FROM trusted_devices WHERE user_id = $1`, userID); err != nil {
+		return fmt.Errorf("删除授信设备失败: %w", err)
+	}
+
+	return tx.Commit()
+}
+
+// ChangePassword 修改密码:同事务内删全部会话与授信设备,不删 Token(auth.md 8.3)。
+func (s *Service) ChangePassword(ctx context.Context, userID, oldPassword, newPassword, totpCode string) error {
+	user, err := s.findUserByID(ctx, userID)
+	if err != nil {
+		return err
+	}
+	if !passwordMatches(user.PasswordHash, oldPassword) {
+		return ErrInvalidCredentials
+	}
+	if user.TotpEnabled && !TotpValid(user.TotpSecret, totpCode, s.now()) {
+		return ErrBadTotp
+	}
+	if err := ValidatePassword(newPassword, oldPassword); err != nil {
+		return err
+	}
+
+	hash, err := HashPassword(newPassword)
+	if err != nil {
+		return err
+	}
+
+	tx, err := s.db.BeginTx(ctx, nil)
+	if err != nil {
+		return fmt.Errorf("开启事务失败: %w", err)
+	}
+	defer tx.Rollback()
+
+	if _, err := tx.ExecContext(ctx,
+		`UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1`,
+		userID, hash); err != nil {
+		return fmt.Errorf("更新密码失败: %w", err)
+	}
+	if _, err := tx.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = $1`, userID); err != nil {
+		return fmt.Errorf("删除会话失败: %w", err)
+	}
+	if _, err := tx.ExecContext(ctx, `DELETE FROM trusted_devices WHERE user_id = $1`, userID); err != nil {
+		return fmt.Errorf("删除授信设备失败: %w", err)
+	}
+	// 刻意不删 tokens(auth.md 8.3 第 3 条)
+
+	return tx.Commit()
+}
+
+// issueSessionWithFlags 与 issueSession 相同,额外指定会话是否带 TOTP 标记。
+//
+// login_success 的 via 决定标记:
+//   - password+totp              → 会话通过过 TOTP
+//   - password+trusted_device    → 授信设备只免 TOTP,会话本身未通过过 TOTP
+//   - 未启用 TOTP                → 未通过过 TOTP
+func (s *Service) issueSessionWithFlags(ctx context.Context, user User, trustDevice bool, userAgent string, totpVerified bool, now time.Time) (LoginResult, error) {
+	sessionToken, err := newSessionTokenFor(totpVerified)
+	if err != nil {
+		return LoginResult{}, err
+	}
+
+	tx, err := s.db.BeginTx(ctx, nil)
+	if err != nil {
+		return LoginResult{}, fmt.Errorf("开启事务失败: %w", err)
+	}
+	defer tx.Rollback()
+
+	base, _ := splitSessionToken(sessionToken)
+	if _, err := tx.ExecContext(ctx,
+		`INSERT INTO sessions (user_id, token_hash, expires_at, last_used_at, user_agent)
+		 VALUES ($1, $2, $3, $4, $5)`,
+		user.ID, HashToken(base), now.Add(SessionTTL), now, userAgent); err != nil {
+		return LoginResult{}, fmt.Errorf("创建会话失败: %w", err)
+	}
+
+	res := LoginResult{
+		User:         UserBrief{ID: user.ID, Username: user.Username},
+		TotpRequired: false,
+		SessionToken: sessionToken,
+	}
+
+	if trustDevice {
+		deviceToken, err := newDeviceToken()
+		if err != nil {
+			return LoginResult{}, err
+		}
+		name := deviceNameFromUA(userAgent)
+		if _, err := tx.ExecContext(ctx,
+			`INSERT INTO trusted_devices (user_id, token_hash, device_name, expires_at, last_used_at)
+			 VALUES ($1, $2, $3, $4, $5)`,
+			user.ID, HashToken(deviceToken), name, now.Add(TrustedDeviceTTL), now); err != nil {
+			return LoginResult{}, fmt.Errorf("创建授信设备失败: %w", err)
+		}
+		res.DeviceToken = deviceToken
+		res.DeviceName = name
+	}
+
+	if err := tx.Commit(); err != nil {
+		return LoginResult{}, fmt.Errorf("提交事务失败: %w", err)
+	}
+	return res, nil
+}
+
+// issueSession 创建会话,供未启用 TOTP / 授信设备路径复用(未通过 TOTP)。
+func (s *Service) issueSession(ctx context.Context, user User, trustDevice bool, userAgent string, now time.Time) (LoginResult, error) {
+	return s.issueSessionWithFlags(ctx, user, trustDevice, userAgent, false, now)
+}
+
+// trustedDeviceValid 校验授信设备:未过期、哈希匹配、属于该用户(auth.md 2.1)。
+// 匹配成功会刷新 last_used_at。
+func (s *Service) trustedDeviceValid(ctx context.Context, userID, cookieValue string, now time.Time) (bool, error) {
+	const q = `SELECT id FROM trusted_devices WHERE user_id = $1 AND token_hash = $2 AND expires_at > now()`
+	var id string
+	err := s.db.QueryRowContext(ctx, q, userID, HashToken(cookieValue)).Scan(&id)
+	if errors.Is(err, sql.ErrNoRows) {
+		return false, nil
+	}
+	if err != nil {
+		return false, fmt.Errorf("查询授信设备失败: %w", err)
+	}
+	if _, err := s.db.ExecContext(ctx,
+		`UPDATE trusted_devices SET last_used_at = $2 WHERE id = $1`, id, now); err != nil {
+		return false, fmt.Errorf("更新授信设备时间失败: %w", err)
+	}
+	return true, nil
+}
+
+func (s *Service) findUserByUsername(ctx context.Context, username string) (User, error) {
+	// username 不区分大小写(auth.md 第九节规则 3)
+	const q = `SELECT id, username, password_hash, coalesce(totp_secret, ''), totp_enabled
+	           FROM users WHERE lower(username) = lower($1)`
+	return s.scanUser(s.db.QueryRowContext(ctx, q, strings.TrimSpace(username)))
+}
+
+func (s *Service) findUserByID(ctx context.Context, id string) (User, error) {
+	const q = `SELECT id, username, password_hash, coalesce(totp_secret, ''), totp_enabled
+	           FROM users WHERE id = $1`
+	return s.scanUser(s.db.QueryRowContext(ctx, q, id))
+}
+
+func (s *Service) scanUser(row *sql.Row) (User, error) {
+	var u User
+	err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.TotpSecret, &u.TotpEnabled)
+	if errors.Is(err, sql.ErrNoRows) {
+		return User{}, ErrNotFound
+	}
+	if err != nil {
+		return User{}, fmt.Errorf("查询用户失败: %w", err)
+	}
+	return u, nil
+}
+
+// pendingUsername 用 pending_logins 的哈希反查用户名,供失败审计使用。
+func (s *Service) pendingUsername(ctx context.Context, tokenHash string) (string, error) {
+	const q = `
+		SELECT u.username
+		FROM pending_logins p
+		JOIN users u ON u.id = p.user_id
+		WHERE p.token_hash = $1`
+	var name string
+	if err := s.db.QueryRowContext(ctx, q, tokenHash).Scan(&name); err != nil {
+		return "", fmt.Errorf("查询待验证登录用户名失败: %w", err)
+	}
+	return name, nil
+}
+
+// deviceNameFromUA 从 UA 里取一个粗略可读的设备名,作为 trusted_devices.device_name 的默认值。
+// 用户可在设置页后续改名;这里只避免留空串(auth.md 第四节字段默认 ”)。
+func deviceNameFromUA(ua string) string {
+	if ua == "" {
+		return ""
+	}
+	for _, sep := range []string{" ", "/"} {
+		if i := strings.Index(ua, sep); i > 0 {
+			return ua[:i]
+		}
+	}
+	return ua
+}

+ 468 - 0
backend/internal/auth/service_test.go

@@ -0,0 +1,468 @@
+package auth
+
+import (
+	"context"
+	"database/sql"
+	"testing"
+	"time"
+)
+
+// 集成测试:见 testdb_test.go 的 schema 隔离说明。
+
+// createTestUser 插入一个测试用户,返回 id。
+// createTestUser 插入一个测试用户,返回 id。
+func createTestUser(t *testing.T, svc *Service, username, password string, totpEnabled bool) string {
+	t.Helper()
+	hash, err := HashPassword(password)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	var totpSecret any
+	if totpEnabled {
+		secret, _, err := NewTotpSecret("Haven", username)
+		if err != nil {
+			t.Fatal(err)
+		}
+		totpSecret = secret
+	}
+
+	var id string
+	err = svc.db.QueryRow(
+		`INSERT INTO users (username, password_hash, totp_secret, totp_enabled)
+		 VALUES ($1, $2, $3, $4) RETURNING id`,
+		username, hash, totpSecret, totpEnabled).Scan(&id)
+	if err != nil {
+		t.Fatalf("插入测试用户失败: %v", err)
+	}
+	return id
+}
+
+func totpSecretOf(t *testing.T, svc *Service, userID string) string {
+	t.Helper()
+	var secret sql.NullString
+	if err := svc.db.QueryRow(`SELECT totp_secret FROM users WHERE id = $1`, userID).Scan(&secret); err != nil {
+		t.Fatal(err)
+	}
+	return secret.String
+}
+
+func TestServiceLoginWithoutTotp(t *testing.T) {
+	svc := newSchemaService(t)
+	ctx := context.Background()
+	userID := createTestUser(t, svc, "kairo", "correct-horse-battery", false)
+
+	res, err := svc.Login(ctx, "kairo", "correct-horse-battery", false, "", "test-agent")
+	if err != nil {
+		t.Fatalf("Login 返回错误: %v", err)
+	}
+	if res.TotpRequired {
+		t.Error("未启用 TOTP 时不应要求第二步")
+	}
+	if res.SessionToken == "" {
+		t.Fatal("应下发会话 token")
+	}
+	if res.DeviceToken != "" {
+		t.Error("未请求 trust_device 时不应下发设备 token")
+	}
+	if res.AuditVia != auditViaTotp {
+		t.Errorf("审计 via = %q", res.AuditVia)
+	}
+
+	// 会话已落库,且只存哈希
+	var count int
+	if err := svc.db.QueryRow(`SELECT count(*) FROM sessions WHERE user_id = $1`, userID).Scan(&count); err != nil {
+		t.Fatal(err)
+	}
+	if count != 1 {
+		t.Errorf("sessions 行数 = %d", count)
+	}
+	var storedHash string
+	if err := svc.db.QueryRow(`SELECT token_hash FROM sessions WHERE user_id = $1`, userID).Scan(&storedHash); err != nil {
+		t.Fatal(err)
+	}
+	if storedHash == res.SessionToken {
+		t.Error("数据库不应存会话 token 明文")
+	}
+	if storedHash != HashToken(res.SessionToken) {
+		t.Error("token_hash 应为明文的 SHA-256")
+	}
+}
+
+func TestServiceLoginWrongPassword(t *testing.T) {
+	svc := newSchemaService(t)
+	createTestUser(t, svc, "kairo", "correct-horse-battery", false)
+
+	if _, err := svc.Login(context.Background(), "kairo", "wrong", false, "", ""); err != ErrInvalidCredentials {
+		t.Errorf("错误密码应返回 ErrInvalidCredentials,实际 %v", err)
+	}
+	if _, err := svc.Login(context.Background(), "nobody", "whatever", false, "", ""); err != ErrInvalidCredentials {
+		t.Errorf("不存在的用户应返回 ErrInvalidCredentials(防枚举),实际 %v", err)
+	}
+}
+
+func TestServiceLoginRequiresTotp(t *testing.T) {
+	svc := newSchemaService(t)
+	ctx := context.Background()
+	userID := createTestUser(t, svc, "kairo", "correct-horse-battery", true)
+	if totpSecretOf(t, svc, userID) == "" {
+		t.Fatal("启用 TOTP 的用户应已有 secret")
+	}
+
+	res, err := svc.Login(ctx, "kairo", "correct-horse-battery", false, "", "")
+	if err != nil {
+		t.Fatalf("Login 返回错误: %v", err)
+	}
+	if !res.TotpRequired || res.TempToken == "" {
+		t.Fatalf("应要求 TOTP 并返回 temp_token,实际 %+v", res)
+	}
+	if res.SessionToken != "" {
+		t.Error("需要 TOTP 时不应下发会话")
+	}
+
+	var pending int
+	if err := svc.db.QueryRow(`SELECT count(*) FROM pending_logins`).Scan(&pending); err != nil {
+		t.Fatal(err)
+	}
+	if pending != 1 {
+		t.Errorf("pending_logins 行数 = %d", pending)
+	}
+}
+
+func TestServiceLoginTotpFlow(t *testing.T) {
+	svc := newSchemaService(t)
+	ctx := context.Background()
+	userID := createTestUser(t, svc, "kairo", "correct-horse-battery", true)
+	secret := totpSecretOf(t, svc, userID)
+
+	// trust_device=true:标记应编进 temp_token,不落库
+	res, err := svc.Login(ctx, "kairo", "correct-horse-battery", true, "", "")
+	if err != nil {
+		t.Fatalf("Login 返回错误: %v", err)
+	}
+	if _, trust := splitPendingToken(res.TempToken); !trust {
+		t.Fatal("trust_device=true 时 temp_token 应带授信标记")
+	}
+	// pending_logins 表没有 trust_device 列(auth.md 3.1),确认列未被误加
+	cols, err := tableColumns(svc, "pending_logins")
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, c := range cols {
+		if c == "trust_device" {
+			t.Error("pending_logins 不应有 trust_device 列")
+		}
+	}
+
+	code, err := GenerateTotpCode(secret, time.Now())
+	if err != nil {
+		t.Fatal(err)
+	}
+	final, err := svc.LoginTotp(ctx, res.TempToken, code, "test-agent")
+	if err != nil {
+		t.Fatalf("LoginTotp 返回错误: %v", err)
+	}
+	if final.SessionToken == "" {
+		t.Error("第二步成功应下发会话")
+	}
+	if final.DeviceToken == "" {
+		t.Error("第一步请求了 trust_device,第二步成功应下发授信设备")
+	}
+	if final.AuditVia != auditViaTotp {
+		t.Errorf("审计 via = %q", final.AuditVia)
+	}
+
+	var pending int
+	if err := svc.db.QueryRow(`SELECT count(*) FROM pending_logins`).Scan(&pending); err != nil {
+		t.Fatal(err)
+	}
+	if pending != 0 {
+		t.Error("第二步成功后应删除 pending_logins 记录")
+	}
+}
+
+func TestServiceLoginBadTotpKeepsPending(t *testing.T) {
+	svc := newSchemaService(t)
+	ctx := context.Background()
+	createTestUser(t, svc, "kairo", "correct-horse-battery", true)
+
+	res, err := svc.Login(ctx, "kairo", "correct-horse-battery", false, "", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := svc.LoginTotp(ctx, res.TempToken, "000000", ""); err != ErrBadTotp {
+		t.Errorf("错误动态码应返回 ErrBadTotp,实际 %v", err)
+	}
+
+	// auth.md 3.1 规则 5:失败不计数,记录保留
+	var pending int
+	if err := svc.db.QueryRow(`SELECT count(*) FROM pending_logins`).Scan(&pending); err != nil {
+		t.Fatal(err)
+	}
+	if pending != 1 {
+		t.Error("动态码错误时 pending_logins 应保留以便重试")
+	}
+}
+
+func TestServiceLoginExpiredPending(t *testing.T) {
+	svc := newSchemaService(t)
+	ctx := context.Background()
+	createTestUser(t, svc, "kairo", "correct-horse-battery", true)
+
+	res, err := svc.Login(ctx, "kairo", "correct-horse-battery", false, "", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := svc.db.Exec(`UPDATE pending_logins SET expires_at = now() - interval '1 second'`); err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := svc.LoginTotp(ctx, res.TempToken, "123456", ""); err != ErrPendingExpired {
+		t.Errorf("过期临时 token 应返回 ErrPendingExpired,实际 %v", err)
+	}
+}
+
+func TestServiceTrustedDeviceSkipsTotp(t *testing.T) {
+	svc := newSchemaService(t)
+	ctx := context.Background()
+	userID := createTestUser(t, svc, "kairo", "correct-horse-battery", true)
+	secret := totpSecretOf(t, svc, userID)
+
+	// 先正常走两步,拿到授信设备
+	res, err := svc.Login(ctx, "kairo", "correct-horse-battery", true, "", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+	code, _ := GenerateTotpCode(secret, time.Now())
+	final, err := svc.LoginTotp(ctx, res.TempToken, code, "")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// 再登录:带授信设备 cookie,应直接完成
+	second, err := svc.Login(ctx, "kairo", "correct-horse-battery", false, final.DeviceToken, "")
+	if err != nil {
+		t.Fatalf("带授信设备登录失败: %v", err)
+	}
+	if second.TotpRequired {
+		t.Error("授信设备有效时不应要求 TOTP")
+	}
+	if second.AuditVia != auditViaTrustedDevice {
+		t.Errorf("审计 via = %q", second.AuditVia)
+	}
+
+	// 授信设备不免密
+	if _, err := svc.Login(ctx, "kairo", "wrong", false, final.DeviceToken, ""); err != ErrInvalidCredentials {
+		t.Errorf("授信设备不应免除密码,实际 %v", err)
+	}
+}
+
+func TestServiceTotpSetupVerifyDisable(t *testing.T) {
+	svc := newSchemaService(t)
+	ctx := context.Background()
+	userID := createTestUser(t, svc, "kairo", "correct-horse-battery", false)
+
+	secret, uri, err := svc.SetupTotp(userID, "Haven")
+	if err != nil {
+		t.Fatalf("SetupTotp 返回错误: %v", err)
+	}
+	if secret == "" || uri == "" {
+		t.Fatal("应返回 secret 与 otpauth URI")
+	}
+	// api.md 2.4:secret 此时未落库
+	if got := totpSecretOf(t, svc, userID); got != "" {
+		t.Errorf("setup 阶段不应落库,实际 %q", got)
+	}
+
+	code, _ := GenerateTotpCode(secret, time.Now())
+
+	// 错误码
+	if err := svc.VerifyTotp(ctx, userID, secret, "000000"); err != ErrBadTotp {
+		t.Errorf("错误动态码应返回 ErrBadTotp,实际 %v", err)
+	}
+
+	if err := svc.VerifyTotp(ctx, userID, secret, code); err != nil {
+		t.Fatalf("VerifyTotp 返回错误: %v", err)
+	}
+	if got := totpSecretOf(t, svc, userID); got != secret {
+		t.Errorf("verify 后应落库,期望 %q,实际 %q", secret, got)
+	}
+
+	// 已启用时重复 setup 应 409
+	if _, _, err := svc.SetupTotp(userID, "Haven"); err != ErrTotpAlreadyEnabled {
+		t.Errorf("重复 setup 应返回 ErrTotpAlreadyEnabled,实际 %v", err)
+	}
+
+	// 加一台授信设备,disable 时应被删除
+	if _, err := svc.db.Exec(
+		`INSERT INTO trusted_devices (user_id, token_hash, device_name, expires_at, last_used_at)
+		 VALUES ($1, 'devhash', 'test', now() + interval '30 days', now())`, userID); err != nil {
+		t.Fatal(err)
+	}
+
+	if err := svc.DisableTotp(ctx, userID, code); err != nil {
+		t.Fatalf("DisableTotp 返回错误: %v", err)
+	}
+
+	var devices int
+	if err := svc.db.QueryRow(`SELECT count(*) FROM trusted_devices WHERE user_id = $1`, userID).Scan(&devices); err != nil {
+		t.Fatal(err)
+	}
+	if devices != 0 {
+		t.Error("关闭 TOTP 应删除全部授信设备")
+	}
+	if got := totpSecretOf(t, svc, userID); got != "" {
+		t.Error("关闭 TOTP 应清空 totp_secret")
+	}
+}
+
+func TestServiceChangePasswordCascade(t *testing.T) {
+	svc := newSchemaService(t)
+	ctx := context.Background()
+	userID := createTestUser(t, svc, "kairo", "correct-horse-battery", true)
+	secret := totpSecretOf(t, svc, userID)
+
+	// 造两条会话、一台授信设备、一个 Token
+	for i := 0; i < 2; i++ {
+		if _, err := svc.db.Exec(
+			`INSERT INTO sessions (user_id, token_hash, expires_at, last_used_at)
+			 VALUES ($1, $2, now() + interval '7 days', now())`, userID, HashToken("sess"+string(rune('a'+i)))); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if _, err := svc.db.Exec(
+		`INSERT INTO trusted_devices (user_id, token_hash, device_name, expires_at, last_used_at)
+		 VALUES ($1, 'devhash', 'x', now() + interval '30 days', now())`, userID); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := svc.db.Exec(
+		`INSERT INTO tokens (user_id, name, token_hash, token_prefix, scope)
+		 VALUES ($1, 'cli', 'tokhash', 'haven_ro', 'read')`, userID); err != nil {
+		t.Fatal(err)
+	}
+
+	code, _ := GenerateTotpCode(secret, time.Now())
+	if err := svc.ChangePassword(ctx, userID, "correct-horse-battery", "brand-new-password", code); err != nil {
+		t.Fatalf("ChangePassword 返回错误: %v", err)
+	}
+
+	var sessions, devices, tokens int
+	if err := svc.db.QueryRow(`SELECT count(*) FROM sessions WHERE user_id = $1`, userID).Scan(&sessions); err != nil {
+		t.Fatal(err)
+	}
+	if err := svc.db.QueryRow(`SELECT count(*) FROM trusted_devices WHERE user_id = $1`, userID).Scan(&devices); err != nil {
+		t.Fatal(err)
+	}
+	if err := svc.db.QueryRow(`SELECT count(*) FROM tokens WHERE user_id = $1`, userID).Scan(&tokens); err != nil {
+		t.Fatal(err)
+	}
+
+	if sessions != 0 {
+		t.Error("改密后应删除全部会话")
+	}
+	if devices != 0 {
+		t.Error("改密后应删除全部授信设备")
+	}
+	if tokens != 1 {
+		t.Error("改密不应删除 Token(auth.md 8.3)")
+	}
+
+	// 新密码可用,旧密码失效
+	if _, err := svc.Login(ctx, "kairo", "brand-new-password", false, "", ""); err != nil {
+		t.Errorf("新密码应能登录: %v", err)
+	}
+	if _, err := svc.Login(ctx, "kairo", "correct-horse-battery", false, "", ""); err != ErrInvalidCredentials {
+		t.Error("旧密码应失效")
+	}
+}
+
+func TestServiceChangePasswordValidation(t *testing.T) {
+	svc := newSchemaService(t)
+	ctx := context.Background()
+	userID := createTestUser(t, svc, "kairo", "correct-horse-battery", false)
+
+	if err := svc.ChangePassword(ctx, userID, "correct-horse-battery", "short", ""); err == nil {
+		t.Error("过短的新密码应报错")
+	}
+	if err := svc.ChangePassword(ctx, userID, "wrong-old", "brand-new-password", ""); err != ErrInvalidCredentials {
+		t.Errorf("原密码错误应返回 ErrInvalidCredentials,实际 %v", err)
+	}
+	if err := svc.ChangePassword(ctx, userID, "correct-horse-battery", "correct-horse-battery", ""); err == nil {
+		t.Error("新密码与旧密码相同应报错")
+	}
+}
+
+func TestServiceLogout(t *testing.T) {
+	svc := newSchemaService(t)
+	ctx := context.Background()
+	userID := createTestUser(t, svc, "kairo", "correct-horse-battery", false)
+
+	res, err := svc.Login(ctx, "kairo", "correct-horse-battery", false, "", "")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	var sessionID string
+	if err := svc.db.QueryRow(`SELECT id FROM sessions WHERE user_id = $1`, userID).Scan(&sessionID); err != nil {
+		t.Fatal(err)
+	}
+
+	if err := svc.Logout(ctx, sessionID); err != nil {
+		t.Fatalf("Logout 返回错误: %v", err)
+	}
+
+	var count int
+	if err := svc.db.QueryRow(`SELECT count(*) FROM sessions WHERE user_id = $1`, userID).Scan(&count); err != nil {
+		t.Fatal(err)
+	}
+	if count != 0 {
+		t.Error("登出应删除会话")
+	}
+
+	// 会话凭证不再可用
+	if _, err := NewSQLStore(svc.db).FindSessionByHash(ctx, HashToken(res.SessionToken)); err != ErrNotFound {
+		t.Errorf("登出后的会话应查不到,实际 %v", err)
+	}
+}
+
+func TestSQLStoreSessionExpiry(t *testing.T) {
+	svc := newSchemaService(t)
+	ctx := context.Background()
+	userID := createTestUser(t, svc, "kairo", "correct-horse-battery", false)
+
+	store := NewSQLStore(svc.db)
+	token, _ := newSessionToken()
+
+	if _, err := svc.db.Exec(
+		`INSERT INTO sessions (user_id, token_hash, expires_at, last_used_at)
+		 VALUES ($1, $2, now() - interval '1 second', now())`, userID, HashToken(token)); err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := store.FindSessionByHash(ctx, HashToken(token)); err != ErrNotFound {
+		t.Errorf("过期会话应查不到,实际 %v", err)
+	}
+}
+
+func tableColumns(svc *Service, table string) ([]string, error) {
+	rows, err := svc.db.Query(
+		`SELECT column_name FROM information_schema.columns
+		 WHERE table_name = $1 AND table_schema = current_schema()
+		 ORDER BY ordinal_position`,
+		table)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var out []string
+	for rows.Next() {
+		var c string
+		if err := rows.Scan(&c); err != nil {
+			return nil, err
+		}
+		out = append(out, c)
+	}
+	return out, rows.Err()
+}

+ 40 - 0
backend/internal/auth/session_token.go

@@ -0,0 +1,40 @@
+package auth
+
+// 会话的「是否通过过 TOTP」标记。
+//
+// docs/backend/auth.md 6.2 要求 totp_verified 表示「会话创建时通过过 TOTP」,
+// 但 sessions 表(auth.md 第三节)没有该字段。为此在会话记录里串上标记:
+//
+//	<会话随机串>                  → 未通过 TOTP 创建
+//	<会话随机串>:totp             → 通过 TOTP 创建
+//
+// cookie 里下发明文,数据库存剥离后缀后的 SHA-256。与 pending_logins 的
+// trust_device 标记(auth.md 3.1 规则 1)是同一手法:不新增列、明文不落库。
+const sessionTotpSuffix = ":totp"
+
+// newSessionTokenFor 生成会话 token,并按需带 TOTP 标记。
+func newSessionTokenFor(totpVerified bool) (string, error) {
+	tok, err := newSessionToken()
+	if err != nil {
+		return "", err
+	}
+	if totpVerified {
+		return tok + sessionTotpSuffix, nil
+	}
+	return tok, nil
+}
+
+// splitSessionToken 剥离会话 token 末尾的 TOTP 标记。
+// 返回「用于哈希的原始值」与「是否通过过 TOTP」。
+func splitSessionToken(plain string) (token string, totpVerified bool) {
+	if len(plain) > len(sessionTotpSuffix) && plain[len(plain)-len(sessionTotpSuffix):] == sessionTotpSuffix {
+		return plain[:len(plain)-len(sessionTotpSuffix)], true
+	}
+	return plain, false
+}
+
+// sessionLookupHash 计算会话查库用的哈希:先剥离标记,再哈希。
+func sessionLookupHash(plain string) string {
+	base, _ := splitSessionToken(plain)
+	return HashToken(base)
+}

+ 93 - 0
backend/internal/auth/sessions.go

@@ -0,0 +1,93 @@
+package auth
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"time"
+)
+
+// 会话管理(api.md 第三节)。
+//
+// 会话列表只对会话身份开放,Token 调用返回 403 —— 这条在 handler 层用
+// RequireSession 控制;Service 本身不区分调用方身份。
+
+// ErrSessionNotFound 见 service.go。
+
+// SessionSummary 是 GET /api/sessions 的列表项(api.md 3.1)。
+type SessionSummary struct {
+	ID         string    `json:"id"`
+	UserAgent  string    `json:"user_agent"`
+	CreatedAt  time.Time `json:"created_at"`
+	LastUsedAt time.Time `json:"last_used_at"`
+	IsCurrent  bool      `json:"is_current"`
+}
+
+// ListSessions 返回当前用户的全部会话,按最近使用倒序。
+// currentSessionID 用于标记 is_current(api.md 3.1)。
+func (s *Service) ListSessions(ctx context.Context, userID, currentSessionID string) ([]SessionSummary, error) {
+	const q = `
+		SELECT id, user_agent, created_at, last_used_at
+		FROM sessions
+		WHERE user_id = $1 AND expires_at > now()
+		ORDER BY last_used_at DESC`
+
+	rows, err := s.db.QueryContext(ctx, q, userID)
+	if err != nil {
+		return nil, fmt.Errorf("查询会话列表失败: %w", err)
+	}
+	defer rows.Close()
+
+	out := make([]SessionSummary, 0, 4)
+	for rows.Next() {
+		var item SessionSummary
+		if err := rows.Scan(&item.ID, &item.UserAgent, &item.CreatedAt, &item.LastUsedAt); err != nil {
+			return nil, fmt.Errorf("扫描会话失败: %w", err)
+		}
+		item.IsCurrent = item.ID == currentSessionID
+		out = append(out, item)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, fmt.Errorf("读取会话列表失败: %w", err)
+	}
+	return out, nil
+}
+
+// RevokeSession 撤销指定会话(api.md 3.2)。
+// 不允许撤销当前会话:api.md 3.2 要求返回 422,引导走登出接口。
+func (s *Service) RevokeSession(ctx context.Context, userID, sessionID, currentSessionID string) error {
+	if sessionID == currentSessionID {
+		return ErrCannotRevokeCurrentSession
+	}
+
+	const q = `DELETE FROM sessions WHERE id = $1 AND user_id = $2`
+	res, err := s.db.ExecContext(ctx, q, sessionID, userID)
+	if err != nil {
+		return fmt.Errorf("删除会话失败: %w", err)
+	}
+	n, err := res.RowsAffected()
+	if err != nil {
+		return fmt.Errorf("读取删除结果失败: %w", err)
+	}
+	if n == 0 {
+		// 不存在或不属于当前用户,一律 404(不泄露存在性)
+		return ErrSessionNotFound
+	}
+	return nil
+}
+
+// FindSessionOwner 返回会话所属用户与创建时间,供撤销时写审计事件。
+// 会话不存在(或不属于该用户)时返回 ErrSessionNotFound。
+func (s *Service) FindSessionOwner(ctx context.Context, userID, sessionID string) (time.Time, error) {
+	const q = `SELECT created_at FROM sessions WHERE id = $1 AND user_id = $2`
+	var createdAt time.Time
+	err := s.db.QueryRowContext(ctx, q, sessionID, userID).Scan(&createdAt)
+	if errors.Is(err, sql.ErrNoRows) {
+		return time.Time{}, ErrSessionNotFound
+	}
+	if err != nil {
+		return time.Time{}, fmt.Errorf("查询会话失败: %w", err)
+	}
+	return createdAt, nil
+}

+ 112 - 0
backend/internal/auth/sqlstore.go

@@ -0,0 +1,112 @@
+package auth
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"time"
+)
+
+// SQLStore 是基于 database/sql 的 Store 实现,手写 SQL(architecture.md 5.1)。
+type SQLStore struct {
+	db *sql.DB
+}
+
+// NewSQLStore 构造 SQLStore。
+func NewSQLStore(db *sql.DB) *SQLStore {
+	return &SQLStore{db: db}
+}
+
+// DB 暴露底层连接池,供 main 装配迁移检查与测试使用。
+func (s *SQLStore) DB() *sql.DB { return s.db }
+
+// FindSessionByHash 按 token_hash 查未过期的会话(auth.md 6.1)。
+func (s *SQLStore) FindSessionByHash(ctx context.Context, tokenHash string) (Session, error) {
+	const q = `
+		SELECT id, user_id, token_hash, expires_at, last_used_at, user_agent, created_at
+		FROM sessions
+		WHERE token_hash = $1 AND expires_at > now()`
+
+	var sess Session
+	err := s.db.QueryRowContext(ctx, q, tokenHash).Scan(
+		&sess.ID, &sess.UserID, &sess.TokenHash,
+		&sess.ExpiresAt, &sess.LastUsedAt, &sess.UserAgent, &sess.CreatedAt,
+	)
+	if errors.Is(err, sql.ErrNoRows) {
+		return Session{}, ErrNotFound
+	}
+	if err != nil {
+		return Session{}, fmt.Errorf("查询会话失败: %w", err)
+	}
+	return sess, nil
+}
+
+// TouchSession 更新 last_used_at,不延长 expires_at(auth.md 第三节规则 5)。
+func (s *SQLStore) TouchSession(ctx context.Context, id string, at time.Time) error {
+	const q = `UPDATE sessions SET last_used_at = $2 WHERE id = $1`
+	if _, err := s.db.ExecContext(ctx, q, id, at); err != nil {
+		return fmt.Errorf("更新会话时间失败: %w", err)
+	}
+	return nil
+}
+
+// DeleteSession 撤销会话:硬删除(auth.md 第三节规则 7)。
+func (s *SQLStore) DeleteSession(ctx context.Context, id string) error {
+	const q = `DELETE FROM sessions WHERE id = $1`
+	if _, err := s.db.ExecContext(ctx, q, id); err != nil {
+		return fmt.Errorf("删除会话失败: %w", err)
+	}
+	return nil
+}
+
+// FindTokenByHash 按 token_hash 查 Token,包含已撤销 / 已过期的记录,由调用方判定。
+func (s *SQLStore) FindTokenByHash(ctx context.Context, tokenHash string) (APIToken, error) {
+	const q = `
+		SELECT id, user_id, name, token_hash, token_prefix, scope,
+		       expires_at, last_used_at, revoked_at, created_at
+		FROM tokens
+		WHERE token_hash = $1`
+
+	var t APIToken
+	err := s.db.QueryRowContext(ctx, q, tokenHash).Scan(
+		&t.ID, &t.UserID, &t.Name, &t.TokenHash, &t.TokenPrefix, &t.Scope,
+		&t.ExpiresAt, &t.LastUsedAt, &t.RevokedAt, &t.CreatedAt,
+	)
+	if errors.Is(err, sql.ErrNoRows) {
+		return APIToken{}, ErrNotFound
+	}
+	if err != nil {
+		return APIToken{}, fmt.Errorf("查询 Token 失败: %w", err)
+	}
+	return t, nil
+}
+
+// TouchToken 更新 last_used_at。
+func (s *SQLStore) TouchToken(ctx context.Context, id string, at time.Time) error {
+	const q = `UPDATE tokens SET last_used_at = $2 WHERE id = $1`
+	if _, err := s.db.ExecContext(ctx, q, id, at); err != nil {
+		return fmt.Errorf("更新 Token 时间失败: %w", err)
+	}
+	return nil
+}
+
+// GetUser 按 id 取用户(不含密码哈希与 TOTP secret,够 /api/me 使用)。
+func (s *SQLStore) GetUser(ctx context.Context, id string) (User, error) {
+	const q = `SELECT id, username, password_hash, coalesce(totp_secret, ''), totp_enabled
+	           FROM users WHERE id = $1`
+
+	var u User
+	err := s.db.QueryRowContext(ctx, q, id).Scan(
+		&u.ID, &u.Username, &u.PasswordHash, &u.TotpSecret, &u.TotpEnabled)
+	if errors.Is(err, sql.ErrNoRows) {
+		return User{}, ErrNotFound
+	}
+	if err != nil {
+		return User{}, fmt.Errorf("查询用户失败: %w", err)
+	}
+	return u, nil
+}
+
+// 编译期断言。
+var _ Store = (*SQLStore)(nil)

+ 139 - 0
backend/internal/auth/store_test.go

@@ -0,0 +1,139 @@
+package auth
+
+import (
+	"context"
+	"strings"
+	"testing"
+	"time"
+)
+
+// fakeStore 是 Store 的内存实现,供中间件与业务逻辑的单测使用。
+type fakeStore struct {
+	sessions map[string]Session // token_hash -> session
+	tokens   map[string]APIToken
+	users    map[string]User
+
+	touchedSessions []string
+	touchedTokens   []string
+	deletedSessions []string
+}
+
+func newFakeStore() *fakeStore {
+	return &fakeStore{
+		sessions: make(map[string]Session),
+		tokens:   make(map[string]APIToken),
+		users:    make(map[string]User),
+	}
+}
+
+func (f *fakeStore) FindSessionByHash(_ context.Context, tokenHash string) (Session, error) {
+	s, ok := f.sessions[tokenHash]
+	if !ok {
+		return Session{}, ErrNotFound
+	}
+	return s, nil
+}
+
+func (f *fakeStore) TouchSession(_ context.Context, id string, _ time.Time) error {
+	f.touchedSessions = append(f.touchedSessions, id)
+	return nil
+}
+
+func (f *fakeStore) DeleteSession(_ context.Context, id string) error {
+	f.deletedSessions = append(f.deletedSessions, id)
+	return nil
+}
+
+func (f *fakeStore) FindTokenByHash(_ context.Context, tokenHash string) (APIToken, error) {
+	t, ok := f.tokens[tokenHash]
+	if !ok {
+		return APIToken{}, ErrNotFound
+	}
+	return t, nil
+}
+
+func (f *fakeStore) TouchToken(_ context.Context, id string, _ time.Time) error {
+	f.touchedTokens = append(f.touchedTokens, id)
+	return nil
+}
+
+func (f *fakeStore) GetUser(_ context.Context, id string) (User, error) {
+	u, ok := f.users[id]
+	if !ok {
+		return User{}, ErrNotFound
+	}
+	return u, nil
+}
+
+var _ Store = (*fakeStore)(nil)
+
+// HashTokenForTest 暴露「剥离会话标记后再哈希」的行为,供中间件测试构造 key。
+func HashTokenForTest(plain string) string {
+	base, _ := splitSessionToken(plain)
+	return base
+}
+
+func TestHashTokenConsistency(t *testing.T) {
+	// 相同输入必须得到相同哈希,用于按 token_hash 查库。
+	if HashToken("abc") != HashToken("abc") {
+		t.Error("同一输入的哈希应一致")
+	}
+}
+
+func TestSplitPendingTokenEdgeCases(t *testing.T) {
+	cases := []struct {
+		in    string
+		token string
+		trust bool
+	}{
+		{in: "haven_pend_xyz", token: "haven_pend_xyz", trust: false},
+		{in: "haven_pend_xyz:1", token: "haven_pend_xyz", trust: true},
+		{in: "haven_pend_xyz:0", token: "haven_pend_xyz:0", trust: false},
+		{in: ":1", token: "", trust: true},
+		{in: "", token: "", trust: false},
+	}
+
+	for _, tc := range cases {
+		tok, trust := splitPendingToken(tc.in)
+		if tok != tc.token || trust != tc.trust {
+			t.Errorf("splitPendingToken(%q) = (%q, %v),期望 (%q, %v)",
+				tc.in, tok, trust, tc.token, tc.trust)
+		}
+	}
+}
+
+func TestIdentityString(t *testing.T) {
+	if got := Anonymous.String(); got != "anonymous" {
+		t.Errorf("Anonymous.String() = %q", got)
+	}
+	if got := SessionUser.String(); got != "session" {
+		t.Errorf("SessionUser.String() = %q", got)
+	}
+	if got := TokenUser.String(); got != "token" {
+		t.Errorf("TokenUser.String() = %q", got)
+	}
+}
+
+func TestIdentityIsLoggedIn(t *testing.T) {
+	if (Identity{Kind: Anonymous}).IsLoggedIn() {
+		t.Error("Anonymous 不应算已登录")
+	}
+	if !(Identity{Kind: SessionUser}).IsLoggedIn() {
+		t.Error("SessionUser 应算已登录")
+	}
+	if !(Identity{Kind: TokenUser}).IsLoggedIn() {
+		t.Error("TokenUser 应算已登录")
+	}
+}
+
+func TestTruncatePrefix(t *testing.T) {
+	if got := truncatePrefix("abc"); got != "abc" {
+		t.Errorf("短于前缀长度的应原样返回,实际 %q", got)
+	}
+	if got := truncatePrefix("haven_ro_abcdefghij"); got != "haven_ro" {
+		t.Errorf("应截取前 8 位,实际 %q", got)
+	}
+	if strings.HasPrefix(truncatePrefix("haven_ro_abcdefghij"), "haven_ro_") {
+		t.Error("前 8 位不应包含 scope 后的随机串")
+	}
+}

+ 127 - 0
backend/internal/auth/testdb_test.go

@@ -0,0 +1,127 @@
+package auth
+
+import (
+	"context"
+	"crypto/rand"
+	"database/sql"
+	"encoding/hex"
+	"fmt"
+	"net/url"
+	"os"
+	"strings"
+	"testing"
+
+	"github.com/kairo/haven/internal/db"
+)
+
+// 集成测试的隔离方式:每个用例在自己的 PostgreSQL schema 里跑。
+//
+// 平铺的表名加上 search_path 切换,既能复用 0001_init.up.sql,又不会让并发的
+// TRUNCATE 互相踩踏(`go test ./...` 会并行跑不同包,同包内也可能并发)。
+//
+//	HAVEN_TEST_DB_DSN='postgres://haven:...@127.0.0.1:5432/haven_dev?sslmode=disable' \
+//	  go test ./... -v
+
+// newSchemaService 在独占 schema 里建表,返回已切到该 schema 的 Service 与连接。
+func newSchemaService(t *testing.T) *Service {
+	t.Helper()
+	admin, dsn := openTestDB(t)
+
+	schema := fmt.Sprintf("test_%s", sanitize(t.Name()))
+
+	ctx := context.Background()
+	if _, err := admin.ExecContext(ctx, `DROP SCHEMA IF EXISTS `+schema+` CASCADE`); err != nil {
+		t.Fatalf("清理测试 schema 失败: %v", err)
+	}
+	if _, err := admin.ExecContext(ctx, `CREATE SCHEMA `+schema); err != nil {
+		t.Fatalf("创建测试 schema 失败: %v", err)
+	}
+
+	// 在该 schema 内重建全部表:search_path 切到新 schema,
+	// pg_trgm 建在 public 并由 search_path 继承。
+	conn, err := db.Open(ctx, dsnWithSchema(dsn, schema+",public"))
+	if err != nil {
+		t.Fatalf("连接测试 schema 失败: %v", err)
+	}
+	t.Cleanup(func() {
+		conn.Close()
+		admin.ExecContext(context.Background(), `DROP SCHEMA IF EXISTS `+schema+` CASCADE`)
+		admin.Close()
+	})
+
+	migration, err := os.ReadFile("./migrations/0001_init.up.sql")
+	if err == nil {
+		// 从包目录跑时可用
+		if _, err := conn.ExecContext(ctx, string(migration)); err != nil {
+			t.Fatalf("在测试 schema 内建表失败: %v", err)
+		}
+	} else {
+		// 在 internal/auth 下跑时,迁移文件在 ../../migrations
+		migration, err = os.ReadFile("../../migrations/0001_init.up.sql")
+		if err != nil {
+			t.Fatalf("读取 migration 失败: %v", err)
+		}
+		if _, err := conn.ExecContext(ctx, string(migration)); err != nil {
+			t.Fatalf("在测试 schema 内建表失败: %v", err)
+		}
+	}
+
+	return NewService(conn)
+}
+
+// openTestDB 打开与测试库的连接,未设置 HAVEN_TEST_DB_DSN 时跳过。
+func openTestDB(t *testing.T) (*sql.DB, string) {
+	t.Helper()
+	dsn := os.Getenv("HAVEN_TEST_DB_DSN")
+	if dsn == "" {
+		t.Skip("未设置 HAVEN_TEST_DB_DSN,跳过数据库集成测试")
+	}
+
+	conn, err := db.Open(context.Background(), dsn)
+	if err != nil {
+		t.Fatalf("连接测试库失败: %v", err)
+	}
+	t.Cleanup(func() { conn.Close() })
+	return conn, dsn
+}
+
+// dsnWithSchema 在 DSN 上追加 search_path。
+func dsnWithSchema(dsn, searchPath string) string {
+	u, err := url.Parse(dsn)
+	if err != nil {
+		sep := "?"
+		if strings.Contains(dsn, "?") {
+			sep = "&"
+		}
+		return dsn + sep + "search_path=" + url.QueryEscape(searchPath)
+	}
+	q := u.Query()
+	q.Set("search_path", searchPath)
+	u.RawQuery = q.Encode()
+	return u.String()
+}
+
+// randomSuffix 给 schema 名加一个短随机后缀,避免 -count>1 时重名。
+func randomSuffix() string {
+	buf := make([]byte, 4)
+	_, _ = rand.Read(buf)
+	return hex.EncodeToString(buf)
+}
+
+// sanitize 把测试名变成合法的 schema 片段(只留小写字母数字下划线,限长 40)。
+func sanitize(name string) string {
+	var b strings.Builder
+	for _, r := range strings.ToLower(name) {
+		switch {
+		case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
+			b.WriteRune(r)
+		default:
+			b.WriteRune('_')
+		}
+	}
+	out := b.String()
+	if len(out) > 40 {
+		out = out[:40]
+	}
+	return out
+}

+ 138 - 0
backend/internal/auth/token.go

@@ -0,0 +1,138 @@
+package auth
+
+import (
+	"crypto/rand"
+	"crypto/sha256"
+	"crypto/subtle"
+	"encoding/base64"
+	"encoding/hex"
+	"fmt"
+	"strings"
+)
+
+// 凭证生成与哈希工具。
+//
+// 依据 docs/backend/auth.md:
+//   - 会话 / 授信设备 / 临时登录 token 都是「随机 32 字节,base64url 编码后下发,
+//     数据库只存 SHA-256 哈希」(auth.md 第三节规则 1、3.1 规则 1、第四节规则 1);
+//   - API Token 格式为 haven_<scope>_<32字节随机串base64url>,同样只存哈希,
+//     另存明文前 8 位 token_prefix 用于展示(auth.md 第五节规则 2、3)。
+//
+// 注意:token_prefix 是明文前缀,不是哈希。它的唯一用途是设置页展示,
+// 鉴权时必须查 token_hash,不解析前缀(auth.md 第五节规则 2)。
+
+// tokenBytes 是各类 token 的随机字节数,固定 32。
+const tokenBytes = 32
+
+// tokenPrefixLen 是 tokens.token_prefix 的长度,即明文前 8 位。
+const tokenPrefixLen = 8
+
+// 固定前缀段,便于用户和日志识别 token 类型(不含敏感信息)。
+const (
+	sessionTokenPrefix   = "haven_sess_"
+	deviceTokenPrefix    = "haven_dev_"
+	pendingTokenPrefix   = "haven_pend_"
+	apiTokenReadPrefix   = "haven_ro_"
+	apiTokenWritePrefix  = "haven_rw_"
+	pendingTrustSuffixOn = ":1"
+)
+
+// randomToken 生成 base64url 编码的随机串(32 字节)。
+func randomToken() (string, error) {
+	buf := make([]byte, tokenBytes)
+	if _, err := rand.Read(buf); err != nil {
+		return "", fmt.Errorf("生成随机 token 失败: %w", err)
+	}
+	return base64.RawURLEncoding.EncodeToString(buf), nil
+}
+
+// newSessionToken 生成会话 token 明文。调用方只把明文写进 cookie,库里存 HashToken 结果。
+func newSessionToken() (string, error) {
+	tok, err := randomToken()
+	if err != nil {
+		return "", err
+	}
+	return sessionTokenPrefix + tok, nil
+}
+
+// newDeviceToken 生成授信设备 token 明文(cookie haven_trusted_device)。
+func newDeviceToken() (string, error) {
+	tok, err := randomToken()
+	if err != nil {
+		return "", err
+	}
+	return deviceTokenPrefix + tok, nil
+}
+
+// newPendingToken 生成待验证登录的临时 token 明文。
+//
+// trustDevice 表示第一步登录请求里的 trust_device。按 auth.md 3.1 规则 1,
+// 该标记拼接在明文末尾(如 <随机串>:1),不单独落库;校验时先剥离后缀再哈希。
+// 注意 suffix 必须在哈希之前剥离,所以临时 token 的哈希覆盖的是**去掉后缀后的值**。
+func newPendingToken(trustDevice bool) (string, error) {
+	tok, err := randomToken()
+	if err != nil {
+		return "", err
+	}
+	out := pendingTokenPrefix + tok
+	if trustDevice {
+		out += pendingTrustSuffixOn
+	}
+	return out, nil
+}
+
+// splitPendingToken 剥离临时 token 末尾的 trust_device 标记,
+// 返回「用于哈希的原始值」与「是否请求授信设备」。
+//
+// 对不含后缀的值原样返回,trustDevice 为 false。这样即便调用方传入已剥离过的值也安全。
+func splitPendingToken(plain string) (token string, trustDevice bool) {
+	if strings.HasSuffix(plain, pendingTrustSuffixOn) {
+		return strings.TrimSuffix(plain, pendingTrustSuffixOn), true
+	}
+	return plain, false
+}
+
+// newAPIToken 按 scope 生成 API Token 明文,并返回展示用的前缀。
+//
+// scope 只允许 read / read_write,其余取值由调用方的校验层拦截(返回 422)。
+func newAPIToken(scope string) (token string, prefix string, err error) {
+	var p string
+	switch scope {
+	case "read":
+		p = apiTokenReadPrefix
+	case "read_write":
+		p = apiTokenWritePrefix
+	default:
+		return "", "", fmt.Errorf("非法的 token scope %q", scope)
+	}
+
+	tok, err := randomToken()
+	if err != nil {
+		return "", "", err
+	}
+	token = p + tok
+	return token, truncatePrefix(token), nil
+}
+
+// truncatePrefix 取明文前 tokenPrefixLen 个字节作为展示前缀。
+//
+// 前缀段本身都是 ASCII,且长度固定,因此按字节截断不会切坏多字节字符。
+func truncatePrefix(token string) string {
+	if len(token) <= tokenPrefixLen {
+		return token
+	}
+	return token[:tokenPrefixLen]
+}
+
+// HashToken 计算 token 的 SHA-256 哈希(小写十六进制),用于落库与按 token_hash 查询。
+//
+// 对临时 token,调用方必须先经 splitPendingToken 剥离 trust_device 后缀再哈希。
+func HashToken(token string) string {
+	sum := sha256.Sum256([]byte(token))
+	return hex.EncodeToString(sum[:])
+}
+
+// secureEqual 用恒定时间比较两个字符串,避免通过耗时差异推断内容。
+func secureEqual(a, b string) bool {
+	return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
+}

+ 180 - 0
backend/internal/auth/token_test.go

@@ -0,0 +1,180 @@
+package auth
+
+import (
+	"regexp"
+	"strings"
+	"testing"
+)
+
+// 32 字节 base64url 无填充后固定 43 个字符。
+const b64Len = 43
+
+func TestRandomTokenFormat(t *testing.T) {
+	seen := make(map[string]bool, 100)
+	for i := 0; i < 100; i++ {
+		tok, err := randomToken()
+		if err != nil {
+			t.Fatalf("randomToken() 返回错误: %v", err)
+		}
+		if len(tok) != b64Len {
+			t.Fatalf("base64url(32 字节) 长度应为 %d,实际 %d (%q)", b64Len, len(tok), tok)
+		}
+		if !regexp.MustCompile(`^[A-Za-z0-9_-]+$`).MatchString(tok) {
+			t.Fatalf("token 含 base64url 之外的字符: %q", tok)
+		}
+		if seen[tok] {
+			t.Fatalf("生成了重复 token: %q", tok)
+		}
+		seen[tok] = true
+	}
+}
+
+func TestSessionAndDeviceTokenUnique(t *testing.T) {
+	a, err := newSessionToken()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.HasPrefix(a, sessionTokenPrefix) {
+		t.Errorf("会话 token 应以 %q 开头,实际 %q", sessionTokenPrefix, a)
+	}
+
+	b, err := newDeviceToken()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !strings.HasPrefix(b, deviceTokenPrefix) {
+		t.Errorf("设备 token 应以 %q 开头,实际 %q", deviceTokenPrefix, b)
+	}
+
+	if a == b {
+		t.Error("两次生成的 token 不应相同")
+	}
+}
+
+func TestPendingTokenTrustDevice(t *testing.T) {
+	t.Run("不带授信标记", func(t *testing.T) {
+		plain, err := newPendingToken(false)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if strings.HasSuffix(plain, pendingTrustSuffixOn) {
+			t.Fatalf("未请求授信时不应带后缀: %q", plain)
+		}
+
+		token, trust := splitPendingToken(plain)
+		if trust {
+			t.Error("splitPendingToken 应返回 trustDevice=false")
+		}
+		if token != plain {
+			t.Errorf("无后缀时应原样返回,实际 %q", token)
+		}
+	})
+
+	t.Run("带授信标记", func(t *testing.T) {
+		plain, err := newPendingToken(true)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if !strings.HasSuffix(plain, pendingTrustSuffixOn) {
+			t.Fatalf("请求授信时应带后缀: %q", plain)
+		}
+
+		token, trust := splitPendingToken(plain)
+		if !trust {
+			t.Error("splitPendingToken 应返回 trustDevice=true")
+		}
+		if strings.HasSuffix(token, pendingTrustSuffixOn) {
+			t.Errorf("剥离后不应残留后缀: %q", token)
+		}
+		if len(token) != len(pendingTokenPrefix)+b64Len {
+			t.Errorf("剥离后长度异常: %d", len(token))
+		}
+	})
+}
+
+func TestPendingTokenHashIgnoresSuffix(t *testing.T) {
+	// 同一个 token 是否请求授信,剥离后缀后哈希必须一致;
+	// 不剥离直接哈希则不同 —— 这正是「先剥离再哈希」不可省略的原因。
+	plain, err := newPendingToken(true)
+	if err != nil {
+		t.Fatal(err)
+	}
+	base, trust := splitPendingToken(plain)
+	if !trust {
+		t.Fatal("splitPendingToken 应识别出授信标记")
+	}
+
+	if HashToken(plain) == HashToken(base) {
+		t.Error("未剥离后缀时哈希应不同,说明剥离步骤不可省略")
+	}
+
+	rebuilt := base + pendingTrustSuffixOn
+	if stripped, _ := splitPendingToken(rebuilt); HashToken(stripped) != HashToken(base) {
+		t.Error("带后缀的值剥离后应与 base 哈希一致")
+	}
+}
+
+func TestPendingTokenSuffixStrippedBeforeHash(t *testing.T) {
+	// 直接对带后缀的明文哈希会查不到记录,这是调用方必须遵守的顺序。
+	if HashToken("tok:1") == HashToken("tok") {
+		t.Error("带后缀与不带后缀的哈希应不同")
+	}
+}
+
+func TestAPIToken(t *testing.T) {
+	t.Run("read", func(t *testing.T) {
+		token, prefix, err := newAPIToken("read")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if !strings.HasPrefix(token, apiTokenReadPrefix) {
+			t.Errorf("read token 应以 %q 开头,实际 %q", apiTokenReadPrefix, token)
+		}
+		if len(prefix) != tokenPrefixLen {
+			t.Errorf("token_prefix 长度应为 %d,实际 %d (%q)", tokenPrefixLen, len(prefix), prefix)
+		}
+		if !strings.HasPrefix(token, prefix) {
+			t.Errorf("token_prefix 应是明文前缀: prefix=%q token=%q", prefix, token)
+		}
+	})
+
+	t.Run("read_write", func(t *testing.T) {
+		token, _, err := newAPIToken("read_write")
+		if err != nil {
+			t.Fatal(err)
+		}
+		if !strings.HasPrefix(token, apiTokenWritePrefix) {
+			t.Errorf("read_write token 应以 %q 开头,实际 %q", apiTokenWritePrefix, token)
+		}
+	})
+
+	t.Run("非法 scope", func(t *testing.T) {
+		if _, _, err := newAPIToken("admin"); err == nil {
+			t.Error("非法 scope 应返回错误")
+		}
+	})
+}
+
+func TestHashToken(t *testing.T) {
+	got := HashToken("hello")
+	// 固定向量,防止哈希实现被意外改动
+	const want = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
+	if got != want {
+		t.Errorf("HashToken(\"hello\") = %q,期望 %q", got, want)
+	}
+	if len(got) != 64 {
+		t.Errorf("SHA-256 十六进制长度应为 64,实际 %d", len(got))
+	}
+}
+
+func TestSecureEqual(t *testing.T) {
+	if !secureEqual("abc", "abc") {
+		t.Error("相同字符串应返回 true")
+	}
+	if secureEqual("abc", "abd") {
+		t.Error("不同字符串应返回 false")
+	}
+	if secureEqual("abc", "abcd") {
+		t.Error("不同长度应返回 false")
+	}
+}

+ 167 - 0
backend/internal/auth/tokens.go

@@ -0,0 +1,167 @@
+package auth
+
+import (
+	"context"
+	"database/sql"
+	"errors"
+	"fmt"
+	"time"
+)
+
+// API Token 管理(api.md 第五节 / auth.md 第五节)。
+//
+// 规则要点:
+//   - 明文只在创建时返回一次,之后只能看到 token_prefix;
+//   - 撤销 = 写 revoked_at,不删除记录;
+//   - Token 不能管理凭证类资源,由 handler 层的 RequireTotpVerified 拦截。
+
+// ErrTokenNotFound 表示 Token 不存在或不属于当前用户(404)。
+var ErrTokenNotFound = errors.New("Token 不存在")
+
+// ErrTokenAlreadyRevoked 表示 Token 已撤销(422,api.md 5.3)。
+var ErrTokenAlreadyRevoked = errors.New("Token 已撤销")
+
+// APITokenSummary 是 GET /api/tokens 的列表项(api.md 5.1)。
+// 不包含明文,也不包含 token_hash。
+type APITokenSummary struct {
+	ID          string     `json:"id"`
+	Name        string     `json:"name"`
+	TokenPrefix string     `json:"token_prefix"`
+	Scope       string     `json:"scope"`
+	ExpiresAt   *time.Time `json:"expires_at"`
+	LastUsedAt  *time.Time `json:"last_used_at"`
+	RevokedAt   *time.Time `json:"revoked_at"`
+	CreatedAt   time.Time  `json:"created_at"`
+}
+
+// CreatedAPIToken 是 POST /api/tokens 的响应(api.md 5.2),含一次性明文。
+type CreatedAPIToken struct {
+	APITokenSummary
+	Token string `json:"token"`
+}
+
+// ListAPITokens 返回当前用户的全部 Token(含已撤销),按创建时间倒序。
+func (s *Service) ListAPITokens(ctx context.Context, userID string) ([]APITokenSummary, error) {
+	const q = `
+		SELECT id, name, token_prefix, scope, expires_at, last_used_at, revoked_at, created_at
+		FROM tokens
+		WHERE user_id = $1
+		ORDER BY created_at DESC`
+
+	rows, err := s.db.QueryContext(ctx, q, userID)
+	if err != nil {
+		return nil, fmt.Errorf("查询 Token 列表失败: %w", err)
+	}
+	defer rows.Close()
+
+	out := make([]APITokenSummary, 0, 4)
+	for rows.Next() {
+		var item APITokenSummary
+		if err := rows.Scan(
+			&item.ID, &item.Name, &item.TokenPrefix, &item.Scope,
+			&item.ExpiresAt, &item.LastUsedAt, &item.RevokedAt, &item.CreatedAt,
+		); err != nil {
+			return nil, fmt.Errorf("扫描 Token 失败: %w", err)
+		}
+		out = append(out, item)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, fmt.Errorf("读取 Token 列表失败: %w", err)
+	}
+	return out, nil
+}
+
+// CreateAPIToken 创建 Token 并返回一次性明文(api.md 5.2)。
+// expiresAt 为 nil 表示永不过期(auth.md 第五节规则 5)。
+func (s *Service) CreateAPIToken(ctx context.Context, userID, name, scope string, expiresAt *time.Time) (CreatedAPIToken, error) {
+	if err := ValidateScope(scope); err != nil {
+		return CreatedAPIToken{}, err
+	}
+
+	token, prefix, err := newAPIToken(scope)
+	if err != nil {
+		return CreatedAPIToken{}, err
+	}
+
+	const q = `
+		INSERT INTO tokens (user_id, name, token_hash, token_prefix, scope, expires_at)
+		VALUES ($1, $2, $3, $4, $5, $6)
+		RETURNING id, created_at`
+
+	var id string
+	var createdAt time.Time
+	if err := s.db.QueryRowContext(ctx, q,
+		userID, name, HashToken(token), prefix, scope, expiresAt,
+	).Scan(&id, &createdAt); err != nil {
+		return CreatedAPIToken{}, fmt.Errorf("创建 Token 失败: %w", err)
+	}
+
+	return CreatedAPIToken{
+		APITokenSummary: APITokenSummary{
+			ID:          id,
+			Name:        name,
+			TokenPrefix: prefix,
+			Scope:       scope,
+			ExpiresAt:   expiresAt,
+			CreatedAt:   createdAt,
+		},
+		Token: token,
+	}, nil
+}
+
+// RevokeAPIToken 撤销 Token:写 revoked_at,不删记录(api.md 5.3)。
+// 返回 Token 名与前缀,供写审计事件(audit.md 第三节)。
+func (s *Service) RevokeAPIToken(ctx context.Context, userID, tokenID string) (APITokenSummary, error) {
+	const findQ = `
+		SELECT id, name, token_prefix, scope, expires_at, last_used_at, revoked_at, created_at
+		FROM tokens WHERE id = $1 AND user_id = $2`
+
+	var item APITokenSummary
+	err := s.db.QueryRowContext(ctx, findQ, tokenID, userID).Scan(
+		&item.ID, &item.Name, &item.TokenPrefix, &item.Scope,
+		&item.ExpiresAt, &item.LastUsedAt, &item.RevokedAt, &item.CreatedAt)
+	if errors.Is(err, sql.ErrNoRows) {
+		return APITokenSummary{}, ErrTokenNotFound
+	}
+	if err != nil {
+		return APITokenSummary{}, fmt.Errorf("查询 Token 失败: %w", err)
+	}
+	if item.RevokedAt != nil {
+		return APITokenSummary{}, ErrTokenAlreadyRevoked
+	}
+
+	const q = `UPDATE tokens SET revoked_at = now() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL`
+	if _, err := s.db.ExecContext(ctx, q, tokenID, userID); err != nil {
+		return APITokenSummary{}, fmt.Errorf("撤销 Token 失败: %w", err)
+	}
+
+	revokedAt := s.now()
+	item.RevokedAt = &revokedAt
+	return item, nil
+}
+
+// ValidateScope 校验 scope 取值(auth.md 第五节规则 4)。
+func ValidateScope(scope string) error {
+	switch scope {
+	case ScopeRead, ScopeReadWrite:
+		return nil
+	default:
+		return fmt.Errorf("scope 只能取 %s 或 %s", ScopeRead, ScopeReadWrite)
+	}
+}
+
+// createTokenForTest 直接建一个可用 Token 并返回明文,仅供测试使用。
+func createTokenForTest(ctx context.Context, db *sql.DB, userID, scope string) (string, error) {
+	token, prefix, err := newAPIToken(scope)
+	if err != nil {
+		return "", err
+	}
+	_, err = db.ExecContext(ctx,
+		`INSERT INTO tokens (user_id, name, token_hash, token_prefix, scope)
+		 VALUES ($1, 'test', $2, $3, $4)`,
+		userID, HashToken(token), prefix, scope)
+	if err != nil {
+		return "", err
+	}
+	return token, nil
+}

+ 162 - 0
backend/internal/httpx/httpx.go

@@ -0,0 +1,162 @@
+// Package httpx 提供跨领域共用的 HTTP 辅助。
+//
+// 依据 docs/architecture.md 5.4:统一错误响应构造函数放在这里,
+// 所有 handler 调用同一套;错误细节不返回给客户端。
+package httpx
+
+import (
+	"errors"
+	"log/slog"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/kairo/haven/internal/auth"
+)
+
+// ErrorBody 是 api.md 1.4 定义的错误响应。
+// fields 仅在 422 validation_failed 时出现。
+type ErrorBody struct {
+	Error   string            `json:"error"`
+	Message string            `json:"message"`
+	Fields  map[string]string `json:"fields,omitempty"`
+}
+
+// 错误码常量,取值见 docs/api.md 1.4。
+const (
+	CodeBadRequest       = "bad_request"
+	CodeUnauthorized     = "unauthorized"
+	CodeForbidden        = "forbidden"
+	CodeNotFound         = "not_found"
+	CodeConflict         = "conflict"
+	CodeValidationFailed = "validation_failed"
+	CodeInternal         = "internal_error"
+)
+
+// FailInternal 返回 500,细节只进日志(api.md 1.4、architecture.md 5.4)。
+func FailInternal(c *gin.Context, err error) {
+	if err != nil {
+		slog.Error("请求处理失败", "method", c.Request.Method, "path", c.Request.URL.Path, "err", err)
+	}
+	c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorBody{
+		Error:   CodeInternal,
+		Message: "服务端错误",
+	})
+}
+
+// FailUnauthorized 返回 401。
+func FailUnauthorized(c *gin.Context) {
+	FailUnauthorizedWith(c, "未登录或凭证无效")
+}
+
+// FailUnauthorizedWith 返回 401,并带自定义 message(如“动态码错误”)。
+func FailUnauthorizedWith(c *gin.Context, message string) {
+	c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorBody{
+		Error:   CodeUnauthorized,
+		Message: message,
+	})
+}
+
+// FailBadRequest 返回 400(JSON 解析失败等),解析细节只进日志。
+func FailBadRequest(c *gin.Context, err error) {
+	if err != nil {
+		slog.Debug("请求体解析失败", "method", c.Request.Method, "path", c.Request.URL.Path, "err", err)
+	}
+	FailBadRequestWith(c, "请求体格式错误")
+}
+
+// FailBadRequestWith 返回 400,并带自定义 message。
+func FailBadRequestWith(c *gin.Context, message string) {
+	c.AbortWithStatusJSON(http.StatusBadRequest, ErrorBody{
+		Error:   CodeBadRequest,
+		Message: message,
+	})
+}
+
+// FailNotFound 返回 404。
+func FailNotFound(c *gin.Context) {
+	c.AbortWithStatusJSON(http.StatusNotFound, ErrorBody{
+		Error:   CodeNotFound,
+		Message: "资源不存在",
+	})
+}
+
+// FailConflict 返回 409。
+func FailConflict(c *gin.Context, message string) {
+	c.AbortWithStatusJSON(http.StatusConflict, ErrorBody{
+		Error:   CodeConflict,
+		Message: message,
+	})
+}
+
+// FailUnprocessable 返回 422,但不带 fields(语义化校验失败)。
+func FailUnprocessable(c *gin.Context, message string) {
+	c.AbortWithStatusJSON(http.StatusUnprocessableEntity, ErrorBody{
+		Error:   CodeValidationFailed,
+		Message: message,
+	})
+}
+
+// FailForbidden 返回 403。
+func FailForbidden(c *gin.Context) {
+	c.AbortWithStatusJSON(http.StatusForbidden, ErrorBody{
+		Error:   CodeForbidden,
+		Message: "权限不足",
+	})
+}
+
+// FailValidation 返回 422,并带 fields。
+func FailValidation(c *gin.Context, fields map[string]string) {
+	c.AbortWithStatusJSON(http.StatusUnprocessableEntity, ErrorBody{
+		Error:   CodeValidationFailed,
+		Message: "字段校验失败",
+		Fields:  fields,
+	})
+}
+
+// Fail 按领域错误映射成 api.md 1.4 的响应。
+//
+// 只映射认证包已定义的业务错误;未识别的错误一律 500。
+func Fail(c *gin.Context, err error) {
+	switch {
+	case errors.Is(err, auth.ErrInvalidCredentials):
+		c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorBody{
+			Error:   CodeUnauthorized,
+			Message: auth.ErrInvalidCredentials.Error(),
+		})
+	case errors.Is(err, auth.ErrPendingExpired):
+		c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorBody{
+			Error:   CodeUnauthorized,
+			Message: auth.ErrPendingExpired.Error(),
+		})
+	case errors.Is(err, auth.ErrBadTotp):
+		c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorBody{
+			Error:   CodeUnauthorized,
+			Message: auth.ErrBadTotp.Error(),
+		})
+	case errors.Is(err, auth.ErrTotpAlreadyEnabled):
+		c.AbortWithStatusJSON(http.StatusConflict, ErrorBody{
+			Error:   CodeConflict,
+			Message: auth.ErrTotpAlreadyEnabled.Error(),
+		})
+	case errors.Is(err, auth.ErrTotpNotEnabled):
+		FailValidation(c, map[string]string{"code": "尚未启用 TOTP"})
+	case errors.Is(err, auth.ErrNotFound):
+		c.AbortWithStatusJSON(http.StatusNotFound, ErrorBody{
+			Error:   CodeNotFound,
+			Message: "资源不存在",
+		})
+	default:
+		FailInternal(c, err)
+	}
+}
+
+// InstallAuthErrorHandlers 把统一错误响应注入 auth 包,
+// 使鉴权中间件不必依赖 httpx(避免包循环)。
+func InstallAuthErrorHandlers() {
+	auth.SetErrorHandlers(
+		func(c *gin.Context, _ error) { FailUnauthorized(c) },
+		func(c *gin.Context, _ error) { FailForbidden(c) },
+		FailInternal,
+	)
+}

+ 89 - 0
backend/internal/httpx/httpx_test.go

@@ -0,0 +1,89 @@
+package httpx
+
+import (
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/kairo/haven/internal/auth"
+)
+
+func contains(s, sub string) bool { return strings.Contains(s, sub) }
+
+func init() { gin.SetMode(gin.TestMode) }
+
+func run(fn func(c *gin.Context)) *httptest.ResponseRecorder {
+	r := gin.New()
+	r.GET("/x", fn)
+	w := httptest.NewRecorder()
+	r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x", nil))
+	return w
+}
+
+func TestFailMapping(t *testing.T) {
+	cases := []struct {
+		name     string
+		err      error
+		wantCode int
+		wantBody string
+	}{
+		{name: "凭据错误", err: auth.ErrInvalidCredentials, wantCode: 401, wantBody: "unauthorized"},
+		{name: "临时token过期", err: auth.ErrPendingExpired, wantCode: 401, wantBody: "unauthorized"},
+		{name: "动态码错误", err: auth.ErrBadTotp, wantCode: 401, wantBody: "unauthorized"},
+		{name: "TOTP已启用", err: auth.ErrTotpAlreadyEnabled, wantCode: 409, wantBody: "conflict"},
+		{name: "TOTP未启用", err: auth.ErrTotpNotEnabled, wantCode: 422, wantBody: "validation_failed"},
+		{name: "找不到", err: auth.ErrNotFound, wantCode: 404, wantBody: "not_found"},
+		{name: "未知错误", err: errors.New("boom"), wantCode: 500, wantBody: "internal_error"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			w := run(func(c *gin.Context) { Fail(c, tc.err) })
+			if w.Code != tc.wantCode {
+				t.Errorf("状态码 = %d,期望 %d", w.Code, tc.wantCode)
+			}
+			if body := w.Body.String(); !contains(body, tc.wantBody) {
+				t.Errorf("响应体应含 %q,实际 %s", tc.wantBody, body)
+			}
+			// 内部错误细节不能泄漏给客户端
+			if tc.wantCode == 500 && contains(w.Body.String(), "boom") {
+				t.Error("500 响应不应包含内部错误细节")
+			}
+		})
+	}
+}
+
+func TestFailValidationFields(t *testing.T) {
+	w := run(func(c *gin.Context) {
+		FailValidation(c, map[string]string{"title": "不能为空"})
+	})
+	if w.Code != http.StatusUnprocessableEntity {
+		t.Errorf("状态码 = %d", w.Code)
+	}
+	if body := w.Body.String(); !contains(body, `"fields":{"title":"不能为空"}`) {
+		t.Errorf("应带 fields,实际 %s", body)
+	}
+}
+
+func TestInstallAuthErrorHandlers(t *testing.T) {
+	InstallAuthErrorHandlers()
+
+	// 用 RequireLogin 触发 401,不需要数据库
+	r := gin.New()
+	r.Use(auth.Authenticate(nil), auth.RequireLogin())
+	r.GET("/p", func(c *gin.Context) { c.Status(http.StatusOK) })
+
+	w := httptest.NewRecorder()
+	r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/p", nil))
+
+	if w.Code != http.StatusUnauthorized {
+		t.Fatalf("状态码 = %d", w.Code)
+	}
+	if body := w.Body.String(); !contains(body, `"error":"unauthorized"`) {
+		t.Errorf("应走 httpx 的统一错误体,实际 %s", body)
+	}
+}

苏ICP备2026082406号
苏公网安备 待审核