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:
Deluan Quintão
2021-06-13 12:46:36 -04:00
committed by GitHub
parent bed2f017af
commit 03efc48137
16 changed files with 298 additions and 317 deletions
+1 -1
View File
@@ -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 {
+1
View File
@@ -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")
+1
View File
@@ -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
View File
@@ -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) {
+1 -1
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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
} }
+32 -30
View File
@@ -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
View File
@@ -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
} }
+1 -1
View File
@@ -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)
+2 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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'
+6 -4
View File
@@ -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()
} }