mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
Go's base64 decoders are lenient in two independent ways, and both JWT verifiers used them raw, so one credential validated under several spellings. The unused low bits of the final quantum are ignored: an HMAC-SHA256 signature is 32 bytes in 43 base64url characters, leaving 2 slack bits and so 4 accepted strings per admin token; an RSA-2048 signature is 256 bytes in 342 characters, leaving 4 slack bits and so 16 per IAM token. Separately, \r and \n are skipped wherever they appear, which Encoding.Strict() does not close -- so Strict alone would not have been a fix. Every string-keyed structure over a credential silently splits under that: a denylist misses the spellings it was not handed, an audit log reads one actor as several, a rate limiter multiplies the budget by the class size. pkg/token holds the one canonical encoding. Decode round-trips rather than enumerating failure modes -- a segment is canonical exactly when it is the encoding of the bytes it decodes to -- and rejects rather than normalizes. token.ID is the hash to key revocation, audit and rate limiting on, so the identity is never the submitted string. Both verifiers now decode every segment through it. JWKS n/e stays lenient on purpose: public key material from the trusted IAM endpoint, where every spelling is the same key. Also in pkg/admin: sign returns raw MAC bytes instead of text, dropping an encode-then-decode round trip in the verify path; the header is now decoded and required to declare HS256, closing alg confusion under future refactor; and a comment referencing a long-deleted hashPassword is gone. Tests: exactly one spelling authenticates on both paths; revocation keyed on token.ID holds across all four; CR/LF, padding, standard alphabet and empty segments rejected. TestValidateToken_TamperedSignature substituted a fixed 'X' that was a no-op whenever the token already ended in one -- a 1-in-64 flake that this bug made a 1-in-16 one -- now derived from the token. LLM.md: admin auth section corrected (bcrypt cost 12, not SHA-256+salt; no ADMIN_* env vars exist; middleware strips those headers rather than setting them; package is not mounted). Co-authored-by: Hanzo Dev <[email protected]>
278 lines
8.1 KiB
Go
278 lines
8.1 KiB
Go
// Package admin provides JWT-based authentication for admin dashboard endpoints.
|
|
// Passwords are always stored as bcrypt hashes — never plaintext.
|
|
package admin
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"github.com/luxfi/broker/pkg/token"
|
|
)
|
|
|
|
// alg is the only signature algorithm this package issues or accepts. The
|
|
// verifier never dispatches on the token's own header — it requires this.
|
|
const alg = "HS256"
|
|
|
|
// contextKey is an unexported type for context keys in this package.
|
|
type contextKey string
|
|
|
|
const (
|
|
// ContextKeyAdminUser is the context key for the admin username.
|
|
ContextKeyAdminUser contextKey = "admin_user"
|
|
// ContextKeyAdminRole is the context key for the admin role.
|
|
ContextKeyAdminRole contextKey = "admin_role"
|
|
)
|
|
|
|
// UserFromContext returns the admin username from the request context.
|
|
func UserFromContext(ctx context.Context) string {
|
|
v, _ := ctx.Value(ContextKeyAdminUser).(string)
|
|
return v
|
|
}
|
|
|
|
// RoleFromContext returns the admin role from the request context.
|
|
func RoleFromContext(ctx context.Context) string {
|
|
v, _ := ctx.Value(ContextKeyAdminRole).(string)
|
|
return v
|
|
}
|
|
|
|
// Admin represents an admin user with hashed credentials.
|
|
type Admin struct {
|
|
Username string `json:"username"`
|
|
PasswordHash string `json:"-"` // bcrypt hash
|
|
Role string `json:"role"` // super_admin, admin, reviewer
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// Store manages admin users. In production, back this with a database.
|
|
type Store struct {
|
|
mu sync.RWMutex
|
|
admins map[string]*Admin // username -> Admin
|
|
secret []byte // JWT signing secret
|
|
}
|
|
|
|
// NewStore creates an admin store with the given JWT signing secret.
|
|
func NewStore(jwtSecret string) *Store {
|
|
return &Store{
|
|
admins: make(map[string]*Admin),
|
|
secret: []byte(jwtSecret),
|
|
}
|
|
}
|
|
|
|
// AddAdmin registers an admin user. Password is hashed with bcrypt before storage.
|
|
func (s *Store) AddAdmin(username, password, role string) error {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
|
if err != nil {
|
|
return fmt.Errorf("hash password: %w", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.admins[username] = &Admin{
|
|
Username: username,
|
|
PasswordHash: string(hash),
|
|
Role: role,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Authenticate validates admin credentials and returns a JWT token.
|
|
func (s *Store) Authenticate(username, password string) (string, error) {
|
|
s.mu.RLock()
|
|
admin, ok := s.admins[username]
|
|
s.mu.RUnlock()
|
|
|
|
if !ok {
|
|
return "", fmt.Errorf("invalid credentials")
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
|
|
return "", fmt.Errorf("invalid credentials")
|
|
}
|
|
|
|
return s.generateJWT(admin)
|
|
}
|
|
|
|
// ValidateToken validates a JWT and returns the claims.
|
|
//
|
|
// Every segment is decoded canonically: a token is its bytes, and exactly one
|
|
// string encodes those bytes. Accepting a second spelling would hand out one
|
|
// credential under several identities, which is what breaks a denylist.
|
|
func (s *Store) ValidateToken(tokenStr string) (*Claims, error) {
|
|
parts := strings.Split(tokenStr, ".")
|
|
if len(parts) != 3 {
|
|
return nil, fmt.Errorf("invalid token format")
|
|
}
|
|
|
|
headerJSON, err := token.Decode(parts[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid token header")
|
|
}
|
|
var header struct {
|
|
Alg string `json:"alg"`
|
|
}
|
|
if err := json.Unmarshal(headerJSON, &header); err != nil || header.Alg != alg {
|
|
return nil, fmt.Errorf("invalid token header")
|
|
}
|
|
|
|
// Constant-time compare of raw MAC bytes — never of their text.
|
|
sig, err := token.Decode(parts[2])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid token signature")
|
|
}
|
|
if !hmac.Equal(sig, sign([]byte(parts[0]+"."+parts[1]), s.secret)) {
|
|
return nil, fmt.Errorf("invalid token signature")
|
|
}
|
|
|
|
claimsJSON, err := token.Decode(parts[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid token payload")
|
|
}
|
|
|
|
var claims Claims
|
|
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
|
|
return nil, fmt.Errorf("invalid token claims")
|
|
}
|
|
|
|
// Check expiration
|
|
if time.Now().Unix() > claims.Exp {
|
|
return nil, fmt.Errorf("token expired")
|
|
}
|
|
|
|
return &claims, nil
|
|
}
|
|
|
|
// Claims are the JWT payload fields.
|
|
type Claims struct {
|
|
Sub string `json:"sub"` // username
|
|
Role string `json:"role"` // admin role
|
|
Iat int64 `json:"iat"` // issued at
|
|
Exp int64 `json:"exp"` // expires at
|
|
}
|
|
|
|
// Middleware returns HTTP middleware that validates admin JWT tokens.
|
|
// Tokens are passed as: Authorization: Bearer <token>
|
|
// Claims are stored in the request context, not HTTP headers.
|
|
func Middleware(store *Store) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Strip any incoming claim headers to prevent injection
|
|
r.Header.Del("X-Admin-User")
|
|
r.Header.Del("X-Admin-Role")
|
|
|
|
auth := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(auth, "Bearer ") {
|
|
writeAdminError(w, http.StatusUnauthorized, "admin token required")
|
|
return
|
|
}
|
|
token := strings.TrimPrefix(auth, "Bearer ")
|
|
|
|
claims, err := store.ValidateToken(token)
|
|
if err != nil {
|
|
writeAdminError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
// Pass claims through request context — never via headers
|
|
ctx := context.WithValue(r.Context(), ContextKeyAdminUser, claims.Sub)
|
|
ctx = context.WithValue(ctx, ContextKeyAdminRole, claims.Role)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *Store) generateJWT(admin *Admin) (string, error) {
|
|
header := token.Encode([]byte(`{"alg":"` + alg + `","typ":"JWT"}`))
|
|
|
|
claims := Claims{
|
|
Sub: admin.Username,
|
|
Role: admin.Role,
|
|
Iat: time.Now().Unix(),
|
|
Exp: time.Now().Add(24 * time.Hour).Unix(),
|
|
}
|
|
claimsJSON, _ := json.Marshal(claims)
|
|
payload := token.Encode(claimsJSON)
|
|
|
|
signingInput := header + "." + payload
|
|
return signingInput + "." + token.Encode(sign([]byte(signingInput), s.secret)), nil
|
|
}
|
|
|
|
// sign returns the raw MAC. Bytes are the value; encoding happens only at the
|
|
// wire boundary, so nothing downstream can compare or key on the text.
|
|
func sign(data, secret []byte) []byte {
|
|
mac := hmac.New(sha256.New, secret)
|
|
mac.Write(data)
|
|
return mac.Sum(nil)
|
|
}
|
|
|
|
// LoginHandler returns an http.HandlerFunc that accepts POST {username, password}
|
|
// and returns {token: "jwt..."} on success.
|
|
func LoginHandler(store *Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeAdminError(w, http.StatusMethodNotAllowed, "POST required")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeAdminError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Username == "" || req.Password == "" {
|
|
writeAdminError(w, http.StatusBadRequest, "username and password required")
|
|
return
|
|
}
|
|
|
|
token, err := store.Authenticate(req.Username, req.Password)
|
|
if err != nil {
|
|
writeAdminError(w, http.StatusUnauthorized, "invalid credentials")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"token": token})
|
|
}
|
|
}
|
|
|
|
// VerifyHandler returns an http.HandlerFunc that validates the Bearer token
|
|
// from the Authorization header and returns the decoded claims on success.
|
|
func VerifyHandler(store *Store) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
auth := r.Header.Get("Authorization")
|
|
if !strings.HasPrefix(auth, "Bearer ") {
|
|
writeAdminError(w, http.StatusUnauthorized, "token required")
|
|
return
|
|
}
|
|
token := strings.TrimPrefix(auth, "Bearer ")
|
|
|
|
claims, err := store.ValidateToken(token)
|
|
if err != nil {
|
|
writeAdminError(w, http.StatusUnauthorized, err.Error())
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(claims)
|
|
}
|
|
}
|
|
|
|
func writeAdminError(w http.ResponseWriter, status int, msg string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
|
}
|