Files
silo-server/internal/jellycompat/handlers_auth.go
T

288 lines
12 KiB
Go

package jellycompat
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
)
type authenticateByNameRequest struct {
Username string `json:"Username"`
Pw string `json:"Pw"`
Password string `json:"Password"`
}
type userPolicyResponse struct {
IsAdministrator bool `json:"IsAdministrator"`
IsHidden bool `json:"IsHidden"`
EnableCollectionManagement bool `json:"EnableCollectionManagement"`
EnableSubtitleManagement bool `json:"EnableSubtitleManagement"`
EnableLyricManagement bool `json:"EnableLyricManagement"`
IsDisabled bool `json:"IsDisabled"`
EnableUserPreferenceAccess bool `json:"EnableUserPreferenceAccess"`
EnableRemoteControlOfOtherUsers bool `json:"EnableRemoteControlOfOtherUsers"`
EnableSharedDeviceControl bool `json:"EnableSharedDeviceControl"`
EnableRemoteAccess bool `json:"EnableRemoteAccess"`
EnableLiveTVManagement bool `json:"EnableLiveTvManagement"`
EnableLiveTVAccess bool `json:"EnableLiveTvAccess"`
EnableMediaPlayback bool `json:"EnableMediaPlayback"`
EnableAudioPlaybackTranscoding bool `json:"EnableAudioPlaybackTranscoding"`
EnableVideoPlaybackTranscoding bool `json:"EnableVideoPlaybackTranscoding"`
EnablePlaybackRemuxing bool `json:"EnablePlaybackRemuxing"`
ForceRemoteSourceTranscoding bool `json:"ForceRemoteSourceTranscoding"`
EnableContentDeletion bool `json:"EnableContentDeletion"`
EnableContentDownloading bool `json:"EnableContentDownloading"`
EnableSyncTranscoding bool `json:"EnableSyncTranscoding"`
EnableMediaConversion bool `json:"EnableMediaConversion"`
EnableAllDevices bool `json:"EnableAllDevices"`
EnableAllChannels bool `json:"EnableAllChannels"`
EnableAllFolders bool `json:"EnableAllFolders"`
InvalidLoginAttemptCount int `json:"InvalidLoginAttemptCount"`
LoginAttemptsBeforeLockout int `json:"LoginAttemptsBeforeLockout"`
MaxActiveSessions int `json:"MaxActiveSessions"`
EnablePublicSharing bool `json:"EnablePublicSharing"`
RemoteClientBitrateLimit int `json:"RemoteClientBitrateLimit"`
AuthenticationProviderID string `json:"AuthenticationProviderId"`
PasswordResetProviderID string `json:"PasswordResetProviderId"`
SyncPlayAccess string `json:"SyncPlayAccess"`
BlockedTags []string `json:"BlockedTags,omitempty"`
AllowedTags []string `json:"AllowedTags,omitempty"`
EnableContentDeletionFromFolders []string `json:"EnableContentDeletionFromFolders,omitempty"`
EnabledDevices []string `json:"EnabledDevices,omitempty"`
EnabledChannels []string `json:"EnabledChannels,omitempty"`
EnabledFolders []string `json:"EnabledFolders,omitempty"`
BlockedMediaFolders []string `json:"BlockedMediaFolders,omitempty"`
BlockedChannels []string `json:"BlockedChannels,omitempty"`
}
type userDTOResponse struct {
ID string `json:"Id"`
Name string `json:"Name"`
ServerID string `json:"ServerId"`
HasPassword bool `json:"HasPassword"`
HasConfiguredPassword bool `json:"HasConfiguredPassword"`
HasConfiguredEasyPassword bool `json:"HasConfiguredEasyPassword"`
Policy userPolicyResponse `json:"Policy"`
Configuration userConfigurationResponse `json:"Configuration"`
}
type userConfigurationResponse struct {
PlayDefaultAudioTrack bool `json:"PlayDefaultAudioTrack"`
DisplayMissingEpisodes bool `json:"DisplayMissingEpisodes"`
GroupedFolders []string `json:"GroupedFolders"`
SubtitleMode string `json:"SubtitleMode"`
DisplayCollectionsView bool `json:"DisplayCollectionsView"`
EnableLocalPassword bool `json:"EnableLocalPassword"`
OrderedViews []string `json:"OrderedViews"`
LatestItemsExcludes []string `json:"LatestItemsExcludes"`
MyMediaExcludes []string `json:"MyMediaExcludes"`
HidePlayedInLatest bool `json:"HidePlayedInLatest"`
RememberAudioSelections bool `json:"RememberAudioSelections"`
RememberSubtitleSelections bool `json:"RememberSubtitleSelections"`
EnableNextEpisodeAutoPlay bool `json:"EnableNextEpisodeAutoPlay"`
}
type authenticateByNameResponse struct {
AccessToken string `json:"AccessToken"`
ServerID string `json:"ServerId"`
User userDTOResponse `json:"User"`
SessionInfo *sessionInfoResponse `json:"SessionInfo,omitempty"`
}
type sessionInfoResponse struct {
PlayableMediaTypes []string `json:"PlayableMediaTypes"`
UserID string `json:"UserId"`
UserName string `json:"UserName"`
LastActivityDate string `json:"LastActivityDate"`
LastPlaybackCheckIn string `json:"LastPlaybackCheckIn"`
IsActive bool `json:"IsActive"`
SupportsMediaControl bool `json:"SupportsMediaControl"`
SupportsRemoteControl bool `json:"SupportsRemoteControl"`
HasCustomDeviceName bool `json:"HasCustomDeviceName"`
SupportedCommands []string `json:"SupportedCommands"`
ServerID string `json:"ServerId"`
}
type loginResolver interface {
Resolve(ctx context.Context, combinedUsername, password, userAgent, remoteIP string) (*Session, error)
}
// AuthHandler serves Jellyfin login/current-user routes.
type AuthHandler struct {
cfg *config.Config
loginResolver loginResolver
authenticator *Authenticator
}
// NewAuthHandler creates a new auth handler.
func NewAuthHandler(cfg *config.Config, loginResolver loginResolver, authenticator *Authenticator) *AuthHandler {
return &AuthHandler{
cfg: cfg,
loginResolver: loginResolver,
authenticator: authenticator,
}
}
// HandlePublicUsers serves GET /Users/Public.
func (h *AuthHandler) HandlePublicUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, []any{})
}
// HandleAuthenticateByName serves POST /Users/AuthenticateByName.
func (h *AuthHandler) HandleAuthenticateByName(w http.ResponseWriter, r *http.Request) {
var req authenticateByNameRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "BadRequest", "Invalid request body")
return
}
password := req.Pw
if password == "" {
password = req.Password
}
if req.Username == "" || password == "" {
writeError(w, http.StatusBadRequest, "BadRequest", "Username and password are required")
return
}
session, err := h.loginResolver.Resolve(r.Context(), req.Username, password, r.UserAgent(), clientip.FromContext(r.Context()))
if err != nil {
status, code, message := mapLoginError(err)
writeError(w, status, code, message)
return
}
writeJSON(w, http.StatusOK, authenticateByNameResponse{
AccessToken: session.Token,
ServerID: h.cfg.JellyfinCompat.ServerID,
User: h.userDTO(session),
SessionInfo: h.sessionInfo(session),
})
}
// HandleCurrentUser serves GET /Users/Me.
func (h *AuthHandler) HandleCurrentUser(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
writeJSON(w, http.StatusOK, h.userDTO(session))
}
// HandleUserByID serves GET /Users/{id}.
func (h *AuthHandler) HandleUserByID(w http.ResponseWriter, r *http.Request) {
session := SessionFromContext(r.Context())
if session == nil {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
if chi.URLParam(r, "id") != session.PseudoUserID.String() {
writeError(w, http.StatusNotFound, "NotFound", "User not found")
return
}
writeJSON(w, http.StatusOK, h.userDTO(session))
}
// HandleLogout serves POST /Sessions/Logout.
func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
token, ok := ExtractToken(r)
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized", "Missing authentication token")
return
}
h.authenticator.sessions.Delete(token)
w.WriteHeader(http.StatusNoContent)
}
func (h *AuthHandler) userDTO(session *Session) userDTOResponse {
name := session.Username
return userDTOResponse{
ID: session.PseudoUserID.String(),
Name: name,
ServerID: h.cfg.JellyfinCompat.ServerID,
HasPassword: true,
HasConfiguredPassword: true,
HasConfiguredEasyPassword: false,
Policy: userPolicyResponse{
IsAdministrator: false,
IsHidden: false,
EnableCollectionManagement: false,
EnableSubtitleManagement: false,
EnableLyricManagement: false,
IsDisabled: false,
EnableUserPreferenceAccess: true,
EnableRemoteControlOfOtherUsers: false,
EnableSharedDeviceControl: false,
EnableRemoteAccess: true,
EnableLiveTVManagement: false,
EnableLiveTVAccess: false,
EnableMediaPlayback: true,
EnableAudioPlaybackTranscoding: true,
EnableVideoPlaybackTranscoding: true,
EnablePlaybackRemuxing: true,
ForceRemoteSourceTranscoding: false,
EnableContentDeletion: false,
EnableContentDownloading: true,
EnableSyncTranscoding: false,
EnableMediaConversion: false,
EnableAllDevices: true,
EnableAllChannels: false,
EnableAllFolders: true,
InvalidLoginAttemptCount: 0,
LoginAttemptsBeforeLockout: 0,
MaxActiveSessions: 0,
EnablePublicSharing: false,
RemoteClientBitrateLimit: 0,
AuthenticationProviderID: "silo-local",
PasswordResetProviderID: "silo-local",
SyncPlayAccess: "None",
},
Configuration: userConfigurationResponse{
PlayDefaultAudioTrack: true,
DisplayMissingEpisodes: false,
GroupedFolders: []string{},
SubtitleMode: "Smart",
DisplayCollectionsView: true,
EnableLocalPassword: false,
OrderedViews: []string{},
LatestItemsExcludes: []string{},
MyMediaExcludes: []string{},
HidePlayedInLatest: false,
RememberAudioSelections: true,
RememberSubtitleSelections: true,
EnableNextEpisodeAutoPlay: true,
},
}
}
func (h *AuthHandler) sessionInfo(session *Session) *sessionInfoResponse {
name := session.ProfileName
if name == "" {
name = session.Username
}
now := time.Now().UTC().Format(time.RFC3339Nano)
return &sessionInfoResponse{
PlayableMediaTypes: []string{},
UserID: session.PseudoUserID.String(),
UserName: name,
LastActivityDate: now,
LastPlaybackCheckIn: now,
IsActive: true,
SupportsMediaControl: true,
SupportsRemoteControl: true,
HasCustomDeviceName: false,
SupportedCommands: []string{},
ServerID: h.cfg.JellyfinCompat.ServerID,
}
}