Skip to content
This repository was archived by the owner on Jan 2, 2020. It is now read-only.

Commit 4d7a38a

Browse files
author
Sriram Viswanathan
committed
[#935] Implements account recovery authenticator which extends Authenticator
with @tayanefernandes
1 parent 5d4bb90 commit 4d7a38a

8 files changed

Lines changed: 140 additions & 18 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#
2+
# Copyright (c) 2014 ThoughtWorks, Inc.
3+
#
4+
# Pixelated is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU Affero General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# Pixelated is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU Affero General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU Affero General Public License
15+
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
16+
17+
from twisted.cred.error import UnauthorizedLogin
18+
19+
from authentication import Authenticator
20+
21+
22+
class AccountRecoveryAuthenticator(Authenticator):
23+
def __init__(self, leap_provider):
24+
super(AccountRecoveryAuthenticator, self).__init__(leap_provider)
25+
26+
def _auth_error(self):
27+
raise UnauthorizedLogin("User typed wrong recovery-code/username combination.")

service/pixelated/authentication.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#
1414
# You should have received a copy of the GNU Affero General Public License
1515
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
16+
from __future__ import print_function
1617
import re
1718
from collections import namedtuple
1819

@@ -44,7 +45,7 @@ def _srp_auth(self, credentials):
4445
try:
4546
auth = yield self._bonafide_auth(credentials)
4647
except SRPAuthError:
47-
raise UnauthorizedLogin("User typed wrong password/username combination.")
48+
self._auth_error()
4849
returnValue(auth)
4950

5051
@inlineCallbacks
@@ -58,6 +59,9 @@ def _bonafide_auth(self, credentials):
5859
'session_id',
5960
{'is_admin': False}))
6061

62+
def _auth_error(self):
63+
raise UnauthorizedLogin("User typed wrong password/username combination.")
64+
6165
def clean_username(self, username):
6266
if '@' not in username:
6367
return username

service/pixelated/resources/account_recovery_resource.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,16 @@
1818
import json
1919

2020
from twisted.python.filepath import FilePath
21-
from twisted.web.http import OK, INTERNAL_SERVER_ERROR, BAD_REQUEST
21+
from twisted.web.http import OK, INTERNAL_SERVER_ERROR, BAD_REQUEST, UNAUTHORIZED
2222
from twisted.web.template import Element, XMLFile, renderElement
2323
from twisted.web.server import NOT_DONE_YET
2424
from twisted.internet import defer
2525
from twisted.logger import Logger
26+
from twisted.cred.error import UnauthorizedLogin
2627

2728
from pixelated.resources import BaseResource
2829
from pixelated.resources import get_public_static_folder
30+
from pixelated.account_recovery_authenticator import AccountRecoveryAuthenticator
2931

3032
log = Logger()
3133

@@ -49,8 +51,9 @@ class AccountRecoveryResource(BaseResource):
4951
BASE_URL = 'account-recovery'
5052
isLeaf = True
5153

52-
def __init__(self, services_factory):
54+
def __init__(self, services_factory, provider):
5355
BaseResource.__init__(self, services_factory)
56+
self._authenticator = AccountRecoveryAuthenticator(provider)
5457

5558
def render_GET(self, request):
5659
request.setResponseCode(OK)
@@ -69,6 +72,8 @@ def error_response(failure):
6972
log.warn(failure)
7073
if failure.type is InvalidPasswordError or failure.type is EmptyFieldsError:
7174
request.setResponseCode(BAD_REQUEST)
75+
elif failure.type is UnauthorizedLogin:
76+
request.setResponseCode(UNAUTHORIZED)
7277
else:
7378
request.setResponseCode(INTERNAL_SERVER_ERROR)
7479
request.finish()
@@ -80,20 +85,24 @@ def error_response(failure):
8085
def _get_post_form(self, request):
8186
return json.loads(request.content.getvalue())
8287

88+
def _validate_empty_fields(self, username, user_code):
89+
if not username or not user_code:
90+
raise EmptyFieldsError('The user entered an empty username or empty usercode')
91+
8392
def _validate_password(self, password, confirm_password):
84-
return password == confirm_password and len(password) >= 8 and len(password) <= 9999
93+
if password != confirm_password or len(password) < 8 or len(password) > 9999:
94+
raise InvalidPasswordError('The user entered an invalid password or confirmation')
8595

