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