package server import ( "crypto/rand" "crypto/subtle" "encoding/base64" "net/http" "time" "music-server/internal/db/repository" "music-server/internal/logging" "music-server/internal/server/middleware" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" "go.uber.org/zap" ) const tokenLifetime = 24 * time.Hour // TokenRequest represents a request to generate a new token type TokenRequest struct { Password string `json:"password"` // API password, required when API_PASSWORD is set 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 creates and removes session tokens. type TokenHandler struct { q repository.Querier apiPassword string disableSessionAuth bool } // NewTokenHandler creates a TokenHandler. An empty apiPassword accepts any password. func NewTokenHandler(q repository.Querier, apiPassword string, disableSessionAuth bool) *TokenHandler { return &TokenHandler{q: q, apiPassword: apiPassword, disableSessionAuth: disableSessionAuth} } func generateToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return base64.URLEncoding.EncodeToString(b), nil } func (h *TokenHandler) passwordValid(password string) bool { if h.disableSessionAuth || h.apiPassword == "" { return true } return subtle.ConstantTimeCompare([]byte(password), []byte(h.apiPassword)) == 1 } // CreateTokenHandler creates a new session token // // @Summary Create session token // @Description Returns a new session token for API access. Requires the API password when one is configured. // @Tags auth // @Accept json // @Produce json // @Param request body TokenRequest true "Password and client type" // @Success 200 {object} TokenResponse // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 429 {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 !h.passwordValid(req.Password) { logging.GetLogger().Warn("Invalid password attempt for token creation", zap.String("ip", c.RealIP()), zap.String("user_agent", c.Request().UserAgent())) return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"}) } if req.ClientType == "" { req.ClientType = "web" } token, err := generateToken() if err != nil { logging.GetLogger().Error("Failed to generate token", zap.Error(err)) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate token"}) } session, err := h.q.CreateSession(c.Request().Context(), repository.CreateSessionParams{ Token: token, IpAddress: c.RealIP(), UserAgent: c.Request().UserAgent(), ClientType: &req.ClientType, ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(tokenLifetime), Valid: true}, }) if err != nil { logging.GetLogger().Error("Failed to create session", zap.Error(err)) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to create session"}) } clientType := req.ClientType if session.ClientType != nil { clientType = *session.ClientType } return c.JSON(http.StatusOK, TokenResponse{ Token: session.Token, ExpiresAt: session.ExpiresAt.Time, ClientType: clientType, }) } // DeleteTokenHandler invalidates a session token // // @Summary Invalidate session token // @Description Deletes the current session token // @Tags auth // @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 { if c.Request().Header.Get("Authorization") == "" { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Authorization header required"}) } token, ok := middleware.BearerToken(c) if !ok { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid authorization format"}) } if err := h.q.DeleteSession(c.Request().Context(), token); err != nil { logging.GetLogger().Error("Failed to delete session", zap.Error(err)) 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 // // @Summary Cleanup expired sessions // @Description Removes all expired session tokens from the database // @Tags auth // @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/cleanup [post] func (h *TokenHandler) CleanupExpiredSessionsHandler(c *echo.Context) error { if err := h.q.DeleteExpiredSessions(c.Request().Context()); err != nil { logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err)) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to cleanup sessions"}) } return c.JSON(http.StatusOK, map[string]string{"status": "cleanup complete"}) }