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 "` 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) }) }