Refactor routing, changes API URLs (#1171)
* Make authentication part of the server, so it can be reused outside the Native API This commit has broken tests after a rebase * Serve frontend assets from `server`, and not from Native API * Change Native API URL * Fix auth tests * Refactor server authentication * Simplify authProvider, now subsonic token+salt comes from the server * Don't send JWT token to UI when authenticated via Request Header * Enable ReverseProxyWhitelist to be read from environment
This commit is contained in:
+1
-1
@@ -74,7 +74,7 @@ func startServer() (func() error, func(err error)) {
|
|||||||
return func() error {
|
return func() error {
|
||||||
a := CreateServer(conf.Server.MusicFolder)
|
a := CreateServer(conf.Server.MusicFolder)
|
||||||
a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter())
|
a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter())
|
||||||
a.MountRouter("WebUI", consts.URLPathUI, CreateAppRouter())
|
a.MountRouter("Native API", consts.URLPathNativeAPI, CreateAppRouter())
|
||||||
return a.Run(fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port))
|
return a.Run(fmt.Sprintf("%s:%d", conf.Server.Address, conf.Server.Port))
|
||||||
}, func(err error) {
|
}, func(err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ func init() {
|
|||||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||||
|
|
||||||
viper.SetDefault("reverseproxyuserheader", "Remote-User")
|
viper.SetDefault("reverseproxyuserheader", "Remote-User")
|
||||||
|
viper.SetDefault("reverseproxywhitelist", "")
|
||||||
|
|
||||||
viper.SetDefault("scanner.extractor", "taglib")
|
viper.SetDefault("scanner.extractor", "taglib")
|
||||||
viper.SetDefault("agents", "lastfm,spotify")
|
viper.SetDefault("agents", "lastfm,spotify")
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const (
|
|||||||
DevInitialName = "Dev Admin"
|
DevInitialName = "Dev Admin"
|
||||||
|
|
||||||
URLPathUI = "/app"
|
URLPathUI = "/app"
|
||||||
|
URLPathNativeAPI = "/api"
|
||||||
URLPathSubsonicAPI = "/rest"
|
URLPathSubsonicAPI = "/rest"
|
||||||
|
|
||||||
// Login backgrounds from https://unsplash.com/collections/20072696/navidrome
|
// Login backgrounds from https://unsplash.com/collections/20072696/navidrome
|
||||||
|
|||||||
+24
-55
@@ -8,80 +8,51 @@ import (
|
|||||||
|
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/httprate"
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/ui"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Router struct {
|
type Router struct {
|
||||||
|
http.Handler
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
mux http.Handler
|
|
||||||
broker events.Broker
|
broker events.Broker
|
||||||
share core.Share
|
share core.Share
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ds model.DataStore, broker events.Broker, share core.Share) *Router {
|
func New(ds model.DataStore, broker events.Broker, share core.Share) *Router {
|
||||||
return &Router{ds: ds, broker: broker, share: share}
|
r := &Router{ds: ds, broker: broker, share: share}
|
||||||
|
r.Handler = r.routes()
|
||||||
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *Router) Setup(path string) {
|
func (app *Router) routes() http.Handler {
|
||||||
app.mux = app.routes(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
app.mux.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *Router) routes(path string) http.Handler {
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
if conf.Server.AuthRequestLimit > 0 {
|
r.Use(server.Authenticator(app.ds))
|
||||||
log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit,
|
r.Use(server.JWTRefresher)
|
||||||
"windowLength", conf.Server.AuthWindowLength)
|
app.R(r, "/user", model.User{}, true)
|
||||||
|
app.R(r, "/song", model.MediaFile{}, true)
|
||||||
|
app.R(r, "/album", model.Album{}, true)
|
||||||
|
app.R(r, "/artist", model.Artist{}, true)
|
||||||
|
app.R(r, "/player", model.Player{}, true)
|
||||||
|
app.R(r, "/playlist", model.Playlist{}, true)
|
||||||
|
app.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||||
|
app.RX(r, "/share", app.share.NewRepository, true)
|
||||||
|
app.RX(r, "/translation", newTranslationRepository, false)
|
||||||
|
|
||||||
rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
|
app.addPlaylistTrackRoute(r)
|
||||||
r.With(rateLimiter).Post("/login", Login(app.ds))
|
|
||||||
} else {
|
|
||||||
log.Warn("Login rate limit is disabled! Consider enabling it to be protected against brute-force attacks")
|
|
||||||
|
|
||||||
r.Post("/login", Login(app.ds))
|
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
|
||||||
}
|
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
|
||||||
r.Post("/createAdmin", CreateAdmin(app.ds))
|
|
||||||
|
|
||||||
r.Route("/api", func(r chi.Router) {
|
|
||||||
r.Use(mapAuthHeader())
|
|
||||||
r.Use(verifier())
|
|
||||||
r.Use(authenticator(app.ds))
|
|
||||||
app.R(r, "/user", model.User{}, true)
|
|
||||||
app.R(r, "/song", model.MediaFile{}, true)
|
|
||||||
app.R(r, "/album", model.Album{}, true)
|
|
||||||
app.R(r, "/artist", model.Artist{}, true)
|
|
||||||
app.R(r, "/player", model.Player{}, true)
|
|
||||||
app.R(r, "/playlist", model.Playlist{}, true)
|
|
||||||
app.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
|
||||||
app.RX(r, "/share", app.share.NewRepository, true)
|
|
||||||
app.RX(r, "/translation", newTranslationRepository, false)
|
|
||||||
|
|
||||||
app.addPlaylistTrackRoute(r)
|
|
||||||
|
|
||||||
// Keepalive endpoint to be used to keep the session valid (ex: while playing songs)
|
|
||||||
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
|
|
||||||
})
|
|
||||||
|
|
||||||
if conf.Server.DevActivityPanel {
|
|
||||||
r.Handle("/events", app.broker)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Serve UI app assets
|
if conf.Server.DevActivityPanel {
|
||||||
r.Handle("/", serveIndex(app.ds, ui.Assets()))
|
r.Handle("/events", app.broker)
|
||||||
r.Handle("/*", http.StripPrefix(path, http.FileServer(http.FS(ui.Assets()))))
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -110,8 +81,6 @@ func (app *Router) RX(r chi.Router, pathPrefix string, constructor rest.Reposito
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
|
|
||||||
|
|
||||||
func (app *Router) addPlaylistTrackRoute(r chi.Router) {
|
func (app *Router) addPlaylistTrackRoute(r chi.Router) {
|
||||||
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
|
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
|
||||||
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestApp(t *testing.T) {
|
func TestNativeApi(t *testing.T) {
|
||||||
tests.Init(t, false)
|
tests.Init(t, false)
|
||||||
log.SetLevel(log.LevelCritical)
|
log.SetLevel(log.LevelCritical)
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
|
||||||
|
|
||||||
func getPlaylist(ds model.DataStore) http.HandlerFunc {
|
func getPlaylist(ds model.DataStore) http.HandlerFunc {
|
||||||
// Add a middleware to capture the playlistId
|
// Add a middleware to capture the playlistId
|
||||||
wrapper := func(handler restHandler) http.HandlerFunc {
|
wrapper := func(handler restHandler) http.HandlerFunc {
|
||||||
|
|||||||
+167
-146
@@ -1,4 +1,4 @@
|
|||||||
package app
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -17,7 +16,6 @@ import (
|
|||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
"github.com/go-chi/jwtauth/v5"
|
"github.com/go-chi/jwtauth/v5"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/lestrrat-go/jwx/jwt"
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core/auth"
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
@@ -28,12 +26,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrFirstTime = errors.New("no users created")
|
ErrNoUsers = errors.New("no users created")
|
||||||
|
ErrUnauthenticated = errors.New("request not authenticated")
|
||||||
)
|
)
|
||||||
|
|
||||||
func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
|
func login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
|
||||||
auth.Init(ds)
|
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
username, password, err := getCredentialsFromBody(r)
|
username, password, err := getCredentialsFromBody(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -42,60 +39,11 @@ func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLogin(ds, username, password, w, r)
|
doLogin(ds, username, password, w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) *map[string]interface{} {
|
func doLogin(ds model.DataStore, username string, password string, w http.ResponseWriter, r *http.Request) {
|
||||||
if !validateIPAgainstList(r.RemoteAddr, conf.Server.ReverseProxyWhitelist) {
|
|
||||||
log.Warn("Ip is not whitelisted for reverse proxy login", "ip", r.RemoteAddr)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
username := r.Header.Get(conf.Server.ReverseProxyUserHeader)
|
|
||||||
|
|
||||||
userRepo := ds.User(r.Context())
|
|
||||||
user, err := userRepo.FindByUsername(username)
|
|
||||||
if user == nil || err != nil {
|
|
||||||
log.Warn("User passed in header not found", "user", username)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
err = userRepo.UpdateLastLoginAt(user.ID)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Could not update LastLoginAt", "user", username, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenString, err := auth.CreateToken(user)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Could not create token", "user", username, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := buildPayload(user, tokenString)
|
|
||||||
|
|
||||||
bytes := make([]byte, 3)
|
|
||||||
_, err = rand.Read(bytes)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Could not create subsonic salt", "user", username, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
salt := hex.EncodeToString(bytes)
|
|
||||||
payload["subsonicSalt"] = salt
|
|
||||||
|
|
||||||
h := md5.New()
|
|
||||||
_, err = io.WriteString(h, user.Password+salt)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Could not create subsonic token", "user", username, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
payload["subsonicToken"] = hex.EncodeToString(h.Sum(nil))
|
|
||||||
|
|
||||||
return &payload
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleLogin(ds model.DataStore, username string, password string, w http.ResponseWriter, r *http.Request) {
|
|
||||||
user, err := validateLogin(ds.User(r.Context()), username, password)
|
user, err := validateLogin(ds.User(r.Context()), username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again")
|
_ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authentication user. Please try again")
|
||||||
@@ -112,14 +60,13 @@ func handleLogin(ds model.DataStore, username string, password string, w http.Re
|
|||||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again")
|
_ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
payload := buildPayload(user, tokenString)
|
payload := buildAuthPayload(user)
|
||||||
|
payload["token"] = tokenString
|
||||||
_ = rest.RespondWithJSON(w, http.StatusOK, payload)
|
_ = rest.RespondWithJSON(w, http.StatusOK, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPayload(user *model.User, tokenString string) map[string]interface{} {
|
func buildAuthPayload(user *model.User) map[string]interface{} {
|
||||||
payload := map[string]interface{}{
|
payload := map[string]interface{}{
|
||||||
"message": "User '" + user.UserName + "' authenticated successfully",
|
|
||||||
"token": tokenString,
|
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"name": user.Name,
|
"name": user.Name,
|
||||||
"username": user.UserName,
|
"username": user.UserName,
|
||||||
@@ -128,37 +75,20 @@ func buildPayload(user *model.User, tokenString string) map[string]interface{} {
|
|||||||
if conf.Server.EnableGravatar && user.Email != "" {
|
if conf.Server.EnableGravatar && user.Email != "" {
|
||||||
payload["avatar"] = gravatar.Url(user.Email, 50)
|
payload["avatar"] = gravatar.Url(user.Email, 50)
|
||||||
}
|
}
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateIPAgainstList(ip string, comaSeparatedList string) bool {
|
|
||||||
if comaSeparatedList == "" || ip == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if net.ParseIP(ip) == nil {
|
|
||||||
ip, _, _ = net.SplitHostPort(ip)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ip == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
cidrs := strings.Split(comaSeparatedList, ",")
|
|
||||||
testedIP, _, err := net.ParseCIDR(fmt.Sprintf("%s/32", ip))
|
|
||||||
|
|
||||||
|
bytes := make([]byte, 3)
|
||||||
|
_, err := rand.Read(bytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
log.Error("Could not create subsonic salt", "user", user.UserName, err)
|
||||||
|
return payload
|
||||||
}
|
}
|
||||||
|
subsonicSalt := hex.EncodeToString(bytes)
|
||||||
|
payload["subsonicSalt"] = subsonicSalt
|
||||||
|
|
||||||
for _, cidr := range cidrs {
|
subsonicToken := md5.Sum([]byte(user.Password + subsonicSalt))
|
||||||
_, ipnet, err := net.ParseCIDR(cidr)
|
payload["subsonicToken"] = hex.EncodeToString(subsonicToken[:])
|
||||||
if err == nil && ipnet.Contains(testedIP) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCredentialsFromBody(r *http.Request) (username string, password string, err error) {
|
func getCredentialsFromBody(r *http.Request) (username string, password string, err error) {
|
||||||
@@ -174,7 +104,7 @@ func getCredentialsFromBody(r *http.Request) (username string, password string,
|
|||||||
return username, password, nil
|
return username, password, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
|
func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) {
|
||||||
auth.Init(ds)
|
auth.Init(ds)
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -193,16 +123,16 @@ func CreateAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request
|
|||||||
_ = rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin")
|
_ = rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = createDefaultUser(r.Context(), ds, username, password)
|
err = createAdminUser(r.Context(), ds, username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleLogin(ds, username, password, w, r)
|
doLogin(ds, username, password, w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createDefaultUser(ctx context.Context, ds model.DataStore, username, password string) error {
|
func createAdminUser(ctx context.Context, ds model.DataStore, username, password string) error {
|
||||||
log.Warn("Creating initial user", "user", username)
|
log.Warn("Creating initial user", "user", username)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
initialUser := model.User{
|
initialUser := model.User{
|
||||||
@@ -239,71 +169,162 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func contextWithUser(ctx context.Context, ds model.DataStore, token jwt.Token) context.Context {
|
func contextWithUser(ctx context.Context, ds model.DataStore, username string) (context.Context, error) {
|
||||||
userName := token.Subject()
|
user, err := ds.User(ctx).FindByUsername(username)
|
||||||
user, _ := ds.User(ctx).FindByUsername(userName)
|
if err != nil {
|
||||||
return request.WithUser(ctx, *user)
|
log.Error(ctx, "Authenticated username not found in DB", "username", username)
|
||||||
}
|
return ctx, err
|
||||||
|
|
||||||
func getToken(ds model.DataStore, ctx context.Context) (jwt.Token, error) {
|
|
||||||
token, claims, err := jwtauth.FromContext(ctx)
|
|
||||||
|
|
||||||
valid := err == nil && token != nil
|
|
||||||
valid = valid && claims["sub"] != nil
|
|
||||||
if valid {
|
|
||||||
return token, nil
|
|
||||||
}
|
}
|
||||||
|
return request.WithUser(ctx, *user), nil
|
||||||
c, err := ds.User(ctx).CountAll()
|
|
||||||
firstTime := c == 0 && err == nil
|
|
||||||
if firstTime {
|
|
||||||
return nil, ErrFirstTime
|
|
||||||
}
|
|
||||||
return nil, errors.New("invalid authentication")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// This method maps the custom authorization header to the default 'Authorization', used by the jwtauth library
|
// This method maps the custom authorization header to the default 'Authorization', used by the jwtauth library
|
||||||
func mapAuthHeader() func(next http.Handler) http.Handler {
|
func authHeaderMapper(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
bearer := r.Header.Get(consts.UIAuthorizationHeader)
|
||||||
|
r.Header.Set("Authorization", bearer)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func jwtVerifier(next http.Handler) http.Handler {
|
||||||
|
return jwtauth.Verify(auth.TokenAuth, jwtauth.TokenFromHeader, jwtauth.TokenFromCookie, jwtauth.TokenFromQuery)(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsernameFromToken(r *http.Request) string {
|
||||||
|
token, claims, err := jwtauth.FromContext(r.Context())
|
||||||
|
if err != nil || claims["sub"] == nil || token == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
log.Trace(r, "Found username in JWT token", "username", token.Subject())
|
||||||
|
return token.Subject()
|
||||||
|
}
|
||||||
|
|
||||||
|
func UsernameFromReverseProxyHeader(r *http.Request) string {
|
||||||
|
if conf.Server.ReverseProxyWhitelist == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !validateIPAgainstList(r.RemoteAddr, conf.Server.ReverseProxyWhitelist) {
|
||||||
|
log.Warn("IP is not whitelisted for reverse proxy login", "ip", r.RemoteAddr)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
username := r.Header.Get(conf.Server.ReverseProxyUserHeader)
|
||||||
|
if username == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
log.Trace(r, "Found username in ReverseProxyUserHeader", "username", username)
|
||||||
|
return username
|
||||||
|
}
|
||||||
|
|
||||||
|
func authenticateRequest(ds model.DataStore, r *http.Request, findUsernameFns ...func(r *http.Request) string) (context.Context, error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
c, err := ds.User(ctx).CountAll()
|
||||||
|
firstTime := c == 0 && err == nil
|
||||||
|
if firstTime {
|
||||||
|
return nil, ErrNoUsers
|
||||||
|
}
|
||||||
|
|
||||||
|
var username string
|
||||||
|
for _, fn := range findUsernameFns {
|
||||||
|
username = fn(r)
|
||||||
|
if username != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if username == "" {
|
||||||
|
return nil, ErrUnauthenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
return contextWithUser(r.Context(), ds, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
bearer := r.Header.Get(consts.UIAuthorizationHeader)
|
ctx, err := authenticateRequest(ds, r, UsernameFromToken, UsernameFromReverseProxyHeader)
|
||||||
r.Header.Set("Authorization", bearer)
|
if err == ErrNoUsers {
|
||||||
|
_ = rest.RespondWithJSON(w, http.StatusUnauthorized, map[string]string{"message": ErrNoUsers.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWTRefresher updates the expire date of the received JWT token, and add the new one to the Authorization Header
|
||||||
|
func JWTRefresher(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
token, _, err := jwtauth.FromContext(ctx)
|
||||||
|
if err != nil {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
return
|
||||||
}
|
}
|
||||||
|
newTokenString, err := auth.TouchToken(token)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(r, "Could not sign new token", err)
|
||||||
|
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set(consts.UIAuthorizationHeader, newTokenString)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifier() func(http.Handler) http.Handler {
|
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} {
|
||||||
return func(next http.Handler) http.Handler {
|
username := UsernameFromReverseProxyHeader(r)
|
||||||
return jwtauth.Verify(auth.TokenAuth, jwtauth.TokenFromHeader, jwtauth.TokenFromCookie, jwtauth.TokenFromQuery)(next)
|
if username == "" {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userRepo := ds.User(r.Context())
|
||||||
|
user, err := userRepo.FindByUsername(username)
|
||||||
|
if user == nil || err != nil {
|
||||||
|
log.Warn(r, "User passed in header not found", "user", username)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err = userRepo.UpdateLastLoginAt(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error(r, "Could not update LastLoginAt", "user", username, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildAuthPayload(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
|
func validateIPAgainstList(ip string, comaSeparatedList string) bool {
|
||||||
auth.Init(ds)
|
if comaSeparatedList == "" || ip == "" {
|
||||||
|
return false
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
token, err := getToken(ds, r.Context())
|
|
||||||
if err == ErrFirstTime {
|
|
||||||
_ = rest.RespondWithJSON(w, http.StatusUnauthorized, map[string]string{"message": ErrFirstTime.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newCtx := contextWithUser(r.Context(), ds, token)
|
|
||||||
newTokenString, err := auth.TouchToken(token)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(r, "signing new token", err)
|
|
||||||
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set(consts.UIAuthorizationHeader, newTokenString)
|
|
||||||
next.ServeHTTP(w, r.WithContext(newCtx))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if net.ParseIP(ip) == nil {
|
||||||
|
ip, _, _ = net.SplitHostPort(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
cidrs := strings.Split(comaSeparatedList, ",")
|
||||||
|
testedIP, _, err := net.ParseCIDR(fmt.Sprintf("%s/32", ip))
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, cidr := range cidrs {
|
||||||
|
_, ipnet, err := net.ParseCIDR(cidr)
|
||||||
|
if err == nil && ipnet.Contains(testedIP) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
@@ -1,41 +1,44 @@
|
|||||||
package app
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
|
||||||
. "github.com/onsi/ginkgo"
|
. "github.com/onsi/ginkgo"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("Auth", func() {
|
var _ = Describe("Auth", func() {
|
||||||
Describe("Public functions", func() {
|
Describe("User login", func() {
|
||||||
var ds model.DataStore
|
var ds model.DataStore
|
||||||
var req *http.Request
|
var req *http.Request
|
||||||
var resp *httptest.ResponseRecorder
|
var resp *httptest.ResponseRecorder
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
|
auth.Init(ds)
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("CreateAdmin", func() {
|
Describe("createAdmin", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
req = httptest.NewRequest("POST", "/createAdmin", strings.NewReader(`{"username":"johndoe", "password":"secret"}`))
|
req = httptest.NewRequest("POST", "/createAdmin", strings.NewReader(`{"username":"johndoe", "password":"secret"}`))
|
||||||
resp = httptest.NewRecorder()
|
resp = httptest.NewRecorder()
|
||||||
CreateAdmin(ds)(resp, req)
|
createAdmin(ds)(resp, req)
|
||||||
})
|
})
|
||||||
|
|
||||||
It("creates an admin user with the specified password", func() {
|
It("creates an admin user with the specified password", func() {
|
||||||
usr := ds.User(context.TODO())
|
usr := ds.User(context.Background())
|
||||||
u, err := usr.FindByUsername("johndoe")
|
u, err := usr.FindByUsername("johndoe")
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(u.Password).ToNot(BeEmpty())
|
Expect(u.Password).ToNot(BeEmpty())
|
||||||
@@ -57,6 +60,8 @@ var _ = Describe("Auth", func() {
|
|||||||
fs := os.DirFS("tests/fixtures")
|
fs := os.DirFS("tests/fixtures")
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
usr := ds.User(context.Background())
|
||||||
|
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
||||||
req = httptest.NewRequest("GET", "/index.html", nil)
|
req = httptest.NewRequest("GET", "/index.html", nil)
|
||||||
req.Header.Add("Remote-User", "janedoe")
|
req.Header.Add("Remote-User", "janedoe")
|
||||||
resp = httptest.NewRecorder()
|
resp = httptest.NewRecorder()
|
||||||
@@ -65,9 +70,6 @@ var _ = Describe("Auth", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("sets auth data if IPv4 matches whitelist", func() {
|
It("sets auth data if IPv4 matches whitelist", func() {
|
||||||
usr := ds.User(context.TODO())
|
|
||||||
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
|
||||||
|
|
||||||
req.RemoteAddr = "192.168.0.42:25293"
|
req.RemoteAddr = "192.168.0.42:25293"
|
||||||
serveIndex(ds, fs)(resp, req)
|
serveIndex(ds, fs)(resp, req)
|
||||||
|
|
||||||
@@ -78,9 +80,6 @@ var _ = Describe("Auth", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("sets no auth data if IPv4 does not match whitelist", func() {
|
It("sets no auth data if IPv4 does not match whitelist", func() {
|
||||||
usr := ds.User(context.TODO())
|
|
||||||
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
|
||||||
|
|
||||||
req.RemoteAddr = "8.8.8.8:25293"
|
req.RemoteAddr = "8.8.8.8:25293"
|
||||||
serveIndex(ds, fs)(resp, req)
|
serveIndex(ds, fs)(resp, req)
|
||||||
|
|
||||||
@@ -89,9 +88,6 @@ var _ = Describe("Auth", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("sets auth data if IPv6 matches whitelist", func() {
|
It("sets auth data if IPv6 matches whitelist", func() {
|
||||||
usr := ds.User(context.TODO())
|
|
||||||
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
|
||||||
|
|
||||||
req.RemoteAddr = "[2001:4860:4860:1234:5678:0000:4242:8888]:25293"
|
req.RemoteAddr = "[2001:4860:4860:1234:5678:0000:4242:8888]:25293"
|
||||||
serveIndex(ds, fs)(resp, req)
|
serveIndex(ds, fs)(resp, req)
|
||||||
|
|
||||||
@@ -102,9 +98,6 @@ var _ = Describe("Auth", func() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
It("sets no auth data if IPv6 does not match whitelist", func() {
|
It("sets no auth data if IPv6 does not match whitelist", func() {
|
||||||
usr := ds.User(context.TODO())
|
|
||||||
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
|
||||||
|
|
||||||
req.RemoteAddr = "[5005:0:3003]:25293"
|
req.RemoteAddr = "[5005:0:3003]:25293"
|
||||||
serveIndex(ds, fs)(resp, req)
|
serveIndex(ds, fs)(resp, req)
|
||||||
|
|
||||||
@@ -112,12 +105,16 @@ var _ = Describe("Auth", func() {
|
|||||||
Expect(config["auth"]).To(BeNil())
|
Expect(config["auth"]).To(BeNil())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("sets no auth data if user does not exist", func() {
|
||||||
|
req.Header.Set("Remote-User", "INVALID_USER")
|
||||||
|
serveIndex(ds, fs)(resp, req)
|
||||||
|
|
||||||
|
config := extractAppConfig(resp.Body.String())
|
||||||
|
Expect(config["auth"]).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
It("sets auth data if user exists", func() {
|
It("sets auth data if user exists", func() {
|
||||||
req.RemoteAddr = "192.168.0.42:25293"
|
req.RemoteAddr = "192.168.0.42:25293"
|
||||||
|
|
||||||
usr := ds.User(context.TODO())
|
|
||||||
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
|
||||||
|
|
||||||
serveIndex(ds, fs)(resp, req)
|
serveIndex(ds, fs)(resp, req)
|
||||||
|
|
||||||
config := extractAppConfig(resp.Body.String())
|
config := extractAppConfig(resp.Body.String())
|
||||||
@@ -127,28 +124,33 @@ var _ = Describe("Auth", func() {
|
|||||||
Expect(parsed["isAdmin"]).To(BeFalse())
|
Expect(parsed["isAdmin"]).To(BeFalse())
|
||||||
Expect(parsed["name"]).To(Equal("Jane"))
|
Expect(parsed["name"]).To(Equal("Jane"))
|
||||||
Expect(parsed["username"]).To(Equal("janedoe"))
|
Expect(parsed["username"]).To(Equal("janedoe"))
|
||||||
Expect(parsed["token"]).ToNot(BeEmpty())
|
|
||||||
Expect(parsed["subsonicSalt"]).ToNot(BeEmpty())
|
Expect(parsed["subsonicSalt"]).ToNot(BeEmpty())
|
||||||
Expect(parsed["subsonicToken"]).ToNot(BeEmpty())
|
Expect(parsed["subsonicToken"]).ToNot(BeEmpty())
|
||||||
|
salt := parsed["subsonicSalt"].(string)
|
||||||
|
token := fmt.Sprintf("%x", md5.Sum([]byte("abc123"+salt)))
|
||||||
|
Expect(parsed["subsonicToken"]).To(Equal(token))
|
||||||
|
|
||||||
|
// Request Header authentication should not generate a JWT token
|
||||||
|
Expect(parsed).ToNot(HaveKey("token"))
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
Describe("Login", func() {
|
Describe("login", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
req = httptest.NewRequest("POST", "/login", strings.NewReader(`{"username":"janedoe", "password":"abc123"}`))
|
req = httptest.NewRequest("POST", "/login", strings.NewReader(`{"username":"janedoe", "password":"abc123"}`))
|
||||||
resp = httptest.NewRecorder()
|
resp = httptest.NewRecorder()
|
||||||
})
|
})
|
||||||
|
|
||||||
It("fails if user does not exist", func() {
|
It("fails if user does not exist", func() {
|
||||||
Login(ds)(resp, req)
|
login(ds)(resp, req)
|
||||||
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
|
Expect(resp.Code).To(Equal(http.StatusUnauthorized))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("logs in successfully if user exists", func() {
|
It("logs in successfully if user exists", func() {
|
||||||
usr := ds.User(context.TODO())
|
usr := ds.User(context.Background())
|
||||||
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
_ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false})
|
||||||
|
|
||||||
Login(ds)(resp, req)
|
login(ds)(resp, req)
|
||||||
Expect(resp.Code).To(Equal(http.StatusOK))
|
Expect(resp.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
var parsed map[string]interface{}
|
var parsed map[string]interface{}
|
||||||
@@ -162,13 +164,13 @@ var _ = Describe("Auth", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("mapAuthHeader", func() {
|
Describe("authHeaderMapper", func() {
|
||||||
It("maps the custom header to Authorization header", func() {
|
It("maps the custom header to Authorization header", func() {
|
||||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||||
r.Header.Set(consts.UIAuthorizationHeader, "test authorization bearer")
|
r.Header.Set(consts.UIAuthorizationHeader, "test authorization bearer")
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
mapAuthHeader()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
authHeaderMapper(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
Expect(r.Header.Get("Authorization")).To(Equal("test authorization bearer"))
|
Expect(r.Header.Get("Authorization")).To(Equal("test authorization bearer"))
|
||||||
w.WriteHeader(200)
|
w.WriteHeader(200)
|
||||||
})).ServeHTTP(w, r)
|
})).ServeHTTP(w, r)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package app
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
@@ -18,13 +17,7 @@ import (
|
|||||||
|
|
||||||
// Injects the config in the `index.html` template
|
// Injects the config in the `index.html` template
|
||||||
func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc {
|
func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc {
|
||||||
policy := bluemonday.UGCPolicy()
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
base := path.Join(conf.Server.BaseURL, consts.URLPathUI)
|
|
||||||
if r.URL.Path == base {
|
|
||||||
http.Redirect(w, r, base+"/", http.StatusFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := ds.User(r.Context()).CountAll()
|
c, err := ds.User(r.Context()).CountAll()
|
||||||
firstTime := c == 0 && err == nil
|
firstTime := c == 0 && err == nil
|
||||||
|
|
||||||
@@ -33,6 +26,7 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
policy := bluemonday.UGCPolicy()
|
||||||
appConfig := map[string]interface{}{
|
appConfig := map[string]interface{}{
|
||||||
"version": consts.Version(),
|
"version": consts.Version(),
|
||||||
"firstTime": firstTime,
|
"firstTime": firstTime,
|
||||||
@@ -53,7 +47,7 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
auth := handleLoginFromHeaders(ds, r)
|
auth := handleLoginFromHeaders(ds, r)
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
appConfig["auth"] = *auth
|
appConfig["auth"] = auth
|
||||||
}
|
}
|
||||||
j, err := json.Marshal(appConfig)
|
j, err := json.Marshal(appConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package app
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -27,16 +27,6 @@ var _ = Describe("serveIndex", func() {
|
|||||||
conf.Server.UILoginBackgroundURL = ""
|
conf.Server.UILoginBackgroundURL = ""
|
||||||
})
|
})
|
||||||
|
|
||||||
It("redirects bare /app path to /app/", func() {
|
|
||||||
r := httptest.NewRequest("GET", "/app", nil)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
serveIndex(ds, fs)(w, r)
|
|
||||||
|
|
||||||
Expect(w.Code).To(Equal(302))
|
|
||||||
Expect(w.Header().Get("Location")).To(Equal("/app/"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("adds app_config to index.html", func() {
|
It("adds app_config to index.html", func() {
|
||||||
r := httptest.NewRequest("GET", "/index.html", nil)
|
r := httptest.NewRequest("GET", "/index.html", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
+31
-10
@@ -8,18 +8,15 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
|
"github.com/go-chi/httprate"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/ui"
|
"github.com/navidrome/navidrome/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler interface {
|
|
||||||
http.Handler
|
|
||||||
Setup(path string)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
router *chi.Mux
|
router *chi.Mux
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
@@ -34,10 +31,9 @@ func New(ds model.DataStore) *Server {
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Server) MountRouter(description, urlPath string, subRouter Handler) {
|
func (a *Server) MountRouter(description, urlPath string, subRouter http.Handler) {
|
||||||
urlPath = path.Join(conf.Server.BaseURL, urlPath)
|
urlPath = path.Join(conf.Server.BaseURL, urlPath)
|
||||||
log.Info(fmt.Sprintf("Mounting %s routes", description), "path", urlPath)
|
log.Info(fmt.Sprintf("Mounting %s routes", description), "path", urlPath)
|
||||||
subRouter.Setup(urlPath)
|
|
||||||
a.router.Group(func(r chi.Router) {
|
a.router.Group(func(r chi.Router) {
|
||||||
r.Mount(urlPath, subRouter)
|
r.Mount(urlPath, subRouter)
|
||||||
})
|
})
|
||||||
@@ -49,6 +45,8 @@ func (a *Server) Run(addr string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Server) initRoutes() {
|
func (a *Server) initRoutes() {
|
||||||
|
auth.Init(a.ds)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
r.Use(secureMiddleware())
|
r.Use(secureMiddleware())
|
||||||
@@ -61,11 +59,34 @@ func (a *Server) initRoutes() {
|
|||||||
r.Use(injectLogger)
|
r.Use(injectLogger)
|
||||||
r.Use(requestLogger)
|
r.Use(requestLogger)
|
||||||
r.Use(robotsTXT(ui.Assets()))
|
r.Use(robotsTXT(ui.Assets()))
|
||||||
|
r.Use(authHeaderMapper)
|
||||||
|
r.Use(jwtVerifier)
|
||||||
|
|
||||||
indexHtml := path.Join(conf.Server.BaseURL, consts.URLPathUI)
|
r.Route(path.Join(conf.Server.BaseURL, "/auth"), func(r chi.Router) {
|
||||||
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
if conf.Server.AuthRequestLimit > 0 {
|
||||||
http.Redirect(w, r, indexHtml, 302)
|
log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit,
|
||||||
|
"windowLength", conf.Server.AuthWindowLength)
|
||||||
|
|
||||||
|
rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength)
|
||||||
|
r.With(rateLimiter).Post("/login", login(a.ds))
|
||||||
|
} else {
|
||||||
|
log.Warn("Login rate limit is disabled! Consider enabling it to be protected against brute-force attacks")
|
||||||
|
|
||||||
|
r.Post("/login", login(a.ds))
|
||||||
|
}
|
||||||
|
r.Post("/createAdmin", createAdmin(a.ds))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Serve UI app assets
|
||||||
|
appRoot := path.Join(conf.Server.BaseURL, consts.URLPathUI)
|
||||||
|
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, appRoot+"/", 302)
|
||||||
|
})
|
||||||
|
r.Get(appRoot, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, appRoot+"/", 302)
|
||||||
|
})
|
||||||
|
r.Handle(appRoot+"/", serveIndex(a.ds, ui.Assets()))
|
||||||
|
r.Handle(appRoot+"/*", http.StripPrefix(appRoot, http.FileServer(http.FS(ui.Assets()))))
|
||||||
|
|
||||||
a.router = r
|
a.router = r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSubsonicApi(t *testing.T) {
|
func TestServer(t *testing.T) {
|
||||||
tests.Init(t, false)
|
tests.Init(t, false)
|
||||||
log.SetLevel(log.LevelCritical)
|
log.SetLevel(log.LevelCritical)
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const Version = "1.16.1"
|
|||||||
type handler = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, error)
|
type handler = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, error)
|
||||||
|
|
||||||
type Router struct {
|
type Router struct {
|
||||||
|
http.Handler
|
||||||
DataStore model.DataStore
|
DataStore model.DataStore
|
||||||
Artwork core.Artwork
|
Artwork core.Artwork
|
||||||
Streamer core.MediaStreamer
|
Streamer core.MediaStreamer
|
||||||
@@ -32,8 +33,6 @@ type Router struct {
|
|||||||
ExternalMetadata core.ExternalMetadata
|
ExternalMetadata core.ExternalMetadata
|
||||||
Scanner scanner.Scanner
|
Scanner scanner.Scanner
|
||||||
Broker events.Broker
|
Broker events.Broker
|
||||||
|
|
||||||
mux http.Handler
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ds model.DataStore, artwork core.Artwork, streamer core.MediaStreamer, archiver core.Archiver, players core.Players,
|
func New(ds model.DataStore, artwork core.Artwork, streamer core.MediaStreamer, archiver core.Archiver, players core.Players,
|
||||||
@@ -48,16 +47,10 @@ func New(ds model.DataStore, artwork core.Artwork, streamer core.MediaStreamer,
|
|||||||
Scanner: scanner,
|
Scanner: scanner,
|
||||||
Broker: broker,
|
Broker: broker,
|
||||||
}
|
}
|
||||||
r.mux = r.routes()
|
r.Handler = r.routes()
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *Router) Setup(path string) {}
|
|
||||||
|
|
||||||
func (api *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
api.mux.ServeHTTP(w, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *Router) routes() http.Handler {
|
func (api *Router) routes() http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
|||||||
+24
-39
@@ -1,31 +1,35 @@
|
|||||||
import jwtDecode from 'jwt-decode'
|
import jwtDecode from 'jwt-decode'
|
||||||
import md5 from 'blueimp-md5'
|
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
import { baseUrl } from './utils'
|
import { baseUrl } from './utils'
|
||||||
import config from './config'
|
import config from './config'
|
||||||
import { startEventStream, stopEventStream } from './eventStream'
|
import { startEventStream, stopEventStream } from './eventStream'
|
||||||
|
|
||||||
|
// config sent from server may contain authentication info, for example when the user is authenticated
|
||||||
|
// by a reverse proxy request header
|
||||||
if (config.auth) {
|
if (config.auth) {
|
||||||
try {
|
try {
|
||||||
jwtDecode(config.auth.token)
|
storeAuthenticationInfo(config.auth)
|
||||||
localStorage.setItem('token', config.auth.token)
|
|
||||||
localStorage.setItem('userId', config.auth.id)
|
|
||||||
localStorage.setItem('name', config.auth.name)
|
|
||||||
localStorage.setItem('username', config.auth.username)
|
|
||||||
config.auth.avatar && config.auth.setItem('avatar', config.auth.avatar)
|
|
||||||
localStorage.setItem('role', config.auth.isAdmin ? 'admin' : 'regular')
|
|
||||||
localStorage.setItem('subsonic-salt', config.auth.subsonicSalt)
|
|
||||||
localStorage.setItem('subsonic-token', config.auth.subsonicToken)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function storeAuthenticationInfo(authInfo) {
|
||||||
|
authInfo.token && localStorage.setItem('token', authInfo.token)
|
||||||
|
localStorage.setItem('userId', authInfo.id)
|
||||||
|
localStorage.setItem('name', authInfo.name)
|
||||||
|
localStorage.setItem('username', authInfo.username)
|
||||||
|
authInfo.avatar && localStorage.setItem('avatar', authInfo.avatar)
|
||||||
|
localStorage.setItem('role', authInfo.isAdmin ? 'admin' : 'regular')
|
||||||
|
localStorage.setItem('subsonic-salt', authInfo.subsonicSalt)
|
||||||
|
localStorage.setItem('subsonic-token', authInfo.subsonicToken)
|
||||||
|
localStorage.setItem('is-authenticated', 'true')
|
||||||
|
}
|
||||||
|
|
||||||
const authProvider = {
|
const authProvider = {
|
||||||
login: ({ username, password }) => {
|
login: ({ username, password }) => {
|
||||||
let url = baseUrl('/app/login')
|
let url = baseUrl('/auth/login')
|
||||||
if (config.firstTime) {
|
if (config.firstTime) {
|
||||||
url = baseUrl('/app/createAdmin')
|
url = baseUrl('/auth/createAdmin')
|
||||||
}
|
}
|
||||||
const request = new Request(url, {
|
const request = new Request(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -40,22 +44,9 @@ const authProvider = {
|
|||||||
return response.json()
|
return response.json()
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
// Validate token
|
jwtDecode(response.token) // Validate token
|
||||||
jwtDecode(response.token)
|
storeAuthenticationInfo(response)
|
||||||
// TODO Store all items in one object
|
// Avoid "going to create admin" dialog after logout/login without a refresh
|
||||||
localStorage.setItem('token', response.token)
|
|
||||||
localStorage.setItem('userId', response.id)
|
|
||||||
localStorage.setItem('name', response.name)
|
|
||||||
localStorage.setItem('username', response.username)
|
|
||||||
response.avatar && localStorage.setItem('avatar', response.avatar)
|
|
||||||
localStorage.setItem('role', response.isAdmin ? 'admin' : 'regular')
|
|
||||||
const salt = generateSubsonicSalt()
|
|
||||||
localStorage.setItem('subsonic-salt', salt)
|
|
||||||
localStorage.setItem(
|
|
||||||
'subsonic-token',
|
|
||||||
generateSubsonicToken(password, salt)
|
|
||||||
)
|
|
||||||
// Avoid going to create admin dialog after logout/login without a refresh
|
|
||||||
config.firstTime = false
|
config.firstTime = false
|
||||||
if (config.devActivityPanel) {
|
if (config.devActivityPanel) {
|
||||||
startEventStream()
|
startEventStream()
|
||||||
@@ -86,7 +77,9 @@ const authProvider = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
checkAuth: () =>
|
checkAuth: () =>
|
||||||
localStorage.getItem('token') ? Promise.resolve() : Promise.reject(),
|
localStorage.getItem('is-authenticated')
|
||||||
|
? Promise.resolve()
|
||||||
|
: Promise.reject(),
|
||||||
|
|
||||||
checkError: ({ status }) => {
|
checkError: ({ status }) => {
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
@@ -119,6 +112,7 @@ const removeItems = () => {
|
|||||||
localStorage.removeItem('role')
|
localStorage.removeItem('role')
|
||||||
localStorage.removeItem('subsonic-salt')
|
localStorage.removeItem('subsonic-salt')
|
||||||
localStorage.removeItem('subsonic-token')
|
localStorage.removeItem('subsonic-token')
|
||||||
|
localStorage.removeItem('is-authenticated')
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearServiceWorkerCache = () => {
|
const clearServiceWorkerCache = () => {
|
||||||
@@ -128,13 +122,4 @@ const clearServiceWorkerCache = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateSubsonicSalt = () => {
|
|
||||||
const h = md5(uuidv4())
|
|
||||||
return h.slice(0, 6)
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateSubsonicToken = (password, salt) => {
|
|
||||||
return md5(password + salt)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default authProvider
|
export default authProvider
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
export const REST_URL = '/app/api'
|
export const REST_URL = '/api'
|
||||||
|
|
||||||
export const M3U_MIME_TYPE = 'audio/x-mpegurl'
|
export const M3U_MIME_TYPE = 'audio/x-mpegurl'
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ const getEventStream = async () => {
|
|||||||
if (!es) {
|
if (!es) {
|
||||||
// Call `keepalive` to refresh the jwt token
|
// Call `keepalive` to refresh the jwt token
|
||||||
await httpClient(`${REST_URL}/keepalive/keepalive`)
|
await httpClient(`${REST_URL}/keepalive/keepalive`)
|
||||||
es = new EventSource(
|
let url = baseUrl(`${REST_URL}/events`)
|
||||||
baseUrl(`${REST_URL}/events?jwt=${localStorage.getItem('token')}`)
|
if (localStorage.getItem('token')) {
|
||||||
)
|
url = url + `?jwt=${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
|
es = new EventSource(url)
|
||||||
}
|
}
|
||||||
return es
|
return es
|
||||||
}
|
}
|
||||||
@@ -64,7 +66,7 @@ const throttledEventHandler = throttle(eventHandler, 100, { trailing: true })
|
|||||||
|
|
||||||
const startEventStream = async () => {
|
const startEventStream = async () => {
|
||||||
setTimeout(currentIntervalCheck)
|
setTimeout(currentIntervalCheck)
|
||||||
if (!localStorage.getItem('token')) {
|
if (!localStorage.getItem('is-authenticated')) {
|
||||||
console.log('Cannot create a unauthenticated EventSource connection')
|
console.log('Cannot create a unauthenticated EventSource connection')
|
||||||
return Promise.reject()
|
return Promise.reject()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user