From 143cde37e50661334af18c2de3666024484c5bd8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 19 Jun 2021 14:07:26 -0400 Subject: [PATCH] Implement Last.FM Web authentication flow --- core/agents/lastfm/auth_router.go | 206 +++++++++---------------- core/agents/lastfm/token_received.html | 1 + tests/mock_property_repo.go | 12 ++ 3 files changed, 89 insertions(+), 130 deletions(-) create mode 100644 core/agents/lastfm/token_received.html diff --git a/core/agents/lastfm/auth_router.go b/core/agents/lastfm/auth_router.go index 7fce1dd17..1dee44e57 100644 --- a/core/agents/lastfm/auth_router.go +++ b/core/agents/lastfm/auth_router.go @@ -1,111 +1,83 @@ package lastfm import ( + "bytes" "context" - "errors" - "fmt" + _ "embed" "net/http" - "net/url" "time" - "github.com/navidrome/navidrome/model/request" - "github.com/navidrome/navidrome/server" - - "github.com/ReneKroon/ttlcache/v2" - "github.com/deluan/rest" - "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/utils" ) -const ( - authURL = "https://www.last.fm/api/auth/" - sessionKeyPropertyPrefix = "LastFMSessionKey_" -) - -var ( - ErrLinkPending = errors.New("linking pending") - ErrUnlinked = errors.New("account not linked") -) +//go:embed token_received.html +var tokenReceivedPage []byte type Router struct { http.Handler - ds model.DataStore - client *Client - sessionMan *sessionMan - apiKey string - secret string + ds model.DataStore + sessionKeys *sessionKeys + client *Client + apiKey string + secret string } func NewRouter(ds model.DataStore) *Router { r := &Router{ds: ds, apiKey: lastFMAPIKey, secret: lastFMAPISecret} + r.sessionKeys = &sessionKeys{ds: ds} r.Handler = r.routes() if conf.Server.LastFM.ApiKey != "" { r.apiKey = conf.Server.LastFM.ApiKey r.secret = conf.Server.LastFM.Secret } r.client = NewClient(r.apiKey, r.secret, "en", http.DefaultClient) - r.sessionMan = newSessionMan(ds, r.client) return r } func (s *Router) routes() http.Handler { r := chi.NewRouter() - r.Use(server.Authenticator(s.ds)) - r.Use(server.JWTRefresher) + r.Group(func(r chi.Router) { + r.Use(server.Authenticator(s.ds)) + r.Use(server.JWTRefresher) - r.Get("/link", s.starLink) - r.Get("/link/status", s.getLinkStatus) - r.Delete("/link", s.unlink) + r.Get("/link", s.getLinkStatus) + r.Delete("/link", s.unlink) + }) + + r.Get("/link/callback", s.callback) return r } -func (s *Router) starLink(w http.ResponseWriter, r *http.Request) { - token, err := s.client.GetToken(r.Context()) - if err != nil { - log.Error(r.Context(), "Error obtaining token from LastFM", err) - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(fmt.Sprintf("Error obtaining token from LastFM: %s", err))) - return - } - username, _ := request.UsernameFrom(r.Context()) - s.sessionMan.FetchSession(username, token) - params := url.Values{} - params.Add("api_key", s.apiKey) - params.Add("token", token) - http.Redirect(w, r, authURL+"?"+params.Encode(), http.StatusFound) -} - func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - username, _ := request.UsernameFrom(ctx) - _, err := s.sessionMan.Session(ctx, username) - resp := map[string]string{"status": "linked"} - if err != nil { - switch err { - case ErrLinkPending: - resp["status"] = "pending" - case ErrUnlinked: - resp["status"] = "unlinked" - default: - resp["status"] = "unlinked" - resp["error"] = err.Error() - _ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp) - return - } + u, _ := request.UserFrom(ctx) + + resp := map[string]interface{}{"status": true} + key, err := s.sessionKeys.get(ctx, u.ID) + if err != nil && err != model.ErrNotFound { + resp["error"] = err + resp["status"] = false + _ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp) + return } + resp["status"] = key != "" _ = rest.RespondWithJSON(w, http.StatusOK, resp) } func (s *Router) unlink(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - username, _ := request.UsernameFrom(ctx) - err := s.sessionMan.RemoveSession(ctx, username) + u, _ := request.UserFrom(ctx) + + err := s.sessionKeys.delete(ctx, u.ID) if err != nil { _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) } else { @@ -113,83 +85,57 @@ func (s *Router) unlink(w http.ResponseWriter, r *http.Request) { } } -type sessionMan struct { - ds model.DataStore - client *Client - tokens *ttlcache.Cache +func (s *Router) callback(w http.ResponseWriter, r *http.Request) { + token := utils.ParamString(r, "token") + if token == "" { + _ = rest.RespondWithError(w, http.StatusBadRequest, "token not received") + return + } + uid := utils.ParamString(r, "uid") + if uid == "" { + _ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received") + return + } + + ctx := r.Context() + err := s.fetchSessionKey(ctx, uid, token) + if err != nil { + _ = rest.RespondWithError(w, http.StatusBadRequest, err.Error()) + return + } + + http.ServeContent(w, r, "response", time.Now(), bytes.NewReader(tokenReceivedPage)) } -func newSessionMan(ds model.DataStore, client *Client) *sessionMan { - s := &sessionMan{ - ds: ds, - client: client, +func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error { + sessionKey, err := s.client.GetSession(ctx, token) + if err != nil { + log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token, err) + return err } - s.tokens = ttlcache.NewCache() - s.tokens.SetCacheSizeLimit(0) - _ = s.tokens.SetTTL(30 * time.Second) - s.tokens.SkipTTLExtensionOnHit(true) - go s.run() - return s + err = s.sessionKeys.put(ctx, uid, sessionKey) + if err != nil { + log.Error("Could not save LastFM session key", "userId", uid, err) + } + return err } -func (s *sessionMan) FetchSession(username, token string) { - _ = s.ds.Property(context.Background()).Delete(sessionKeyPropertyPrefix + username) - _ = s.tokens.Set(username, token) +const ( + sessionKeyPropertyPrefix = "LastFMSessionKey_" +) + +type sessionKeys struct { + ds model.DataStore } -func (s *sessionMan) Session(ctx context.Context, username string) (string, error) { - properties := s.ds.Property(context.Background()) - key, err := properties.Get(sessionKeyPropertyPrefix + username) - if key != "" { - return key, nil - } - if err != nil && err != model.ErrNotFound { - return "", err - } - _, err = s.tokens.Get(username) - if err == nil { - return "", ErrLinkPending - } - return "", ErrUnlinked +func (sk *sessionKeys) put(ctx context.Context, uid string, sessionKey string) error { + return sk.ds.Property(ctx).Put(sessionKeyPropertyPrefix+uid, sessionKey) } -func (s *sessionMan) RemoveSession(ctx context.Context, username string) error { - _ = s.tokens.Remove(username) - properties := s.ds.Property(context.Background()) - return properties.Delete(sessionKeyPropertyPrefix + username) +func (sk *sessionKeys) get(ctx context.Context, uid string) (string, error) { + return sk.ds.Property(ctx).Get(sessionKeyPropertyPrefix + uid) } -func (s *sessionMan) run() { - t := time.NewTicker(2 * time.Second) - defer t.Stop() - for { - <-t.C - if s.tokens.Count() == 0 { - continue - } - s.fetchSessions() - } -} - -func (s *sessionMan) fetchSessions() { - ctx := context.Background() - for _, username := range s.tokens.GetKeys() { - token, err := s.tokens.Get(username) - if err != nil { - log.Error("Error retrieving token from cache", "username", username, err) - _ = s.tokens.Remove(username) - continue - } - sessionKey, err := s.client.GetSession(ctx, token.(string)) - log.Debug(ctx, "Fetching session", "username", username, "sessionKey", sessionKey, "token", token, err) - if err != nil { - continue - } - properties := s.ds.Property(ctx) - err = properties.Put(sessionKeyPropertyPrefix+username, sessionKey) - if err != nil { - log.Error("Could not save LastFM session key", "username", username, err) - } - _ = s.tokens.Remove(username) - } +func (sk *sessionKeys) delete(ctx context.Context, uid string) error { + return sk.ds.Property(ctx).Delete(sessionKeyPropertyPrefix + uid) } diff --git a/core/agents/lastfm/token_received.html b/core/agents/lastfm/token_received.html new file mode 100644 index 000000000..1bbd6a256 --- /dev/null +++ b/core/agents/lastfm/token_received.html @@ -0,0 +1 @@ +Success! Your account is linked to Last.FM. You can close this tab now. diff --git a/tests/mock_property_repo.go b/tests/mock_property_repo.go index dfc9c7c96..2dd789ece 100644 --- a/tests/mock_property_repo.go +++ b/tests/mock_property_repo.go @@ -34,6 +34,18 @@ func (p *MockedPropertyRepo) Get(id string) (string, error) { return "", model.ErrNotFound } +func (p *MockedPropertyRepo) Delete(id string) error { + if p.err != nil { + return p.err + } + p.init() + if _, ok := p.data[id]; ok { + delete(p.data, id) + return nil + } + return model.ErrNotFound +} + func (p *MockedPropertyRepo) DefaultGet(id string, defaultValue string) (string, error) { if p.err != nil { return "", p.err