Files
Cyrene_v2/internal/services/gateway/admin_system.go
T
AskaEth 026b27b310
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(auth): refresh token 续期(B2) — 不透明哈希+单次旋转+/auth/refresh+/auth/logout
- auth/refresh.go: RefreshStore(Issue/Rotate/Revoke/RevokeAll/RevokeByUser), SHA-256哈希256-bit随机串, 单次旋转事务(并发重放fail-closed)
- 迁移0008_refresh_tokens(id/user_id/role/token_hash UNIQUE/expires_at/revoked_at + 索引)
- gateway: POST /v1/auth/refresh(旋转/续期) + /v1/auth/logout(单/全设备); login响应增refreshToken
- config: AUTH_REFRESH_TTL(720h)/AUTH_REFRESH_ROTATE_DISABLED; decode.go 支持time.Duration
- rotate-jwt成功后RevokeAll(响应refreshTokensRevoked), revoke-keys不动refresh
- 14测试+真实二进制e2e, build/vet/test绿+交叉编译
- Docs/api-client v0.25 + api-reference §2.3/2.4 同步
2026-09-08 21:45:00 +08:00

268 lines
9.8 KiB
Go

package gateway
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
)
// System management admin writes (owner-only, audited; design/11 §4 敏感操作代审 +
// design/10 §3 module 14 系统设置). Every handler audits a system.* action and never
// echoes a JWT secret or a database path back to the client.
// adminRevokeAllKeys revokes every currently registered API key. It is the
// `system.revoke_keys` admin write; the table is emptied so no prior key can
// authenticate afterward.
func (s *Server) adminRevokeAllKeys(w http.ResponseWriter, r *http.Request) {
keys, ok := s.users.(*auth.APIKey)
if !ok {
s.auditClaims(r, "system.revoke_keys", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "users_unavailable", "用户服务未配置"))
return
}
n := keys.RevokeAll()
s.auditClaims(r, "system.revoke_keys", "ok")
writeJSON(w, http.StatusOK, map[string]any{"revoked": n})
}
// rotateJWTRequest is the POST /api/v1/admin/system/rotate-jwt body. The new
// secret is accepted from the request so the owner can supply a fresh value; it
// is never echoed back or logged.
type rotateJWTRequest struct {
NewSecret string `json:"newSecret"`
}
// adminRotateJWT replaces the JWT signing secret, invalidating every token issued
// under the old secret. It is the `system.rotate_jwt` admin write.
func (s *Server) adminRotateJWT(w http.ResponseWriter, r *http.Request) {
var req rotateJWTRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.NewSecret) == "" {
s.auditClaims(r, "system.rotate_jwt", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "newSecret is required"))
return
}
jwt, ok := s.auth.(*auth.JWTService)
if !ok {
s.auditClaims(r, "system.rotate_jwt", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "auth_unavailable", "认证服务未配置"))
return
}
if err := jwt.RotateSecret(req.NewSecret); err != nil {
s.auditClaims(r, "system.rotate_jwt", "error")
if errors.Is(err, auth.ErrInvalidSecret) {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "新密钥长度不足 32 位"))
return
}
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,
"refreshTokensRevoked": refreshRevoked,
"note": "现有 token 与 refresh token 已全部失效,需重新登录",
})
}
// adminBackup snapshots the SQLite database into the data directory using
// `VACUUM INTO` (which includes committed WAL content), returning the backup path
// and size. It is the `system.backup` admin write.
func (s *Server) adminBackup(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(s.dbPath) == "" {
s.auditClaims(r, "system.backup", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "backup_unavailable", "数据库路径未配置"))
return
}
path, size, err := s.backupDatabase(r.Context())
if err != nil {
s.auditClaims(r, "system.backup", "error")
s.log.Error("backup", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "backup_failed", "备份失败"))
return
}
s.auditClaims(r, "system.backup", "ok")
writeJSON(w, http.StatusOK, map[string]any{"path": path, "size": size})
}
// restoreRequest is the POST /api/v1/admin/system/restore body. Path is the
// backup file to restore, expected to live inside the data directory.
type restoreRequest struct {
Path string `json:"path"`
}
// adminRestore validates a backup path (must be inside the data directory — a
// path-traversal guard), snapshots the current database first, then replaces the
// live DB file. Because the running process holds the SQLite file handle open, the
// replace takes effect on restart; it is reported as such. It is the
// `system.restore` admin write.
func (s *Server) adminRestore(w http.ResponseWriter, r *http.Request) {
var req restoreRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(s.dbPath) == "" {
s.auditClaims(r, "system.restore", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "backup_unavailable", "数据库路径未配置"))
return
}
src := strings.TrimSpace(req.Path)
if src == "" {
s.auditClaims(r, "system.restore", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "path is required"))
return
}
absSrc, ok := s.restorePathWithinDataDir(src)
if !ok {
s.auditClaims(r, "system.restore", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "恢复路径必须在数据目录内"))
return
}
if _, err := os.Stat(absSrc); err != nil {
s.auditClaims(r, "system.restore", "error")
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "备份文件不存在"))
return
}
// Snapshot the current database before touching it, so an accidental restore
// of the wrong file is recoverable.
curPath, _, err := s.backupDatabase(r.Context())
if err != nil {
s.auditClaims(r, "system.restore", "error")
s.log.Error("restore pre-backup", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "restore_failed", "恢复前备份失败"))
return
}
if err := replaceDatabase(absSrc, s.dbPath); err != nil {
s.auditClaims(r, "system.restore", "error")
s.log.Error("restore replace", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "restore_failed", "替换数据库失败"))
return
}
s.auditClaims(r, "system.restore", "ok")
writeJSON(w, http.StatusOK, map[string]any{
"restored": true,
"backup": curPath,
"note": "已安排恢复,需重启服务后生效",
})
}
// restorePathWithinDataDir resolves src to an absolute path and reports whether it
// lives inside the data directory (the parent of s.dbPath). It is the path-
// traversal guard for the restore endpoint: only paths under the data dir are
// accepted.
func (s *Server) restorePathWithinDataDir(src string) (string, bool) {
dataDir, err := filepath.Abs(filepath.Dir(s.dbPath))
if err != nil {
return "", false
}
dataDir = filepath.Clean(dataDir)
absSrc, err := filepath.Abs(src)
if err != nil {
return "", false
}
absSrc = filepath.Clean(absSrc)
rel, err := filepath.Rel(dataDir, absSrc)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", false
}
return absSrc, true
}
// backupDatabase snapshots the live SQLite database into
// <dataDir>/backup-<ts>.db via `VACUUM INTO`, which includes every committed WAL
// transaction, and returns the new file's path and size. Opening a second
// connection to the file-backed database is safe: SQLite serializes access, and
// busy_timeout handles a momentarily locked source.
func (s *Server) backupDatabase(ctx context.Context) (string, int64, error) {
dir := filepath.Dir(s.dbPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", 0, fmt.Errorf("create backup dir: %w", err)
}
dest := filepath.Join(dir, fmt.Sprintf("backup-%s.db", time.Now().UTC().Format("20060102T150405.000000000Z")))
conn, err := db.Open(ctx, s.dbPath, s.log)
if err != nil {
return "", 0, fmt.Errorf("open db for backup: %w", err)
}
defer conn.Close()
// VACUUM INTO takes a string literal, not a bound parameter; escape the
// generated filename (safe characters, but escape defensively).
query := fmt.Sprintf("VACUUM INTO '%s'", escapeSQLString(dest))
if _, err := conn.ExecContext(ctx, query); err != nil {
return "", 0, fmt.Errorf("vacuum into: %w", err)
}
info, err := os.Stat(dest)
if err != nil {
return "", 0, fmt.Errorf("stat backup: %w", err)
}
return dest, info.Size(), nil
}
// replaceDatabase atomically replaces the live DB file at dest with a copy of src,
// writing to a temp file first so a failure never leaves dest truncated. On Linux
// a running SQLite connection keeps its open handle to the old inode, so the new
// data becomes visible only after the process restarts — which the restore
// endpoint reports.
func replaceDatabase(src, dest string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer in.Close()
tmp := dest + ".restore.tmp"
out, err := os.Create(tmp)
if err != nil {
return fmt.Errorf("create temp: %w", err)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
_ = os.Remove(tmp)
return fmt.Errorf("copy: %w", err)
}
if err := out.Close(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("close temp: %w", err)
}
if err := os.Rename(tmp, dest); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename: %w", err)
}
return nil
}
// escapeSQLString doubles any single quote so a filename can be safely embedded
// in a SQL string literal for VACUUM INTO.
func escapeSQLString(s string) string {
return strings.ReplaceAll(s, "'", "''")
}