Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Django + Google Federated Login

I would like to enable the visitors of my website to login using their Google Accounts instead of having to sign up and create a new one.

A few things:

  • I am NOT using the Django authentication framework, instead, I do my own authentication and keep information about users in my own set of tables
  • consequently, various django-openid libs are not applicable since they all assume that the standard Django auth framework is used.

I tried to study the python-openid library + the google federated login API but I am lost. I get as close as to understand the instantiating the Consumer class but do not understand the session and store params required. I cannot fathom something that seems so easy can be so complicated. Is there really no step by step tutorial of how to do this in pure python or django?

I tried to look at the examples/consumer.py within the python-openid but it's 500lines of code again that I do not understand.

I also don't understand how verification of the user against google accounts is done on every request to my website. Google API only explains initial login steps. What happens on every request to my website where authentication must be verified against a google server?

like image 944
Rok Avatar asked Dec 12 '22 17:12

Rok


2 Answers

I think your problem stems from a basic misunderstanding of how OpenID and/or OAuth work.

It looks like you just want authentication, so let's stick to OpenID for now. You are correct to look at existing libraries. python-openid is the one to use if you only need OpenID and not OAuth, and you are not using Django's built-in auth framework.

The full documentation for Federated Login with OpenID and OAuth is here: http://code.google.com/apis/accounts/docs/OpenID.html . In particular, look at the diagram under "Interaction sequence".

First, here is a very good working example from Facebook's Tornado web server's auth module:

https://github.com/facebook/tornado/blob/master/tornado/auth.py (grep that for "GoogleHandler". I've used it with great success.) This is independent of Django and Django auth, and should give you a good example of how to implement what you want. If that's still not enough, read on...

You said django-openid is irrelevant, but in fact it demonstrates an implementation of exactly what you want, but for Django's auth system instead of yours. Actually, you should look at the similar plugin, Django-SocialAuth, which implements OpenID + OAuth for a few different providers (Google, Facebook, Twitter, etc.). In particular, look at:

https://github.com/agiliq/Django-Socialauth/blob/master/socialauth/lib/oauthgoogle.py and https://github.com/agiliq/Django-Socialauth/tree/master/openid_consumer and https://github.com/agiliq/Django-Socialauth/tree/master/example_project

...for a full working example using django's auth framework, and can be adapted to your custom auth framework.

Best of luck. I encourage you to document whatever ends up working for you and build a step-by-step guide for others like you.

  • Federated Login for Google Account Users
  • django-openid
  • django-socialauth
like image 103
ido Avatar answered Dec 29 '22 12:12

ido


I have managed to demistify the problem so here is the solution and I hope someone else can benefit from it: 1) Google Account verification is not done against the google accounts server on every request to your application. For example: 1.1 a user logs into your app using their gmail account 1.2 the user also navigates to gmail.com where they check their email 1.3 they log out of gmail 1.4 they remain logged into your application and can use it fully This means you have to take care of session expiry on your end, Google account does not take care of it.

2) The core Python code I used is the following:

from openid.consumer.consumer import Consumer, \
    SUCCESS, CANCEL, FAILURE, SETUP_NEEDED
from openid.consumer.discover import DiscoveryFailure
from django.utils.encoding import smart_unicode
from myapp.common.util.openid import DjangoOpenIDStore

