Merge lp:~canonical-isd-hackers/canonical-identity-provider/enforce-user-requirestwo-factor into lp:canonical-identity-provider/release

Proposed by Michael Foord
Status: Merged
Merged at revision: 324
Proposed branch: lp:~canonical-isd-hackers/canonical-identity-provider/enforce-user-requirestwo-factor
Merge into: lp:canonical-identity-provider/release
Diff against target: 798 lines (+264/-76)
15 files modified
identityprovider/decorators.py (+37/-0)
identityprovider/models/twofactor.py (+36/-0)
identityprovider/tests/acceptance/devices/add_device.py (+1/-1)
identityprovider/tests/acceptance/devices/login.py (+1/-1)
identityprovider/tests/acceptance/devices/prefs.py (+12/-2)
identityprovider/tests/acceptance/devices/remove_device.py (+1/-1)
identityprovider/tests/acceptance/devices/rename_device.py (+1/-1)
identityprovider/tests/acceptance/views_protected.csv (+8/-0)
identityprovider/tests/acceptance/views_protected.py (+44/-0)
identityprovider/tests/unit/test_decorators.py (+74/-2)
identityprovider/tests/unit/test_views_ui.py (+12/-13)
identityprovider/views/account.py (+8/-9)
identityprovider/views/devices.py (+10/-10)
identityprovider/views/server.py (+2/-2)
identityprovider/views/ui.py (+17/-34)
To merge this branch: bzr merge lp:~canonical-isd-hackers/canonical-identity-provider/enforce-user-requirestwo-factor
Reviewer Review Type Date Requested Status
Canonical ISD hackers Pending
Review via email: mp+91824@code.launchpad.net

Commit message

Adds new sso_login_required decorator to wrap up django's login_required and add twofactor checks

