feat(auth): JWT(HS256 stdlib) + RBAC(owner/admin/viewer) + API key + 密码 hash;config 校验
This commit is contained in:
1 parent
7f7cb8fbf7
commit
a3f6f4f687
10 files changed
+1116
No files matched your search
@@ -0,0 +1,224 @@
|
||||
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 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))
|
||||
}
|
||||
Reference in new issue
Block a user