Merge lp:~jamestait/canonical-identity-provider/add-ax-fetch into lp:canonical-identity-provider/release

Proposed by James Tait
Status: Merged
Approved by: Ricardo Kirkner
Approved revision: no longer in the source branch.
Merged at revision: 764
Proposed branch: lp:~jamestait/canonical-identity-provider/add-ax-fetch
Merge into: lp:canonical-identity-provider/release
Diff against target: 1492 lines (+1023/-109)
12 files modified
identityprovider/admin.py (+9/-1)
identityprovider/const.py (+26/-0)
identityprovider/forms.py (+152/-25)
identityprovider/migrations/0007_add_allowed_ax.py (+212/-0)
identityprovider/models/openidmodels.py (+1/-0)
identityprovider/templates/server/decide.html (+15/-7)
identityprovider/templates/server/openidapplication-xrds.xml (+1/-0)
identityprovider/tests/openid_server/test_discovery.py (+1/-0)
identityprovider/tests/test_admin.py (+73/-8)
identityprovider/tests/test_forms.py (+199/-2)
identityprovider/tests/test_views_server.py (+296/-61)
identityprovider/views/server.py (+38/-5)
To merge this branch: bzr merge lp:~jamestait/canonical-identity-provider/add-ax-fetch
Reviewer Review Type Date Requested Status
Ricardo Kirkner (community) Approve
Review via email: mp+155200@code.launchpad.net

Commit message

Added handling of OpenID Attribute Exchange.

Description of the change

Add support for OpenID Attribute Exchange 1.0 Fetch Requests
============================================================

This is a fairly large change to add support for OpenID Attribute Exchange 1.0 Fetch Requests [0]. AX is like Simple Registration (SReg, [1]) on steroids. It allows a Relying Party to request certain attributes about the user from the OpenID Provider at login time. Where SReg has a handful of fixed, predefined properties that can be requested, defined by short keywords, AX is extensible, with attributes being identified by a URI, and having a symbolic alias attached.

Taking advantage of this extensibility, as well as returning the same properties as SReg, by way of an SReg compatibility schema for AX, we also return the email verification status. This isn't defined in a spec anywhere, so I've assigned this attribute our own URI in the ns.launchpad.net namespace.

Points to Note
--------------

0) I've added the allowable AX properties for a given RP as a separate coluumn in the RPConfig (allowed_ax) from the allowable SReg properties (allowed_sreg). I initially did this because I was going to store the URIs in the RPConfig, but I later switched to storing the alias for the properties that we can provide. Maybe these could be amalgamated into a single column and the forms could DTRT to avoid people futzing with the SReg requests and getting attributes they shouldn't.
1) I'm not sure about AX_DATA_FIELDS as a NamespaceMap in identityprovider.const.
2) I'm a little unhappy with the complexity in places - lots of mapping using AX_DATA_FIELDS which makes the code somewhat ungainly. I switched from using the URIs to avoid form fields in the AXFetchRequestForm on the decide page with URIs for names, but maybe there's a middle ground where we can use symbolic names just for rendering the form and use the URIs everywhere else.
3) Maybe we don't want to use ns.launchpad.net for the email_verified attribute.
4) Maybe the label for email_verified could be improved.
5) Currently we do nothing with AX Store Requests.
6) If a request has both SReg and AX requests, the SReg request will be ignored and return empty results. This isn't a problem for django-openid-auth, which starts with the SReg results but uses the AX results in preference; other libraries may vary.

[0] http://goo.gl/CBlJZ
[1] http://goo.gl/JyRhC

To post a comment you must log in.
Revision history for this message
Ricardo Kirkner (ricardokirkner) wrote :

- missing commit message
- instead of *_EMAIL_VERIFIED I'd use *_ACCOUNT_VERIFIED (as we should care about the status of the account, not the email; if the account is verified, the returned email is also verified).
- l. 96-99: I'd extract this into a helper method for readability
- l. 101-105: ditto
- l. 114-135: since these values (except email_verified) are the same for AX or SREG, why not have a common helper function to get them (DRY)?
- l. 462-463: you can use {% elif ... %} here I believe?
- l. 516: why not get the entry again and confirm it has the fields you just posted?
- l. 531: this could match any checkbox in the form? why not rather add a specific test to verify email is listed as an ax field?
- l. 613-614: you could also use assertIn, assertNotIn
- l. 818-819,870-894,930-954,988-1024,1065-1086: we now tend to prefer using pyquery for asserting against the DOM
- l. 1166: can we call _get_openid_request with named params? it's hard to understand what each param means otherwise
- l. 1250: why not just do: if ax_form.data: ?
- l. 1250-1253: or maybe add a helper to the form to get the values without having to know that data is a dict?

Despite some desired changes, I must say I'm impressed with the clarity of the work done. It was very easy to understand the changes and follow the new logic.

Well done!

review: Needs Fixing
Revision history for this message
James Tait (jamestait) wrote :

> - missing commit message

Fixed. :)

> - instead of *_EMAIL_VERIFIED I'd use *_ACCOUNT_VERIFIED (as we should care
> about the status of the account, not the email; if the account is verified,
> the returned email is also verified).

This came about as a result of a misunderstanding on my part about the difference between a verified account and an account with a verified e-mail address. Happy to fix this.

> - l. 96-99: I'd extract this into a helper method for readability
> - l. 101-105: ditto

Yes, that would clean things up nicely.

> - l. 114-135: since these values (except email_verified) are the same for AX
> or SREG, why not have a common helper function to get them (DRY)?

Ditto.

> - l. 462-463: you can use {% elif ... %} here I believe?

Alas, not in Django 1.3. ;)

> - l. 516: why not get the entry again and confirm it has the fields you just
> posted?

Yes, that should work. I'll look into adding that extra verification, and maybe do the same for the SReg tests.

> - l. 531: this could match any checkbox in the form? why not rather add a
> specific test to verify email is listed as an ax field?

Indeed, this should be made more robust. I'll shore this up and again look to do the same for the SReg tests.

> - l. 613-614: you could also use assertIn, assertNotIn

Good catch!

> - l. 818-819,870-894,930-954,988-1024,1065-1086: we now tend to prefer using
> pyquery for asserting against the DOM

I'll look into this. I have to admit I was cringing when writing the tests, so I'm grateful for the suggestion of a better method.

> - l. 1166: can we call _get_openid_request with named params? it's hard to
> understand what each param means otherwise

Yes, that would be an improvement.

> - l. 1250: why not just do: if ax_form.data: ?
> - l. 1250-1253: or maybe add a helper to the form to get the values without
> having to know that data is a dict?

I prefer the latter approach. It had occurred to me to do something like this, but I wanted to get eyes on the code before I made any further changes.

Thanks for the comments!

Revision history for this message
Ricardo Kirkner (ricardokirkner) wrote :

