Commit d2434a6b by James Henstridge Committed by Tarmac

Add support for requesting user details via the Attribute Exchange extension. …

Add support for requesting user details via the Attribute Exchange extension.  This allows us to retrieve user details from providers that don't implement the Simple Registration extension (e.g. Google).  Fixes bug #517393.
parents 524454e9 c64f47e7
...@@ -39,8 +39,9 @@ single signon systems. ...@@ -39,8 +39,9 @@ single signon systems.
OPENID_CREATE_USERS = True OPENID_CREATE_USERS = True
5. To have user details updated from OpenID Simple Registration data 5. To have user details updated from OpenID Simple Registration or
each time they log in, add the following: Attribute Exchange extension data each time they log in, add the
following:
OPENID_UPDATE_DETAILS_FROM_SREG = True OPENID_UPDATE_DETAILS_FROM_SREG = True
......
...@@ -33,7 +33,7 @@ __metaclass__ = type ...@@ -33,7 +33,7 @@ __metaclass__ = type
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from openid.consumer.consumer import SUCCESS from openid.consumer.consumer import SUCCESS
from openid.extensions import sreg from openid.extensions import ax, sreg
from django_openid_auth import teams from django_openid_auth import teams
from django_openid_auth.models import UserOpenID from django_openid_auth.models import UserOpenID
...@@ -80,10 +80,8 @@ class OpenIDBackend: ...@@ -80,10 +80,8 @@ class OpenIDBackend:
return None return None
if getattr(settings, 'OPENID_UPDATE_DETAILS_FROM_SREG', False): if getattr(settings, 'OPENID_UPDATE_DETAILS_FROM_SREG', False):
sreg_response = sreg.SRegResponse.fromSuccessResponse( details = self._extract_user_details(openid_response)
openid_response) self.update_user_details(user, details)
if sreg_response:
self.update_user_details_from_sreg(user, sreg_response)
teams_response = teams.TeamsResponse.fromSuccessResponse( teams_response = teams.TeamsResponse.fromSuccessResponse(
openid_response) openid_response)
...@@ -92,14 +90,45 @@ class OpenIDBackend: ...@@ -92,14 +90,45 @@ class OpenIDBackend:
return user return user
def create_user_from_openid(self, openid_response): def _extract_user_details(self, openid_response):
email = fullname = first_name = last_name = nickname = None
sreg_response = sreg.SRegResponse.fromSuccessResponse(openid_response) sreg_response = sreg.SRegResponse.fromSuccessResponse(openid_response)
if sreg_response: if sreg_response:
nickname = sreg_response.get('nickname', 'openiduser') email = sreg_response.get('email')
email = sreg_response.get('email', '') fullname = sreg_response.get('fullname')
else: nickname = sreg_response.get('nickname')
nickname = 'openiduser'
email = '' # If any attributes are provided via Attribute Exchange, use
# them in preference.
fetch_response = ax.FetchResponse.fromSuccessResponse(openid_response)
if fetch_response:
email = fetch_response.getSingle(
'http://axschema.org/contact/email', email)
fullname = fetch_response.getSingle(
'http://axschema.org/namePerson', fullname)
first_name = fetch_response.getSingle(
'http://axschema.org/namePerson/first', first_name)
last_name = fetch_response.getSingle(
'http://axschema.org/namePerson/last', last_name)
nickname = fetch_response.getSingle(
'http://axschema.org/namePerson/friendly', nickname)
if fullname and not (first_name or last_name):
# Django wants to store first and last names separately,
# so we do our best to split the full name.
if ' ' in fullname:
first_name, last_name = fullname.rsplit(None, 1)
else:
first_name = u''
last_name = fullname
return dict(email=email, nickname=nickname,
first_name=first_name, last_name=last_name)
def create_user_from_openid(self, openid_response):
details = self._extract_user_details(openid_response)
nickname = details['nickname'] or 'openiduser'
email = details['email'] or ''
# Pick a username for the user based on their nickname, # Pick a username for the user based on their nickname,
# checking for conflicts. # checking for conflicts.
...@@ -115,9 +144,7 @@ class OpenIDBackend: ...@@ -115,9 +144,7 @@ class OpenIDBackend:
i += 1 i += 1
user = User.objects.create_user(username, email, password=None) user = User.objects.create_user(username, email, password=None)
self.update_user_details(user, details)
if sreg_response:
self.update_user_details_from_sreg(user, sreg_response)
self.associate_openid(user, openid_response) self.associate_openid(user, openid_response)
return user return user
...@@ -142,20 +169,20 @@ class OpenIDBackend: ...@@ -142,20 +169,20 @@ class OpenIDBackend:
return user_openid return user_openid
def update_user_details_from_sreg(self, user, sreg_response): def update_user_details(self, user, details):
fullname = sreg_response.get('fullname') updated = False
if fullname: if details['first_name']:
# Do our best here ... user.first_name = details['first_name']
if ' ' in fullname: updated = True
user.first_name, user.last_name = fullname.rsplit(None, 1) if details['last_name']:
else: user.last_name = details['last_name']
user.first_name = u'' updated = True
user.last_name = fullname if details['email']:
user.email = details['email']
email = sreg_response.get('email') updated = True
if email:
user.email = email if updated:
user.save() user.save()
def update_groups_from_teams(self, user, teams_response): def update_groups_from_teams(self, user, teams_response):
teams_mapping_auto = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING_AUTO', False) teams_mapping_auto = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING_AUTO', False)
......
...@@ -34,7 +34,7 @@ import unittest ...@@ -34,7 +34,7 @@ import unittest
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User, Group from django.contrib.auth.models import User, Group
from django.test import TestCase from django.test import TestCase
from openid.extensions.sreg import SRegRequest, SRegResponse from openid.extensions import ax, sreg
from openid.fetchers import ( from openid.fetchers import (
HTTPFetcher, HTTPFetchingError, HTTPResponse, setDefaultFetcher) HTTPFetcher, HTTPFetchingError, HTTPResponse, setDefaultFetcher)
from openid.oidutil import importElementTree from openid.oidutil import importElementTree
...@@ -58,10 +58,12 @@ class StubOpenIDProvider(HTTPFetcher): ...@@ -58,10 +58,12 @@ class StubOpenIDProvider(HTTPFetcher):
self.endpoint_url = base_url + 'endpoint' self.endpoint_url = base_url + 'endpoint'
self.server = Server(self.store, self.endpoint_url) self.server = Server(self.store, self.endpoint_url)
self.last_request = None self.last_request = None
self.type_uris = ['http://specs.openid.net/auth/2.0/signon']
def fetch(self, url, body=None, headers=None): def fetch(self, url, body=None, headers=None):
if url == self.identity_url: if url == self.identity_url:
# Serve an XRDS document directly, which is the # Serve an XRDS document directly, pointing at our endpoint.
type_uris = ['<Type>%s</Type>' % uri for uri in self.type_uris]
return HTTPResponse( return HTTPResponse(
url, 200, {'content-type': 'application/xrds+xml'}, """\ url, 200, {'content-type': 'application/xrds+xml'}, """\
<?xml version="1.0"?> <?xml version="1.0"?>
...@@ -70,13 +72,13 @@ class StubOpenIDProvider(HTTPFetcher): ...@@ -70,13 +72,13 @@ class StubOpenIDProvider(HTTPFetcher):
xmlns:xrds="xri://$xrds"> xmlns:xrds="xri://$xrds">
<XRD> <XRD>
<Service priority="0"> <Service priority="0">
<Type>http://specs.openid.net/auth/2.0/signon</Type> %s
<URI>%s</URI> <URI>%s</URI>
<LocalID>%s</LocalID> <LocalID>%s</LocalID>
</Service> </Service>
</XRD> </XRD>
</xrds:XRDS> </xrds:XRDS>
""" % (self.endpoint_url, self.localid_url)) """ % ('\n'.join(type_uris), self.endpoint_url, self.localid_url))
elif url.startswith(self.endpoint_url): elif url.startswith(self.endpoint_url):
# Gather query parameters # Gather query parameters
query = {} query = {}
...@@ -260,9 +262,9 @@ class RelyingPartyTests(TestCase): ...@@ -260,9 +262,9 @@ class RelyingPartyTests(TestCase):
# Complete the request, passing back some simple registration # Complete the request, passing back some simple registration
# data. The user is redirected to the next URL. # data. The user is redirected to the next URL.
openid_request = self.provider.parseFormPost(response.content) openid_request = self.provider.parseFormPost(response.content)
sreg_request = SRegRequest.fromOpenIDRequest(openid_request) sreg_request = sreg.SRegRequest.fromOpenIDRequest(openid_request)
openid_response = openid_request.answer(True) openid_response = openid_request.answer(True)
sreg_response = SRegResponse.extractResponse( sreg_response = sreg.SRegResponse.extractResponse(
sreg_request, {'nickname': 'someuser', 'fullname': 'Some User', sreg_request, {'nickname': 'someuser', 'fullname': 'Some User',
'email': 'foo@example.com'}) 'email': 'foo@example.com'})
openid_response.addExtension(sreg_response) openid_response.addExtension(sreg_response)
...@@ -298,9 +300,9 @@ class RelyingPartyTests(TestCase): ...@@ -298,9 +300,9 @@ class RelyingPartyTests(TestCase):
# Complete the request, passing back some simple registration # Complete the request, passing back some simple registration
# data. The user is redirected to the next URL. # data. The user is redirected to the next URL.
openid_request = self.provider.parseFormPost(response.content) openid_request = self.provider.parseFormPost(response.content)
sreg_request = SRegRequest.fromOpenIDRequest(openid_request) sreg_request = sreg.SRegRequest.fromOpenIDRequest(openid_request)
openid_response = openid_request.answer(True) openid_response = openid_request.answer(True)
sreg_response = SRegResponse.extractResponse( sreg_response = sreg.SRegResponse.extractResponse(
sreg_request, {'nickname': 'someuser', 'fullname': 'Some User', sreg_request, {'nickname': 'someuser', 'fullname': 'Some User',
'email': 'foo@example.com'}) 'email': 'foo@example.com'})
openid_response.addExtension(sreg_response) openid_response.addExtension(sreg_response)
...@@ -318,6 +320,68 @@ class RelyingPartyTests(TestCase): ...@@ -318,6 +320,68 @@ class RelyingPartyTests(TestCase):
self.assertEquals(user.last_name, 'User') self.assertEquals(user.last_name, 'User')
self.assertEquals(user.email, 'foo@example.com') self.assertEquals(user.email, 'foo@example.com')
def test_login_attribute_exchange(self):
settings.OPENID_UPDATE_DETAILS_FROM_SREG = True
user = User.objects.create_user('testuser', 'someone@example.com')
useropenid = UserOpenID(
user=user,
claimed_id='http://example.com/identity',
display_id='http://example.com/identity')
useropenid.save()
# Configure the provider to advertise attribute exchange
# protocol and start the authentication process:
self.provider.type_uris.append('http://openid.net/srv/ax/1.0')
response = self.client.post('/openid/login/',
{'openid_identifier': 'http://example.com/identity',
'next': '/getuser/'})
self.assertContains(response, 'OpenID transaction in progress')
# The resulting OpenID request uses the Attribute Exchange
# extension rather than the Simple Registration extension.
openid_request = self.provider.parseFormPost(response.content)
sreg_request = sreg.SRegRequest.fromOpenIDRequest(openid_request)
self.assertEqual(sreg_request.required, [])
self.assertEqual(sreg_request.optional, [])
fetch_request = ax.FetchRequest.fromOpenIDRequest(openid_request)
self.assertTrue(fetch_request.has_key(
'http://axschema.org/contact/email'))
self.assertTrue(fetch_request.has_key(
'http://axschema.org/namePerson'))
self.assertTrue(fetch_request.has_key(
'http://axschema.org/namePerson/first'))
self.assertTrue(fetch_request.has_key(
'http://axschema.org/namePerson/last'))
self.assertTrue(fetch_request.has_key(
'http://axschema.org/namePerson/friendly'))
# Build up a response including AX data.
openid_response = openid_request.answer(True)
fetch_response = ax.FetchResponse(fetch_request)
fetch_response.addValue(
'http://axschema.org/contact/email', 'foo@example.com')
fetch_response.addValue(
'http://axschema.org/namePerson/first', 'Firstname')
fetch_response.addValue(
'http://axschema.org/namePerson/last', 'Lastname')
fetch_response.addValue(
'http://axschema.org/namePerson/friendly', 'someuser')
openid_response.addExtension(fetch_response)
response = self.complete(openid_response)
self.assertRedirects(response, 'http://testserver/getuser/')
# And they are now logged in as testuser (the passed in
# nickname has not caused the username to change).
response = self.client.get('/getuser/')
self.assertEquals(response.content, 'testuser')
# The user's full name and email have been updated.
user = User.objects.get(username='testuser')
self.assertEquals(user.first_name, 'Firstname')
self.assertEquals(user.last_name, 'Lastname')
self.assertEquals(user.email, 'foo@example.com')
def test_login_teams(self): def test_login_teams(self):
settings.OPENID_LAUNCHPAD_TEAMS_MAPPING = {'teamname': 'groupname', settings.OPENID_LAUNCHPAD_TEAMS_MAPPING = {'teamname': 'groupname',
'otherteam': 'othergroup'} 'otherteam': 'othergroup'}
......
...@@ -44,7 +44,7 @@ from django.template.loader import render_to_string ...@@ -44,7 +44,7 @@ from django.template.loader import render_to_string
from openid.consumer.consumer import ( from openid.consumer.consumer import (
Consumer, SUCCESS, CANCEL, FAILURE) Consumer, SUCCESS, CANCEL, FAILURE)
from openid.consumer.discover import DiscoveryFailure from openid.consumer.discover import DiscoveryFailure
from openid.extensions import sreg from openid.extensions import sreg, ax
from django_openid_auth import teams from django_openid_auth import teams
from django_openid_auth.forms import OpenIDLoginForm from django_openid_auth.forms import OpenIDLoginForm
...@@ -163,9 +163,25 @@ def login_begin(request, template_name='openid/login.html', ...@@ -163,9 +163,25 @@ def login_begin(request, template_name='openid/login.html',
return render_failure( return render_failure(
request, "OpenID discovery error: %s" % (str(exc),), status=500) request, "OpenID discovery error: %s" % (str(exc),), status=500)
# Request some user details. # Request some user details. If the provider advertises support
openid_request.addExtension( # for attribute exchange, use that.
sreg.SRegRequest(optional=['email', 'fullname', 'nickname'])) if openid_request.endpoint.supportsType(ax.AXMessage.ns_uri):
fetch_request = ax.FetchRequest()
# We mark all the attributes as required, since Google ignores
# optional attributes. We request both the full name and
# first/last components since some providers offer one but not
# the other.
for (attr, alias) in [
('http://axschema.org/contact/email', 'email'),
('http://axschema.org/namePerson', 'fullname'),
('http://axschema.org/namePerson/first', 'firstname'),
('http://axschema.org/namePerson/last', 'lastname'),
('http://axschema.org/namePerson/friendly', 'nickname')]:
fetch_request.add(ax.AttrInfo(attr, alias=alias, required=True))
openid_request.addExtension(fetch_request)
else:
openid_request.addExtension(
sreg.SRegRequest(optional=['email', 'fullname', 'nickname']))
# Request team info # Request team info
teams_mapping_auto = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING_AUTO', False) teams_mapping_auto = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING_AUTO', False)
......
...@@ -33,7 +33,7 @@ The library integrates with Django's built in authentication system, so ...@@ -33,7 +33,7 @@ The library integrates with Django's built in authentication system, so
most applications require minimal changes to support OpenID llogin. The most applications require minimal changes to support OpenID llogin. The
library also includes the following features: library also includes the following features:
* Basic user details are transferred from the OpenID server via the * Basic user details are transferred from the OpenID server via the
simple Registration extension. Simple Registration extension or Attribute Exchange extension.
* can be configured to use a fixed OpenID server URL, for use in SSO. * can be configured to use a fixed OpenID server URL, for use in SSO.
* supports the launchpad.net teams extension to get team membership * supports the launchpad.net teams extension to get team membership
info. info.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment