v0.12.0 migrate sqlite3 to psql18, updated auth, deploy docs, account and replay updates
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
This commit is contained in:
1 parent
5d2be4abb2
commit
d0326b059e
66 files changed
+4016
-2433
No files matched your search
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user