From a3f6f4f687972d82c4f81510592baf4dcee83599 Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 07:19:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20JWT(HS256=20stdlib)=20+=20RBAC(ow?= =?UTF-8?q?ner/admin/viewer)=20+=20API=20key=20+=20=E5=AF=86=E7=A0=81=20ha?= =?UTF-8?q?sh=EF=BC=9Bconfig=20=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/auth/apikey.go | 73 +++++++++++ internal/auth/apikey_test.go | 82 ++++++++++++ internal/auth/auth.go | 93 ++++++++++++++ internal/auth/jwt.go | 144 +++++++++++++++++++++ internal/auth/jwt_test.go | 224 +++++++++++++++++++++++++++++++++ internal/auth/password.go | 68 ++++++++++ internal/auth/password_test.go | 64 ++++++++++ internal/auth/rbac.go | 99 +++++++++++++++ internal/auth/rbac_test.go | 174 +++++++++++++++++++++++++ internal/auth/role_test.go | 95 ++++++++++++++ 10 files changed, 1116 insertions(+) create mode 100644 internal/auth/apikey.go create mode 100644 internal/auth/apikey_test.go create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/jwt.go create mode 100644 internal/auth/jwt_test.go create mode 100644 internal/auth/password.go create mode 100644 internal/auth/password_test.go create mode 100644 internal/auth/rbac.go create mode 100644 internal/auth/rbac_test.go create mode 100644 internal/auth/role_test.go diff --git a/internal/auth/apikey.go b/internal/auth/apikey.go new file mode 100644 index 0000000..06a53ac --- /dev/null +++ b/internal/auth/apikey.go @@ -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) +} diff --git a/internal/auth/apikey_test.go b/internal/auth/apikey_test.go new file mode 100644 index 0000000..c054cb3 --- /dev/null +++ b/internal/auth/apikey_test.go @@ -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") + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..8d272c0 --- /dev/null +++ b/internal/auth/auth.go @@ -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() +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..0d70a76 --- /dev/null +++ b/internal/auth/jwt.go @@ -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) +} diff --git a/internal/auth/jwt_test.go b/internal/auth/jwt_test.go new file mode 100644 index 0000000..cde0792 --- /dev/null +++ b/internal/auth/jwt_test.go @@ -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)) +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..242e62b --- /dev/null +++ b/internal/auth/password.go @@ -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$$". 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 +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..9d482f3 --- /dev/null +++ b/internal/auth/password_test.go @@ -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) + } + }) + } +} diff --git a/internal/auth/rbac.go b/internal/auth/rbac.go new file mode 100644 index 0000000..c424b49 --- /dev/null +++ b/internal/auth/rbac.go @@ -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) + } +} diff --git a/internal/auth/rbac_test.go b/internal/auth/rbac_test.go new file mode 100644 index 0000000..8bb544c --- /dev/null +++ b/internal/auth/rbac_test.go @@ -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") + } +} diff --git a/internal/auth/role_test.go b/internal/auth/role_test.go new file mode 100644 index 0000000..0b844d4 --- /dev/null +++ b/internal/auth/role_test.go @@ -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) + } + }) + } +}