LGTM. Awesome stuff!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'identityprovider/admin.py'
2--- identityprovider/admin.py 2013-03-21 17:03:32 +0000
3+++ identityprovider/admin.py 2013-04-03 14:40:35 +0000
4@@ -6,7 +6,10 @@
5 from django.core.urlresolvers import reverse
6 from django.template.defaultfilters import force_escape
7
8-from identityprovider.const import SREG_LABELS
9+from identityprovider.const import (
10+ AX_DATA_LABELS,
11+ SREG_LABELS,
12+)
13 from identityprovider.fields import CommaSeparatedField
14 from identityprovider.models import (
15 Account,
16@@ -42,6 +45,11 @@
17 displayname = forms.CharField(label="Display name")
18 trust_root = forms.URLField(verify_exists=False)
19 logo = forms.CharField(required=False)
20+ allowed_ax = CommaSeparatedField(
21+ choices=AX_DATA_LABELS.items(),
22+ required=False,
23+ widget=CommaSeparatedWidget,
24+ )
25 allowed_sreg = CommaSeparatedField(
26 choices=SREG_LABELS.items(),
27 required=False,
28
29=== modified file 'identityprovider/const.py'
30--- identityprovider/const.py 2012-10-12 21:23:31 +0000
31+++ identityprovider/const.py 2013-04-03 14:40:35 +0000
32@@ -1,6 +1,8 @@
33 # Copyright 2010 Canonical Ltd. This software is licensed under the
34 # GNU Affero General Public License version 3 (see the file LICENSE).
35
36+from openid.message import NamespaceMap
37+
38 LAUNCHPAD_TEAMS_NS = 'http://ns.launchpad.net/2007/openid-teams'
39
40 PERSON_VISIBILITY_PUBLIC = 1
41@@ -20,3 +22,27 @@
42 'timezone': 'Time zone',
43 'language': 'Preferred language',
44 }
45+
46+AX_URI_FULL_NAME = 'http://axschema.org/namePerson'
47+AX_URI_NICKNAME = 'http://axschema.org/namePerson/friendly'
48+AX_URI_EMAIL = 'http://axschema.org/contact/email'
49+AX_URI_TIMEZONE = 'http://axschema.org/timezone'
50+AX_URI_LANGUAGE = 'http://axschema.org/language/pref'
51+AX_URI_ACCOUNT_VERIFIED = 'http://ns.login.ubuntu.com/2013/validation/account'
52+
53+AX_DATA_FIELDS = NamespaceMap()
54+AX_DATA_FIELDS.addAlias(AX_URI_FULL_NAME, 'fullname')
55+AX_DATA_FIELDS.addAlias(AX_URI_NICKNAME, 'nickname')
56+AX_DATA_FIELDS.addAlias(AX_URI_EMAIL, 'email')
57+AX_DATA_FIELDS.addAlias(AX_URI_TIMEZONE, 'timezone')
58+AX_DATA_FIELDS.addAlias(AX_URI_LANGUAGE, 'language')
59+AX_DATA_FIELDS.addAlias(AX_URI_ACCOUNT_VERIFIED, 'account_verified')
60+
61+AX_DATA_LABELS = {
62+ 'fullname': 'Full name',
63+ 'nickname': 'Username',
64+ 'email': 'Email address',
65+ 'timezone': 'Time zone',
66+ 'language': 'Preferred language',
67+ 'account_verified': 'Account verified',
68+}
69
70=== modified file 'identityprovider/forms.py'
71--- identityprovider/forms.py 2013-03-27 14:30:56 +0000
72+++ identityprovider/forms.py 2013-04-03 14:40:35 +0000
73@@ -10,6 +10,8 @@
74 from django.utils.translation import ugettext as _
75
76 from identityprovider.const import (
77+ AX_DATA_FIELDS,
78+ AX_DATA_LABELS,
79 SREG_DATA_FIELDS_ORDER,
80 SREG_LABELS,
81 )
82@@ -340,6 +342,31 @@
83 callback = forms.CharField(error_messages=default_errors)
84
85
86+def _get_data_for_user(request, fields, with_verified=False):
87+ """Get the data to ask about in the form based on the user's
88+ account record.
89+ """
90+ values = {}
91+ user = request.user
92+ values['fullname'] = user.displayname
93+ if user.preferredemail is not None:
94+ values['email'] = user.preferredemail.email
95+ if with_verified:
96+ values['account_verified'] = (
97+ 'token_via_email' if user.is_verified else 'no')
98+ if user.person is not None:
99+ values['nickname'] = user.person.name
100+ if user.person.time_zone is not None:
101+ values['timezone'] = user.person.time_zone
102+ if user.preferredlanguage is not None:
103+ values['language'] = user.preferredlanguage
104+ else:
105+ values['language'] = translation.get_language_from_request(request)
106+ logger.debug("values (sreg_fields) = " + str(values))
107+
108+ return dict([(f, values[f]) for f in fields if f in values])
109+
110+
111 class SRegRequestForm(Form):
112 """A form object for user control over OpenID sreg data.
113 """
114@@ -351,6 +378,11 @@
115 return dict([(f, self.data[f]) for f in self.data
116 if self.field_approved(f)])
117
118+ @property
119+ def has_data(self):
120+ """Helper property to check if this form has any data."""
121+ return len(self.data) > 0
122+
123 def __init__(self, request, sreg_request, rpconfig, approved_data=None):
124 self.request = request
125 self.request_method = request.META.get('REQUEST_METHOD')
126@@ -368,32 +400,10 @@
127 fields = set()
128 else:
129 fields = sreg_fields
130- self.data = self._get_sreg_data_for_user(request.user, fields)
131+ self.data = _get_data_for_user(request, fields, with_verified=False)
132
133 super(SRegRequestForm, self).__init__(self.data)
134 self._init_fields(self.data)
135- self.should_display = len(self.data) > 0
136-
137- def _get_sreg_data_for_user(self, user, sreg_fields):
138- """Get the sreg data to ask about in the form based on the user's
139- account record.
140- """
141- values = {}
142- values['fullname'] = user.displayname
143- if user.preferredemail is not None:
144- values['email'] = user.preferredemail.email
145- if user.person is not None:
146- values['nickname'] = user.person.name
147- if user.person.time_zone is not None:
148- values['timezone'] = user.person.time_zone
149- if user.preferredlanguage is not None:
150- values['language'] = user.preferredlanguage
151- else:
152- values['language'] = translation.get_language_from_request(
153- self.request)
154- logger.debug("values (sreg_fields) = " + str(values))
155-
156- return dict([(f, values[f]) for f in sreg_fields if f in values])
157
158 def _init_fields(self, data):
159 """Initialises form fields for the user's sreg data.
160@@ -444,6 +454,114 @@
161 return field in approved
162
163
164+class AXFetchRequestForm(Form):
165+ """A form object for user control over OpenID Attribute Exchange."""
166+
167+ def __init__(self, request, ax_request, rpconfig, approved_data=None):
168+ self.request = request
169+ self.request_method = request.META.get('REQUEST_METHOD')
170+ self.ax_request = ax_request
171+ self.rpconfig = rpconfig
172+ self.approved_data = approved_data
173+ # generate initial form data
174+ ax_fields = self._get_requested_field_aliases()
175+ if rpconfig is not None:
176+ ax_fields = self._filter_allowed_fields(ax_fields)
177+ self.data = _get_data_for_user(request, ax_fields, with_verified=True)
178+
179+ super(AXFetchRequestForm, self).__init__(self.data)
180+ self._init_fields(self.data)
181+
182+ def _get_requested_field_aliases(self):
183+ return [AX_DATA_FIELDS.getAlias(f)
184+ for f in AX_DATA_FIELDS.iterNamespaceURIs()
185+ if f in set(self.ax_request.requested_attributes.keys())]
186+
187+ def _filter_allowed_fields(self, requested_fields):
188+ if self.rpconfig.allowed_ax:
189+ return set(requested_fields).intersection(
190+ set(self.rpconfig.allowed_ax.split(',')))
191+ return set()
192+
193+ def _init_fields(self, data):
194+ """Initialises form fields for the user's ax data.
195+ """
196+ for key, val in data.items():
197+ label = "%s: %s" % (AX_DATA_LABELS.get(key, key), val)
198+ attrs = {}
199+ if (AX_DATA_FIELDS.getNamespaceURI(key) in
200+ self.ax_request.getRequiredAttrs()):
201+ attrs['class'] = 'required'
202+ if self.rpconfig is not None:
203+ attrs['disabled'] = 'disabled'
204+ self.fields[key] = fields.BooleanField(
205+ label=label,
206+ widget=forms.CheckboxInput(attrs=attrs,
207+ check_test=self.check_test),
208+ )
209+
210+ def check_test(self, value):
211+ """Determines if a checkbox should be pre-checked based on previously
212+ approved user data, openid request and relying party type.
213+ """
214+ for k, v in self.data.items():
215+ if value == v:
216+ value = k
217+ break
218+
219+ if self.rpconfig:
220+ # Trusted site, check required fields
221+ if (AX_DATA_FIELDS.getNamespaceURI(value) in
222+ self.ax_request.getRequiredAttrs()):
223+ return True
224+ # If we have previous (dis)approval for this site, use it
225+ if self.approved_data:
226+ return (value in self.approved_data.get('requested', []) and
227+ value in self.approved_data.get('approved', []))
228+ # Otherwise, default to True
229+ return (AX_DATA_FIELDS.getNamespaceURI(value) in
230+ self.ax_request.requested_attributes)
231+ else:
232+ # If we have previous (dis)approval for this site, use it
233+ if self.approved_data:
234+ return (value in self.approved_data.get('requested', []) and
235+ value in self.approved_data.get('approved', []))
236+ # No previous (dis)approval, check required and leave the rest
237+ if (AX_DATA_FIELDS.getNamespaceURI(value) in
238+ self.ax_request.getRequiredAttrs()):
239+ return True
240+ # Otherwise default to False
241+ return False
242+
243+ def field_approved(self, field):
244+ """Check if the field should be returned in the response based on user
245+ preferences and overridden for trusted relying parties.
246+ """
247+ approved = set([AX_DATA_FIELDS.getNamespaceURI(f)
248+ for f in self.request.POST.keys()])
249+ if self.rpconfig is not None:
250+ if self.rpconfig.auto_authorize:
251+ ax_fields = set(self.ax_request.requested_attributes.keys())
252+ else:
253+ ax_fields = set(self.ax_request.getRequiredAttrs())
254+ approved.update(ax_fields)
255+ return field in approved
256+
257+ @property
258+ def data_approved_for_request(self):
259+ """Return the list of ax data approved for the request."""
260+ if self.request_method == 'POST':
261+ return dict(
262+ [(f, self.data[f]) for f in self.data
263+ if self.field_approved(AX_DATA_FIELDS.getNamespaceURI(f))])
264+ return {}
265+
266+ @property
267+ def has_data(self):
268+ """Helper property to check if this form has any data."""
269+ return len(self.data) > 0
270+
271+
272 class TeamsRequestForm(Form):
273 """A form object for user control over OpenID teams data.
274 """
275@@ -471,7 +589,6 @@
276 super(TeamsRequestForm, self).__init__(self.memberships)
277
278 self._init_fields(self.memberships)
279- self.should_display = len(self.memberships) > 0
280
281 def _get_teams_for_user(self, user, include_private=False):
282 """Get the list of teams to ask about in the form based on the user's
283@@ -485,9 +602,14 @@
284 def _init_fields(self, form_data):
285 """Initialises form fields for the user's team memberships.
286 """
287+ if len(form_data) == 1:
288+ label_format = 'Team membership: %s'
289+ else:
290+ label_format = '%s'
291 for team in form_data:
292+ label = label_format % team
293 self.fields[team] = fields.BooleanField(
294- label=team, widget=forms.CheckboxInput(
295+ label=label, widget=forms.CheckboxInput(
296 check_test=self.check_test))
297
298 def check_test(self, value):
299@@ -500,6 +622,11 @@
300 else:
301 return self.rpconfig is not None
302
303+ @property
304+ def has_data(self):
305+ """Helper property to check if this form has any data."""
306+ return len(self.memberships) > 0
307+
308
309 class HOTPDeviceForm(Form):
310 name = fields.CharField()
311
312=== added file 'identityprovider/migrations/0007_add_allowed_ax.py'
313--- identityprovider/migrations/0007_add_allowed_ax.py 1970-01-01 00:00:00 +0000
314+++ identityprovider/migrations/0007_add_allowed_ax.py 2013-04-03 14:40:35 +0000
315@@ -0,0 +1,212 @@
316+# -*- coding: utf-8 -*-
317+import datetime
318+from south.db import db
319+from south.v2 import SchemaMigration
320+from django.db import models
321+
322+
323+class Migration(SchemaMigration):
324+
325+ def forwards(self, orm):
326+ # Adding field 'OpenIDRPConfig.allowed_ax'
327+ db.add_column('ssoopenidrpconfig', 'allowed_ax',
328+ self.gf('django.db.models.fields.TextField')(null=True, blank=True),
329+ keep_default=False)
330+
331+
332+ def backwards(self, orm):
333+ # Deleting field 'OpenIDRPConfig.allowed_ax'
334+ db.delete_column('ssoopenidrpconfig', 'allowed_ax')
335+
336+
337+ models = {
338+ 'identityprovider.account': {
339+ 'Meta': {'object_name': 'Account', 'db_table': "u'account'"},
340+ 'creation_rationale': ('django.db.models.fields.IntegerField', [], {}),
341+ 'date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.utcnow'}),
342+ 'date_status_set': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.utcnow'}),
343+ 'displayname': ('identityprovider.models.account.DisplaynameField', [], {}),
344+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
345+ 'old_openid_identifier': ('django.db.models.fields.TextField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
346+ 'openid_identifier': ('django.db.models.fields.TextField', [], {'default': "u'XJm6fny'", 'unique': 'True'}),
347+ 'preferredlanguage': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
348+ 'status': ('django.db.models.fields.IntegerField', [], {}),
349+ 'status_comment': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
350+ 'twofactor_attempts': ('django.db.models.fields.SmallIntegerField', [], {'default': '0', 'null': 'True'}),
351+ 'twofactor_required': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
352+ 'warn_about_backup_device': ('django.db.models.fields.BooleanField', [], {'default': 'True'})
353+ },
354+ 'identityprovider.accountpassword': {
355+ 'Meta': {'object_name': 'AccountPassword', 'db_table': "u'accountpassword'"},
356+ 'account': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['identityprovider.Account']", 'unique': 'True', 'db_column': "'account'"}),
357+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
358+ 'password': ('identityprovider.models.account.PasswordField', [], {})
359+ },
360+ 'identityprovider.apiuser': {
361+ 'Meta': {'object_name': 'APIUser', 'db_table': "'api_user'"},
362+ 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
363+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
364+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '256'}),
365+ 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
366+ 'username': ('django.db.models.fields.CharField', [], {'max_length': '256'})
367+ },
368+ 'identityprovider.authenticationdevice': {
369+ 'Meta': {'ordering': "('id',)", 'object_name': 'AuthenticationDevice'},
370+ 'account': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'devices'", 'to': "orm['identityprovider.Account']"}),
371+ 'counter': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
372+ 'device_type': ('django.db.models.fields.TextField', [], {'null': 'True'}),
373+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
374+ 'key': ('django.db.models.fields.TextField', [], {}),
375+ 'name': ('django.db.models.fields.TextField', [], {})
376+ },
377+ 'identityprovider.authtoken': {
378+ 'Meta': {'object_name': 'AuthToken', 'db_table': "u'authtoken'"},
379+ 'date_consumed': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
380+ 'date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.utcnow', 'db_index': 'True', 'blank': 'True'}),
381+ 'displayname': ('identityprovider.models.account.DisplaynameField', [], {'null': 'True', 'blank': 'True'}),
382+ 'email': ('django.db.models.fields.TextField', [], {'db_index': 'True'}),
383+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
384+ 'password': ('identityprovider.models.account.PasswordField', [], {'null': 'True', 'blank': 'True'}),
385+ 'redirection_url': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
386+ 'requester': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['identityprovider.Account']", 'null': 'True', 'db_column': "'requester'", 'blank': 'True'}),
387+ 'requester_email': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
388+ 'token': ('django.db.models.fields.TextField', [], {'unique': 'True'}),
389+ 'token_type': ('django.db.models.fields.IntegerField', [], {})
390+ },
391+ 'identityprovider.emailaddress': {
392+ 'Meta': {'object_name': 'EmailAddress', 'db_table': "u'emailaddress'"},
393+ 'account': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['identityprovider.Account']", 'null': 'True', 'db_column': "'account'", 'blank': 'True'}),
394+ 'date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.utcnow', 'blank': 'True'}),
395+ 'email': ('django.db.models.fields.TextField', [], {}),
396+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
397+ 'lp_person': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_column': "'person'", 'blank': 'True'}),
398+ 'status': ('django.db.models.fields.IntegerField', [], {})
399+ },
400+ 'identityprovider.invalidatedemailaddress': {
401+ 'Meta': {'object_name': 'InvalidatedEmailAddress', 'db_table': "u'invalidated_emailaddress'"},
402+ 'account': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['identityprovider.Account']", 'null': 'True', 'db_column': "'account'", 'blank': 'True'}),
403+ 'date_created': ('django.db.models.fields.DateTimeField', [], {'blank': 'True'}),
404+ 'email': ('django.db.models.fields.TextField', [], {}),
405+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
406+ },
407+ 'identityprovider.lpopenididentifier': {
408+ 'Meta': {'object_name': 'LPOpenIdIdentifier', 'db_table': "u'lp_openididentifier'"},
409+ 'date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.date.today'}),
410+ 'identifier': ('django.db.models.fields.TextField', [], {'unique': 'True', 'primary_key': 'True'}),
411+ 'lp_account': ('django.db.models.fields.IntegerField', [], {'db_column': "'account'", 'db_index': 'True'})
412+ },
413+ 'identityprovider.openidassociation': {
414+ 'Meta': {'unique_together': "(('server_url', 'handle'),)", 'object_name': 'OpenIDAssociation', 'db_table': "u'openidassociation'"},
415+ 'assoc_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}),
416+ 'handle': ('django.db.models.fields.CharField', [], {'max_length': '255', 'primary_key': 'True'}),
417+ 'issued': ('django.db.models.fields.IntegerField', [], {}),
418+ 'lifetime': ('django.db.models.fields.IntegerField', [], {}),
419+ 'secret': ('django.db.models.fields.TextField', [], {}),
420+ 'server_url': ('django.db.models.fields.CharField', [], {'max_length': '2047'})
421+ },
422+ 'identityprovider.openidauthorization': {
423+ 'Meta': {'object_name': 'OpenIDAuthorization', 'db_table': "u'openidauthorization'"},
424+ 'account': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['identityprovider.Account']", 'db_column': "'account'"}),
425+ 'client_id': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
426+ 'date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.utcnow', 'blank': 'True'}),
427+ 'date_expires': ('django.db.models.fields.DateTimeField', [], {}),
428+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
429+ 'trust_root': ('django.db.models.fields.TextField', [], {})
430+ },
431+ 'identityprovider.openidnonce': {
432+ 'Meta': {'unique_together': "(('server_url', 'timestamp', 'salt'),)", 'object_name': 'OpenIDNonce', 'db_table': "'openidnonce'"},
433+ 'salt': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
434+ 'server_url': ('django.db.models.fields.CharField', [], {'max_length': '2047', 'primary_key': 'True'}),
435+ 'timestamp': ('django.db.models.fields.IntegerField', [], {})
436+ },
437+ 'identityprovider.openidrpconfig': {
438+ 'Meta': {'object_name': 'OpenIDRPConfig', 'db_table': "'ssoopenidrpconfig'"},
439+ 'allow_unverified': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
440+ 'allowed_ax': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
441+ 'allowed_sreg': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
442+ 'auto_authorize': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
443+ 'can_query_any_team': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
444+ 'creation_rationale': ('django.db.models.fields.IntegerField', [], {'default': '13'}),
445+ 'description': ('django.db.models.fields.TextField', [], {}),
446+ 'displayname': ('django.db.models.fields.TextField', [], {}),
447+ 'flag_twofactor': ('django.db.models.fields.CharField', [], {'max_length': '256', 'null': 'True', 'blank': 'True'}),
448+ 'ga_snippet': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
449+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
450+ 'logo': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
451+ 'prefer_canonical_email': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
452+ 'require_two_factor': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
453+ 'trust_root': ('django.db.models.fields.TextField', [], {'unique': 'True'})
454+ },
455+ 'identityprovider.openidrpsummary': {
456+ 'Meta': {'unique_together': "(('account', 'trust_root', 'openid_identifier'),)", 'object_name': 'OpenIDRPSummary', 'db_table': "u'openidrpsummary'"},
457+ 'account': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['identityprovider.Account']", 'db_column': "'account'"}),
458+ 'approved_data': ('django.db.models.fields.TextField', [], {'default': "''", 'null': 'True', 'blank': 'True'}),
459+ 'date_created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.utcnow', 'blank': 'True'}),
460+ 'date_last_used': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.utcnow', 'blank': 'True'}),
461+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
462+ 'openid_identifier': ('django.db.models.fields.TextField', [], {'db_index': 'True'}),
463+ 'total_logins': ('django.db.models.fields.IntegerField', [], {'default': '1'}),
464+ 'trust_root': ('django.db.models.fields.TextField', [], {'db_index': 'True'})
465+ },
466+ 'identityprovider.person': {
467+ 'Meta': {'object_name': 'Person', 'db_table': "u'lp_person'"},
468+ 'addressline1': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
469+ 'addressline2': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
470+ 'city': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
471+ 'country': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_column': "'country'", 'blank': 'True'}),
472+ 'creation_comment': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
473+ 'creation_rationale': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}),
474+ 'datecreated': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'blank': 'True'}),
475+ 'defaultmembershipperiod': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}),
476+ 'defaultrenewalperiod': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'blank': 'True'}),
477+ 'displayname': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
478+ 'fti': ('django.db.models.fields.TextField', [], {'null': 'True'}),
479+ 'hide_email_addresses': ('django.db.models.fields.NullBooleanField', [], {'default': 'False', 'null': 'True', 'blank': 'True'}),
480+ 'homepage_content': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
481+ 'icon': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_column': "'icon'", 'blank': 'True'}),
482+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
483+ 'language': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_column': "'language'", 'blank': 'True'}),
484+ 'logo': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_column': "'logo'", 'blank': 'True'}),
485+ 'lp_account': ('django.db.models.fields.IntegerField', [], {'unique': 'True', 'null': 'True', 'db_column': "'account'"}),
486+ 'mail_resumption_date': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
487+ 'mailing_list_auto_subscribe_policy': ('django.db.models.fields.IntegerField', [], {'default': '1', 'null': 'True'}),
488+ 'mailing_list_receive_duplicates': ('django.db.models.fields.NullBooleanField', [], {'default': 'True', 'null': 'True', 'blank': 'True'}),
489+ 'merged': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_column': "'merged'", 'blank': 'True'}),
490+ 'mugshot': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_column': "'mugshot'", 'blank': 'True'}),
491+ 'name': ('django.db.models.fields.TextField', [], {'unique': 'True', 'null': 'True'}),
492+ 'organization': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
493+ 'personal_standing': ('django.db.models.fields.IntegerField', [], {'default': '0', 'null': 'True'}),
494+ 'personal_standing_reason': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
495+ 'phone': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
496+ 'postcode': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
497+ 'province': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
498+ 'registrant': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_column': "'registrant'", 'blank': 'True'}),
499+ 'renewal_policy': ('django.db.models.fields.IntegerField', [], {'default': '10', 'null': 'True'}),
500+ 'subscriptionpolicy': ('django.db.models.fields.IntegerField', [], {'default': '1', 'null': 'True'}),
501+ 'teamdescription': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
502+ 'teamowner': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_column': "'teamowner'", 'blank': 'True'}),
503+ 'verbose_bugnotifications': ('django.db.models.fields.NullBooleanField', [], {'default': 'False', 'null': 'True', 'blank': 'True'}),
504+ 'visibility': ('django.db.models.fields.IntegerField', [], {'default': '1', 'null': 'True'})
505+ },
506+ 'identityprovider.personlocation': {
507+ 'Meta': {'object_name': 'PersonLocation', 'db_table': "u'lp_personlocation'"},
508+ 'date_created': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
509+ 'date_last_modified': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}),
510+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
511+ 'last_modified_by': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_column': "'last_modified_by'"}),
512+ 'latitude': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
513+ 'locked': ('django.db.models.fields.NullBooleanField', [], {'default': 'False', 'null': 'True', 'blank': 'True'}),
514+ 'longitude': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
515+ 'person': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['identityprovider.Person']", 'unique': 'True', 'null': 'True', 'db_column': "'person'"}),
516+ 'time_zone': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
517+ 'visible': ('django.db.models.fields.NullBooleanField', [], {'default': 'True', 'null': 'True', 'blank': 'True'})
518+ },
519+ 'identityprovider.teamparticipation': {
520+ 'Meta': {'unique_together': "(('team', 'person'),)", 'object_name': 'TeamParticipation', 'db_table': "u'lp_teamparticipation'"},
521+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
522+ 'person': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['identityprovider.Person']", 'null': 'True', 'db_column': "'person'"}),
523+ 'team': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'team_participations'", 'null': 'True', 'db_column': "'team'", 'to': "orm['identityprovider.Person']"})
524+ }
525+ }
526+
527+ complete_apps = ['identityprovider']
528\ No newline at end of file
529
530=== modified file 'identityprovider/models/openidmodels.py'
531--- identityprovider/models/openidmodels.py 2013-03-05 18:08:29 +0000
532+++ identityprovider/models/openidmodels.py 2013-04-03 14:40:35 +0000
533@@ -154,6 +154,7 @@
534 displayname = models.TextField()
535 description = models.TextField()
536 logo = models.TextField(blank=True, null=True)
537+ allowed_ax = models.TextField(blank=True, null=True)
538 allowed_sreg = models.TextField(blank=True, null=True)
539 creation_rationale = models.IntegerField(
540 default=13, choices=AccountCreationRationale._get_choices())
541
542=== modified file 'identityprovider/templates/server/decide.html'
543--- identityprovider/templates/server/decide.html 2013-02-14 17:12:13 +0000
544+++ identityprovider/templates/server/decide.html 2013-04-03 14:40:35 +0000
545@@ -31,15 +31,23 @@
546 <form action="{{ action }}" method="POST" name="decideform">
547 {% csrf_token %}
548 <div class="info-items">
549- {% if sreg_form.should_display or teams_form.should_display %}
550+ {% if sreg_form.has_data or teams_form.has_data or ax_form.has_data %}
551 <ul class="list">
552- {% for field in sreg_form %}
553+ {% if ax_form.has_data %}
554+ {% for field in ax_form %}
555+ <li class="ax">{{ field|safe }} {{ field.label_tag }}</li>
556+ {% endfor %}
557+ {% else %}
558+ {% if sreg_form.has_data %}
559+ {% for field in sreg_form %}
560+ <li class="sreg">{{ field|safe }} {{ field.label_tag }}</li>
561+ {% endfor %}
562+ {% endif %}
563+ {% endif %}
564+ {% if teams_form.has_data %}
565+ {% ifequal teams_form.fields|length 1 %}
566+ {% for field in teams_form %}
567 <li>{{ field|safe }} {{ field.label_tag }}</li>
568- {% endfor %}
569- {% if teams_form.should_display %}
570- {% ifequal teams_form.fields|length 1 %}
571- {% for field in teams_form %}
572- <li>{{ field|safe }} <label for="id_{{ field.html_name }}">{% trans "Team membership:"%}</label> {{ field.label_tag }}</li>
573 {% endfor %}
574 {% else %}
575 <li id="teamslist_item">
576
577=== modified file 'identityprovider/templates/server/openidapplication-xrds.xml'
578--- identityprovider/templates/server/openidapplication-xrds.xml 2012-12-04 18:51:42 +0000
579+++ identityprovider/templates/server/openidapplication-xrds.xml 2013-04-03 14:40:35 +0000
580@@ -5,6 +5,7 @@
581 <XRD>
582 <Service priority="0">
583 <Type>http://specs.openid.net/auth/2.0/server</Type>
584+ <Type>http://openid.net/srv/ax/1.0</Type>
585 <Type>http://openid.net/extensions/sreg/1.1</Type>
586 <Type>http://ns.launchpad.net/2007/openid-teams</Type>
587 <URI>{{ endpoint_url }}</URI>
588
589=== modified file 'identityprovider/tests/openid_server/test_discovery.py'
590--- identityprovider/tests/openid_server/test_discovery.py 2013-04-01 16:17:07 +0000
591+++ identityprovider/tests/openid_server/test_discovery.py 2013-04-03 14:40:35 +0000
592@@ -54,6 +54,7 @@
593 'server_url': self.base_openid_url,
594 'supports': [
595 'http://specs.openid.net/auth/2.0/server',
596+ 'http://openid.net/srv/ax/1.0',
597 'http://openid.net/extensions/sreg/1.1',
598 'http://ns.launchpad.net/2007/openid-teams',
599 ],
600
601=== modified file 'identityprovider/tests/test_admin.py'
602--- identityprovider/tests/test_admin.py 2013-03-29 20:36:42 +0000
603+++ identityprovider/tests/test_admin.py 2013-04-03 14:40:35 +0000
604@@ -53,18 +53,33 @@
605 self.assertRaises(NotRegistered, admin.site.unregister, model)
606
607 def test_openidrpconfig_allowed_sreg_checkboxes_postable(self):
608- data = {'trust_root': 'http://localhost/bla/',
609- 'displayname': 'My Test RP',
610- 'description': 'Bla',
611- 'allowed_sreg': ['fullname', 'email'],
612- 'creation_rationale': '13',
613+ trust_root = 'http://localhost/bla/'
614+ displayname = 'My Test RP'
615+ description = 'Bla'
616+ allowed_sreg = ['fullname', 'email']
617+ creation_rationale = 13
618+
619+ data = {'trust_root': trust_root,
620+ 'displayname': displayname,
621+ 'description': description,
622+ 'allowed_sreg': allowed_sreg,
623+ 'creation_rationale': creation_rationale,
624 }
625 add_view = reverse('admin:identityprovider_openidrpconfig_add')
626 response = self.client.get(add_view)
627 response = self.client.post(add_view, data)
628 self.assertEqual(302, response.status_code)
629- # We don't get the ID back, so continue on to the next test to
630- # verify that the checkbox appears on the model's screen.
631+ # We don't get the ID back, so ensure we only have one entity and
632+ # assume it's the correct one. This is racy, but the alternative is
633+ # another request to the list screen to scrape the ID from there.
634+ self.assertEqual(OpenIDRPConfig.objects.count(), 1)
635+ rpconfig = OpenIDRPConfig.objects.get()
636+ self.assertEqual(rpconfig.trust_root, trust_root)
637+ self.assertEqual(rpconfig.displayname, displayname)
638+ self.assertEqual(rpconfig.description, description)
639+ self.assertEqual(sorted(rpconfig.allowed_sreg.split(',')),
640+ sorted(allowed_sreg))
641+ self.assertEqual(rpconfig.creation_rationale, creation_rationale)
642
643 def test_openidrpconfig_allowed_sreg_checkboxes_getable(self):
644 data = {'trust_root': 'http://localhost/bla/',
645@@ -78,7 +93,57 @@
646 'admin:identityprovider_openidrpconfig_change',
647 args=(rpconfig.id,))
648 response = self.client.get(change_view)
649- self.assertContains(response, 'checked')
650+ dom = PyQuery(response.content)
651+ checked = dom.find('input[checked=checked]')
652+ self.assertEqual(len(checked), 1)
653+ self.assertEqual(checked[0].value, 'fullname')
654+
655+ def test_openidrpconfig_allowed_ax_checkboxes_postable(self):
656+ trust_root = 'http://localhost/bla/'
657+ displayname = 'My Test RP'
658+ description = 'Bla'
659+ allowed_ax = ['fullname', 'email']
660+ creation_rationale = 13
661+
662+ data = {'trust_root': trust_root,
663+ 'displayname': displayname,
664+ 'description': description,
665+ 'allowed_ax': allowed_ax,
666+ 'creation_rationale': creation_rationale,
667+ }
668+ add_view = reverse('admin:identityprovider_openidrpconfig_add')
669+ response = self.client.get(add_view)
670+ response = self.client.post(add_view, data)
671+ self.assertEqual(302, response.status_code)
672+ # We don't get the ID back, so ensure we only have one entity and
673+ # assume it's the correct one. This is racy, but the alternative is
674+ # another request to the list screen to scrape the ID from there.
675+ self.assertEqual(OpenIDRPConfig.objects.count(), 1)
676+ rpconfig = OpenIDRPConfig.objects.get()
677+ self.assertEqual(rpconfig.trust_root, trust_root)
678+ self.assertEqual(rpconfig.displayname, displayname)
679+ self.assertEqual(rpconfig.description, description)
680+ self.assertEqual(sorted(rpconfig.allowed_ax.split(',')),
681+ sorted(allowed_ax))
682+ self.assertEqual(rpconfig.creation_rationale, creation_rationale)
683+
684+ def test_openidrpconfig_allowed_ax_checkboxes_getable(self):
685+ data = {'trust_root': 'http://localhost/bla/',
686+ 'displayname': 'My Test RP',
687+ 'description': 'Bla',
688+ 'allowed_ax': 'email',
689+ 'creation_rationale': '13',
690+ }
691+ rpconfig = OpenIDRPConfig(**data)
692+ rpconfig.save()
693+ change_view = reverse(
694+ 'admin:identityprovider_openidrpconfig_change',
695+ args=(rpconfig.id,))
696+ response = self.client.get(change_view)
697+ dom = PyQuery(response.content)
698+ checked = dom.find('input[checked=checked]')
699+ self.assertEqual(len(checked), 1)
700+ self.assertEqual(checked[0].value, 'email')
701
702 def test_inline_models(self):
703 expected_inlines = [AccountPasswordInline, EmailAddressInline,
704
705=== modified file 'identityprovider/tests/test_forms.py'
706--- identityprovider/tests/test_forms.py 2013-03-29 21:15:13 +0000
707+++ identityprovider/tests/test_forms.py 2013-04-03 14:40:35 +0000
708@@ -6,11 +6,20 @@
709 from django.http import HttpRequest
710 from django_openid_auth.teams import TeamsRequest
711 from mock import patch
712+from openid.extensions.ax import (
713+ AttrInfo,
714+ FetchRequest,
715+)
716 from openid.extensions.sreg import SRegRequest
717
718 from identityprovider.models.account import Account
719 from identityprovider.models.const import EmailStatus
720+from identityprovider.const import (
721+ AX_URI_FULL_NAME,
722+ AX_URI_EMAIL,
723+)
724 from identityprovider.forms import (
725+ AXFetchRequestForm,
726 DeviceRenameForm,
727 EditAccountForm,
728 LoginForm,
729@@ -280,8 +289,8 @@
730 self._get_request_with_post_args(),
731 SRegRequest(required=['fullname'], optional=['email']),
732 self.rpconfig)
733- self.assertTrue('fullname' in form.data_approved_for_request)
734- self.assertFalse('email' in form.data_approved_for_request)
735+ self.assertIn('fullname', form.data_approved_for_request)
736+ self.assertNotIn('email', form.data_approved_for_request)
737
738 def test_optional_fields_for_trusted_site(self):
739 """The server should return values for optional fields to trusted
740@@ -378,6 +387,194 @@
741 self.assertTrue(form.check_test('email'))
742
743
744+class AXFetchRequestFormTestCase(SSOBaseTestCase):
745+ """AX Fetch Request form tests.
746+
747+ This is all functionally very similar to Simple Registration, except that
748+ the extensibility of AX adds an extra layer of complexity.
749+ """
750+
751+ def _get_request_with_post_args(self, args={}):
752+ request = HttpRequest()
753+ request.user = self.test_user
754+ request.POST = args
755+ request.META = {'REQUEST_METHOD': 'POST'}
756+ return request
757+
758+ def setUp(self):
759+ super(AXFetchRequestFormTestCase, self).setUp()
760+ self.test_user = Account.objects.create_account(
761+ 'My name', 'me@test.com', DEFAULT_USER_PASSWORD)
762+ self.rpconfig = OpenIDRPConfig.objects.create(
763+ trust_root='http://localhost/', description="Some description")
764+
765+ def test_no_approved_fields_without_post_request(self):
766+ """The server should not generate a list of approved fields when the
767+ request is not a POST request.
768+ """
769+ request = self._get_request_with_post_args()
770+ request.META['REQUEST_METHOD'] = 'GET'
771+ fetch_request = FetchRequest()
772+ for (attr, alias) in [
773+ (AX_URI_FULL_NAME, 'fullname'),
774+ (AX_URI_EMAIL, 'email')]:
775+ fetch_request.add(AttrInfo(attr, alias=alias, required=True))
776+ form = AXFetchRequestForm(request, fetch_request, self.rpconfig)
777+ self.assertEqual(len(form.data_approved_for_request), 0)
778+
779+ def test_required_fields_for_trusted_site(self):
780+ """The server should always return values for required fields to
781+ trusted sites, regardless of the state of the checkbox in the UI.
782+ Optional fields should not be returned if the user has unchecked them.
783+ """
784+ self.rpconfig.allowed_ax = 'fullname,email'
785+ fetch_request = FetchRequest()
786+ fetch_request.add(
787+ AttrInfo(AX_URI_FULL_NAME, alias='fullname', required=True))
788+ fetch_request.add(
789+ AttrInfo(AX_URI_EMAIL, alias='email', required=False))
790+ form = AXFetchRequestForm(self._get_request_with_post_args(),
791+ fetch_request, self.rpconfig)
792+ self.assertTrue('fullname' in form.data_approved_for_request)
793+ self.assertFalse('email' in form.data_approved_for_request)
794+
795+ def test_optional_fields_for_trusted_site(self):
796+ """The server should return values for optional fields to trusted
797+ sites only when the user checks the checkbox in the UI.
798+ """
799+ self.rpconfig.allowed_ax = 'fullname,email'
800+ post_args = {'email': 'email'}
801+ fetch_request = FetchRequest()
802+ for (attr, alias) in [
803+ (AX_URI_FULL_NAME, 'fullname'),
804+ (AX_URI_EMAIL, 'email')]:
805+ fetch_request.add(AttrInfo(attr, alias=alias, required=False))
806+ form = AXFetchRequestForm(self._get_request_with_post_args(post_args),
807+ fetch_request, self.rpconfig)
808+ self.assertFalse('fullname' in form.data_approved_for_request)
809+ self.assertTrue('email' in form.data_approved_for_request)
810+
811+ def test_required_fields_for_untrusted_site(self):
812+ """The server should return values for required fields to untrusted
813+ sites only when the user checks the checkbox in the UI.
814+ """
815+ post_args = {'email': 'email'}
816+ fetch_request = FetchRequest()
817+ for (attr, alias) in [
818+ (AX_URI_FULL_NAME, 'fullname'),
819+ (AX_URI_EMAIL, 'email')]:
820+ fetch_request.add(AttrInfo(attr, alias=alias, required=True))
821+ form = AXFetchRequestForm(self._get_request_with_post_args(post_args),
822+ fetch_request, None)
823+ self.assertFalse('fullname' in form.data_approved_for_request)
824+ self.assertTrue('email' in form.data_approved_for_request)
825+
826+ def test_optional_fields_for_untrusted_site(self):
827+ """The server should return values for optional fields to untrusted
828+ sites only when the user checks the checkbox in the UI.
829+ """
830+ post_args = {'fullname': 'fullname'}
831+ fetch_request = FetchRequest()
832+ for (attr, alias) in [
833+ (AX_URI_FULL_NAME, 'fullname'),
834+ (AX_URI_EMAIL, 'email')]:
835+ fetch_request.add(AttrInfo(attr, alias=alias, required=False))
836+ form = AXFetchRequestForm(self._get_request_with_post_args(post_args),
837+ fetch_request, None)
838+ self.assertTrue('fullname' in form.data_approved_for_request)
839+ self.assertFalse('email' in form.data_approved_for_request)
840+
841+ def test_checkbox_status_for_trusted_site(self):
842+ """Checkboxes are always checked if the site is trusted"""
843+ fetch_request = FetchRequest()
844+ fetch_request.add(
845+ AttrInfo(AX_URI_FULL_NAME, alias='fullname', required=True))
846+ fetch_request.add(
847+ AttrInfo(AX_URI_EMAIL, alias='email', required=False))
848+ form = AXFetchRequestForm(self._get_request_with_post_args(),
849+ fetch_request, self.rpconfig)
850+ # True because fullname required
851+ self.assertTrue(form.check_test('fullname'))
852+ # True because trusted site and no previous disapproval
853+ self.assertTrue(form.check_test('email'))
854+ # Throw in an unrequested field for good measure
855+ self.assertFalse(form.check_test('language'))
856+
857+ def test_checkbox_status_for_trusted_site_with_approved_data(self):
858+ """If the user has previously approved sending data to a trusted site
859+ the same checkbox settings should be returned on the next request
860+ unless those conflict with the required fields.
861+ """
862+ approved_data = {
863+ 'requested': ['fullname', 'email'],
864+ 'approved': ['email', 'language']}
865+ fetch_request = FetchRequest()
866+ fetch_request.add(
867+ AttrInfo(AX_URI_FULL_NAME, alias='fullname', required=True))
868+ fetch_request.add(
869+ AttrInfo(AX_URI_EMAIL, alias='email', required=False))
870+ form1 = AXFetchRequestForm(self._get_request_with_post_args(),
871+ fetch_request, self.rpconfig,
872+ approved_data=approved_data)
873+ # True because fullname required
874+ self.assertTrue(form1.check_test('fullname'))
875+ # True because email previously approved
876+ self.assertTrue(form1.check_test('email'))
877+ # Throw in an unrequested, previously-approved field for good measure
878+ self.assertFalse(form1.check_test('language'))
879+
880+ approved_data['approved'] = []
881+ form2 = AXFetchRequestForm(self._get_request_with_post_args(),
882+ fetch_request, self.rpconfig,
883+ approved_data=approved_data)
884+ # True because fullname required
885+ self.assertTrue(form1.check_test('fullname'))
886+ # False because email previously disapproved
887+ self.assertFalse(form2.check_test('email'))
888+ # Throw in an unrequested field for good measure
889+ self.assertFalse(form2.check_test('language'))
890+
891+ def test_checkbox_status_for_untrusted_site(self):
892+ """Checkboxes are only checked on untrusted site requests if the field
893+ is required
894+ """
895+ fetch_request = FetchRequest()
896+ fetch_request.add(
897+ AttrInfo(AX_URI_FULL_NAME, alias='fullname', required=True))
898+ fetch_request.add(
899+ AttrInfo(AX_URI_EMAIL, alias='email', required=False))
900+ form = AXFetchRequestForm(self._get_request_with_post_args(),
901+ fetch_request, None)
902+ # True because fullname required
903+ self.assertTrue(form.check_test('fullname'))
904+ # False because untrusted site and no previous approval
905+ self.assertFalse(form.check_test('email'))
906+ # Throw in an unrequested field for good measure
907+ self.assertFalse(form.check_test('language'))
908+
909+ def test_checkbox_status_for_untrusted_site_with_approved_data(self):
910+ """If the user has previously approved sending data to an untrusted
911+ site the same checkbox settings should be returned on the next request.
912+ """
913+ approved_data = {
914+ 'requested': ['fullname', 'email'],
915+ 'approved': ['email', 'language']}
916+ fetch_request = FetchRequest()
917+ fetch_request.add(
918+ AttrInfo(AX_URI_FULL_NAME, alias='fullname', required=True))
919+ fetch_request.add(
920+ AttrInfo(AX_URI_EMAIL, alias='email', required=False))
921+ form = AXFetchRequestForm(self._get_request_with_post_args(),
922+ fetch_request, None,
923+ approved_data=approved_data)
924+ # False because untrusted site and previously disapproved
925+ self.assertFalse(form.check_test('fullname'))
926+ # True because previously approved
927+ self.assertTrue(form.check_test('email'))
928+ # Throw in an unrequested, previously-approved field for good measure
929+ self.assertFalse(form.check_test('language'))
930+
931+
932 class TeamsRequestFormTestCase(SSOBaseTestCase):
933
934 def _get_request_with_post_args(self, args={}):
935
936=== modified file 'identityprovider/tests/test_views_server.py'
937--- identityprovider/tests/test_views_server.py 2013-03-29 20:36:42 +0000
938+++ identityprovider/tests/test_views_server.py 2013-04-03 14:40:35 +0000
939@@ -15,6 +15,10 @@
940 from django.http import HttpRequest
941 from gargoyle.testutils import switches
942 from mock import Mock, patch
943+from openid.extensions.ax import (
944+ AXMessage,
945+ FetchRequest,
946+)
947 from openid.extensions.sreg import SRegRequest
948 from openid.message import (
949 IDENTIFIER_SELECT,
950@@ -26,6 +30,12 @@
951 from pyquery import PyQuery
952
953 import identityprovider.signed as signed
954+from identityprovider.const import (
955+ AX_URI_ACCOUNT_VERIFIED,
956+ AX_URI_EMAIL,
957+ AX_URI_FULL_NAME,
958+ AX_URI_LANGUAGE,
959+)
960 from identityprovider.models import (
961 Account,
962 OpenIDAuthorization,
963@@ -509,7 +519,40 @@
964 # no extra check is needed
965 server._check_team_membership(request, self.orequest, oresponse)
966
967- def test_list_of_details_is_complete(self):
968+ def test_only_ax_or_sreg_form_is_displayed(self):
969+ """Even if both SReg and AX requests are present, only display the AX
970+ form."""
971+ team_name = 'ubuntu-team'
972+ team = self.factory.make_team(name=team_name)
973+ self.factory.add_account_to_team(self.account, team)
974+
975+ # create a trusted rpconfig
976+ rpconfig = OpenIDRPConfig(
977+ trust_root='http://localhost/',
978+ allowed_sreg='fullname,email',
979+ allowed_ax='fullname,email,account_verified',
980+ can_query_any_team=True,
981+ description="Some description",
982+ )
983+ rpconfig.save()
984+ param_overrides = {
985+ 'openid.sreg.required': 'nickname,email,fullname,language',
986+ 'openid.ns.ax': AXMessage.ns_uri,
987+ 'openid.ax.mode': FetchRequest.mode,
988+ 'openid.ax.type.fullname': AX_URI_FULL_NAME,
989+ 'openid.ax.type.email': AX_URI_EMAIL,
990+ 'openid.ax.type.account_verified': AX_URI_ACCOUNT_VERIFIED,
991+ 'openid.ax.type.language': AX_URI_LANGUAGE,
992+ 'openid.ax.required': 'fullname,email,account_verified,language',
993+ 'openid.lp.query_membership': team_name,
994+ }
995+ self._prepare_openid_token(param_overrides=param_overrides)
996+ response = self.client.get('/%s/+decide' % self.token)
997+ dom = PyQuery(response.content)
998+ self.assertEqual(len(dom.find('li.ax')), 3)
999+ self.assertEqual(len(dom.find('li.sreg')), 0)
1000+
1001+ def test_list_of_details_is_complete_with_sreg(self):
1002 team_name = 'ubuntu-team'
1003 team = self.factory.make_team(name=team_name)
1004 self.factory.add_account_to_team(self.account, team)
1005@@ -532,7 +575,91 @@
1006 self.assertContains(response, "Email address")
1007 self.assertContains(response, "Preferred language")
1008
1009- def test_state_of_checkboxes_and_data_formats_trusted(self):
1010+ def test_list_of_details_is_complete_with_ax(self):
1011+ team_name = 'ubuntu-team'
1012+ team = self.factory.make_team(name=team_name)
1013+ self.factory.add_account_to_team(self.account, team)
1014+
1015+ # create a trusted rpconfig
1016+ rpconfig = OpenIDRPConfig(
1017+ trust_root='http://localhost/',
1018+ allowed_ax='fullname,email,account_verified',
1019+ can_query_any_team=True,
1020+ description="Some description",
1021+ )
1022+ rpconfig.save()
1023+ param_overrides = {
1024+ 'openid.ns.ax': AXMessage.ns_uri,
1025+ 'openid.ax.mode': FetchRequest.mode,
1026+ 'openid.ax.type.fullname': AX_URI_FULL_NAME,
1027+ 'openid.ax.type.email': AX_URI_EMAIL,
1028+ 'openid.ax.type.account_verified': AX_URI_ACCOUNT_VERIFIED,
1029+ 'openid.ax.type.language': AX_URI_LANGUAGE,
1030+ 'openid.ax.required': 'fullname,email,account_verified,language',
1031+ 'openid.lp.query_membership': team_name,
1032+ }
1033+ self._prepare_openid_token(param_overrides=param_overrides)
1034+ response = self.client.get('/%s/+decide' % self.token)
1035+ self.assertContains(response, "Team membership")
1036+ self.assertContains(response, "Full name")
1037+ self.assertContains(response, "Email address")
1038+ self.assertContains(response, "Account verified")
1039+
1040+ def _test_state_of_checkboxes_and_data_formats(
1041+ self, dom, field, label=None, value=None, required=False,
1042+ disabled=False, checked=False):
1043+ elem = dom.find('#id_%s' % field)
1044+ self.assertEqual(len(elem), 1)
1045+ self.assertEqual(elem[0].get('type'), 'checkbox')
1046+ if required:
1047+ self.assertEqual(elem[0].get('class'), 'required')
1048+ else:
1049+ self.assertIsNone(elem[0].get('class'))
1050+ if checked:
1051+ self.assertEqual(elem[0].get('checked'), 'checked')
1052+ else:
1053+ self.assertIsNone(elem[0].get('checked'))
1054+ if disabled:
1055+ self.assertEqual(elem[0].get('disabled'), 'disabled')
1056+ else:
1057+ self.assertIsNone(elem[0].get('disabled'))
1058+ self.assertEqual(elem[0].get('value'), value)
1059+ elem = dom.find('label[for=id_%s]' % field)
1060+ self.assertEqual(len(elem), 1)
1061+ self.assertEqual(
1062+ elem[0].text, '%s: %s' % (label, value) if label else value)
1063+
1064+ def _test_required_trusted_field(self, dom, field, label=None, value=None):
1065+ """Required fields for trusted RPs *should* be checked, *should* be
1066+ disabled and *should* be required."""
1067+ self._test_state_of_checkboxes_and_data_formats(
1068+ dom, field=field, label=label, value=value,
1069+ required=True, disabled=True, checked=True)
1070+
1071+ def _test_optional_trusted_field(self, dom, field, label=None, value=None):
1072+ """Optional fields for trusted RPs *should* be checked, *should not* be
1073+ disabled and *should not* be required."""
1074+ self._test_state_of_checkboxes_and_data_formats(
1075+ dom, field=field, label=label, value=value, checked=True,
1076+ disabled=False, required=False)
1077+
1078+ def _test_required_untrusted_field(self, dom, field, label=None,
1079+ value=None):
1080+ """Required fields for untrusted RPs *should* be checked, *should not*
1081+ be disabled and *should* be required."""
1082+ self._test_state_of_checkboxes_and_data_formats(
1083+ dom, field=field, label=label, value=value, checked=True,
1084+ disabled=False, required=True)
1085+
1086+ def _test_optional_untrusted_field(self, dom, field, label=None,
1087+ value=None):
1088+ """Optional fields for untrusted RPs *should not* be checked,
1089+ *should not* be disabled and *should not* be required."""
1090+ self._test_state_of_checkboxes_and_data_formats(
1091+ dom, field=field, label=label, value=value, required=False,
1092+ checked=False, disabled=False)
1093+
1094+ def test_state_of_checkboxes_and_data_formats_trusted_sreg(self):
1095 teams = ('ubuntu-team', 'launchpad-team', 'isd-team')
1096 for team_name in teams:
1097 team = self.factory.make_team(name=team_name)
1098@@ -552,39 +679,68 @@
1099 }
1100 self._prepare_openid_token(param_overrides=param_overrides)
1101 response = self.client.get(self.url)
1102- # checkbox checked and disabled for required fields and label is bold
1103+ dom = PyQuery(response.content)
1104+ self.assertEqual(len(dom.find('li.sreg')), 3)
1105+
1106 nickname = self.account.person.name
1107- username_html = ('<li><input checked="checked" name="nickname" '
1108- 'value="%s" class="required" disabled="disabled" '
1109- 'type="checkbox" id="id_nickname" /> <label '
1110- 'for="id_nickname">Username: %s</label></li>' %
1111- (nickname, nickname))
1112- email_html = ('<li><input checked="checked" name="email" '
1113- 'value="%s" class="required" '
1114- 'disabled="disabled" type="checkbox" id="id_email" /> '
1115- '<label for="id_email">Email address: %s</label></li>' %
1116- (self.login_email, self.login_email))
1117- # checkbox checked and enabled for optional fields and label is plain
1118- language_html = ('<li><input checked="checked" type="checkbox" '
1119- 'name="language" value="en" id="id_language" /> '
1120- '<label for="id_language">Preferred language: en'
1121- '</label></li>')
1122- # team data is enabled and checked, the label is plain
1123- team_html_1 = ('<li><input checked="checked" type="checkbox" '
1124- 'name="ubuntu-team" value="ubuntu-team" id="id_'
1125- 'ubuntu-team" /> <label for="id_ubuntu-team">'
1126- 'ubuntu-team</label></li>')
1127- team_html_2 = ('<li><input checked="checked" type="checkbox" '
1128- 'name="isd-team" value="isd-team" id="id_isd-team" /> '
1129- '<label for="id_isd-team">isd-team</label></li>')
1130-
1131- self.assertContains(response, username_html)
1132- self.assertContains(response, email_html)
1133- self.assertContains(response, language_html)
1134- self.assertContains(response, team_html_1)
1135- self.assertContains(response, team_html_2)
1136-
1137- def test_state_of_checkboxes_and_data_formats_untrusted(self):
1138+ self._test_required_trusted_field(dom, field='nickname',
1139+ label='Username', value=nickname)
1140+ self._test_required_trusted_field(dom, field='email',
1141+ label='Email address',
1142+ value=self.login_email)
1143+
1144+ self._test_optional_trusted_field(dom, field='language',
1145+ label='Preferred language',
1146+ value='en')
1147+
1148+ for team in teams:
1149+ self._test_optional_trusted_field(dom, field=team, value=team)
1150+
1151+ def test_state_of_checkboxes_and_data_formats_trusted_ax(self):
1152+ teams = ('ubuntu-team', 'launchpad-team', 'isd-team')
1153+ for team_name in teams:
1154+ team = self.factory.make_team(name=team_name)
1155+ self.factory.add_account_to_team(self.account, team)
1156+
1157+ # create a trusted rpconfig
1158+ rpconfig = OpenIDRPConfig(
1159+ trust_root='http://localhost/',
1160+ allowed_ax='nickname,email,language,account_verified',
1161+ can_query_any_team=True,
1162+ description="Some description",
1163+ )
1164+ rpconfig.save()
1165+ param_overrides = {
1166+ 'openid.ns.ax': AXMessage.ns_uri,
1167+ 'openid.ax.mode': FetchRequest.mode,
1168+ 'openid.ax.type.fullname': AX_URI_FULL_NAME,
1169+ 'openid.ax.type.email': AX_URI_EMAIL,
1170+ 'openid.ax.type.account_verified': AX_URI_ACCOUNT_VERIFIED,
1171+ 'openid.ax.type.language': AX_URI_LANGUAGE,
1172+ 'openid.ax.required': 'fullname,email,account_verified',
1173+ 'openid.ax.if_available': 'language',
1174+ 'openid.lp.query_membership': ','.join(teams),
1175+ }
1176+ self._prepare_openid_token(param_overrides=param_overrides)
1177+ response = self.client.get('/%s/+decide' % self.token)
1178+ dom = PyQuery(response.content)
1179+ self.assertEqual(len(dom.find('li.ax')), 3)
1180+
1181+ self._test_required_trusted_field(dom, field='email',
1182+ label='Email address',
1183+ value=self.login_email)
1184+ self._test_required_trusted_field(dom, field='account_verified',
1185+ label='Account verified',
1186+ value='token_via_email')
1187+
1188+ self._test_optional_trusted_field(dom, field='language',
1189+ label='Preferred language',
1190+ value='en')
1191+
1192+ for team in teams:
1193+ self._test_optional_trusted_field(dom, field=team, value=team)
1194+
1195+ def test_state_of_checkboxes_and_data_formats_untrusted_sreg(self):
1196 team_name = 'ubuntu-team'
1197 team = self.factory.make_team(name=team_name)
1198 self.factory.add_account_to_team(self.account, team)
1199@@ -602,30 +758,71 @@
1200 }
1201 self._prepare_openid_token(param_overrides=param_overrides)
1202 response = self.client.get(self.url)
1203+ dom = PyQuery(response.content)
1204+ self.assertEqual(len(dom.find('li.sreg')), 4)
1205+
1206 nickname = self.account.person.name
1207- # checkbox checked and *enabled* for required fields and label is bold
1208- username_html = ('<li><input checked="checked" name="nickname" '
1209- 'value="%s" class="required" type="checkbox" '
1210- 'id="id_nickname" /> <label for="id_nickname">'
1211- 'Username: %s</label></li>' % (nickname, nickname))
1212- email_html = ('<li><input checked="checked" name="email" '
1213- 'value="%s" class="required" type='
1214- '"checkbox" id="id_email" /> <label for="id_email">'
1215- 'Email address: %s</label></li>' %
1216- (self.login_email, self.login_email))
1217- # checkbox *not checked* and enabled for optional fields & plain label
1218- language_html = ('<li><input type="checkbox" name="language" value="'
1219- 'en" id="id_language" /> <label for="id_language">'
1220- 'Preferred language: en</label></li>')
1221- # team data is enabled and *not checked*, the label is plain
1222- team_html = ('<li><input type="checkbox" name="ubuntu-team" '
1223- 'value="ubuntu-team" id="id_ubuntu-team" /> '
1224- '<label for="id_ubuntu-team">Team membership:</label> '
1225- '<label for="id_ubuntu-team">ubuntu-team</label></li>')
1226- self.assertContains(response, username_html)
1227- self.assertContains(response, email_html)
1228- self.assertContains(response, language_html)
1229- self.assertContains(response, team_html)
1230+ self._test_required_untrusted_field(dom, field='nickname',
1231+ label='Username', value=nickname)
1232+ self._test_required_untrusted_field(dom, field='email',
1233+ label='Email address',
1234+ value=self.login_email)
1235+
1236+ self._test_optional_untrusted_field(dom, field='language',
1237+ label='Preferred language',
1238+ value='en')
1239+
1240+ self._test_optional_untrusted_field(dom, field=team_name,
1241+ label='Team membership',
1242+ value=team_name)
1243+
1244+ def test_state_of_checkboxes_and_data_formats_untrusted_ax(self):
1245+ team_name = 'ubuntu-team'
1246+ team = self.factory.make_team(name=team_name)
1247+ self.factory.add_account_to_team(self.account, team)
1248+
1249+ # create a trusted rpconfig
1250+ trust_root = 'http://untrusted/'
1251+ rpconfig = OpenIDRPConfig(
1252+ trust_root=trust_root,
1253+ can_query_any_team=True,
1254+ description="Some description",
1255+ )
1256+
1257+ delete_rpconfig_cache_entry("http://localhost/")
1258+
1259+ rpconfig.save()
1260+ param_overrides = {
1261+ 'openid.ns.ax': AXMessage.ns_uri,
1262+ 'openid.ax.mode': FetchRequest.mode,
1263+ 'openid.ax.type.fullname': AX_URI_FULL_NAME,
1264+ 'openid.ax.type.email': AX_URI_EMAIL,
1265+ 'openid.ax.type.account_verified': AX_URI_ACCOUNT_VERIFIED,
1266+ 'openid.ax.type.language': AX_URI_LANGUAGE,
1267+ 'openid.ax.required': 'fullname,email,account_verified',
1268+ 'openid.ax.if_available': 'language',
1269+ 'openid.lp.query_membership': 'ubuntu-team',
1270+ }
1271+ self._prepare_openid_token(param_overrides=param_overrides)
1272+ response = self.client.get('/%s/+decide' % self.token)
1273+ dom = PyQuery(response.content)
1274+ self.assertEqual(len(dom.find('li.ax')), 4)
1275+
1276+ fullname = self.account.get_full_name()
1277+ self._test_required_untrusted_field(dom, field='fullname',
1278+ label='Full name',
1279+ value=fullname)
1280+ self._test_required_untrusted_field(dom, field='email',
1281+ label='Email address',
1282+ value=self.login_email)
1283+
1284+ self._test_optional_untrusted_field(dom, field='language',
1285+ label='Preferred language',
1286+ value='en')
1287+
1288+ self._test_optional_untrusted_field(dom, field=team_name,
1289+ label='Team membership',
1290+ value=team_name)
1291
1292
1293 class DecideUserUnverifiedTestCase(DecideBaseTestCase):
1294@@ -1353,7 +1550,8 @@
1295
1296 class ApprovedDataTestCase(SSOBaseTestCase):
1297
1298- def _get_openid_request(self, with_sreg=True, with_teams=True):
1299+ def _get_openid_request(
1300+ self, with_sreg=True, with_ax=True, with_teams=True):
1301 request = {
1302 'openid.mode': 'checkid_setup',
1303 'openid.trust_root': 'http://localhost/',
1304@@ -1361,6 +1559,12 @@
1305 'openid.identity': IDENTIFIER_SELECT}
1306 if with_sreg:
1307 request['openid.sreg.required'] = 'email,fullname'
1308+ if with_ax:
1309+ request['openid.ns.ax'] = AXMessage.ns_uri
1310+ request['openid.ax.mode'] = FetchRequest.mode
1311+ request['openid.ax.type.fullname'] = AX_URI_FULL_NAME
1312+ request['openid.ax.type.email'] = AX_URI_EMAIL
1313+ request['openid.ax.required'] = 'email,fullname'
1314 if with_teams:
1315 request['openid.lp.query_membership'] = 'ubuntu-team'
1316 openid_server = server._get_openid_server()
1317@@ -1391,31 +1595,62 @@
1318 post_args = {'email': 'email', 'ubuntu-team': 'ubuntu-team'}
1319 result = server._get_approved_data(
1320 self._get_request_with_post_args(post_args),
1321- self._get_openid_request(True, False))
1322+ self._get_openid_request(with_sreg=True, with_ax=False,
1323+ with_teams=False))
1324 self.assertEqual(sorted(result['sreg']['requested']),
1325 ['email', 'fullname'])
1326 self.assertEqual(result['sreg']['approved'], ['email'])
1327 self.assertNotIn('teams', result)
1328+ self.assertNotIn('ax', result)
1329+
1330+ def test_approved_data_for_ax_only(self):
1331+ post_args = {'email': 'email', 'ubuntu-team': 'ubuntu-team'}
1332+ result = server._get_approved_data(
1333+ self._get_request_with_post_args(post_args),
1334+ self._get_openid_request(with_sreg=False, with_ax=True,
1335+ with_teams=False))
1336+ self.assertEqual(sorted(result['ax']['requested']),
1337+ ['email', 'fullname'])
1338+ self.assertEqual(result['ax']['approved'], ['email'])
1339+ self.assertNotIn('sreg', result)
1340+ self.assertNotIn('teams', result)
1341
1342 def test_approved_data_for_teams_only(self):
1343 post_args = {'ubuntu-team': 'ubuntu-team'}
1344 result = server._get_approved_data(
1345 self._get_request_with_post_args(post_args),
1346- self._get_openid_request(False, True))
1347+ self._get_openid_request(with_sreg=False, with_ax=False,
1348+ with_teams=True))
1349 self.assertEqual(result['teams']['requested'], ['ubuntu-team'])
1350 self.assertEqual(result['teams']['approved'], ['ubuntu-team'])
1351+ self.assertNotIn('ax', result)
1352 self.assertNotIn('sreg', result)
1353
1354 def test_approved_data_for_sreg_and_teams(self):
1355 post_args = {'email': 'email', 'ubuntu-team': 'ubuntu-team'}
1356 result = server._get_approved_data(
1357 self._get_request_with_post_args(post_args),
1358- self._get_openid_request())
1359+ self._get_openid_request(with_sreg=True, with_ax=False,
1360+ with_teams=True))
1361 self.assertEqual(sorted(result['sreg']['requested']),
1362 ['email', 'fullname'])
1363 self.assertEqual(result['sreg']['approved'], ['email'])
1364 self.assertEqual(result['teams']['requested'], ['ubuntu-team'])
1365 self.assertEqual(result['teams']['approved'], ['ubuntu-team'])
1366+ self.assertNotIn('ax', result)
1367+
1368+ def test_approved_data_for_ax_and_teams(self):
1369+ post_args = {'email': 'email', 'ubuntu-team': 'ubuntu-team'}
1370+ result = server._get_approved_data(
1371+ self._get_request_with_post_args(post_args),
1372+ self._get_openid_request(with_sreg=False, with_ax=True,
1373+ with_teams=True))
1374+ self.assertEqual(sorted(result['ax']['requested']),
1375+ ['email', 'fullname'])
1376+ self.assertEqual(result['ax']['approved'], ['email'])
1377+ self.assertEqual(result['teams']['requested'], ['ubuntu-team'])
1378+ self.assertEqual(result['teams']['approved'], ['ubuntu-team'])
1379+ self.assertNotIn('sreg', result)
1380
1381
1382 class TokenLoginTestCase(SSOBaseTestCase):
1383
1384=== modified file 'identityprovider/views/server.py'
1385--- identityprovider/views/server.py 2013-03-28 17:20:52 +0000
1386+++ identityprovider/views/server.py 2013-04-03 14:40:35 +0000
1387@@ -12,7 +12,10 @@
1388 timedelta,
1389 )
1390
1391-from openid.extensions import pape
1392+from openid.extensions import (
1393+ ax,
1394+ pape,
1395+)
1396 from openid.extensions.sreg import (
1397 SRegRequest,
1398 SRegResponse,
1399@@ -59,8 +62,12 @@
1400
1401 import identityprovider.signed as signed
1402
1403-from identityprovider.const import LAUNCHPAD_TEAMS_NS
1404+from identityprovider.const import (
1405+ AX_DATA_FIELDS,
1406+ LAUNCHPAD_TEAMS_NS,
1407+)
1408 from identityprovider.forms import (
1409+ AXFetchRequestForm,
1410 PreAuthorizeForm,
1411 SRegRequestForm,
1412 TeamsRequestForm,
1413@@ -278,6 +285,7 @@
1414 return _process_decide(request, orequest, decision=True)
1415
1416 sreg_request = SRegRequest.fromOpenIDRequest(orequest)
1417+ ax_request = ax.FetchRequest.fromOpenIDRequest(orequest)
1418 teams_request = TeamsRequest.fromOpenIDRequest(orequest)
1419 try:
1420 summary = OpenIDRPSummary.objects.get(
1421@@ -287,6 +295,9 @@
1422 except OpenIDRPSummary.DoesNotExist:
1423 approved_data = {}
1424
1425+ ax_form = (AXFetchRequestForm(
1426+ request, ax_request, rpconfig, approved_data=approved_data.get('ax'))
1427+ if ax_request else None)
1428 sreg_form = SRegRequestForm(request, sreg_request, rpconfig,
1429 approved_data=approved_data.get('sreg'))
1430 teams_form = TeamsRequestForm(request, teams_request, rpconfig,
1431@@ -295,6 +306,7 @@
1432 'account': request.user,
1433 'trust_root': orequest.trust_root,
1434 'rpconfig': rpconfig,
1435+ 'ax_form': ax_form,
1436 'sreg_form': sreg_form,
1437 'teams_form': teams_form,
1438 'token': token,
1439@@ -541,15 +553,23 @@
1440 return None
1441
1442 approved_data = {}
1443-
1444- sreg_request = SRegRequest.fromOpenIDRequest(orequest)
1445 rpconfig = utils.get_rpconfig(orequest.trust_root)
1446+
1447+ sreg_request = SRegRequest.fromOpenIDRequest(orequest)
1448 sreg_form = SRegRequestForm(request, sreg_request, rpconfig)
1449- if len(sreg_form.data.keys()) > 0:
1450+ if sreg_form.has_data:
1451 approved_data['sreg'] = {
1452 'requested': sreg_form.data.keys(),
1453 'approved': sreg_form.data_approved_for_request.keys()}
1454
1455+ ax_request = ax.FetchRequest.fromOpenIDRequest(orequest)
1456+ if ax_request:
1457+ ax_form = AXFetchRequestForm(request, ax_request, rpconfig)
1458+ if ax_form.has_data:
1459+ approved_data['ax'] = {
1460+ 'requested': ax_form.data.keys(),
1461+ 'approved': ax_form.data_approved_for_request.keys()}
1462+
1463 args = orequest.message.getArgs(LAUNCHPAD_TEAMS_NS)
1464 team_names = args.get('query_membership')
1465 if team_names:
1466@@ -586,6 +606,18 @@
1467 openid_response.addExtension(sreg_response)
1468
1469
1470+def _add_ax(request, openid_request, openid_response):
1471+ ax_request = ax.FetchRequest.fromOpenIDRequest(openid_request)
1472+ if ax_request:
1473+ rpconfig = utils.get_rpconfig(openid_request.trust_root)
1474+ form = AXFetchRequestForm(request, ax_request, rpconfig)
1475+ if form.data_approved_for_request:
1476+ ax_response = ax.FetchResponse(ax_request)
1477+ for k, v in form.data_approved_for_request.iteritems():
1478+ ax_response.addValue(AX_DATA_FIELDS.getNamespaceURI(k), v)
1479+ openid_response.addExtension(ax_response)
1480+
1481+
1482 def _process_decide(request, orequest, decision):
1483 oresponse = orequest.answer(
1484 decision, identity=request.user.openid_identity_url)
1485@@ -603,6 +635,7 @@
1486 orequest.trust_root,
1487 client_id=request.session.session_key)
1488 _add_sreg(request, orequest, oresponse)
1489+ _add_ax(request, orequest, oresponse)
1490 # if there's no submitted POST data, this is an auto-authorized
1491 # (immediate) request
1492 immediate = not request.POST