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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 298 additions and 317 deletions

View File

@ -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 {

View File

@ -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")

View File

@ -22,6 +22,7 @@ const (
DevInitialName = "Dev Admin"
URLPathUI = "/app"
URLPathNativeAPI = "/api"
URLPathSubsonicAPI = "/rest"
// Login backgrounds from https://unsplash.com/collections/20072696/navidrome

View File

@ -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) {

View File

@ -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)

View File

@ -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 {

View File

@ -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
}

View File

@ -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)

View File

@ -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 {

View File

@ -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()

View File

@ -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
}

View File

@ -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)

View File

@ -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()

View File

@ -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

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'

View File

@ -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()
}