- 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 同步
268 lines
9.8 KiB
Go
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, "'", "''")
|
|
}
|