Files
bookhoard/internal/router/router.go
T
john-okeefe 542fbea313 fix(auth): correct JWT token lookup to strip Bearer prefix
The TokenLookup config was missing the Bearer prefix stripper, causing
all authenticated requests to fail with 'token is malformed'. The JWT
library was trying to decode 'Bearer eyJh...' as a token, failing at
the space character.

Changed from: 'cookie:token,header:Authorization'
Changed to:   'cookie:token,header:Authorization:Bearer '

This fixes all integration tests that use Bearer token authentication.
2026-02-20 17:03:53 -05:00

246 lines
7.5 KiB
Go

package router
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/middleware"
ratelimit "bookhoard/internal/middleware"
"bookhoard/internal/services"
"bookhoard/internal/sync"
"bookhoard/templates"
"bytes"
"log"
"net/http"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
echojwt "github.com/labstack/echo-jwt/v4"
"github.com/labstack/echo/v4"
echomiddleware "github.com/labstack/echo/v4/middleware"
)
// CustomValidator wraps the go-playground validator
type CustomValidator struct {
validator *validator.Validate
}
func (cv *CustomValidator) Validate(i interface{}) error {
return cv.validator.Struct(i)
}
// Config holds all dependencies needed for route registration
type Config struct {
Echo *echo.Echo
Queries *database.Queries
Cfg *config.Config
DBPool interface{} // pgxpool.Pool interface
AuthHandler *handlers.AuthHandler
LibraryHandler *handlers.LibraryHandler
DeviceHandler *handlers.DeviceHandler
MediaHandler *handlers.MediaHandler
MatchingHandler *handlers.MatchingHandler
KOReaderHandler *handlers.KOReaderHandler
WSHandler *handlers.WSHandler
ConflictHandler *handlers.ConflictHandler
AnalyticsHandler *handlers.AnalyticsHandler
QueueHandler *handlers.QueueHandler
CollectionHandler *handlers.CollectionHandler
DashboardHandler *handlers.DashboardHandler
DashboardService *services.DashboardService
OPDSHandler *handlers.OPDSHandler
SystemSettingsHandler *handlers.SystemSettingsHandler
ConnManager *sync.ConnectionManager
QueueProcessor *sync.SyncQueueProcessor
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
LoginTracker *ratelimit.LoginAttemptTracker
ScannerHandler *handlers.Handler
}
// createJWTMiddleware creates a JWT middleware with proper user context setup
func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
return echojwt.WithConfig(echojwt.Config{
SigningKey: []byte(cfg.Cfg.JWTSecret),
ContextKey: "user",
TokenLookup: "cookie:token,header:Authorization:Bearer ",
SuccessHandler: func(c echo.Context) {
token := c.Get("user").(*jwt.Token)
claims := token.Claims.(jwt.MapClaims)
c.Set("user_id", claims["user_id"])
c.Set("user_role", claims["user_role"])
c.Set("user_email", claims["user_email"])
c.Set("user_username", claims["user_username"])
// Parse UUID from string claims
userIDStr, _ := claims["user_id"].(string)
userUUID, err := uuid.Parse(userIDStr)
if err != nil {
c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user ID in token"})
return
}
c.Set("user", database.Users{
ID: pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true},
Email: claims["user_email"].(string),
Username: claims["user_username"].(string),
Role: claims["user_role"].(string),
})
},
ErrorHandler: func(c echo.Context, err error) error {
// Check if this is a page request (browser navigation)
if wantsHTML(c.Request().Header) {
// Page request → Redirect to login with message
loginURL := "/login?session=expired"
return c.Redirect(http.StatusFound, loginURL)
}
// API request → Return JSON error
return c.JSON(http.StatusUnauthorized, map[string]string{
"error": "session_expired",
"message": "Your session has expired. Please log in again.",
})
},
})
}
// ensureUserExistsMiddleware checks if the authenticated user still exists in the database
func ensureUserExistsMiddleware(cfg *Config) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
userIDStr, ok := c.Get("user_id").(string)
if !ok {
return next(c)
}
userUUID, err := uuid.Parse(userIDStr)
if err != nil {
log.Printf("Invalid UUID in user existence check: %v", err)
return next(c)
}
// Check if user exists in database
_, err = cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
if err != nil {
log.Printf("User not found in database: %s", userIDStr)
// Clear invalid cookie
c.SetCookie(&http.Cookie{
Name: "token",
Value: "",
Expires: time.Now().Add(-24 * time.Hour),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
// Redirect to login with session=invalid
return c.Redirect(http.StatusFound, "/login?session=invalid")
}
return next(c)
}
}
}
// wantsHTML determines if the request expects HTML response
func wantsHTML(header http.Header) bool {
// Check Accept header
accept := header.Get("Accept")
if accept != "" && (accept == "text/html" || strings.Contains(accept, "text/html")) {
return true
}
// Check HTMX request
if header.Get("HX-Request") == "true" {
return true
}
// Check for AJAX requests (should get JSON)
if header.Get("X-Requested-With") == "XMLHttpRequest" {
return false
}
// Default to JSON for API routes
return false
}
// RegisterRoutes registers all application routes
func RegisterRoutes(cfg *Config) *handlers.Handler {
e := cfg.Echo
// Set up validator
v := validator.New()
if err := ratelimit.RegisterPasswordValidation(v); err != nil {
log.Fatal("Failed to register password validator:", err)
}
e.Validator = &CustomValidator{validator: v}
// Global middleware
e.Use(echomiddleware.Logger())
e.Use(echomiddleware.Recover())
e.Use(echomiddleware.CORS())
e.Use(ratelimit.RequestTracingMiddleware(cfg.Cfg))
// Rate limiter
rateLimiterConfig := ratelimit.RateLimiterConfig{
Enabled: cfg.Cfg.RateLimitEnabled,
RequestsPerMinute: cfg.Cfg.RequestsPerMinute,
CleanupInterval: 5 * time.Minute,
}
rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)
rateLimitMiddleware := ratelimit.RateLimiterMiddleware(rateLimiter)
// Register core application routes (collections, devices, media, etc.) - ONCE
jwtMiddleware := createJWTMiddleware(cfg)
protected := e.Group("/api", jwtMiddleware)
// Create scanner handler for scanner routes and progress routes
scannerHandler := handlers.SetupRoutes(protected, cfg.Queries, cfg.ConnManager, cfg.QueueProcessor)
cfg.ScannerHandler = scannerHandler
// Register route groups
registerAuthRoutes(cfg, rateLimitMiddleware)
registerLibraryRoutes(cfg)
registerDeviceRoutes(cfg)
registerSyncRoutes(cfg)
registerCollectionsRoutes(cfg)
registerDashboardRoutes(cfg)
registerMediaRoutes(cfg)
registerSearchRoutes(cfg)
registerMatchingRoutes(cfg)
registerConflictRoutes(cfg)
registerAnalyticsRoutes(cfg)
registerQueueRoutes(cfg)
registerOPDSRoutes(cfg)
registerWebSocketRoutes(cfg)
registerFrontendRoutes(cfg)
registerDocumentationRoutes(cfg)
e.Static("/static", "web/static")
// Catch-all 404 handler - must be last
e.GET("/*", func(c echo.Context) error {
var buf bytes.Buffer
err := templates.ErrorPage("Page not found", "404").Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusNotFound, buf.String())
})
// Start background tasks (queue processor and connection cleanup)
scannerHandler.StartBackgroundTasks()
// Register progress routes with actual handler
registerProgressRoutes(cfg, scannerHandler)
// Register scanner routes (admin only)
admin := protected.Group("", handlers.AdminMiddleware)
registerScannerRoutes(admin, scannerHandler)
return scannerHandler
}