Merge lp:~matiasb/canonical-identity-provider/phoneid-as-emails into lp:canonical-identity-provider/release

Proposed by Matias Bordese
Status: Merged
Approved by: Natalia Bidart
Approved revision: no longer in the source branch.
Merged at revision: 657
Proposed branch: lp:~matiasb/canonical-identity-provider/phoneid-as-emails
Merge into: lp:canonical-identity-provider/release
Diff against target: 447 lines (+319/-1)
8 files modified
api/urls.py (+3/-0)
api/v20/handlers.py (+64/-1)
api/v20/tests/test_phone_login.py (+155/-0)
identityprovider/login.py (+1/-0)
identityprovider/models/emailaddress.py (+32/-0)
identityprovider/tests/unit/test_models_emailaddress.py (+32/-0)
webui/templates/account/emails.html (+2/-0)
webui/tests/test_views_account.py (+30/-0)
To merge this branch: bzr merge lp:~matiasb/canonical-identity-provider/phoneid-as-emails
Reviewer Review Type Date Requested Status
Natalia Bidart (community) Approve
Review via email: mp+149055@code.launchpad.net

Commit message

Added new login API (behind a flag) to allow login with a phone id.

Description of the change

Added new login API (behind a flag) to allow login with a phone id.

To post a comment you must log in.
Revision history for this message
Natalia Bidart (nataliabidart) wrote :

