Made cache_path and username optional (#567)

* Made cache_path and username optional

* Update readme and example

* Lint

* Lint

* Feedback / add warning
This commit is contained in:
Stéphane Bruckert 2020-08-30 23:59:38 +01:00 committed by GitHub
parent d448d33704
commit d0ca977647
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 126 additions and 137 deletions

View File

@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `SpotifyImplicitGrant` warns of security considerations and recommends - `SpotifyImplicitGrant` warns of security considerations and recommends
`SpotifyPKCE` `SpotifyPKCE`
### Changed
- Specifying a cache_path or username is now optional
### Fixed ### Fixed
- Using `SpotifyPKCE.get_authorization_url` will now generate a code - Using `SpotifyPKCE.get_authorization_url` will now generate a code

View File

@ -50,7 +50,6 @@ from spotipy.oauth2 import SpotifyOAuth
sp = spotipy.Spotify(auth_manager=SpotifyOAuth(client_id="YOUR_APP_CLIENT_ID", sp = spotipy.Spotify(auth_manager=SpotifyOAuth(client_id="YOUR_APP_CLIENT_ID",
client_secret="YOUR_APP_CLIENT_SECRET", client_secret="YOUR_APP_CLIENT_SECRET",
redirect_uri="YOUR_APP_REDIRECT_URI", redirect_uri="YOUR_APP_REDIRECT_URI",
username="YOUR_SPOTIFY_USERNAME",
scope="user-library-read")) scope="user-library-read"))
results = sp.current_user_saved_tracks() results = sp.current_user_saved_tracks()

View File

@ -1,16 +1,8 @@
# Shows the top tracks for a user # Shows the top tracks for a user
import sys
import spotipy import spotipy
from spotipy.oauth2 import SpotifyOAuth from spotipy.oauth2 import SpotifyOAuth
if len(sys.argv) > 1:
username = sys.argv[1]
else:
print("Usage: %s username" % (sys.argv[0],))
sys.exit()
scope = 'user-top-read' scope = 'user-top-read'
sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope)) sp = spotipy.Spotify(auth_manager=SpotifyOAuth(scope=scope))

View File

@ -15,7 +15,7 @@ if len(sys.argv) > 2:
track_ids.append({"uri": tid, "positions": [int(pos)]}) track_ids.append({"uri": tid, "positions": [int(pos)]})
else: else:
print( print(
"Usage: %s username playlist_id track_id,pos track_id,pos ..." % "Usage: %s playlist_id track_id,pos track_id,pos ..." %
(sys.argv[0],)) (sys.argv[0],))
sys.exit() sys.exit()

View File

@ -10,7 +10,7 @@ if len(sys.argv) > 3:
playlist_id = sys.argv[1] playlist_id = sys.argv[1]
track_ids = sys.argv[2:] track_ids = sys.argv[2:]
else: else:
print("Usage: %s username playlist_id track_id ..." % (sys.argv[0],)) print("Usage: %s playlist_id track_id ..." % (sys.argv[0],))
sys.exit() sys.exit()
scope = 'playlist-modify-public' scope = 'playlist-modify-public'

View File

@ -1,5 +1,4 @@
# Shows artist info for a URN or URL
# shows artist info for a URN or URL
from spotipy.oauth2 import SpotifyClientCredentials from spotipy.oauth2 import SpotifyClientCredentials
import spotipy import spotipy

View File