96+
@defer.inlineCallbacks
8697
def _handle_post(self, request):
8798
form = self._get_post_form(request)
99+
username = form.get('username')
100+
user_code = form.get('userCode')
88101
password = form.get('password')
89102
confirm_password = form.get('confirmPassword')
90103

91-
if not self._validate_password(password, confirm_password):
92-
return defer.fail(InvalidPasswordError('The user entered an invalid password or confirmation'))
93-
94-
username = form.get('username')
95-
user_code = form.get('userCode')
96-
if not username or not user_code:
97-
return defer.fail(EmptyFieldsError('The user entered an empty username or empty usercode'))
104+
self._validate_empty_fields(username, user_code)
105+
self._validate_password(password, confirm_password)
98106

99-
return defer.succeed('Done!')
107+
user_auth = yield self._authenticator.authenticate(username, user_code)
108+
defer.returnValue(user_auth)

service/pixelated/resources/login_resource.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
import os
1818
from xml.sax import SAXParseException
1919

20-
from pixelated.authentication import Authenticator
2120
from pixelated.config.leap import BootstrapUserServices
2221
from pixelated.resources import BaseResource, UnAuthorizedResource, IPixelatedSession
2322
from pixelated.resources.account_recovery_resource import AccountRecoveryResource
@@ -103,7 +102,7 @@ def getChild(self, path, request):
103102
if path == 'status':
104103
return LoginStatusResource(self._services_factory)
105104
if path == AccountRecoveryResource.BASE_URL:
106-
return AccountRecoveryResource(self._services_factory)
105+
return AccountRecoveryResource(self._services_factory, self._provider)
107106
if not self.is_logged_in(request):
108107
return UnAuthorizedResource()
109108
return NoResource()

service/pixelated/resources/root_resource.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def _is_xsrf_valid(self, request):
9292

9393
def initialize(self, provider=None, disclaimer_banner=None, authenticator=None):
9494
self._child_resources.add('assets', File(self._protected_static_folder))
95-
self._child_resources.add(AccountRecoveryResource.BASE_URL, AccountRecoveryResource(self._services_factory))
95+
self._child_resources.add(AccountRecoveryResource.BASE_URL, AccountRecoveryResource(self._services_factory, provider))
9696
self._child_resources.add('backup-account', BackupAccountResource(self._services_factory, authenticator))
9797
self._child_resources.add('sandbox', SandboxResource(self._protected_static_folder))
9898
self._child_resources.add('keys', KeysResource(self._services_factory))

service/test/unit/resources/test_account_recovery_resource.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414
# You should have received a copy of the GNU Affero General Public License
1515
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
1616

17-
from mock import MagicMock
17+
from mock import MagicMock, patch
18+
19+
from twisted.internet import defer
1820
from twisted.trial import unittest
1921
from twisted.web.test.requesthelper import DummyRequest
22+
from twisted.cred.error import UnauthorizedLogin
2023

2124
from pixelated.resources.account_recovery_resource import AccountRecoveryResource
2225
from test.unit.resources import DummySite
@@ -25,7 +28,8 @@
2528
class TestAccountRecoveryResource(unittest.TestCase):
2629
def setUp(self):
2730
self.services_factory = MagicMock()
28-
self.resource = AccountRecoveryResource(self.services_factory)
31+
self.provider = MagicMock()
32+
self.resource = AccountRecoveryResource(self.services_factory, self.provider)
2933
self.web = DummySite(self.resource)
3034

3135
def test_get(self):
@@ -40,20 +44,40 @@ def assert_200_when_user_logged_in(_):
4044
d.addCallback(assert_200_when_user_logged_in)
4145
return d
4246

43-
def test_post_returns_successfully(self):
47+
@patch('pixelated.resources.account_recovery_resource.AccountRecoveryAuthenticator.authenticate')
48+
def test_post_returns_successfully(self, mock_authenticate):
4449
request = DummyRequest(['/account-recovery'])
4550
request.method = 'POST'
4651
request.content = MagicMock()
4752
request.content.getvalue.return_value = '{"username": "alice", "userCode": "abc123", "password": "12345678", "confirmPassword": "12345678"}'
53+
mock_authenticate.return_value = defer.succeed('')
4854

