diff --git a/cmd/root.go b/cmd/root.go index 667598520..9a165b2ca 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -74,7 +74,7 @@ func startServer() (func() error, func(err error)) { return func() error { a := CreateServer(conf.Server.MusicFolder) 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)) }, func(err error) { if err != nil { diff --git a/conf/configuration.go b/conf/configuration.go index 0b4cee520..b2f25fa51 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -204,6 +204,7 @@ func init() { viper.SetDefault("authwindowlength", 20*time.Second) viper.SetDefault("reverseproxyuserheader", "Remote-User") + viper.SetDefault("reverseproxywhitelist", "") viper.SetDefault("scanner.extractor", "taglib") viper.SetDefault("agents", "lastfm,spotify") diff --git a/consts/consts.go b/consts/consts.go index cd81c0eac..5bfd3c85d 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -22,6 +22,7 @@ const ( DevInitialName = "Dev Admin" URLPathUI = "/app" + URLPathNativeAPI = "/api" URLPathSubsonicAPI = "/rest" // Login backgrounds from https://unsplash.com/collections/20072696/navidrome diff --git a/server/app/app.go b/server/app/app.go index 59a4eecaf..419b7a3dd 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -8,80 +8,51 @@ import ( "github.com/deluan/rest" "github.com/go-chi/chi/v5" - "github.com/go-chi/httprate" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/server/events" - "github.com/navidrome/navidrome/ui" ) type Router struct { + http.Handler ds model.DataStore - mux http.Handler broker events.Broker share core.Share } 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) { - 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 { +func (app *Router) routes() http.Handler { r := chi.NewRouter() - if conf.Server.AuthRequestLimit > 0 { - log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit, - "windowLength", conf.Server.AuthWindowLength) + r.Use(server.Authenticator(app.ds)) + r.Use(server.JWTRefresher) + 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) - 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") + app.addPlaylistTrackRoute(r) - r.Post("/login", Login(app.ds)) - } - - 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) - } + // 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"}`)) }) - // Serve UI app assets - r.Handle("/", serveIndex(app.ds, ui.Assets())) - r.Handle("/*", http.StripPrefix(path, http.FileServer(http.FS(ui.Assets())))) + if conf.Server.DevActivityPanel { + r.Handle("/events", app.broker) + } 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) { r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { diff --git a/server/app/app_suite_test.go b/server/app/app_suite_test.go index 10f1a37a5..d940aef3a 100644 --- a/server/app/app_suite_test.go +++ b/server/app/app_suite_test.go @@ -9,7 +9,7 @@ import ( . "github.com/onsi/gomega" ) -func TestApp(t *testing.T) { +func TestNativeApi(t *testing.T) { tests.Init(t, false) log.SetLevel(log.LevelCritical) RegisterFailHandler(Fail) diff --git a/server/app/playlists.go b/server/app/playlists.go index 768e95ee1..01c71039e 100644 --- a/server/app/playlists.go +++ b/server/app/playlists.go @@ -16,6 +16,8 @@ import ( "github.com/navidrome/navidrome/utils" ) +type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc + func getPlaylist(ds model.DataStore) http.HandlerFunc { // Add a middleware to capture the playlistId wrapper := func(handler restHandler) http.HandlerFunc { diff --git a/server/app/auth.go b/server/auth.go similarity index 57% rename from server/app/auth.go rename to server/auth.go index 0aef3d179..87a8d4caf 100644 --- a/server/app/auth.go +++ b/server/auth.go @@ -1,4 +1,4 @@ -package app +package server import ( "context" @@ -8,7 +8,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net" "net/http" "strings" @@ -17,7 +16,6 @@ import ( "github.com/deluan/rest" "github.com/go-chi/jwtauth/v5" "github.com/google/uuid" - "github.com/lestrrat-go/jwx/jwt" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core/auth" @@ -28,12 +26,11 @@ import ( ) 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) { - auth.Init(ds) - +func login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { username, password, err := getCredentialsFromBody(r) if err != nil { @@ -42,60 +39,11 @@ func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) { return } - handleLogin(ds, username, password, w, r) + doLogin(ds, username, password, w, r) } } -func handleLoginFromHeaders(ds model.DataStore, r *http.Request) *map[string]interface{} { - 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) { +func doLogin(ds model.DataStore, username string, password string, w http.ResponseWriter, r *http.Request) { user, err := validateLogin(ds.User(r.Context()), username, password) if err != nil { _ = 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") return } - payload := buildPayload(user, tokenString) + payload := buildAuthPayload(user) + payload["token"] = tokenString _ = 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{}{ - "message": "User '" + user.UserName + "' authenticated successfully", - "token": tokenString, "id": user.ID, "name": user.Name, "username": user.UserName, @@ -128,37 +75,20 @@ func buildPayload(user *model.User, tokenString string) map[string]interface{} { if conf.Server.EnableGravatar && user.Email != "" { 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 { - 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 { - _, ipnet, err := net.ParseCIDR(cidr) - if err == nil && ipnet.Contains(testedIP) { - return true - } - } + subsonicToken := md5.Sum([]byte(user.Password + subsonicSalt)) + payload["subsonicToken"] = hex.EncodeToString(subsonicToken[:]) - return false + return payload } 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 } -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) 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") return } - err = createDefaultUser(r.Context(), ds, username, password) + err = createAdminUser(r.Context(), ds, username, password) if err != nil { _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) 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) now := time.Now() initialUser := model.User{ @@ -239,71 +169,162 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m return u, nil } -func contextWithUser(ctx context.Context, ds model.DataStore, token jwt.Token) context.Context { - userName := token.Subject() - user, _ := ds.User(ctx).FindByUsername(userName) - return request.WithUser(ctx, *user) -} - -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 +func contextWithUser(ctx context.Context, ds model.DataStore, username string) (context.Context, error) { + user, err := ds.User(ctx).FindByUsername(username) + if err != nil { + log.Error(ctx, "Authenticated username not found in DB", "username", username) + return ctx, err } - - c, err := ds.User(ctx).CountAll() - firstTime := c == 0 && err == nil - if firstTime { - return nil, ErrFirstTime - } - return nil, errors.New("invalid authentication") + return request.WithUser(ctx, *user), nil } // 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 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - bearer := r.Header.Get(consts.UIAuthorizationHeader) - r.Header.Set("Authorization", bearer) + ctx, err := authenticateRequest(ds, r, UsernameFromToken, UsernameFromReverseProxyHeader) + 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) - }) - } + 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 { - return func(next http.Handler) http.Handler { - return jwtauth.Verify(auth.TokenAuth, jwtauth.TokenFromHeader, jwtauth.TokenFromCookie, jwtauth.TokenFromQuery)(next) +func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} { + username := UsernameFromReverseProxyHeader(r) + 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 { - auth.Init(ds) - - 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)) - }) +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)) + + if err != nil { + return false + } + + for _, cidr := range cidrs { + _, ipnet, err := net.ParseCIDR(cidr) + if err == nil && ipnet.Contains(testedIP) { + return true + } + } + + return false } diff --git a/server/app/auth_test.go b/server/auth_test.go similarity index 81% rename from server/app/auth_test.go rename to server/auth_test.go index ba91b64c7..566b0476a 100644 --- a/server/app/auth_test.go +++ b/server/auth_test.go @@ -1,41 +1,44 @@ -package app +package server import ( "context" + "crypto/md5" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" "strings" "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/tests" - - "github.com/navidrome/navidrome/consts" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" ) var _ = Describe("Auth", func() { - Describe("Public functions", func() { + Describe("User login", func() { var ds model.DataStore var req *http.Request var resp *httptest.ResponseRecorder BeforeEach(func() { ds = &tests.MockDataStore{} + auth.Init(ds) }) - Describe("CreateAdmin", func() { + Describe("createAdmin", func() { BeforeEach(func() { req = httptest.NewRequest("POST", "/createAdmin", strings.NewReader(`{"username":"johndoe", "password":"secret"}`)) resp = httptest.NewRecorder() - CreateAdmin(ds)(resp, req) + createAdmin(ds)(resp, req) }) 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") Expect(err).To(BeNil()) Expect(u.Password).ToNot(BeEmpty()) @@ -57,6 +60,8 @@ var _ = Describe("Auth", func() { fs := os.DirFS("tests/fixtures") 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.Header.Add("Remote-User", "janedoe") resp = httptest.NewRecorder() @@ -65,9 +70,6 @@ var _ = Describe("Auth", 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" 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() { - 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" serveIndex(ds, fs)(resp, req) @@ -89,9 +88,6 @@ var _ = Describe("Auth", 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" 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() { - 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" serveIndex(ds, fs)(resp, req) @@ -112,12 +105,16 @@ var _ = Describe("Auth", func() { 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() { 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) config := extractAppConfig(resp.Body.String()) @@ -127,28 +124,33 @@ var _ = Describe("Auth", func() { Expect(parsed["isAdmin"]).To(BeFalse()) Expect(parsed["name"]).To(Equal("Jane")) Expect(parsed["username"]).To(Equal("janedoe")) - Expect(parsed["token"]).ToNot(BeEmpty()) Expect(parsed["subsonicSalt"]).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() { req = httptest.NewRequest("POST", "/login", strings.NewReader(`{"username":"janedoe", "password":"abc123"}`)) resp = httptest.NewRecorder() }) It("fails if user does not exist", func() { - Login(ds)(resp, req) + login(ds)(resp, req) Expect(resp.Code).To(Equal(http.StatusUnauthorized)) }) 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}) - Login(ds)(resp, req) + login(ds)(resp, req) Expect(resp.Code).To(Equal(http.StatusOK)) 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() { r := httptest.NewRequest("GET", "/index.html", nil) r.Header.Set(consts.UIAuthorizationHeader, "test authorization bearer") 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")) w.WriteHeader(200) })).ServeHTTP(w, r) diff --git a/server/app/serve_index.go b/server/serve_index.go similarity index 92% rename from server/app/serve_index.go rename to server/serve_index.go index f802a8042..8f6ff0efa 100644 --- a/server/app/serve_index.go +++ b/server/serve_index.go @@ -1,4 +1,4 @@ -package app +package server import ( "encoding/json" @@ -6,7 +6,6 @@ import ( "io/fs" "io/ioutil" "net/http" - "path" "strings" "github.com/microcosm-cc/bluemonday" @@ -18,13 +17,7 @@ import ( // Injects the config in the `index.html` template func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc { - policy := bluemonday.UGCPolicy() 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() firstTime := c == 0 && err == nil @@ -33,6 +26,7 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc { http.NotFound(w, r) return } + policy := bluemonday.UGCPolicy() appConfig := map[string]interface{}{ "version": consts.Version(), "firstTime": firstTime, @@ -53,7 +47,7 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc { } auth := handleLoginFromHeaders(ds, r) if auth != nil { - appConfig["auth"] = *auth + appConfig["auth"] = auth } j, err := json.Marshal(appConfig) if err != nil { diff --git a/server/app/serve_index_test.go b/server/serve_index_test.go similarity index 95% rename from server/app/serve_index_test.go rename to server/serve_index_test.go index ab6daa8dc..8ed084397 100644 --- a/server/app/serve_index_test.go +++ b/server/serve_index_test.go @@ -1,4 +1,4 @@ -package app +package server import ( "encoding/json" @@ -27,16 +27,6 @@ var _ = Describe("serveIndex", func() { 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() { r := httptest.NewRequest("GET", "/index.html", nil) w := httptest.NewRecorder() diff --git a/server/server.go b/server/server.go index 5392ab4fa..4c4d16473 100644 --- a/server/server.go +++ b/server/server.go @@ -8,18 +8,15 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" + "github.com/go-chi/httprate" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/ui" ) -type Handler interface { - http.Handler - Setup(path string) -} - type Server struct { router *chi.Mux ds model.DataStore @@ -34,10 +31,9 @@ func New(ds model.DataStore) *Server { 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) log.Info(fmt.Sprintf("Mounting %s routes", description), "path", urlPath) - subRouter.Setup(urlPath) a.router.Group(func(r chi.Router) { r.Mount(urlPath, subRouter) }) @@ -49,6 +45,8 @@ func (a *Server) Run(addr string) error { } func (a *Server) initRoutes() { + auth.Init(a.ds) + r := chi.NewRouter() r.Use(secureMiddleware()) @@ -61,11 +59,34 @@ func (a *Server) initRoutes() { r.Use(injectLogger) r.Use(requestLogger) r.Use(robotsTXT(ui.Assets())) + r.Use(authHeaderMapper) + r.Use(jwtVerifier) - indexHtml := path.Join(conf.Server.BaseURL, consts.URLPathUI) - r.Get("/*", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, indexHtml, 302) + r.Route(path.Join(conf.Server.BaseURL, "/auth"), func(r chi.Router) { + if conf.Server.AuthRequestLimit > 0 { + 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 } diff --git a/server/server_suite_test.go b/server/server_suite_test.go index 05bc6e47b..9677e3961 100644 --- a/server/server_suite_test.go +++ b/server/server_suite_test.go @@ -9,7 +9,7 @@ import ( . "github.com/onsi/gomega" ) -func TestSubsonicApi(t *testing.T) { +func TestServer(t *testing.T) { tests.Init(t, false) log.SetLevel(log.LevelCritical) RegisterFailHandler(Fail) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 903485ce3..b50b26a7f 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -24,6 +24,7 @@ const Version = "1.16.1" type handler = func(http.ResponseWriter, *http.Request) (*responses.Subsonic, error) type Router struct { + http.Handler DataStore model.DataStore Artwork core.Artwork Streamer core.MediaStreamer @@ -32,8 +33,6 @@ type Router struct { ExternalMetadata core.ExternalMetadata Scanner scanner.Scanner Broker events.Broker - - mux http.Handler } 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, Broker: broker, } - r.mux = r.routes() + r.Handler = r.routes() 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 { r := chi.NewRouter() diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index cfbad45ff..f569aa476 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -1,31 +1,35 @@ import jwtDecode from 'jwt-decode' -import md5 from 'blueimp-md5' -import { v4 as uuidv4 } from 'uuid' import { baseUrl } from './utils' import config from './config' 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) { try { - jwtDecode(config.auth.token) - 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) + storeAuthenticationInfo(config.auth) } catch (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 = { login: ({ username, password }) => { - let url = baseUrl('/app/login') + let url = baseUrl('/auth/login') if (config.firstTime) { - url = baseUrl('/app/createAdmin') + url = baseUrl('/auth/createAdmin') } const request = new Request(url, { method: 'POST', @@ -40,22 +44,9 @@ const authProvider = { return response.json() }) .then((response) => { - // Validate token - jwtDecode(response.token) - // TODO Store all items in one object - 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 + jwtDecode(response.token) // Validate token + storeAuthenticationInfo(response) + // Avoid "going to create admin" dialog after logout/login without a refresh config.firstTime = false if (config.devActivityPanel) { startEventStream() @@ -86,7 +77,9 @@ const authProvider = { }, checkAuth: () => - localStorage.getItem('token') ? Promise.resolve() : Promise.reject(), + localStorage.getItem('is-authenticated') + ? Promise.resolve() + : Promise.reject(), checkError: ({ status }) => { if (status === 401) { @@ -119,6 +112,7 @@ const removeItems = () => { localStorage.removeItem('role') localStorage.removeItem('subsonic-salt') localStorage.removeItem('subsonic-token') + localStorage.removeItem('is-authenticated') } 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 diff --git a/ui/src/consts.js b/ui/src/consts.js index 3dc6bcbf2..140e2b3ae 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -1,4 +1,4 @@ -export const REST_URL = '/app/api' +export const REST_URL = '/api' export const M3U_MIME_TYPE = 'audio/x-mpegurl' diff --git a/ui/src/eventStream.js b/ui/src/eventStream.js index 61ab0630d..ca8931d6e 100644 --- a/ui/src/eventStream.js +++ b/ui/src/eventStream.js @@ -15,9 +15,11 @@ const getEventStream = async () => { if (!es) { // Call `keepalive` to refresh the jwt token await httpClient(`${REST_URL}/keepalive/keepalive`) - es = new EventSource( - baseUrl(`${REST_URL}/events?jwt=${localStorage.getItem('token')}`) - ) + let url = baseUrl(`${REST_URL}/events`) + if (localStorage.getItem('token')) { + url = url + `?jwt=${localStorage.getItem('token')}` + } + es = new EventSource(url) } return es } @@ -64,7 +66,7 @@ const throttledEventHandler = throttle(eventHandler, 100, { trailing: true }) const startEventStream = async () => { setTimeout(currentIntervalCheck) - if (!localStorage.getItem('token')) { + if (!localStorage.getItem('is-authenticated')) { console.log('Cannot create a unauthenticated EventSource connection') return Promise.reject() }