Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ✨ social-auth auto associate existing user #458

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/accounts/constants/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
HTK_DEFAULT_LOGGED_IN_ACCOUNT_HOME = 'account_index'

HTK_ACCOUNTS_CHANGE_PASSWORD_UPDATE_SESSION_AUTH_HASH = True

# The list of backends that will automatically link user accounts with social
# auth accounts.
HTK_ACCOUNTS_SOCIAL_AUTO_ASSOCIATE_BACKENDS = []
HTK_ACCOUNTS_REGISTER_SET_PRETTY_USERNAME_FROM_EMAIL = False
HTK_ACCOUNTS_REGISTER_SOCIAL_LOGIN_URL_NAME = 'account_register_social_login'
HTK_ACCOUNTS_REGISTER_SOCIAL_EMAIL_URL_NAME = 'account_register_social_email'
Expand Down
149 changes: 109 additions & 40 deletions apps/accounts/social_auth_pipeline.py
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer's Note

Most of this is formatting. The important part is marked with a comment.

Original file line number Diff line number Diff line change
@@ -1,20 +1,32 @@
# Third Party / PIP Imports
# Django Imports
from django.shortcuts import redirect

# Third Party (PyPI) Imports
# Django Extensions Imports
# Third Party (PyPI) Imports
from social_core.pipeline.partial import partial
from social_core.pipeline.social_auth import associate_user

# Django Imports
from django.shortcuts import redirect

# HTK Imports
from htk.apps.accounts.emails import welcome_email
from htk.apps.accounts.session_keys import *
from htk.apps.accounts.utils import associate_user_email
from htk.apps.accounts.utils import get_incomplete_signup_user_by_email
from htk.apps.accounts.utils import get_user_by_email
from htk.apps.accounts.view_helpers import redirect_to_social_auth_complete
from htk.apps.accounts.session_keys import (
SOCIAL_AUTH_FLOW_KEYS,
SOCIAL_REGISTRATION_SETTING_AGREED_TO_TERMS,
SOCIAL_REGISTRATION_SETTING_EMAIL,
SOCIAL_REGISTRATION_SETTING_MISSING_EMAIL,
)
from htk.apps.accounts.utils import (
associate_user_email,
get_incomplete_signup_user_by_email,
get_user_by_email,
)
from htk.utils import htk_setting


# isort: off


# Custom Pipeline Functions
# https://django-social-auth.readthedocs.org/en/v0.7.22/pipeline.html
#
Expand All @@ -28,99 +40,148 @@

# 1. If there is no email, have the user enter an email
# 2. Check association. If there is an account with that email:
# a. "An account with this email address already exists. Please log in to link your {{ SOCIAL }} account."
# b. "An account with this email address is already linked to {{ SOCIAL }}. Please create a new account using a different email address."
# a. "An account with this email address already exists. Please log in to link your
# {{ SOCIAL }} account."
# b. "An account with this email address is already linked to {{ SOCIAL }}. Please
# create a new account using a different email address."
# 3. Create the account with the username and email


def python_social_auth_shim(pipeline_func):
"""Shim layer decorator for django-social-auth to python-social auth migration
pipeline complete wasn't passing the request object, but the strategy object instead
"""

def wrapped(strategy, *args, **kwargs):
if not kwargs.get('request'):
request = strategy.request
kwargs['request'] = request
return pipeline_func(*args, **kwargs)

return wrapped


def reset_session_keys(strategy, *args, **kwargs):
"""Reset a bunch of keys used as part of the social auth flow
This is to prevent partially-completed values from a previous flow from affecting a new social auth flow

This is to prevent partially-completed values from a previous flow from affecting a
new social auth flow
"""
for key in SOCIAL_AUTH_FLOW_KEYS:
if strategy.request.session.get(key):
del strategy.request.session[key]
return None


@partial
def check_email(strategy, details, user=None, *args, **kwargs):
def check_email(strategy, details, backend, uid, user=None, *args, **kwargs):
"""Ask the user to enter the email if we don't have one yet

The pipeline process was cut prior to this custom pipeline function, and will resume to this same function after completing
The pipeline process was cut prior to this custom pipeline function, and will
resume to this same function after completing the social auth flow.
"""
response = None
if user is None:
strategy.request.session['backend'] = kwargs.get('current_partial').backend
strategy.request.session['backend'] = kwargs.get(
'current_partial'
).backend
social_email = details.get('email')
collected_email = strategy.request.session.get(SOCIAL_REGISTRATION_SETTING_EMAIL)
collected_email = strategy.request.session.get(
SOCIAL_REGISTRATION_SETTING_EMAIL
)
if social_email:
# email available from social auth
user = get_user_by_email(social_email)
if user and user.is_active:
# a user is already associated with this email
# TODO: there is an error with linking accounts...
strategy.request.session[SOCIAL_REGISTRATION_SETTING_EMAIL] = social_email
if user.has_usable_password():
# user should log into the existing account with a password
url_name = htk_setting('HTK_ACCOUNTS_REGISTER_SOCIAL_LOGIN_URL_NAME')
# A user is already associated with this email
auto_associate_backends = htk_setting(
'HTK_ACCOUNTS_SOCIAL_AUTO_ASSOCIATE_BACKENDS', []
)
backend_name = backend.name
if backend_name in auto_associate_backends:
# The backend is one of the auto-associate backends, so we need to
# associate the Django user with the social auth account
# This is a job for `associate_user` pipeline but the association
# must happen in here.
associate_user(backend, uid, user=user)
Comment on lines +96 to +106
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewer's Note

