lint w black. add more logging

This commit is contained in:
lpm0073 2022-10-06 10:13:38 -05:00
parent 7c99eb3b88
commit 50d81932d2
4 changed files with 233 additions and 134 deletions

7
data/wp-oauth-me.json Normal file
View File

@ -0,0 +1,7 @@
{
"access_token": "x39tb1rws4gcpsyvahkofrj7xwnrefxuk3jonrjn",
"expires_in": 3600,
"refresh_token": "bo85pscgut9nqy56snnzgw6ixljzspac3f74eemy",
"scope": "basic profile email",
"token_type": "Bearer"
}

View File

@ -11,10 +11,12 @@ os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir)))
HERE = os.path.abspath(os.path.dirname(__file__)) HERE = os.path.abspath(os.path.dirname(__file__))
def load_readme(): def load_readme():
with io.open(os.path.join(HERE, "README.rst"), "rt", encoding="utf8") as f: with io.open(os.path.join(HERE, "README.rst"), "rt", encoding="utf8") as f:
return f.read() return f.read()
def load_about(): def load_about():
about = {} about = {}
with io.open( with io.open(
@ -25,6 +27,7 @@ def load_about():
exec(f.read(), about) # pylint: disable=exec-used exec(f.read(), about) # pylint: disable=exec-used
return about return about
def load_requirements(*requirements_paths): def load_requirements(*requirements_paths):
""" """
Load all requirements from the specified requirements files. Load all requirements from the specified requirements files.
@ -34,7 +37,9 @@ def load_requirements(*requirements_paths):
requirements = set() requirements = set()
for path in requirements_paths: for path in requirements_paths:
requirements.update( requirements.update(
line.split("#")[0].strip() for line in open(path).readlines() if is_requirement(line.strip()) line.split("#")[0].strip()
for line in open(path).readlines()
if is_requirement(line.strip())
) )
return list(requirements) return list(requirements)
@ -54,19 +59,22 @@ def is_requirement(line):
or line.startswith("git+") or line.startswith("git+")
) )
README = load_readme() README = load_readme()
ABOUT = load_about() ABOUT = load_about()
VERSION = ABOUT["__version__"] VERSION = ABOUT["__version__"]
setup( setup(
name='wp-oauth-backend', name="wp-oauth-backend",
version=VERSION, version=VERSION,
description=('An OAuth backend for the WP OAuth Wordpress Plugin, ' description=(
'that is customized for use in Open edX installations.'), "An OAuth backend for the WP OAuth Wordpress Plugin, "
"that is customized for use in Open edX installations."
),
long_description=README, long_description=README,
author='Lawrence McDaniel, lpm0073@gmail.com', author="Lawrence McDaniel, lpm0073@gmail.com",
author_email='lpm0073@gmail.com', author_email="lpm0073@gmail.com",
url='https://github.com/StepwiseMath/wp-oauth-backend', url="https://github.com/StepwiseMath/wp-oauth-backend",
project_urls={ project_urls={
"Code": "https://github.com/StepwiseMath/wp-oauth-backend", "Code": "https://github.com/StepwiseMath/wp-oauth-backend",
"Issue tracker": "https://github.com/StepwiseMath/wp-oauth-backend/issues", "Issue tracker": "https://github.com/StepwiseMath/wp-oauth-backend/issues",
@ -76,11 +84,11 @@ setup(
include_package_data=True, include_package_data=True,
package_data={"": ["*.html"]}, # include any templates found in this repo. package_data={"": ["*.html"]}, # include any templates found in this repo.
zip_safe=False, zip_safe=False,
keywords='WP OAuth', keywords="WP OAuth",
python_requires=">=3.7", python_requires=">=3.7",
install_requires=load_requirements("requirements/stable-psa.txt"), install_requires=load_requirements("requirements/stable-psa.txt"),
classifiers=[ classifiers=[
'Intended Audience :: Developers', "Intended Audience :: Developers",
'License :: OSI Approved :: MIT License', "License :: OSI Approved :: MIT License",
], ],
) )

View File

@ -1 +1 @@
__version__ = '0.1.0' __version__ = "0.1.0"

View File

@ -21,6 +21,8 @@ User = get_user_model()
logger = getLogger(__name__) logger = getLogger(__name__)
VERBOSE_LOGGING = True VERBOSE_LOGGING = True
class StepwiseMathWPOAuth2(BaseOAuth2): class StepwiseMathWPOAuth2(BaseOAuth2):
""" """
WP OAuth authentication backend customized for Open edX. WP OAuth authentication backend customized for Open edX.
@ -37,6 +39,7 @@ class StepwiseMathWPOAuth2(BaseOAuth2):
upstream to break. If you try this then you'll get an error about a missing upstream to break. If you try this then you'll get an error about a missing
positional argument, 'strategy'. positional argument, 'strategy'.
""" """
_user_details = None _user_details = None
# This defines the backend name and identifies it during the auth process. # This defines the backend name and identifies it during the auth process.
@ -46,7 +49,7 @@ class StepwiseMathWPOAuth2(BaseOAuth2):
# Third Party Authentication / Provider Configuration (OAuth) # Third Party Authentication / Provider Configuration (OAuth)
# setup page drop-down box titled, "Backend name:", just above # setup page drop-down box titled, "Backend name:", just above
# the "Client ID:" and "Client Secret:" fields. # the "Client ID:" and "Client Secret:" fields.
name = 'stepwisemath-oauth' name = "stepwisemath-oauth"
# note: no slash at the end of the base url. Python Social Auth # note: no slash at the end of the base url. Python Social Auth
# might clean this up for you, but i'm not 100% certain of that. # might clean this up for you, but i'm not 100% certain of that.
@ -56,7 +59,7 @@ class StepwiseMathWPOAuth2(BaseOAuth2):
# used in the auth process when some basic user data is returned. This Id # used in the auth process when some basic user data is returned. This Id
# is stored in the UserSocialAuth.uid field and this, together with the # is stored in the UserSocialAuth.uid field and this, together with the
# UserSocialAuth.provider field, is used to uniquely identify a user association. # UserSocialAuth.provider field, is used to uniquely identify a user association.
ID_KEY = 'id' ID_KEY = "id"
# Flags the backend to enforce email validation during the pipeline # Flags the backend to enforce email validation during the pipeline
# (if the corresponding pipeline social_core.pipeline.mail.mail_validation was enabled). # (if the corresponding pipeline social_core.pipeline.mail.mail_validation was enabled).
@ -69,11 +72,11 @@ class StepwiseMathWPOAuth2(BaseOAuth2):
# wp-oauth supports 4 scopes: basic, email, profile, openeid. # wp-oauth supports 4 scopes: basic, email, profile, openeid.
# we want the first three of these. # we want the first three of these.
# see https://wp-oauth.com/docs/how-to/adding-supported-scopes/ # see https://wp-oauth.com/docs/how-to/adding-supported-scopes/
DEFAULT_SCOPE = ['basic', 'profile', 'email'] DEFAULT_SCOPE = ["basic", "profile", "email"]
# Specifying the method type required to retrieve your access token if its # Specifying the method type required to retrieve your access token if its
# not the default GET request. # not the default GET request.
ACCESS_TOKEN_METHOD = 'POST' ACCESS_TOKEN_METHOD = "POST"
# require redirect domain to match the original initiating domain. # require redirect domain to match the original initiating domain.
SOCIAL_AUTH_SANITIZE_REDIRECTS = True SOCIAL_AUTH_SANITIZE_REDIRECTS = True
@ -86,10 +89,10 @@ class StepwiseMathWPOAuth2(BaseOAuth2):
# tuples in the form (name, alias) where name is the key in the user data # tuples in the form (name, alias) where name is the key in the user data
# (which should be a dict instance) and alias is the name to store it on extra_data. # (which should be a dict instance) and alias is the name to store it on extra_data.
EXTRA_DATA = [ EXTRA_DATA = [
('id', 'id'), ("id", "id"),
('is_superuser', 'is_superuser'), ("is_superuser", "is_superuser"),
('is_staff', 'is_staff'), ("is_staff", "is_staff"),
('date_joined', 'date_joined'), ("date_joined", "date_joined"),
] ]
# the value of the scope separator is user-defined. Check the # the value of the scope separator is user-defined. Check the
@ -112,12 +115,25 @@ class StepwiseMathWPOAuth2(BaseOAuth2):
in qc_keys. in qc_keys.
""" """
if not type(response) == dict: if not type(response) == dict:
logger.warning('is_valid_user_details() was expecting a dict but received an object of type: {type}'.format( logger.warning(
"is_valid_user_details() was expecting a dict but received an object of type: {type}".format(
type=type(response) type=type(response)
)) )
)
return False return False
qc_keys = ['id', 'date_joined', 'email', 'first_name', 'fullname', 'is_staff', 'is_superuser', 'last_name', 'username'] qc_keys = [
if all(key in response for key in qc_keys): return True "id",
"date_joined",
"email",
"first_name",
"fullname",
"is_staff",
"is_superuser",
"last_name",
"username",
]
if all(key in response for key in qc_keys):
return True
return False return False
def is_wp_oauth_response(self, response) -> bool: def is_wp_oauth_response(self, response) -> bool:
@ -126,22 +142,39 @@ class StepwiseMathWPOAuth2(BaseOAuth2):
supposed to be a dict with at least the keys included in qc_keys. supposed to be a dict with at least the keys included in qc_keys.
""" """
if not type(response) == dict: if not type(response) == dict:
logger.warning('is_valid_user_details() was expecting a dict but received an object of type: {type}'.format( logger.warning(
"is_valid_user_details() was expecting a dict but received an object of type: {type}".format(
type=type(response) type=type(response)
)) )
)
return False return False
qc_keys = ['ID' 'display_name', 'user_email', 'user_login', 'user_roles'] qc_keys = ["ID" "display_name", "user_email", "user_login", "user_roles"]
if all(key in response for key in qc_keys): return True if all(key in response for key in qc_keys):
return True
return False return False
def is_wp_oauth_extended_response(self, response) -> bool: def is_wp_oauth_refresh_token_response(self, response) -> bool:
""" """
validate the structure of the extended response object from wp-oauth. it's validate that the structure of the response contains the keys of
supposed to be a dict with at least the keys included in qc_keys. a refresh token dict.
""" """
if not self.is_valid_user_details(response): return False if not self.is_valid_user_details(response):
qc_keys = ['access_token' 'expires_in', 'refresh_token', 'scope', 'token_type'] return False
if all(key in response for key in qc_keys): return True qc_keys = ["access_token" "expires_in", "refresh_token", "scope", "token_type"]
if all(key in response for key in qc_keys):
return True
return False
def is_get_user_details_extended_dict(self, response) -> bool:
"""
validate whether the structure the response is a dict that
contains a.) all keys of a get_user_details() return, plus,
b.) all keys of a wp-oauth refresh token response.
"""
if not self.is_valid_user_details(response):
return False
if self.is_wp_oauth_refresh_token_response(response):
return True
return False return False
# override Python Social Auth default end points. # override Python Social Auth default end points.
@ -153,21 +186,21 @@ class StepwiseMathWPOAuth2(BaseOAuth2):
def AUTHORIZATION_URL(self) -> str: def AUTHORIZATION_URL(self) -> str:
retval = f"{self.BASE_URL}/oauth/authorize" retval = f"{self.BASE_URL}/oauth/authorize"
if VERBOSE_LOGGING: if VERBOSE_LOGGING:
logger.info('AUTHORIZATION_URL: {url}'.format(url=retval)) logger.info("AUTHORIZATION_URL: {url}".format(url=retval))
return retval return retval
@property @property
def ACCESS_TOKEN_URL(self) -> str: def ACCESS_TOKEN_URL(self) -> str:
retval = f"{self.BASE_URL}/oauth/token" retval = f"{self.BASE_URL}/oauth/token"
if VERBOSE_LOGGING: if VERBOSE_LOGGING:
logger.info('ACCESS_TOKEN_URL: {url}'.format(url=retval)) logger.info("ACCESS_TOKEN_URL: {url}".format(url=retval))
return retval return retval
@property @property
def USER_QUERY(self) -> str: def USER_QUERY(self) -> str:
retval = f"{self.BASE_URL}/oauth/me" retval = f"{self.BASE_URL}/oauth/me"
if VERBOSE_LOGGING: if VERBOSE_LOGGING:
logger.info('USER_QUERY: {url}'.format(url=retval)) logger.info("USER_QUERY: {url}".format(url=retval))
return retval return retval
@property @property
@ -178,28 +211,39 @@ class StepwiseMathWPOAuth2(BaseOAuth2):
def user_details(self, value: dict): def user_details(self, value: dict):
if self.is_valid_user_details(value): if self.is_valid_user_details(value):
if VERBOSE_LOGGING: if VERBOSE_LOGGING:
logger.info('user_details.setter: new value set {value}'.format( logger.info(
"user_details.setter: new value set {value}".format(
value=json.dumps(value, sort_keys=True, indent=4) value=json.dumps(value, sort_keys=True, indent=4)
)) )
)
self._user_details = value self._user_details = value
else: else:
logger.error('user_details.setter: tried to pass an invalid object {value}'.format( logger.error(
"user_details.setter: tried to pass an invalid object {value}".format(
value=json.dumps(value, sort_keys=True, indent=4) value=json.dumps(value, sort_keys=True, indent=4)
)) )
)
# see https://python-social-auth.readthedocs.io/en/latest/backends/implementation.html # see https://python-social-auth.readthedocs.io/en/latest/backends/implementation.html
# Return user details from the Wordpress user account # Return user details from the Wordpress user account
def get_user_details(self, response) -> dict: def get_user_details(self, response) -> dict:
if not (self.is_valid_user_details(response) or self.is_wp_oauth_response(response)): if not (
logger.error('get_user_details() - received an unrecognized response object. Cannot continue: {response}'.format( self.is_valid_user_details(response) or self.is_wp_oauth_response(response)
):
logger.error(
"get_user_details() - received an unrecognized response object. Cannot continue: {response}".format(
response=json.dumps(response, sort_keys=True, indent=4) response=json.dumps(response, sort_keys=True, indent=4)
)) )
)
# if we have cached results then we might be able to recover. # if we have cached results then we might be able to recover.
return self.user_details return self.user_details
if VERBOSE_LOGGING: logger.info('get_user_details() begin with response: {response}'.format( if VERBOSE_LOGGING:
logger.info(
"get_user_details() begin with response: {response}".format(
response=json.dumps(response, sort_keys=True, indent=4) response=json.dumps(response, sort_keys=True, indent=4)
)) )
)
# a def in the third_party_auth pipeline list calls get_user_details() after its already # a def in the third_party_auth pipeline list calls get_user_details() after its already
# been called once. i don't know why. but, it passes the original get_user_details() dict # been called once. i don't know why. but, it passes the original get_user_details() dict
# enhanced with additional token-related keys. if we receive this modified dict then we # enhanced with additional token-related keys. if we receive this modified dict then we
@ -207,64 +251,72 @@ class StepwiseMathWPOAuth2(BaseOAuth2):
# #
# If most of the original keys (see dict definition below) exist in the response object # If most of the original keys (see dict definition below) exist in the response object
# then we can assume that this is our case. # then we can assume that this is our case.
if self.is_wp_oauth_extended_response(response): if self.is_get_user_details_extended_dict(response):
# ------------------------------------------------------------- # -------------------------------------------------------------
# expected use case #2: an enhanced derivation of an original # expected use case #2: an enhanced derivation of an original
# user_details dict. This is created when get_user_details() # user_details dict. This is created when get_user_details()
# is called from user_data(). # is called from user_data().
# ------------------------------------------------------------- # -------------------------------------------------------------
if VERBOSE_LOGGING: if VERBOSE_LOGGING:
logger.info('get_user_details() - detected an enhanced get_user_details() dict in the response: {response}'.format( logger.info(
"get_user_details() - detected an enhanced get_user_details() dict in the response: {response}".format(
response=json.dumps(response, sort_keys=True, indent=4) response=json.dumps(response, sort_keys=True, indent=4)
)) )
)
return response return response
# at this point we've ruled out the possibility of the response object # at this point we've ruled out the possibility of the response object
# being a derivation of a user_details dict. So, it should therefore # being a derivation of a user_details dict. So, it should therefore
# conform to the structure of a wp-oauth dict. # conform to the structure of a wp-oauth dict.
if not self.is_wp_oauth_response(response): if not self.is_wp_oauth_response(response):
logger.warning('get_user_details() - response object is not a valid wp-oauth object. Cannot continue. {response}'.format( logger.warning(
"get_user_details() - response object is not a valid wp-oauth object. Cannot continue. {response}".format(
response=json.dumps(response, sort_keys=True, indent=4) response=json.dumps(response, sort_keys=True, indent=4)
)) )
)
return self.user_details return self.user_details
# ------------------------------------------------------------- # -------------------------------------------------------------
# expected use case #1: response object is a dict with all required keys. # expected use case #1: response object is a dict with all required keys.
# ------------------------------------------------------------- # -------------------------------------------------------------
if VERBOSE_LOGGING: if VERBOSE_LOGGING:
logger.info('get_user_details() - start. response: {response}'.format( logger.info(
"get_user_details() - start. response: {response}".format(
response=json.dumps(response, sort_keys=True, indent=4) response=json.dumps(response, sort_keys=True, indent=4)
)) )
)
# try to parse out the first and last names # try to parse out the first and last names
split_name = response.get('display_name', '').split() split_name = response.get("display_name", "").split()
first_name = split_name[0] if len(split_name) > 0 else '' first_name = split_name[0] if len(split_name) > 0 else ""
last_name = split_name[-1] if len(split_name) == 2 else '' last_name = split_name[-1] if len(split_name) == 2 else ""
# check for superuser / staff status # check for superuser / staff status
user_roles = response.get('user_roles', []) user_roles = response.get("user_roles", [])
super_user = 'administrator' in user_roles super_user = "administrator" in user_roles
is_staff = 'administrator' in user_roles is_staff = "administrator" in user_roles
self.user_details = { self.user_details = {
'id': int(response.get('ID'), 0), "id": int(response.get("ID"), 0),
'username': response.get('user_login', ''), "username": response.get("user_login", ""),
'email': response.get('user_email', ''), "email": response.get("user_email", ""),
'first_name': first_name, "first_name": first_name,
'last_name': last_name, "last_name": last_name,
'fullname': response.get('display_name', ''), "fullname": response.get("display_name", ""),
'is_superuser': super_user, "is_superuser": super_user,
'is_staff': is_staff, "is_staff": is_staff,
'refresh_token': response.get('refresh_token', ''), "refresh_token": response.get("refresh_token", ""),
'scope': response.get('scope', ''), "scope": response.get("scope", ""),
'token_type': response.get('token_type', ''), "token_type": response.get("token_type", ""),
'date_joined': response.get('user_registered', ''), "date_joined": response.get("user_registered", ""),
'user_status': response.get('user_status', ''), "user_status": response.get("user_status", ""),
} }
if VERBOSE_LOGGING: if VERBOSE_LOGGING:
logger.info('get_user_details() - finish. user_details: {user_details}'.format( logger.info(
"get_user_details() - finish. user_details: {user_details}".format(
user_details=json.dumps(self.user_details, sort_keys=True, indent=4) user_details=json.dumps(self.user_details, sort_keys=True, indent=4)
)) )
)
return self.user_details return self.user_details
# Load user data from service url end point. Note that in the case of # Load user data from service url end point. Note that in the case of
@ -273,50 +325,82 @@ class StepwiseMathWPOAuth2(BaseOAuth2):
# #
# see https://python-social-auth.readthedocs.io/en/latest/backends/implementation.html # see https://python-social-auth.readthedocs.io/en/latest/backends/implementation.html
def user_data(self, access_token, *args, **kwargs) -> dict: def user_data(self, access_token, *args, **kwargs) -> dict:
response = None
url = f'{self.USER_QUERY}?' + urlencode({ user_details = None
'access_token': access_token url = f"{self.USER_QUERY}?" + urlencode({"access_token": access_token})
})
if VERBOSE_LOGGING: if VERBOSE_LOGGING:
logger.info("user_data() url: {url}".format(url=url)) logger.info("user_data() url: {url}".format(url=url))
try: try:
response = json.loads(self._urlopen(url)) response = json.loads(self._urlopen(url))
if VERBOSE_LOGGING:
logger.info(
"user_data() response: {response}".format(
response=json.dumps(self.user_details, sort_keys=True, indent=4)
)
)
user_details = self.get_user_details(response)
if VERBOSE_LOGGING:
logger.info(
"user_data() local variable user_details: {user_details}".format(
user_details=json.dumps(user_details, sort_keys=True, indent=4)
)
)
if VERBOSE_LOGGING:
logger.info(
"user_data() class property value of self.user_details: {user_details}".format(
user_details=json.dumps(
self.user_details, sort_keys=True, indent=4
)
)
)
except ValueError as e: except ValueError as e:
logger.error('user_data() {err}'.format(err=e)) logger.error("user_data() {err}".format(err=e))
return None return None
if not self.is_valid_user_details(response): if not self.is_valid_user_details(user_details):
logger.error('user_data() response object is invalid: {response}'.format( logger.error(
response=json.dumps(self.user_details, sort_keys=True, indent=4) "user_data() user_details object is invalid: {user_details}".format(
)) user_details=json.dumps(user_details, sort_keys=True, indent=4)
)
)
return self.user_details return self.user_details
# refresh our internal user_details property after having validated
# response from USER_QUERY
self.get_user_details(response)
# add syncronization of any data fields that get missed by the built-in # add syncronization of any data fields that get missed by the built-in
# open edx third party authentication sync functionality. # open edx third party authentication sync functionality.
try: try:
# this gets called just prior to account creation for # this gets called just prior to account creation for
# new users, hence, we need to catch DoesNotExist # new users, hence, we need to catch DoesNotExist
# exceptions. # exceptions.
user=User.objects.get(username=self.user_details['username']) user = User.objects.get(username=self.user_details["username"])
except User.DoesNotExist: except User.DoesNotExist:
return self.user_details return self.user_details
if (user.is_superuser != self.user_details['is_superuser']) or (user.is_staff != self.user_details['is_staff']): if (user.is_superuser != self.user_details["is_superuser"]) or (
user.is_superuser = self.user_details['is_superuser'] user.is_staff != self.user_details["is_staff"]
user.is_staff = self.user_details['is_staff'] ):
user.is_superuser = self.user_details["is_superuser"]
user.is_staff = self.user_details["is_staff"]
user.save() user.save()
logger.info('Updated the is_superuser/is_staff flags for user {username}'.format(username=user.username)) logger.info(
"Updated the is_superuser/is_staff flags for user {username}".format(
username=user.username
)
)
if (user.first_name != self.user_details['first_name']) or (user.last_name != self.user_details['last_name']): if (user.first_name != self.user_details["first_name"]) or (
user.first_name = self.user_details['first_name'] user.last_name != self.user_details["last_name"]
user.last_name = self.user_details['last_name'] ):
user.first_name = self.user_details["first_name"]
user.last_name = self.user_details["last_name"]
user.save() user.save()
logger.info('Updated first_name/last_name for user {username}'.format(username=user.username)) logger.info(
"Updated first_name/last_name for user {username}".format(
username=user.username
)
)
return self.user_details return self.user_details