From 31f0341caae23250f506b62eb0b0e60b5883446e Mon Sep 17 00:00:00 2001 From: Pascal Cadotte Michaud Date: Tue, 10 Sep 2019 10:16:31 -0400 Subject: [PATCH] Revert "Revert "WAZO-67 refresh token"" This reverts commit 711d8f99b9c36b1605e412e3d7a355bdd5fab460. --- .gitignore | 1 + ...b15f7bd52ba_add_the_refresh_token_table.py | 38 +++++++++ ..._add_the_user_agent_and_remote_addr_to_.py | 23 ++++++ .../suite/helpers/fixtures/db.py | 2 + integration_tests/suite/test_db_token.py | 2 + integration_tests/suite/test_token.py | 69 ++++++++++++++++ wazo_auth/controller.py | 2 + wazo_auth/database/models.py | 18 +++++ wazo_auth/database/queries/__init__.py | 2 + wazo_auth/database/queries/refresh_token.py | 46 +++++++++++ wazo_auth/database/queries/token.py | 4 + wazo_auth/exceptions.py | 18 +++++ wazo_auth/helpers.py | 7 +- wazo_auth/plugins/http/tokens/api.yml | 42 +++++++++- wazo_auth/plugins/http/tokens/http.py | 33 ++++---- wazo_auth/plugins/http/tokens/plugin.py | 2 +- wazo_auth/plugins/http/tokens/schemas.py | 40 +++++++++- .../plugins/http/tokens/tests/__init__.py | 0 .../plugins/http/tokens/tests/test_schemas.py | 78 +++++++++++++++++++ wazo_auth/services/__init__.py | 2 + wazo_auth/services/authentication.py | 35 +++++++++ wazo_auth/services/email.py | 2 + .../services/tests/test_authentication.py | 40 ++++++++++ wazo_auth/services/token.py | 18 ++++- wazo_auth/tests/test_tokens.py | 4 + wazo_auth/token.py | 42 ++++++---- 26 files changed, 531 insertions(+), 39 deletions(-) create mode 100644 alembic/versions/7b15f7bd52ba_add_the_refresh_token_table.py create mode 100644 alembic/versions/7c7c2fc280ca_add_the_user_agent_and_remote_addr_to_.py create mode 100644 integration_tests/suite/test_token.py create mode 100644 wazo_auth/database/queries/refresh_token.py create mode 100644 wazo_auth/plugins/http/tokens/tests/__init__.py create mode 100644 wazo_auth/plugins/http/tokens/tests/test_schemas.py create mode 100644 wazo_auth/services/authentication.py create mode 100644 wazo_auth/services/tests/test_authentication.py diff --git a/.gitignore b/.gitignore index 8627979f..5699b4a6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ nosetests.xml pep8.txt pylint.txt pycodestyle.txt +unit-tests.xml diff --git a/alembic/versions/7b15f7bd52ba_add_the_refresh_token_table.py b/alembic/versions/7b15f7bd52ba_add_the_refresh_token_table.py new file mode 100644 index 00000000..66bb0c64 --- /dev/null +++ b/alembic/versions/7b15f7bd52ba_add_the_refresh_token_table.py @@ -0,0 +1,38 @@ +"""add the refresh_token table + +Revision ID: 7b15f7bd52ba +Revises: 7c7c2fc280ca + +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.schema import Column + +# revision identifiers, used by Alembic. +revision = '7b15f7bd52ba' +down_revision = '7c7c2fc280ca' + + +def upgrade(): + op.create_table( + 'auth_refresh_token', + Column( + 'uuid', sa.String(36), server_default=sa.text('uuid_generate_v4()'), primary_key=True, + ), + Column('client_id', sa.Text), + Column('user_uuid', sa.String(36), sa.ForeignKey('auth_user.uuid', ondelete='CASCADE')), + Column('backend', sa.Text), + Column('login', sa.Text), + Column('user_agent', sa.Text), + Column('remote_addr', sa.Text), + ) + op.create_unique_constraint( + 'auth_refresh_token_client_id_user_uuid', + 'auth_refresh_token', + ['client_id', 'user_uuid'], + ) + + +def downgrade(): + op.drop_table('auth_refresh_token') diff --git a/alembic/versions/7c7c2fc280ca_add_the_user_agent_and_remote_addr_to_.py b/alembic/versions/7c7c2fc280ca_add_the_user_agent_and_remote_addr_to_.py new file mode 100644 index 00000000..63ab6455 --- /dev/null +++ b/alembic/versions/7c7c2fc280ca_add_the_user_agent_and_remote_addr_to_.py @@ -0,0 +1,23 @@ +"""add the user-agent and remote_addr to the token + +Revision ID: 7c7c2fc280ca +Revises: 63acf2b9a7b9 + +""" + +# revision identifiers, used by Alembic. +revision = '7c7c2fc280ca' +down_revision = '63acf2b9a7b9' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.add_column('auth_token', sa.Column('user_agent', sa.Text, server_default='', default='')) + op.add_column('auth_token', sa.Column('remote_addr', sa.Text, server_default='', default='')) + + +def downgrade(): + op.drop_column('auth_token', 'remote_addr') + op.drop_column('auth_token', 'user_agent') diff --git a/integration_tests/suite/helpers/fixtures/db.py b/integration_tests/suite/helpers/fixtures/db.py index 1d6b1a59..1cee81e7 100644 --- a/integration_tests/suite/helpers/fixtures/db.py +++ b/integration_tests/suite/helpers/fixtures/db.py @@ -66,6 +66,8 @@ def wrapper(self, *args, **kwargs): 'expire_t': now + token_args.get('expiration', 120), 'acls': token_args.get('acls', []), 'metadata': token_args.get('metadata', {}), + 'user_agent': token_args.get('user_agent', ''), + 'remote_addr': token_args.get('remote_addr', ''), } session = token_args.get('session', {}) diff --git a/integration_tests/suite/test_db_token.py b/integration_tests/suite/test_db_token.py index 079e76eb..e5b0a341 100644 --- a/integration_tests/suite/test_db_token.py +++ b/integration_tests/suite/test_db_token.py @@ -45,6 +45,8 @@ def test_create(self): 'id': 42, 'msg': 'a string field', }, + 'user_agent': 'my-user-agent', + 'remote_addr': '192.168.1.1', } session = {} token_uuid, session_uuid = self._token_dao.create(body, session) diff --git a/integration_tests/suite/test_token.py b/integration_tests/suite/test_token.py new file mode 100644 index 00000000..a9329b43 --- /dev/null +++ b/integration_tests/suite/test_token.py @@ -0,0 +1,69 @@ +# Copyright 2019 The Wazo Authors (see the AUTHORS file) +# SPDX-License-Identifier: GPL-3.0-or-later + +from hamcrest import ( + assert_that, + calling, + ends_with, + equal_to, + has_entries, + has_key, + has_properties, + not_, +) +from xivo_test_helpers.hamcrest.raises import raises + +from .helpers.base import WazoAuthTestCase + + +class TestTokens(WazoAuthTestCase): + + def test_that_a_token_has_a_remote_address_and_user_agent(self): + ua = 'My Test Runner' + + post_result = self.client.token.new(expiration=1, user_agent=ua) + assert_that(post_result, has_entries(user_agent=ua, remote_addr=ends_with('.1'))) + # Docker host address are always X.X.X.1 + + get_result = self.client.token.get(post_result['token']) + assert_that(get_result, has_entries(user_agent=ua, remote_addr=ends_with('.1'))) + + def test_refresh_token(self): + client_id = 'my-test' + + result = self.client.token.new(expiration=1, access_type='offline', client_id=client_id) + assert_that(result, has_entries(refresh_token=not_(None))) + + refresh_token = result['refresh_token'] + + result = self.client.token.new( + expiration=1, + refresh_token=refresh_token, + client_id=client_id, + ) + assert_that(result, not_(has_key('refresh_token'))) + + def test_that_only_one_refresh_token_exist_for_each_user_uuid_client_id(self): + client_id = 'two-refresh-token' + + result_1 = self.client.token.new(expiration=1, access_type='offline', client_id=client_id) + result_2 = self.client.token.new(expiration=1, access_type='offline', client_id=client_id) + + assert_that(result_1['refresh_token'], equal_to(result_2['refresh_token'])) + + def test_refresh_token_with_the_wrong_client_id(self): + client_id = 'my-test' + + result = self.client.token.new(expiration=1, access_type='offline', client_id=client_id) + assert_that(result, has_entries(refresh_token=not_(None))) + + refresh_token = result['refresh_token'] + + assert_that( + calling(self.client.token.new).with_args( + expiration=1, + refresh_token=refresh_token, + client_id='another-client-id', + ), + raises(Exception).matching(has_properties(response=has_properties(status_code=401))) + ) diff --git a/wazo_auth/controller.py b/wazo_auth/controller.py index 5cdcab62..606b9fa5 100644 --- a/wazo_auth/controller.py +++ b/wazo_auth/controller.py @@ -53,6 +53,7 @@ def __init__(self, config): self._tenant_tree = services.helpers.CachedTenantTree(dao.tenant) self._token_service = services.TokenService(config, dao, self._tenant_tree, self._bus_publisher) self._backends = BackendsProxy() + authentication_service = services.AuthenticationService(dao, self._backends) email_service = services.EmailService(dao, self._tenant_tree, config, template_formatter) external_auth_service = services.ExternalAuthService( dao, self._tenant_tree, config, self._bus_publisher, config['enabled_external_auth_plugins'] @@ -96,6 +97,7 @@ def __init__(self, config): self._config['loaded_plugins'] = self._loaded_plugins_names(self._backends) dependencies = { 'api': api, + 'authentication_service': authentication_service, 'backends': self._backends, 'config': config, 'email_service': email_service, diff --git a/wazo_auth/database/models.py b/wazo_auth/database/models.py index 8c2ca123..dede9678 100644 --- a/wazo_auth/database/models.py +++ b/wazo_auth/database/models.py @@ -122,11 +122,29 @@ class Token(Base): issued_t = Column(Integer) expire_t = Column(Integer) metadata_ = Column(Text, name='metadata') + user_agent = Column(Text) + remote_addr = Column(Text) acls = relationship('ACL') session = relationship('Session') +class RefreshToken(Base): + + __tablename__ = 'auth_refresh_token' + __table_args__ = ( + UniqueConstraint('client_id', 'user_uuid'), + ) + + uuid = Column(String(36), server_default=text('uuid_generate_v4()'), primary_key=True) + client_id = Column(Text) + user_uuid = Column(String(36), ForeignKey('auth_user.uuid', ondelete='CASCADE')) + backend = Column(Text) + login = Column(Text) + user_agent = Column(Text) + remote_addr = Column(Text) + + class Session(Base): __tablename__ = 'auth_session' diff --git a/wazo_auth/database/queries/__init__.py b/wazo_auth/database/queries/__init__.py index 87d333fd..b309293d 100644 --- a/wazo_auth/database/queries/__init__.py +++ b/wazo_auth/database/queries/__init__.py @@ -10,6 +10,7 @@ from .tenant import TenantDAO from .token import TokenDAO from .user import UserDAO +from .refresh_token import RefreshTokenDAO from xivo import sqlalchemy_helper @@ -22,6 +23,7 @@ class DAO: 'external_auth': ExternalAuthDAO, 'group': GroupDAO, 'policy': PolicyDAO, + 'refresh_token': RefreshTokenDAO, 'session': SessionDAO, 'tenant': TenantDAO, 'token': TokenDAO, diff --git a/wazo_auth/database/queries/refresh_token.py b/wazo_auth/database/queries/refresh_token.py new file mode 100644 index 00000000..1ddfc9ef --- /dev/null +++ b/wazo_auth/database/queries/refresh_token.py @@ -0,0 +1,46 @@ +# Copyright 2019 The Wazo Authors (see the AUTHORS file) +# SPDX-License-Identifier: GPL-3.0-or-later + +from sqlalchemy import and_, exc + +from wazo_auth import exceptions + +from .base import BaseDAO +from ..models import RefreshToken + + +class RefreshTokenDAO(BaseDAO): + + def create(self, body): + refresh_token = RefreshToken(**body) + with self.new_session() as s: + s.add(refresh_token) + try: + s.flush() + except exc.IntegrityError as e: + if e.orig.pgcode == self._UNIQUE_CONSTRAINT_CODE: + constraint = e.orig.diag.constraint_name + if constraint == 'auth_refresh_token_client_id_user_uuid': + s.rollback() + return self._get_existing_refresh_token( + body['client_id'], body['user_uuid'] + ) + raise + + return refresh_token.uuid + + def get(self, refresh_token, client_id): + filter_ = and_(RefreshToken.client_id == client_id, RefreshToken.uuid == refresh_token) + with self.new_session() as s: + query = s.query(RefreshToken).filter(filter_) + for refresh_token in query.all(): + return {'backend_name': refresh_token.backend, 'login': refresh_token.login} + + raise exceptions.UnknownRefreshToken(refresh_token, client_id) + + def _get_existing_refresh_token(self, client_id, user_uuid): + filter_ = and_(RefreshToken.client_id == client_id, RefreshToken.user_uuid == user_uuid) + with self.new_session() as s: + query = s.query(RefreshToken).filter(filter_) + for refresh_token in query.all(): + return refresh_token.uuid diff --git a/wazo_auth/database/queries/token.py b/wazo_auth/database/queries/token.py index fd2a5ed5..ed1e48a7 100644 --- a/wazo_auth/database/queries/token.py +++ b/wazo_auth/database/queries/token.py @@ -24,6 +24,8 @@ def create(self, body, session_body): xivo_uuid=body['xivo_uuid'], issued_t=int(body['issued_t']), expire_t=int(body['expire_t']), + user_agent=body['user_agent'], + remote_addr=body['remote_addr'], metadata_=serialized_metadata, ) token.acls = [ACL(token_uuid=token.uuid, value=acl) for acl in body.get('acls') or []] @@ -56,6 +58,8 @@ def get(self, token_uuid): 'acls': [acl.value for acl in token.acls], 'metadata': json.loads(token.metadata_) if token.metadata_ else {}, 'session_uuid': token.session_uuid, + 'remote_addr': token.remote_addr, + 'user_agent': token.user_agent, } raise exceptions.UnknownTokenException() diff --git a/wazo_auth/exceptions.py b/wazo_auth/exceptions.py index 785d92c4..95c21aae 100644 --- a/wazo_auth/exceptions.py +++ b/wazo_auth/exceptions.py @@ -5,6 +5,24 @@ from xivo.rest_api_helpers import APIException +class NoSuchBackendException(Exception): + + def __init__(self, backend_name): + super().__init__(f'no such backend {backend_name}') + + +class InvalidUsernamePassword(Exception): + + def __init__(self, login): + super().__init__(f'unknown username or password for login {login}') + + +class UnknownRefreshToken(Exception): + + def __init__(self, refresh_token, client_id): + super().__init__(f'unknown refresh_token "{refresh_token}" with client_id "{client_id}"') + + class TokenServiceException(Exception): pass diff --git a/wazo_auth/helpers.py b/wazo_auth/helpers.py index a28f621d..f0829b2b 100644 --- a/wazo_auth/helpers.py +++ b/wazo_auth/helpers.py @@ -68,7 +68,12 @@ def get_token(self): return self._renew_time = time.time() + self._delay - self._threshold - self._token = self._new_token({'expiration': 3600, 'backend': 'wazo_user'}) + self._token = self._new_token({ + 'expiration': 3600, + 'backend': 'wazo_user', + 'user_agent': '', + 'remote_addr': '127.0.0.1', + }) return self._token.token diff --git a/wazo_auth/plugins/http/tokens/api.yml b/wazo_auth/plugins/http/tokens/api.yml index d215ce3b..8201b644 100644 --- a/wazo_auth/plugins/http/tokens/api.yml +++ b/wazo_auth/plugins/http/tokens/api.yml @@ -6,9 +6,18 @@ paths: produces: - application/json summary: Creates a token - description: 'Creates a valid token for the supplied username and password combination - using the specified backend. The stock backends are: ``wazo_user``, - ``ldap_user``. For more details about the backends, see http://documentation.wazo.community/en/latest/system/wazo-auth/stock_plugins.html#backends-plugins' + description: | + Creates a valid token for the supplied username and password combination or refresh_token + using the specified backend. + + The stock backends are: ``wazo_user``, ``ldap_user``. + + Creating a token with the `access_type` *offline* will also create a refresh token which can be used + to create a new token without specifying the username and password. + + The username/password and refresh_token method of authentication are mutually exclusive + + For more details about the backends, see http://documentation.wazo.community/en/latest/system/wazo-auth/stock_plugins.html#backends-plugins operationId: createToken tags: - token @@ -28,6 +37,33 @@ paths: default: wazo_user expiration: type: integer + access_type: + type: string + description: | + The `access_type` indicates whether your application can refresh the tokens when the user is not + present at the browser. Valid parameter values are *online*, which is the default value, and *offline* + + Only one refresh token will be created for a given user with a given `client_id`. The old refresh + for `client_id` will be revoken when creating a new one. + + The *client_id* field is required when using the `access_type` *offline* + default: online + enum: + - online + - offline + client_id: + type: string + description: | + The `client_id` is used in conjunction with the `access_type` *offline* to known for which application + a refresh token has been emitted. + + *Required when using `access_type: offline`* + refresh_token: + type: string + description: | + The `refresh_token` can be used to get a new access token without using the username/password. + This is useful for client application that should not store the username and password once the + user has logged in a first time. responses: '200': description: "The created token's data" diff --git a/wazo_auth/plugins/http/tokens/http.py b/wazo_auth/plugins/http/tokens/http.py index 3880d097..ee306c01 100644 --- a/wazo_auth/plugins/http/tokens/http.py +++ b/wazo_auth/plugins/http/tokens/http.py @@ -14,39 +14,40 @@ class BaseResource(http.ErrorCatchingResource): - def __init__(self, token_service, backends, user_service): - self._backends = backends + def __init__(self, token_service, user_service, authentication_service): self._token_service = token_service self._user_service = user_service + self._authentication_service = authentication_service class Tokens(BaseResource): def post(self): - if request.authorization: - login = request.authorization.username - password = request.authorization.password - else: - login = '' - password = '' + user_agent = request.headers.get('User-Agent', '') + remote_addr = request.environ.get('HTTP_X_REAL_IP', request.remote_addr) try: args = schemas.TokenRequestSchema().load(request.get_json(force=True)) except marshmallow.ValidationError as e: return http._error(400, str(e.messages)) + if request.authorization: + args['login'] = request.authorization.username + args['password'] = request.authorization.password + session_type = request.headers.get('Wazo-Session-Type', '').lower() args['mobile'] = True if session_type == 'mobile' else False + args['user_agent'] = user_agent + args['remote_addr'] = remote_addr - backend_name = args['backend'] try: - backend = self._backends[backend_name].obj - except KeyError: - logger.debug('Backend not found: "%s"', backend_name) - return http._error(401, 'Authentication Failed') - - if not backend.verify_password(login, password, args): - logger.debug('Invalid password for user "%s" in backend "%s"', login, backend_name) + backend, login = self._authentication_service.verify_auth(args) + except ( + exceptions.NoSuchBackendException, + exceptions.InvalidUsernamePassword, + exceptions.UnknownRefreshToken, + ) as e: + logger.info('failed login %s from %s %s', e, remote_addr, user_agent) return http._error(401, 'Authentication Failed') token = self._token_service.new_token(backend, login, args) diff --git a/wazo_auth/plugins/http/tokens/plugin.py b/wazo_auth/plugins/http/tokens/plugin.py index 3daac50c..4217ab4d 100644 --- a/wazo_auth/plugins/http/tokens/plugin.py +++ b/wazo_auth/plugins/http/tokens/plugin.py @@ -10,8 +10,8 @@ def load(self, dependencies): api = dependencies['api'] args = ( dependencies['token_service'], - dependencies['backends'], dependencies['user_service'], + dependencies['authentication_service'], ) api.add_resource(http.Tokens, '/token', resource_class_args=args) diff --git a/wazo_auth/plugins/http/tokens/schemas.py b/wazo_auth/plugins/http/tokens/schemas.py index 76a266f1..852513f0 100644 --- a/wazo_auth/plugins/http/tokens/schemas.py +++ b/wazo_auth/plugins/http/tokens/schemas.py @@ -1,10 +1,44 @@ -# Copyright 2017-2018 The Wazo Authors (see the AUTHORS file) +# Copyright 2017-2019 The Wazo Authors (see the AUTHORS file) # SPDX-License-Identifier: GPL-3.0-or-later -from marshmallow import Schema, fields -from marshmallow.validate import Range +from marshmallow import Schema, fields, validates_schema +from marshmallow.validate import Length, Range, OneOf +from marshmallow.exceptions import ValidationError class TokenRequestSchema(Schema): backend = fields.String(missing='wazo_user') expiration = fields.Integer(validate=Range(min=1)) + access_type = fields.String(validate=OneOf(['online', 'offline'])) + client_id = fields.String(validate=Length(min=1, max=1024)) + refresh_token = fields.String() + + @validates_schema + def check_access_type_usage(self, data): + access_type = data.get('access_type') + if access_type != 'offline': + return + + refresh_token = data.get('refresh_token') + if refresh_token: + raise ValidationError( + 'cannot use the "access_type" "offline" with a refresh token' + ) + + client_id = data.get('client_id') + if not client_id: + raise ValidationError( + '"client_id" must be specified when using "access_type" is "offline"' + ) + + @validates_schema + def check_refresh_token_usage(self, data): + refresh_token = data.get('refresh_token') + if not refresh_token: + return + + client_id = data.get('client_id') + if not client_id: + raise ValidationError( + '"client_id" must be specified when using a "refresh_token"', + ) diff --git a/wazo_auth/plugins/http/tokens/tests/__init__.py b/wazo_auth/plugins/http/tokens/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/wazo_auth/plugins/http/tokens/tests/test_schemas.py b/wazo_auth/plugins/http/tokens/tests/test_schemas.py new file mode 100644 index 00000000..60ab2dd8 --- /dev/null +++ b/wazo_auth/plugins/http/tokens/tests/test_schemas.py @@ -0,0 +1,78 @@ +# Copyright 2019 The Wazo Authors (see the AUTHORS file) +# SPDX-License-Identifier: GPL-3.0-or-later + +from unittest import TestCase + +from marshmallow.exceptions import ValidationError + +from hamcrest import assert_that, calling, has_properties, has_item, not_ +from xivo_test_helpers.hamcrest.raises import raises + +from ..schemas import TokenRequestSchema + + +class TestTokenRequestSchema(TestCase): + + def setUp(self): + self.schema = TokenRequestSchema() + + def test_invalid_expiration(self): + invalid_values = [None, True, False, 'foobar', 0] + + for value in invalid_values: + body = {'expiration': value} + assert_that( + calling(self.schema.load).with_args(body), + raises(ValidationError).matching( + has_properties(field_names=has_item('expiration')), + ), + ) + + def test_minimal_body(self): + body = {} + assert_that(calling(self.schema.load).with_args(body), not_(raises(Exception))) + + def test_that_acces_type_offline_requires_a_client_id(self): + body = {'access_type': 'offline'} + + assert_that( + calling(self.schema.load).with_args(body), + raises(ValidationError).matching( + has_properties(field_names=has_item('_schema')), + ) + ) + + def test_that_the_access_type_is_online_when_using_a_refresh_token(self): + body = {'refresh_token': 'foobar', 'client_id': 'x'} + + assert_that( + calling(self.schema.load).with_args(body), + not_(raises(Exception)), + ) + + assert_that( + calling(self.schema.load).with_args(dict(access_type='online', **body)), + not_(raises(Exception)), + ) + + assert_that( + calling(self.schema.load).with_args(dict(access_type='offline', **body)), + raises(ValidationError).matching( + has_properties(field_names=has_item('_schema')), + ) + ) + + def test_that_a_refresh_token_requires_a_client_id(self): + body = {'refresh_token': 'the-token'} + + assert_that( + calling(self.schema.load).with_args(dict(client_id='x', **body)), + not_(raises(Exception)), + ) + + assert_that( + calling(self.schema.load).with_args(body), + raises(ValidationError).matching( + has_properties(field_names=has_item('_schema')), + ) + ) diff --git a/wazo_auth/services/__init__.py b/wazo_auth/services/__init__.py index cdc19e30..01065c7f 100644 --- a/wazo_auth/services/__init__.py +++ b/wazo_auth/services/__init__.py @@ -1,6 +1,7 @@ # Copyright 2018-2019 The Wazo Authors (see the AUTHORS file) # SPDX-License-Identifier: GPL-3.0-or-later +from .authentication import AuthenticationService from .email import EmailService from .external_auth import ExternalAuthService from .group import GroupService @@ -11,6 +12,7 @@ from .user import UserService, PasswordEncrypter __all__ = [ + "AuthenticationService", "EmailService", "ExternalAuthService", "GroupService", diff --git a/wazo_auth/services/authentication.py b/wazo_auth/services/authentication.py new file mode 100644 index 00000000..148b34ca --- /dev/null +++ b/wazo_auth/services/authentication.py @@ -0,0 +1,35 @@ +# Copyright 2019 The Wazo Authors (see the AUTHORS file) +# SPDX-License-Identifier: GPL-3.0-or-later + +import logging + +from wazo_auth.exceptions import InvalidUsernamePassword, NoSuchBackendException + +logger = logging.getLogger(__name__) + + +class AuthenticationService: + + def __init__(self, dao, backends): + self._dao = dao + self._backends = backends + + def verify_auth(self, args): + refresh_token = args.get('refresh_token') + if refresh_token: + refresh_token_data = self._dao.refresh_token.get(refresh_token, args['client_id']) + backend = self._get_backend(refresh_token_data['backend_name']) + return backend, refresh_token_data['login'] + else: + backend = self._get_backend(args['backend']) + login = args.get('login', '') + if not backend.verify_password(login, args.pop('password', ''), args): + raise InvalidUsernamePassword(login) + return backend, login + + def _get_backend(self, backend_name): + try: + return self._backends[backend_name].obj + except KeyError: + logger.debug('backend not found: "%s"', backend_name) + raise NoSuchBackendException(backend_name) diff --git a/wazo_auth/services/email.py b/wazo_auth/services/email.py index aa6c2b92..0db4aab7 100644 --- a/wazo_auth/services/email.py +++ b/wazo_auth/services/email.py @@ -92,6 +92,8 @@ def _new_generic_token(self, expiration, *acls): 'expire_t': t + expiration, 'issued_t': t, 'acls': acls, + 'user_agent': 'wazo-auth-email-reset', + 'remote_addr': '', } session_payload = {} token_uuid, session_uuid = self._dao.token.create(token_payload, session_payload) diff --git a/wazo_auth/services/tests/test_authentication.py b/wazo_auth/services/tests/test_authentication.py new file mode 100644 index 00000000..c6dd8274 --- /dev/null +++ b/wazo_auth/services/tests/test_authentication.py @@ -0,0 +1,40 @@ +# Copyright 2019 The Wazo Authors (see the AUTHORS file) +# SPDX-License-Identifier: GPL-3.0-or-later + +from unittest import TestCase + +from hamcrest import assert_that, contains +from mock import sentinel as s, Mock + +from ..authentication import AuthenticationService + + +class TestAuthenticationService(TestCase): + + def setUp(self): + self.dao = Mock() + self.backend = Mock() + self.backends = { + s.backend_name: Mock(obj=self.backend), + } + + self.service = AuthenticationService(self.dao, self.backends) + + def test_verify_auth_refresh_token(self): + args = {'refresh_token': s.refresh_token, 'client_id': s.client_id} + self.dao.refresh_token.get.return_value = { + 'login': s.original_login, + 'backend_name': s.backend_name, + } + + result = self.service.verify_auth(args) + + assert_that(result, contains(self.backend, s.original_login)) + + def test_verify_auth_with_login_password(self): + args = {'backend': s.backend_name, 'login': s.login, 'password': s.password} + + self.backend.verify_password.return_value = True + result = self.service.verify_auth(args) + + assert_that(result, contains(self.backend, s.login)) diff --git a/wazo_auth/services/token.py b/wazo_auth/services/token.py index 382c6f5d..96f04230 100644 --- a/wazo_auth/services/token.py +++ b/wazo_auth/services/token.py @@ -32,7 +32,7 @@ def new_token(self, backend, login, args): logger.debug('metadata for %s: %s', login, metadata) auth_id = metadata['auth_id'] - xivo_user_uuid = metadata.get('xivo_user_uuid') + user_uuid = metadata.get('xivo_user_uuid') xivo_uuid = metadata['xivo_uuid'] args['acl_templates'] = self._get_acl_templates(args['backend']) @@ -50,14 +50,28 @@ def new_token(self, backend, login, args): token_payload = { 'auth_id': auth_id, - 'xivo_user_uuid': xivo_user_uuid, + 'xivo_user_uuid': user_uuid, 'xivo_uuid': xivo_uuid, 'expire_t': current_time + expiration, 'issued_t': current_time, 'acls': acls or [], 'metadata': metadata, + 'user_agent': args['user_agent'], + 'remote_addr': args['remote_addr'], } + if args.get('access_type', 'online') == 'offline': + body = { + 'backend': args['backend'], + 'login': args['login'], + 'client_id': args['client_id'], + 'user_uuid': metadata['uuid'], + 'user_agent': args['user_agent'], + 'remote_addr': args['remote_addr'], + } + refresh_token = self._dao.refresh_token.create(body) + token_payload['refresh_token'] = refresh_token + token_uuid, session_uuid = self._dao.token.create(token_payload, session_payload) token = Token(token_uuid, session_uuid=session_uuid, **token_payload) diff --git a/wazo_auth/tests/test_tokens.py b/wazo_auth/tests/test_tokens.py index 5a33a175..35b7dc5a 100644 --- a/wazo_auth/tests/test_tokens.py +++ b/wazo_auth/tests/test_tokens.py @@ -30,6 +30,8 @@ def setUp(self): 'auth_id': self.auth_id, 'xivo_user_uuid': self.xivo_user_uuid, } + self.user_agent = 'user-agent' + self.remote_addr = '192.168.1.1' self.token = token.Token( self.id_, @@ -41,6 +43,8 @@ def setUp(self): acls=self.acls, metadata=self.metadata, session_uuid=self.session_uuid, + user_agent=self.user_agent, + remote_addr=self.remote_addr, ) self.utc_issued_at = '2016-11-24T18:17:51.535370' self.utc_expires_at = '2016-11-24T18:18:33.535370' diff --git a/wazo_auth/token.py b/wazo_auth/token.py index cb721d67..c67fe145 100644 --- a/wazo_auth/token.py +++ b/wazo_auth/token.py @@ -18,7 +18,11 @@ class Token: - def __init__(self, id_, auth_id, xivo_user_uuid, xivo_uuid, issued_t, expire_t, acls, metadata, session_uuid): + def __init__( + self, id_, auth_id, xivo_user_uuid, xivo_uuid, issued_t, + expire_t, acls, metadata, session_uuid, user_agent, remote_addr, + refresh_token=None, + ): self.token = id_ self.auth_id = auth_id self.xivo_user_uuid = xivo_user_uuid @@ -28,6 +32,9 @@ def __init__(self, id_, auth_id, xivo_user_uuid, xivo_uuid, issued_t, expire_t, self.acls = acls self.metadata = metadata self.session_uuid = session_uuid + self.user_agent = user_agent + self.remote_addr = remote_addr + self.refresh_token = refresh_token def __eq__(self, other): return ( @@ -39,7 +46,9 @@ def __eq__(self, other): self.expire_t == other.expire_t and self.acls == other.acls and self.metadata == other.metadata and - self.session_uuid == other.session_uuid + self.session_uuid == other.session_uuid and + self.user_agent == other.user_agent and + self.remote_addr == other.remote_addr ) def __ne__(self, other): @@ -58,17 +67,24 @@ def _format_utc_time(t): return datetime.utcfromtimestamp(t).isoformat() def to_dict(self): - return {'token': self.token, - 'auth_id': self.auth_id, - 'xivo_user_uuid': self.xivo_user_uuid, - 'xivo_uuid': self.xivo_uuid, - 'issued_at': self._format_local_time(self.issued_t), - 'expires_at': self._format_local_time(self.expire_t), - 'utc_issued_at': self._format_utc_time(self.issued_t), - 'utc_expires_at': self._format_utc_time(self.expire_t), - 'acls': self.acls, - 'metadata': self.metadata, - 'session_uuid': self.session_uuid} + result = { + 'token': self.token, + 'auth_id': self.auth_id, + 'xivo_user_uuid': self.xivo_user_uuid, + 'xivo_uuid': self.xivo_uuid, + 'issued_at': self._format_local_time(self.issued_t), + 'expires_at': self._format_local_time(self.expire_t), + 'utc_issued_at': self._format_utc_time(self.issued_t), + 'utc_expires_at': self._format_utc_time(self.expire_t), + 'acls': self.acls, + 'metadata': self.metadata, + 'session_uuid': self.session_uuid, + 'remote_addr': self.remote_addr, + 'user_agent': self.user_agent, + } + if self.refresh_token: + result['refresh_token'] = self.refresh_token + return result def is_expired(self): return self.expire_t and time.time() > self.expire_t