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

[#935] Auth with recovery code #1049

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
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, recovery_session=True)

def _auth_error(self):
raise UnauthorizedLogin("User typed wrong username/recovery-code combination.")
22 changes: 13 additions & 9 deletions service/pixelated/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions service/pixelated/resources/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
43 changes: 29 additions & 14 deletions service/pixelated/resources/account_recovery_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')))

Expand All @@ -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)
Expand All @@ -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)
15 changes: 7 additions & 8 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 All @@ -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
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 All @@ -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()

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, provider))
self._child_resources.add('sandbox', SandboxResource(self._protected_static_folder))
self._child_resources.add('keys', KeysResource(self._services_factory))
Expand Down
15 changes: 8 additions & 7 deletions service/test/support/integration/multi_user_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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})
Expand All @@ -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):
Expand Down
Loading