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

Commit

Permalink
[#935] Implements account recovery authenticator which extends Authen…
Browse files Browse the repository at this point in the history
…ticator

with @tayanefernandes
  • Loading branch information
Sriram Viswanathan committed Apr 10, 2017
1 parent 5d4bb90 commit 4d7a38a
Show file tree
Hide file tree
Showing 8 changed files with 140 additions and 18 deletions.
27 changes: 27 additions & 0 deletions service/pixelated/account_recovery_authenticator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#
# Copyright (c) 2014 ThoughtWorks, Inc.
#
# Pixelated is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Pixelated is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.

from twisted.cred.error import UnauthorizedLogin

from authentication import Authenticator


class AccountRecoveryAuthenticator(Authenticator):
def __init__(self, leap_provider):
super(AccountRecoveryAuthenticator, self).__init__(leap_provider)

def _auth_error(self):
raise UnauthorizedLogin("User typed wrong recovery-code/username combination.")
6 changes: 5 additions & 1 deletion service/pixelated/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.
from __future__ import print_function
import re
from collections import namedtuple

Expand Down Expand Up @@ -44,7 +45,7 @@ def _srp_auth(self, credentials):
try:
auth = yield self._bonafide_auth(credentials)
except SRPAuthError:
raise UnauthorizedLogin("User typed wrong password/username combination.")
self._auth_error()
returnValue(auth)

@inlineCallbacks
Expand All @@ -58,6 +59,9 @@ def _bonafide_auth(self, credentials):
'session_id',
{'is_admin': False}))

def _auth_error(self):
raise UnauthorizedLogin("User typed wrong password/username combination.")

def clean_username(self, username):
if '@' not in username:
return username
Expand Down
31 changes: 20 additions & 11 deletions service/pixelated/resources/account_recovery_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@
import json

from twisted.python.filepath import FilePath
from twisted.web.http import OK, INTERNAL_SERVER_ERROR, BAD_REQUEST
from twisted.web.http import OK, INTERNAL_SERVER_ERROR, BAD_REQUEST, UNAUTHORIZED
from twisted.web.template import Element, XMLFile, renderElement
from twisted.web.server import NOT_DONE_YET
from twisted.internet import defer
from twisted.logger import Logger
from twisted.cred.error import UnauthorizedLogin

from pixelated.resources import BaseResource
from pixelated.resources import get_public_static_folder
from pixelated.account_recovery_authenticator import AccountRecoveryAuthenticator

log = Logger()

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

def __init__(self, services_factory):
def __init__(self, services_factory, provider):
BaseResource.__init__(self, services_factory)
self._authenticator = AccountRecoveryAuthenticator(provider)

def render_GET(self, request):
request.setResponseCode(OK)
Expand All @@ -69,6 +72,8 @@ def error_response(failure):
log.warn(failure)
if failure.type is InvalidPasswordError or failure.type is EmptyFieldsError:
request.setResponseCode(BAD_REQUEST)
elif failure.type is UnauthorizedLogin:
request.setResponseCode(UNAUTHORIZED)
else:
request.setResponseCode(INTERNAL_SERVER_ERROR)
request.finish()
Expand All @@ -80,20 +85,24 @@ def error_response(failure):
def _get_post_form(self, request):
return json.loads(request.content.getvalue())

def _validate_empty_fields(self, username, user_code):
if not username or not user_code:
raise EmptyFieldsError('The user entered an empty username or empty usercode')

def _validate_password(self, password, confirm_password):
return password == confirm_password and len(password) >= 8 and len(password) <= 9999
if password != confirm_password or len(password) < 8 or len(password) > 9999:
raise InvalidPasswordError('The user entered an invalid password or confirmation')

@defer.inlineCallbacks
def _handle_post(self, request):
form = self._get_post_form(request)
username = form.get('username')
user_code = form.get('userCode')
password = form.get('password')
confirm_password = form.get('confirmPassword')

if not self._validate_password(password, confirm_password):
return defer.fail(InvalidPasswordError('The user entered an invalid password or confirmation'))

username = form.get('username')
user_code = form.get('userCode')
if not username or not user_code:
return defer.fail(EmptyFieldsError('The user entered an empty username or empty usercode'))
self._validate_empty_fields(username, user_code)
self._validate_password(password, confirm_password)

return defer.succeed('Done!')
user_auth = yield self._authenticator.authenticate(username, user_code)
defer.returnValue(user_auth)
3 changes: 1 addition & 2 deletions service/pixelated/resources/login_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
import os
from xml.sax import SAXParseException

from pixelated.authentication import Authenticator
from pixelated.config.leap import BootstrapUserServices
from pixelated.resources import BaseResource, UnAuthorizedResource, IPixelatedSession
from pixelated.resources.account_recovery_resource import AccountRecoveryResource
Expand Down Expand Up @@ -103,7 +102,7 @@ def getChild(self, path, request):
if path == 'status':
return LoginStatusResource(self._services_factory)
if path == AccountRecoveryResource.BASE_URL:
return AccountRecoveryResource(self._services_factory)
return AccountRecoveryResource(self._services_factory, self._provider)
if not self.is_logged_in(request):
return UnAuthorizedResource()
return NoResource()
Expand Down
2 changes: 1 addition & 1 deletion service/pixelated/resources/root_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def _is_xsrf_valid(self, request):

