- Add auth HTTP handlers (login, me, logout) with proper JSON responses - Enforce HS256 via jwt.WithValidMethods to prevent algorithm confusion attacks - Add context helpers for extracting operator ID and role from JWT claims - Add comprehensive auth test suite (11 unit tests + 6 integration tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
126 lines
3.4 KiB
Go
126 lines
3.4 KiB
Go
// Package auth provides operator authentication for the Felt tournament engine.
|
|
// It implements PIN-based login with JWT issuance and rate limiting.
|
|
package auth
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"fmt"
|
|
"log"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// Claims holds the JWT claims for an authenticated operator.
|
|
type Claims struct {
|
|
OperatorID string `json:"sub"`
|
|
Role string `json:"role"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// JWTService handles JWT token creation and validation.
|
|
type JWTService struct {
|
|
signingKey []byte
|
|
expiry time.Duration
|
|
}
|
|
|
|
// NewJWTService creates a JWT service with the given signing key and token expiry.
|
|
func NewJWTService(signingKey []byte, expiry time.Duration) *JWTService {
|
|
return &JWTService{
|
|
signingKey: signingKey,
|
|
expiry: expiry,
|
|
}
|
|
}
|
|
|
|
// NewToken creates an HS256-signed JWT with sub (operator ID) and role claims.
|
|
func (s *JWTService) NewToken(operatorID, role string) (string, error) {
|
|
if operatorID == "" {
|
|
return "", fmt.Errorf("jwt: empty operator ID")
|
|
}
|
|
if role == "" {
|
|
return "", fmt.Errorf("jwt: empty role")
|
|
}
|
|
|
|
now := time.Now()
|
|
claims := Claims{
|
|
OperatorID: operatorID,
|
|
Role: role,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
Subject: operatorID,
|
|
IssuedAt: jwt.NewNumericDate(now),
|
|
ExpiresAt: jwt.NewNumericDate(now.Add(s.expiry)),
|
|
},
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
tokenStr, err := token.SignedString(s.signingKey)
|
|
if err != nil {
|
|
return "", fmt.Errorf("jwt: sign token: %w", err)
|
|
}
|
|
|
|
return tokenStr, nil
|
|
}
|
|
|
|
// ValidateToken parses and validates a JWT token string, returning the claims.
|
|
// Enforces HS256 via jwt.WithValidMethods to prevent algorithm confusion attacks.
|
|
func (s *JWTService) ValidateToken(tokenStr string) (*Claims, error) {
|
|
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
|
return s.signingKey, nil
|
|
}, jwt.WithValidMethods([]string{"HS256"}))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("jwt: parse token: %w", err)
|
|
}
|
|
|
|
claims, ok := token.Claims.(*Claims)
|
|
if !ok || !token.Valid {
|
|
return nil, fmt.Errorf("jwt: invalid token claims")
|
|
}
|
|
|
|
if claims.OperatorID == "" {
|
|
return nil, fmt.Errorf("jwt: missing operator ID in token")
|
|
}
|
|
|
|
return claims, nil
|
|
}
|
|
|
|
// LoadOrCreateSigningKey loads the JWT signing key from the _config table.
|
|
// If no key exists, generates a random 256-bit key and persists it.
|
|
// This ensures keys survive server restarts.
|
|
func LoadOrCreateSigningKey(db *sql.DB) ([]byte, error) {
|
|
// Ensure _config table exists
|
|
_, err := db.Exec(`CREATE TABLE IF NOT EXISTS _config (
|
|
key TEXT PRIMARY KEY,
|
|
value BLOB NOT NULL,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
)`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("jwt: create _config table: %w", err)
|
|
}
|
|
|
|
// Try to load existing key
|
|
var key []byte
|
|
err = db.QueryRow("SELECT value FROM _config WHERE key = 'jwt_signing_key'").Scan(&key)
|
|
if err == nil && len(key) == 32 {
|
|
log.Printf("auth: JWT signing key loaded from database")
|
|
return key, nil
|
|
}
|
|
|
|
// Generate new random key
|
|
key = make([]byte, 32)
|
|
if _, err := rand.Read(key); err != nil {
|
|
return nil, fmt.Errorf("jwt: generate signing key: %w", err)
|
|
}
|
|
|
|
// Persist to database
|
|
_, err = db.Exec(
|
|
"INSERT OR REPLACE INTO _config (key, value) VALUES ('jwt_signing_key', ?)",
|
|
key,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("jwt: persist signing key: %w", err)
|
|
}
|
|
|
|
log.Printf("auth: JWT signing key generated and persisted")
|
|
return key, nil
|
|
}
|