4955
d = self.web.get(request)
5056

5157
def assert_successful_response(_):
5258
self.assertEqual(200, request.responseCode)
59+
mock_authenticate.assert_called_with('alice', 'abc123')
5360

5461
d.addCallback(assert_successful_response)
5562
return d
5663

64+
@patch('pixelated.resources.account_recovery_resource.AccountRecoveryAuthenticator.authenticate')
65+
def test_post_returns_unauthorized(self, mock_authenticate):
66+
request = DummyRequest(['/account-recovery'])
67+
request.method = 'POST'
68+
request.content = MagicMock()
69+
request.content.getvalue.return_value = '{"username": "alice", "userCode": "abc123", "password": "12345678", "confirmPassword": "12345678"}'
70+
mock_authenticate.return_value = defer.fail(UnauthorizedLogin())
71+
72+
d = self.web.get(request)
73+
74+
def assert_error_response(_):
75+
self.assertEqual(401, request.responseCode)
76+
mock_authenticate.assert_called_with('alice', 'abc123')
77+
78+
d.addErrback(assert_error_response)
79+
return d
80+
5781
def test_post_returns_failure_by_empty_usercode(self):
5882
request = DummyRequest(['/account-recovery'])
5983
request.method = 'POST'

service/test/unit/resources/test_login_resource.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ def assert_successful(_):
7777
d.addCallback(assert_successful)
7878
return d
7979

80+
def test_get_child_for_account_recovery_path(self):
81+
request = DummyRequest(['account-recovery'])
82+
result = self.resource.getChild('account-recovery', request)
83+
self.assertEqual(result._authenticator._leap_provider, self.portal)
84+
8085
@patch('pixelated.resources.session.PixelatedSession.is_logged_in')
8186
def test_there_are_no_grand_children_resources_when_logged_in(self, mock_is_logged_in):
8287
request = DummyRequest(['/login/grand_children'])
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#
2+
# Copyright (c) 2015 ThoughtWorks, Inc.
3+
#
4+
# Pixelated is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU Affero General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# Pixelated is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU Affero General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU Affero General Public License
15+
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
16+
17+
from twisted.cred.error import UnauthorizedLogin
18+
from twisted.trial import unittest
19+
from twisted.internet.defer import inlineCallbacks
20+
21+
from leap.bitmask.bonafide._srp import SRPAuthError
22+
23+
from mock import patch, MagicMock
24+
25+
from pixelated.account_recovery_authenticator import AccountRecoveryAuthenticator
26+
from pixelated.bitmask_libraries.provider import LeapProvider
27+
28+
PROVIDER_JSON = {
29+
"api_uri": "https://api.domain.org:4430",
30+
"api_version": "1",
31+
"ca_cert_fingerprint": "SHA256: some_stub_sha",
32+
"ca_cert_uri": "https://domain.org/ca.crt",
33+
"domain": "domain.org",
34+
}
35+
36+
37+
class AccountRecoveryAuthenticatorTest(unittest.TestCase):
38+
def setUp(self):
39+
self._domain = 'domain.org'
40+
with patch.object(LeapProvider, 'fetch_provider_json', return_value=PROVIDER_JSON):
41+
self._leap_provider = LeapProvider(self._domain)
42+
43+
@inlineCallbacks
44+
def test_bonafide_srp_exceptions_should_raise_unauthorized_login(self):
45+
account_recovery_authenticator = AccountRecoveryAuthenticator(self._leap_provider)
46+
mock_bonafide_session = MagicMock()
47+
mock_bonafide_session.authenticate = MagicMock(side_effect=SRPAuthError())
48+
with patch('pixelated.authentication.Session', return_value=mock_bonafide_session):
49+
with self.assertRaises(UnauthorizedLogin):
50+
try:
51+
yield account_recovery_authenticator.authenticate('username', 'recovery_code')
52+
except UnauthorizedLogin as e:
53+
self.assertEqual("User typed wrong recovery-code/username combination.", e.message)
54+
raise

0 commit comments

Comments
 (0)