Files
Cyrene_v2/internal/auth/jwt_test.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

268 lines
7.7 KiB
Go

package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
func testConfig(secret string) config.AuthConfig {
if secret == "" {
secret = strings.Repeat("s", 40)
}
return config.AuthConfig{JWTSecret: secret}
}
func TestNew_ValidSecret(t *testing.T) {
t.Parallel()
svc := New(testConfig(""))
if svc == nil {
t.Fatal("New returned nil")
}
}
func TestNew_ShortSecretPanics(t *testing.T) {
t.Parallel()
defer func() {
r := recover()
if r == nil {
t.Fatal("expected panic for short JWTSecret, got none")
}
if !errors.Is(r.(error), ErrInvalidSecret) {
t.Fatalf("panic = %v, want ErrInvalidSecret", r)
}
}()
New(config.AuthConfig{JWTSecret: "short"})
}
func TestValidate_SecretLength(t *testing.T) {
t.Parallel()
cases := []struct {
name string
secret string
ok bool
}{
{"32 chars", strings.Repeat("a", 32), true},
{"40 chars", strings.Repeat("a", 40), true},
{"31 chars", strings.Repeat("a", 31), false},
{"empty", "", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := Validate(config.AuthConfig{JWTSecret: tc.secret})
if tc.ok && err != nil {
t.Fatalf("Validate(%d chars) error: %v", len(tc.secret), err)
}
if !tc.ok && !errors.Is(err, ErrInvalidSecret) {
t.Fatalf("Validate(%d chars) err = %v, want ErrInvalidSecret", len(tc.secret), err)
}
})
}
}
func TestJWTSignVerify_Roundtrip(t *testing.T) {
t.Parallel()
svc := New(testConfig(""))
before := time.Now().Unix()
token, err := svc.Sign("user-1", RoleAdmin, time.Hour)
if err != nil {
t.Fatalf("Sign error: %v", err)
}
if token == "" {
t.Fatal("Sign returned empty token")
}
claims, err := svc.Verify(token)
if err != nil {
t.Fatalf("Verify error: %v", err)
}
if claims.Sub != "user-1" {
t.Errorf("Sub = %q, want user-1", claims.Sub)
}
if claims.Role != RoleAdmin {
t.Errorf("Role = %q, want admin", claims.Role)
}
if claims.IssuedAt < before || claims.IssuedAt > before+1 {
t.Errorf("IssuedAt = %d, want within [%d, %d]", claims.IssuedAt, before, before+1)
}
if claims.Exp != claims.IssuedAt+3600 {
t.Errorf("Exp = %d, want IssuedAt+3600 (%d)", claims.Exp, claims.IssuedAt+3600)
}
}
func TestJWTSign_InvalidRole(t *testing.T) {
t.Parallel()
svc := New(testConfig(""))
if _, err := svc.Sign("u", Role("god"), time.Hour); !errors.Is(err, ErrInvalidRole) {
t.Fatalf("Sign invalid role err = %v, want ErrInvalidRole", err)
}
}
func TestJWTVerify_Expired(t *testing.T) {
t.Parallel()
svc := New(testConfig(""))
token, err := svc.Sign("u", RoleViewer, -time.Minute)
if err != nil {
t.Fatalf("Sign error: %v", err)
}
if _, err := svc.Verify(token); !errors.Is(err, ErrTokenExpired) {
t.Fatalf("Verify expired err = %v, want ErrTokenExpired", err)
}
}
func TestJWTVerify_TamperedPayload(t *testing.T) {
t.Parallel()
svc := New(testConfig(""))
token, err := svc.Sign("u", RoleViewer, time.Hour)
if err != nil {
t.Fatalf("Sign error: %v", err)
}
parts := strings.Split(token, ".")
// Flip a character in a bit-significant position of the payload (not the
// final trailing base64 group, whose low bits are padding and may not change
// the decoded bytes). The signature was computed over the original payload,
// so it no longer matches.
payload := parts[1]
flip := byte('A')
if payload[3] == 'A' {
flip = 'B'
}
tampered := parts[0] + "." + payload[:3] + string(flip) + payload[4:] + "." + parts[2]
if tampered == token {
t.Fatal("tampered token unchanged, test ineffective")
}
if _, err := svc.Verify(tampered); !errors.Is(err, ErrInvalidSignature) {
t.Fatalf("Verify tampered err = %v, want ErrInvalidSignature", err)
}
}
func TestJWTVerify_WrongSecret(t *testing.T) {
t.Parallel()
signer := New(testConfig(strings.Repeat("a", 40)))
verifier := New(testConfig(strings.Repeat("b", 40)))
token, err := signer.Sign("u", RoleViewer, time.Hour)
if err != nil {
t.Fatalf("Sign error: %v", err)
}
if _, err := verifier.Verify(token); !errors.Is(err, ErrInvalidSignature) {
t.Fatalf("Verify wrong secret err = %v, want ErrInvalidSignature", err)
}
}
func TestJWTVerify_UnsupportedAlg(t *testing.T) {
t.Parallel()
secret := strings.Repeat("k", 40)
svc := New(config.AuthConfig{JWTSecret: secret})
token := craftSignedToken(t, secret, "HS512", Claims{
Sub: "u", Role: RoleViewer,
IssuedAt: time.Now().Unix(), Exp: time.Now().Add(time.Hour).Unix(),
})
if _, err := svc.Verify(token); !errors.Is(err, ErrUnsupportedAlg) {
t.Fatalf("Verify HS512 err = %v, want ErrUnsupportedAlg", err)
}
}
func TestJWTVerify_InvalidRoleClaim(t *testing.T) {
t.Parallel()
secret := strings.Repeat("k", 40)
svc := New(config.AuthConfig{JWTSecret: secret})
token := craftSignedToken(t, secret, "HS256", Claims{
Sub: "u", Role: Role("god"),
IssuedAt: time.Now().Unix(), Exp: time.Now().Add(time.Hour).Unix(),
})
if _, err := svc.Verify(token); !errors.Is(err, ErrInvalidRole) {
t.Fatalf("Verify god role err = %v, want ErrInvalidRole", err)
}
}
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(""))
input := map[string]string{
"no segments": "token",
"barely two": "a.b",
"bad base64 sig": "a.b.c",
}
for name, tok := range input {
tok := tok
t.Run(name, func(t *testing.T) {
t.Parallel()
if _, err := svc.Verify(tok); !errors.Is(err, ErrMalformedToken) {
t.Fatalf("Verify(%q) err = %v, want ErrMalformedToken", tok, err)
}
})
}
}
// craftSignedToken builds an HS256-signed token with an arbitrary header alg,
// used to probe alg policy and role validation.
func craftSignedToken(t *testing.T, secret, alg string, claims Claims) string {
t.Helper()
headerB64 := createSegment(t, `{"alg":"`+alg+`","typ":"JWT"}`)
payloadB, err := json.Marshal(claims)
if err != nil {
t.Fatalf("marshal claims: %v", err)
}
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadB)
signingInput := headerB64 + "." + payloadB64
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signingInput))
return signingInput + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func createSegment(t *testing.T, raw string) string {
t.Helper()
return base64.RawURLEncoding.EncodeToString([]byte(raw))
}