From 5b8f4788f952d995279502263164ca1cde9fb678 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 23 Nov 2023 23:14:47 +0100 Subject: [PATCH] lastfm: authentication --- cmd/auth.go | 18 +++++----- go.mod | 1 + go.sum | 2 ++ internal/auth/callback.go | 4 +-- internal/auth/strategy.go | 21 ++++++++++-- internal/backends/backends.go | 2 ++ internal/backends/deezer/auth.go | 9 +++-- internal/backends/lastfm/auth.go | 51 ++++++++++++++++++++++++++++ internal/backends/lastfm/lastfm.go | 54 ++++++++++++++++++++++++++++++ scotty.example.toml | 11 ++++++ 10 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 internal/backends/lastfm/auth.go create mode 100644 internal/backends/lastfm/lastfm.go diff --git a/cmd/auth.go b/cmd/auth.go index 810c41a..67b7411 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -43,10 +43,6 @@ var authCmd = &cobra.Command{ redirectURL, err := backends.BuildRedirectURL(viper.GetViper(), backend.Name()) cobra.CheckErr(err) - // Start an HTTP server to listen for the response - responseChan := make(chan auth.CodeResponse) - auth.RunOauth2CallbackServer(*redirectURL, responseChan) - // The backend must provide an authentication strategy strategy := backend.OAuth2Strategy(redirectURL) @@ -56,14 +52,20 @@ var authCmd = &cobra.Command{ state := "somestate" // FIXME: Should be a random string // Redirect user to consent page to ask for permission specified scopes. - url := strategy.AuthCodeURL(verifier, state) - fmt.Printf("Visit the URL for the auth dialog: %v\n", url) - err = browser.OpenURL(url) + authUrl := strategy.AuthCodeURL(verifier, state) + + // Start an HTTP server to listen for the response + responseChan := make(chan auth.CodeResponse) + auth.RunOauth2CallbackServer(*redirectURL, authUrl.Param, responseChan) + + // Open the URL + fmt.Printf("Visit the URL for the auth dialog: %v\n", authUrl.Url) + err = browser.OpenURL(authUrl.Url) cobra.CheckErr(err) // Retrieve the code from the authentication callback code := <-responseChan - if code.State != state { + if code.State != authUrl.State { cobra.CompErrorln("Error: oauth state mismatch") os.Exit(1) } diff --git a/go.mod b/go.mod index f6e61af..0d85553 100644 --- a/go.mod +++ b/go.mod @@ -44,6 +44,7 @@ require ( github.com/rivo/uniseg v0.4.4 // indirect github.com/sagikazarmark/locafero v0.3.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.10.0 // indirect github.com/spf13/cast v1.5.1 // indirect diff --git a/go.sum b/go.sum index 84e28c7..51d651e 100644 --- a/go.sum +++ b/go.sum @@ -222,6 +222,8 @@ github.com/sagikazarmark/locafero v0.3.0 h1:zT7VEGWC2DTflmccN/5T1etyKvxSxpHsjb9c github.com/sagikazarmark/locafero v0.3.0/go.mod h1:w+v7UsPNFwzF1cHuOajOOzoq4U7v/ig1mpRjqV+Bu1U= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 h1:cgqwZtnR+IQfUYDLJ3Kiy4aE+O/wExTzEIg8xwC4Qfs= +github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0/go.mod h1:n3nudMl178cEvD44PaopxH9jhJaQzthSxUzLO5iKMy4= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY= diff --git a/internal/auth/callback.go b/internal/auth/callback.go index 4fa41f6..0ad9c9d 100644 --- a/internal/auth/callback.go +++ b/internal/auth/callback.go @@ -21,9 +21,9 @@ import ( "net/url" ) -func RunOauth2CallbackServer(redirectURL url.URL, responseChan chan CodeResponse) { +func RunOauth2CallbackServer(redirectURL url.URL, param string, responseChan chan CodeResponse) { http.HandleFunc(redirectURL.Path, func(w http.ResponseWriter, r *http.Request) { - code := r.URL.Query().Get("code") + code := r.URL.Query().Get(param) state := r.URL.Query().Get("state") fmt.Fprint(w, "Token received, you can close this window now.") responseChan <- CodeResponse{ diff --git a/internal/auth/strategy.go b/internal/auth/strategy.go index 403c429..3d03fa4 100644 --- a/internal/auth/strategy.go +++ b/internal/auth/strategy.go @@ -24,11 +24,21 @@ import ( type OAuth2Strategy interface { Config() oauth2.Config - AuthCodeURL(verifier string, state string) string + AuthCodeURL(verifier string, state string) AuthUrl ExchangeToken(code CodeResponse, verifier string) (*oauth2.Token, error) } +type AuthUrl struct { + // The URL the user must visit to approve access + Url string + // Random state string passed on to the callback. + // Leave empty if the service does not support state. + State string + // Parameter name of the code passed on to the callback (usually "code") + Param string +} + type CodeResponse struct { Code string State string @@ -46,8 +56,13 @@ func (s StandardStrategy) Config() oauth2.Config { return s.conf } -func (s StandardStrategy) AuthCodeURL(verifier string, state string) string { - return s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) +func (s StandardStrategy) AuthCodeURL(verifier string, state string) AuthUrl { + url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) + return AuthUrl{ + Url: url, + State: state, + Param: "code", + } } func (s StandardStrategy) ExchangeToken(code CodeResponse, verifier string) (*oauth2.Token, error) { diff --git a/internal/backends/backends.go b/internal/backends/backends.go index cf78dad..029cca7 100644 --- a/internal/backends/backends.go +++ b/internal/backends/backends.go @@ -27,6 +27,7 @@ import ( "go.uploadedlobster.com/scotty/internal/backends/dump" "go.uploadedlobster.com/scotty/internal/backends/funkwhale" "go.uploadedlobster.com/scotty/internal/backends/jspf" + "go.uploadedlobster.com/scotty/internal/backends/lastfm" "go.uploadedlobster.com/scotty/internal/backends/listenbrainz" "go.uploadedlobster.com/scotty/internal/backends/maloja" "go.uploadedlobster.com/scotty/internal/backends/scrobblerlog" @@ -80,6 +81,7 @@ var knownBackends = map[string]func() models.Backend{ "dump": func() models.Backend { return &dump.DumpBackend{} }, "funkwhale": func() models.Backend { return &funkwhale.FunkwhaleApiBackend{} }, "jspf": func() models.Backend { return &jspf.JSPFBackend{} }, + "lastfm": func() models.Backend { return &lastfm.LastfmApiBackend{} }, "listenbrainz": func() models.Backend { return &listenbrainz.ListenBrainzApiBackend{} }, "maloja": func() models.Backend { return &maloja.MalojaApiBackend{} }, "scrobbler-log": func() models.Backend { return &scrobblerlog.ScrobblerLogBackend{} }, diff --git a/internal/backends/deezer/auth.go b/internal/backends/deezer/auth.go index 53bfd98..aa30b04 100644 --- a/internal/backends/deezer/auth.go +++ b/internal/backends/deezer/auth.go @@ -33,8 +33,13 @@ func (s deezerStrategy) Config() oauth2.Config { return s.conf } -func (s deezerStrategy) AuthCodeURL(verifier string, state string) string { - return s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) +func (s deezerStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl { + url := s.conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(verifier)) + return auth.AuthUrl{ + Url: url, + State: state, + Param: "code", + } } func (s deezerStrategy) ExchangeToken(code auth.CodeResponse, verifier string) (*oauth2.Token, error) { diff --git a/internal/backends/lastfm/auth.go b/internal/backends/lastfm/auth.go new file mode 100644 index 0000000..c9718d5 --- /dev/null +++ b/internal/backends/lastfm/auth.go @@ -0,0 +1,51 @@ +/* +Copyright © 2023 Philipp Wolfer + +Scotty is free software: you can redistribute it and/or modify it under the +terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later version. + +Scotty is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with +Scotty. If not, see . +*/ + +package lastfm + +import ( + "net/url" + + "github.com/shkh/lastfm-go/lastfm" + "go.uploadedlobster.com/scotty/internal/auth" + "golang.org/x/oauth2" +) + +type lastfmStrategy struct { + client *lastfm.Api + redirectUrl *url.URL +} + +func (s lastfmStrategy) Config() oauth2.Config { + return oauth2.Config{} +} + +func (s lastfmStrategy) AuthCodeURL(verifier string, state string) auth.AuthUrl { + // Last.fm does not use OAuth2, but the provided authorization flow with + // callback URL is close enough we can shoehorn it into the existing + // authentication strategy. + // TODO: Investigate and use callback-less flow with api.GetAuthTokenUrl(token) + url := s.client.GetAuthRequestUrl(s.redirectUrl.String()) + return auth.AuthUrl{ + Url: url, + State: "", // last.fm does not use state + Param: "token", + } +} + +func (s lastfmStrategy) ExchangeToken(code auth.CodeResponse, verifier string) (*oauth2.Token, error) { + // The token is directly valid + return &oauth2.Token{AccessToken: code.Code}, nil +} diff --git a/internal/backends/lastfm/lastfm.go b/internal/backends/lastfm/lastfm.go new file mode 100644 index 0000000..0b8c957 --- /dev/null +++ b/internal/backends/lastfm/lastfm.go @@ -0,0 +1,54 @@ +/* +Copyright © 2023 Philipp Wolfer + +Scotty is free software: you can redistribute it and/or modify it under the +terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later version. + +Scotty is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with +Scotty. If not, see . +*/ + +package lastfm + +import ( + "net/url" + + "github.com/shkh/lastfm-go/lastfm" + "github.com/spf13/viper" + "go.uploadedlobster.com/scotty/internal/auth" + "go.uploadedlobster.com/scotty/internal/models" + "golang.org/x/oauth2" +) + +type LastfmApiBackend struct { + client *lastfm.Api +} + +func (b *LastfmApiBackend) Name() string { return "lastfm" } + +func (b *LastfmApiBackend) FromConfig(config *viper.Viper) models.Backend { + clientId := config.GetString("client-id") + clientSecret := config.GetString("client-secret") + b.client = lastfm.New(clientId, clientSecret) + return b +} + +func (b *LastfmApiBackend) OAuth2Strategy(redirectUrl *url.URL) auth.OAuth2Strategy { + return lastfmStrategy{ + client: b.client, + redirectUrl: redirectUrl, + } +} + +func (b *LastfmApiBackend) OAuth2Setup(token oauth2.TokenSource) error { + t, err := token.Token() + if err != nil { + return err + } + return b.client.LoginWithToken(t.AccessToken) +} diff --git a/scotty.example.toml b/scotty.example.toml index 0e09b70..391fda5 100644 --- a/scotty.example.toml +++ b/scotty.example.toml @@ -86,6 +86,17 @@ backend = "deezer" client-id = "" client-secret = "" +[service.lastfm] +backend = "lastfm" +# Your Last.fm username +username = "" +# You need to register an application on https://www.last.fm/api/account/create +# and set the API ID and shared secret below. +# When registering use "http://127.0.0.1:2222/callback/lastfm" as the +# callback URI. +client-id = "" +client-secret = "" + [service.dump] # This backend allows writing listens and loves as console output. Useful for # debugging the export from other services.