feat(auth): JWT(HS256 stdlib) + RBAC(owner/admin/viewer) + API key + 密码 hash;config 校验

This commit is contained in:
Aska committed 2026-09-06 07:19:28 +08:00
1 parent 7f7cb8fbf7
commit a3f6f4f687
10 files changed
+1116

No files matched your search

+73
View File
@@ -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)
}
+82
View File
@@ -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")
}
}
+93
View File
@@ -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()
}
+144
View File
@@ -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)
}
+224
View File
@@ -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))
}
+68
View File
@@ -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
}
+64
View File
@@ -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)
}
})
}
}
+99
View File
@@ -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)
}
}
+174
View File
@@ -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")
}
}
+95
View File
@@ -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)
}
})
}
}