def google_signin(request):
    """ This is the view where the Google account login icon on your site points to, e.g. http://www.yourdomain.com/google-signin """
    consumer = Consumer(request.session, DjangoOpenIDStore())

    # catch Google Apps domain that is referring, if any 
    _domain = None
    if 'domain' in request.POST:
        _domain = request.POST['domain']
    elif 'domain' in request.GET:
        _domain = request.GET['domain']

    try:
        # two different endpoints depending on whether the using is using Google Account or Google Apps Account
        if _domain:
            auth_request = consumer.begin('https://www.google.com/accounts/o8/site-xrds?hd=%s' % _domain)
        else:
            auth_request = consumer.begin('https://www.google.com/accounts/o8/id')
    except DiscoveryFailure as e:
        return CustomError(request, "Google Accounts Error", "Google's OpenID endpoint is not available.")

    # add requests for additional account information required, in my case: email, first name & last name
    auth_request.addExtensionArg('http://openid.net/srv/ax/1.0', 'mode', 'fetch_request')
    auth_request.addExtensionArg('http://openid.net/srv/ax/1.0', 'required', 'email,firstname,lastname')
    auth_request.addExtensionArg('http://openid.net/srv/ax/1.0', 'type.email', 'http://schema.openid.net/contact/email')
    auth_request.addExtensionArg('http://openid.net/srv/ax/1.0', 'type.firstname', 'http://axschema.org/namePerson/first')
    auth_request.addExtensionArg('http://openid.net/srv/ax/1.0', 'type.lastname', 'http://axschema.org/namePerson/last')

    return redirect(auth_request.redirectURL('http://www.yourdomain.com', 'http://www.yourdomain.com/google-signin-response')))


@transaction.commit_manually 
def google_signin_response(request):
    """ Callback from Google Account service with login the status. Your url could be http://www.yourdomain.com/google-signin-response """
    transaction.rollback() # required due to Django's transaction inconsistency between calls
    oidconsumer = Consumer(request.session, DjangoOpenIDStore())

    # parse GET parameters submit them with the full url to consumer.complete
    _params = dict((k,smart_unicode(v)) for k, v in request.GET.items())
    info = oidconsumer.complete(_params, request.build_absolute_uri().split('?')[0])
    display_identifier = info.getDisplayIdentifier()

    if info.status == FAILURE and display_identifier:
        return CustomError(request, _("Google Login Error"), _("Verification of %(user)s failed: %(error_message)s") % {'user' : display_identifier, 'error_message' : info.message})

    elif info.status == SUCCESS:
        try:
            _email = info.message.args[('http://openid.net/srv/ax/1.0', 'value.email')]
            _first_name = info.message.args[('http://openid.net/srv/ax/1.0', 'value.firstname')]
            _last_name = info.message.args[('http://openid.net/srv/ax/1.0', 'value.lastname')]
            try:
                _user = User.objects.get(email__iexact=_email)
            except ObjectDoesNotExist:
                # create a new account if one does not exist with the authorized email yet and log that user in
                _new_user = _new_account(_email, _first_name + ' ' + _last_name, _first_name, _last_name, p_account_status=1)
                _login(request, _new_user, info.message.args[('http://specs.openid.net/auth/2.0', 'response_nonce')])
                transaction.commit()
                return redirect('home')
            else:
                # login existing user
                _login(request, _user, info.message.args[('http://specs.openid.net/auth/2.0', 'response_nonce')])
                transaction.commit()
                return redirect('home')
        except Exception as e:
            transaction.rollback()
            system_log_entry(e, request=request)
            return CustomError(request, _("Login Unsuccessful"), "%s" % e)

    elif info.status == CANCEL:
        return CustomError(request, _("Google Login Error"), _('Google account verification cancelled.'))

    elif info.status == SETUP_NEEDED:
        if info.setup_url:
            return CustomError(request, _("Google Login Setup Needed"), _('<a href="%(url)s">Setup needed</a>') % { 'url' : info.setup_url })
        else:
            # This means auth didn't succeed, but you're welcome to try
            # non-immediate mode.
            return CustomError(request, _("Google Login Setup Needed"), _('Setup needed'))
    else:
        # Either we don't understand the code or there is no
        # openid_url included with the error. Give a generic
        # failure message. The library should supply debug
        # information in a log.
        return CustomError(request, _("Google Login Error"), _('Google account verification failed for an unknown reason. Please try to create a manual account on Acquee.'))