@ -78,6 +78,17 @@ def _ensure_value(value, env_key):
return _val return _val
def _get_cache_path(cache_path, username):
if cache_path:
return cache_path
cache_path = ".cache"
if username:
cache_path += "-" + str(username)
return cache_path
class SpotifyAuthBase(object): class SpotifyAuthBase(object):
def __init__(self, requests_session): def __init__(self, requests_session):
if isinstance(requests_session, requests.Session): if isinstance(requests_session, requests.Session):
@ -219,7 +230,6 @@ class SpotifyOAuth(SpotifyAuthBase):
""" """
Implements Authorization Code Flow for Spotify's OAuth implementation. Implements Authorization Code Flow for Spotify's OAuth implementation.
""" """
OAUTH_AUTHORIZE_URL = "https://accounts.spotify.com/authorize" OAUTH_AUTHORIZE_URL = "https://accounts.spotify.com/authorize"
OAUTH_TOKEN_URL = "https://accounts.spotify.com/api/token" OAUTH_TOKEN_URL = "https://accounts.spotify.com/api/token"
@ -238,18 +248,22 @@ class SpotifyOAuth(SpotifyAuthBase):
requests_timeout=None requests_timeout=None
): ):
""" """
Creates a SpotifyOAuth object Creates a SpotifyOAuth object
Parameters: Parameters:
- client_id - the client id of your app * client_id: Must be supplied or set as environment variable
- client_secret - the client secret of your app * client_secret: Must be supplied or set as environment variable
- redirect_uri - the redirect URI of your app * redirect_uri: Must be supplied or set as environment variable
- state - security state * state: May be supplied, no verification is performed
- scope - the desired scope of the request * scope: May be supplied, intuitively converted to proper format
- cache_path - path to location to save tokens * cache_path: May be supplied, will otherwise be generated
- requests_timeout - tell Requests to stop waiting for a response (takes precedence over `username`)
after a given number of seconds * username: May be supplied or set as environment variable
- username - username of current client (will set `cache_path` to `.cache-{username}`)
* proxies: Proxy for the requests library to route through
* show_dialog: Interpreted as boolean
* requests_timeout: Tell Requests to stop waiting for a response after a given number
of seconds
""" """
super(SpotifyOAuth, self).__init__(requests_session) super(SpotifyOAuth, self).__init__(requests_session)
@ -258,10 +272,10 @@ class SpotifyOAuth(SpotifyAuthBase):
self.client_secret = client_secret self.client_secret = client_secret
self.redirect_uri = redirect_uri self.redirect_uri = redirect_uri
self.state = state self.state = state
self.cache_path = cache_path
self.username = username or os.getenv( self.username = username or os.getenv(
CLIENT_CREDS_ENV_VARS["client_username"] CLIENT_CREDS_ENV_VARS["client_username"]
) )
self.cache_path = _get_cache_path(cache_path, self.username)
self.scope = self._normalize_scope(scope) self.scope = self._normalize_scope(scope)
self.proxies = proxies self.proxies = proxies
self.requests_timeout = requests_timeout self.requests_timeout = requests_timeout
@ -272,33 +286,26 @@ class SpotifyOAuth(SpotifyAuthBase):
""" """
token_info = None token_info = None
if not self.cache_path and self.username: try:
self.cache_path = ".cache-" + str(self.username) f = open(self.cache_path)
elif not self.cache_path and not self.username: token_info_string = f.read()
raise SpotifyOauthError( f.close()
"You must either set a cache_path or a username." token_info = json.loads(token_info_string)
)
if self.cache_path: # if scopes don't match, then bail
try: if "scope" not in token_info or not self._is_scope_subset(
f = open(self.cache_path) self.scope, token_info["scope"]
token_info_string = f.read() ):
f.close() return None
token_info = json.loads(token_info_string)
# if scopes don't match, then bail if self.is_token_expired(token_info):
if "scope" not in token_info or not self._is_scope_subset( token_info = self.refresh_access_token(
self.scope, token_info["scope"] token_info["refresh_token"]
): )
return None
if self.is_token_expired(token_info): except IOError:
token_info = self.refresh_access_token( logger.warning("Couldn't read cache at: %s", self.cache_path)
token_info["refresh_token"]
)
except IOError:
pass
return token_info return token_info
def _save_token_info(self, token_info): def _save_token_info(self, token_info):
@ -579,28 +586,33 @@ class SpotifyPKCE(SpotifyAuthBase):
requests_timeout=None, requests_timeout=None,
requests_session=True,): requests_session=True,):
""" """
Creates Auth Manager with the PKCE Auth flow. Creates Auth Manager with the PKCE Auth flow.
Parameters: Parameters:
- client_id - the client id of your app * client_id: Must be supplied or set as environment variable
- redirect_uri - the redirect URI of your app * client_secret: Must be supplied or set as environment variable
- state - security state * redirect_uri: Must be supplied or set as environment variable
- scope - the desired scope of the request * state: May be supplied, no verification is performed
- cache_path - path to location to save tokens * scope: May be supplied, intuitively converted to proper format
- username - username of current client * cache_path: May be supplied, will otherwise be generated
- proxies - proxy for the requests library to route through (takes precedence over `username`)
- requests_timeout - tell Requests to stop waiting for a response * username: May be supplied or set as environment variable
after a given number of seconds (will set `cache_path` to `.cache-{username}`)
* show_dialog: Interpreted as boolean
* proxies: Proxy for the requests library to route through
* requests_timeout: Tell Requests to stop waiting for a response after a given number
of seconds
""" """
super(SpotifyPKCE, self).__init__(requests_session) super(SpotifyPKCE, self).__init__(requests_session)
self.client_id = client_id self.client_id = client_id
self.redirect_uri = redirect_uri self.redirect_uri = redirect_uri
self.state = state self.state = state
self.scope = self._normalize_scope(scope) self.scope = self._normalize_scope(scope)
self.cache_path = cache_path
self.username = username or os.getenv( self.username = username or os.getenv(
CLIENT_CREDS_ENV_VARS["client_username"] CLIENT_CREDS_ENV_VARS["client_username"]
) )
self.cache_path = _get_cache_path(cache_path, self.username)
self.proxies = proxies self.proxies = proxies
self.requests_timeout = requests_timeout self.requests_timeout = requests_timeout
@ -617,10 +629,10 @@ class SpotifyPKCE(SpotifyAuthBase):
return None return None
def _get_code_verifier(self): def _get_code_verifier(self):
''' Spotify PCKE code verifier - See step 1 of the reference guide below """ Spotify PCKE code verifier - See step 1 of the reference guide below
Reference: Reference:
https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce
''' """
# Range (33,96) is used to select between 44-128 base64 characters for the # Range (33,96) is used to select between 44-128 base64 characters for the
# next operation. The range looks weird because base64 is 6 bytes # next operation. The range looks weird because base64 is 6 bytes
import random import random
@ -638,10 +650,10 @@ class SpotifyPKCE(SpotifyAuthBase):
return verifier return verifier
def _get_code_challenge(self): def _get_code_challenge(self):
''' Spotify PCKE code challenge - See step 1 of the reference guide below """ Spotify PCKE code challenge - See step 1 of the reference guide below
Reference: Reference:
https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce https://developer.spotify.com/documentation/general/guides/authorization-guide/#authorization-code-flow-with-proof-key-for-code-exchange-pkce
''' """
import hashlib import hashlib
import base64 import base64
code_challenge_digest = hashlib.sha256(self.code_verifier.encode('utf-8')).digest() code_challenge_digest = hashlib.sha256(self.code_verifier.encode('utf-8')).digest()
@ -744,33 +756,26 @@ class SpotifyPKCE(SpotifyAuthBase):
""" """
token_info = None token_info = None
if not self.cache_path and self.username: try:
self.cache_path = ".cache-" + str(self.username) f = open(self.cache_path)
elif not self.cache_path and not self.username: token_info_string = f.read()
raise SpotifyOauthError( f.close()
"You must either set a cache_path or a username." token_info = json.loads(token_info_string)
)
if self.cache_path: # if scopes don't match, then bail
try: if "scope" not in token_info or not self._is_scope_subset(
f = open(self.cache_path) self.scope, token_info["scope"]
token_info_string = f.read() ):
f.close() return None
token_info = json.loads(token_info_string)
# if scopes don't match, then bail if self.is_token_expired(token_info):
if "scope" not in token_info or not self._is_scope_subset( token_info = self.refresh_access_token(
self.scope, token_info["scope"] token_info["refresh_token"]
): )
return None
if self.is_token_expired(token_info): except IOError:
token_info = self.refresh_access_token( logger.warning("Couldn't read cache at: %s", self.cache_path)
token_info["refresh_token"]
)
except IOError:
pass
return token_info return token_info
def _is_scope_subset(self, needle_scope, haystack_scope): def _is_scope_subset(self, needle_scope, haystack_scope):
@ -853,9 +858,9 @@ class SpotifyPKCE(SpotifyAuthBase):
raise SpotifyOauthError('error: {0}, error_descr: {1}'.format(error_payload['error'], raise SpotifyOauthError('error: {0}, error_descr: {1}'.format(error_payload['error'],
error_payload[ error_payload[
'error_description' 'error_description'
]), ]),
error=error_payload['error'], error=error_payload['error'],
error_description=error_payload['error_description']) error_description=error_payload['error_description'])
token_info = response.json() token_info = response.json()
token_info = self._add_custom_values_to_token_info(token_info) token_info = self._add_custom_values_to_token_info(token_info)
self._save_token_info(token_info) self._save_token_info(token_info)
@ -971,7 +976,9 @@ class SpotifyImplicitGrant(SpotifyAuthBase):
* state: May be supplied, no verification is performed * state: May be supplied, no verification is performed
* scope: May be supplied, intuitively converted to proper format * scope: May be supplied, intuitively converted to proper format
* cache_path: May be supplied, will otherwise be generated * cache_path: May be supplied, will otherwise be generated
* username: Must be supplied or set as environment variable (takes precedence over `username`)
* username: May be supplied or set as environment variable
(will set `cache_path` to `.cache-{username}`)
* show_dialog: Interpreted as boolean * show_dialog: Interpreted as boolean
""" """
logger.warning("The OAuth standard no longer recommends the Implicit " logger.warning("The OAuth standard no longer recommends the Implicit "
@ -983,10 +990,10 @@ class SpotifyImplicitGrant(SpotifyAuthBase):
self.client_id = client_id self.client_id = client_id
self.redirect_uri = redirect_uri self.redirect_uri = redirect_uri
self.state = state self.state = state
self.cache_path = cache_path
self.username = username or os.getenv( self.username = username or os.getenv(
CLIENT_CREDS_ENV_VARS["client_username"] CLIENT_CREDS_ENV_VARS["client_username"]
) )
self.cache_path = _get_cache_path(cache_path, self.username)
self.scope = self._normalize_scope(scope) self.scope = self._normalize_scope(scope)
self.show_dialog = show_dialog self.show_dialog = show_dialog
self._session = None # As to not break inherited __del__ self._session = None # As to not break inherited __del__
@ -996,44 +1003,33 @@ class SpotifyImplicitGrant(SpotifyAuthBase):
""" """
token_info = None token_info = None
if not self.cache_path and self.username: try:
self.cache_path = ".cache-" + str(self.username) f = open(self.cache_path)
elif not self.cache_path and not self.username: token_info_string = f.read()
raise SpotifyOauthError( f.close()
"You must either set a cache_path or a username." token_info = json.loads(token_info_string)
)
if self.cache_path: # if scopes don't match, then bail
try: if "scope" not in token_info or not self._is_scope_subset(
f = open(self.cache_path) self.scope, token_info["scope"]
token_info_string = f.read() ):
f.close() return None
token_info = json.loads(token_info_string)
# if scopes don't match, then bail if self.is_token_expired(token_info):
if "scope" not in token_info or not self._is_scope_subset( return None
self.scope, token_info["scope"]
):
return None
if self.is_token_expired(token_info): except IOError:
return None logger.warning("Couldn't read cache at: %s", self.cache_path)
except IOError:
pass
return token_info return token_info
def _save_token_info(self, token_info): def _save_token_info(self, token_info):
if not self.cache_path and self.username: try:
self.cache_path = ".cache-" + str(self.username) f = open(self.cache_path, "w")
if self.cache_path: f.write(json.dumps(token_info))
try: f.close()
f = open(self.cache_path, "w") except IOError:
f.write(json.dumps(token_info)) logger.warning("Couldn't write token to cache at: %s", self.cache_path)
f.close()
except IOError:
logger.warning('Couldn\'t write token to cache at: %s',
self.cache_path)
def _is_scope_subset(self, needle_scope, haystack_scope): def _is_scope_subset(self, needle_scope, haystack_scope):
needle_scope = set(needle_scope.split()) if needle_scope else set() needle_scope = set(needle_scope.split()) if needle_scope else set()

View File

@ -21,7 +21,7 @@ CLIENT_CREDS_ENV_VARS = {
def prompt_for_user_token( def prompt_for_user_token(
username, username=None,
scope=None, scope=None,
client_id=None, client_id=None,
client_secret=None, client_secret=None,
@ -43,14 +43,14 @@ def prompt_for_user_token(
Parameters: Parameters:
- username - the Spotify username - username - the Spotify username (optional)
- scope - the desired scope of the request - scope - the desired scope of the request (optional)
- client_id - the client id of your app - client_id - the client id of your app (required)
- client_secret - the client secret of your app - client_secret - the client secret of your app (required)
- redirect_uri - the redirect URI of your app - redirect_uri - the redirect URI of your app (required)
- cache_path - path to location to save tokens - cache_path - path to location to save tokens (optional)
- oauth_manager - Oauth manager object. - oauth_manager - Oauth manager object (optional)
- show_dialog - If true, a login prompt always shows - show_dialog - If true, a login prompt always shows (optional, defaults to False)
""" """
if not oauth_manager: if not oauth_manager:
@ -79,14 +79,13 @@ def prompt_for_user_token(
) )
raise spotipy.SpotifyException(550, -1, "no credentials set") raise spotipy.SpotifyException(550, -1, "no credentials set")
cache_path = cache_path or ".cache-" + username
sp_oauth = oauth_manager or spotipy.SpotifyOAuth( sp_oauth = oauth_manager or spotipy.SpotifyOAuth(
client_id, client_id,
client_secret, client_secret,
redirect_uri, redirect_uri,
scope=scope, scope=scope,
cache_path=cache_path, cache_path=cache_path,
username=username,
show_dialog=show_dialog show_dialog=show_dialog
) )

View File

@ -436,8 +436,8 @@ class TestSpotifyPKCE(unittest.TestCase):
auth.get_pkce_handshake_parameters() auth.get_pkce_handshake_parameters()
self.assertEqual(auth.code_challenge, self.assertEqual(auth.code_challenge,
base64.urlsafe_b64encode( base64.urlsafe_b64encode(
hashlib.sha256(auth.code_verifier.encode('utf-8')) hashlib.sha256(auth.code_verifier.encode('utf-8'))
.digest()) .digest())
.decode('utf-8') .decode('utf-8')
.replace('=', '')) .replace('=', ''))