The limiter covered only /api/v1/auth/login. Jellyfin's AuthenticateByName and Subsonic's authenticate both called auth.Authenticate with no limiting, so an attacker brute-forced through those instead: the same password, the same argon2, no ceiling. The limiter moves from auth.Handlers onto *Authenticator, which all three surfaces already hold, so they share one budget and splitting guesses across surfaces gains nothing. main.go builds one authenticator and hands it to both compat layers, so the sharing is real rather than intended. Subsonic needs care: it has no login step, authenticate runs on every request, so a naive check would charge playback against the login budget and lock a listener out mid-album. The limiter is consulted only when a credential was actually supplied, and a success or an uncredentialed request spends nothing. Verified red-green on all three: removing the Jellyfin gate fails TestAuthenticateByNameIsRateLimited with 401 instead of 429; removing the Subsonic credential guard fails TestSuccessAndUncredentialedDoNotConsumeTheBudget; neutering AllowLogin or ResetLogin fails the auth tests. Known gap, unchanged: the limiter is in-process, so it resets on restart and does not span replicas. Fine for one binary against one SQLite file.
161 lines
4.5 KiB
Go
161 lines
4.5 KiB
Go
package jellyfin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/mithral/vellum/internal/apitoken"
|
|
"github.com/mithral/vellum/internal/auth"
|
|
"github.com/mithral/vellum/internal/httpx"
|
|
"github.com/mithral/vellum/internal/jellyfin/dto"
|
|
)
|
|
|
|
func (h *Handlers) handleSystemInfoPublic(w http.ResponseWriter, r *http.Request) {
|
|
httpx.WriteJSON(w, http.StatusOK, dto.SystemInfoPublic{
|
|
Id: h.serverIDFor(r.Context()),
|
|
ServerName: "Vellum",
|
|
Version: dto.Version,
|
|
ProductName: "Vellum",
|
|
OperatingSystem: "Linux",
|
|
})
|
|
}
|
|
|
|
func (h *Handlers) handleAuthenticateByName(w http.ResponseWriter, r *http.Request) {
|
|
// Deliberately not httpx.DecodeJSON: that rejects unknown fields, and real
|
|
// clients add extras. We only read Username and Pw.
|
|
var req dto.AuthByNameRequest
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
if req.Username == "" || req.Pw == "" {
|
|
httpx.WriteError(w, http.StatusBadRequest, "username and password are required")
|
|
return
|
|
}
|
|
|
|
ip := h.a.ClientIP(r)
|
|
if !h.a.AllowLogin(ip, req.Username) {
|
|
// Same wording as the web login: this surface shares its budget, and a
|
|
// client cannot tell which one refused it.
|
|
h.log.Warn("jellyfin login rate limited", "ip", ip, "username", req.Username)
|
|
httpx.WriteError(w, http.StatusTooManyRequests, "too many login attempts, try again later")
|
|
return
|
|
}
|
|
|
|
u, err := auth.Authenticate(r.Context(), h.db, req.Username, req.Pw)
|
|
if err != nil {
|
|
if errors.Is(err, auth.ErrInvalidCredentials) {
|
|
// Same message for unknown user and wrong password, as the web
|
|
// login does, so usernames are not enumerable.
|
|
httpx.WriteError(w, http.StatusUnauthorized, "invalid username or password")
|
|
return
|
|
}
|
|
h.fail(w, "authenticate", err)
|
|
return
|
|
}
|
|
h.a.ResetLogin(ip, req.Username)
|
|
|
|
label := deviceLabel(r)
|
|
token, err := apitoken.Mint(r.Context(), h.db, u.ID, apitoken.KindJellyfin, label)
|
|
if err != nil {
|
|
h.fail(w, "mint token", err)
|
|
return
|
|
}
|
|
|
|
dto, err := h.userDTO(r.Context(), u)
|
|
if err != nil {
|
|
h.fail(w, "build user", err)
|
|
return
|
|
}
|
|
|
|
h.log.Info("jellyfin login", "user", u.Username, "device", label)
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
|
"User": dto,
|
|
"AccessToken": token,
|
|
"ServerId": h.serverIDFor(r.Context()),
|
|
})
|
|
}
|
|
|
|
func (h *Handlers) handleUserMe(w http.ResponseWriter, r *http.Request, u auth.User) {
|
|
dto, err := h.userDTO(r.Context(), u)
|
|
if err != nil {
|
|
h.fail(w, "build user", err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, dto)
|
|
}
|
|
|
|
func (h *Handlers) handleUserByID(w http.ResponseWriter, r *http.Request, u auth.User) {
|
|
id, _, ok := dto.DecodeID(r.PathValue("userId"))
|
|
if !ok || id != u.ID {
|
|
// A token may only address its own user. Cross-user reads (which
|
|
// Jellyfin permits for admins) are NOT implemented; a mismatched id is
|
|
// a 404 so account existence is not disclosed.
|
|
httpx.WriteError(w, http.StatusNotFound, "user not found")
|
|
return
|
|
}
|
|
dto, err := h.userDTO(r.Context(), u)
|
|
if err != nil {
|
|
h.fail(w, "build user", err)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, dto)
|
|
}
|
|
|
|
// userDTO renders the Jellyfin user shape, including the visible library
|
|
// folders so a client can scope its views.
|
|
func (h *Handlers) userDTO(ctx context.Context, u auth.User) (dto.UserDto, error) {
|
|
libs, err := auth.VisibleLibraryIDs(ctx, h.db, u)
|
|
if err != nil {
|
|
return dto.UserDto{}, err
|
|
}
|
|
return dto.User(u, h.serverIDFor(ctx), libs), nil
|
|
}
|
|
|
|
// deviceLabel best-effort extracts a human device name from the
|
|
// X-Emby-Authorization header so the token list in the UI is recognisable.
|
|
func deviceLabel(r *http.Request) string {
|
|
hdr := r.Header.Get("X-Emby-Authorization")
|
|
if hdr == "" {
|
|
hdr = r.Header.Get("Authorization")
|
|
}
|
|
for _, f := range splitParams(hdr) {
|
|
if v, ok := cutParam(f, "Device="); ok && v != "" {
|
|
return "Jellyfin: " + v
|
|
}
|
|
}
|
|
return "Jellyfin"
|
|
}
|
|
|
|
// splitParams splits a MediaBrowser-style parameter list on commas or spaces.
|
|
func splitParams(s string) []string {
|
|
out := []string{}
|
|
cur := ""
|
|
for _, r := range s {
|
|
if r == ',' || r == ' ' {
|
|
if cur != "" {
|
|
out = append(out, cur)
|
|
}
|
|
cur = ""
|
|
continue
|
|
}
|
|
cur += string(r)
|
|
}
|
|
if cur != "" {
|
|
out = append(out, cur)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cutParam(field, prefix string) (string, bool) {
|
|
if len(field) < len(prefix) || field[:len(prefix)] != prefix {
|
|
return "", false
|
|
}
|
|
v := field[len(prefix):]
|
|
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
|
|
v = v[1 : len(v)-1]
|
|
}
|
|
return v, true
|
|
}
|