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密码仅登录验证不打印
268 lines
7.7 KiB
Go
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))
|
|
}
|