Looks great, thanks!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'api/urls.py'
--- api/urls.py 2013-02-04 11:13:04 +0000
+++ api/urls.py 2013-02-18 16:43:22 +0000
@@ -42,6 +42,7 @@
42 handler=v20.AccountsHandler, authentication=ApiOAuthAuthentication())42 handler=v20.AccountsHandler, authentication=ApiOAuthAuthentication())
43v2emails = Resource(handler=v20.EmailsHandler)43v2emails = Resource(handler=v20.EmailsHandler)
44v2login = Resource(handler=v20.AccountLoginHandler)44v2login = Resource(handler=v20.AccountLoginHandler)
45v2login_phone = Resource(handler=v20.AccountPhoneLoginHandler)
45v2registration = Resource(handler=v20.AccountRegistrationHandler)46v2registration = Resource(handler=v20.AccountRegistrationHandler)
46v2requests = Resource(handler=v20.RequestsHandler)47v2requests = Resource(handler=v20.RequestsHandler)
4748
@@ -78,6 +79,8 @@
78 url(r'^v2/tokens$', v2login, name='api-login'),79 url(r'^v2/tokens$', v2login, name='api-login'),
79 url(r'^v2/accounts/(\w+)$', v2accounts, name='api-account'),80 url(r'^v2/accounts/(\w+)$', v2accounts, name='api-account'),
80 url(r'^v2/requests/validate$', v2requests, name='api-requests'),81 url(r'^v2/requests/validate$', v2requests, name='api-requests'),
82 # login from phone, with a phone user id
83 url(r'^v2/tokens/phone$', v2login_phone, name='api-login-phone'),
81 # temporarily hooked up so we can do reverse()84 # temporarily hooked up so we can do reverse()
82 url(r'^v2/emails/(.*)$', v2emails, name='api-email'),85 url(r'^v2/emails/(.*)$', v2emails, name='api-email'),
83 url(r'v2/tokens/(.*)$', v2login, name='api-token'),86 url(r'v2/tokens/(.*)$', v2login, name='api-token'),
8487
=== modified file 'api/v20/handlers.py'
--- api/v20/handlers.py 2013-02-06 15:14:39 +0000
+++ api/v20/handlers.py 2013-02-18 16:43:22 +0000
@@ -20,7 +20,7 @@
20 AuthenticationError,20 AuthenticationError,
21 authenticate_user,21 authenticate_user,
22)22)
23from identityprovider.models import Account, twofactor23from identityprovider.models import Account, EmailAddress, twofactor
24from identityprovider.models.captcha import Captcha, VerifyCaptchaError24from identityprovider.models.captcha import Captcha, VerifyCaptchaError
25from identityprovider.store import SSODataStore25from identityprovider.store import SSODataStore
2626
@@ -137,6 +137,69 @@
137 return response137 return response
138138
139139
140class AccountPhoneLoginHandler(BaseHandler):
141 allowed_methods = ('POST', )
142
143 @require_mime('json')
144 def create(self, request):
145 if not gargoyle.is_active('LOGIN_BY_PHONE'):
146 return errors.RESOURCE_NOT_FOUND()
147
148 data = request.data
149 # email should be available the first time, to bind account/phone
150 # after that, get email from phoneid in the token name
151 email = data.get('email')
152 try:
153 phone_id = data['phone_id']
154 password = data['password']
155 token_name = data['token_name']
156 except KeyError:
157 expected = set(('phone_id', 'password', 'token_name'))
158 missing = dict((k, 'field required') for k in expected - set(data))
159 return errors.INVALID_DATA(**missing)
160
161 try:
162 phone_email = EmailAddress.objects.get_from_phone_id(phone_id)
163 login_email = phone_email.email
164 except EmailAddress.DoesNotExist:
165 phone_email = None
166 login_email = email
167
168 if login_email is None:
169 return errors.INVALID_DATA(phone_id='invalid value')
170
171 try:
172 account = authenticate_user(login_email, password)
173 except AccountSuspended:
174 return errors.ACCOUNT_SUSPENDED()
175 except AccountDeactivated:
176 return errors.ACCOUNT_DEACTIVATED()
177 except AuthenticationError:
178 return errors.INVALID_CREDENTIALS()
179
180 # TODO: here we should check for 2fa, avoiding for demo
181
182 if phone_email is None:
183 # user is authenticated through email,
184 # adding phone id as email for future login
185 EmailAddress.objects.create_from_phone_id(phone_id, account)
186
187 token = account.get_or_create_oauth_token(token_name)
188 response = rc.ALL_OK
189 response.content = {
190 "consumer_key": token.consumer.key,
191 "consumer_secret": token.consumer.secret,
192 "token_key": token.token,
193 "token_secret": token.token_secret,
194 "token_name": token_name,
195 "date_created": token.created_at,
196 "date_updated": token.updated_at,
197 "href": reverse('api-token', args=(token.token,)),
198 "openid": account.openid_identifier,
199 }
200 return response
201
202
140class EmailsHandler(BaseHandler):203class EmailsHandler(BaseHandler):
141 allowed_methods = ('GET',)204 allowed_methods = ('GET',)
142205
143206
=== added file 'api/v20/tests/test_phone_login.py'
--- api/v20/tests/test_phone_login.py 1970-01-01 00:00:00 +0000
+++ api/v20/tests/test_phone_login.py 2013-02-18 16:43:22 +0000
@@ -0,0 +1,155 @@
1
2from django.core.urlresolvers import reverse
3from gargoyle.testutils import switches
4from mock import patch
5
6from identityprovider.login import (
7 AuthenticationError,
8 AccountDeactivated,
9 AccountSuspended,
10)
11from identityprovider.models.emailaddress import PHONE_EMAIL_DOMAIN
12from identityprovider.tests import DEFAULT_USER_PASSWORD
13from identityprovider.tests.utils import (
14 SSOBaseTestCase,
15)
16
17from api.v20 import handlers
18from api.v20.tests.utils import call
19
20
21handler = handlers.AccountPhoneLoginHandler()
22API_URL = reverse('api-login-phone')
23API_DATA = dict(
24 email='foo@bar.com', phone_id='tel:+1234567890',
25 password='foobar', token_name='token_name')
26
27
28class PhoneLoginHandlerTestCase(SSOBaseTestCase):
29 fixtures = ['test']
30
31 def test_disabled_by_default(self):
32 response, json_body = call(handler.create, API_URL, API_DATA)
33 self.assertEqual(response.status_code, 404)
34
35 @switches(LOGIN_BY_PHONE=True)
36 def test_login_required_parameters(self):
37 response, json_body = call(handler.create, API_URL, {})
38 self.assertEqual(response.status_code, 400)
39 self.assertEqual(json_body, {
40 'code': 'INVALID_DATA',
41 'extra': {
42 'phone_id': 'field required', 'password': 'field required',
43 'token_name': 'field required'
44 },
45 'message': 'Invalid request data'})
46
47 @switches(LOGIN_BY_PHONE=True)
48 def test_account_suspended(self):
49 self.assert_failed_login('ACCOUNT_SUSPENDED', AccountSuspended)
50
51 @switches(LOGIN_BY_PHONE=True)
52 def test_account_deactivated(self):
53 self.assert_failed_login('ACCOUNT_DEACTIVATED', AccountDeactivated)
54
55 @switches(LOGIN_BY_PHONE=True)
56 def test_failed_login(self):
57 self.assert_failed_login('INVALID_CREDENTIALS', AuthenticationError)
58
59 @switches(LOGIN_BY_PHONE=True)
60 def assert_failed_login(self, code, exception):
61 status_code = 403
62 if exception is AuthenticationError:
63 status_code = 401
64
65 with patch('api.v20.handlers.authenticate_user') as mock_authenticate:
66 mock_authenticate.side_effect = exception
67 response, json_body = call(handler.create, API_URL, API_DATA)
68
69 self.assertEqual(response.status_code, status_code)
70 mock_authenticate.assert_called_once_with('foo@bar.com', 'foobar')
71 self.assertEqual(json_body['code'], code)
72
73 @switches(LOGIN_BY_PHONE=True)
74 @switches(ALLOW_UNVALIDATED=True)
75 def test_login_add_phone_email(self):
76 account = self.factory.make_account()
77 email = account.preferredemail
78 # no unverified_emails
79 assert len(account.unverified_emails()) == 0
80
81 data = {'phone_id': 'tel:+1234567890', 'email': email.email,
82 'password': DEFAULT_USER_PASSWORD, 'token_name': 'token_name'}
83 response, json_body = call(handler.create, API_URL, data)
84
85 self.assertEqual(response.status_code, 200)
86 token = account.get_or_create_oauth_token('token_name')
87 expected_response = {
88 "consumer_key": token.consumer.key,
89 "consumer_secret": token.consumer.secret,
90 "token_key": token.token,
91 "token_secret": token.token_secret,
92 "token_name": 'token_name',
93 "date_created": token.created_at,
94 "href": reverse('api-token', args=(token.token,)),
95 "openid": account.openid_identifier,
96 }
97 # ignore date_updated
98 json_body.pop('date_updated')
99 self.assertEqual(json_body, expected_response)
100 # check added phone email
101 unverified_emails = account.unverified_emails()
102 self.assertEqual(len(unverified_emails), 1)
103 phone_email = unverified_emails[0].email
104 self.assertTrue(phone_email.endswith(PHONE_EMAIL_DOMAIN))
105
106 @switches(LOGIN_BY_PHONE=True)
107 @switches(ALLOW_UNVALIDATED=True)
108 def test_login_without_email_or_added_phone_email(self):
109 account = self.factory.make_account()
110 # no unverified_emails
111 assert len(account.unverified_emails()) == 0
112
113 data = {'phone_id': 'tel:+1234567890',
114 'password': DEFAULT_USER_PASSWORD, 'token_name': 'token_name'}
115 response, json_body = call(handler.create, API_URL, data)
116
117 self.assertEqual(response.status_code, 400)
118 self.assertEqual(json_body, {
119 'code': 'INVALID_DATA',
120 'extra': {'phone_id': 'invalid value'},
121 'message': 'Invalid request data'})
122
123 @switches(LOGIN_BY_PHONE=True)
124 @switches(ALLOW_UNVALIDATED=True)
125 def test_login_with_phone_email(self):
126 account = self.factory.make_account()
127 email = account.preferredemail
128 # no unverified_emails
129 assert len(account.unverified_emails()) == 0
130
131 # login first with email and phone id
132 data = {'phone_id': 'tel:+1234567890', 'email': email.email,
133 'password': DEFAULT_USER_PASSWORD, 'token_name': 'token_name'}
134 response, json_body = call(handler.create, API_URL, data)
135 assert response.status_code == 200
136
137 # now we should be able to login with phone id only
138 data = {'phone_id': 'tel:+1234567890',
139 'password': DEFAULT_USER_PASSWORD, 'token_name': 'token_name'}
140 response, json_body = call(handler.create, API_URL, data)
141 self.assertEqual(response.status_code, 200)
142 token = account.get_or_create_oauth_token('token_name')
143 expected_response = {
144 "consumer_key": token.consumer.key,
145 "consumer_secret": token.consumer.secret,
146 "token_key": token.token,
147 "token_secret": token.token_secret,
148 "token_name": 'token_name',
149 "date_created": token.created_at,
150 "href": reverse('api-token', args=(token.token,)),
151 "openid": account.openid_identifier,
152 }
153 # ignore date_updated
154 json_body.pop('date_updated')
155 self.assertEqual(json_body, expected_response)
0156
=== modified file 'identityprovider/login.py'
--- identityprovider/login.py 2013-01-03 11:41:12 +0000
+++ identityprovider/login.py 2013-02-18 16:43:22 +0000
@@ -43,6 +43,7 @@
43 else:43 else:
44 raise AuthenticationError(_("Password didn't match."))44 raise AuthenticationError(_("Password didn't match."))
4545
46 # also check for LOGIN_BY_PHONE flag conditional to canonical users
46 if not gargoyle.is_active('ALLOW_UNVALIDATED'):47 if not gargoyle.is_active('ALLOW_UNVALIDATED'):
47 email_obj = account.emailaddress_set.get(email__iexact=email)48 email_obj = account.emailaddress_set.get(email__iexact=email)
48 if email_obj.status == EmailStatus.NEW:49 if email_obj.status == EmailStatus.NEW:
4950
=== modified file 'identityprovider/models/emailaddress.py'
--- identityprovider/models/emailaddress.py 2013-01-25 20:39:28 +0000
+++ identityprovider/models/emailaddress.py 2013-02-18 16:43:22 +0000
@@ -3,6 +3,7 @@
3# LICENSE).3# LICENSE).
44
5import datetime5import datetime
6import re
67
7from django.core.urlresolvers import reverse8from django.core.urlresolvers import reverse
8from django.core.validators import validate_email9from django.core.validators import validate_email
@@ -18,6 +19,31 @@
18)19)
1920
2021
22PHONE_EMAIL_DOMAIN = 'phone.ubuntu'
23PHONE_EMAIL_INVALID_CHARS = re.compile(r"[^-!#$%&'*+/=?^_`{}|~0-9A-Z\.]",
24 re.IGNORECASE)
25
26
27class EmailAddressManager(models.Manager):
28
29 def _generate_email_from_phone_id(self, phone_id):
30 # replace chars not validated by django validate_email by #
31 email = '%s@%s' % (PHONE_EMAIL_INVALID_CHARS.sub('#', phone_id),
32 PHONE_EMAIL_DOMAIN)
33 return email
34
35 def create_from_phone_id(self, phone_id, account):
36 email = self._generate_email_from_phone_id(phone_id)
37 email_address = EmailAddress.objects.create(
38 email=email, account=account, status=EmailStatus.NEW)
39 return email_address
40
41 def get_from_phone_id(self, phone_id):
42 email = self._generate_email_from_phone_id(phone_id)
43 email_address = self.get(email=email)
44 return email_address
45
46
21class EmailAddress(models.Model):47class EmailAddress(models.Model):
22 email = models.TextField(validators=[validate_email])48 email = models.TextField(validators=[validate_email])
23 lp_person = models.IntegerField(49 lp_person = models.IntegerField(
@@ -28,6 +54,8 @@
28 account = models.ForeignKey(54 account = models.ForeignKey(
29 Account, db_column='account', blank=True, null=True)55 Account, db_column='account', blank=True, null=True)
3056
57 objects = EmailAddressManager()
58
31 class Meta:59 class Meta:
32 app_label = 'identityprovider'60 app_label = 'identityprovider'
33 db_table = u'emailaddress'61 db_table = u'emailaddress'
@@ -39,6 +67,10 @@
39 def is_preferred(self):67 def is_preferred(self):
40 return self.status == EmailStatus.PREFERRED68 return self.status == EmailStatus.PREFERRED
4169
70 def is_verifiable(self):
71 suffix = '@%s' % PHONE_EMAIL_DOMAIN
72 return not self.email.endswith(suffix)
73
42 def is_verified(self):74 def is_verified(self):
43 return self.status in (EmailStatus.VALIDATED, EmailStatus.PREFERRED)75 return self.status in (EmailStatus.VALIDATED, EmailStatus.PREFERRED)
4476
4577
=== modified file 'identityprovider/tests/unit/test_models_emailaddress.py'
--- identityprovider/tests/unit/test_models_emailaddress.py 2013-01-25 21:32:21 +0000
+++ identityprovider/tests/unit/test_models_emailaddress.py 2013-02-18 16:43:22 +0000
@@ -2,9 +2,34 @@
22
3from identityprovider.models import Account, EmailAddress3from identityprovider.models import Account, EmailAddress
4from identityprovider.models.const import EmailStatus4from identityprovider.models.const import EmailStatus
5from identityprovider.models.emailaddress import PHONE_EMAIL_DOMAIN
5from identityprovider.tests.utils import SSOBaseTestCase6from identityprovider.tests.utils import SSOBaseTestCase
67
78
9class EmailAddressManagerTestCase(SSOBaseTestCase):
10 fixtures = ['test']
11
12 def test_create_from_phone_id(self):
13 account = Account.objects.get_by_email('test@canonical.com')
14 new_email = EmailAddress.objects.create_from_phone_id('tel:+123',
15 account)
16 self.assertEqual(new_email.status, EmailStatus.NEW)
17 self.assertEqual(new_email.account, account)
18 self.assertEqual(new_email.email, 'tel#+123@%s' % PHONE_EMAIL_DOMAIN)
19
20 def test_get_from_phone_id(self):
21 account = Account.objects.get_by_email('test@canonical.com')
22 new_email = EmailAddress.objects.create_from_phone_id('tel:+123',
23 account)
24 email = EmailAddress.objects.get_from_phone_id('tel:+123')
25 self.assertEqual(email, new_email)
26
27 def test_get_from_phone_id_not_exist(self):
28 self.assertRaises(
29 EmailAddress.DoesNotExist,
30 EmailAddress.objects.get_from_phone_id, 'tel:+123')
31
32
8class EmailAddressTestCase(SSOBaseTestCase):33class EmailAddressTestCase(SSOBaseTestCase):
9 fixtures = ['test']34 fixtures = ['test']
1035
@@ -13,6 +38,13 @@
13 email = EmailAddress.objects.get(email='test@canonical.com')38 email = EmailAddress.objects.get(email='test@canonical.com')
14 self.assertEqual(account, email.account)39 self.assertEqual(account, email.account)
1540
41 def test_emailaddress_is_verifiable(self):
42 email = EmailAddress.objects.get(email='test@canonical.com')
43 self.assertTrue(email.is_verifiable())
44 unverifiable_email = 'test@%s' % PHONE_EMAIL_DOMAIN
45 email = EmailAddress(email=unverifiable_email)
46 self.assertFalse(email.is_verifiable())
47
16 def test_emailaddress_is_verified(self):48 def test_emailaddress_is_verified(self):
17 email = EmailAddress.objects.get(email='test@canonical.com')49 email = EmailAddress.objects.get(email='test@canonical.com')
18 assert email.status == EmailStatus.PREFERRED50 assert email.status == EmailStatus.PREFERRED
1951
=== modified file 'webui/templates/account/emails.html'
--- webui/templates/account/emails.html 2012-12-19 18:54:44 +0000
+++ webui/templates/account/emails.html 2013-02-18 16:43:22 +0000
@@ -69,7 +69,9 @@
69 <td class="email">{{ email }}</td>69 <td class="email">{{ email }}</td>
70 {% if not readonly %}70 {% if not readonly %}
71 <td class="actions">71 <td class="actions">
72 {% if email.is_verifiable %}
72 <a href="./+verify-email?id={{ email.id }}" class="btn-sm"><span>{% trans "Verify" %}</span></a>73 <a href="./+verify-email?id={{ email.id }}" class="btn-sm"><span>{% trans "Verify" %}</span></a>
74 {% endif %}
73 <a href="./+remove-email?id={{ email.id }}" class="btn-sm"><span>{% trans "Delete" %}</span></a>75 <a href="./+remove-email?id={{ email.id }}" class="btn-sm"><span>{% trans "Delete" %}</span></a>
74 </td>76 </td>
75 {% endif %}77 {% endif %}
7678
=== modified file 'webui/tests/test_views_account.py'
--- webui/tests/test_views_account.py 2013-01-25 20:40:15 +0000
+++ webui/tests/test_views_account.py 2013-02-18 16:43:22 +0000
@@ -36,6 +36,36 @@
36from identityprovider.utils import validate_launchpad_password36from identityprovider.utils import validate_launchpad_password
3737
3838
39class AccountEmailsViewTestCase(AuthenticatedTestCase):
40
41 def setUp(self):
42 super(AccountEmailsViewTestCase, self).setUp()
43 account = Account.objects.get(emailaddress__email=self.login_email)
44 self.phone_email = EmailAddress.objects.create_from_phone_id(
45 'tel:+1234567890', account)
46
47 def test_phone_id_email_is_not_verifiable(self):
48 url = reverse('account-emails')
49 response = self.client.get(url)
50 tree = PyQuery(response.content)
51 # unverified emails -> second listing table
52 unverified_emails = tree.find('table.listing')[1]
53 emails_td = PyQuery(unverified_emails).find('td.email')
54 for email_td in emails_td:
55 if email_td.text == self.phone_email.email:
56 # get available actions
57 actions = email_td.getnext().getchildren()
58 # only delete option available, is not verifiable
59 self.assertEqual(len(actions), 1)
60 self.assertEqual(actions[0].get('href'),
61 '.%s?id=%d' % (reverse('delete_email'),
62 self.phone_email.id))
63 else:
64 # get available actions: verify, remove
65 actions = email_td.getnext().getchildren()
66 self.assertEqual(len(actions), 2)
67
68
39class AccountViewsUnauthenticatedTestCase(SSOBaseTestCase):69class AccountViewsUnauthenticatedTestCase(SSOBaseTestCase):
4070
41 def test_index_unauthenticated(self):71 def test_index_unauthenticated(self):