Implement Last.FM Web authentication flow

This commit is contained in:
Deluan 2021-06-19 14:07:26 -04:00 committed by Deluan Quintão
parent 502a719e96
commit 143cde37e5
3 changed files with 89 additions and 130 deletions

View File

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

View File

@ -0,0 +1 @@
Success! Your account is linked to Last.FM. You can close this tab now.

View File

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