diff --git a/service/pixelated/account_recovery_authenticator.py b/service/pixelated/account_recovery_authenticator.py new file mode 100644 index 000000000..c794700fd --- /dev/null +++ b/service/pixelated/account_recovery_authenticator.py @@ -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 . + +from twisted.cred.error import UnauthorizedLogin + +from authentication import Authenticator + + +class AccountRecoveryAuthenticator(Authenticator): + def __init__(self, leap_provider): + super(AccountRecoveryAuthenticator, self).__init__(leap_provider, recovery_session=True) + + def _auth_error(self): + raise UnauthorizedLogin("User typed wrong username/recovery-code combination.") diff --git a/service/pixelated/authentication.py b/service/pixelated/authentication.py index b5edbec0b..c85e8b5a3 100644 --- a/service/pixelated/authentication.py +++ b/service/pixelated/authentication.py @@ -27,37 +27,41 @@ class Authenticator(object): - def __init__(self, leap_provider): + def __init__(self, leap_provider, recovery_session=False): self._leap_provider = leap_provider self.domain = leap_provider.server_name self.bonafide_session = None + self.recovery_session = recovery_session @inlineCallbacks def authenticate(self, username, password): username = self.clean_username(username) - auth = yield self._srp_auth(username, password) + credentials = Credentials(username, password) + auth = yield self._srp_auth(credentials) returnValue(auth) @inlineCallbacks - def _srp_auth(self, username, password): + def _srp_auth(self, credentials): try: - auth = yield self._bonafide_auth(username, password) + auth = yield self._bonafide_auth(credentials) except SRPAuthError: - raise UnauthorizedLogin("User typed wrong password/username combination.") + self._auth_error() returnValue(auth) @inlineCallbacks - def _bonafide_auth(self, user, password): + def _bonafide_auth(self, credentials): srp_provider = Api(self._leap_provider.api_uri) - credentials = Credentials(user, password) self.bonafide_session = Session(credentials, srp_provider, self._leap_provider.local_ca_crt) - yield self.bonafide_session.authenticate() - returnValue(Authentication(user, + yield self.bonafide_session.authenticate(recovery=self.recovery_session) + returnValue(Authentication(credentials.username, self.bonafide_session.token, self.bonafide_session.uuid, 'session_id', {'is_admin': False})) + def _auth_error(self): + raise UnauthorizedLogin("User typed wrong username/password combination.") + def clean_username(self, username): if '@' not in username: return username diff --git a/service/pixelated/resources/__init__.py b/service/pixelated/resources/__init__.py index 61e60720e..644221988 100644 --- a/service/pixelated/resources/__init__.py +++ b/service/pixelated/resources/__init__.py @@ -17,17 +17,24 @@ import json import os -from twisted.web.http import UNAUTHORIZED +from twisted.web.http import UNAUTHORIZED, BAD_REQUEST, INTERNAL_SERVER_ERROR, SERVICE_UNAVAILABLE from twisted.web.resource import Resource from twisted.logger import Logger +from twisted.cred.error import UnauthorizedLogin from pixelated.resources.session import IPixelatedSession -from twisted.web.http import INTERNAL_SERVER_ERROR, SERVICE_UNAVAILABLE - log = Logger() +class InvalidPasswordError(Exception): + pass + + +class EmptyFieldsError(Exception): + pass + + class SetEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, set): @@ -72,6 +79,15 @@ def _get_static_folder(): return static_folder +def get_error_response_code(error_type): + status_codes = { + InvalidPasswordError: BAD_REQUEST, + EmptyFieldsError: BAD_REQUEST, + UnauthorizedLogin: UNAUTHORIZED + } + return status_codes.get(error_type, INTERNAL_SERVER_ERROR) + + class BaseResource(Resource): def __init__(self, services_factory): diff --git a/service/pixelated/resources/account_recovery_resource.py b/service/pixelated/resources/account_recovery_resource.py index 209a7693d..e17b78236 100644 --- a/service/pixelated/resources/account_recovery_resource.py +++ b/service/pixelated/resources/account_recovery_resource.py @@ -18,22 +18,20 @@ import json from twisted.python.filepath import FilePath -from twisted.web.http import OK, INTERNAL_SERVER_ERROR +from twisted.web.http import OK 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.resources import BaseResource, InvalidPasswordError, EmptyFieldsError +from pixelated.resources import get_public_static_folder, get_error_response_code +from pixelated.account_recovery_authenticator import AccountRecoveryAuthenticator log = Logger() -class InvalidPasswordError(Exception): - pass - - class AccountRecoveryPage(Element): loader = XMLFile(FilePath(os.path.join(get_public_static_folder(), 'account_recovery.html'))) @@ -45,8 +43,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) @@ -62,26 +61,42 @@ def success_response(response): request.finish() def error_response(failure): - log.warn(failure) - request.setResponseCode(INTERNAL_SERVER_ERROR) + self._log_error(failure) + response_code = get_error_response_code(failure.type) + request.setResponseCode(response_code) request.finish() d = self._handle_post(request) d.addCallbacks(success_response, error_response) return NOT_DONE_YET + def _log_error(self, error): + if error.type in [InvalidPasswordError, EmptyFieldsError, UnauthorizedLogin]: + log.info('{}'.format(error.getErrorMessage())) + else: + log.error('{}\n{}'.format(error.getErrorMessage(), error.getTraceback())) + 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 user recovery code') + 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')) + 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) diff --git a/service/pixelated/resources/login_resource.py b/service/pixelated/resources/login_resource.py index 45942ea6f..3db22ed69 100644 --- a/service/pixelated/resources/login_resource.py +++ b/service/pixelated/resources/login_resource.py @@ -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 @@ -27,7 +26,7 @@ from twisted.logger import Logger from twisted.python.filepath import FilePath from twisted.web import util -from twisted.web.http import UNAUTHORIZED, OK +from twisted.web.http import OK from twisted.web.resource import NoResource from twisted.web.server import NOT_DONE_YET from twisted.web.static import File @@ -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() @@ -128,12 +127,12 @@ def render_response(user_auth): def render_error(error): if error.type is UnauthorizedLogin: - log.info('Unauthorized login for %s. User typed wrong username/password combination.' % request.args['username'][0]) + log.info('Unauthorized login for %s. %s' % (request.args['username'][0], error.getErrorMessage())) + content = util.redirectTo("/login?auth-error", request) else: - log.error('Authentication error for %s' % request.args['username'][0]) - log.error('%s' % error) - request.setResponseCode(UNAUTHORIZED) - content = util.redirectTo("/login?auth-error", request) + log.error('Authentication error for %s: %s \n %s' % (request.args['username'][0], error.getErrorMessage(), error.getTraceback())) + content = util.redirectTo("/login?error", request) + request.write(content) request.finish() diff --git a/service/pixelated/resources/root_resource.py b/service/pixelated/resources/root_resource.py index 896bc24b7..50902b8dc 100644 --- a/service/pixelated/resources/root_resource.py +++ b/service/pixelated/resources/root_resource.py @@ -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, provider)) self._child_resources.add('sandbox', SandboxResource(self._protected_static_folder)) self._child_resources.add('keys', KeysResource(self._services_factory)) diff --git a/service/test/support/integration/multi_user_client.py b/service/test/support/integration/multi_user_client.py index 82acb2109..ccd10d915 100644 --- a/service/test/support/integration/multi_user_client.py +++ b/service/test/support/integration/multi_user_client.py @@ -16,7 +16,7 @@ from leap.bitmask.bonafide._srp import SRPAuthError from mock import patch from mockito import mock, when, any as ANY -from pixelated.authentication import Authenticator, Authentication +from pixelated.authentication import Authenticator, Authentication, Credentials from twisted.internet import defer from pixelated.application import UserAgentMode, set_up_protected_resources @@ -50,12 +50,12 @@ def start_client(self, mode=UserAgentMode(is_single_user=True)): self.credentials_checker = StubSRPChecker(leap_provider) self.resource = set_up_protected_resources(root_resource, leap_provider, self.service_factory) - def _mock_bonafide_auth(self, username, password): - if username == 'username' and password == 'password': - self.credentials_checker.add_user(username, password) - when(Authenticator)._bonafide_auth(username, password).thenReturn(self.user_auth) + def _mock_bonafide_auth(self, credentials): + if credentials.username == 'username' and credentials.password == 'password': + self.credentials_checker.add_user(credentials.username, credentials.password) + when(Authenticator)._bonafide_auth(credentials).thenReturn(self.user_auth) else: - when(Authenticator)._bonafide_auth(username, password).thenRaise(SRPAuthError) + when(Authenticator)._bonafide_auth(credentials).thenRaise(SRPAuthError) def login(self, username='username', password='password'): session = Authentication(username, 'some_user_token', 'some_user_uuid', 'session_id', {'is_admin': False}) @@ -69,7 +69,8 @@ def login(self, username='username', password='password'): self.services = self._test_account.services self.user_auth = session - self._mock_bonafide_auth(username, password) + credentials = Credentials(username, password) + self._mock_bonafide_auth(credentials) when(LeapSessionFactory).create(username, password, session).thenReturn(leap_session) with patch('mockito.invocation.AnswerSelector', AnswerSelector): diff --git a/service/test/unit/resources/test_account_recovery_resource.py b/service/test/unit/resources/test_account_recovery_resource.py index 4e26fc5bf..190bdff6a 100644 --- a/service/test/unit/resources/test_account_recovery_resource.py +++ b/service/test/unit/resources/test_account_recovery_resource.py @@ -14,19 +14,22 @@ # You should have received a copy of the GNU Affero General Public License # along with Pixelated. If not, see . -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.internet import defer +from twisted.cred.error import UnauthorizedLogin -from pixelated.resources.account_recovery_resource import AccountRecoveryResource, InvalidPasswordError +from pixelated.resources.account_recovery_resource import AccountRecoveryResource from test.unit.resources import DummySite 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): @@ -41,68 +44,78 @@ 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' - self.resource._handle_post = MagicMock(return_value=defer.succeed(None)) + 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 - def test_post_returns_failure(self): + @patch('pixelated.resources.account_recovery_resource.AccountRecoveryAuthenticator.authenticate') + def test_post_returns_unauthorized(self, mock_authenticate): request = DummyRequest(['/account-recovery']) request.method = 'POST' - self.resource._handle_post = MagicMock(return_value=defer.fail(InvalidPasswordError)) + 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(500, request.responseCode) + self.assertEqual(401, request.responseCode) + mock_authenticate.assert_called_with('alice', 'abc123') - d.addCallback(assert_error_response) + d.addErrback(assert_error_response) return d - def test_handle_post_successfully(self): - request = MagicMock() - self.resource._get_post_form = MagicMock() - self.resource._validate_password = MagicMock(return_value=True) + def test_post_returns_failure_by_empty_usercode(self): + request = DummyRequest(['/account-recovery']) + request.method = 'POST' + request.content = MagicMock() + request.content.getvalue.return_value = '{"username": "alice", "userCode": "", "password": "1234", "confirmPassword": "1234"}' - d = self.resource._handle_post(request) + d = self.web.get(request) - def assert_successful(success): - self.assertEqual(success, 'Done!') + def assert_error_response(_): + self.assertEqual(400, request.responseCode) - d.addCallback(assert_successful) + d.addCallback(assert_error_response) return d - @defer.inlineCallbacks - def test_handle_post_failed(self): - request = MagicMock() - self.resource._get_post_form = MagicMock() - self.resource._validate_password = MagicMock(return_value=False) + def test_post_returns_failure_by_password_length(self): + request = DummyRequest(['/account-recovery']) + request.method = 'POST' + request.content = MagicMock() + request.content.getvalue.return_value = '{"username": "alice", "userCode": "abc123", "password": "1234", "confirmPassword": "1234"}' + + d = self.web.get(request) - with self.assertRaises(InvalidPasswordError): - yield self.resource._handle_post(request) + def assert_error_response(_): + self.assertEqual(400, request.responseCode) - def test_get_post_form(self): - request = MagicMock() - request.content.getvalue.return_value = '{"userCode": "abc", "password": "123", "confirmPassword": "456"}' - form = self.resource._get_post_form(request) + d.addCallback(assert_error_response) + return d - self.assertEqual(form.get('userCode'), 'abc') - self.assertEqual(form.get('password'), '123') - self.assertEqual(form.get('confirmPassword'), '456') + def test_post_returns_failure_by_password_confirmation(self): + request = DummyRequest(['/account-recovery']) + request.method = 'POST' + request.content = MagicMock() + request.content.getvalue.return_value = '{"username": "alice", "userCode": "abc123", "password": "12345678", "confirmPassword": "1234"}' - def test_validate_password_successfully(self): - self.assertTrue(self.resource._validate_password('12345678', '12345678')) + d = self.web.get(request) - def test_validate_password_failed_by_confirmation(self): - self.assertFalse(self.resource._validate_password('12345678', '1234')) + def assert_error_response(_): + self.assertEqual(400, request.responseCode) - def test_validate_password_failed_by_length(self): - self.assertFalse(self.resource._validate_password('1234', '1234')) + d.addCallback(assert_error_response) + return d diff --git a/service/test/unit/resources/test_login_resource.py b/service/test/unit/resources/test_login_resource.py index eaaba1d4c..a100c45c1 100644 --- a/service/test/unit/resources/test_login_resource.py +++ b/service/test/unit/resources/test_login_resource.py @@ -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']) diff --git a/service/test/unit/test_account_recovery_authenticator.py b/service/test/unit/test_account_recovery_authenticator.py new file mode 100644 index 000000000..15755b9f6 --- /dev/null +++ b/service/test/unit/test_account_recovery_authenticator.py @@ -0,0 +1,62 @@ +# +# 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 . + +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 username/recovery-code combination.", e.message) + raise + + def test_bonafide_auth_called_with_recovery_as_true(self): + auth = AccountRecoveryAuthenticator(self._leap_provider) + mock_bonafide_session = MagicMock() + + with patch('pixelated.authentication.Session', return_value=mock_bonafide_session): + auth.authenticate('username', 'password') + mock_bonafide_session.authenticate.assert_called_with(recovery=True) diff --git a/service/test/unit/test_authentication.py b/service/test/unit/test_authenticator.py similarity index 84% rename from service/test/unit/test_authentication.py rename to service/test/unit/test_authenticator.py index 0d261685a..853494b56 100644 --- a/service/test/unit/test_authentication.py +++ b/service/test/unit/test_authenticator.py @@ -22,7 +22,7 @@ from mock import patch, Mock -from pixelated.authentication import Authenticator +from pixelated.authentication import Authenticator, Credentials from pixelated.bitmask_libraries.provider import LeapProvider from pixelated.authentication import Authentication @@ -51,33 +51,41 @@ def test_bonafide_srp_exceptions_should_raise_unauthorized_login(self): try: yield auth.authenticate('username', 'password') except UnauthorizedLogin as e: - self.assertEqual("User typed wrong password/username combination.", e.message) + self.assertEqual("User typed wrong username/password combination.", e.message) raise @inlineCallbacks def test_domain_name_is_stripped_before_making_bonafide_srp_auth(self): username_without_domain = 'username' username_with_domain = '%s@%s' % (username_without_domain, self._domain) + credentials = Credentials(username_without_domain, 'password') auth = Authenticator(self._leap_provider) with patch.object(Authenticator, '_bonafide_auth') as mock_leap_authenticate: yield auth.authenticate(username_with_domain, 'password') - mock_leap_authenticate.assert_called_once_with(username_without_domain, 'password') + mock_leap_authenticate.assert_called_once_with(credentials) @inlineCallbacks def test_successful_bonafide_auth_should_return_the_user_authentication_object(self): auth = Authenticator(self._leap_provider) mock_bonafide_session = Mock() - mock_srp_auth = Mock() - mock_srp_auth.token = 'some_token' - mock_srp_auth.uuid = 'some_uuid' - mock_bonafide_session.authenticate = Mock(return_value=mock_srp_auth) - with patch('pixelated.authentication.Session', return_value=mock_srp_auth): + mock_bonafide_session.token = 'some_token' + mock_bonafide_session.uuid = 'some_uuid' + + with patch('pixelated.authentication.Session', return_value=mock_bonafide_session): resulting_auth = yield auth.authenticate('username@domain.org', 'password') self.assertIsInstance(resulting_auth, Authentication) self.assertEquals('username', resulting_auth.username) self.assertEquals('some_token', resulting_auth.token) self.assertEquals('some_uuid', resulting_auth.uuid) - self.assertEquals(mock_srp_auth, auth.bonafide_session) + self.assertEquals(mock_bonafide_session, auth.bonafide_session) + + def test_bonafide_auth_called_with_recovery_as_false(self): + auth = Authenticator(self._leap_provider) + mock_bonafide_session = Mock() + + with patch('pixelated.authentication.Session', return_value=mock_bonafide_session): + auth.authenticate('username', 'password') + mock_bonafide_session.authenticate.assert_called_with(recovery=False) def test_username_without_domain_is_not_changed(self): username_without_domain = 'username' diff --git a/web-ui/app/locales/en_US/translation.json b/web-ui/app/locales/en_US/translation.json index 6ca72283c..7bbbad3b6 100644 --- a/web-ui/app/locales/en_US/translation.json +++ b/web-ui/app/locales/en_US/translation.json @@ -68,6 +68,7 @@ "general": "Problems talking to server", "parse": "Got invalid response from server", "auth": "Invalid email or password", + "recovery-auth": "Invalid email or recovery code", "login": { "title": "Oh, something went wrong :(", "message": "Try to login again in a few minutes. If the problem persists, contact your account administrator." diff --git a/web-ui/app/locales/pt_BR/translation.json b/web-ui/app/locales/pt_BR/translation.json index 1baf9b076..3bad56538 100644 --- a/web-ui/app/locales/pt_BR/translation.json +++ b/web-ui/app/locales/pt_BR/translation.json @@ -68,6 +68,7 @@ "general": "Problemas ao se comunicar com o servidor", "parse": "Obteve uma resposta inválida do servidor", "auth": "E-mail ou senha inválidos", + "recovery-auth": "E-mail ou código de recuperação inválidos", "login": { "title": "Ops, algo deu errado :(", "message": "Tente entrar novamente em alguns minutos. Se o problema persistir, contate o administrador da sua conta." diff --git a/web-ui/package.json b/web-ui/package.json index 2ef337a2a..af9d3cb2d 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -65,6 +65,7 @@ "node-sass": "4.5.0", "nyc": "10.1.2", "postcss-loader": "1.2.2", + "query-string": "^4.3.2", "quoted-printable": "1.0.1", "react": "15.4.2", "react-a11y": "0.3.3", diff --git a/web-ui/src/account_recovery/new_password_form/new_password_form.js b/web-ui/src/account_recovery/new_password_form/new_password_form.js index 5e1e72c9c..379f728cb 100644 --- a/web-ui/src/account_recovery/new_password_form/new_password_form.js +++ b/web-ui/src/account_recovery/new_password_form/new_password_form.js @@ -43,11 +43,16 @@ export class NewPasswordForm extends React.Component { submitHandler = (event) => { event.preventDefault(); submitForm(event, '/account-recovery', { + username: this.props.username, userCode: this.props.userCode, password: this.state.password, confirmPassword: this.state.confirmPassword - }).then(() => this.props.next()); - } + }).then((response) => { + if (response.ok) this.props.next(); + else if (response.status === 401) this.props.onError('error.recovery-auth'); + else this.props.onError('error.general'); + }); + }; handleChangePassword = (event) => { this.setState({ password: event.target.value }); @@ -105,7 +110,9 @@ NewPasswordForm.propTypes = { t: React.PropTypes.func.isRequired, next: React.PropTypes.func.isRequired, previous: React.PropTypes.func.isRequired, - userCode: React.PropTypes.string.isRequired + username: React.PropTypes.string.isRequired, + userCode: React.PropTypes.string.isRequired, + onError: React.PropTypes.func.isRequired }; export default translate('', { wait: true })(NewPasswordForm); diff --git a/web-ui/src/account_recovery/new_password_form/new_password_form.spec.js b/web-ui/src/account_recovery/new_password_form/new_password_form.spec.js index c29487a7e..6589b002e 100644 --- a/web-ui/src/account_recovery/new_password_form/new_password_form.spec.js +++ b/web-ui/src/account_recovery/new_password_form/new_password_form.spec.js @@ -8,13 +8,23 @@ describe('NewPasswordForm', () => { let newPasswordForm; let mockPrevious; let mockNext; + let mockOnError; let mockTranslations; beforeEach(() => { mockTranslations = key => key; mockPrevious = expect.createSpy(); + mockNext = expect.createSpy(); + mockOnError = expect.createSpy(); newPasswordForm = shallow( - + ); }); @@ -42,31 +52,88 @@ describe('NewPasswordForm', () => { }); describe('Submit', () => { - beforeEach((done) => { - mockNext = expect.createSpy().andCall(() => done()); - newPasswordForm = shallow( - - ); - fetchMock.post('/account-recovery', 200); + const submitForm = () => { newPasswordForm.find('InputField[name="new-password"]').simulate('change', { target: { value: '123' } }); newPasswordForm.find('InputField[name="confirm-password"]').simulate('change', { target: { value: '456' } }); newPasswordForm.find('form').simulate('submit', { preventDefault: expect.createSpy() }); - }); + }; - it('posts to account recovery', () => { - expect(fetchMock.called('/account-recovery')).toBe(true, 'POST was not called'); - }); + const createNewPasswordForm = () => { + newPasswordForm = shallow( + + ); + }; + + context('on success', () => { + beforeEach((done) => { + mockNext = expect.createSpy().andCall(() => done()); + createNewPasswordForm(); + fetchMock.post('/account-recovery', 200); + submitForm(); + }); + + it('posts to account recovery', () => { + expect(fetchMock.called('/account-recovery')).toBe(true, 'POST was not called'); + }); + + it('sends username as content', () => { + expect(fetchMock.lastOptions('/account-recovery').body).toContain('"username":"alice"'); + }); + + it('sends user code as content', () => { + expect(fetchMock.lastOptions('/account-recovery').body).toContain('"userCode":"def234"'); + }); + + it('sends password as content', () => { + expect(fetchMock.lastOptions('/account-recovery').body).toContain('"password":"123"'); + }); - it('sends user code as content', () => { - expect(fetchMock.lastOptions('/account-recovery').body).toContain('"userCode":"def234"'); + it('sends confirm password as content', () => { + expect(fetchMock.lastOptions('/account-recovery').body).toContain('"confirmPassword":"456"'); + }); + + it('calls next handler on success', () => { + expect(mockNext).toHaveBeenCalled(); + }); + + afterEach(fetchMock.restore); }); - it('sends password as content', () => { - expect(fetchMock.lastOptions('/account-recovery').body).toContain('"password":"123"'); + context('on unauthorized error', () => { + beforeEach((done) => { + mockOnError.andCall(() => done()); + createNewPasswordForm(); + fetchMock.post('/account-recovery', 401); + submitForm(); + }); + + it('shows error message on 401', () => { + expect(mockOnError).toHaveBeenCalledWith('error.recovery-auth'); + }); + + afterEach(fetchMock.restore); }); - it('sends confirm password as content', () => { - expect(fetchMock.lastOptions('/account-recovery').body).toContain('"confirmPassword":"456"'); + context('on server error', () => { + beforeEach((done) => { + mockOnError.andCall(() => done()); + createNewPasswordForm(); + fetchMock.post('/account-recovery', 500); + submitForm(); + }); + + it('shows error message on 500', () => { + expect(mockOnError).toHaveBeenCalledWith('error.general'); + }); + + afterEach(fetchMock.restore); }); }); @@ -153,9 +220,5 @@ describe('NewPasswordForm', () => { expect(newPasswordForm.find('SubmitButton').props().disabled).toBe(true); }); }); - - it('calls next handler on success', () => { - expect(mockNext).toHaveBeenCalled(); - }); }); }); diff --git a/web-ui/src/account_recovery/page.js b/web-ui/src/account_recovery/page.js index 94927a165..fd69480e6 100644 --- a/web-ui/src/account_recovery/page.js +++ b/web-ui/src/account_recovery/page.js @@ -24,6 +24,8 @@ import UserRecoveryCodeForm from 'src/account_recovery/user_recovery_code_form/u import NewPasswordForm from 'src/account_recovery/new_password_form/new_password_form'; import BackupAccountStep from 'src/account_recovery/backup_account_step/backup_account_step'; import Footer from 'src/common/footer/footer'; +import Util from 'src/common/util'; +import SnackbarNotification from 'src/common/snackbar_notification/snackbar_notification'; import 'font-awesome/scss/font-awesome.scss'; import './page.scss'; @@ -33,23 +35,29 @@ export class Page extends React.Component { constructor(props) { super(props); - this.state = { step: 0, userCode: '' }; + this.state = { step: 0, userCode: '', username: this.setUsername(), errorMessage: '' }; } + setUsername = () => (Util.getQueryParameter('username') || ''); + nextStep = (event) => { if (event) { event.preventDefault(); } this.setState({ step: this.state.step + 1 }); - } + }; previousStep = () => { this.setState({ step: this.state.step - 1 }); - } + }; saveUserCode = (event) => { this.setState({ userCode: event.target.value }); - } + }; + + errorHandler = (errorMessage) => { + this.setState({ errorMessage }); + }; steps = () => ({ 0: , @@ -64,12 +72,21 @@ export class Page extends React.Component { previous={this.previousStep} userCode={this.state.userCode} next={this.nextStep} + username={this.state.username} + onError={this.errorHandler} />), 3: - }) + }); mainContent = () => this.steps()[this.state.step]; + showSnackbarOnError = (t) => { + if (this.state.errorMessage) { + return ; + } + return undefined; // To satisfy eslint error - consistent-return + }; + render() { const t = this.props.t; return ( @@ -81,6 +98,7 @@ export class Page extends React.Component { {this.mainContent()} + {this.showSnackbarOnError(t)}