def get_url_host(request):
    if request.is_secure():
        protocol = 'https'
    else:
        protocol = 'http'
    host = escape(get_host(request))
    return '%s://%s' % (protocol, host)

3) an additional lib I created and imported above (myapp.common.util.openid) is a merge of a few existing Django openID libs so kudos to those guys:

from django.db import models
from django.conf import settings
from django.utils.hashcompat import md5_constructor

from openid.store.interface import OpenIDStore
import openid.store 
from openid.association import Association as OIDAssociation
import time, base64

from myapp.common.db.accounts.models import Association, Nonce

class DjangoOpenIDStore(OpenIDStore):
    """
The Python openid library needs an OpenIDStore subclass to persist data
related to OpenID authentications. This one uses our Django models.
"""

    def storeAssociation(self, server_url, association):
        assoc = Association(
            server_url = server_url,
            handle = association.handle,
            secret = base64.encodestring(association.secret),
            issued = association.issued,
            lifetime = association.issued,
            assoc_type = association.assoc_type
        )
        assoc.save()

    def getAssociation(self, server_url, handle=None):
        assocs = []
        if handle is not None:
            assocs = Association.objects.filter(
                server_url = server_url, handle = handle
            )
        else:
            assocs = Association.objects.filter(
                server_url = server_url
            )
        if not assocs:
            return None
        associations = []
        for assoc in assocs:
            association = OIDAssociation(
                assoc.handle, base64.decodestring(assoc.secret), assoc.issued,
                assoc.lifetime, assoc.assoc_type
            )
            if association.getExpiresIn() == 0:
                self.removeAssociation(server_url, assoc.handle)
            else:
                associations.append((association.issued, association))
        if not associations:
            return None
        return associations[-1][1]

    def removeAssociation(self, server_url, handle):
        assocs = list(Association.objects.filter(
            server_url = server_url, handle = handle
        ))
        assocs_exist = len(assocs) > 0
        for assoc in assocs:
            assoc.delete()
        return assocs_exist

    def useNonce(self, server_url, timestamp, salt):
        # Has nonce expired?
        if abs(timestamp - time.time()) > openid.store.nonce.SKEW:
            return False
        try:
            nonce = Nonce.objects.get(
                server_url__exact = server_url,
                timestamp__exact = timestamp,
                salt__exact = salt
            )
        except Nonce.DoesNotExist:
            nonce = Nonce.objects.create(
                server_url = server_url,
                timestamp = timestamp,
                salt = salt
            )
            return True
        nonce.delete()
        return False

    def cleanupNonce(self):
        Nonce.objects.filter(
            timestamp__lt = (int(time.time()) - nonce.SKEW)
        ).delete()

    def cleaupAssociations(self):
        Association.objects.extra(
            where=['issued + lifetimeint < (%s)' % time.time()]
        ).delete()

    def getAuthKey(self):
        # Use first AUTH_KEY_LEN characters of md5 hash of SECRET_KEY
        return md5_constructor.new(settings.SECRET_KEY).hexdigest()[:self.AUTH_KEY_LEN]

    def isDumb(self):
        return False

4) and the model that is required in order to hold google account session identifiers and verified endpoints:

class Nonce(models.Model):
    """ Required for OpenID functionality """
    server_url = models.CharField(max_length=255)
    timestamp = models.IntegerField()
    salt = models.CharField(max_length=40)

    def __unicode__(self):
        return u"Nonce: %s for %s" % (self.salt, self.server_url)


class Association(models.Model):
    """ Required for OpenID functionality """
    server_url = models.TextField(max_length=2047)
    handle = models.CharField(max_length=255)
    secret = models.TextField(max_length=255) # Stored base64 encoded
    issued = models.IntegerField()
    lifetime = models.IntegerField()
    assoc_type = models.TextField(max_length=64)

    def __unicode__(self):
        return u"Association: %s, %s" % (self.server_url, self.handle)

Good luck! Rok

like image 36
Rok Avatar answered Dec 29 '22 12:12

Rok