Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to implement an invite flow using django allauth for signup/signin?

Background

I'm building an app in which users are able to invite other people to collaborate on different resources. The people that are invited could already be users of the app or could be completely new to it. As i'm using allauth for my signup/signin an invitee can respond to an invite through the standard signup/signin forms or through one of three social accounts (fb, twitter, google).

Due to these requirements, subclassing DefaultAccountAdapter and overriding the is_open_for_signup method will not work, as this is not part of the sign in flow, if an existing user accepts an invite.


Flow

  • user submits invite form, specifying email address of recipient
  • invite email is sent, containing link to invite acceptance form
  • user clicks link to acceptance form - they may or may not already have their own user account for the app
  • as the invite acceptance link contains the unique key for this invite, the view adds the 'invite_key' to the session
  • the invitee is presented with the option to sign up or sign in to an existing user account to accept the invite
  • once the invitee has completed signup/signin, the 'user_signed_up' or 'user_signed_in' signal is received and the session is checked for an 'invite_key' to confirm that the new user has just accepted an invite
  • the invite is retrieved using the key and the invite is processed against the new user


Logic

The url pattern for the acceptance view

url(r'^invitation/(?P<invite_key>[\w\d]+)/$', views.ResourceInviteAcceptanceView.as_view(), name='resource-invite-accept'),

These are the base classes for my view

https://gist.github.com/jamesbrobb/748c47f46b9bd224b07f

and this is the view logic for the invite acceptance view

from django.contrib.auth.models import User
from django.shortcuts import get_object_or_404
from django.dispatch import receiver

from allauth.account import app_settings
from allauth.account.forms import LoginForm, SignupForm
from allauth.account.utils import get_next_redirect_url, complete_signup
from allauth.account.signals import user_signed_up, user_logged_in

from forms.views import MultiFormsView
from api.models import ResourceInvite

class ResourceInviteAcceptanceView(MultiFormsView):
    template_name = 'public/resource_invite_accept.html'
    form_classes = {'login': LoginForm,
                    'signup': SignupForm}
    redirect_field_name = "next"

    def get_invite(self):
        invite_key = self.kwargs['invite_key']
        invite = get_object_or_404(ResourceInvite, key=invite_key)
        return invite

    def get_login_initial(self):
        invite = self.get_invite()
        return {'login':invite.email}

    def get_signup_initial(self):
        invite = self.get_invite()
        return {'email':invite.email}

    def get_context_data(self, **kwargs):
        context = super(ResourceInviteAcceptanceView, self).get_context_data(**kwargs)
        context.update({"redirect_field_name": self.redirect_field_name,
                        "redirect_field_value": self.request.REQUEST.get(self.redirect_field_name)})
        return context

    def get_success_url(self):
        # Explicitly passed ?next= URL takes precedence
        ret = (get_next_redirect_url(self.request,
                                     self.redirect_field_name)
               or self.success_url)
        return ret

    def login_form_valid(self, form):
        return form.login(self.request, redirect_url=self.get_success_url())

    def signup_form_valid(self, form):
        user = form.save(self.request)
        return complete_signup(self.request, user,
                               app_settings.EMAIL_VERIFICATION,
                               self.get_success_url())

    def get(self, request, *args, **kwargs):
        session = request.session
        session['invite_key'] = self.kwargs['invite_key'] 
        return super(ResourceInviteAcceptanceView, self).get(request, *args, **kwargs)


@receiver ([user_signed_up, user_logged_in], sender=User)
def check_for_invite(sender, **kwargs):
    signal = kwargs.get('signal', None)
    user = kwargs.get('user', None)
    request = kwargs.get('request', None)
    session = request.session
    invite_key = session.get('invite_key')
    if invite_key:
        invite = get_object_or_404(ResourceInvite, key=invite_key)
        """ logic to process invite goes here """
        del session['invite_key']


Issues

This all works fine as long as the invitee clicks the link and completes the invite acceptance process.

But...

If they bail at any point during that process (explicitly or due to error), the 'invite_key' is still present on the session and therefore gets processed when the next person (either them or someone else) signs up or signs in.