To post a comment you must log in.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'identityprovider/decorators.py'
--- identityprovider/decorators.py 2012-01-24 12:23:06 +0000
+++ identityprovider/decorators.py 2012-02-09 22:30:07 +0000
@@ -2,9 +2,17 @@
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4import logging4import logging
5from functools import wraps
56
6from django.template import RequestContext7from django.template import RequestContext
7from django.http import Http404, HttpResponseRedirect, HttpResponseForbidden8from django.http import Http404, HttpResponseRedirect, HttpResponseForbidden
9from django.contrib.auth import REDIRECT_FIELD_NAME
10from django.utils.decorators import available_attrs
11from django.contrib.auth.decorators import (
12 login_required as django_login_required,
13)
14from django.contrib.auth.views import redirect_to_login
15from django.core.urlresolvers import reverse
8from django.core.cache import cache16from django.core.cache import cache
9from django.conf import settings17from django.conf import settings
10from django.template.loader import render_to_string18from django.template.loader import render_to_string
@@ -16,6 +24,8 @@
1624
17from identityprovider import auth25from identityprovider import auth
18from identityprovider.cookies import set_test_cookie, test_cookie_worked26from identityprovider.cookies import set_test_cookie, test_cookie_worked
27from identityprovider.models import twofactor
28
1929
20def guest_required(func):30def guest_required(func):
21 @functools.wraps(func)31 @functools.wraps(func)
@@ -38,6 +48,33 @@
38 return _dont_cache_decorator48 return _dont_cache_decorator
3949
4050
51def sso_login_required(function=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url=None):
52 """
53 Decorator the wraps up django's login_required, and also checks for 2f
54 """
55 def decorator(view_func):
56 @wraps(view_func, assigned=available_attrs(view_func))
57 def _wrapped_view(request, *args, **kwargs):
58 if (twofactor.user_requires_twofactor_auth(request, request.user) and
59 not twofactor.is_authenticated(request)):
60 # some views send token as None, which breaks reverse
61 if 'token' in kwargs and kwargs['token'] == None:
62 del kwargs['token']
63 return redirect_to_login(
64 request.get_full_path(),
65 reverse('twofactor', kwargs=kwargs),
66 redirect_field_name)
67 return view_func(request, *args, **kwargs)
68 return django_login_required(
69 _wrapped_view,
70 redirect_field_name,
71 login_url)
72
73 if function:
74 return decorator(function)
75 return decorator
76
77
41def check_readonly(func):78def check_readonly(func):
42 """ A readonly aware decorator.79 """ A readonly aware decorator.
4380
4481
=== modified file 'identityprovider/models/twofactor.py'
--- identityprovider/models/twofactor.py 2012-01-30 14:42:31 +0000
+++ identityprovider/models/twofactor.py 2012-02-09 22:30:07 +0000
@@ -1,10 +1,15 @@
1# Copyright 2010-2012 Canonical Ltd. This software is licensed under the1# Copyright 2010-2012 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4import logging
5
3from oath.hotp import accept_hotp6from oath.hotp import accept_hotp
4from django.db import models7from django.db import models
5from django.conf import settings8from django.conf import settings
6from django.utils.translation import ugettext as _9from django.utils.translation import ugettext as _
710
11from gargoyle import gargoyle
12
8from identityprovider.models import Account13from identityprovider.models import Account
9from identityprovider.utils import AuthenticationError14from identityprovider.utils import AuthenticationError
1015
@@ -20,6 +25,37 @@
20 else:25 else:
21 return None26 return None
2227
28TWOFACTOR_KEY = 'logged_in_via_two_factor'
29
30def is_twofactor_enabled(request):
31 return gargoyle.is_active('TWOFACTOR', request)
32
33def login(request):
34 request.session[TWOFACTOR_KEY] = True
35
36def logout(request):
37 if TWOFACTOR_KEY in request.session:
38 del request.session[TWOFACTOR_KEY]
39
40def is_authenticated(request):
41 return request.session.get(TWOFACTOR_KEY, False)
42
43def site_requires_twofactor_auth(request, token):
44 if not is_twofactor_enabled(request):
45 return False
46 # TODO check if site requires 2f (pape?)
47 return False
48
49def user_requires_twofactor_auth(request, account):
50 if not is_twofactor_enabled(request):
51 return False
52 if account.twofactor_required:
53 if account.has_twofactor_devices():
54 return True
55 logging.warning('Account %s requires two-factor but has no devices' % account.openid_identifier)
56 return False
57
58
23class AuthenticationDevice(models.Model):59class AuthenticationDevice(models.Model):
24 account = models.ForeignKey(Account, related_name='devices')60 account = models.ForeignKey(Account, related_name='devices')
25 key = models.TextField()61 key = models.TextField()
2662
=== added file 'identityprovider/tests/acceptance/devices/__init__.py'
=== modified file 'identityprovider/tests/acceptance/devices/add_device.py'
--- identityprovider/tests/acceptance/devices/add_device.py 2012-01-27 16:28:56 +0000
+++ identityprovider/tests/acceptance/devices/add_device.py 2012-02-09 22:30:07 +0000
@@ -4,7 +4,7 @@
4from sst.actions import *4from sst.actions import *
55
6from identityprovider.tests.acceptance import _helpers as helpers6from identityprovider.tests.acceptance import _helpers as helpers
7from _helpers import click_add_device_button, click_add_new_device_link7from identityprovider.tests.acceptance.devices._helpers import click_add_device_button, click_add_new_device_link
88
99
10config.set_base_url_from_env()10config.set_base_url_from_env()
1111
=== modified file 'identityprovider/tests/acceptance/devices/login.py'
--- identityprovider/tests/acceptance/devices/login.py 2012-02-03 13:57:54 +0000
+++ identityprovider/tests/acceptance/devices/login.py 2012-02-09 22:30:07 +0000
@@ -4,7 +4,7 @@
4from canonical.isd.tests.sst import config4from canonical.isd.tests.sst import config
5from sst.actions import *5from sst.actions import *
66
7from _helpers import add_device7from identityprovider.tests.acceptance.devices._helpers import add_device
8from identityprovider.tests.acceptance import _helpers as helpers8from identityprovider.tests.acceptance import _helpers as helpers
99
10def enter_otp(otp):10def enter_otp(otp):
1111
=== modified file 'identityprovider/tests/acceptance/devices/prefs.py'
--- identityprovider/tests/acceptance/devices/prefs.py 2012-01-27 16:28:56 +0000
+++ identityprovider/tests/acceptance/devices/prefs.py 2012-02-09 22:30:07 +0000
@@ -1,8 +1,9 @@
1# Test user two factor preferences1# Test user two factor preferences
2from oath import hotp
2from canonical.isd.tests.sst import config3from canonical.isd.tests.sst import config
3from sst.actions import *4from sst.actions import *
45
5from _helpers import add_device6from identityprovider.tests.acceptance.devices._helpers import add_device
6from identityprovider.tests.acceptance import _helpers as helpers7from identityprovider.tests.acceptance import _helpers as helpers
78
89
@@ -26,7 +27,7 @@
26 disabled="disabled")27 disabled="disabled")
2728
28# Add an authentication device29# Add an authentication device
29add_device()30aes_key = add_device()
3031
31# Test preferences form is now enabled32# Test preferences form is now enabled
32assert_attribute(get_element(tag="input", name="two-factor", value="always"),33assert_attribute(get_element(tag="input", name="two-factor", value="always"),
@@ -45,7 +46,16 @@
45set_radio_value(get_element(tag="input", name="two-factor", value="always"))46set_radio_value(get_element(tag="input", name="two-factor", value="always"))
46click_button("authentication-prefs")47click_button("authentication-prefs")
4748
49# so we now should now have the twofactor auth form
50assert_url('/two_factor_auth?next=/device-list')
51
52# 2 factor login
53otp = hotp.hotp(aes_key, 1)
54write_textfield('id_oath_token', otp)
55click_button(get_element(type='submit'))
56
48# Check that "always" is now shown in the user preferences57# Check that "always" is now shown in the user preferences
58assert_url('/device-list')
49assert_attribute(get_element(tag="input", name="two-factor", value="always"),59assert_attribute(get_element(tag="input", name="two-factor", value="always"),
50 'checked', 'true')60 'checked', 'true')
51assert_attribute(get_element(tag="input", name="two-factor", value="as-needed"),61assert_attribute(get_element(tag="input", name="two-factor", value="as-needed"),
5262
=== modified file 'identityprovider/tests/acceptance/devices/remove_device.py'
--- identityprovider/tests/acceptance/devices/remove_device.py 2012-01-27 16:29:24 +0000
+++ identityprovider/tests/acceptance/devices/remove_device.py 2012-02-09 22:30:07 +0000
@@ -3,7 +3,7 @@
3from sst.actions import *3from sst.actions import *
44
5from identityprovider.tests.acceptance import _helpers as helpers5from identityprovider.tests.acceptance import _helpers as helpers
6from _helpers import add_device6from identityprovider.tests.acceptance.devices._helpers import add_device
77
88
9def click_remove_button():9def click_remove_button():
1010
=== modified file 'identityprovider/tests/acceptance/devices/rename_device.py'
--- identityprovider/tests/acceptance/devices/rename_device.py 2012-01-27 16:28:56 +0000
+++ identityprovider/tests/acceptance/devices/rename_device.py 2012-02-09 22:30:07 +0000
@@ -2,7 +2,7 @@
2from sst.actions import *2from sst.actions import *
33
4from identityprovider.tests.acceptance import _helpers as helpers4from identityprovider.tests.acceptance import _helpers as helpers
5from _helpers import (5from identityprovider.tests.acceptance.devices._helpers import (
6 add_device,6 add_device,
7 assert_device,7 assert_device,
8 assert_no_device,8 assert_no_device,
99
=== added file 'identityprovider/tests/acceptance/views_protected.csv'
--- identityprovider/tests/acceptance/views_protected.csv 1970-01-01 00:00:00 +0000
+++ identityprovider/tests/acceptance/views_protected.csv 2012-02-09 22:30:07 +0000
@@ -0,0 +1,8 @@
1url
2'/+edit'
3'/+emails'
4'/+index'
5'/+applications'
6'/+new-email'
7'/+verify-email'
8'/+remove-email'
09
=== added file 'identityprovider/tests/acceptance/views_protected.py'
--- identityprovider/tests/acceptance/views_protected.py 1970-01-01 00:00:00 +0000
+++ identityprovider/tests/acceptance/views_protected.py 2012-02-09 22:30:07 +0000
@@ -0,0 +1,44 @@
1import sys
2from urllib import quote
3
4from canonical.isd.tests.sst import config
5from sst.actions import *
6
7import _helpers as helpers
8from devices import _helpers as dev_helpers
9
10config.set_base_url_from_env()
11
12try:
13 import _views_protected_shared as shared
14except ImportError:
15 class module(object):
16 pass
17 m = module()
18
19 m.twofactor_password = 'Admin009'
20 m.twofactor_email = helpers.register_account(password=m.twofactor_password)
21 m.aes_key = dev_helpers.add_device()
22 # Change the preference to always require 2 factor authentication and save
23 set_radio_value(get_element(tag="input", name="two-factor", value="always"))
24 click_button("authentication-prefs")
25 assert_url('/two_factor_auth?next=/device-list')
26 helpers.logout()
27
28 sys.modules['_views_protected_shared'] = m
29 import _views_protected_shared as shared
30
31# are we redirected?
32go_to(url)
33wait_for(assert_title, 'Log in')
34
35helpers.login(shared.twofactor_email, shared.twofactor_password)
36
37go_to(url)
38# should be redirected
39assert_url('/two_factor_auth?next=%s' % quote(url))
40
41helpers.logout()
42
43
44
045
=== modified file 'identityprovider/tests/unit/test_decorators.py'
--- identityprovider/tests/unit/test_decorators.py 2012-01-10 21:17:33 +0000
+++ identityprovider/tests/unit/test_decorators.py 2012-02-09 22:30:07 +0000
@@ -1,9 +1,9 @@
1from datetime import datetime1from datetime import datetime
2from django.test import TestCase2from django.test import TestCase
33
4from mock import Mock, patch4from mock import Mock, patch, MagicMock
55
6from identityprovider.decorators import ratelimit6from identityprovider.decorators import ratelimit, sso_login_required
77
88
9class RatelimitTestCase(TestCase):9class RatelimitTestCase(TestCase):
@@ -32,3 +32,75 @@
32 limiter.current_key(request)32 limiter.current_key(request)
3333
34 self.assertEqual(mock_datetime.utcnow.called, True)34 self.assertEqual(mock_datetime.utcnow.called, True)
35
36
37class SSOLoginRequiredTestCase(TestCase):
38
39 @staticmethod
40 def fake_view(request, *args, **kwargs):
41 return "SUCCESS"
42
43 def mock_request(self, authed, path='/'):
44 mock_request = MagicMock()
45 mock_request.method = 'GET'
46 mock_request.user.is_authenticated.return_value = authed
47 mock_request.POST = {}
48 mock_request.META = {}
49 mock_request.build_absolute_uri.return_value = path
50 mock_request.get_full_path.return_value = path
51 return mock_request
52
53 def test_django_login_check_still_works(self):
54 view = sso_login_required(self.fake_view, login_url='/login')
55 response = view(self.mock_request(False, '/target'))
56 self.assertEqual(response.status_code, 302)
57 self.assertEqual(response['Location'], '/login?next=/target')
58
59 @patch('identityprovider.decorators.twofactor.user_requires_twofactor_auth')
60 def test_allowed_2f_not_required(self, mock_user):
61 mock_user.return_value = False
62 view = sso_login_required(self.fake_view, login_url='/login')
63 response = view(self.mock_request(True))
64 self.assertEqual(response, 'SUCCESS')
65
66 @patch('identityprovider.decorators.twofactor.is_authenticated')
67 @patch('identityprovider.decorators.twofactor.user_requires_twofactor_auth')
68 def test_allowed_when_2f_required_and_2f_authed(self, mock_user, mock_auth):
69 mock_user.return_value = True
70 mock_auth.return_value = True
71 view = sso_login_required(self.fake_view, login_url='/login')
72 response = view(self.mock_request(True))
73 self.assertEqual(response, 'SUCCESS')
74
75 @patch('identityprovider.decorators.twofactor.is_authenticated')
76 @patch('identityprovider.decorators.twofactor.user_requires_twofactor_auth')
77 def test_redirected_when_2f_required_and_not_2f_authed(self, mock_user, mock_auth):
78 mock_user.return_value = True
79 mock_auth.return_value = False
80 view = sso_login_required(self.fake_view, login_url='/login')
81 response = view(self.mock_request(True, '/target'))
82 self.assertEqual(response.status_code, 302)
83 self.assertEqual(response['Location'], '/two_factor_auth?next=/target')
84
85 def test_decoration_no_params(self):
86 @sso_login_required
87 def view(request, *args, **kwargs):
88 return "SUCCESS"
89 response = view(self.mock_request(True, '/target'))
90 # if this works, the decorator handles this invocation properly
91 self.assertEqual(response, 'SUCCESS')
92
93 def test_decoration_with_paramss(self):
94 @sso_login_required(login_url='/alt_login')
95 def view(request, *args, **kwargs):
96 return "SUCCESS"
97 response = view(self.mock_request(True, '/target'))
98 # if this works, the decorator handles this invocation properly
99 self.assertEqual(response, 'SUCCESS')
100
101
102
103
104
105
106
35107
=== modified file 'identityprovider/tests/unit/test_views_ui.py'
--- identityprovider/tests/unit/test_views_ui.py 2012-02-09 11:47:25 +0000
+++ identityprovider/tests/unit/test_views_ui.py 2012-02-09 22:30:07 +0000
@@ -30,7 +30,7 @@
30from identityprovider.models import EmailAddress, Person, OpenIDRPConfig30from identityprovider.models import EmailAddress, Person, OpenIDRPConfig
31from identityprovider.models.const import EmailStatus31from identityprovider.models.const import EmailStatus
32from identityprovider.views import ui, server32from identityprovider.views import ui, server
33from identityprovider.models import Account, AuthToken33from identityprovider.models import Account, AuthToken, twofactor
34from identityprovider.models.authtoken import (34from identityprovider.models.authtoken import (
35 InvalidEmailException,35 InvalidEmailException,
36 create_token,36 create_token,
@@ -1180,14 +1180,14 @@
1180 data['next'] = next1180 data['next'] = next
1181 return self.client.post('/two_factor_auth', data)1181 return self.client.post('/two_factor_auth', data)
11821182
1183 @patch('identityprovider.views.ui.site_requires_twofactor_auth')1183 @patch('identityprovider.views.ui.twofactor.site_requires_twofactor_auth')
1184 def test_when_site_does_not_require_twofactor_no_oath_field(self, mock_site):1184 def test_when_site_does_not_require_twofactor_no_oath_field(self, mock_site):
1185 mock_site.return_value = False1185 mock_site.return_value = False
1186 response = self.client.get('/+login')1186 response = self.client.get('/+login')
1187 tree = PyQuery(fromstring(response.content))1187 tree = PyQuery(fromstring(response.content))
1188 self.assertEqual(len(tree.find('input[name="oath_token"]')), 0)1188 self.assertEqual(len(tree.find('input[name="oath_token"]')), 0)
11891189
1190 @patch('identityprovider.views.ui.site_requires_twofactor_auth')1190 @patch('identityprovider.views.ui.twofactor.site_requires_twofactor_auth')
1191 def test_when_site_requires_twofactor_oath_field_is_present(self, mock_site):1191 def test_when_site_requires_twofactor_oath_field_is_present(self, mock_site):
1192 mock_site.return_value = True1192 mock_site.return_value = True
1193 response = self.client.get('/+login')1193 response = self.client.get('/+login')
@@ -1197,7 +1197,7 @@
1197 oath = inputs[0]1197 oath = inputs[0]
1198 self.assertEqual(oath.attrib['autocomplete'], 'off')1198 self.assertEqual(oath.attrib['autocomplete'], 'off')
11991199
1200 @patch('identityprovider.views.ui.site_requires_twofactor_auth')1200 @patch('identityprovider.views.ui.twofactor.site_requires_twofactor_auth')
1201 def test_when_site_requires_twofactor_oath_field_is_present_lp(self, mock_site):1201 def test_when_site_requires_twofactor_oath_field_is_present_lp(self, mock_site):
1202 mock_site.return_value = True1202 mock_site.return_value = True
1203 response = self.client.get('/+login')1203 response = self.client.get('/+login')
@@ -1207,27 +1207,26 @@
1207 oath = inputs[0]1207 oath = inputs[0]
1208 self.assertEqual(oath.attrib['autocomplete'], 'off')1208 self.assertEqual(oath.attrib['autocomplete'], 'off')
12091209
12101210 @patch('identityprovider.views.ui.twofactor.user_requires_twofactor_auth')
1211 @patch('identityprovider.views.ui.user_requires_twofactor_auth')
1212 def test_when_user_requires_twofactor_redirected(self, mock_user):1211 def test_when_user_requires_twofactor_redirected(self, mock_user):
1213 mock_user.return_value = True1212 mock_user.return_value = True
1214 r = self.do_login()1213 r = self.do_login()
1215 self.assertRedirects(r, '/two_factor_auth')1214 self.assertRedirects(r, '/two_factor_auth')
12161215
1217 @patch('identityprovider.views.ui.user_requires_twofactor_auth')1216 @patch('identityprovider.views.ui.twofactor.user_requires_twofactor_auth')
1218 def test_when_user_requires_twofactor_redirected_with_token(self, mock_user):1217 def test_when_user_requires_twofactor_redirected_with_token(self, mock_user):
1219 mock_user.return_value = True1218 mock_user.return_value = True
1220 token = 'a' * 161219 token = 'a' * 16
1221 r = self.do_login(token=token)1220 r = self.do_login(token=token)
1222 self.assertRedirects(r, '/%s/two_factor_auth' % token)1221 self.assertRedirects(r, '/%s/two_factor_auth' % token)
12231222
1224 @patch('identityprovider.views.ui.user_requires_twofactor_auth')1223 @patch('identityprovider.views.ui.twofactor.user_requires_twofactor_auth')
1225 def test_when_user_requires_twofactor_redirected_with_next(self, mock_user):1224 def test_when_user_requires_twofactor_redirected_with_next(self, mock_user):
1226 mock_user.return_value = True1225 mock_user.return_value = True
1227 r = self.do_login(next='/+edit')1226 r = self.do_login(next='/+edit')
1228 self.assertRedirects(r, '/two_factor_auth?next=/%2Bedit')1227 self.assertRedirects(r, '/two_factor_auth?next=/%2Bedit')
12291228
1230 @patch('identityprovider.views.ui.site_requires_twofactor_auth')1229 @patch('identityprovider.views.ui.twofactor.site_requires_twofactor_auth')
1231 @patch('identityprovider.views.ui.authenticate_device')1230 @patch('identityprovider.views.ui.authenticate_device')
1232 def test_full_twofactor_login_redirected_to_next(self, mock_auth, mock_site):1231 def test_full_twofactor_login_redirected_to_next(self, mock_auth, mock_site):
1233 mock_auth.return_value = True1232 mock_auth.return_value = True
@@ -1236,7 +1235,7 @@
1236 mock_auth.assert_called_once_with(self.account, '123456')1235 mock_auth.assert_called_once_with(self.account, '123456')
1237 self.assertRedirects(r, '/+edit')1236 self.assertRedirects(r, '/+edit')
12381237
1239 @patch('identityprovider.views.ui.site_requires_twofactor_auth')1238 @patch('identityprovider.views.ui.twofactor.site_requires_twofactor_auth')
1240 def test_full_twofactor_login_illformed_token_displays_error(self, mock_site):1239 def test_full_twofactor_login_illformed_token_displays_error(self, mock_site):
1241 mock_site.return_value = True1240 mock_site.return_value = True
1242 response = self.do_login(next='/+edit', oath_token='XXX')1241 response = self.do_login(next='/+edit', oath_token='XXX')
@@ -1246,7 +1245,7 @@
1246 self.assertEqual(len(error), 1)1245 self.assertEqual(len(error), 1)
1247 self.assertEqual(error[0].text.strip(), "Please enter a 6-digit or 8-digit one-time password.")1246 self.assertEqual(error[0].text.strip(), "Please enter a 6-digit or 8-digit one-time password.")
12481247
1249 @patch('identityprovider.views.ui.site_requires_twofactor_auth')1248 @patch('identityprovider.views.ui.twofactor.site_requires_twofactor_auth')
1250 @patch('identityprovider.views.ui.authenticate_device')1249 @patch('identityprovider.views.ui.authenticate_device')
1251 def test_full_twofactor_login_invalid_code_displays_error(self, mock_auth, mock_site):1250 def test_full_twofactor_login_invalid_code_displays_error(self, mock_auth, mock_site):
1252 mock_auth.side_effect = AuthenticationError("ERRORMSG")1251 mock_auth.side_effect = AuthenticationError("ERRORMSG")
@@ -1314,12 +1313,12 @@
1314 def test_user_requires_twofactor_auth(self):1313 def test_user_requires_twofactor_auth(self):
1315 def f(require2f, has_devices, returns, logs):1314 def f(require2f, has_devices, returns, logs):
1316 with patch('logging.warning') as mock_warning:1315 with patch('logging.warning') as mock_warning:
1317 with patch('identityprovider.views.ui.is_twofactor_enabled') as mock_flag:1316 with patch('identityprovider.views.ui.twofactor.is_twofactor_enabled') as mock_flag:
1318 mock_flag.return_value = True1317 mock_flag.return_value = True
1319 account = Mock()1318 account = Mock()
1320 account.twofactor_required = require2f1319 account.twofactor_required = require2f
1321 account.has_twofactor_devices.return_value = has_devices1320 account.has_twofactor_devices.return_value = has_devices
1322 r = ui.user_requires_twofactor_auth(None, account)1321 r = twofactor.user_requires_twofactor_auth(None, account)
1323 self.assertEqual(returns, r)1322 self.assertEqual(returns, r)
1324 self.assertEqual(logs, mock_warning.called)1323 self.assertEqual(logs, mock_warning.called)
1325 f(require2f=False, has_devices=False, returns=False, logs=False)1324 f(require2f=False, has_devices=False, returns=False, logs=False)
13261325
=== modified file 'identityprovider/views/account.py'
--- identityprovider/views/account.py 2012-01-13 14:49:47 +0000
+++ identityprovider/views/account.py 2012-02-09 22:30:07 +0000
@@ -4,7 +4,6 @@
4from openid.yadis.constants import YADIS_HEADER_NAME, YADIS_CONTENT_TYPE4from openid.yadis.constants import YADIS_HEADER_NAME, YADIS_CONTENT_TYPE
55
6from django.conf import settings6from django.conf import settings
7from django.contrib.auth.decorators import login_required
8from django.http import HttpResponseRedirect7from django.http import HttpResponseRedirect
9from django.shortcuts import render_to_response, get_object_or_4048from django.shortcuts import render_to_response, get_object_or_404
10from django.template import RequestContext9from django.template import RequestContext
@@ -12,7 +11,7 @@
12from django.views.decorators.vary import vary_on_headers11from django.views.decorators.vary import vary_on_headers
1312
14from identityprovider.cookies import set_test_cookie, test_cookie_worked13from identityprovider.cookies import set_test_cookie, test_cookie_worked
15from identityprovider.decorators import check_readonly14from identityprovider.decorators import check_readonly, sso_login_required
16from identityprovider.forms import EditAccountForm, LoginForm, NewEmailForm15from identityprovider.forms import EditAccountForm, LoginForm, NewEmailForm
17from identityprovider.models import (send_validation_email_request,16from identityprovider.models import (send_validation_email_request,
18 EmailAddress)17 EmailAddress)
@@ -58,7 +57,7 @@
58 return set_test_cookie(render_to_response('cookies.html'))57 return set_test_cookie(render_to_response('cookies.html'))
5958
6059
61@login_required60@sso_login_required
62def account_index(request, token=None):61def account_index(request, token=None):
63 request.token = token62 request.token = token
64 account = request.user63 account = request.user
@@ -86,7 +85,7 @@
86 return render_to_response('account/edit.html', context)85 return render_to_response('account/edit.html', context)
8786
8887
89@login_required88@sso_login_required
90def account_emails(request):89def account_emails(request):
91 account = request.user90 account = request.user
92 form = NewEmailForm()91 form = NewEmailForm()
@@ -103,7 +102,7 @@
103 return render_to_response('account/emails.html', context)102 return render_to_response('account/emails.html', context)
104103
105104
106@login_required105@sso_login_required
107def account_deactivate(request, token=None):106def account_deactivate(request, token=None):
108 request.token = token107 request.token = token
109 import django.contrib.auth as auth108 import django.contrib.auth as auth
@@ -152,7 +151,7 @@
152 )151 )
153152
154153
155@login_required154@sso_login_required
156@check_readonly155@check_readonly
157def new_email(request, token=None, emailid=None):156def new_email(request, token=None, emailid=None):
158 request.token = token157 request.token = token
@@ -171,7 +170,7 @@
171 return render_to_response('account/new_email.html', context)170 return render_to_response('account/new_email.html', context)
172171
173172
174@login_required173@sso_login_required
175def verify_email(request, token=None):174def verify_email(request, token=None):
176 request.token = token175 request.token = token
177 emailid = request.GET.get('id', 0)176 emailid = request.GET.get('id', 0)
@@ -180,7 +179,7 @@
180 return _send_verification_email(request, email.email, token)179 return _send_verification_email(request, email.email, token)
181180
182181
183@login_required182@sso_login_required
184def delete_email(request, token=None):183def delete_email(request, token=None):
185 request.token = token184 request.token = token
186 emailid = request.GET.get('id', 0)185 emailid = request.GET.get('id', 0)
@@ -201,7 +200,7 @@
201 return render_to_response('account/delete_email.html', context)200 return render_to_response('account/delete_email.html', context)
202201
203202
204@login_required203@sso_login_required
205@check_readonly204@check_readonly
206def applications(request):205def applications(request):
207 if request.method == 'POST':206 if request.method == 'POST':
208207
=== modified file 'identityprovider/views/devices.py'
--- identityprovider/views/devices.py 2012-02-01 22:34:09 +0000
+++ identityprovider/views/devices.py 2012-02-09 22:30:07 +0000
@@ -4,7 +4,6 @@
4import re4import re
5from base64 import b16encode5from base64 import b16encode
66
7from django.contrib.auth.decorators import login_required
8from django.core.urlresolvers import reverse7from django.core.urlresolvers import reverse
9from django.conf import settings8from django.conf import settings
10from django.shortcuts import get_object_or_404, render_to_response9from django.shortcuts import get_object_or_404, render_to_response
@@ -18,6 +17,7 @@
18from identityprovider.models import AuthenticationDevice17from identityprovider.models import AuthenticationDevice
19from identityprovider.models.twofactor import get_otp_type18from identityprovider.models.twofactor import get_otp_type
20from identityprovider.forms import HOTPDeviceForm, DeviceRenameForm19from identityprovider.forms import HOTPDeviceForm, DeviceRenameForm
20from identityprovider.decorators import sso_login_required
2121
22from .device_urldata import *22from .device_urldata import *
23from .utils import HttpResponseRedirect, HttpResponseSeeOther, allow_only23from .utils import HttpResponseRedirect, HttpResponseSeeOther, allow_only
@@ -52,7 +52,7 @@
52 return rand_bytes(n)52 return rand_bytes(n)
5353
5454
55@login_required55@sso_login_required
56@require_twofactor56@require_twofactor
57@allow_only('GET', 'POST')57@allow_only('GET', 'POST')
58def device_list(request):58def device_list(request):
@@ -88,7 +88,7 @@
88 return render_to_response('device/list.html', context)88 return render_to_response('device/list.html', context)
8989
9090
91@login_required91@sso_login_required
92@require_twofactor92@require_twofactor
93@allow_only('GET', 'POST')93@allow_only('GET', 'POST')
94def device_addition(request):94def device_addition(request):
@@ -173,7 +173,7 @@
173173
174174
175175
176@login_required176@sso_login_required
177@require_twofactor177@require_twofactor
178@allow_only('GET', 'POST')178@allow_only('GET', 'POST')
179def device_verification(request, device_id):179def device_verification(request, device_id):
@@ -187,7 +187,7 @@
187 pass187 pass
188188
189189
190@login_required190@sso_login_required
191@require_twofactor191@require_twofactor
192@allow_only('GET', 'POST')192@allow_only('GET', 'POST')
193def device_removal(request, device_id):193def device_removal(request, device_id):
@@ -235,10 +235,10 @@
235 device_list_path=reverse(DEVICE_LIST),235 device_list_path=reverse(DEVICE_LIST),
236 form=form))236 form=form))
237237
238device_rename = login_required(DeviceRenameView.as_view())238device_rename = sso_login_required(DeviceRenameView.as_view())
239239
240240
241@login_required241@sso_login_required
242@require_twofactor242@require_twofactor
243@allow_only('GET', 'POST')243@allow_only('GET', 'POST')
244def pad_removal(request):244def pad_removal(request):
@@ -248,7 +248,7 @@
248 return HttpResponseSeeOther('/device-list')248 return HttpResponseSeeOther('/device-list')
249249
250250
251@login_required251@sso_login_required
252@require_twofactor252@require_twofactor
253@allow_only('GET', 'POST')253@allow_only('GET', 'POST')
254def device_lost(request):254def device_lost(request):
255255
=== modified file 'identityprovider/views/server.py'
--- identityprovider/views/server.py 2012-01-24 12:23:06 +0000
+++ identityprovider/views/server.py 2012-02-09 22:30:07 +0000
@@ -33,7 +33,6 @@
33import django.contrib.auth as auth33import django.contrib.auth as auth
3434
35from django.conf import settings35from django.conf import settings
36from django.contrib.auth.decorators import login_required
37from django.http import (36from django.http import (
38 Http404,37 Http404,
39 HttpResponse,38 HttpResponse,
@@ -55,6 +54,7 @@
55import identityprovider.signed as signed54import identityprovider.signed as signed
5655
57from identityprovider.const import LAUNCHPAD_TEAMS_NS56from identityprovider.const import LAUNCHPAD_TEAMS_NS
57from identityprovider.decorators import sso_login_required
58from identityprovider.forms import (58from identityprovider.forms import (
59 PreAuthorizeForm,59 PreAuthorizeForm,
60 SRegRequestForm,60 SRegRequestForm,
@@ -532,7 +532,7 @@
532 openid_response.addExtension(sreg_response)532 openid_response.addExtension(sreg_response)
533533
534534
535@login_required535@sso_login_required
536def _process_decide(request, orequest, decision):536def _process_decide(request, orequest, decision):
537 oresponse = orequest.answer(decision,537 oresponse = orequest.answer(decision,
538 identity=request.user.openid_identity_url)538 identity=request.user.openid_identity_url)
539539
=== modified file 'identityprovider/views/ui.py'
--- identityprovider/views/ui.py 2012-02-06 16:54:13 +0000
+++ identityprovider/views/ui.py 2012-02-09 22:30:07 +0000
@@ -24,12 +24,16 @@
24from django.utils.decorators import method_decorator24from django.utils.decorators import method_decorator
25from django import forms25from django import forms
2626
27from gargoyle import gargoyle
28from gargoyle.decorators import switch_is_active27from gargoyle.decorators import switch_is_active
2928
30from identityprovider.branding import current_brand29from identityprovider.branding import current_brand
31from identityprovider.decorators import (check_readonly, dont_cache,30from identityprovider.decorators import (
32 guest_required, limitlogin, requires_cookies)31 check_readonly,
32 dont_cache,
33 guest_required,
34 limitlogin,
35 requires_cookies,
36 )
33from identityprovider.forms import (37from identityprovider.forms import (
34 ConfirmNewAccountForm, 38 ConfirmNewAccountForm,
35 ForgotPasswordForm, 39 ForgotPasswordForm,
@@ -48,6 +52,7 @@
48from identityprovider.models.const import (AccountStatus, EmailStatus,52from identityprovider.models.const import (AccountStatus, EmailStatus,
49 LoginTokenType)53 LoginTokenType)
50from identityprovider.models.openidmodels import OpenIDRPConfig54from identityprovider.models.openidmodels import OpenIDRPConfig
55from identityprovider.models import twofactor
51import identityprovider.signed as signed56import identityprovider.signed as signed
52from identityprovider.utils import (57from identityprovider.utils import (
53 AuthenticationError,58 AuthenticationError,
@@ -62,7 +67,6 @@
62from identityprovider.views.utils import get_rpconfig, set_session_token_info67from identityprovider.views.utils import get_rpconfig, set_session_token_info
6368
6469
65
66logger = logging.getLogger('sso')70logger = logging.getLogger('sso')
6771
6872
@@ -91,30 +95,6 @@
91 errors.append(str(exc))95 errors.append(str(exc))
9296
9397
94def is_twofactor_enabled(request):
95 return gargoyle.is_active('TWOFACTOR', request)
96
97
98# these may need to live elsewhere, but putting here for now
99def site_requires_twofactor_auth(request, token):
100 if not is_twofactor_enabled(request):
101 return False
102 # TODO check if site requires 2f (pape?)
103 return False
104
105
106def user_requires_twofactor_auth(request, account):
107 if not is_twofactor_enabled(request):
108 return False
109 if account.twofactor_required:
110 if account.has_twofactor_devices():
111 return True
112 logging.warning('Account %s requires two-factor but has no devices' % account.openid_identifier)
113 return False
114
115
116
117
118def authenticate_user(email, password):98def authenticate_user(email, password):
119 """Attempts to authenticate a user. Returns account on success, or throws99 """Attempts to authenticate a user. Returns account on success, or throws
120 AuthenticationErrors on failure"""100 AuthenticationErrors on failure"""
@@ -191,12 +171,12 @@
191 template_name = 'registration/login.html'171 template_name = 'registration/login.html'
192172
193 def get_login_type(self, request, token):173 def get_login_type(self, request, token):
194 twofactor = site_requires_twofactor_auth(request, token)174 required = twofactor.site_requires_twofactor_auth(request, token)
195 return twofactor, TwoFactorLoginForm if twofactor else LoginForm175 return required, TwoFactorLoginForm if required else LoginForm
196176
197 def get(self, request, token=None, rpconfig=None):177 def get(self, request, token=None, rpconfig=None):
198 rpconfig = self.setup(request, token, rpconfig)178 rpconfig = self.setup(request, token, rpconfig)
199 twofactor, form_cls = self.get_login_type(request, token)179 required2f, form_cls = self.get_login_type(request, token)
200 # track login attempts180 # track login attempts
201 stats.increment('flows.login', key='requested', rpconfig=rpconfig)181 stats.increment('flows.login', key='requested', rpconfig=rpconfig)
202 return self.render(request, token, rpconfig, form_cls())182 return self.render(request, token, rpconfig, form_cls())
@@ -223,7 +203,8 @@
223 # handle case where the user's account requires two factor but we203 # handle case where the user's account requires two factor but we
224 # didn't know that until we authenticated them204 # didn't know that until we authenticated them
225 oath_token = form.cleaned_data.get('oath_token', None)205 oath_token = form.cleaned_data.get('oath_token', None)
226 if not oath_token and user_requires_twofactor_auth(request, account):206 if (not oath_token and
207 twofactor.user_requires_twofactor_auth(request, account)):
227 kwargs = {'token':token} if token else {}208 kwargs = {'token':token} if token else {}
228 url = reverse('twofactor', kwargs=kwargs)209 url = reverse('twofactor', kwargs=kwargs)
229 if next and _is_safe_redirect_url(next):210 if next and _is_safe_redirect_url(next):
@@ -234,7 +215,7 @@
234 if site_twofactor or oath_token:215 if site_twofactor or oath_token:
235 try:216 try:
236 authenticate_device(account, oath_token)217 authenticate_device(account, oath_token)
237 request.session['logged_in_via_two_factor'] = True218 twofactor.login(request)
238 except AuthenticationError as e:219 except AuthenticationError as e:
239 return self.display_errors(request, token, rpconfig, form, e)220 return self.display_errors(request, token, rpconfig, form, e)
240221
@@ -279,7 +260,7 @@
279260
280 try:261 try:
281 authenticate_device(account, form.cleaned_data['oath_token'])262 authenticate_device(account, form.cleaned_data['oath_token'])
282 request.session['logged_in_via_two_step'] = True263 twofactor.login(request)
283 stats.increment('flows.login', key='success', rpconfig=rpconfig)264 stats.increment('flows.login', key='success', rpconfig=rpconfig)
284 except AuthenticationError as e:265 except AuthenticationError as e:
285 return self.display_errors(request, token, rpconfig, form, e)266 return self.display_errors(request, token, rpconfig, form, e)
@@ -297,6 +278,7 @@
297278
298 @method_decorator(dont_cache)279 @method_decorator(dont_cache)
299 @method_decorator(limitlogin())280 @method_decorator(limitlogin())
281 # for this page, we need to be logged in but NOT twofactored
300 @method_decorator(login_required)282 @method_decorator(login_required)
301 @method_decorator(switch_is_active('TWOFACTOR'))283 @method_decorator(switch_is_active('TWOFACTOR'))
302 def dispatch(self, *args, **kwargs):284 def dispatch(self, *args, **kwargs):
@@ -380,6 +362,7 @@
380 # We don't want to lose session[token] when we log the user out362 # We don't want to lose session[token] when we log the user out
381 raw_orequest = request.session.get(token, None)363 raw_orequest = request.session.get(token, None)
382 auth.logout(request)364 auth.logout(request)
365 twofactor.logout(request)
383 self.set_orequest(request.session, token, raw_orequest)366 self.set_orequest(request.session, token, raw_orequest)
384367
385 template_file = ('%s/registration/logout.html' %368 template_file = ('%s/registration/logout.html' %