Files
MusicServer/internal/server/token_handler.go
T
Sansan 973ebf224b
CI / integration (push) Successful in 49s
CI / docker (push) Successful in 1m0s
CI / test (push) Successful in 1m6s
CI / publish (push) Skipped
Generate the OpenAPI spec with Huma instead of swag
The spec is now built from the same calls that register the routes, so it
can't drift from the router like the swag comments did (all /api/v1
routes were missing).

- /api/v1 endpoints are typed Huma operations; the spec is OpenAPI 3.1 at
  /openapi.json (3.0 at /openapi-3.0.json, YAML too), docs at /docs.
  /openapi and /swagger/* redirect there
- Bearer token security is declared per operation; token creation and
  the new GET /api/v1/health are public
- Legacy routes use the same handlers, are hidden from the spec and keep
  the Deprecation header
- /sync/reset is hidden (debug only); the /sync/new, /sync/quick and
  /sync/new/full aliases are removed (use /sync and /sync/full)
- Invalid parameters return 422 (validation); every error is
  {"error": "..."}; empty lists are [] instead of null
- /sync/progress is documented as progress or result (oneOf)
- Tests validate the spec and check real responses of every documented
  operation against it (kin-openapi)
- Removed swag annotations, cmd/docs, echo-swagger and the swag tool
2026-10-06 16:31:06 +02:00

132 lines
4.5 KiB
Go

package server
import (
"context"
"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/danielgtaylor/huma/v2"
"github.com/jackc/pgx/v5/pgtype"
"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,omitempty" doc:"API password, required when the server has one configured"`
ClientType string `json:"client_type,omitempty" doc:"Kind of client, for example web, godot or api" default:"web"`
}
// TokenResponse represents the response with a new token
type TokenResponse struct {
Token string `json:"token" doc:"Send as Authorization: Bearer <token>"`
ExpiresAt time.Time `json:"expires_at"`
ClientType string `json:"client_type"`
}
type tokenInput struct {
Body TokenRequest `required:"false"`
}
type statusMessage struct {
Status string `json:"status"`
}
func generateToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
func (s *Server) passwordValid(password string) bool {
if s.cfg.DisableSessionAuth || s.cfg.APIPassword == "" {
return true
}
return subtle.ConstantTimeCompare([]byte(password), []byte(s.cfg.APIPassword)) == 1
}
func (s *Server) registerToken(a apis) {
create := huma.Operation{
OperationID: "create-token",
Method: http.MethodPost,
Path: "/token",
Summary: "Create session token",
Description: "Returns a token for the other /api/v1 endpoints, valid for 24 hours. " +
"Requires the API password when the server has one. Limited to 10 requests per minute per client.",
Tags: []string{"auth"},
Errors: []int{http.StatusUnauthorized, http.StatusTooManyRequests},
Middlewares: huma.Middlewares{middleware.RateLimit(a.v1, 10, time.Minute)},
}
register(s, a, route{op: create, public: true},
func(ctx context.Context, in *tokenInput) (*jsonBody[TokenResponse], error) {
client := middleware.ClientFrom(ctx)
if !s.passwordValid(in.Body.Password) {
logging.GetLogger().Warn("Invalid password attempt for token creation",
zap.String("ip", client.IP), zap.String("user_agent", client.UserAgent))
return nil, huma.Error401Unauthorized("Invalid password")
}
clientType := in.Body.ClientType
if clientType == "" {
clientType = "web"
}
token, err := generateToken()
if err != nil {
logging.GetLogger().Error("Failed to generate token", zap.Error(err))
return nil, huma.Error500InternalServerError("Failed to generate token")
}
session, err := s.q.CreateSession(ctx, repository.CreateSessionParams{
Token: token,
IpAddress: client.IP,
UserAgent: client.UserAgent,
ClientType: &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 nil, huma.Error500InternalServerError("Failed to create session")
}
return ok(TokenResponse{Token: session.Token, ExpiresAt: session.ExpiresAt.Time, ClientType: clientType}, nil)
})
register(s, a, route{op: huma.Operation{
OperationID: "delete-token",
Method: http.MethodDelete,
Path: "/token",
Summary: "Invalidate session token",
Description: "Deletes the token used for this request.",
Tags: []string{"auth"},
}}, func(ctx context.Context, _ *struct{}) (*jsonBody[statusMessage], error) {
if err := s.q.DeleteSession(ctx, middleware.Token(ctx)); err != nil {
logging.GetLogger().Error("Failed to delete session", zap.Error(err))
return nil, huma.Error500InternalServerError("Failed to invalidate token")
}
return ok(statusMessage{Status: "token invalidated"}, nil)
})
register(s, a, route{op: huma.Operation{
OperationID: "cleanup-tokens",
Method: http.MethodPost,
Path: "/token/cleanup",
Summary: "Remove expired tokens",
Description: "Deletes all expired session tokens.",
Tags: []string{"auth"},
}}, func(ctx context.Context, _ *struct{}) (*jsonBody[statusMessage], error) {
if err := s.q.DeleteExpiredSessions(ctx); err != nil {
logging.GetLogger().Error("Failed to cleanup sessions", zap.Error(err))
return nil, huma.Error500InternalServerError("Failed to cleanup sessions")
}
return ok(statusMessage{Status: "cleanup complete"}, nil)
})
}