mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-14 21:37:09 +02:00
feat(jellyfin): authentication middleware and AuthenticateByName login
This commit is contained in:
parent
ce7e7513fb
commit
535d252d17
5 changed files with 243 additions and 0 deletions
|
|
@ -47,6 +47,12 @@ func (api *Router) routes() http.Handler {
|
|||
r.Get("/System/Ping", api.ping)
|
||||
r.Post("/System/Ping", api.ping)
|
||||
r.Get("/QuickConnect/Enabled", api.quickConnectEnabled)
|
||||
r.Post("/Users/AuthenticateByName", api.authenticateByName)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(api.authenticate)
|
||||
// authenticated endpoints are registered by later tasks
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
|
|
|||
55
server/jellyfin/auth.go
Normal file
55
server/jellyfin/auth.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package jellyfin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||
)
|
||||
|
||||
type authenticateByNameRequest struct {
|
||||
Username string `json:"Username"`
|
||||
Pw string `json:"Pw"`
|
||||
}
|
||||
|
||||
func (api *Router) authenticateByName(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
var body authenticateByNameRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Navidrome stores recoverable passwords; this mirrors Subsonic's validateCredentials plaintext path.
|
||||
usr, err := api.ds.User(ctx).FindByUsernameWithPassword(body.Username)
|
||||
if err != nil || usr.Password != body.Pw {
|
||||
log.Warn(ctx, "Jellyfin API: invalid login", "username", body.Username, "remoteAddr", r.RemoteAddr)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.CreateToken(usr)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
api.ok(w, r, dto.AuthenticationResult{
|
||||
User: userToDto(usr, api.serverName()),
|
||||
AccessToken: token,
|
||||
ServerId: api.publicInfo(ctx).Id,
|
||||
SessionInfo: &dto.SessionInfo{Id: parseEmbyAuth(r).DeviceId, UserId: usr.ID},
|
||||
})
|
||||
}
|
||||
|
||||
func userToDto(u *model.User, serverName string) *dto.UserDto {
|
||||
return &dto.UserDto{
|
||||
Name: u.UserName,
|
||||
Id: u.ID,
|
||||
HasPassword: true,
|
||||
HasConfiguredPassword: true,
|
||||
}
|
||||
}
|
||||
51
server/jellyfin/auth_test.go
Normal file
51
server/jellyfin/auth_test.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package jellyfin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("AuthenticateByName", func() {
|
||||
var api *Router
|
||||
var ds *tests.MockDataStore
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
ur := ds.User(nil).(*tests.MockedUserRepo)
|
||||
Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed())
|
||||
api = &Router{ds: ds}
|
||||
})
|
||||
|
||||
It("issues a token for valid credentials", func() {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/AuthenticateByName",
|
||||
strings.NewReader(`{"Username":"alice","Pw":"secret"}`))
|
||||
api.authenticateByName(w, r)
|
||||
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
var res dto.AuthenticationResult
|
||||
Expect(json.Unmarshal(w.Body.Bytes(), &res)).To(Succeed())
|
||||
Expect(res.AccessToken).ToNot(BeEmpty())
|
||||
Expect(res.User.Name).To(Equal("alice"))
|
||||
claims, err := auth.Validate(res.AccessToken)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(claims.Subject).To(Equal("alice"))
|
||||
})
|
||||
|
||||
It("rejects invalid credentials with 401", func() {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("POST", "/Users/AuthenticateByName",
|
||||
strings.NewReader(`{"Username":"alice","Pw":"wrong"}`))
|
||||
api.authenticateByName(w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
78
server/jellyfin/middlewares.go
Normal file
78
server/jellyfin/middlewares.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package jellyfin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
)
|
||||
|
||||
type embyAuth struct {
|
||||
Client, Device, DeviceId, Version, Token string
|
||||
}
|
||||
|
||||
var embyAuthField = regexp.MustCompile(`(\w+)="([^"]*)"`)
|
||||
|
||||
// parseEmbyAuth reads the MediaBrowser/Emby authorization header, e.g.
|
||||
// `MediaBrowser Client="Finamp", Device="Pixel", DeviceId="abc", Version="1.0", Token="jwt"`.
|
||||
func parseEmbyAuth(r *http.Request) embyAuth {
|
||||
var a embyAuth
|
||||
h := r.Header.Get("X-Emby-Authorization")
|
||||
if h == "" {
|
||||
h = r.Header.Get("Authorization")
|
||||
}
|
||||
for _, m := range embyAuthField.FindAllStringSubmatch(h, -1) {
|
||||
switch m[1] {
|
||||
case "Client":
|
||||
a.Client = m[2]
|
||||
case "Device":
|
||||
a.Device = m[2]
|
||||
case "DeviceId":
|
||||
a.DeviceId = m[2]
|
||||
case "Version":
|
||||
a.Version = m[2]
|
||||
case "Token":
|
||||
a.Token = m[2]
|
||||
}
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func tokenFromRequest(r *http.Request) string {
|
||||
if t := r.Header.Get("X-Emby-Token"); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := r.Header.Get("X-MediaBrowser-Token"); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := parseEmbyAuth(r).Token; t != "" {
|
||||
return t
|
||||
}
|
||||
return r.URL.Query().Get("api_key")
|
||||
}
|
||||
|
||||
func (api *Router) authenticate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
token := tokenFromRequest(r)
|
||||
if token == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
claims, err := auth.Validate(token)
|
||||
if err != nil || claims.Subject == "" {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
usr, err := api.ds.User(ctx).FindByUsername(claims.Subject)
|
||||
if err != nil {
|
||||
log.Warn(ctx, "Jellyfin API: token subject not found", "user", claims.Subject, err)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ctx = request.WithUser(ctx, *usr)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
53
server/jellyfin/middlewares_test.go
Normal file
53
server/jellyfin/middlewares_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package jellyfin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("authenticate middleware", func() {
|
||||
var api *Router
|
||||
var ds *tests.MockDataStore
|
||||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
auth.Init(ds)
|
||||
ur := ds.User(nil).(*tests.MockedUserRepo)
|
||||
Expect(ur.Put(&model.User{ID: "u1", UserName: "alice", NewPassword: "secret"})).To(Succeed())
|
||||
api = &Router{ds: ds}
|
||||
})
|
||||
|
||||
tokenFor := func(name string) string {
|
||||
t, err := auth.CreateToken(&model.User{ID: "u1", UserName: name})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return t
|
||||
}
|
||||
|
||||
It("passes with a valid X-Emby-Token and injects the user", func() {
|
||||
var gotUser model.User
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotUser, _ = request.UserFrom(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items", nil)
|
||||
r.Header.Set("X-Emby-Token", tokenFor("alice"))
|
||||
api.authenticate(next).ServeHTTP(w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusOK))
|
||||
Expect(gotUser.UserName).To(Equal("alice"))
|
||||
})
|
||||
|
||||
It("rejects a missing token with 401", func() {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/Items", nil)
|
||||
api.authenticate(next).ServeHTTP(w, r)
|
||||
Expect(w.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue