v0.12.0 migrate sqlite3 to psql18, updated auth, deploy docs, account and replay updates
test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-26 20:58:19 -04:00
1 parent 5d2be4abb2
commit d0326b059e
66 files changed
+4016 -2433

No files matched your search

+10 -5
View File
@@ -91,22 +91,22 @@ func (h *HTTPHandler) RegisterHandler(c *fiber.Ctx) error {
req.Email = strings.ToLower(req.Email)
}
// Create the user and initial session atomically (temp by default via API).
user, sessionID, err := h.svc.RegisterUser(req.Username, req.Email, req.Password, false)
// Create the user and initial session atomically.
user, sessionID, err := h.svc.RegisterUser(req.Username, req.Email, req.Password)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
if errors.Is(err, service.ErrAtCapacity) || errors.Is(err, service.ErrPermanentSlotsFull) {
if errors.Is(err, service.ErrAtCapacity) || errors.Is(err, service.ErrAuthBusy) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "registration temporarily unavailable",
Code: core.ErrResourceLimit,
Details: err.Error(),
})
}
if strings.Contains(err.Error(), "already exists") {
if errors.Is(err, service.ErrUserExists) {
return c.Status(fiber.StatusConflict).JSON(core.ErrorResponse{
Error: "user already exists",
Code: core.ErrInvalidRequest,
@@ -194,6 +194,11 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
if errors.Is(err, service.ErrAuthBusy) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication temporarily unavailable", Code: core.ErrResourceLimit,
})
}
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid credentials",
Code: core.ErrInvalidRequest,
@@ -214,7 +219,7 @@ func (h *HTTPHandler) LoginHandler(c *fiber.Ctx) error {
UserID: user.UserID,
Username: user.Username,
Email: user.Email,
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
ExpiresAt: time.Now().Add(service.SessionTTL),
})
}
+53 -29
View File
@@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"chess/internal/server/core"
@@ -30,21 +29,58 @@ func NewHTTPHandler(proc *processor.Processor, svc *service.Service) *HTTPHandle
return &HTTPHandler{proc: proc, svc: svc}
}
func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRequests bool) *fiber.App {
// Create handler
h := NewHTTPHandler(proc, svc)
// Options configures the API application.
type Options struct {
DevMode bool
LogRequests bool
// TrustedProxies lists reverse-proxy IPs or CIDRs whose ProxyHeader value
// is the client address. Empty means the TCP peer is the client.
TrustedProxies []string
// ProxyHeader names a single-address header the proxy overwrites, such as
// X-Real-IP (nginx: proxy_set_header X-Real-IP $remote_addr). Avoid
// X-Forwarded-For: its first entry is supplied by the client.
ProxyHeader string
}
// Initialize Fiber app
app := fiber.New(fiber.Config{
// appConfig returns the Fiber configuration. Client-IP resolution only honors
// ProxyHeader on connections from a trusted proxy, so rate-limit keys cannot be
// chosen by a client that reaches the API directly.
func appConfig(opts Options) fiber.Config {
config := fiber.Config{
ErrorHandler: customErrorHandler,
ReadTimeout: 15 * time.Second,
WriteTimeout: 35 * time.Second,
IdleTimeout: 60 * time.Second,
})
}
if len(opts.TrustedProxies) > 0 && opts.ProxyHeader != "" {
config.EnableTrustedProxyCheck = true
config.TrustedProxies = opts.TrustedProxies
config.ProxyHeader = opts.ProxyHeader
config.EnableIPValidation = true
}
return config
}
// clientIP is the rate-limit key: the proxy-reported address from a trusted
// proxy, otherwise the TCP peer.
func clientIP(c *fiber.Ctx) string {
if ip := c.IP(); ip != "" {
return ip
}
return c.Context().RemoteIP().String()
}
func NewFiberApp(proc *processor.Processor, svc *service.Service, opts Options) *fiber.App {
// Create handler
h := NewHTTPHandler(proc, svc)
devMode := opts.DevMode
// Initialize Fiber app
app := fiber.New(appConfig(opts))
// Global middleware (order matters)
app.Use(recover.New())
if logRequests {
if opts.LogRequests {
app.Use(logger.New(logger.Config{
Format: "${time} HTTP ${status} ${method} ${path} ${latency}\n",
TimeFormat: time.RFC3339,
@@ -68,11 +104,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe
// Register: 5 req/min per IP
auth.Post("/register", limiter.New(limiter.Config{
Max: 5,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
Max: 5,
Expiration: 1 * time.Minute,
KeyGenerator: clientIP,
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
Error: "rate limit exceeded",
@@ -84,11 +118,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe
// Login: 10 req/min per IP
auth.Post("/login", limiter.New(limiter.Config{
Max: 10,
Expiration: 1 * time.Minute,
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP()
},
Max: 10,
Expiration: 1 * time.Minute,
KeyGenerator: clientIP,
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
Error: "rate limit exceeded",
@@ -113,17 +145,9 @@ func NewFiberApp(proc *processor.Processor, svc *service.Service, devMode, logRe
maxReq = rateLimitRate * 2
}
api.Use(limiter.New(limiter.Config{
Max: maxReq,
Expiration: 1 * time.Second,
KeyGenerator: func(c *fiber.Ctx) string {
if xff := c.Get("X-Forwarded-For"); xff != "" {
if idx := strings.Index(xff, ","); idx != -1 {
return strings.TrimSpace(xff[:idx])
}
return xff
}
return c.IP()
},
Max: maxReq,
Expiration: 1 * time.Second,
KeyGenerator: clientIP,
LimitReached: func(c *fiber.Ctx) error {
return c.Status(fiber.StatusTooManyRequests).JSON(core.ErrorResponse{
Error: "rate limit exceeded",
+61
View File
@@ -0,0 +1,61 @@
package http
import (
"io"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
)
func TestClientIPTrustsProxyHeaderOnlyFromTrustedPeers(t *testing.T) {
// app.Test connections originate from 0.0.0.0.
for name, test := range map[string]struct {
opts Options
want string
}{
"no proxy configured": {Options{ProxyHeader: "X-Real-IP"}, "0.0.0.0"},
"untrusted peer": {Options{TrustedProxies: []string{"10.0.0.1"}, ProxyHeader: "X-Real-IP"}, "0.0.0.0"},
"trusted peer": {Options{TrustedProxies: []string{"0.0.0.0/32"}, ProxyHeader: "X-Real-IP"}, "203.0.113.9"},
"trusted, header only": {Options{TrustedProxies: []string{"0.0.0.0"}, ProxyHeader: "X-Real-IP"}, "203.0.113.9"},
} {
app := fiber.New(appConfig(test.opts))
app.Get("/ip", func(c *fiber.Ctx) error { return c.SendString(clientIP(c)) })
request := httptest.NewRequest("GET", "/ip", nil)
request.Header.Set("X-Real-IP", "203.0.113.9")
request.Header.Set("X-Forwarded-For", "198.51.100.1") // never consulted
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(response.Body)
if string(body) != test.want {
t.Errorf("%s: client IP = %q, want %q", name, body, test.want)
}
}
// A trusted proxy that omits the header must not collapse clients into one key.
app := fiber.New(appConfig(Options{TrustedProxies: []string{"0.0.0.0"}, ProxyHeader: "X-Real-IP"}))
app.Get("/ip", func(c *fiber.Ctx) error { return c.SendString(clientIP(c)) })
response, err := app.Test(httptest.NewRequest("GET", "/ip", nil))
if err != nil {
t.Fatal(err)
}
if body, _ := io.ReadAll(response.Body); string(body) != "0.0.0.0" {
t.Errorf("missing proxy header: client IP = %q, want peer address", body)
}
}
func TestIsValidUUIDRequiresCanonicalForm(t *testing.T) {
for value, want := range map[string]bool{
"5579b47e-4d3b-4eb3-abbd-846fe94cb955": true,
"{5579b47e-4d3b-4eb3-abbd-846fe94cb955}": false,
"urn:uuid:5579b47e-4d3b-4eb3-abbd-846fe94cb955": false,
"5579b47e4d3b4eb3abbd846fe94cb955": false,
"not-a-uuid": false,
} {
if got := isValidUUID(value); got != want {
t.Errorf("isValidUUID(%q) = %v, want %v", value, got, want)
}
}
}
+35 -45
View File
@@ -2,12 +2,12 @@ package http
import (
"errors"
"strings"
"chess/internal/server/core"
"chess/internal/server/service"
"github.com/gofiber/fiber/v2"
"github.com/lixenwraith/auth"
)
// TokenValidator validates JWT tokens
@@ -16,69 +16,59 @@ type TokenValidator func(token string) (userID string, claims map[string]any, er
// AuthRequired enforces JWT authentication for protected endpoints
func AuthRequired(validateToken TokenValidator) fiber.Handler {
return func(c *fiber.Ctx) error {
token := extractBearerToken(c.Get("Authorization"))
if token == "" {
header := c.Get(fiber.HeaderAuthorization)
if header == "" {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "missing authorization token",
Code: core.ErrUnauthorized,
})
}
userID, claims, err := validateToken(token)
token, err := auth.ParseBearerToken(header)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid or expired token",
Code: core.ErrUnauthorized,
})
return invalidToken(c)
}
c.Locals("userID", userID)
if sessionID, ok := claims["session_id"].(string); ok {
c.Locals("sessionID", sessionID)
}
return c.Next()
return authenticate(c, validateToken, token)
}
}
// OptionalAuth permits an absent token but rejects an invalid token instead of
// silently downgrading an intended authenticated request to anonymous access.
// OptionalAuth permits an absent Authorization header but rejects a malformed
// or invalid one instead of silently downgrading an intended authenticated
// request to anonymous access.
func OptionalAuth(validateToken TokenValidator) fiber.Handler {
return func(c *fiber.Ctx) error {
token := extractBearerToken(c.Get("Authorization"))
if token == "" {
header := c.Get(fiber.HeaderAuthorization)
if header == "" {
return c.Next()
}
userID, claims, err := validateToken(token)
token, err := auth.ParseBearerToken(header)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid or expired token", Code: core.ErrUnauthorized,
return invalidToken(c)
}
return authenticate(c, validateToken, token)
}
}
func authenticate(c *fiber.Ctx, validateToken TokenValidator, token string) error {
userID, claims, err := validateToken(token)
if err != nil {
if errors.Is(err, service.ErrStorageDisabled) || errors.Is(err, service.ErrStorageUnavailable) {
return c.Status(fiber.StatusServiceUnavailable).JSON(core.ErrorResponse{
Error: "authentication storage unavailable", Code: core.ErrStorageUnavailable,
})
}
c.Locals("userID", userID)
if sessionID, ok := claims["session_id"].(string); ok {
c.Locals("sessionID", sessionID)
}
return c.Next()
return invalidToken(c)
}
c.Locals("userID", userID)
if sessionID, ok := claims["session_id"].(string); ok {
c.Locals("sessionID", sessionID)
}
return c.Next()
}
// extractBearerToken extracts JWT token from Authorization header
func extractBearerToken(header string) string {
const prefix = "Bearer "
if !strings.HasPrefix(header, prefix) {
return ""
}
return strings.TrimPrefix(header, prefix)
func invalidToken(c *fiber.Ctx) error {
return c.Status(fiber.StatusUnauthorized).JSON(core.ErrorResponse{
Error: "invalid or expired token", Code: core.ErrUnauthorized,
})
}
+33
View File
@@ -58,3 +58,36 @@ func TestAuthMiddlewareReportsStorageUnavailable(t *testing.T) {
}
}
}
func TestAuthMiddlewareParsesBearerHeaderStrictly(t *testing.T) {
validate := func(token string) (string, map[string]any, error) {
if token != "good.token.value" {
return "", nil, errors.New("invalid token")
}
return "user", map[string]any{"session_id": "session"}, nil
}
for _, middleware := range []func(TokenValidator) fiber.Handler{AuthRequired, OptionalAuth} {
app := fiber.New()
app.Get("/route", middleware(validate), func(c *fiber.Ctx) error {
return c.SendString(c.Locals("userID").(string))
})
for header, want := range map[string]int{
"Bearer good.token.value": fiber.StatusOK,
"bearer good.token.value": fiber.StatusOK, // RFC 6750 scheme is case-insensitive
"Basic dXNlcjpwYXNz": fiber.StatusUnauthorized,
"Bearer good token": fiber.StatusUnauthorized,
"Bearer ": fiber.StatusUnauthorized,
"good.token.value": fiber.StatusUnauthorized,
} {
request := httptest.NewRequest("GET", "/route", nil)
request.Header.Set("Authorization", header)
response, err := app.Test(request)
if err != nil {
t.Fatal(err)
}
if response.StatusCode != want {
t.Errorf("header %q: status = %d, want %d", header, response.StatusCode, want)
}
}
}
}
+7 -1
View File
@@ -96,7 +96,13 @@ func validationMiddleware(c *fiber.Ctx) error {
return c.Next()
}
// isValidUUID accepts only the canonical 36-character form the server issues.
// uuid.Parse also accepts braced, URN, and unhyphenated forms, which never
// match an in-memory game and are rejected by the database's uuid input.
func isValidUUID(s string) bool {
if len(s) != 36 {
return false
}
_, err := uuid.Parse(s)
return err == nil
}
}