Echo errors, middleware and handlers used three different formats (message object, error object, bare string). One error handler now writes all of them the same way; status codes are unchanged.
166 lines
5.5 KiB
Go
166 lines
5.5 KiB
Go
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 echo.NewHTTPError(http.StatusBadRequest, "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 echo.NewHTTPError(http.StatusUnauthorized, "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 echo.NewHTTPError(http.StatusInternalServerError, "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 echo.NewHTTPError(http.StatusInternalServerError, "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 echo.NewHTTPError(http.StatusUnauthorized, "Authorization header required")
|
|
}
|
|
token, ok := middleware.BearerToken(c)
|
|
if !ok {
|
|
return echo.NewHTTPError(http.StatusUnauthorized, "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 echo.NewHTTPError(http.StatusInternalServerError, "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 echo.NewHTTPError(http.StatusInternalServerError, "Failed to cleanup sessions")
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "cleanup complete"})
|
|
}
|