diff --git a/internal/db/dbHelper.go b/internal/db/dbHelper.go index 8454eb2..8bcfc55 100644 --- a/internal/db/dbHelper.go +++ b/internal/db/dbHelper.go @@ -136,19 +136,23 @@ func Migrate_db(host string, port string, user string, password string, dbname s // logging.GetLogger().Error("Force migration error", zap.String("error", err.Error())) //} - err = m.Migrate(2) - //err = m.Up() // or m.Steps(2) if you want to explicitly set the number of migrations to run + // Use Up() to apply all pending migrations instead of Migrate(2) + err = m.Up() if err != nil { - logging.GetLogger().Error("Migration error", zap.String("error", err.Error())) + if err == migrate.ErrNoChange { + logging.GetLogger().Info("Database already up to date") + } else { + logging.GetLogger().Error("Migration error", zap.String("error", err.Error())) + } + } else { + versionAfter, _, err := m.Version() + if err != nil { + logging.GetLogger().Error("Failed to get migration version after", zap.String("error", err.Error())) + } else { + logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter)) + } } - versionAfter, _, err := m.Version() - if err != nil { - logging.GetLogger().Error("Failed to get migration version after", zap.String("error", err.Error())) - } - - logging.GetLogger().Info("Migration version after", zap.Uint("version", versionAfter)) - logging.GetLogger().Info("Migration completed") db.Close() diff --git a/internal/db/migrations/000004_create_sessions_table_and_indexes.down.sql b/internal/db/migrations/000004_create_sessions_table_and_indexes.down.sql new file mode 100644 index 0000000..b9f4a70 --- /dev/null +++ b/internal/db/migrations/000004_create_sessions_table_and_indexes.down.sql @@ -0,0 +1,24 @@ +-- Drop indexes for sessions table +DROP INDEX IF EXISTS idx_sessions_expires; +DROP INDEX IF EXISTS idx_sessions_token; +DROP INDEX IF EXISTS idx_sessions_ip; +DROP INDEX IF EXISTS idx_sessions_created; + +-- Drop sessions table +DROP TABLE IF EXISTS sessions; + +-- Drop performance indexes for song_list +DROP INDEX IF EXISTS idx_song_list_match_date; +DROP INDEX IF EXISTS idx_song_list_match_id; + +-- Drop performance indexes for song +DROP INDEX IF EXISTS idx_song_hash; +DROP INDEX IF EXISTS idx_song_path; +DROP INDEX IF EXISTS idx_song_game_id; +DROP INDEX IF EXISTS idx_song_game_id_song_name; + +-- Drop performance indexes for game +DROP INDEX IF EXISTS idx_game_deleted; +DROP INDEX IF EXISTS idx_game_hash; +DROP INDEX IF EXISTS idx_game_path; +DROP INDEX IF EXISTS idx_game_name; diff --git a/internal/db/migrations/000004_create_sessions_table_and_indexes.up.sql b/internal/db/migrations/000004_create_sessions_table_and_indexes.up.sql new file mode 100644 index 0000000..d96f734 --- /dev/null +++ b/internal/db/migrations/000004_create_sessions_table_and_indexes.up.sql @@ -0,0 +1,39 @@ +-- ============================================ +-- PERFORMANCE INDEXES FOR EXISTING TABLES +-- ============================================ + +-- Game table indexes +CREATE INDEX IF NOT EXISTS idx_game_deleted ON game(deleted) WHERE deleted IS NULL; +CREATE INDEX IF NOT EXISTS idx_game_hash ON game(hash); +CREATE INDEX IF NOT EXISTS idx_game_path ON game(path); +CREATE INDEX IF NOT EXISTS idx_game_name ON game(game_name); + +-- Song table indexes +CREATE INDEX IF NOT EXISTS idx_song_hash ON song(hash); +CREATE INDEX IF NOT EXISTS idx_song_path ON song(path); +CREATE INDEX IF NOT EXISTS idx_song_game_id ON song(game_id); +CREATE INDEX IF NOT EXISTS idx_song_game_id_song_name ON song(game_id, song_name); + +-- Song_list table indexes +CREATE INDEX IF NOT EXISTS idx_song_list_match_date ON song_list(match_date); +CREATE INDEX IF NOT EXISTS idx_song_list_match_id ON song_list(match_id); + +-- ============================================ +-- SESSIONS TABLE FOR TOKEN MANAGEMENT +-- ============================================ + +-- Create sessions table for tracking client tokens +CREATE TABLE sessions ( + token VARCHAR(64) PRIMARY KEY, + ip_address VARCHAR(45) NOT NULL, + user_agent TEXT NOT NULL, + client_type VARCHAR(20) DEFAULT 'web', + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes for fast lookup and cleanup +CREATE INDEX idx_sessions_expires ON sessions(expires_at); +CREATE INDEX idx_sessions_token ON sessions(token); +CREATE INDEX idx_sessions_ip ON sessions(ip_address); +CREATE INDEX idx_sessions_created ON sessions(created_at); diff --git a/internal/db/queries/session.sql b/internal/db/queries/session.sql new file mode 100644 index 0000000..7ed4be0 --- /dev/null +++ b/internal/db/queries/session.sql @@ -0,0 +1,23 @@ +-- name: CreateSession :one +INSERT INTO sessions (token, ip_address, user_agent, client_type, expires_at) +VALUES ($1, $2, $3, $4, $5) +RETURNING token, ip_address, user_agent, client_type, expires_at, created_at; + +-- name: GetSession :one +SELECT token, ip_address, user_agent, client_type, expires_at, created_at +FROM sessions +WHERE token = $1 +LIMIT 1; + +-- name: DeleteSession :exec +DELETE FROM sessions +WHERE token = $1; + +-- name: DeleteExpiredSessions :exec +DELETE FROM sessions +WHERE expires_at < NOW(); + +-- name: ListSessions :many +SELECT token, ip_address, user_agent, client_type, expires_at, created_at +FROM sessions +ORDER BY created_at DESC; diff --git a/internal/db/repository/models.go b/internal/db/repository/models.go index e562a2f..aed7c47 100644 --- a/internal/db/repository/models.go +++ b/internal/db/repository/models.go @@ -6,6 +6,8 @@ package repository import ( "time" + + "github.com/jackc/pgx/v5/pgtype" ) type Game struct { @@ -21,6 +23,15 @@ type Game struct { Hash string `json:"hash"` } +type Session struct { + Token string `json:"token"` + IpAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + ClientType *string `json:"client_type"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Song struct { GameID int32 `json:"game_id"` SongName string `json:"song_name"` diff --git a/internal/db/repository/session.sql.go b/internal/db/repository/session.sql.go new file mode 100644 index 0000000..b011790 --- /dev/null +++ b/internal/db/repository/session.sql.go @@ -0,0 +1,120 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: session.sql + +package repository + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createSession = `-- name: CreateSession :one +INSERT INTO sessions (token, ip_address, user_agent, client_type, expires_at) +VALUES ($1, $2, $3, $4, $5) +RETURNING token, ip_address, user_agent, client_type, expires_at, created_at +` + +type CreateSessionParams struct { + Token string `json:"token"` + IpAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + ClientType *string `json:"client_type"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` +} + +func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) { + row := q.db.QueryRow(ctx, createSession, + arg.Token, + arg.IpAddress, + arg.UserAgent, + arg.ClientType, + arg.ExpiresAt, + ) + var i Session + err := row.Scan( + &i.Token, + &i.IpAddress, + &i.UserAgent, + &i.ClientType, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec +DELETE FROM sessions +WHERE expires_at < NOW() +` + +func (q *Queries) DeleteExpiredSessions(ctx context.Context) error { + _, err := q.db.Exec(ctx, deleteExpiredSessions) + return err +} + +const deleteSession = `-- name: DeleteSession :exec +DELETE FROM sessions +WHERE token = $1 +` + +func (q *Queries) DeleteSession(ctx context.Context, token string) error { + _, err := q.db.Exec(ctx, deleteSession, token) + return err +} + +const getSession = `-- name: GetSession :one +SELECT token, ip_address, user_agent, client_type, expires_at, created_at +FROM sessions +WHERE token = $1 +LIMIT 1 +` + +func (q *Queries) GetSession(ctx context.Context, token string) (Session, error) { + row := q.db.QueryRow(ctx, getSession, token) + var i Session + err := row.Scan( + &i.Token, + &i.IpAddress, + &i.UserAgent, + &i.ClientType, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const listSessions = `-- name: ListSessions :many +SELECT token, ip_address, user_agent, client_type, expires_at, created_at +FROM sessions +ORDER BY created_at DESC +` + +func (q *Queries) ListSessions(ctx context.Context) ([]Session, error) { + rows, err := q.db.Query(ctx, listSessions) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Session + for rows.Next() { + var i Session + if err := rows.Scan( + &i.Token, + &i.IpAddress, + &i.UserAgent, + &i.ClientType, + &i.ExpiresAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/server/middleware/init.go b/internal/server/middleware/init.go new file mode 100644 index 0000000..bb94d76 --- /dev/null +++ b/internal/server/middleware/init.go @@ -0,0 +1,2 @@ +// Package middleware provides Echo middleware for the MusicServer application. +package middleware diff --git a/internal/server/middleware/token_auth.go b/internal/server/middleware/token_auth.go new file mode 100644 index 0000000..70f24cc --- /dev/null +++ b/internal/server/middleware/token_auth.go @@ -0,0 +1,77 @@ +package middleware + +import ( + "net/http" + "strings" + "time" + + "music-server/internal/db/repository" + "music-server/internal/logging" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/labstack/echo/v5" + "go.uber.org/zap" +) + +// TokenAuthMiddleware returns an Echo middleware that validates session tokens +func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + // Extract token from Authorization header + authHeader := c.Request().Header.Get("Authorization") + if authHeader == "" { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"}) + } + + // Bearer token format + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format. Use: Bearer "}) + } + + token := parts[1] + queries := repository.New(pool) + session, err := queries.GetSession(c.Request().Context(), token) + if err != nil { + logging.GetLogger().Warn("Invalid token attempt", + zap.String("token", token), + zap.String("ip", c.RealIP()), + zap.String("error", err.Error()), + ) + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid or expired token"}) + } + + // Check if token is expired + if time.Now().After(session.ExpiresAt.Time) { + // Clean up expired session in background + go func() { + queries.DeleteSession(c.Request().Context(), token) + }() + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Token expired"}) + } + + // Add session to request context for potential use by handlers + c.Set("session", session) + + return next(c) + } + } +} + +// TokenIPCheckMiddleware checks if the request IP matches the session IP +func TokenIPCheckMiddleware(next echo.HandlerFunc) echo.HandlerFunc { + return func(c *echo.Context) error { + sessionVal := c.Get("session") + if sessionVal == nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "No session in context"}) + } + session := sessionVal.(repository.Session) + if session.IpAddress != c.RealIP() { + logging.GetLogger().Warn("Token IP mismatch", + zap.String("token_ip", session.IpAddress), + zap.String("request_ip", c.RealIP()), + ) + } + return next(c) + } +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 4092a67..9b245c1 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -2,15 +2,17 @@ package server import ( "music-server/cmd/web" + "music-server/internal/db" + "music-server/internal/logging" + "music-server/internal/server/middleware" "net/http" "sort" "strings" "github.com/a-h/templ" "github.com/labstack/echo/v5" - "github.com/labstack/echo/v5/middleware" + echoMiddleware "github.com/labstack/echo/v5/middleware" echoSwagger "github.com/swaggo/echo-swagger/v2" - "music-server/internal/logging" "go.uber.org/zap" ) @@ -36,9 +38,9 @@ func (s *Server) RegisterRoutes() http.Handler { http.ServeFile(w, r, "cmd/docs/swagger.json") }))) e.Use(logging.RequestLogger()) - e.Use(middleware.Recover()) + e.Use(echoMiddleware.Recover()) - e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ AllowOrigins: []string{"https://*", "http://*"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, AllowHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, @@ -99,6 +101,33 @@ func (s *Server) RegisterRoutes() http.Handler { musicGroup.GET("/addQue", music.AddLatestToQue) musicGroup.GET("/addPlayed", music.AddLatestPlayed) + // ============================================ + // API v1 Routes with Token Authentication + // ============================================ + + // Create /api/v1 group + apiV1 := e.Group("/api/v1") + + // Public endpoints - no token required + apiV1.POST("/token", func(c *echo.Context) error { + return s.tokenHandler.CreateTokenHandler(c) + }) + apiV1.DELETE("/token", func(c *echo.Context) error { + return s.tokenHandler.DeleteTokenHandler(c) + }) + apiV1.POST("/token/cleanup", func(c *echo.Context) error { + return s.tokenHandler.CleanupExpiredSessionsHandler(c) + }) + + // Protected endpoints - require valid token + // Create token auth middleware with pool access + tokenAuthMiddleware := middleware.TokenAuthMiddleware(db.Dbpool) + + // Protected group with token authentication - will be used by VGMQ and Statistics API + _ = apiV1.Group("", tokenAuthMiddleware) + + // Note: Future protected endpoints (VGMQ, Statistics) will be added here + routes := e.Router().Routes() sort.Slice(routes, func(i, j int) bool { return routes[i].Path < routes[j].Path diff --git a/internal/server/server.go b/internal/server/server.go index 83973d4..b48b17d 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -14,7 +14,8 @@ import ( ) type Server struct { - port int + port int + tokenHandler *TokenHandler } var ( @@ -39,8 +40,13 @@ func NewServer() *http.Server { logger := logging.GetLogger() port, _ := strconv.Atoi(os.Getenv("PORT")) + + // Initialize token handler + tokenHandler := NewTokenHandler() + NewServer := &Server{ - port: port, + port: port, + tokenHandler: tokenHandler, } logger.Info("Starting server", diff --git a/internal/server/token_handler.go b/internal/server/token_handler.go new file mode 100644 index 0000000..910d147 --- /dev/null +++ b/internal/server/token_handler.go @@ -0,0 +1,172 @@ +package server + +import ( + "crypto/rand" + "encoding/base64" + "net/http" + "strings" + "time" + + "music-server/internal/db" + "music-server/internal/db/repository" + "music-server/internal/logging" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v5" + "go.uber.org/zap" +) + +// TokenRequest represents a request to generate a new token +type TokenRequest struct { + ClientType string `json:"client_type"` // Optional: "web", "mobile", "api" +} + +// TokenResponse represents the response with a new token +type TokenResponse struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + ClientType string `json:"client_type"` +} + +// TokenHandler contains the database pool for token operations +type TokenHandler struct { + pool *repository.Queries +} + +// NewTokenHandler creates a new token handler with database pool +func NewTokenHandler() *TokenHandler { + return &TokenHandler{ + pool: repository.New(db.Dbpool), + } +} + +// generateToken creates a new cryptographically secure token +func (h *TokenHandler) generateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} + +// CreateTokenHandler creates a new session token +// POST /api/v1/token +// +// @Summary Create session token +// @Description Returns a new session token for API access +// @Tags auth +// @Accept json +// @Produce json +// @Param request body TokenRequest true "Client type" +// @Success 200 {object} TokenResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/token [post] +func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error { + var req TokenRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) + } + + if req.ClientType == "" { + req.ClientType = "web" + } + + // Generate token + token, err := h.generateToken() + if err != nil { + logging.GetLogger().Error("Failed to generate token", zap.String("error", err.Error())) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate token"}) + } + + // Set expiration (24 hours from now) + expiresAt := time.Now().Add(24 * time.Hour) + clientType := req.ClientType + + // Store in database using sqlc-generated repository + session, err := h.pool.CreateSession(c.Request().Context(), repository.CreateSessionParams{ + Token: token, + IpAddress: c.RealIP(), + UserAgent: c.Request().UserAgent(), + ClientType: &clientType, + ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true}, + }) + if err != nil { + logging.GetLogger().Error("Failed to create session", zap.String("error", err.Error())) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create session"}) + } + + response := TokenResponse{ + Token: session.Token, + ExpiresAt: session.ExpiresAt.Time, + ClientType: *session.ClientType, + } + + return c.JSON(http.StatusOK, response) +} + +// DeleteTokenHandler invalidates a session token +// DELETE /api/v1/token +// +// @Summary Invalidate session token +// @Description Deletes the current session token +// @Tags auth +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer token" +// @Success 200 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/token [delete] +func (h *TokenHandler) DeleteTokenHandler(c *echo.Context) error { + authHeader := c.Request().Header.Get("Authorization") + if authHeader == "" { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"}) + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format"}) + } + + token := parts[1] + + // Delete session using sqlc-generated repository + err := h.pool.DeleteSession(c.Request().Context(), token) + if err != nil { + logging.GetLogger().Error("Failed to delete session", zap.String("error", err.Error())) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to invalidate token"}) + } + + return c.JSON(http.StatusOK, map[string]string{"status": "token invalidated"}) +} + +// CleanupExpiredSessionsHandler removes all expired sessions +// POST /api/v1/token/cleanup +// +// @Summary Cleanup expired sessions +// @Description Removes all expired session tokens from the database +// @Tags auth +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer token" +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/token/cleanup [post] +func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error { + // Verify token is valid first (using existing middleware) + // The middleware will have already validated the token + + err := h.pool.DeleteExpiredSessions(c.Request().Context()) + if err != nil { + logging.GetLogger().Error("Failed to cleanup sessions", zap.String("error", err.Error())) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to cleanup sessions"}) + } + + // Get count of deleted rows (DeleteExpiredSessions doesn't return count in the generated code) + // So we just return success + return c.JSON(http.StatusOK, map[string]interface{}{ + "status": "cleanup complete", + }) +}