feat: JWT过期自动重置 + 用户端登录页 + 系统设置前三个真实实现
agent-111 JWT过期重置+系统设置去占位: - ws.ts 识别401/过期 markAuthExpired(清token+停重连+expired态),isTokenExpired;wsParse.ts拆出压行371;scheduleReconnect有token未过期才重连 - api.ts REST 401→auth_expired+清token+引导重登;login走skipAuthExpired密码错不误清 - types.ts WsStatus加expired;TopBar/Nav expired显示登录过期;System.tsx去alert占位改4张事实卡+合并Notice agent-113 用户端登录页: - components/Login.tsx Miuix登录卡(用户名/密码→login()→setAuthToken);App !getAuthToken()→<Login>;AdminApp LoginGate指向 agent-112 系统设置前三个(owner+审计): - auth.APIKey.RevokeAll(吊销全部key);auth.JWTService.RotateSecret(≥32校验+并发安全+新密钥不回显) - gateway admin_system.go: POST revoke-keys/rotate-jwt/backup(VACUUM INTO)/restore(防路径穿越+恢复前自动备份+原子替换+需重启) - wire.go 传DatabasePath;全仓 build/vet/test绿+交叉编译;api-client v0.17系统设置契约 实机验证: 登录(role=owner)+revoke-keys 200+渠道列表含kook; 全仓绿+前端build绿; .env密码仅登录验证不打印
This commit is contained in:
1 parent
7e39153a69
commit
1ebc82fffd
19 files changed
+1164
-245
No files matched your search
@@ -72,6 +72,17 @@ func (k *APIKey) Revoke(key string) {
|
||||
delete(k.keys, key)
|
||||
}
|
||||
|
||||
// RevokeAll removes every currently registered API key and returns how many were
|
||||
// removed. It is the administrative "revoke all" operation: after a call the
|
||||
// table is empty, so no previously issued key can authenticate.
|
||||
func (k *APIKey) RevokeAll() int {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
n := len(k.keys)
|
||||
k.keys = make(map[string]Role)
|
||||
return n
|
||||
}
|
||||
|
||||
// RevokeByMask removes every key whose masked identifier (the same derived id
|
||||
// List returns) matches mask. It returns true when at least one key was revoked.
|
||||
// The mask is lossy, so it is matched by recomputing it for each key; the table
|
||||
|
||||
@@ -80,3 +80,28 @@ func TestAPIKey_Revoke(t *testing.T) {
|
||||
t.Error("Verify after revoke = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_RevokeAll(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
keys := make([]string, 0, 3)
|
||||
for _, role := range []Role{RoleOwner, RoleAdmin, RoleViewer} {
|
||||
key, err := k.Generate(role)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if got := k.RevokeAll(); got != 3 {
|
||||
t.Fatalf("RevokeAll() = %d, want 3", got)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if k.Verify(key) {
|
||||
t.Errorf("Verify(%q) after RevokeAll = true, want false", key)
|
||||
}
|
||||
}
|
||||
// An empty table reports zero and is a safe no-op.
|
||||
if got := k.RevokeAll(); got != 0 {
|
||||
t.Fatalf("RevokeAll() on empty table = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
+26
-2
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
@@ -28,8 +29,11 @@ type jwtHeader struct {
|
||||
Typ string `json:"typ"`
|
||||
}
|
||||
|
||||
// JWTService signs and verifies HS256 JWTs using a shared secret.
|
||||
// JWTService signs and verifies HS256 JWTs using a shared secret. The secret is
|
||||
// protected by a RWMutex so RotateSecret can swap it while Sign/Verify read it
|
||||
// concurrently (the gateway rotates the secret live from the admin panel).
|
||||
type JWTService struct {
|
||||
mu sync.RWMutex
|
||||
secret []byte
|
||||
log *slog.Logger
|
||||
}
|
||||
@@ -76,10 +80,27 @@ func (s *JWTService) Sign(sub string, role Role, ttl time.Duration) (string, err
|
||||
return "", fmt.Errorf("auth: encode payload: %w", err)
|
||||
}
|
||||
signingInput := headerB64 + "." + payloadB64
|
||||
s.mu.RLock()
|
||||
sig := hmacSign(s.secret, []byte(signingInput))
|
||||
s.mu.RUnlock()
|
||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// RotateSecret replaces the signing secret with newSecret, invalidating every
|
||||
// token issued under the previous secret. It returns ErrInvalidSecret when
|
||||
// newSecret is shorter than the 32-character minimum. The new secret is never
|
||||
// logged or echoed back to the caller.
|
||||
func (s *JWTService) RotateSecret(newSecret string) error {
|
||||
if len(newSecret) < minSecretLen {
|
||||
return fmt.Errorf("%w: got %d chars", ErrInvalidSecret, len(newSecret))
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.secret = []byte(newSecret)
|
||||
s.mu.Unlock()
|
||||
s.log.Info("jwt secret rotated; previously issued tokens are now invalid")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify validates the token signature, algorithm, role, and expiry, returning
|
||||
// the parsed Claims on success.
|
||||
func (s *JWTService) Verify(token string) (*Claims, error) {
|
||||
@@ -94,7 +115,10 @@ func (s *JWTService) Verify(token string) (*Claims, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: decode signature: %v", ErrMalformedToken, err)
|
||||
}
|
||||
if !hmac.Equal(provided, hmacSign(s.secret, []byte(signingInput))) {
|
||||
s.mu.RLock()
|
||||
expected := hmacSign(s.secret, []byte(signingInput))
|
||||
s.mu.RUnlock()
|
||||
if !hmac.Equal(provided, expected) {
|
||||
return nil, ErrInvalidSignature
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,49 @@ func TestJWTVerify_InvalidRoleClaim(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_RotateSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(strings.Repeat("a", 40)))
|
||||
token, err := svc.Sign("u", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
if _, err := svc.Verify(token); err != nil {
|
||||
t.Fatalf("Verify before rotation error: %v", err)
|
||||
}
|
||||
if err := svc.RotateSecret(strings.Repeat("b", 40)); err != nil {
|
||||
t.Fatalf("RotateSecret error: %v", err)
|
||||
}
|
||||
// The previously issued token no longer verifies under the new secret.
|
||||
if _, err := svc.Verify(token); !errors.Is(err, ErrInvalidSignature) {
|
||||
t.Fatalf("Verify after rotation err = %v, want ErrInvalidSignature", err)
|
||||
}
|
||||
// A token signed under the new secret verifies fine.
|
||||
newToken, err := svc.Sign("u", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign after rotation error: %v", err)
|
||||
}
|
||||
if _, err := svc.Verify(newToken); err != nil {
|
||||
t.Fatalf("Verify under new secret error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_RotateSecretShort(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(strings.Repeat("a", 40)))
|
||||
if err := svc.RotateSecret("short"); !errors.Is(err, ErrInvalidSecret) {
|
||||
t.Fatalf("RotateSecret(short) err = %v, want ErrInvalidSecret", err)
|
||||
}
|
||||
// A rejected short secret leaves the previous secret intact.
|
||||
token, err := svc.Sign("u", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
if _, err := svc.Verify(token); err != nil {
|
||||
t.Fatalf("Verify after rejected rotation error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTVerify_Malformed(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
|
||||
Reference in new issue
Block a user