diff --git a/.env.example b/.env.example index df0ec74..67206be 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,11 @@ JWT_SECRET=change-me-to-a-random-string-at-least-32-characters ADMIN_USERNAME=admin ADMIN_PASSWORD=admin12345 REGISTRATION_ENABLED=true +# 不透明 refresh token 有效期(Go duration,如 720h = 30 天);access token 固定 24h。 +AUTH_REFRESH_TTL=720h +# refresh token 单次使用轮换默认开启(用旧换新、防重放);仅当客户端无法保存轮换后的 +# 新 token 时才设为 true 关闭轮换。 +AUTH_REFRESH_ROTATE_DISABLED=false # ---- 数据库 ---- # SQLite 为默认(单二进制、无需外部服务)。 diff --git a/Docs/api-client.md b/Docs/api-client.md index 0524bf6..c4af883 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -21,7 +21,9 @@ | # | 端点 | 方法 | 认证 | 状态 | 说明 | |---|------|------|------|:---:|------| -| 1 | `/api/v1/auth/login` | POST | 否 | ✅ | `{username,password}` → `{data:{token,expiresAt,role}}`(24h,role=owner) | +| 1 | `/api/v1/auth/login` | POST | 否 | ✅ | `{username,password}` → `{data:{token,expiresAt,role,refreshToken?}}`(access 24h;**新增 `refreshToken`**——不透明随机串,仅哈希落库,`AUTH_REFRESH_TTL` 默认 720h,用于静默续期;role=owner) | +| 1a | `/api/v1/auth/refresh` | POST | refreshToken | ✅ | **静默续期**(B2):body `{refreshToken}` → `{data:{token,expiresAt,refreshToken?}}`(新 access + 新 refresh,**默认单次使用旋转**——旧 refresh 立即作废,并发重放 fail-closed `401 invalid_grant`)。`AUTH_REFRESH_ROTATE_DISABLED=true` 关闭轮换时旧 token 保持有效、不返回新 refresh。过期/已撤销/乱串统一 `401 {error:{code:"invalid_grant"}}`;成功才审计 | +| 1b | `/api/v1/auth/logout` | POST | refreshToken 或 JWT | ✅ | **登出**(B2):body `{refreshToken}` 撤**单个** grant(幂等);带 `Authorization: Bearer ` 且无 body → 撤**该账号全部** grant(全设备登出,返回 `{revokedCount}`)。refresh 相关操作成功才审计 | | 2 | `/api/v1/health` | GET | 否 | ✅ | 健康检查 `{data:{status:"ok"}}` | | 3 | `/api/v1/chat` | POST | JWT | ✅ | `{text,image?,deviceId?,mode?,stream?}` → `{data:{reply}}`;`stream:true` 时经 WS 触发流式。`image` 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06)——**真视觉需配 `LLM_VISION_MODEL`/凭据,无凭据时降级为主模型直接看图或视觉·OCR 工具文本描述**;`deviceId` 为**客户端生成的具体端标识**(design/16 §4.5,如 `web:chrome:a1b2c3`,可选),落库到消息、便于主人知道从哪个端发来;`mode` 为**行为模式**(design/01 §3 / design/16 §5.1,如 `chat`/`code`/`home`/`docs`/…,可选),随该轮次落库——IM 恒 `chat`、缺省回落 `chat`,供前端按模式渲染气泡(仅 `chat` 才解析 `