def initialize(self, provider=None, disclaimer_banner=None, authenticator=None):
self._child_resources.add('assets', File(self._protected_static_folder))
self._child_resources.add(AccountRecoveryResource.BASE_URL, AccountRecoveryResource(self._services_factory))
self._child_resources.add(AccountRecoveryResource.BASE_URL, AccountRecoveryResource(self._services_factory, provider))
self._child_resources.add('backup-account', BackupAccountResource(self._services_factory, authenticator))
self._child_resources.add('sandbox', SandboxResource(self._protected_static_folder))
self._child_resources.add('keys', KeysResource(self._services_factory))
Expand Down
30 changes: 27 additions & 3 deletions service/test/unit/resources/test_account_recovery_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@
# You should have received a copy of the GNU Affero General Public License
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.

from mock import MagicMock
from mock import MagicMock, patch

from twisted.internet import defer
from twisted.trial import unittest
from twisted.web.test.requesthelper import DummyRequest
from twisted.cred.error import UnauthorizedLogin

from pixelated.resources.account_recovery_resource import AccountRecoveryResource
from test.unit.resources import DummySite
Expand All @@ -25,7 +28,8 @@
class TestAccountRecoveryResource(unittest.TestCase):
def setUp(self):
self.services_factory = MagicMock()
self.resource = AccountRecoveryResource(self.services_factory)
self.provider = MagicMock()
self.resource = AccountRecoveryResource(self.services_factory, self.provider)
self.web = DummySite(self.resource)

def test_get(self):
Expand All @@ -40,20 +44,40 @@ def assert_200_when_user_logged_in(_):
d.addCallback(assert_200_when_user_logged_in)
return d

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

d = self.web.get(request)

def assert_successful_response(_):
self.assertEqual(200, request.responseCode)
mock_authenticate.assert_called_with('alice', 'abc123')

d.addCallback(assert_successful_response)
return d

@patch('pixelated.resources.account_recovery_resource.AccountRecoveryAuthenticator.authenticate')
def test_post_returns_unauthorized(self, mock_authenticate):
request = DummyRequest(['/account-recovery'])
request.method = 'POST'
request.content = MagicMock()
request.content.getvalue.return_value = '{"username": "alice", "userCode": "abc123", "password": "12345678", "confirmPassword": "12345678"}'
mock_authenticate.return_value = defer.fail(UnauthorizedLogin())

d = self.web.get(request)

def assert_error_response(_):
self.assertEqual(401, request.responseCode)
mock_authenticate.assert_called_with('alice', 'abc123')

d.addErrback(assert_error_response)
return d

def test_post_returns_failure_by_empty_usercode(self):
request = DummyRequest(['/account-recovery'])
request.method = 'POST'
Expand Down
5 changes: 5 additions & 0 deletions service/test/unit/resources/test_login_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ def assert_successful(_):
d.addCallback(assert_successful)
return d

def test_get_child_for_account_recovery_path(self):
request = DummyRequest(['account-recovery'])
result = self.resource.getChild('account-recovery', request)
self.assertEqual(result._authenticator._leap_provider, self.portal)

@patch('pixelated.resources.session.PixelatedSession.is_logged_in')
def test_there_are_no_grand_children_resources_when_logged_in(self, mock_is_logged_in):
request = DummyRequest(['/login/grand_children'])
Expand Down
54 changes: 54 additions & 0 deletions service/test/unit/test_account_recovery_authenticator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#
# Copyright (c) 2015 ThoughtWorks, Inc.
#
# Pixelated is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Pixelated is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Pixelated. If not, see <http://www.gnu.org/licenses/>.

from twisted.cred.error import UnauthorizedLogin
from twisted.trial import unittest
from twisted.internet.defer import inlineCallbacks

from leap.bitmask.bonafide._srp import SRPAuthError

from mock import patch, MagicMock

from pixelated.account_recovery_authenticator import AccountRecoveryAuthenticator
from pixelated.bitmask_libraries.provider import LeapProvider

PROVIDER_JSON = {
"api_uri": "https://api.domain.org:4430",
"api_version": "1",
"ca_cert_fingerprint": "SHA256: some_stub_sha",
"ca_cert_uri": "https://domain.org/ca.crt",
"domain": "domain.org",
}


class AccountRecoveryAuthenticatorTest(unittest.TestCase):
def setUp(self):
self._domain = 'domain.org'
with patch.object(LeapProvider, 'fetch_provider_json', return_value=PROVIDER_JSON):
self._leap_provider = LeapProvider(self._domain)

@inlineCallbacks
def test_bonafide_srp_exceptions_should_raise_unauthorized_login(self):
account_recovery_authenticator = AccountRecoveryAuthenticator(self._leap_provider)
mock_bonafide_session = MagicMock()
mock_bonafide_session.authenticate = MagicMock(side_effect=SRPAuthError())
with patch('pixelated.authentication.Session', return_value=mock_bonafide_session):
with self.assertRaises(UnauthorizedLogin):
try:
yield account_recovery_authenticator.authenticate('username', 'recovery_code')
except UnauthorizedLogin as e:
self.assertEqual("User typed wrong recovery-code/username combination.", e.message)
raise

0 comments on commit 4d7a38a

Please sign in to comment.