The main change!

else:
# no password was set, so user must log in with another social auth account
url_name = htk_setting('HTK_ACCOUNTS_REGISTER_SOCIAL_ALREADY_LINKED_URL_NAME')
response = redirect(url_name)
# TODO: There is an error with linking accounts...
strategy.request.session[
SOCIAL_REGISTRATION_SETTING_EMAIL
] = social_email
if user.has_usable_password():
# user should log into the existing account with a password
url_name = htk_setting(
'HTK_ACCOUNTS_REGISTER_SOCIAL_LOGIN_URL_NAME'
)
response = redirect(url_name)
else:
# no password was set, so user must log in with another social
# auth account
url_name = htk_setting(
'HTK_ACCOUNTS_REGISTER_SOCIAL_ALREADY_LINKED_URL_NAME'
)
response = redirect(url_name)
else:
# no user found with this email
pass

elif collected_email:
# email provided by user
details['email'] = collected_email
response = { 'details' : details }
response = {'details': details}
else:
# no email provided from social auth
strategy.request.session[SOCIAL_REGISTRATION_SETTING_MISSING_EMAIL] = True
url_name = htk_setting('HTK_ACCOUNTS_REGISTER_SOCIAL_EMAIL_URL_NAME')
strategy.request.session[
SOCIAL_REGISTRATION_SETTING_MISSING_EMAIL
] = True
url_name = htk_setting(
'HTK_ACCOUNTS_REGISTER_SOCIAL_EMAIL_URL_NAME'
)
response = redirect(url_name)
else:
pass

return response


@partial
def check_terms_agreement(strategy, details, user=None, *args, **kwargs):
"""
Ask the user to agree to Privacy Policy and Terms of Service
"""
response = None
if user is None:
agreed_to_terms = strategy.request.session.get(SOCIAL_REGISTRATION_SETTING_AGREED_TO_TERMS, False)
agreed_to_terms = strategy.request.session.get(
SOCIAL_REGISTRATION_SETTING_AGREED_TO_TERMS, False
)
if not agreed_to_terms:
email = details.get('email')
strategy.request.session[SOCIAL_REGISTRATION_SETTING_EMAIL] = email
url_name = htk_setting('HTK_ACCOUNTS_REGISTER_SOCIAL_EMAIL_AND_TERMS_URL_NAME')
url_name = htk_setting(
'HTK_ACCOUNTS_REGISTER_SOCIAL_EMAIL_AND_TERMS_URL_NAME'
)
response = redirect(url_name)
else:
pass
else:
pass
return response


def check_incomplete_signup(strategy, details, user=None, *args, **kwargs):
"""Checks for an incomplete signup, and sets that User instead
"""
"""Checks for an incomplete signup, and sets that User instead"""
response = None
if user is None:
social_email = details.get('email')
user = get_incomplete_signup_user_by_email(social_email)
response = {
'user' : user,
'is_new' : user is None,
'user': user,
'is_new': user is None,
}
return response


def set_username(strategy, details, user, social, *args, **kwargs):
"""This pipeline function can be used to set UserProfile.has_username_set = True

Expand All @@ -137,9 +198,9 @@ def set_username(strategy, details, user, social, *args, **kwargs):
user_profile.save()
return response


def associate_email(strategy, details, user, social, *args, **kwargs):
"""Associate email with the user
"""
"""Associate email with the user"""
if not user or not social:
return None

Expand All @@ -149,26 +210,34 @@ def associate_email(strategy, details, user, social, *args, **kwargs):
domain = strategy.request.get_host()
# Should confirm if the email was provided by the social auth provider, not the user
# i.e. SOCIAL_REGISTRATION_SETTING_MISSING_EMAIL was False
confirmed = not(strategy.request.session.get(SOCIAL_REGISTRATION_SETTING_MISSING_EMAIL, False))
user_email = associate_user_email(user, email, domain=domain, confirmed=confirmed)
confirmed = not (
strategy.request.session.get(
SOCIAL_REGISTRATION_SETTING_MISSING_EMAIL, False
)
)
user_email = associate_user_email(
user, email, domain=domain, confirmed=confirmed
)

if user_email:
# need to update the User with the activated one, so that it doesn't get overwritten later on
# need to update the User with the activated one, so that it doesn't get
# overwritten later on
response = {
'user': user_email.user,
}
return response


def handle_new_user(user, is_new, *args, **kwargs):
"""Do stuff if the account was newly created
"""
"""Do stuff if the account was newly created"""
if not user:
return None

if is_new:
# send a welcome email to the user, regardless of email confirmation status
welcome_email(user)


def post_connect(user, social, *args, **kwargs):
response = None
return response