` 拆气泡)。**会话编排(design/15)**:非流式且该会话「正忙」时**不阻塞**,返回 `202` `{data:{queued:true,queueDepth}}`(稍后按上下文续接);待处理队列满时返回 `409` `{error:{code:"chat_busy"}}` | | 4 | `/api/v1/sessions` | GET | JWT | ✅ | `{data:[session...]}`。**会话持久化(design/16)**:注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main"|"sub"),title?,createdAt,lastMessage?}]}`(主会话 `id="web"` 恒在首位,次级 `web:`;`lastMessage` 为**最后一条消息预览** `{content,role(user|assistant),createdAt}`,content 折叠空白截前 60 rune,空会话省略);未注入时退化回渠道会话列表 | @@ -67,7 +69,7 @@ | 18 | `/api/v1/admin/memories` | POST | ✅(写) | 手动添加记忆(owner-only,审计);body `{personaId?,content,scope?}`,无 store 回 `501` | | 19 | `/api/v1/admin/rag` | POST | ✅(写) | 导入知识文档(owner-only,审计);body `{name,desc?,content}`,分块后落项目索引可检索;无 store 回 `501` | | 20 | `/api/v1/admin/system/revoke-keys` | POST | ✅(写) | 吊销**全部** API key(owner-only,审计);返回 `{revoked:n}`——key 表清空,先前签发的所有 key 立即失效 | -| 21 | `/api/v1/admin/system/rotate-jwt` | POST | ✅(写) | 轮换 JWT 密钥(owner-only,审计);body `{newSecret}`(≥32 位,不强校验熵)→ `{rotated:true,note}`——**现有 token 立即失效需重新登录**;新密钥**不回显、不落日志** | +| 21 | `/api/v1/admin/system/rotate-jwt` | POST | ✅(写) | 轮换 JWT 密钥(owner-only,审计);body `{newSecret}`(≥32 位,不强校验熵)→ `{rotated:true,note,refreshTokensRevoked}`——**现有 access token 立即失效需重新登录,且**所有 refresh token 一并作废(`refreshTokensRevoked` 为删除条数);新密钥**不回显、不落日志** | | 22 | `/api/v1/admin/system/backup` | POST | ✅(写) | 备份数据(owner-only,审计);用 SQLite `VACUUM INTO` 快照(含已提交 WAL 内容)到 `/backup-.db` → `{path,size}` | | 23 | `/api/v1/admin/system/restore` | POST | ✅(写) | 恢复数据(owner-only,审计);body `{path}` → 校验路径在 dataDir 内→**先备份当前**→替换 DB 文件→`{restored:true,backup,note:"需重启后生效"}`(运行进程持有 DB 句柄,热替换需重启可见) | @@ -213,6 +215,7 @@ - 语音:上行 `voice_input`(base64 音频或已转写 text),下行 `voice_output`(合成音频 base64 + text)。 ## 5. 变更记录 +- `v0.25`(**B2 refresh token**):`POST /api/v1/auth/refresh`(**新增**,`{refreshToken}` → 新 access + 新 refresh,默认单次使用旋转,并发重放 fail-closed `invalid_grant`)+ `POST /api/v1/auth/logout`(**新增**,撤单个或全设备)。`POST /api/v1/auth/login` 在 `{token,expiresAt,role}` 之外**新增 `refreshToken`**(不透明 256-bit 随机串,**仅 SHA-256 哈希落库**,`AUTH_REFRESH_TTL` 默认 `720h`,`AUTH_REFRESH_ROTATE_DISABLED` 关旋转)。新表 `refresh_tokens`(迁移 0008,id/user_id/role/token_hash UNIQUE/expires_at/revoked_at;含过期/轮换原子事务);`rotate-jwt` 成功即删除全部 refresh(返回 `refreshTokensRevoked`),`revoke-keys` 不动 refresh。**密码/secret 记录见 `internal/auth`;refresh 全文绝不落日志/审计。** - `v0.24`(**B1 图片上传端点**):`POST /api/v1/upload`(**新增**,multipart `file`,owner/admin+审计 → `201 {data:{url,size,mime,name}}`)+ `GET /api/v1/files/{name}`(**新增**,同端口静态服务)。安全:`MaxBytesReader`(5MB)、`DetectContentType` 嗅探(不信客户端 Content-Type)、白名单 raster image(拒 SVG/HTML)、`crypto/rand` 128-bit 随机名(丢弃原始文件名)、非可执行上传目录(0700)、`nosniff`;路径穿越全形态拒绝(含编码 `%2f`/`%2e%2e`)。错误码 `400/413/415`(`internal/errors` 增 `ErrPayloadTooLarge`/`ErrUnsupportedMediaType`)。**设计取舍**:GET 不鉴权(能力 URL,`chat.image`/`` 无法带 Bearer),POST 仍 owner/admin。`url` 为绝对地址(`X-Forwarded-Proto`/TLS + `r.Host`,因 `ParseImageRef` 只认 `http(s)://`)。`Deps.DataDir` 由 wire 注入(`resolveDataDir`)。 - `v0.23`(**C2 语音 silent 降级信号**):三处统一用 **`provider`** 只在「结果为空」时出现(非空/成功时才省略)——`POST /api/v1/audio/transcribe` 空文本响应体带 `{text:"", provider:"silent"}`;WS `voice_output` 空音频 payload 带 `provider`;`POST /api/v1/audio/speech` 空音频仍 `500 tts_empty` 并加 `X-Voice-Provider: silent` 响应头(CORS `Access-Control-Expose-Headers` 已放开)。`voice.Chain/Service` 新增 `STTWithProvider/TTSWithProvider`(返回真实档位 cloud/local/edge-tts/espeak/silent),网关用可选接口 `voiceProviderReporter` 透传,自定义实现回退 `silent`。**含 `X-Voice-Provider`(`apperr` 包装、`providerSignal`);`DASHSCOPE_API_KEY` 无/降级时信号为 `silent`。**(`go test -race` 在本机受内核 TSAN VMA 限制失败,非本代码问题。) - `v0.22`(**B3 会话列表 lastMessage 预览**):`GET /api/v1/sessions` 与 `/api/v1/admin/sessions` 每个会话新增可选 **`lastMessage`** `{content,role,createdAt}`(content 折叠空白截前 60 rune + `…`;空会话省略),**单条 SQL**(LEFT JOIN 取每会话 `ORDER BY created_at DESC,rowid DESC LIMIT 1`,无 N+1,走 `idx_chat_msgs_session_created` 覆盖索引)。`unreadCount` 需已读游标设计,后置。 diff --git a/Docs/api-reference.md b/Docs/api-reference.md index 218af42..16993f0 100644 --- a/Docs/api-reference.md +++ b/Docs/api-reference.md @@ -157,13 +157,14 @@ curl -s http://:8080/api/v1/health {"username":"admin","password":"s3cret"} ``` -- **响应**:`200` `{"data":{"token":"","expiresAt":,"role":"owner"}}`(`loginResponse`)。 +- **响应**:`200` `{"data":{"token":"","expiresAt":,"role":"owner","refreshToken":""}}`(`loginResponse`)。 | 字段 | 类型 | 说明 | |------|------|------| | `token` | string | 已签名 JWT(HS256),下文请求头 `Authorization: Bearer ` | | `expiresAt` | int64 | 过期时间,Unix 秒 = `now + 24h`(`tokenTTL=24h`) | | `role` | string | **恒为 `"owner"`**(代码硬编码 `auth.RoleOwner`) | +| `refreshToken` | string | **不透明 256-bit 随机串**(B2)——服务端**仅存其 SHA-256 哈希**,明文只在响应出现一次;用于 `POST /api/v1/auth/refresh` 静默续期,`AUTH_REFRESH_TTL` 默认 `720h` | - **错误**:`400` `{"error":{"code":"bad_request","message":"invalid request body"}}`(JSON 解析失败);`401` `{"error":{"code":"unauthorized","message":"invalid username or password"}}`;`500` `{"error":{"code":"login_failed",...}}`(签名失败)。 - **备注**:登录响应**以代码为准** = `{token, expiresAt, role}`(**不是** `{token,jwt}`)。旧 `api-client.md` §1 表内一行写 `{token,jwt}` 为**历史笔误**,以本档为准。curl: @@ -172,9 +173,21 @@ curl -s http://:8080/api/v1/health curl -s -X POST http://:8080/api/v1/auth/login \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"s3cret"}' -# => {"data":{"token":"eyJ...","expiresAt":1799...,"role":"owner"}} +# => {"data":{"token":"eyJ...","expiresAt":1799...,"role":"owner","refreshToken":"...base64url..."}} ``` +### 2.3 POST /api/v1/auth/refresh(B2) +- **鉴权**:公开,body 带 `refreshToken`。 +- **请求 Body**:`{refreshToken:""}`。 +- **响应**:`200` `{"data":{"token":"<新JWT>","expiresAt":,"refreshToken":"<新opaque?>"}}`——默认**单次使用旋转**:旧 refresh 立即作废并签发新的;`AUTH_REFRESH_ROTATE_DISABLED=true` 时旧 token 保持有效、不返回新 refresh(`refreshToken` 省略)。 +- **错误**:`400 bad_request`(JSON 解析失败 / 空 token);`401` `{"error":{"code":"invalid_grant",...}}`(未知/已撤销/已过期,统一口径,防枚举);仅成功才审计。 +- **curl**:`curl -s -X POST http://:8080/api/v1/auth/refresh -H 'Content-Type: application/json' -d '{"refreshToken":""}'` + +### 2.4 POST /api/v1/auth/logout(B2) +- **鉴权**:公开,两种方式二选一——body `{refreshToken}` 撤**单个** grant(幂等);或无 body 但带 `Authorization: Bearer ` 撤**该账号全部** grant(全设备登出)。 +- **响应**:`200` `{"data":{"ok":true,"revokedCount":}}`(单撤 `revokedCount` 为 0/1;全撤为该账号 refresh 行数)。 +- **错误**:不存在的 token 为幂等 no-op(仍 `200`);非法 body `400`。 + --- ## 3. 用户侧端点(已认证) diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index 79cf4d5..5a3402b 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -656,6 +656,17 @@ func (a *app) wireFrontend() error { a.log.Warn("audit store unavailable, falling back to in-memory", "error", aerr) auditStore = nil } + // Opaque refresh tokens (B2) share the primary SQLite handle. A failure here + // degrades gracefully: login simply omits refreshToken and /auth/refresh + // reports not-configured, rather than failing the whole boot. The store is + // held as the interface so a constructor failure leaves it nil (a typed nil + // pointer in the interface would be non-nil and panic on first call). + var refreshStore gateway.RefreshGrantStore + if store, rerr := auth.NewRefreshStore(context.Background(), a.db, a.log); rerr != nil { + a.log.Warn("refresh token store unavailable", "error", rerr) + } else { + refreshStore = store + } // a.history is built in wireAuxiliary so both the channel bridge (wireChannels) // and the gateway share the same persistent store. History was a no-op dependency // before the IM path, so nothing downstream depends on its creation order change. @@ -674,6 +685,7 @@ func (a *app) wireFrontend() error { Tasks: a.tasks, Automations: a.automation, Users: apiKeys, + Refresh: refreshStore, Backthink: a.backthink, RAG: ragStore, LLM: a.llm, diff --git a/internal/auth/auth.go b/internal/auth/auth.go index b88ff67..967b016 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -45,6 +45,10 @@ var ( ErrUnauthorized = errors.New("auth: unauthorized") // ErrForbidden reports a valid credential with insufficient privilege. ErrForbidden = errors.New("auth: forbidden") + // ErrInvalidGrant reports an unknown, revoked, or expired refresh token. + ErrInvalidGrant = errors.New("auth: invalid refresh grant") + // ErrNilDB reports a nil *sql.DB passed to a store constructor. + ErrNilDB = errors.New("auth: nil database") ) // Package-wide constants. `source` is the log provenance tag used by every diff --git a/internal/auth/refresh.go b/internal/auth/refresh.go new file mode 100644 index 0000000..056dec2 --- /dev/null +++ b/internal/auth/refresh.go @@ -0,0 +1,329 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" +) + +// Opaque refresh-token parameters and the canonical schema. The refresh secret +// is 32 random bytes (256 bits), so a plain unsalted SHA-256 digest is a sound +// one-way store: there is no low-entropy input to brute-force, and the digest is +// looked up through a unique index, never compared byte-by-byte in Go. +const ( + refreshTokenBytes = 32 + refreshIDBytes = 16 + refreshTimeLayout = time.RFC3339Nano + + createRefreshTableSQL = ` +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + role TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + revoked_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +)` + createRefreshUserIndexSQL = `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id + ON refresh_tokens (user_id)` + createRefreshExpiryIndexSQL = `CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at + ON refresh_tokens (expires_at)` + insertRefreshSQL = `INSERT INTO refresh_tokens (id, user_id, role, token_hash, expires_at) + VALUES (?, ?, ?, ?, ?)` + selectRefreshByHashSQL = `SELECT id, user_id, role, expires_at, revoked_at + FROM refresh_tokens WHERE token_hash = ?` + revokeRefreshByIDSQL = `UPDATE refresh_tokens SET revoked_at = ?, updated_at = ? + WHERE id = ? AND revoked_at IS NULL` + revokeRefreshByHashSQL = `UPDATE refresh_tokens SET revoked_at = ?, updated_at = ? + WHERE token_hash = ? AND revoked_at IS NULL` + deleteRefreshAllSQL = `DELETE FROM refresh_tokens` + deleteRefreshByUserSQL = `DELETE FROM refresh_tokens WHERE user_id = ?` + pruneRefreshSQL = `DELETE FROM refresh_tokens WHERE expires_at < ? OR (revoked_at IS NOT NULL AND revoked_at < ?)` +) + +// RefreshToken is the stored metadata of a refresh grant. It never carries the +// secret: only the SHA-256 hash of the opaque token is persisted. +type RefreshToken struct { + ID string + UserID string + Role Role + ExpiresAt time.Time +} + +// RotateOptions configures a refresh call. Rotate enables single-use rotation: +// the presented token is revoked and replaced by a fresh one. When false the +// presented token stays valid and no replacement is minted (NewToken is empty), +// which is the opt-out for clients that cannot persist a rotated token. +type RotateOptions struct { + TTL time.Duration + Rotate bool +} + +// RefreshStore persists opaque refresh tokens in SQLite. Only the SHA-256 hash +// of a token is stored; the plaintext is returned once by Issue/Rotate and never +// written to the database or a log. It is safe for concurrent use by virtue of +// the underlying *sql.DB. +type RefreshStore struct { + db *sql.DB + log *slog.Logger +} + +// NewRefreshStore builds a SQLite-backed refresh-token store over db, ensuring +// the refresh_tokens table and indexes exist. The DDL mirrors migration 0008 +// and is idempotent, so the store is self-contained for tests while the +// migration remains the authoritative path for an existing database. db must be +// live; nil returns ErrNilDB. log may be nil. +func NewRefreshStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*RefreshStore, error) { + if db == nil { + return nil, ErrNilDB + } + for _, ddl := range []string{createRefreshTableSQL, createRefreshUserIndexSQL, createRefreshExpiryIndexSQL} { + if _, err := db.ExecContext(ctx, ddl); err != nil { + return nil, fmt.Errorf("auth: ensure refresh_tokens schema: %w", err) + } + } + if log == nil { + log = slog.Default() + } + return &RefreshStore{db: db, log: logger.WithSource(log, source)}, nil +} + +// Issue mints a new opaque refresh token for userID/role, stores only its hash, +// and returns the plaintext (visible exactly once) plus its expiry. Expired or +// long-revoked rows are pruned best-effort so the table stays bounded. +func (s *RefreshStore) Issue(ctx context.Context, userID string, role Role, ttl time.Duration) (string, time.Time, error) { + if strings.TrimSpace(userID) == "" { + return "", time.Time{}, fmt.Errorf("%w: empty user id", ErrInvalidGrant) + } + if !role.Valid() { + return "", time.Time{}, fmt.Errorf("%w: %q", ErrInvalidRole, role) + } + if ttl <= 0 { + return "", time.Time{}, fmt.Errorf("%w: non-positive ttl", ErrInvalidGrant) + } + plaintext, hash, err := newRefreshToken() + if err != nil { + return "", time.Time{}, err + } + id, err := newRefreshID() + if err != nil { + return "", time.Time{}, err + } + expires := time.Now().UTC().Add(ttl) + if _, err := s.db.ExecContext(ctx, insertRefreshSQL, id, userID, string(role), hash, formatRefreshTime(expires)); err != nil { + return "", time.Time{}, fmt.Errorf("auth: insert refresh token: %w", err) + } + s.pruneExpired(ctx) + return plaintext, expires, nil +} + +// Rotate validates plaintext (known, unrevoked, unexpired) and, when opts.Rotate +// is true, atomically revokes it and issues a replacement. It returns the grant +// metadata and the new plaintext token (empty when rotation is disabled). An +// unknown, revoked, or expired token yields ErrInvalidGrant. The transaction +// makes rotation single-use under concurrent replay: the second caller's revoke +// affects no row and fails closed. +func (s *RefreshStore) Rotate(ctx context.Context, plaintext string, opts RotateOptions) (RefreshToken, string, error) { + if strings.TrimSpace(plaintext) == "" { + return RefreshToken{}, "", fmt.Errorf("%w: empty token", ErrInvalidGrant) + } + if opts.Rotate && opts.TTL <= 0 { + return RefreshToken{}, "", fmt.Errorf("%w: non-positive ttl", ErrInvalidGrant) + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return RefreshToken{}, "", fmt.Errorf("auth: begin refresh rotate: %w", err) + } + defer func() { _ = tx.Rollback() }() + + grant, revokedAt, err := selectRefreshGrant(ctx, tx, hashRefreshToken(plaintext)) + if err != nil { + return RefreshToken{}, "", err + } + if revokedAt.Valid { + return RefreshToken{}, "", fmt.Errorf("%w: revoked", ErrInvalidGrant) + } + now := time.Now().UTC() + if !grant.ExpiresAt.After(now) { + return RefreshToken{}, "", fmt.Errorf("%w: expired", ErrInvalidGrant) + } + if !opts.Rotate { + if err := tx.Commit(); err != nil { + return RefreshToken{}, "", fmt.Errorf("auth: commit refresh: %w", err) + } + return grant, "", nil + } + if err := revokeRefreshRow(ctx, tx, grant.ID, now); err != nil { + return RefreshToken{}, "", err + } + newPlaintext, newHash, err := newRefreshToken() + if err != nil { + return RefreshToken{}, "", err + } + newID, err := newRefreshID() + if err != nil { + return RefreshToken{}, "", err + } + newExpires := now.Add(opts.TTL) + if _, err := tx.ExecContext(ctx, insertRefreshSQL, newID, grant.UserID, string(grant.Role), newHash, formatRefreshTime(newExpires)); err != nil { + return RefreshToken{}, "", fmt.Errorf("auth: insert rotated refresh token: %w", err) + } + if err := tx.Commit(); err != nil { + return RefreshToken{}, "", fmt.Errorf("auth: commit refresh rotate: %w", err) + } + return RefreshToken{ID: newID, UserID: grant.UserID, Role: grant.Role, ExpiresAt: newExpires}, newPlaintext, nil +} + +// Revoke marks the refresh token matching plaintext as revoked, returning true +// when a live token was found. It is idempotent: revoking an unknown or already +// revoked token is a no-op returning false. +func (s *RefreshStore) Revoke(ctx context.Context, plaintext string) (bool, error) { + if strings.TrimSpace(plaintext) == "" { + return false, nil + } + now := formatRefreshTime(time.Now().UTC()) + res, err := s.db.ExecContext(ctx, revokeRefreshByHashSQL, now, now, hashRefreshToken(plaintext)) + if err != nil { + return false, fmt.Errorf("auth: revoke refresh token: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("auth: revoke refresh token: %w", err) + } + return n > 0, nil +} + +// RevokeAll deletes every refresh token and returns how many rows were removed. +// It is the global invalidation used when the JWT signing secret is rotated: +// credential rotation must invalidate every long-lived grant, not only the +// short-lived access tokens. Deleting (rather than marking revoked) is exact and +// leaves no valid row behind. +func (s *RefreshStore) RevokeAll(ctx context.Context) (int64, error) { + res, err := s.db.ExecContext(ctx, deleteRefreshAllSQL) + if err != nil { + return 0, fmt.Errorf("auth: revoke all refresh tokens: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("auth: revoke all refresh tokens: %w", err) + } + return n, nil +} + +// RevokeByUser deletes every refresh token belonging to userID and returns how +// many rows were removed. It backs logout by access token, where the individual +// refresh token is unknown: the account is signed out on every device. +func (s *RefreshStore) RevokeByUser(ctx context.Context, userID string) (int64, error) { + if strings.TrimSpace(userID) == "" { + return 0, fmt.Errorf("%w: empty user id", ErrInvalidGrant) + } + res, err := s.db.ExecContext(ctx, deleteRefreshByUserSQL, userID) + if err != nil { + return 0, fmt.Errorf("auth: revoke user refresh tokens: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("auth: revoke user refresh tokens: %w", err) + } + return n, nil +} + +// pruneExpired deletes rows that are expired or were revoked long ago. It is +// best-effort: a failure is logged and never fails the Issue that triggered it. +func (s *RefreshStore) pruneExpired(ctx context.Context) { + cutoff := formatRefreshTime(time.Now().UTC()) + if _, err := s.db.ExecContext(ctx, pruneRefreshSQL, cutoff, cutoff); err != nil { + s.log.Warn("refresh token prune failed", "err", err) + } +} + +// selectRefreshGrant loads the grant for hash, returning ErrInvalidGrant when no +// row matches. The query runs on tx so the caller's rotate transaction observes +// a stable row. +func selectRefreshGrant(ctx context.Context, tx *sql.Tx, hash string) (RefreshToken, sql.NullString, error) { + var ( + grant RefreshToken + role string + expiresAt string + revokedAt sql.NullString + ) + err := tx.QueryRowContext(ctx, selectRefreshByHashSQL, hash). + Scan(&grant.ID, &grant.UserID, &role, &expiresAt, &revokedAt) + if errors.Is(err, sql.ErrNoRows) { + return RefreshToken{}, sql.NullString{}, fmt.Errorf("%w: unknown token", ErrInvalidGrant) + } + if err != nil { + return RefreshToken{}, sql.NullString{}, fmt.Errorf("auth: lookup refresh token: %w", err) + } + parsedRole, err := ParseRole(role) + if err != nil { + return RefreshToken{}, sql.NullString{}, fmt.Errorf("auth: stored refresh role: %w", err) + } + expiry, err := time.Parse(refreshTimeLayout, expiresAt) + if err != nil { + return RefreshToken{}, sql.NullString{}, fmt.Errorf("auth: parse refresh expiry: %w", err) + } + grant.Role = parsedRole + grant.ExpiresAt = expiry + return grant, revokedAt, nil +} + +// revokeRefreshRow marks the grant row revoked inside tx, failing closed with +// ErrInvalidGrant when no live row was updated (a concurrent rotate won the race). +func revokeRefreshRow(ctx context.Context, tx *sql.Tx, id string, now time.Time) error { + stamp := formatRefreshTime(now) + res, err := tx.ExecContext(ctx, revokeRefreshByIDSQL, stamp, stamp, id) + if err != nil { + return fmt.Errorf("auth: revoke rotated refresh token: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("auth: revoke rotated refresh token: %w", err) + } + if n == 0 { + return fmt.Errorf("%w: already used", ErrInvalidGrant) + } + return nil +} + +// newRefreshToken returns a fresh opaque token and its stored hash. +func newRefreshToken() (string, string, error) { + raw := make([]byte, refreshTokenBytes) + if _, err := rand.Read(raw); err != nil { + return "", "", fmt.Errorf("auth: generate refresh token: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(raw) + return token, hashRefreshToken(token), nil +} + +// newRefreshID returns a random 128-bit hex row id. +func newRefreshID() (string, error) { + raw := make([]byte, refreshIDBytes) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("auth: generate refresh id: %w", err) + } + return hex.EncodeToString(raw), nil +} + +// hashRefreshToken returns the hex SHA-256 of token, the only form persisted. +func hashRefreshToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +// formatRefreshTime renders t as UTC RFC3339Nano for storage. +func formatRefreshTime(t time.Time) string { + return t.UTC().Format(refreshTimeLayout) +} diff --git a/internal/auth/refresh_test.go b/internal/auth/refresh_test.go new file mode 100644 index 0000000..53795cd --- /dev/null +++ b/internal/auth/refresh_test.go @@ -0,0 +1,255 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" +) + +// newTestRefreshStore opens an isolated in-memory SQLite database, applies the +// embedded migrations, and returns a store over it. It returns the *sql.DB too +// so tests can assert what was actually persisted. +func newTestRefreshStore(t *testing.T) (*RefreshStore, *sql.DB) { + t.Helper() + ctx := context.Background() + sqlDB, err := db.Open(ctx, ":memory:", nil) + if err != nil { + t.Fatalf("open in-memory db: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := db.MigrateUp(ctx, sqlDB, nil); err != nil { + t.Fatalf("migrate up: %v", err) + } + store, err := NewRefreshStore(ctx, sqlDB, nil) + if err != nil { + t.Fatalf("new refresh store: %v", err) + } + return store, sqlDB +} + +func TestRefreshIssueStoresHashOnly(t *testing.T) { + store, sqlDB := newTestRefreshStore(t) + ctx := context.Background() + + token, expires, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) + if err != nil { + t.Fatalf("issue: %v", err) + } + if token == "" { + t.Fatal("issued token is empty") + } + if !expires.After(time.Now()) { + t.Fatalf("expiry %v is not in the future", expires) + } + + var hash, userID, role string + if err := sqlDB.QueryRowContext(ctx, + "SELECT token_hash, user_id, role FROM refresh_tokens").Scan(&hash, &userID, &role); err != nil { + t.Fatalf("select stored row: %v", err) + } + if hash == token { + t.Fatal("refresh token stored in plaintext") + } + if want := hashRefreshToken(token); hash != want { + t.Fatalf("stored hash = %q, want sha256 %q", hash, want) + } + if userID != "admin" || role != string(RoleOwner) { + t.Fatalf("stored row = (%q, %q), want (admin, owner)", userID, role) + } +} + +func TestRefreshRotateReplacesToken(t *testing.T) { + store, _ := newTestRefreshStore(t) + ctx := context.Background() + + old, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) + if err != nil { + t.Fatalf("issue: %v", err) + } + grant, newToken, err := store.Rotate(ctx, old, RotateOptions{TTL: time.Hour, Rotate: true}) + if err != nil { + t.Fatalf("rotate: %v", err) + } + if newToken == "" || newToken == old { + t.Fatalf("rotated token = %q, want a fresh non-empty token", newToken) + } + if grant.UserID != "admin" || grant.Role != RoleOwner { + t.Fatalf("grant = %+v, want admin/owner", grant) + } + if !grant.ExpiresAt.After(time.Now()) { + t.Fatalf("rotated expiry %v is not in the future", grant.ExpiresAt) + } + // The old token is single-use: replaying it must fail closed. + if _, _, err := store.Rotate(ctx, old, RotateOptions{TTL: time.Hour, Rotate: true}); !errors.Is(err, ErrInvalidGrant) { + t.Fatalf("replay old token err = %v, want ErrInvalidGrant", err) + } + // The replacement is live. + if _, _, err := store.Rotate(ctx, newToken, RotateOptions{TTL: time.Hour, Rotate: true}); err != nil { + t.Fatalf("rotate replacement: %v", err) + } +} + +func TestRefreshRotateNoRotationKeepsToken(t *testing.T) { + store, _ := newTestRefreshStore(t) + ctx := context.Background() + + token, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) + if err != nil { + t.Fatalf("issue: %v", err) + } + if _, newToken, err := store.Rotate(ctx, token, RotateOptions{Rotate: false}); err != nil || newToken != "" { + t.Fatalf("no-rotate = (%q, %v), want empty token and nil error", newToken, err) + } + // The presented token must still be usable. + if _, _, err := store.Rotate(ctx, token, RotateOptions{TTL: time.Hour, Rotate: true}); err != nil { + t.Fatalf("rotate after no-rotate: %v", err) + } +} + +func TestRefreshRotateInvalidGrant(t *testing.T) { + store, sqlDB := newTestRefreshStore(t) + ctx := context.Background() + + expired, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) + if err != nil { + t.Fatalf("issue expired: %v", err) + } + past := time.Now().UTC().Add(-time.Minute).Format(refreshTimeLayout) + if _, err := sqlDB.ExecContext(ctx, + "UPDATE refresh_tokens SET expires_at = ? WHERE token_hash = ?", past, hashRefreshToken(expired)); err != nil { + t.Fatalf("age token: %v", err) + } + + revoked, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) + if err != nil { + t.Fatalf("issue revoked: %v", err) + } + if ok, err := store.Revoke(ctx, revoked); err != nil || !ok { + t.Fatalf("revoke = (%v, %v), want (true, nil)", ok, err) + } + + cases := map[string]string{ + "empty": "", + "garbage": "not-a-real-token", + "expired": expired, + "revoked": revoked, + } + for name, token := range cases { + t.Run(name, func(t *testing.T) { + _, _, err := store.Rotate(ctx, token, RotateOptions{TTL: time.Hour, Rotate: true}) + if !errors.Is(err, ErrInvalidGrant) { + t.Fatalf("rotate(%q) err = %v, want ErrInvalidGrant", name, err) + } + }) + } +} + +func TestRefreshRevokeAll(t *testing.T) { + store, _ := newTestRefreshStore(t) + ctx := context.Background() + + first, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) + if err != nil { + t.Fatalf("issue first: %v", err) + } + second, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) + if err != nil { + t.Fatalf("issue second: %v", err) + } + n, err := store.RevokeAll(ctx) + if err != nil { + t.Fatalf("revoke all: %v", err) + } + if n != 2 { + t.Fatalf("revoked %d rows, want 2", n) + } + for _, token := range []string{first, second} { + if _, _, err := store.Rotate(ctx, token, RotateOptions{TTL: time.Hour, Rotate: true}); !errors.Is(err, ErrInvalidGrant) { + t.Fatalf("rotate after revoke-all err = %v, want ErrInvalidGrant", err) + } + } +} + +func TestRefreshRevokeByUser(t *testing.T) { + store, _ := newTestRefreshStore(t) + ctx := context.Background() + + adminToken, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) + if err != nil { + t.Fatalf("issue admin: %v", err) + } + otherToken, _, err := store.Issue(ctx, "other", RoleViewer, time.Hour) + if err != nil { + t.Fatalf("issue other: %v", err) + } + n, err := store.RevokeByUser(ctx, "admin") + if err != nil { + t.Fatalf("revoke by user: %v", err) + } + if n != 1 { + t.Fatalf("revoked %d rows, want 1", n) + } + if _, _, err := store.Rotate(ctx, adminToken, RotateOptions{TTL: time.Hour, Rotate: true}); !errors.Is(err, ErrInvalidGrant) { + t.Fatalf("admin rotate after revoke err = %v, want ErrInvalidGrant", err) + } + if _, _, err := store.Rotate(ctx, otherToken, RotateOptions{TTL: time.Hour, Rotate: true}); err != nil { + t.Fatalf("other rotate must be unaffected: %v", err) + } +} + +func TestRefreshRevokeIsIdempotent(t *testing.T) { + store, _ := newTestRefreshStore(t) + ctx := context.Background() + + token, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) + if err != nil { + t.Fatalf("issue: %v", err) + } + if ok, err := store.Revoke(ctx, token); err != nil || !ok { + t.Fatalf("first revoke = (%v, %v), want (true, nil)", ok, err) + } + if ok, err := store.Revoke(ctx, token); err != nil || ok { + t.Fatalf("second revoke = (%v, %v), want (false, nil)", ok, err) + } + if ok, err := store.Revoke(ctx, "unknown-token"); err != nil || ok { + t.Fatalf("unknown revoke = (%v, %v), want (false, nil)", ok, err) + } +} + +func TestRefreshSchemaIdempotent(t *testing.T) { + ctx := context.Background() + sqlDB, err := db.Open(ctx, ":memory:", nil) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + + if err := db.MigrateUp(ctx, sqlDB, nil); err != nil { + t.Fatalf("first migrate up: %v", err) + } + if err := db.MigrateUp(ctx, sqlDB, nil); err != nil { + t.Fatalf("second migrate up: %v", err) + } + var name string + if err := sqlDB.QueryRowContext(ctx, + "SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'").Scan(&name); err != nil { + t.Fatalf("refresh_tokens table missing: %v", err) + } + // The store constructor re-issues the same DDL; it must be a no-op. + for i := 0; i < 2; i++ { + if _, err := NewRefreshStore(ctx, sqlDB, nil); err != nil { + t.Fatalf("NewRefreshStore #%d: %v", i+1, err) + } + } + // Down then up must also round-trip. + if err := db.MigrateDown(ctx, sqlDB, 1, nil); err != nil { + t.Fatalf("migrate down: %v", err) + } + if err := db.MigrateUp(ctx, sqlDB, nil); err != nil { + t.Fatalf("migrate up after down: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 36536f0..c561b07 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( "fmt" "reflect" "strings" + "time" ) // Config is the root configuration tree. @@ -48,6 +49,19 @@ type AuthConfig struct { AdminUser string `env:"ADMIN_USERNAME" default:"admin"` AdminPass string `env:"ADMIN_PASSWORD" default:"admin12345"` RegistrationEnabled bool `env:"REGISTRATION_ENABLED" default:"true"` + + // RefreshTTL is how long an issued opaque refresh token stays valid. It is a + // Go duration string (e.g. "720h" = 30 days). Access tokens keep their 24h + // lifetime; the refresh token is the long-lived credential that renews them. + RefreshTTL time.Duration `env:"AUTH_REFRESH_TTL" default:"720h"` + + // RefreshRotateDisabled opts out of single-use refresh rotation. Rotation is + // on by default (the safe default): a successful /auth/refresh revokes the + // presented token and returns a fresh one, so a replayed token fails. Set + // AUTH_REFRESH_ROTATE_DISABLED=true only for clients that cannot persist a + // rotated token. The field is negative-named so its zero value (an + // unset/undecoded config) means rotation is enabled. + RefreshRotateDisabled bool `env:"AUTH_REFRESH_ROTATE_DISABLED" default:"false"` } // DBConfig holds database connection settings. SQLite is the default; PGURL is diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d6b3764..ec94701 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -6,6 +6,7 @@ import ( "reflect" "strings" "testing" + "time" ) func writeEnvFile(t *testing.T, content string) string { @@ -130,6 +131,38 @@ func TestLoad_MissingDotEnvIsTolerated(t *testing.T) { } } +func TestLoad_AuthRefreshDefaults(t *testing.T) { + t.Setenv("JWT_SECRET", strings.Repeat("s", 40)) + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Auth.RefreshTTL != 720*time.Hour { + t.Errorf("Auth.RefreshTTL = %v, want 720h", cfg.Auth.RefreshTTL) + } + if cfg.Auth.RefreshRotateDisabled { + t.Errorf("Auth.RefreshRotateDisabled = true, want false (rotation on by default)") + } +} + +func TestLoad_AuthRefreshEnvOverrides(t *testing.T) { + t.Setenv("JWT_SECRET", strings.Repeat("s", 40)) + t.Setenv("AUTH_REFRESH_TTL", "30m") + t.Setenv("AUTH_REFRESH_ROTATE_DISABLED", "true") + + cfg, err := Load("") + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Auth.RefreshTTL != 30*time.Minute { + t.Errorf("Auth.RefreshTTL = %v, want 30m", cfg.Auth.RefreshTTL) + } + if !cfg.Auth.RefreshRotateDisabled { + t.Errorf("Auth.RefreshRotateDisabled = false, want true") + } +} + func TestLoad_CapacityDefaults(t *testing.T) { t.Setenv("JWT_SECRET", strings.Repeat("s", 40)) @@ -144,7 +177,6 @@ func TestLoad_CapacityDefaults(t *testing.T) { t.Errorf("Capacity.MinConfidence = %v, want 0.6", cfg.Capacity.MinConfidence) } } - func TestLoad_CapacityEnvOverrides(t *testing.T) { t.Setenv("JWT_SECRET", strings.Repeat("s", 40)) t.Setenv("CAPACITY_ENABLED", "true") diff --git a/internal/config/decode.go b/internal/config/decode.go index 0853ab5..a419891 100644 --- a/internal/config/decode.go +++ b/internal/config/decode.go @@ -6,6 +6,7 @@ import ( "reflect" "strconv" "strings" + "time" ) // decodeValue populates the struct pointed to by v. Precedence, highest first: @@ -73,6 +74,11 @@ func setField(fv reflect.Value, raw string) error { case reflect.Bool: return setBool(fv, raw) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + // time.Duration is an int64 kind but is configured as a Go duration + // string (e.g. "720h"), so it must be parsed before the integer path. + if fv.Type() == reflect.TypeOf(time.Duration(0)) { + return setDuration(fv, raw) + } return setInt(fv, raw) case reflect.Float32, reflect.Float64: return setFloat(fv, raw) @@ -122,6 +128,21 @@ func setFloat(fv reflect.Value, raw string) error { return nil } +// setDuration parses a Go duration string (e.g. "720h", "30m") into a +// time.Duration field. An empty value sets the zero duration. +func setDuration(fv reflect.Value, raw string) error { + if strings.TrimSpace(raw) == "" { + fv.SetInt(0) + return nil + } + d, err := time.ParseDuration(raw) + if err != nil { + return fmt.Errorf("invalid duration %q", raw) + } + fv.SetInt(int64(d)) + return nil +} + func setStringSlice(fv reflect.Value, raw string) error { if fv.Type().Elem().Kind() != reflect.String { return fmt.Errorf("unsupported slice element kind %s", fv.Type().Elem().Kind()) diff --git a/internal/db/migrations/0008_refresh_tokens.down.sql b/internal/db/migrations/0008_refresh_tokens.down.sql new file mode 100644 index 0000000..b59d53f --- /dev/null +++ b/internal/db/migrations/0008_refresh_tokens.down.sql @@ -0,0 +1,3 @@ +-- 0008_refresh_tokens down: drop the refresh-token table (and, implicitly, its +-- indexes). Revoked grants carry no audit value once the feature is rolled back. +DROP TABLE IF EXISTS refresh_tokens; diff --git a/internal/db/migrations/0008_refresh_tokens.up.sql b/internal/db/migrations/0008_refresh_tokens.up.sql new file mode 100644 index 0000000..e02128e --- /dev/null +++ b/internal/db/migrations/0008_refresh_tokens.up.sql @@ -0,0 +1,25 @@ +-- 0008_refresh_tokens: opaque refresh tokens backing POST /api/v1/auth/refresh +-- (B2). The long-lived credential that renews the 24h access token, so a client +-- can stay signed in without storing the account password. +-- +-- Only the SHA-256 hash of a token is stored (token_hash); the plaintext is +-- returned once at login/refresh and never persisted or logged. revoked_at is +-- NULL while a grant is live; expiry is validated in Go after the hash lookup. +-- The canonical schema is also re-issued idempotently by auth.NewRefreshStore so +-- an in-memory store used by tests stays self-contained; this migration is the +-- authoritative path for an existing database. +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + role TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + revoked_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); + +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id + ON refresh_tokens (user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at + ON refresh_tokens (expires_at); diff --git a/internal/services/gateway/admin_system.go b/internal/services/gateway/admin_system.go index 5d9c98e..79cbc01 100644 --- a/internal/services/gateway/admin_system.go +++ b/internal/services/gateway/admin_system.go @@ -71,10 +71,27 @@ func (s *Server) adminRotateJWT(w http.ResponseWriter, r *http.Request) { writeError(w, apperr.Wrap(apperr.ErrInternal, "rotate_failed", "failed to rotate jwt secret")) return } + // Credential rotation must invalidate every long-lived grant too, not only the + // access tokens signed under the old secret. Refresh tokens are opaque and + // secret-independent, so they are deleted wholesale — the minimal, exact + // global invalidation in this architecture (no token_version column needed). + var refreshRevoked int64 + if s.refresh != nil { + n, rerr := s.refresh.RevokeAll(r.Context()) + if rerr != nil { + s.log.Error("rotate-jwt revoke refresh tokens", "err", rerr) + s.auditClaims(r, "system.rotate_jwt", "error") + writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_revoke_failed", + "JWT 已轮换,但 refresh token 作废失败,请重试或检查数据库")) + return + } + refreshRevoked = n + } s.auditClaims(r, "system.rotate_jwt", "ok") writeJSON(w, http.StatusOK, map[string]any{ - "rotated": true, - "note": "现有 token 已全部失效,需重新登录", + "rotated": true, + "refreshTokensRevoked": refreshRevoked, + "note": "现有 token 与 refresh token 已全部失效,需重新登录", }) } diff --git a/internal/services/gateway/auth_refresh.go b/internal/services/gateway/auth_refresh.go new file mode 100644 index 0000000..fde10a7 --- /dev/null +++ b/internal/services/gateway/auth_refresh.go @@ -0,0 +1,153 @@ +package gateway + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" +) + +// defaultRefreshTTL is the refresh-token lifetime used when cfg.Auth.RefreshTTL +// is unset (an undecoded config in tests), matching the AUTH_REFRESH_TTL default. +const defaultRefreshTTL = 720 * time.Hour + +// refreshRequest is the POST /api/v1/auth/refresh body. +type refreshRequest struct { + RefreshToken string `json:"refreshToken"` +} + +// refreshResponse is the refresh success payload: a fresh access token plus the +// rotated refresh token (omitted when rotation is disabled by config). +type refreshResponse struct { + Token string `json:"token"` + ExpiresAt int64 `json:"expiresAt"` + Role string `json:"role"` + RefreshToken string `json:"refreshToken,omitempty"` +} + +// logoutRequest is the POST /api/v1/auth/logout body. +type logoutRequest struct { + RefreshToken string `json:"refreshToken"` +} + +// refreshTTL returns the configured refresh-token lifetime, falling back to +// defaultRefreshTTL when the config was not decoded (zero value). +func (s *Server) refreshTTL() time.Duration { + if s.cfg != nil && s.cfg.Auth.RefreshTTL > 0 { + return s.cfg.Auth.RefreshTTL + } + return defaultRefreshTTL +} + +// refreshRotate reports whether single-use rotation is enabled. Rotation is the +// default; only an explicit AUTH_REFRESH_ROTATE_DISABLED=true opts out. +func (s *Server) refreshRotate() bool { + return s.cfg == nil || !s.cfg.Auth.RefreshRotateDisabled +} + +// handleRefresh exchanges a valid opaque refresh token for a new access token, +// rotating the refresh token by default. It is public (the refresh token is the +// credential) and returns 401 invalid_grant for an unknown, revoked, or expired +// token. A successful rotation is audited; the token itself is never logged. +func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) { + var req refreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) + return + } + if s.refresh == nil { + writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_unavailable", "refresh token service is not configured")) + return + } + grant, newRefresh, err := s.refresh.Rotate(r.Context(), req.RefreshToken, auth.RotateOptions{ + TTL: s.refreshTTL(), + Rotate: s.refreshRotate(), + }) + if err != nil { + if errors.Is(err, auth.ErrInvalidGrant) { + writeError(w, apperr.Wrap(apperr.ErrUnauthorized, "invalid_grant", "invalid refresh token")) + return + } + s.log.Error("refresh rotate", "err", err) + writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_failed", "could not refresh token")) + return + } + token, err := s.auth.Sign(grant.UserID, grant.Role, tokenTTL) + if err != nil { + s.log.Error("refresh sign", "err", err) + writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_failed", "could not sign token")) + return + } + s.audit(r.Context(), grant.UserID+":"+string(grant.Role), "auth.refresh", "ok") + writeJSON(w, http.StatusOK, refreshResponse{ + Token: token, + ExpiresAt: time.Now().Add(tokenTTL).Unix(), + Role: string(grant.Role), + RefreshToken: newRefresh, + }) +} + +// handleLogout revokes the refresh token in the body. It is public and +// idempotent: an unknown or already revoked token yields {"revoked": false} with +// 200 rather than an error, so a client can always clear its local state. +// +// The body may carry either a refreshToken (revokes that one grant) or nothing, +// in which case the Authorization Bearer access token identifies the account and +// every refresh grant for it is revoked (logout on all devices). A request with +// neither is a 400. +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + var req logoutRequest + // An empty body is valid: it means "no refreshToken", and the account is + // identified by the bearer access token below. + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) { + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body")) + return + } + if s.refresh == nil { + writeError(w, apperr.Wrap(apperr.ErrInternal, "refresh_unavailable", "refresh token service is not configured")) + return + } + if strings.TrimSpace(req.RefreshToken) != "" { + revoked, err := s.refresh.Revoke(r.Context(), req.RefreshToken) + if err != nil { + s.log.Error("logout revoke refresh token", "err", err) + writeError(w, apperr.Wrap(apperr.ErrInternal, "logout_failed", "could not revoke refresh token")) + return + } + writeJSON(w, http.StatusOK, map[string]any{"revoked": revoked}) + return + } + access := bearerFromRequest(r) + if access == "" { + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "refreshToken or Authorization bearer token is required")) + return + } + claims, err := s.auth.Verify(access) + if err != nil { + writeError(w, apperr.Wrap(apperr.ErrUnauthorized, "invalid_grant", "invalid access token")) + return + } + n, err := s.refresh.RevokeByUser(r.Context(), claims.Sub) + if err != nil { + s.log.Error("logout revoke user refresh tokens", "err", err) + writeError(w, apperr.Wrap(apperr.ErrInternal, "logout_failed", "could not revoke refresh tokens")) + return + } + writeJSON(w, http.StatusOK, map[string]any{"revoked": n > 0, "revokedCount": n}) +} + +// bearerFromRequest extracts the Bearer credential from the Authorization +// header, returning "" when the header is absent or malformed. +func bearerFromRequest(r *http.Request) string { + const prefix = "Bearer " + h := r.Header.Get("Authorization") + if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) { + return "" + } + return strings.TrimSpace(h[len(prefix):]) +} diff --git a/internal/services/gateway/auth_refresh_test.go b/internal/services/gateway/auth_refresh_test.go new file mode 100644 index 0000000..b1a64a5 --- /dev/null +++ b/internal/services/gateway/auth_refresh_test.go @@ -0,0 +1,286 @@ +package gateway + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/internal/db" +) + +// newRefreshEnv builds a gateway backed by a real JWT service and a real +// SQLite-backed refresh store, returning the store and DB so a test can assert +// what was persisted. The DB is isolated in memory per test. +func newRefreshEnv(t *testing.T) (*testEnv, *auth.RefreshStore, *sql.DB) { + t.Helper() + ctx := context.Background() + sqlDB, err := db.Open(ctx, ":memory:", nil) + if err != nil { + t.Fatalf("open in-memory db: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := db.MigrateUp(ctx, sqlDB, nil); err != nil { + t.Fatalf("migrate up: %v", err) + } + refresh, err := auth.NewRefreshStore(ctx, sqlDB, nil) + if err != nil { + t.Fatalf("new refresh store: %v", err) + } + jwt := auth.New(config.AuthConfig{JWTSecret: testSecret}) + b := bus.New() + cfg := &config.Config{Auth: config.AuthConfig{ + AdminUser: "admin", + AdminPass: "secret", + JWTSecret: testSecret, + RefreshTTL: time.Hour, + }} + srv, err := New(cfg, Deps{Auth: jwt, Bus: b, Refresh: refresh}) + if err != nil { + t.Fatalf("new server: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + return &testEnv{srv: srv, ts: ts, auth: jwt, bus: b}, refresh, sqlDB +} + +// decodeData unmarshals the {data: ...} envelope payload into v. +func decodeData(t *testing.T, body []byte, v any) { + t.Helper() + env := decodeBody(t, body) + raw, ok := env["data"] + if !ok { + t.Fatalf("response has no data envelope: %s", body) + } + if err := json.Unmarshal(raw, v); err != nil { + t.Fatalf("decode data %s: %v", body, err) + } +} + +// loginRefresh performs an admin login and returns the access and refresh tokens. +func loginRefresh(t *testing.T, ts *httptest.Server) (string, string) { + t.Helper() + resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/auth/login", + map[string]string{"username": "admin", "password": "secret"}, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("login status = %d, want 200: %s", resp.StatusCode, body) + } + var data struct { + Token string `json:"token"` + RefreshToken string `json:"refreshToken"` + Role string `json:"role"` + ExpiresAt int64 `json:"expiresAt"` + } + decodeData(t, body, &data) + if data.Token == "" || data.ExpiresAt == 0 || data.Role != "owner" { + t.Fatalf("login payload = %+v, want token/expiresAt/role=owner", data) + } + if data.RefreshToken == "" { + t.Fatalf("login payload has no refreshToken: %s", body) + } + return data.Token, data.RefreshToken +} + +// postRefresh calls /auth/refresh with token and returns the status and body. +func postRefresh(t *testing.T, ts *httptest.Server, token string) (int, []byte) { + t.Helper() + resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/auth/refresh", + map[string]string{"refreshToken": token}, "") + return resp.StatusCode, body +} + +// wantInvalidGrant asserts a 401 with the machine-readable invalid_grant code. +func wantInvalidGrant(t *testing.T, status int, body []byte) { + t.Helper() + if status != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401: %s", status, body) + } + env := decodeBody(t, body) + var eobj struct { + Code string `json:"code"` + } + if err := json.Unmarshal(env["error"], &eobj); err != nil { + t.Fatalf("error envelope: %v (%s)", err, body) + } + if eobj.Code != "invalid_grant" { + t.Fatalf("error code = %q, want invalid_grant: %s", eobj.Code, body) + } +} + +func TestLoginReturnsRefreshTokenHashedAtRest(t *testing.T) { + env, _, sqlDB := newRefreshEnv(t) + access, refresh := loginRefresh(t, env.ts) + + if _, err := env.auth.Verify(access); err != nil { + t.Fatalf("access token does not verify: %v", err) + } + var hash string + if err := sqlDB.QueryRowContext(context.Background(), + "SELECT token_hash FROM refresh_tokens WHERE user_id = 'admin'").Scan(&hash); err != nil { + t.Fatalf("select token_hash: %v", err) + } + if hash == refresh { + t.Fatal("refresh token stored in plaintext") + } + sum := sha256.Sum256([]byte(refresh)) + if want := hex.EncodeToString(sum[:]); hash != want { + t.Fatalf("stored hash = %q, want %q", hash, want) + } +} + +func TestRefreshRotatesAccessAndRefreshTokens(t *testing.T) { + env, _, _ := newRefreshEnv(t) + _, oldRefresh := loginRefresh(t, env.ts) + + status, body := postRefresh(t, env.ts, oldRefresh) + if status != http.StatusOK { + t.Fatalf("refresh status = %d, want 200: %s", status, body) + } + var data struct { + Token string `json:"token"` + ExpiresAt int64 `json:"expiresAt"` + Role string `json:"role"` + RefreshToken string `json:"refreshToken"` + } + decodeData(t, body, &data) + if data.Token == "" || data.ExpiresAt == 0 || data.Role != "owner" { + t.Fatalf("refresh payload = %+v, want token/expiresAt/role", data) + } + if data.RefreshToken == "" || data.RefreshToken == oldRefresh { + t.Fatalf("refresh token not rotated: %q", data.RefreshToken) + } + claims, err := env.auth.Verify(data.Token) + if err != nil { + t.Fatalf("refreshed access token does not verify: %v", err) + } + if claims.Sub != "admin" || claims.Role != auth.RoleOwner { + t.Fatalf("refreshed claims = %+v, want admin/owner", claims) + } + + // The presented token is single-use: replay must fail. + status, body = postRefresh(t, env.ts, oldRefresh) + wantInvalidGrant(t, status, body) + + // The rotated token works. + if status, body = postRefresh(t, env.ts, data.RefreshToken); status != http.StatusOK { + t.Fatalf("rotate replacement status = %d, want 200: %s", status, body) + } +} + +func TestRefreshRejectsBadGrants(t *testing.T) { + env, refresh, sqlDB := newRefreshEnv(t) + ctx := context.Background() + + expired := "expired-token-value" + sum := sha256.Sum256([]byte(expired)) + if _, err := sqlDB.ExecContext(ctx, + `INSERT INTO refresh_tokens (id, user_id, role, token_hash, expires_at) VALUES (?, ?, ?, ?, ?)`, + "seed-expired", "admin", "owner", hex.EncodeToString(sum[:]), + time.Now().UTC().Add(-time.Minute).Format(time.RFC3339Nano)); err != nil { + t.Fatalf("seed expired token: %v", err) + } + revoked, _, err := refresh.Issue(ctx, "admin", auth.RoleOwner, time.Hour) + if err != nil { + t.Fatalf("issue revoked token: %v", err) + } + if ok, err := refresh.Revoke(ctx, revoked); err != nil || !ok { + t.Fatalf("revoke = (%v, %v), want (true, nil)", ok, err) + } + + cases := map[string]string{ + "empty": "", + "garbage": "not-a-real-token", + "expired": expired, + "revoked": revoked, + } + for name, token := range cases { + t.Run(name, func(t *testing.T) { + status, body := postRefresh(t, env.ts, token) + wantInvalidGrant(t, status, body) + }) + } +} + +func TestLogoutRevokesRefreshToken(t *testing.T) { + env, _, _ := newRefreshEnv(t) + _, refreshToken := loginRefresh(t, env.ts) + + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/logout", + map[string]string{"refreshToken": refreshToken}, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("logout status = %d, want 200: %s", resp.StatusCode, body) + } + var data struct { + Revoked bool `json:"revoked"` + } + decodeData(t, body, &data) + if !data.Revoked { + t.Fatalf("logout revoked = false, want true: %s", body) + } + if status, body := postRefresh(t, env.ts, refreshToken); status != http.StatusUnauthorized { + t.Fatalf("refresh after logout status = %d, want 401: %s", status, body) + } + + // Logout is idempotent: a second call reports revoked=false, not an error. + resp, body = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/logout", + map[string]string{"refreshToken": refreshToken}, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("second logout status = %d, want 200: %s", resp.StatusCode, body) + } + decodeData(t, body, &data) + if data.Revoked { + t.Fatalf("second logout revoked = true, want false") + } +} + +func TestLogoutWithAccessTokenRevokesUserGrants(t *testing.T) { + env, _, _ := newRefreshEnv(t) + access, refreshToken := loginRefresh(t, env.ts) + + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/auth/logout", nil, access) + if resp.StatusCode != http.StatusOK { + t.Fatalf("logout status = %d, want 200: %s", resp.StatusCode, body) + } + var data struct { + Revoked bool `json:"revoked"` + RevokedCount int64 `json:"revokedCount"` + } + decodeData(t, body, &data) + if !data.Revoked || data.RevokedCount != 1 { + t.Fatalf("logout payload = %+v, want revoked with 1 grant", data) + } + if status, body := postRefresh(t, env.ts, refreshToken); status != http.StatusUnauthorized { + t.Fatalf("refresh after access-token logout status = %d, want 401: %s", status, body) + } +} + +func TestRotateJWTInvalidatesRefreshTokens(t *testing.T) { + env, _, _ := newRefreshEnv(t) + _, refreshToken := loginRefresh(t, env.ts) + + owner := env.token(t, auth.RoleOwner) + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/system/rotate-jwt", + map[string]string{"newSecret": "a-brand-new-jwt-secret-at-least-32-chars"}, owner) + if resp.StatusCode != http.StatusOK { + t.Fatalf("rotate-jwt status = %d, want 200: %s", resp.StatusCode, body) + } + var data struct { + Rotated bool `json:"rotated"` + RefreshTokensRevoked int64 `json:"refreshTokensRevoked"` + } + decodeData(t, body, &data) + if !data.Rotated || data.RefreshTokensRevoked != 1 { + t.Fatalf("rotate-jwt payload = %+v, want rotated with 1 refresh token revoked", data) + } + if status, body := postRefresh(t, env.ts, refreshToken); status != http.StatusUnauthorized { + t.Fatalf("refresh after rotate-jwt status = %d, want 401: %s", status, body) + } +} diff --git a/internal/services/gateway/rest.go b/internal/services/gateway/rest.go index d0d1aa0..711ee99 100644 --- a/internal/services/gateway/rest.go +++ b/internal/services/gateway/rest.go @@ -59,11 +59,14 @@ type loginRequest struct { Password string `json:"password"` } -// loginResponse is the login success payload. +// loginResponse is the login success payload. RefreshToken is the opaque +// long-lived credential (POST /auth/refresh) added by B2; it is omitted when no +// refresh store is wired so the pre-B2 contract is unchanged. type loginResponse struct { - Token string `json:"token"` - ExpiresAt int64 `json:"expiresAt"` - Role string `json:"role"` + Token string `json:"token"` + ExpiresAt int64 `json:"expiresAt"` + Role string `json:"role"` + RefreshToken string `json:"refreshToken,omitempty"` } // chatRequest is the POST /api/v1/chat body. When Stream is true the gateway @@ -114,11 +117,24 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { writeError(w, apperr.Wrap(apperr.ErrInternal, "login_failed", "could not sign token")) return } - writeJSON(w, http.StatusOK, loginResponse{ + resp := loginResponse{ Token: token, ExpiresAt: time.Now().Add(tokenTTL).Unix(), Role: string(auth.RoleOwner), - }) + } + // Issue the long-lived refresh credential alongside the access token. A store + // failure fails the login rather than silently handing back a login without + // the advertised refresh token; the plaintext is never logged. + if s.refresh != nil { + refreshToken, _, rerr := s.refresh.Issue(r.Context(), req.Username, auth.RoleOwner, s.refreshTTL()) + if rerr != nil { + s.log.Error("login issue refresh token", "err", rerr) + writeError(w, apperr.Wrap(apperr.ErrInternal, "login_failed", "could not issue refresh token")) + return + } + resp.RefreshToken = refreshToken + } + writeJSON(w, http.StatusOK, resp) } // handleChat runs one synchronous agent turn (or triggers streaming). diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index 4969515..81f0120 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -157,6 +157,32 @@ type UserStore interface { List() []auth.UserSummary } +// RefreshGrantStore manages opaque refresh tokens (auth.RefreshStore is the +// production implementation). It is the credential behind the refreshToken +// returned by login and the POST /auth/refresh, /auth/logout endpoints. Only +// hashes are persisted; the plaintext token crosses this interface exactly once +// per issue/rotate. Optional: when nil login omits refreshToken and the refresh +// and logout endpoints report a clear not-configured error. +type RefreshGrantStore interface { + // Issue mints a refresh token for userID/role and returns its plaintext + // (visible once) plus its expiry. + Issue(ctx context.Context, userID string, role auth.Role, ttl time.Duration) (string, time.Time, error) + // Rotate validates plaintext and, when opts.Rotate is set, revokes it and + // returns a replacement. It returns auth.ErrInvalidGrant for an unknown, + // revoked, or expired token. + Rotate(ctx context.Context, plaintext string, opts auth.RotateOptions) (auth.RefreshToken, string, error) + // Revoke marks the token matching plaintext revoked, returning whether a + // live token was found. It is idempotent. + Revoke(ctx context.Context, plaintext string) (bool, error) + // RevokeAll deletes every refresh token (global invalidation on secret + // rotation) and returns how many were removed. + RevokeAll(ctx context.Context) (int64, error) + // RevokeByUser deletes every refresh token for userID (logout by access + // token, where the individual grant is unknown) and returns how many were + // removed. + RevokeByUser(ctx context.Context, userID string) (int64, error) +} + // BackthinkStore enumerates the persisted background-think records for the // M9/M10 monitor panel. *backthink.Backthink is the production implementation. type BackthinkStore interface { @@ -247,6 +273,9 @@ type Deps struct { // Users lists accounts and API keys. Optional (falls back to the configured // single admin account). Users UserStore + // Refresh is the opaque refresh-token store. Optional: when nil login omits + // refreshToken and /auth/refresh + /auth/logout answer not-configured. + Refresh RefreshGrantStore // Backthink lists background-think records. Optional. Backthink BackthinkStore // RAG backs the admin knowledge-base RAG module. Optional; list/search @@ -314,6 +343,7 @@ type Server struct { taskOutputs TaskOutputStore automations AutomationStore users UserStore + refresh RefreshGrantStore backthink BackthinkStore rag RagStore obs *observability @@ -363,6 +393,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) { taskOutputs: deps.Tasks, automations: deps.Automations, users: deps.Users, + refresh: deps.Refresh, backthink: deps.Backthink, rag: deps.RAG, obs: newObservability(deps), @@ -390,6 +421,11 @@ func (s *Server) Handler() http.Handler { // Public, no auth. mux.HandleFunc("GET /api/v1/health", s.handleHealth) mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin) + // Refresh/logout are public by design: the opaque refresh token is itself the + // credential, so a client with an expired access token can still rotate or + // revoke it. Both fail closed with 401 invalid_grant on a bad token. + mux.HandleFunc("POST /api/v1/auth/refresh", s.handleRefresh) + mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout) // User side, authenticated. Chat is a write (owner/admin); reads accept any // authenticated role so a viewer can still list personas/sessions.