diff --git a/engine/auth/auth.go b/engine/auth/auth.go new file mode 100644 index 000000000..4ca5c86f7 --- /dev/null +++ b/engine/auth/auth.go @@ -0,0 +1,64 @@ +package auth + +import ( + "fmt" + "sync" + "time" + + "github.com/deluan/navidrome/consts" + "github.com/deluan/navidrome/log" + "github.com/deluan/navidrome/model" + "github.com/dgrijalva/jwt-go" + "github.com/go-chi/jwtauth" +) + +var ( + once sync.Once + JwtSecret []byte + TokenAuth *jwtauth.JWTAuth +) + +func InitTokenAuth(ds model.DataStore) { + once.Do(func() { + secret, err := ds.Property(nil).DefaultGet(consts.JWTSecretKey, "not so secret") + if err != nil { + log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err) + } + JwtSecret = []byte(secret) + TokenAuth = jwtauth.New("HS256", JwtSecret, nil) + }) +} + +func CreateToken(u *model.User) (string, error) { + token := jwt.New(jwt.SigningMethodHS256) + claims := token.Claims.(jwt.MapClaims) + claims["iss"] = consts.JWTIssuer + claims["sub"] = u.UserName + claims["adm"] = u.IsAdmin + + return TouchToken(token) +} + +func TouchToken(token *jwt.Token) (string, error) { + expireIn := time.Now().Add(consts.JWTTokenExpiration).Unix() + claims := token.Claims.(jwt.MapClaims) + claims["exp"] = expireIn + + return token.SignedString(JwtSecret) +} + +func Validate(tokenStr string) (jwt.MapClaims, error) { + token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + // Don't forget to validate the alg is what you expect: + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) + } + + // hmacSampleSecret is a []byte containing your secret, e.g. []byte("my_secret_key") + return JwtSecret, nil + }) + if err != nil { + return nil, err + } + return token.Claims.(jwt.MapClaims), err +} diff --git a/engine/auth/auth_test.go b/engine/auth/auth_test.go new file mode 100644 index 000000000..621d2556e --- /dev/null +++ b/engine/auth/auth_test.go @@ -0,0 +1,55 @@ +package auth_test + +import ( + "testing" + "time" + + "github.com/deluan/navidrome/engine/auth" + "github.com/deluan/navidrome/log" + "github.com/dgrijalva/jwt-go" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestAuth(t *testing.T) { + log.SetLevel(log.LevelCritical) + RegisterFailHandler(Fail) + RunSpecs(t, "Auth Test Suite") +} + +const testJWTSecret = "not so secret" + +var _ = Describe("Auth", func() { + BeforeEach(func() { + auth.JwtSecret = []byte(testJWTSecret) + }) + Context("Validate", func() { + It("returns error with an invalid JWT token", func() { + _, err := auth.Validate("invalid.token") + Expect(err).To(Not(BeNil())) + }) + + It("returns the claims from a valid JWT token", func() { + token := jwt.New(jwt.SigningMethodHS256) + claims := token.Claims.(jwt.MapClaims) + claims["iss"] = "issuer" + claims["exp"] = time.Now().Add(1 * time.Minute).Unix() + tokenStr, _ := token.SignedString(auth.JwtSecret) + + decodedClaims, err := auth.Validate(tokenStr) + Expect(err).To(BeNil()) + Expect(decodedClaims["iss"]).To(Equal("issuer")) + }) + + It("returns ErrExpired if the `exp` field is in the past", func() { + token := jwt.New(jwt.SigningMethodHS256) + claims := token.Claims.(jwt.MapClaims) + claims["iss"] = "issuer" + claims["exp"] = time.Now().Add(-1 * time.Minute).Unix() + tokenStr, _ := token.SignedString(auth.JwtSecret) + + _, err := auth.Validate(tokenStr) + Expect(err).To(MatchError("Token is expired")) + }) + }) +}) diff --git a/engine/users.go b/engine/users.go index 94b505e7d..e30fd80b7 100644 --- a/engine/users.go +++ b/engine/users.go @@ -7,11 +7,12 @@ import ( "fmt" "strings" + "github.com/deluan/navidrome/engine/auth" "github.com/deluan/navidrome/model" ) type Users interface { - Authenticate(ctx context.Context, username, password, token, salt string) (*model.User, error) + Authenticate(ctx context.Context, username, password, token, salt, jwt string) (*model.User, error) } func NewUsers(ds model.DataStore) Users { @@ -22,7 +23,7 @@ type users struct { ds model.DataStore } -func (u *users) Authenticate(ctx context.Context, username, pass, token, salt string) (*model.User, error) { +func (u *users) Authenticate(ctx context.Context, username, pass, token, salt, jwt string) (*model.User, error) { user, err := u.ds.User(ctx).FindByUsername(username) if err == model.ErrNotFound { return nil, model.ErrInvalidAuth @@ -33,6 +34,9 @@ func (u *users) Authenticate(ctx context.Context, username, pass, token, salt st valid := false switch { + case jwt != "": + claims, err := auth.Validate(jwt) + valid = err == nil && claims["sub"] == username case pass != "": if strings.HasPrefix(pass, "enc:") { if dec, err := hex.DecodeString(pass[4:]); err == nil { diff --git a/engine/users_test.go b/engine/users_test.go index 07068f4ee..f93f98e14 100644 --- a/engine/users_test.go +++ b/engine/users_test.go @@ -3,6 +3,7 @@ package engine import ( "context" + "github.com/deluan/navidrome/engine/auth" "github.com/deluan/navidrome/model" "github.com/deluan/navidrome/persistence" . "github.com/onsi/ginkgo" @@ -19,20 +20,20 @@ var _ = Describe("Users", func() { Context("Plaintext password", func() { It("authenticates with plaintext password ", func() { - usr, err := users.Authenticate(context.TODO(), "admin", "wordpass", "", "") + usr, err := users.Authenticate(context.TODO(), "admin", "wordpass", "", "", "") Expect(err).NotTo(HaveOccurred()) Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"})) }) It("fails authentication with wrong password", func() { - _, err := users.Authenticate(context.TODO(), "admin", "INVALID", "", "") + _, err := users.Authenticate(context.TODO(), "admin", "INVALID", "", "", "") Expect(err).To(MatchError(model.ErrInvalidAuth)) }) }) Context("Encoded password", func() { It("authenticates with simple encoded password ", func() { - usr, err := users.Authenticate(context.TODO(), "admin", "enc:776f726470617373", "", "") + usr, err := users.Authenticate(context.TODO(), "admin", "enc:776f726470617373", "", "", "") Expect(err).NotTo(HaveOccurred()) Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"})) }) @@ -40,13 +41,41 @@ var _ = Describe("Users", func() { Context("Token based authentication", func() { It("authenticates with token based authentication", func() { - usr, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt") + usr, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt", "") Expect(err).NotTo(HaveOccurred()) Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"})) }) It("fails if salt is missing", func() { - _, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "") + _, err := users.Authenticate(context.TODO(), "admin", "", "23b342970e25c7928831c3317edd0b67", "", "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + }) + + Context("JWT based authentication", func() { + var validToken string + BeforeEach(func() { + u := &model.User{UserName: "admin"} + var err error + validToken, err = auth.CreateToken(u) + if err != nil { + panic(err) + } + }) + It("authenticates with JWT token based authentication", func() { + usr, err := users.Authenticate(context.TODO(), "admin", "", "", "", validToken) + + Expect(err).NotTo(HaveOccurred()) + Expect(usr).To(Equal(&model.User{UserName: "admin", Password: "wordpass"})) + }) + + It("fails if JWT token is invalid", func() { + _, err := users.Authenticate(context.TODO(), "admin", "", "", "", "invalid.token") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + + It("fails if JWT token sub is different than username", func() { + _, err := users.Authenticate(context.TODO(), "not_admin", "", "", "", validToken) Expect(err).To(MatchError(model.ErrInvalidAuth)) }) }) diff --git a/server/app/app.go b/server/app/app.go index 6343d01ec..25d9b66a5 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/deluan/navidrome/assets" + "github.com/deluan/navidrome/engine/auth" "github.com/deluan/navidrome/model" "github.com/deluan/rest" "github.com/go-chi/chi" @@ -36,7 +37,7 @@ func (app *Router) routes() http.Handler { r.Post("/createAdmin", CreateAdmin(app.ds)) r.Route("/api", func(r chi.Router) { - r.Use(jwtauth.Verifier(TokenAuth)) + r.Use(jwtauth.Verifier(auth.TokenAuth)) r.Use(Authenticator(app.ds)) app.R(r, "/user", model.User{}) app.R(r, "/song", model.MediaFile{}) diff --git a/server/app/auth.go b/server/app/auth.go index 2783bc314..1eb00d683 100644 --- a/server/app/auth.go +++ b/server/app/auth.go @@ -10,6 +10,7 @@ import ( "time" "github.com/deluan/navidrome/consts" + "github.com/deluan/navidrome/engine/auth" "github.com/deluan/navidrome/log" "github.com/deluan/navidrome/model" "github.com/deluan/rest" @@ -20,13 +21,11 @@ import ( var ( once sync.Once - jwtSecret []byte - TokenAuth *jwtauth.JWTAuth ErrFirstTime = errors.New("no users created") ) func Login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) { - initTokenAuth(ds) + auth.InitTokenAuth(ds) return func(w http.ResponseWriter, r *http.Request) { username, password, err := getCredentialsFromBody(r) @@ -52,7 +51,7 @@ func handleLogin(ds model.DataStore, username string, password string, w http.Re return } - tokenString, err := createToken(user) + tokenString, err := auth.CreateToken(user) if err != nil { rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again") return @@ -82,7 +81,7 @@ func getCredentialsFromBody(r *http.Request) (username string, password string, } func CreateAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) { - initTokenAuth(ds) + auth.InitTokenAuth(ds) return func(w http.ResponseWriter, r *http.Request) { username, password, err := getCredentialsFromBody(r) @@ -129,16 +128,6 @@ func createDefaultUser(ctx context.Context, ds model.DataStore, username, passwo return nil } -func initTokenAuth(ds model.DataStore) { - once.Do(func() { - secret, err := ds.Property(nil).DefaultGet(consts.JWTSecretKey, "not so secret") - if err != nil { - log.Error("No JWT secret found in DB. Setting a temp one, but please report this error", err) - } - jwtSecret = []byte(secret) - TokenAuth = jwtauth.New("HS256", jwtSecret, nil) - }) -} func validateLogin(userRepo model.UserRepository, userName, password string) (*model.User, error) { u, err := userRepo.FindByUsername(userName) if err == model.ErrNotFound { @@ -157,24 +146,6 @@ func validateLogin(userRepo model.UserRepository, userName, password string) (*m return u, nil } -func createToken(u *model.User) (string, error) { - token := jwt.New(jwt.SigningMethodHS256) - claims := token.Claims.(jwt.MapClaims) - claims["iss"] = consts.JWTIssuer - claims["sub"] = u.UserName - claims["adm"] = u.IsAdmin - - return touchToken(token) -} - -func touchToken(token *jwt.Token) (string, error) { - expireIn := time.Now().Add(consts.JWTTokenExpiration).Unix() - claims := token.Claims.(jwt.MapClaims) - claims["exp"] = expireIn - - return token.SignedString(jwtSecret) -} - func contextWithUser(ctx context.Context, ds model.DataStore, claims jwt.MapClaims) context.Context { userName := claims["sub"].(string) user, _ := ds.User(ctx).FindByUsername(userName) @@ -199,7 +170,7 @@ func getToken(ds model.DataStore, ctx context.Context) (*jwt.Token, error) { } func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { - initTokenAuth(ds) + auth.InitTokenAuth(ds) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -216,7 +187,7 @@ func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler { claims := token.Claims.(jwt.MapClaims) newCtx := contextWithUser(r.Context(), ds, claims) - newTokenString, err := touchToken(token) + newTokenString, err := auth.TouchToken(token) if err != nil { log.Error(r, "signing new token", err) rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index f4d38e6bf..eb504bf5e 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -44,10 +44,6 @@ func checkRequiredParameters(next http.Handler) http.Handler { } } - if ParamString(r, "p") == "" && (ParamString(r, "s") == "" || ParamString(r, "t") == "") { - log.Warn(r, "Missing authentication information") - } - user := ParamString(r, "u") client := ParamString(r, "c") version := ParamString(r, "v") @@ -69,8 +65,9 @@ func authenticate(users engine.Users) func(next http.Handler) http.Handler { pass := ParamString(r, "p") token := ParamString(r, "t") salt := ParamString(r, "s") + jwt := ParamString(r, "jwt") - usr, err := users.Authenticate(r.Context(), username, pass, token, salt) + usr, err := users.Authenticate(r.Context(), username, pass, token, salt, jwt) if err == model.ErrInvalidAuth { log.Warn(r, "Invalid login", "username", username, err) } else if err != nil { diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index b3c92ae47..e5573e18e 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -113,7 +113,7 @@ var _ = Describe("Middlewares", func() { }) It("passes all parameters to users.Authenticate ", func() { - r := newGetRequest("u=valid", "p=password", "t=token", "s=salt") + r := newGetRequest("u=valid", "p=password", "t=token", "s=salt", "jwt=jwt") cp := authenticate(mockedUser)(next) cp.ServeHTTP(w, r) @@ -121,6 +121,7 @@ var _ = Describe("Middlewares", func() { Expect(mockedUser.password).To(Equal("password")) Expect(mockedUser.token).To(Equal("token")) Expect(mockedUser.salt).To(Equal("salt")) + Expect(mockedUser.jwt).To(Equal("jwt")) Expect(next.called).To(BeTrue()) user := next.req.Context().Value("user").(*model.User) Expect(user.UserName).To(Equal("valid")) @@ -149,14 +150,15 @@ func (mh *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { type mockUsers struct { engine.Users - username, password, token, salt string + username, password, token, salt, jwt string } -func (m *mockUsers) Authenticate(ctx context.Context, username, password, token, salt string) (*model.User, error) { +func (m *mockUsers) Authenticate(ctx context.Context, username, password, token, salt, jwt string) (*model.User, error) { m.username = username m.password = password m.token = token m.salt = salt + m.jwt = jwt if username == "valid" { return &model.User{UserName: username, Password: password}, nil } diff --git a/ui/src/player/Player.js b/ui/src/player/Player.js index 25a25ffad..0b0350356 100644 --- a/ui/src/player/Player.js +++ b/ui/src/player/Player.js @@ -57,7 +57,9 @@ const Player = () => { if (item && !item.scrobbled) { dispatch(scrobble(info.id)) fetchUtils.fetchJson( - `/rest/scrobble?u=admin&p=enc:73756e6461&f=json&v=1.8.0&c=NavidromeUI&id=${info.id}&submission=true` + `/rest/scrobble?u=admin&jwt=${localStorage.getItem( + 'token' + )}&f=json&v=1.8.0&c=NavidromeUI&id=${info.id}&submission=true` ) } } @@ -65,7 +67,9 @@ const Player = () => { const OnAudioPlay = (info) => { if (info.duration) { fetchUtils.fetchJson( - `/rest/scrobble?u=admin&p=enc:73756e6461&f=json&v=1.8.0&c=NavidromeUI&id=${info.id}&submission=false` + `/rest/scrobble?u=admin&jwt=${localStorage.getItem( + 'token' + )}&f=json&v=1.8.0&c=NavidromeUI&id=${info.id}&submission=false` ) dataProvider.getOne('keepalive', { id: info.id }) } diff --git a/ui/src/player/queue.js b/ui/src/player/queue.js index 1f18da672..422859930 100644 --- a/ui/src/player/queue.js +++ b/ui/src/player/queue.js @@ -9,10 +9,12 @@ const mapToAudioLists = (item) => ({ id: item.id, name: item.title, singer: item.artist, - cover: `/rest/getCoverArt?u=admin&p=enc:73756e6461&f=json&v=1.8.0&c=NavidromeUI&size=300&id=${item.id}`, - musicSrc: `/rest/stream?u=admin&p=enc:73756e6461&f=json&v=1.8.0&c=NavidromeUI&id=${ + cover: `/rest/getCoverArt?u=admin&f=json&v=1.8.0&c=NavidromeUI&size=300&id=${ item.id - }&_=${new Date().getTime()}` + }&jwt=${localStorage.getItem('token')}`, + musicSrc: `/rest/stream?u=admin&f=json&v=1.8.0&c=NavidromeUI&jwt=${localStorage.getItem( + 'token' + )}&id=${item.id}&_=${new Date().getTime()}` }) const addTrack = (data) => ({