Files
Cyrene_v2/internal/auth/jwt.go
T
Aska 1ebc82fffd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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密码仅登录验证不打印
2026-09-07 13:57:36 +08:00

169 lines
4.9 KiB
Go

package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// Claims is the verified JWT payload.
type Claims struct {
Sub string `json:"sub"`
Role Role `json:"role"`
Exp int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
}
// jwtHeader is the JOSE header for the HS256 tokens this package emits.
type jwtHeader struct {
Alg string `json:"alg"`
Typ string `json:"typ"`
}
// 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
}
// New validates cfg and returns a JWTService. It panics when JWTSecret is
// shorter than 32 characters so a misconfiguration fails fast at startup.
func New(cfg config.AuthConfig) *JWTService {
if err := Validate(cfg); err != nil {
panic(err)
}
return &JWTService{
secret: []byte(cfg.JWTSecret),
log: logger.WithSource(slog.Default(), source),
}
}
// Validate checks that cfg satisfies the auth package's configuration
// requirements, returning ErrInvalidSecret when JWTSecret is too short.
func Validate(cfg config.AuthConfig) error {
if len(cfg.JWTSecret) < minSecretLen {
return ErrInvalidSecret
}
return nil
}
// Sign issues an HS256 token for sub with the given role and lifetime.
func (s *JWTService) Sign(sub string, role Role, ttl time.Duration) (string, error) {
if !role.Valid() {
return "", fmt.Errorf("%w: %q", ErrInvalidRole, role)
}
now := time.Now().Unix()
claims := Claims{
Sub: sub,
Role: role,
IssuedAt: now,
Exp: now + int64(ttl.Seconds()),
}
headerB64, err := marshalB64(jwtHeader{Alg: "HS256", Typ: "JWT"})
if err != nil {
return "", fmt.Errorf("auth: encode header: %w", err)
}
payloadB64, err := marshalB64(claims)
if err != nil {
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) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("%w: expected 3 segments", ErrMalformedToken)
}
headerB64, payloadB64, sigB64 := parts[0], parts[1], parts[2]
signingInput := headerB64 + "." + payloadB64
provided, err := base64.RawURLEncoding.DecodeString(sigB64)
if err != nil {
return nil, fmt.Errorf("%w: decode signature: %v", ErrMalformedToken, err)
}
s.mu.RLock()
expected := hmacSign(s.secret, []byte(signingInput))
s.mu.RUnlock()
if !hmac.Equal(provided, expected) {
return nil, ErrInvalidSignature
}
headerJSON, err := base64.RawURLEncoding.DecodeString(headerB64)
if err != nil {
return nil, fmt.Errorf("%w: decode header: %v", ErrMalformedToken, err)
}
var hdr jwtHeader
if err := json.Unmarshal(headerJSON, &hdr); err != nil {
return nil, fmt.Errorf("%w: decode header: %v", ErrMalformedToken, err)
}
if hdr.Alg != "HS256" {
return nil, fmt.Errorf("%w: %q", ErrUnsupportedAlg, hdr.Alg)
}
payloadJSON, err := base64.RawURLEncoding.DecodeString(payloadB64)
if err != nil {
return nil, fmt.Errorf("%w: decode payload: %v", ErrMalformedToken, err)
}
var claims Claims
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
return nil, fmt.Errorf("%w: decode payload: %v", ErrMalformedToken, err)
}
if !claims.Role.Valid() {
return nil, fmt.Errorf("%w: %q", ErrInvalidRole, claims.Role)
}
if claims.Exp > 0 && time.Now().Unix() >= claims.Exp {
return nil, ErrTokenExpired
}
return &claims, nil
}
// marshalB64 JSON-encodes v and base64url-encodes the result without padding.
func marshalB64(v any) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// hmacSign returns the HS256 HMAC of input under secret.
func hmacSign(secret, input []byte) []byte {
h := hmac.New(sha256.New, secret)
h.Write(input)
return h.Sum(nil)
}