feat: JWT过期自动重置 + 用户端登录页 + 系统设置前三个真实实现
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

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:
Aska committed 2026-09-07 13:57:36 +08:00
1 parent 7e39153a69
commit 1ebc82fffd
19 files changed
+1164 -245

No files matched your search

+11
View File
@@ -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
+25
View File
@@ -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
View File
@@ -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
}
+43
View File
@@ -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(""))