Merge lp:~michael.nelson/canonical-identity-provider/add_otp_form_field into lp:canonical-identity-provider/release

Proposed by Michael Nelson
Status: Merged
Approved by: Michael Foord
Approved revision: no longer in the source branch.
Merged at revision: 143
Proposed branch: lp:~michael.nelson/canonical-identity-provider/add_otp_form_field
Merge into: lp:canonical-identity-provider/release
Diff against target: 201 lines (+168/-3)
2 files modified
identityprovider/fields.py (+63/-2)
identityprovider/tests/test_fields.py (+105/-1)
To merge this branch: bzr merge lp:~michael.nelson/canonical-identity-provider/add_otp_form_field
Reviewer Review Type Date Requested Status
Michael Foord (community) Approve
Review via email: mp+60507@code.launchpad.net

Commit message

[r=mfoord] Add a new django form field: OneTimePasswordField.

Description of the change

Overview
========

This branch provides a django form field, OneTimePasswordField, which when asked to clean its data by the framework, will:

1) Ensure the length of the otp is between 34-40, and if so,
2) Verify with an actual Yubikey server that the otp is valid.

If either of the above is not true, as per normal django fields, it raises a validation error. It's written to be plugged in to the LoginForm and the NewYubiKeyForm.

Test with:
 $ .env/bin/python django_project/manage.py test identityprovider.tests.test_fields

To post a comment you must log in.
Revision history for this message
Michael Foord (mfoord) wrote :

