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,73 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// APIKey manages a bounded, in-memory constant table of API keys, each bound to
|
||||
// a Role so keys can be minted for admin/owner to reach administrative
|
||||
// endpoints. Keys are generated with crypto/rand and stored by their string
|
||||
// form. This suits the current single-binary milestone; production should
|
||||
// persist keys (e.g. hashed) and support revocation.
|
||||
type APIKey struct {
|
||||
mu sync.RWMutex
|
||||
cfg config.AuthConfig
|
||||
keys map[string]Role
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewAPIKey returns an empty key manager. cfg is accepted for constructor
|
||||
// parity and future persistence settings; keys are runtime-generated.
|
||||
func NewAPIKey(cfg config.AuthConfig) *APIKey {
|
||||
return &APIKey{
|
||||
cfg: cfg,
|
||||
keys: make(map[string]Role),
|
||||
log: logger.WithSource(slog.Default(), source),
|
||||
}
|
||||
}
|
||||
|
||||
// Generate mints a new random key bound to role and stores it. Returns the
|
||||
// plaintext key exactly once; it must not be logged.
|
||||
func (k *APIKey) Generate(role Role) (string, error) {
|
||||
if !role.Valid() {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidRole, role)
|
||||
}
|
||||
raw := make([]byte, keyLength)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("auth: generate api key: %w", err)
|
||||
}
|
||||
key := base64.RawURLEncoding.EncodeToString(raw)
|
||||
|
||||
k.mu.Lock()
|
||||
k.keys[key] = role
|
||||
k.mu.Unlock()
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Verify reports whether key is a known, unrevoked API key.
|
||||
func (k *APIKey) Verify(key string) bool {
|
||||
_, ok := k.Lookup(key)
|
||||
return ok
|
||||
}
|
||||
|
||||
// Lookup returns the role bound to key.
|
||||
func (k *APIKey) Lookup(key string) (Role, bool) {
|
||||
k.mu.RLock()
|
||||
defer k.mu.RUnlock()
|
||||
role, ok := k.keys[key]
|
||||
return role, ok
|
||||
}
|
||||
|
||||
// Revoke removes key from the constant table.
|
||||
func (k *APIKey) Revoke(key string) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
delete(k.keys, key)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
func newTestAPIKey() *APIKey {
|
||||
return NewAPIKey(config.AuthConfig{})
|
||||
}
|
||||
|
||||
func TestAPIKey_GenerateVerify(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
key, err := k.Generate(RoleAdmin)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
if key == "" {
|
||||
t.Fatal("Generate returned empty key")
|
||||
}
|
||||
if !k.Verify(key) {
|
||||
t.Errorf("Verify(%q) = false, want true", key)
|
||||
}
|
||||
role, ok := k.Lookup(key)
|
||||
if !ok || role != RoleAdmin {
|
||||
t.Errorf("Lookup(%q) = (%q,%v), want (admin,true)", key, role, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_GenerateUnique(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
a, err := k.Generate(RoleViewer)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
b, err := k.Generate(RoleViewer)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("two keys are identical, random generation ineffective")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_GenerateInvalidRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
if _, err := k.Generate(Role("god")); !errors.Is(err, ErrInvalidRole) {
|
||||
t.Fatalf("Generate invalid role err = %v, want ErrInvalidRole", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_VerifyUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
if k.Verify("not-a-key") {
|
||||
t.Error("Verify('not-a-key') = true, want false")
|
||||
}
|
||||
if k.Verify("") {
|
||||
t.Error("Verify('') = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKey_Revoke(t *testing.T) {
|
||||
t.Parallel()
|
||||
k := newTestAPIKey()
|
||||
key, err := k.Generate(RoleViewer)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate error: %v", err)
|
||||
}
|
||||
if !k.Verify(key) {
|
||||
t.Fatalf("Verify before revoke = false, want true")
|
||||
}
|
||||
k.Revoke(key)
|
||||
if k.Verify(key) {
|
||||
t.Error("Verify after revoke = true, want false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Package auth provides authentication and authorization primitives for the
|
||||
// Cyrene v2 gateway: JWT signing/verification (HS256), an RBAC middleware built
|
||||
// on an owner>admin>viewer role hierarchy, in-memory API keys, and salted
|
||||
// password hashing. All APIs are self-contained and use only the standard
|
||||
// library, so the package compiles without adding dependencies to the module.
|
||||
//
|
||||
// Identity is carried as strong-typed actor metadata and decided by policy (see
|
||||
// DevDocs/design/11): it is never inferred by an LLM. This package supplies the
|
||||
// credential layer that such policies rely on.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Role is an authorization role in the owner>admin>viewer hierarchy.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
// RoleOwner has full authority, including security/key/system settings.
|
||||
RoleOwner Role = "owner"
|
||||
// RoleAdmin manages modules but no sensitive security items.
|
||||
RoleAdmin Role = "admin"
|
||||
// RoleViewer is read-only (dashboard/logs/monitoring).
|
||||
RoleViewer Role = "viewer"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the auth package.
|
||||
var (
|
||||
// ErrInvalidSecret reports a JWTSecret shorter than the required minimum.
|
||||
ErrInvalidSecret = errors.New("auth: JWTSecret must be at least 32 characters")
|
||||
// ErrInvalidRole reports an unknown or unparseable role name.
|
||||
ErrInvalidRole = errors.New("auth: invalid role")
|
||||
// ErrMalformedToken reports a token that is not well-formed.
|
||||
ErrMalformedToken = errors.New("auth: malformed token")
|
||||
// ErrInvalidSignature reports a token whose HMAC signature does not verify.
|
||||
ErrInvalidSignature = errors.New("auth: invalid signature")
|
||||
// ErrTokenExpired reports a token whose exp is in the past.
|
||||
ErrTokenExpired = errors.New("auth: token expired")
|
||||
// ErrUnsupportedAlg reports a token declaring an algorithm other than HS256.
|
||||
ErrUnsupportedAlg = errors.New("auth: unsupported algorithm")
|
||||
// ErrUnauthorized reports a missing/invalid/unexpired-failed credential.
|
||||
ErrUnauthorized = errors.New("auth: unauthorized")
|
||||
// ErrForbidden reports a valid credential with insufficient privilege.
|
||||
ErrForbidden = errors.New("auth: forbidden")
|
||||
)
|
||||
|
||||
// Package-wide constants. `source` is the log provenance tag used by every
|
||||
// logger.WithSource call in this package (no bare logs are emitted).
|
||||
const (
|
||||
source = "auth"
|
||||
minSecretLen = 32
|
||||
keyLength = 32
|
||||
passwordScheme = "sha256"
|
||||
saltLength = 16
|
||||
passwordIterations = 100000
|
||||
)
|
||||
|
||||
// ParseRole converts a string into a Role, erroring on unknown values.
|
||||
func ParseRole(s string) (Role, error) {
|
||||
r := Role(s)
|
||||
if !r.Valid() {
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidRole, s)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Valid reports whether r is one of owner, admin, or viewer.
|
||||
func (r Role) Valid() bool {
|
||||
return r.rank() > 0
|
||||
}
|
||||
|
||||
// rank returns the privilege rank of r. Invalid roles rank below viewer.
|
||||
func (r Role) rank() int {
|
||||
switch r {
|
||||
case RoleOwner:
|
||||
return 3
|
||||
case RoleAdmin:
|
||||
return 2
|
||||
case RoleViewer:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// HasRole reports whether role holds at least the privilege of required, where
|
||||
// owner>admin>viewer. Owner satisfies admin and viewer, admin satisfies viewer,
|
||||
// and viewer satisfies only viewer.
|
||||
func HasRole(role, required Role) bool {
|
||||
return role.rank() >= required.rank()
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"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.
|
||||
type JWTService struct {
|
||||
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
|
||||
sig := hmacSign(s.secret, []byte(signingInput))
|
||||
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), 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)
|
||||
}
|
||||
if !hmac.Equal(provided, hmacSign(s.secret, []byte(signingInput))) {
|
||||
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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HashPassword derives a salted, iterated SHA-256 digest of password and
|
||||
// returns it encoded as "sha256$<salt>$<digest>". Each call uses a fresh random
|
||||
// salt, so equal passwords produce different encodings.
|
||||
//
|
||||
// NOTE: this is a self-contained, dependency-free scheme that is deliberately
|
||||
// weaker than bcrypt (cost 12). It is acceptable for the current single-binary
|
||||
// milestone; swap to golang.org/x/crypto/bcrypt before exposing auth to
|
||||
// untrusted parties in production.
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, saltLength)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("auth: generate salt: %w", err)
|
||||
}
|
||||
digest := derivePassword(password, salt, passwordIterations)
|
||||
return passwordScheme + "$" +
|
||||
base64.RawStdEncoding.EncodeToString(salt) + "$" +
|
||||
base64.RawStdEncoding.EncodeToString(digest), nil
|
||||
}
|
||||
|
||||
// CheckPassword reports whether password matches the encoded hash. Malformed
|
||||
// input, an unknown scheme, or a mismatch all return false.
|
||||
func CheckPassword(password, encoded string) bool {
|
||||
scheme, salt, digest, err := parsePasswordHash(encoded)
|
||||
if err != nil || scheme != passwordScheme {
|
||||
return false
|
||||
}
|
||||
want := derivePassword(password, salt, passwordIterations)
|
||||
return subtle.ConstantTimeCompare(want, digest) == 1
|
||||
}
|
||||
|
||||
// parsePasswordHash splits an encoded hash into scheme, salt, and digest.
|
||||
func parsePasswordHash(encoded string) (scheme string, salt, digest []byte, err error) {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 3 {
|
||||
return "", nil, nil, fmt.Errorf("auth: malformed password hash")
|
||||
}
|
||||
salt, err = base64.RawStdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("auth: decode salt: %w", err)
|
||||
}
|
||||
digest, err = base64.RawStdEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("auth: decode digest: %w", err)
|
||||
}
|
||||
return parts[0], salt, digest, nil
|
||||
}
|
||||
|
||||
// derivePassword returns the iterated salted SHA-256 digest of password.
|
||||
func derivePassword(password string, salt []byte, iterations int) []byte {
|
||||
sum := sha256.Sum256(append(salt, []byte(password)...))
|
||||
out := sum[:]
|
||||
for i := 1; i < iterations; i++ {
|
||||
cur := sha256.Sum256(append(out, salt...))
|
||||
out = cur[:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHashPassword_Check(t *testing.T) {
|
||||
t.Parallel()
|
||||
encoded, err := HashPassword("s3cret")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(encoded, passwordScheme+"$") {
|
||||
t.Errorf("encoded = %q, want %q prefix", encoded, passwordScheme+"$")
|
||||
}
|
||||
if !CheckPassword("s3cret", encoded) {
|
||||
t.Error("CheckPassword(correct) = false, want true")
|
||||
}
|
||||
if CheckPassword("wrong", encoded) {
|
||||
t.Error("CheckPassword(wrong) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPassword_SaltRandomizes(t *testing.T) {
|
||||
t.Parallel()
|
||||
a, err := HashPassword("s3cret")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword error: %v", err)
|
||||
}
|
||||
b, err := HashPassword("s3cret")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword error: %v", err)
|
||||
}
|
||||
if a == b {
|
||||
t.Error("equal passwords produced identical encodings; salt not random")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPassword_Malformed(t *testing.T) {
|
||||
t.Parallel()
|
||||
salt := base64.RawStdEncoding.EncodeToString([]byte("saltsaltsaltsalts"))
|
||||
digest := base64.RawStdEncoding.EncodeToString([]byte("digestdigestdigestdigest"))
|
||||
cases := []struct {
|
||||
name string
|
||||
enc string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"too few parts", "sha256$onlyone"},
|
||||
{"wrong scheme", "md5$" + salt + "$" + digest},
|
||||
{"bad base64 salt", "sha256$!!$" + digest},
|
||||
{"bad base64 digest", "sha256$" + salt + "$!!"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if CheckPassword("s3cret", tc.enc) {
|
||||
t.Errorf("CheckPassword(%q) = true, want false", tc.enc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// claimsCtxKey is the context key for a validated Claims set by Authorize.
|
||||
type claimsCtxKey struct{}
|
||||
|
||||
// ClaimsFromContext returns the validated Claims stored by Authorize.
|
||||
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
|
||||
claims, ok := ctx.Value(claimsCtxKey{}).(*Claims)
|
||||
return claims, ok
|
||||
}
|
||||
|
||||
// Authorize returns an HTTP middleware that requires a valid Bearer JWT whose
|
||||
// role satisfies at least one of the required roles. With no required roles it
|
||||
// admits any authenticated call. A missing/invalid/expired token yields 401
|
||||
// (ErrUnauthorized); an authenticated token with insufficient privilege yields
|
||||
// 403 (ErrForbidden). The validated claims are stored in the request context
|
||||
// for downstream handlers.
|
||||
func (s *JWTService) Authorize(required ...Role) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := bearerToken(r)
|
||||
if !ok {
|
||||
s.log.Debug("missing bearer token")
|
||||
writeAuthError(w, http.StatusUnauthorized, ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
claims, err := s.Verify(token)
|
||||
if err != nil {
|
||||
s.log.Debug("token verify failed", "err", err)
|
||||
writeAuthError(w, http.StatusUnauthorized, ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
if !satisfies(claims.Role, required) {
|
||||
s.log.Debug("insufficient role", "role", claims.Role)
|
||||
writeAuthError(w, http.StatusForbidden, ErrForbidden)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), claimsCtxKey{}, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// satisfies reports whether role has enough privilege for at least one of the
|
||||
// required roles. An empty required list means any authenticated role passes.
|
||||
func satisfies(role Role, required []Role) bool {
|
||||
if len(required) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, r := range required {
|
||||
if HasRole(role, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bearerToken extracts the token from an Authorization: Bearer header.
|
||||
func bearerToken(r *http.Request) (string, bool) {
|
||||
const prefix = "Bearer "
|
||||
h := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(h, prefix) {
|
||||
return "", false
|
||||
}
|
||||
token := strings.TrimSpace(h[len(prefix):])
|
||||
return token, token != ""
|
||||
}
|
||||
|
||||
// writeAuthError writes an {error:{code,message}} JSON response matching the
|
||||
// project's client contract. No token material is ever logged.
|
||||
func writeAuthError(w http.ResponseWriter, status int, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": authErrorCode(status),
|
||||
"message": err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// authErrorCode maps an HTTP status to a stable error code string.
|
||||
func authErrorCode(status int) string {
|
||||
switch status {
|
||||
case http.StatusUnauthorized:
|
||||
return "unauthorized"
|
||||
case http.StatusForbidden:
|
||||
return "forbidden"
|
||||
default:
|
||||
return http.StatusText(status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func bearerHeader(token string) string {
|
||||
return "Bearer " + token
|
||||
}
|
||||
|
||||
func claimsHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := ClaimsFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "no claims in context", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-Claims-Sub", claims.Sub)
|
||||
w.Header().Set("X-Claims-Role", string(claims.Role))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func doAuth(h http.Handler, tok string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", bearerHeader(tok))
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func responseCode(t *testing.T, rr *httptest.ResponseRecorder, want int) {
|
||||
t.Helper()
|
||||
if rr.Code != want {
|
||||
t.Fatalf("status = %d, want %d (body: %s)", rr.Code, want, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func assertErrorCode(t *testing.T, rr *httptest.ResponseRecorder, want string) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(strings.NewReader(rr.Body.String())).Decode(&body); err != nil {
|
||||
t.Fatalf("decode error body: %v", err)
|
||||
}
|
||||
if body.Error.Code != want {
|
||||
t.Errorf("error.code = %q, want %q", body.Error.Code, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_NoBearer(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
rr := doAuth(svc.Authorize(RoleViewer)(claimsHandler(t)), "")
|
||||
responseCode(t, rr, http.StatusUnauthorized)
|
||||
assertErrorCode(t, rr, "unauthorized")
|
||||
}
|
||||
|
||||
func TestAuthorize_InvalidToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
rr := doAuth(svc.Authorize(RoleViewer)(claimsHandler(t)), "not-a-jwt")
|
||||
responseCode(t, rr, http.StatusUnauthorized)
|
||||
assertErrorCode(t, rr, "unauthorized")
|
||||
}
|
||||
|
||||
func TestAuthorize_ExpiredToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
tok, err := svc.Sign("u", RoleViewer, -time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
rr := doAuth(svc.Authorize(RoleViewer)(claimsHandler(t)), tok)
|
||||
responseCode(t, rr, http.StatusUnauthorized)
|
||||
assertErrorCode(t, rr, "unauthorized")
|
||||
}
|
||||
|
||||
func TestAuthorize_OwnerSatisfiesAdminAndViewer(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
tok, err := svc.Sign("u", RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
rr := doAuth(svc.Authorize(RoleAdmin, RoleViewer)(claimsHandler(t)), tok)
|
||||
responseCode(t, rr, http.StatusOK)
|
||||
if rr.Header().Get("X-Claims-Sub") != "u" {
|
||||
t.Errorf("X-Claims-Sub = %q, want u", rr.Header().Get("X-Claims-Sub"))
|
||||
}
|
||||
if rr.Header().Get("X-Claims-Role") != "owner" {
|
||||
t.Errorf("X-Claims-Role = %q, want owner", rr.Header().Get("X-Claims-Role"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorize_AdminSatisfiesViewer(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
tok, err := svc.Sign("u", RoleAdmin, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
rr := doAuth(svc.Authorize(RoleViewer)(claimsHandler(t)), tok)
|
||||
responseCode(t, rr, http.StatusOK)
|
||||
}
|
||||
|
||||
func TestAuthorize_AdminFailsOwner(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
tok, err := svc.Sign("u", RoleAdmin, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
rr := doAuth(svc.Authorize(RoleOwner)(claimsHandler(t)), tok)
|
||||
responseCode(t, rr, http.StatusForbidden)
|
||||
assertErrorCode(t, rr, "forbidden")
|
||||
}
|
||||
|
||||
func TestAuthorize_ViewerFailsAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
tok, err := svc.Sign("u", RoleViewer, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
rr := doAuth(svc.Authorize(RoleAdmin)(claimsHandler(t)), tok)
|
||||
responseCode(t, rr, http.StatusForbidden)
|
||||
assertErrorCode(t, rr, "forbidden")
|
||||
}
|
||||
|
||||
func TestAuthorize_AnyAuthenticated(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
tok, err := svc.Sign("u", RoleViewer, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
rr := doAuth(svc.Authorize()(claimsHandler(t)), tok)
|
||||
responseCode(t, rr, http.StatusOK)
|
||||
}
|
||||
|
||||
func TestAuthorize_BearerPrefixRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := New(testConfig(""))
|
||||
tok, err := svc.Sign("u", RoleViewer, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign error: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", tok) // no "Bearer " prefix
|
||||
rr := httptest.NewRecorder()
|
||||
svc.Authorize(RoleViewer)(claimsHandler(t)).ServeHTTP(rr, req)
|
||||
responseCode(t, rr, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func TestClaimsFromContext_Missing(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, ok := ClaimsFromContext(context.Background()); ok {
|
||||
t.Fatal("ClaimsFromContext on empty ctx should return ok=false")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseRole_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want Role
|
||||
}{
|
||||
{"owner", "owner", RoleOwner},
|
||||
{"admin", "admin", RoleAdmin},
|
||||
{"viewer", "viewer", RoleViewer},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := ParseRole(tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseRole(%q) error: %v", tc.in, err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("ParseRole(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRole_Invalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := ParseRole("superuser"); err == nil {
|
||||
t.Fatal("expected error for unknown role, got nil")
|
||||
} else if !errors.Is(err, ErrInvalidRole) {
|
||||
t.Fatalf("error = %v, want ErrInvalidRole", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRole_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
role Role
|
||||
want bool
|
||||
}{
|
||||
{"owner", RoleOwner, true},
|
||||
{"admin", RoleAdmin, true},
|
||||
{"viewer", RoleViewer, true},
|
||||
{"empty", "", false},
|
||||
{"unknown", Role("god"), false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tc.role.Valid(); got != tc.want {
|
||||
t.Errorf("Role(%q).Valid() = %v, want %v", tc.role, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasRole_Hierarchy(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
role Role
|
||||
required Role
|
||||
want bool
|
||||
}{
|
||||
{"owner satisfies owner", RoleOwner, RoleOwner, true},
|
||||
{"owner satisfies admin", RoleOwner, RoleAdmin, true},
|
||||
{"owner satisfies viewer", RoleOwner, RoleViewer, true},
|
||||
{"admin satisfies admin", RoleAdmin, RoleAdmin, true},
|
||||
{"admin satisfies viewer", RoleAdmin, RoleViewer, true},
|
||||
{"admin fails owner", RoleAdmin, RoleOwner, false},
|
||||
{"viewer satisfies viewer", RoleViewer, RoleViewer, true},
|
||||
{"viewer fails admin", RoleViewer, RoleAdmin, false},
|
||||
{"viewer fails owner", RoleViewer, RoleOwner, false},
|
||||
{"invalid fails viewer", Role("x"), RoleViewer, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := HasRole(tc.role, tc.required); got != tc.want {
|
||||
t.Errorf("HasRole(%q,%q) = %v, want %v", tc.role, tc.required, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user