Question

What's the best way to deal with this issue? Is there a different point at which the 'invite_key' can be added to the session, that guarantees that the user has already actually accepted the invite?

For standard signup/signin this could be in an overriden 'forms_valid' method, as we know at this point that the user has completed either of those processes. But i have no idea where/how to add the 'invite_key' when they use social signup/sigin?


-- UPDATE --

Possible solution #1

Through social login, the best place to add the invitation key to the session - to ensure the user is in the process of accepting an invite through social login - would appear to by adding a receiver to the 'pre_social_login' signal. The problem i have, is how to ensure that the key is still actually accessible at the point the signal is fired, so that it can be added to the session?

One failed solution was to simply access the HTTP_REFERER in the receiver function which can contain the invitation url. The key could be stripped from this and then added to the session. But this fails if the user is new to the app or is not currently logged into their social account, as they're redirected to a social account login page first (on the social account domain), then when the callback redirect occurs and the signal is fired, the value for HTTP_REFERER no longer exists.

I can't work out a good way to make the invitation key value accessible in the signal receiver function, without it resulting in the same, original issue?

like image 883
james Avatar asked Jun 04 '14 09:06

james


People also ask

How does Django-Allauth work?

django-allauth is an integrated set of Django applications dealing with account authentication, registration, management, and third-party (social) account authentication. It is one of the most popular authentication modules due to its ability to handle both local and social logins.


1 Answers

I've come up with a solution, but i'm not 100% happy with it, as it involves monkey patching the state_from_request class method on allauth.socialaccount.models.SocialLogin.

The reasons for this are

  • it's a single shared point of logic that all providers call when initiating their social auth process

  • the 'state' property of SocialLogin is already stored on the session during the social login process and then retrieved during completion and passed with the 'pre_social_login' signal

This is the original method which retrieves specific values from the request, that are then stored in the session for later use by allauth once the process is completed

@classmethod
def state_from_request(cls, request):
    state = {}
    next_url = get_next_redirect_url(request)
    if next_url:
        state['next'] = next_url
    state['process'] = request.REQUEST.get('process', 'login')
    return state

This is the patch

def state_from_request_wrapper(wrapped_func):
    wrapped_func = wrapped_func.__func__
    def _w(cls, request):
        state = wrapped_func(cls, request)
        invite_key = extract_invite_key(request)
        if invite_key:
            state['invite_key'] = invite_key
        return state
    return classmethod(_w)

def extract_invitation_key(request):
    referer = request.META.get('HTTP_REFERER')
    if not referer:
        return None
    p = re.compile('iv/(?P<invite_key>[\w\d]+)/$')
    match = p.search(referer)
    if not match:
        return None
    return match.group(1)

SocialLogin.state_from_request = state_from_request_wrapper(SocialLogin.state_from_request)

I've removed the overriden get method from the view and overriden the forms_valid method instead to add the invite key to the session, as at this point during standard signin/signup we know that the invite has been accepted

def forms_valid(self, forms, form_name):
    session = self.request.session
    session['invite_key'] = self.get_invite().key
    return super(ResourceInviteAcceptanceView, self).forms_valid(forms, form_name)

And these are the signal receiver functions

@receiver (pre_social_login, sender=SocialLogin)    
def check_pre_social_login(sender, **kwargs):
    social_login = kwargs['sociallogin']
    request = kwargs['request']
    session = request.session
    invite_key = social_login.state.get('invite_key')
    if invite_key:
        session['invite_key'] = invite_key


@receiver ([user_signed_up, user_logged_in], sender=User)
def check_for_invite(sender, **kwargs): 
    request = kwargs['request']
    session = request.session
    invite_key = session.get('invite_key')
    if invite_key:
        invite = get_object_or_404(ResourceInvite, key=invite_key)
        process_invite(kwargs['user'], invite, True)
        del session['invite_key']


def process_invite(user, invite, accept):
    ...
    # process invite here
like image 156
james Avatar answered Sep 23 '22 20:09

james