Great stuff.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'identityprovider/fields.py'
2--- identityprovider/fields.py 2010-04-21 15:29:24 +0000
3+++ identityprovider/fields.py 2011-05-10 15:41:18 +0000
4@@ -1,12 +1,73 @@
5 # Copyright 2010 Canonical Ltd. This software is licensed under the
6 # GNU Affero General Public License version 3 (see the file LICENSE).
7
8-from django.forms import MultipleChoiceField
9+import hashlib
10+import urllib2
11+from datetime import datetime
12+
13+from django import forms
14+from django.conf import settings
15+from django.utils.translation import ugettext as _
16+
17 from identityprovider.widgets import CommaSeparatedWidget
18
19
20-class CommaSeparatedField(MultipleChoiceField):
21+class CommaSeparatedField(forms.MultipleChoiceField):
22 widget = CommaSeparatedWidget
23
24 def clean(self, value):
25 return ','.join(super(CommaSeparatedField, self).clean(value))
26+
27+
28+class OneTimePasswordField(forms.CharField):
29+ """A string of chars between 34 and 48 chars in length.
30+
31+ Note: we don't check the actual characters as different keyboard
32+ layouts can create different otps, which should be handled by
33+ the validation server.
34+ """
35+ def __init__(self, *args, **kwargs):
36+ error_msg = _('The one-time password is invalid.')
37+ kwargs['min_length'] = 34
38+ kwargs['max_length'] = 48
39+ kwargs['error_messages'] = {
40+ 'invalid': error_msg,
41+ 'max_length': error_msg,
42+ 'min_length': error_msg,
43+ }
44+ super(OneTimePasswordField, self).__init__(*args, **kwargs)
45+
46+ def request_otp_validation(self, otp):
47+ # XXX Currently yubico's api verification replies with
48+ # BACKEND_ERROR if the nonce is 40 chars long.
49+ nonce = hashlib.sha1(str(datetime.now())).hexdigest()[:30]
50+ url = settings.YUBICO_API_URL.format(
51+ client_id=settings.YUBICO_CLIENT_ID, otp=otp, nonce=nonce)
52+
53+ try:
54+ response = urllib2.urlopen(url)
55+ except urllib2.URLError:
56+ raise forms.ValidationError(
57+ _('There was an error checking your one-time password.'))
58+
59+ return self.convert_response_to_dict(response.read())
60+
61+ def convert_response_to_dict(self, response):
62+ response_lines = response.split()
63+ response_dict = {}
64+ for line in response_lines:
65+ key, delim, val = line.partition('=')
66+ response_dict[key] = val
67+ return response_dict
68+
69+ def clean(self, otp):
70+ otp = super(OneTimePasswordField, self).clean(otp)
71+
72+ # If the otp has the correct length, then test it against the
73+ # server.
74+ result = self.request_otp_validation(otp)
75+
76+ if result.get('status', None) == 'OK':
77+ return otp
78+
79+ raise forms.ValidationError(_('The one-time password is invalid.'))
80
81=== modified file 'identityprovider/tests/test_fields.py'
82--- identityprovider/tests/test_fields.py 2010-04-21 15:29:24 +0000
83+++ identityprovider/tests/test_fields.py 2011-05-10 15:41:18 +0000
84@@ -1,8 +1,20 @@
85 # Copyright 2010 Canonical Ltd. This software is licensed under the
86 # GNU Affero General Public License version 3 (see the file LICENSE).
87
88+import urllib2
89 from unittest import TestCase
90-from identityprovider.fields import CommaSeparatedField
91+
92+from django import forms
93+from django.conf import settings
94+from mock import (
95+ patch,
96+ Mock,
97+ )
98+
99+from identityprovider.fields import (
100+ CommaSeparatedField,
101+ OneTimePasswordField,
102+ )
103
104
105 class CommaSeparatedFieldTestCase(TestCase):
106@@ -12,3 +24,95 @@
107 field = CommaSeparatedField(choices=choices)
108 value = field.clean(["1", "2", "3"])
109 self.assertEquals(value, "1,2,3")
110+
111+
112+class OneTimePaswordFieldTestCase(TestCase):
113+
114+ YUBIKEY_RESPONSE_REPLAY = (
115+ 'h=LnOtAtozUA1ZjDKlHaVUIQIxkJM=\r\n'
116+ 't=2011-05-10T11:57:11Z0777\r\n'
117+ 'otp=cccccccitngfckhffcuudlkhjkkbtjklvubibhghvdel\r\n'
118+ 'nonce=3df9ec99ef2796f6770a084c54883b\r\n'
119+ 'status=REPLAYED_OTP\r\n\r\n')
120+
121+ YUBIKEY_RESPONSE_OK = (
122+ 'h=LnOtAtozUA1ZjDKlHaVUIQIxkJM=\r\n'
123+ 't=2011-05-10T11:57:11Z0777\r\n'
124+ 'otp=cccccccitngfckhffcuudlkhjkkbtjklvubibhghvdel\r\n'
125+ 'nonce=3df9ec99ef2796f6770a084c54883b\r\n'
126+ 'status=OK\r\n\r\n')
127+
128+ def do_clean(self, value, mock=None):
129+ otp_field = OneTimePasswordField()
130+ mock = mock or Mock(return_value=dict(status='OK'))
131+ otp_field.request_otp_validation = mock
132+
133+ return otp_field.clean(value)
134+
135+ def test_valid_chars_accepted(self):
136+ good_otp = 'cccccccitngfckhffcuudlkhjkkbtjklvubibhghvdel'
137+
138+ self.assertEqual(good_otp, self.do_clean(good_otp))
139+
140+ def test_invalid_length_not_accepted(self):
141+ less_than_34 = 'cmcccccitngflibeilnkcdhcutrjn'
142+ more_than_48 = 'cccccccitngflibeilnkcdhcutrjnkbkbbejggfrvubcccccccc'
143+
144+ self.assertRaises(forms.ValidationError, self.do_clean, less_than_34)
145+ self.assertRaises(forms.ValidationError, self.do_clean, more_than_48)
146+
147+ def test_server_validation_fail_raises_error(self):
148+ otp_field = OneTimePasswordField()
149+ good_otp = 'cccccccitngfckhffcuudlkhjkkbtjklvubibhghvdel'
150+ # We want to check exactly what urlopen is called with.
151+ mock_urlopen = Mock()
152+ mock_urlopen.return_value.read.return_value = (
153+ self.YUBIKEY_RESPONSE_REPLAY)
154+
155+ with patch('urllib2.urlopen', mock_urlopen):
156+ self.assertRaises(
157+ forms.ValidationError, otp_field.clean, good_otp)
158+
159+ def test_convert_response_to_dict(self):
160+ otp_field = OneTimePasswordField()
161+
162+ result = otp_field.convert_response_to_dict(
163+ self.YUBIKEY_RESPONSE_REPLAY)
164+
165+ self.assertEqual(dict(
166+ h='LnOtAtozUA1ZjDKlHaVUIQIxkJM=', t='2011-05-10T11:57:11Z0777',
167+ otp='cccccccitngfckhffcuudlkhjkkbtjklvubibhghvdel',
168+ nonce='3df9ec99ef2796f6770a084c54883b',
169+ status='REPLAYED_OTP'), result)
170+
171+ def test_urlopen_called_correctly(self):
172+ otp_field = OneTimePasswordField()
173+ otp = 'my_otp11111111111111111111111111111'
174+ # Ensure we control what the nonce will be.
175+ mock_sha1 = Mock()
176+ mock_sha1.return_value.hexdigest.return_value = 'mock_nonce'
177+ # We want to check exactly what urlopen is called with.
178+ mock_urlopen = Mock()
179+ mock_urlopen.return_value.read.return_value = (
180+ self.YUBIKEY_RESPONSE_OK)
181+
182+ with patch('urllib2.urlopen', mock_urlopen):
183+ with patch('hashlib.sha1', mock_sha1):
184+ result = otp_field.clean(otp)
185+
186+ mock_urlopen.assert_called_with(
187+ settings.YUBICO_API_URL.format(
188+ otp=otp, client_id=settings.YUBICO_CLIENT_ID,
189+ nonce='mock_nonce'))
190+ self.assertEqual(otp, result)
191+
192+ def test_urlopen_exception(self):
193+ # clean raises an appropriate validation error if urlopen raises
194+ # a URLError.
195+ otp_field = OneTimePasswordField()
196+ otp = 'my_otp11111111111111111111111111111'
197+ mock_urlopen = Mock(side_effect=urllib2.URLError('Bang!'))
198+
199+ with patch('urllib2.urlopen', mock_urlopen):
200+ self.assertRaises(
201+ forms.ValidationError, otp_field.clean, otp)