feat(auth): refresh token 续期(B2) — 不透明哈希+单次旋转+/auth/refresh+/auth/logout
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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 同步
This commit is contained in:
AskaEth committed 2026-09-08 21:45:00 +08:00
1 parent 7ecb43bd5d
commit 026b27b310
17 files changed
+1237 -13

No files matched your search

+36
View File
@@ -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.