diff --git a/auth_totp/README.rst b/auth_totp/README.rst new file mode 100644 index 000000000..6e0200525 --- /dev/null +++ b/auth_totp/README.rst @@ -0,0 +1,105 @@ +.. image:: https://img.shields.io/badge/license-LGPL--3-blue.svg + :target: http://www.gnu.org/licenses/lgpl.html + :alt: License: LGPL-3 + +==================== +MFA Support via TOTP +==================== + +This module adds support for MFA using TOTP (time-based, one-time passwords). +It allows users to enable/disable MFA and manage authentication apps/devices +via the "Change My Preferences" view and an associated wizard. + +After logging in normally, users with MFA enabled are taken to a second screen +where they have to enter a password generated by one of their authentication +apps and are presented with the option to remember the current device. This +creates a secure, HTTP-only cookie that allows subsequent logins to bypass the +MFA step. + +Installation +============ + +1. Install the PyOTP library using pip: ``pip install pyotp`` +2. Follow the standard module install process + +Configuration +============= + +By default, the trusted device cookies introduced by this module have a +``Secure`` flag and can only be sent via HTTPS. You can disable this by going +to ``Settings > Parameters > System Parameters`` and changing the +``auth_totp.secure_cookie`` key to ``0``, but this is not recommended in +production as it increases the likelihood of cookie theft via eavesdropping. + +Usage +===== + +Install and enjoy. + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/149/10.0 + +Known Issues / Roadmap +====================== + +Known Issues +------------ + +* The module does not uninstall cleanly due to an Odoo bug, leaving the + ``res.users.authenticator`` and ``res.users.device`` models partially in + place. This may be addressed at a later time via an Odoo fix or by adding + custom uninstall logic via an uninstall hook. + +Roadmap +------- + +* Make the various durations associated with the module configurable. They are + currently hard-coded as follows: + + * 15 minutes to enter an MFA confirmation code after a password log in + * 30 days before the MFA session expires and the user has to log in again + * 30 days before the trusted device cookie expires + +* Add logic to extend an MFA user's session each time it's validated, + effectively keeping it alive indefinitely as long as the user remains active +* Add device fingerprinting to the trusted device cookie and provide a way to + revoke trusted devices +* Add company-level settings for forcing all users to enable MFA and disabling + the trusted device option + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smash it by providing detailed and welcomed feedback. + +Credits +======= + +Images +------ + +* Odoo Community Association: `Icon `_. + +Contributors +------------ + +* Oleg Bulkin + +Maintainer +---------- + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +This module is maintained by the OCA. + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +To contribute to this module, please visit https://odoo-community.org. diff --git a/auth_totp/__init__.py b/auth_totp/__init__.py new file mode 100644 index 000000000..b1282bc13 --- /dev/null +++ b/auth_totp/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import controllers +from . import exceptions +from . import models +from . import wizards diff --git a/auth_totp/__manifest__.py b/auth_totp/__manifest__.py new file mode 100644 index 000000000..7516e3335 --- /dev/null +++ b/auth_totp/__manifest__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +{ + 'name': 'MFA Support', + 'summary': 'Allows users to enable MFA and add optional trusted devices', + 'version': '10.0.1.0.0', + 'category': 'Extra Tools', + 'website': 'https://laslabs.com/', + 'author': 'LasLabs, Odoo Community Association (OCA)', + 'license': 'LGPL-3', + 'application': False, + 'installable': True, + 'external_dependencies': { + 'python': ['pyotp'], + }, + 'depends': [ + 'report', + 'web', + ], + 'data': [ + 'data/ir_config_parameter.xml', + 'security/ir.model.access.csv', + 'security/res_users_authenticator_security.xml', + 'wizards/res_users_authenticator_create.xml', + 'views/auth_totp.xml', + 'views/res_users.xml', + ], +} diff --git a/auth_totp/controllers/__init__.py b/auth_totp/controllers/__init__.py new file mode 100644 index 000000000..d2ea89a29 --- /dev/null +++ b/auth_totp/controllers/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import main diff --git a/auth_totp/controllers/main.py b/auth_totp/controllers/main.py new file mode 100644 index 000000000..029474528 --- /dev/null +++ b/auth_totp/controllers/main.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from datetime import datetime, timedelta +import json +from werkzeug.contrib.securecookie import SecureCookie +from werkzeug.wrappers import Response as WerkzeugResponse +from odoo import _, http, registry, SUPERUSER_ID +from odoo.api import Environment +from odoo.http import Response, request +from odoo.addons.web.controllers.main import Home +from ..exceptions import MfaTokenInvalidError, MfaTokenExpiredError + + +class JsonSecureCookie(SecureCookie): + serialization_method = json + + +class AuthTotp(Home): + + @http.route() + def web_login(self, *args, **kwargs): + """Add MFA logic to the web_login action in Home + + Overview: + * Call web_login in Home + * Return the result of that call if the user has not logged in yet + using a password, does not have MFA enabled, or has a valid + trusted device cookie + * If none of these is true, generate a new MFA login token for the + user, log the user out, and redirect to the MFA login form + """ + + # sudo() is required because there may be no request.env.uid (likely + # since there may be no user logged in at the start of the request) + user_model_sudo = request.env['res.users'].sudo() + config_model_sudo = user_model_sudo.env['ir.config_parameter'] + + response = super(AuthTotp, self).web_login(*args, **kwargs) + + if not request.params.get('login_success'): + return response + + user = user_model_sudo.browse(request.uid) + if not user.mfa_enabled: + return response + + cookie_key = 'trusted_devices_%d' % user.id + device_cookie = request.httprequest.cookies.get(cookie_key) + if device_cookie: + secret = config_model_sudo.get_param('database.secret') + device_cookie = JsonSecureCookie.unserialize(device_cookie, secret) + if device_cookie.get('device_id') in user.trusted_device_ids.ids: + return response + + user.generate_mfa_login_token() + request.session.logout(keep_db=True) + request.params['login_success'] = False + return http.local_redirect( + '/auth_totp/login', + query={ + 'mfa_login_token': user.mfa_login_token, + 'redirect': request.params.get('redirect'), + }, + keep_hash=True, + ) + + @http.route( + '/auth_totp/login', + type='http', + auth='public', + methods=['GET'], + website=True, + ) + def mfa_login_get(self, *args, **kwargs): + return request.render('auth_totp.mfa_login', qcontext=request.params) + + @http.route('/auth_totp/login', type='http', auth='none', methods=['POST']) + def mfa_login_post(self, *args, **kwargs): + """Process MFA login attempt + + Overview: + * Try to find a user based on the MFA login token. If this doesn't + work, redirect to the password login page with an error message + * Validate the confirmation code provided by the user. If it's not + valid, redirect to the previous login step with an error message + * Generate a long-term MFA login token for the user and log the + user in using the token + * Build a trusted device cookie and add it to the response if the + trusted device option was checked + * Redirect to the provided URL or to '/web' if one was not given + """ + + # sudo() is required because there is no request.env.uid (likely since + # there is no user logged in at the start of the request) + user_model_sudo = request.env['res.users'].sudo() + device_model_sudo = user_model_sudo.env['res.users.device'] + config_model_sudo = user_model_sudo.env['ir.config_parameter'] + + token = request.params.get('mfa_login_token') + try: + user = user_model_sudo.user_from_mfa_login_token(token) + except (MfaTokenInvalidError, MfaTokenExpiredError) as exception: + return http.local_redirect( + '/web/login', + query={ + 'redirect': request.params.get('redirect'), + 'error': exception.message, + }, + keep_hash=True, + ) + + confirmation_code = request.params.get('confirmation_code') + if not user.validate_mfa_confirmation_code(confirmation_code): + return http.local_redirect( + '/auth_totp/login', + query={ + 'redirect': request.params.get('redirect'), + 'error': _( + 'Your confirmation code is not correct. Please try' + ' again.' + ), + 'mfa_login_token': token, + }, + keep_hash=True, + ) + + # These context managers trigger a safe commit, which persists the + # changes right away and is needed for the auth call + with Environment.manage(): + with registry(request.db).cursor() as temp_cr: + temp_env = Environment(temp_cr, SUPERUSER_ID, request.context) + temp_user = temp_env['res.users'].browse(user.id) + temp_user.generate_mfa_login_token(60 * 24 * 30) + token = temp_user.mfa_login_token + request.session.authenticate(request.db, user.login, token, user.id) + request.params['login_success'] = True + + redirect = request.params.get('redirect') + if not redirect: + redirect = '/web' + response = http.redirect_with_hash(redirect) + if not isinstance(response, WerkzeugResponse): + response = Response(response) + + if request.params.get('remember_device'): + device = device_model_sudo.create({'user_id': user.id}) + secret = config_model_sudo.get_param('database.secret') + device_cookie = JsonSecureCookie({'device_id': device.id}, secret) + cookie_lifetime = timedelta(days=30) + cookie_exp = datetime.utcnow() + cookie_lifetime + device_cookie = device_cookie.serialize(cookie_exp) + cookie_key = 'trusted_devices_%d' % user.id + sec_config = config_model_sudo.get_param('auth_totp.secure_cookie') + security_flag = sec_config != '0' + response.set_cookie( + cookie_key, + device_cookie, + max_age=cookie_lifetime.total_seconds(), + expires=cookie_exp, + httponly=True, + secure=security_flag, + ) + + return response diff --git a/auth_totp/data/ir_config_parameter.xml b/auth_totp/data/ir_config_parameter.xml new file mode 100644 index 000000000..9b2bf9856 --- /dev/null +++ b/auth_totp/data/ir_config_parameter.xml @@ -0,0 +1,14 @@ + + + + + + + auth_totp.secure_cookie + + + + diff --git a/auth_totp/exceptions.py b/auth_totp/exceptions.py new file mode 100644 index 000000000..40cd36dd6 --- /dev/null +++ b/auth_totp/exceptions.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo.exceptions import AccessDenied + + +class MfaTokenError(AccessDenied): + + def __init__(self, message): + super(MfaTokenError, self).__init__() + self.message = message + + +class MfaTokenInvalidError(MfaTokenError): + pass + + +class MfaTokenExpiredError(MfaTokenError): + pass diff --git a/auth_totp/models/__init__.py b/auth_totp/models/__init__.py new file mode 100644 index 000000000..af6af42d9 --- /dev/null +++ b/auth_totp/models/__init__.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import res_users +from . import res_users_authenticator +from . import res_users_device diff --git a/auth_totp/models/res_users.py b/auth_totp/models/res_users.py new file mode 100644 index 000000000..72693b3cf --- /dev/null +++ b/auth_totp/models/res_users.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from datetime import datetime, timedelta +import random +import string +from odoo import _, api, fields, models +from odoo.exceptions import AccessDenied, ValidationError +from ..exceptions import MfaTokenInvalidError, MfaTokenExpiredError + + +class ResUsers(models.Model): + _inherit = 'res.users' + + @classmethod + def _build_model(cls, pool, cr): + ModelCls = super(ResUsers, cls)._build_model(pool, cr) + ModelCls.SELF_WRITEABLE_FIELDS += ['mfa_enabled', 'authenticator_ids'] + return ModelCls + + mfa_enabled = fields.Boolean(string='MFA Enabled?') + authenticator_ids = fields.One2many( + comodel_name='res.users.authenticator', + inverse_name='user_id', + string='Authentication Apps/Devices', + help='To delete an authentication app, remove it from this list. To' + ' add a new authentication app, please use the button to the' + ' right. If the button is not present, you do not have the' + ' permissions to do this.', + ) + mfa_login_token = fields.Char() + mfa_login_token_exp = fields.Datetime() + trusted_device_ids = fields.One2many( + comodel_name='res.users.device', + inverse_name='user_id', + string='Trusted Devices', + ) + + @api.multi + @api.constrains('mfa_enabled', 'authenticator_ids') + def _check_enabled_with_authenticator(self): + for record in self: + if record.mfa_enabled and not record.authenticator_ids: + raise ValidationError(_( + 'You have MFA enabled but do not have any authentication' + ' apps/devices set up. To keep from being locked out,' + ' please add one before you activate this feature.' + )) + + @api.model + def check_credentials(self, password): + try: + return super(ResUsers, self).check_credentials(password) + except AccessDenied: + user = self.sudo().search([ + ('id', '=', self.env.uid), + ('mfa_login_token', '=', password), + ]) + user._user_from_mfa_login_token_validate() + + @api.multi + def generate_mfa_login_token(self, lifetime_mins=15): + char_set = string.ascii_letters + string.digits + + for record in self: + record.mfa_login_token = ''.join( + random.SystemRandom().choice(char_set) for __ in range(20) + ) + + expiration = datetime.now() + timedelta(minutes=lifetime_mins) + record.mfa_login_token_exp = fields.Datetime.to_string(expiration) + + @api.model + def user_from_mfa_login_token(self, token): + if not token: + raise MfaTokenInvalidError(_( + 'Your MFA login token is not valid. Please try again.' + )) + + user = self.search([('mfa_login_token', '=', token)]) + user._user_from_mfa_login_token_validate() + + return user + + @api.multi + def _user_from_mfa_login_token_validate(self): + try: + self.ensure_one() + except ValueError: + raise MfaTokenInvalidError(_( + 'Your MFA login token is not valid. Please try again.' + )) + + token_exp = fields.Datetime.from_string(self.mfa_login_token_exp) + if token_exp < datetime.now(): + raise MfaTokenExpiredError(_( + 'Your MFA login token has expired. Please try again.' + )) + + @api.multi + def validate_mfa_confirmation_code(self, confirmation_code): + self.ensure_one() + return self.authenticator_ids.validate_conf_code(confirmation_code) diff --git a/auth_totp/models/res_users_authenticator.py b/auth_totp/models/res_users_authenticator.py new file mode 100644 index 000000000..170dee477 --- /dev/null +++ b/auth_totp/models/res_users_authenticator.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import logging +from odoo import _, api, fields, models + +_logger = logging.getLogger(__name__) +try: + import pyotp +except ImportError: + _logger.debug( + 'Could not import PyOTP. Please make sure this library is available in' + ' your environment.' + ) + + +class ResUsersAuthenticator(models.Model): + _name = 'res.users.authenticator' + _description = 'MFA App/Device' + _sql_constraints = [( + 'user_id_name_uniq', + 'UNIQUE(user_id, name)', + _( + 'There is already an MFA app/device with this name associated with' + ' your account. Please pick a new name and try again.' + ), + )] + + name = fields.Char( + required=True, + readonly=True, + ) + secret_key = fields.Char( + required=True, + readonly=True, + ) + user_id = fields.Many2one( + comodel_name='res.users', + ondelete='cascade', + ) + + @api.multi + @api.constrains('user_id') + def _check_has_user(self): + self.filtered(lambda r: not r.user_id).unlink() + + @api.multi + def validate_conf_code(self, confirmation_code): + for record in self: + totp = pyotp.TOTP(record.secret_key) + if totp.verify(confirmation_code): + return True + + return False diff --git a/auth_totp/models/res_users_device.py b/auth_totp/models/res_users_device.py new file mode 100644 index 000000000..f2f4d0f17 --- /dev/null +++ b/auth_totp/models/res_users_device.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import fields, models + + +class ResUsersDevice(models.Model): + _name = 'res.users.device' + _description = 'Trusted Device for MFA Auth' + + user_id = fields.Many2one( + comodel_name='res.users', + ondelete='cascade', + required=True, + ) diff --git a/auth_totp/security/ir.model.access.csv b/auth_totp/security/ir.model.access.csv new file mode 100644 index 000000000..54c0835ab --- /dev/null +++ b/auth_totp/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +authenticator_access,MFA Authenticator - User Access,model_res_users_authenticator,base.group_user,1,1,1,1 +device_access,MFA Device - Manager Access,model_res_users_device,,0,0,0,0 diff --git a/auth_totp/security/res_users_authenticator_security.xml b/auth_totp/security/res_users_authenticator_security.xml new file mode 100644 index 000000000..5e9adc1fb --- /dev/null +++ b/auth_totp/security/res_users_authenticator_security.xml @@ -0,0 +1,29 @@ + + + + + + + MFA Authenticators - Owner Access + + [('user_id', '=?', user.id)] + + + + + + + + + MFA Authenticators - Admin Read/Unlink + + + + + + + + diff --git a/auth_totp/static/description/icon.png b/auth_totp/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/auth_totp/static/description/icon.png differ diff --git a/auth_totp/tests/__init__.py b/auth_totp/tests/__init__.py new file mode 100644 index 000000000..572c43147 --- /dev/null +++ b/auth_totp/tests/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from . import test_main +from . import test_res_users +from . import test_res_users_authenticator +from . import test_res_users_authenticator_create diff --git a/auth_totp/tests/test_main.py b/auth_totp/tests/test_main.py new file mode 100644 index 000000000..a3810fd3f --- /dev/null +++ b/auth_totp/tests/test_main.py @@ -0,0 +1,411 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from datetime import datetime +import mock +from odoo.http import Response +from odoo.tests.common import TransactionCase +from ..controllers.main import AuthTotp + +CONTROLLER_PATH = 'odoo.addons.auth_totp.controllers.main' +REQUEST_PATH = CONTROLLER_PATH + '.request' +SUPER_PATH = CONTROLLER_PATH + '.Home.web_login' +JSON_PATH = CONTROLLER_PATH + '.JsonSecureCookie' +ENVIRONMENT_PATH = CONTROLLER_PATH + '.Environment' +RESPONSE_PATH = CONTROLLER_PATH + '.Response' +DATETIME_PATH = CONTROLLER_PATH + '.datetime' +REDIRECT_PATH = CONTROLLER_PATH + '.http.redirect_with_hash' +TRANSLATE_PATH_CONT = CONTROLLER_PATH + '._' +MODEL_PATH = 'odoo.addons.auth_totp.models.res_users' +GENERATE_PATH = MODEL_PATH + '.ResUsers.generate_mfa_login_token' +VALIDATE_PATH = MODEL_PATH + '.ResUsers.validate_mfa_confirmation_code' +TRANSLATE_PATH_MOD = MODEL_PATH + '._' + + +@mock.patch(REQUEST_PATH) +class TestAuthTotp(TransactionCase): + + def setUp(self): + super(TestAuthTotp, self).setUp() + + self.test_controller = AuthTotp() + + self.test_user = self.env.ref('base.user_root') + self.env['res.users.authenticator'].create({ + 'name': 'Test Authenticator', + 'secret_key': 'iamatestsecretyo', + 'user_id': self.test_user.id, + }) + self.test_user.mfa_enabled = True + self.test_user.generate_mfa_login_token() + self.test_user.trusted_device_ids = None + + # Needed when tests are run with no prior requests (e.g. on a new DB) + patcher = mock.patch('odoo.http.request') + self.addCleanup(patcher.stop) + patcher.start() + + @mock.patch(SUPER_PATH) + def test_web_login_no_password_login(self, super_mock, request_mock): + '''Should return wrapped result of super if no password log in''' + test_response = 'Test Response' + super_mock.return_value = test_response + request_mock.params = {} + + self.assertEqual(self.test_controller.web_login().data, test_response) + + @mock.patch(SUPER_PATH) + def test_web_login_user_no_mfa(self, super_mock, request_mock): + '''Should return wrapped result of super if user did not enable MFA''' + test_response = 'Test Response' + super_mock.return_value = test_response + request_mock.params = {'login_success': True} + request_mock.env = self.env + request_mock.uid = self.test_user.id + self.test_user.mfa_enabled = False + + self.assertEqual(self.test_controller.web_login().data, test_response) + + @mock.patch(JSON_PATH) + @mock.patch(SUPER_PATH) + def test_web_login_valid_cookie(self, super_mock, json_mock, request_mock): + '''Should return wrapped result of super if valid device cookie''' + test_response = 'Test Response' + super_mock.return_value = test_response + request_mock.params = {'login_success': True} + request_mock.env = self.env + request_mock.uid = self.test_user.id + + device_model = self.env['res.users.device'] + test_device = device_model.create({'user_id': self.test_user.id}) + json_mock.unserialize().get.return_value = test_device.id + + self.assertEqual(self.test_controller.web_login().data, test_response) + + @mock.patch(SUPER_PATH) + @mock.patch(GENERATE_PATH) + def test_web_login_no_cookie(self, gen_mock, super_mock, request_mock): + '''Should respond correctly if no device cookie with expected key''' + request_mock.env = self.env + request_mock.uid = self.test_user.id + request_mock.params = { + 'login_success': True, + 'redirect': 'Test Redir', + } + self.test_user.mfa_login_token = 'Test Token' + request_mock.httprequest.cookies = {} + request_mock.reset_mock() + + test_result = self.test_controller.web_login() + gen_mock.assert_called_once_with() + request_mock.session.logout.assert_called_once_with(keep_db=True) + self.assertIn( + '/auth_totp/login?redirect=Test+Redir&mfa_login_token=Test+Token', + test_result.data, + ) + + @mock.patch(SUPER_PATH) + @mock.patch(JSON_PATH) + @mock.patch(GENERATE_PATH) + def test_web_login_bad_device_id( + self, gen_mock, json_mock, super_mock, request_mock + ): + '''Should respond correctly if invalid device_id in device cookie''' + request_mock.env = self.env + request_mock.uid = self.test_user.id + request_mock.params = { + 'login_success': True, + 'redirect': 'Test Redir', + } + self.test_user.mfa_login_token = 'Test Token' + json_mock.unserialize.return_value = {'device_id': 1} + request_mock.reset_mock() + + test_result = self.test_controller.web_login() + gen_mock.assert_called_once_with() + request_mock.session.logout.assert_called_once_with(keep_db=True) + self.assertIn( + '/auth_totp/login?redirect=Test+Redir&mfa_login_token=Test+Token', + test_result.data, + ) + + def test_mfa_login_get(self, request_mock): + '''Should render mfa_login template with correct context''' + request_mock.render.return_value = 'Test Value' + request_mock.reset_mock() + self.test_controller.mfa_login_get() + + request_mock.render.assert_called_once_with( + 'auth_totp.mfa_login', + qcontext=request_mock.params, + ) + + @mock.patch(TRANSLATE_PATH_MOD) + def test_mfa_login_post_invalid_token(self, tl_mock, request_mock): + '''Should return correct redirect if login token invalid''' + request_mock.env = self.env + request_mock.params = { + 'mfa_login_token': 'Invalid Token', + 'redirect': 'Test Redir', + } + tl_mock.side_effect = lambda arg: arg + tl_mock.reset_mock() + + test_result = self.test_controller.mfa_login_post() + tl_mock.assert_called_once() + self.assertIn('/web/login?redirect=Test+Redir', test_result.data) + self.assertIn( + '&error=Your+MFA+login+token+is+not+valid.', + test_result.data, + ) + + @mock.patch(TRANSLATE_PATH_MOD) + def test_mfa_login_post_expired_token(self, tl_mock, request_mock): + '''Should return correct redirect if login token expired''' + request_mock.env = self.env + self.test_user.generate_mfa_login_token(-1) + request_mock.params = { + 'mfa_login_token': self.test_user.mfa_login_token, + 'redirect': 'Test Redir', + } + tl_mock.side_effect = lambda arg: arg + tl_mock.reset_mock() + + test_result = self.test_controller.mfa_login_post() + tl_mock.assert_called_once() + self.assertIn('/web/login?redirect=Test+Redir', test_result.data) + self.assertIn( + '&error=Your+MFA+login+token+has+expired.', + test_result.data, + ) + + @mock.patch(TRANSLATE_PATH_CONT) + def test_mfa_login_post_invalid_conf_code(self, tl_mock, request_mock): + '''Should return correct redirect if confirmation code is invalid''' + request_mock.env = self.env + request_mock.params = { + 'mfa_login_token': self.test_user.mfa_login_token, + 'redirect': 'Test Redir', + 'confirmation_code': 'Invalid Code', + } + tl_mock.side_effect = lambda arg: arg + tl_mock.reset_mock() + + test_result = self.test_controller.mfa_login_post() + tl_mock.assert_called_once() + self.assertIn('/auth_totp/login?redirect=Test+Redir', test_result.data) + self.assertIn( + '&error=Your+confirmation+code+is+not+correct.', + test_result.data, + ) + self.assertIn( + '&mfa_login_token=%s' % self.test_user.mfa_login_token, + test_result.data, + ) + + @mock.patch(GENERATE_PATH) + @mock.patch(VALIDATE_PATH) + def test_mfa_login_post_new_token(self, val_mock, gen_mock, request_mock): + '''Should refresh user's login token w/right lifetime if info valid''' + request_mock.env = self.env + request_mock.db = self.registry.db_name + test_token = self.test_user.mfa_login_token + request_mock.params = {'mfa_login_token': test_token} + val_mock.return_value = True + gen_mock.reset_mock() + self.test_controller.mfa_login_post() + + gen_mock.assert_called_once_with(60 * 24 * 30) + + @mock.patch(ENVIRONMENT_PATH) + @mock.patch(VALIDATE_PATH) + def test_mfa_login_post_session(self, val_mock, env_mock, request_mock): + '''Should log user in with new token as password if info valid''' + request_mock.env = self.env + request_mock.db = self.registry.db_name + old_test_token = self.test_user.mfa_login_token + request_mock.params = {'mfa_login_token': old_test_token} + val_mock.return_value = True + env_mock.return_value = self.env + request_mock.reset_mock() + self.test_controller.mfa_login_post() + + new_test_token = self.test_user.mfa_login_token + request_mock.session.authenticate.assert_called_once_with( + request_mock.db, + self.test_user.login, + new_test_token, + self.test_user.id, + ) + + @mock.patch(GENERATE_PATH) + @mock.patch(VALIDATE_PATH) + def test_mfa_login_post_redirect(self, val_mock, gen_mock, request_mock): + '''Should return correct redirect if info valid and redirect present''' + request_mock.env = self.env + request_mock.db = self.registry.db_name + test_redir = 'Test Redir' + request_mock.params = { + 'mfa_login_token': self.test_user.mfa_login_token, + 'redirect': test_redir, + } + val_mock.return_value = True + + test_result = self.test_controller.mfa_login_post() + self.assertIn("window.location = '%s'" % test_redir, test_result.data) + + @mock.patch(GENERATE_PATH) + @mock.patch(VALIDATE_PATH) + def test_mfa_login_post_redir_def(self, val_mock, gen_mock, request_mock): + '''Should return redirect to /web if info valid and no redirect''' + request_mock.env = self.env + request_mock.db = self.registry.db_name + test_token = self.test_user.mfa_login_token + request_mock.params = {'mfa_login_token': test_token} + val_mock.return_value = True + + test_result = self.test_controller.mfa_login_post() + self.assertIn("window.location = '/web'", test_result.data) + + @mock.patch(GENERATE_PATH) + @mock.patch(VALIDATE_PATH) + def test_mfa_login_post_device(self, val_mock, gen_mock, request_mock): + '''Should add trusted device to user if remember flag set''' + request_mock.env = self.env + request_mock.db = self.registry.db_name + test_token = self.test_user.mfa_login_token + request_mock.params = { + 'mfa_login_token': test_token, + 'remember_device': True, + } + val_mock.return_value = True + self.test_controller.mfa_login_post() + + self.assertEqual(len(self.test_user.trusted_device_ids), 1) + + @mock.patch(RESPONSE_PATH) + @mock.patch(JSON_PATH) + @mock.patch(GENERATE_PATH) + @mock.patch(VALIDATE_PATH) + def test_mfa_login_post_cookie_werkzeug_cookie( + self, val_mock, gen_mock, json_mock, resp_mock, request_mock + ): + '''Should create Werkzeug cookie w/right info if remember flag set''' + request_mock.env = self.env + request_mock.db = self.registry.db_name + test_token = self.test_user.mfa_login_token + request_mock.params = { + 'mfa_login_token': test_token, + 'remember_device': True, + } + val_mock.return_value = True + resp_mock().__class__ = Response + json_mock.reset_mock() + self.test_controller.mfa_login_post() + + test_device = self.test_user.trusted_device_ids + config_model = self.env['ir.config_parameter'] + test_secret = config_model.get_param('database.secret') + json_mock.assert_called_once_with( + {'device_id': test_device.id}, + test_secret, + ) + + @mock.patch(DATETIME_PATH) + @mock.patch(RESPONSE_PATH) + @mock.patch(JSON_PATH) + @mock.patch(GENERATE_PATH) + @mock.patch(VALIDATE_PATH) + def test_mfa_login_post_cookie_werkzeug_cookie_exp( + self, val_mock, gen_mock, json_mock, resp_mock, dt_mock, request_mock + ): + '''Should serialize Werkzeug cookie w/right exp if remember flag set''' + request_mock.env = self.env + request_mock.db = self.registry.db_name + test_token = self.test_user.mfa_login_token + request_mock.params = { + 'mfa_login_token': test_token, + 'remember_device': True, + } + val_mock.return_value = True + dt_mock.utcnow.return_value = datetime(2016, 12, 1) + resp_mock().__class__ = Response + json_mock.reset_mock() + self.test_controller.mfa_login_post() + + json_mock().serialize.assert_called_once_with(datetime(2016, 12, 31)) + + @mock.patch(DATETIME_PATH) + @mock.patch(RESPONSE_PATH) + @mock.patch(JSON_PATH) + @mock.patch(GENERATE_PATH) + @mock.patch(VALIDATE_PATH) + def test_mfa_login_post_cookie_final_cookie( + self, val_mock, gen_mock, json_mock, resp_mock, dt_mock, request_mock + ): + '''Should add correct cookie to response if remember flag set''' + request_mock.env = self.env + request_mock.db = self.registry.db_name + test_token = self.test_user.mfa_login_token + request_mock.params = { + 'mfa_login_token': test_token, + 'remember_device': True, + } + val_mock.return_value = True + dt_mock.utcnow.return_value = datetime(2016, 12, 1) + config_model = self.env['ir.config_parameter'] + config_model.set_param('auth_totp.secure_cookie', '0') + resp_mock().__class__ = Response + resp_mock.reset_mock() + self.test_controller.mfa_login_post() + + resp_mock().set_cookie.assert_called_once_with( + 'trusted_devices_%s' % self.test_user.id, + json_mock().serialize(), + max_age=30 * 24 * 60 * 60, + expires=datetime(2016, 12, 31), + httponly=True, + secure=False, + ) + + @mock.patch(RESPONSE_PATH) + @mock.patch(GENERATE_PATH) + @mock.patch(VALIDATE_PATH) + def test_mfa_login_post_cookie_final_cookie_secure( + self, val_mock, gen_mock, resp_mock, request_mock + ): + '''Should set secure cookie if config parameter set accordingly''' + request_mock.env = self.env + request_mock.db = self.registry.db_name + test_token = self.test_user.mfa_login_token + request_mock.params = { + 'mfa_login_token': test_token, + 'remember_device': True, + } + val_mock.return_value = True + config_model = self.env['ir.config_parameter'] + config_model.set_param('auth_totp.secure_cookie', '1') + resp_mock().__class__ = Response + resp_mock.reset_mock() + self.test_controller.mfa_login_post() + + new_test_security = resp_mock().set_cookie.mock_calls[0][2]['secure'] + self.assertIs(new_test_security, True) + + @mock.patch(REDIRECT_PATH) + @mock.patch(GENERATE_PATH) + @mock.patch(VALIDATE_PATH) + def test_mfa_login_post_firefox_response_returned( + self, val_mock, gen_mock, redirect_mock, request_mock + ): + '''Should behave well if redirect returns Response (Firefox case)''' + request_mock.env = self.env + request_mock.db = self.registry.db_name + redirect_mock.return_value = Response('Test Response') + test_token = self.test_user.mfa_login_token + request_mock.params = {'mfa_login_token': test_token} + val_mock.return_value = True + + test_result = self.test_controller.mfa_login_post() + self.assertIn('Test Response', test_result.response) diff --git a/auth_totp/tests/test_res_users.py b/auth_totp/tests/test_res_users.py new file mode 100644 index 000000000..60c38cc37 --- /dev/null +++ b/auth_totp/tests/test_res_users.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from datetime import datetime +import mock +import string +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase +from ..exceptions import ( + MfaTokenError, + MfaTokenInvalidError, + MfaTokenExpiredError, +) +from ..models.res_users_authenticator import ResUsersAuthenticator + +DATETIME_PATH = 'odoo.addons.auth_totp.models.res_users.datetime' + + +class TestResUsers(TransactionCase): + + def setUp(self): + super(TestResUsers, self).setUp() + + self.test_user = self.env.ref('base.user_root') + self.test_user.mfa_enabled = False + self.test_user.authenticator_ids = False + self.env.uid = self.test_user.id + + def test_build_model_mfa_fields_in_self_writeable_list(self): + '''Should add MFA fields to list of fields users can modify for self''' + ResUsersClass = type(self.test_user) + self.assertIn('mfa_enabled', ResUsersClass.SELF_WRITEABLE_FIELDS) + self.assertIn('authenticator_ids', ResUsersClass.SELF_WRITEABLE_FIELDS) + + def test_check_enabled_with_authenticator_mfa_no_auth(self): + '''Should raise correct error if MFA enabled without authenticators''' + with self.assertRaisesRegexp(ValidationError, 'locked out'): + self.test_user.mfa_enabled = True + + def test_check_enabled_with_authenticator_no_mfa_auth(self): + '''Should not raise error if MFA not enabled with authenticators''' + try: + self.env['res.users.authenticator'].create({ + 'name': 'Test Name', + 'secret_key': 'Test Key', + 'user_id': self.test_user.id, + }) + except ValidationError: + self.fail('A ValidationError was raised and should not have been.') + + def test_check_enabled_with_authenticator_mfa_auth(self): + '''Should not raise error if MFA enabled with authenticators''' + try: + self.env['res.users.authenticator'].create({ + 'name': 'Test Name', + 'secret_key': 'Test Key', + 'user_id': self.test_user.id, + }) + self.test_user.mfa_enabled = True + except ValidationError: + self.fail('A ValidationError was raised and should not have been.') + + def test_check_credentials_no_match(self): + '''Should raise appropriate error if there is no match''' + with self.assertRaises(MfaTokenInvalidError): + self.env['res.users'].check_credentials('invalid') + + @mock.patch(DATETIME_PATH) + def test_check_credentials_expired(self, datetime_mock): + '''Should raise appropriate error if match based on expired token''' + datetime_mock.now.return_value = datetime(2016, 12, 1) + self.test_user.generate_mfa_login_token() + test_token = self.test_user.mfa_login_token + datetime_mock.now.return_value = datetime(2017, 12, 1) + + with self.assertRaises(MfaTokenExpiredError): + self.env['res.users'].check_credentials(test_token) + + def test_check_credentials_current(self): + '''Should not raise error if match based on active token''' + self.test_user.generate_mfa_login_token() + test_token = self.test_user.mfa_login_token + + try: + self.env['res.users'].check_credentials(test_token) + except MfaTokenError: + self.fail('An MfaTokenError was raised and should not have been.') + + def test_generate_mfa_login_token_token_field_content(self): + '''Should set token field to 20 char string of ASCII letters/digits''' + self.test_user.generate_mfa_login_token() + test_chars = set(string.ascii_letters + string.digits) + + self.assertEqual(len(self.test_user.mfa_login_token), 20) + self.assertTrue(set(self.test_user.mfa_login_token) <= test_chars) + + def test_generate_mfa_login_token_token_field_random(self): + '''Should set token field to new value each time''' + test_tokens = set([]) + for __ in xrange(3): + self.test_user.generate_mfa_login_token() + test_tokens.add(self.test_user.mfa_login_token) + + self.assertEqual(len(test_tokens), 3) + + @mock.patch(DATETIME_PATH) + def test_generate_mfa_login_token_exp_field_default(self, datetime_mock): + '''Should set token lifetime to 15 minutes if no argument provided''' + datetime_mock.now.return_value = datetime(2016, 12, 1) + self.test_user.generate_mfa_login_token() + + self.assertEqual( + self.test_user.mfa_login_token_exp, + '2016-12-01 00:15:00' + ) + + @mock.patch(DATETIME_PATH) + def test_generate_mfa_login_token_exp_field_custom(self, datetime_mock): + '''Should set token lifetime to value provided''' + datetime_mock.now.return_value = datetime(2016, 12, 1) + self.test_user.generate_mfa_login_token(45) + + self.assertEqual( + self.test_user.mfa_login_token_exp, + '2016-12-01 00:45:00' + ) + + def test_user_from_mfa_login_token_validate_not_singleton(self): + '''Should raise correct error when recordset is not a singleton''' + self.test_user.copy() + test_set = self.env['res.users'].search([('id', '>', 0)], limit=2) + + with self.assertRaises(MfaTokenInvalidError): + self.env['res.users']._user_from_mfa_login_token_validate() + with self.assertRaises(MfaTokenInvalidError): + test_set._user_from_mfa_login_token_validate() + + @mock.patch(DATETIME_PATH) + def test_user_from_mfa_login_token_validate_expired(self, datetime_mock): + '''Should raise correct error when record has expired token''' + datetime_mock.now.return_value = datetime(2016, 12, 1) + self.test_user.generate_mfa_login_token() + datetime_mock.now.return_value = datetime(2017, 12, 1) + + with self.assertRaises(MfaTokenExpiredError): + self.test_user._user_from_mfa_login_token_validate() + + def test_user_from_mfa_login_token_validate_current_singleton(self): + '''Should not raise error when one record with active token''' + self.test_user.generate_mfa_login_token() + + try: + self.test_user._user_from_mfa_login_token_validate() + except MfaTokenError: + self.fail('An MfaTokenError was raised and should not have been.') + + def test_user_from_mfa_login_token_match(self): + '''Should retreive correct user when there is a current match''' + self.test_user.generate_mfa_login_token() + test_token = self.test_user.mfa_login_token + + self.assertEqual( + self.env['res.users'].user_from_mfa_login_token(test_token), + self.test_user, + ) + + def test_user_from_mfa_login_token_falsy(self): + '''Should raise correct error when token is falsy''' + with self.assertRaises(MfaTokenInvalidError): + self.env['res.users'].user_from_mfa_login_token(None) + + def test_user_from_mfa_login_token_no_match(self): + '''Should raise correct error when there is no match''' + with self.assertRaises(MfaTokenInvalidError): + self.env['res.users'].user_from_mfa_login_token('Test Token') + + @mock.patch(DATETIME_PATH) + def test_user_from_mfa_login_token_match_expired(self, datetime_mock): + '''Should raise correct error when the match is expired''' + datetime_mock.now.return_value = datetime(2016, 12, 1) + self.test_user.generate_mfa_login_token() + test_token = self.test_user.mfa_login_token + datetime_mock.now.return_value = datetime(2017, 12, 1) + + with self.assertRaises(MfaTokenExpiredError): + self.env['res.users'].user_from_mfa_login_token(test_token) + + def test_validate_mfa_confirmation_code_not_singleton(self): + '''Should raise correct error when recordset is not singleton''' + test_user_2 = self.env['res.users'] + test_user_3 = self.env.ref('base.public_user') + test_set = self.test_user + test_user_3 + + with self.assertRaisesRegexp(ValueError, 'Expected singleton'): + test_user_2.validate_mfa_confirmation_code('Test Code') + with self.assertRaisesRegexp(ValueError, 'Expected singleton'): + test_set.validate_mfa_confirmation_code('Test Code') + + @mock.patch.object(ResUsersAuthenticator, 'validate_conf_code') + def test_validate_mfa_confirmation_code_singleton_return(self, mock_func): + '''Should return validate_conf_code() value if singleton recordset''' + mock_func.return_value = 'Test Result' + + self.assertEqual( + self.test_user.validate_mfa_confirmation_code('Test Code'), + 'Test Result', + ) diff --git a/auth_totp/tests/test_res_users_authenticator.py b/auth_totp/tests/test_res_users_authenticator.py new file mode 100644 index 000000000..82d2f126d --- /dev/null +++ b/auth_totp/tests/test_res_users_authenticator.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import mock +from odoo.tests.common import TransactionCase + +MOCK_PATH = 'odoo.addons.auth_totp.models.res_users_authenticator.pyotp' + + +class TestResUsersAuthenticator(TransactionCase): + + def _new_authenticator(self, extra_values=None): + base_values = { + 'name': 'Test Name', + 'secret_key': 'Test Key', + 'user_id': self.env.ref('base.user_root').id, + } + if extra_values is not None: + base_values.update(extra_values) + + return self.env['res.users.authenticator'].create(base_values) + + def test_check_has_user(self): + '''Should delete record when it no longer has a user_id''' + test_auth = self._new_authenticator() + test_auth.user_id = False + + self.assertFalse(test_auth.exists()) + + def test_validate_conf_code_empty_recordset(self): + '''Should return False if recordset is empty''' + test_auth = self.env['res.users.authenticator'] + + self.assertFalse(test_auth.validate_conf_code('Test Code')) + + @mock.patch(MOCK_PATH) + def test_validate_conf_code_match(self, pyotp_mock): + '''Should return True if code matches at least one record in set''' + test_auth = self._new_authenticator() + test_auth_2 = self._new_authenticator({'name': 'Test Name 2'}) + test_set = test_auth + test_auth_2 + + pyotp_mock.TOTP().verify.side_effect = (True, False) + self.assertTrue(test_set.validate_conf_code('Test Code')) + pyotp_mock.TOTP().verify.side_effect = (True, True) + self.assertTrue(test_set.validate_conf_code('Test Code')) + + @mock.patch(MOCK_PATH) + def test_validate_conf_code_no_match(self, pyotp_mock): + '''Should return False if code does not match any records in set''' + test_auth = self._new_authenticator() + pyotp_mock.TOTP().verify.return_value = False + + self.assertFalse(test_auth.validate_conf_code('Test Code')) + + @mock.patch(MOCK_PATH) + def test_validate_conf_code_pyotp_use(self, pyotp_mock): + '''Should call PyOTP 2x/record with correct arguments until match''' + test_auth = self._new_authenticator() + test_auth_2 = self._new_authenticator({ + 'name': 'Test Name 2', + 'secret_key': 'Test Key 2', + }) + test_auth_3 = self._new_authenticator({ + 'name': 'Test Name 3', + 'secret_key': 'Test Key 3', + }) + test_set = test_auth + test_auth_2 + test_auth_3 + pyotp_mock.TOTP().verify.side_effect = (False, True, True) + pyotp_mock.reset_mock() + test_set.validate_conf_code('Test Code') + + pyotp_calls = [ + mock.call('Test Key'), + mock.call().verify('Test Code'), + mock.call('Test Key 2'), + mock.call().verify('Test Code'), + ] + self.assertEqual(pyotp_mock.TOTP.mock_calls, pyotp_calls) diff --git a/auth_totp/tests/test_res_users_authenticator_create.py b/auth_totp/tests/test_res_users_authenticator_create.py new file mode 100644 index 000000000..1db130737 --- /dev/null +++ b/auth_totp/tests/test_res_users_authenticator_create.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +# Copyright 2016-2017 LasLabs Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import mock +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +@mock.patch( + 'odoo.addons.auth_totp.wizards.res_users_authenticator_create.pyotp' +) +class TestResUsersAuthenticatorCreate(TransactionCase): + + def setUp(self): + super(TestResUsersAuthenticatorCreate, self).setUp() + + self.test_user = self.env.ref('base.user_root') + + def _new_wizard(self, extra_values=None): + base_values = { + 'name': 'Test Authenticator', + 'confirmation_code': 'Test', + 'user_id': self.test_user.id, + } + if extra_values is not None: + base_values.update(extra_values) + + return self.env['res.users.authenticator.create'].create(base_values) + + def test_secret_key_default(self, pyotp_mock): + '''Should default to random string generated by PyOTP''' + pyotp_mock.random_base32.return_value = test_random = 'Test' + test_wiz = self.env['res.users.authenticator.create'] + test_key = test_wiz.default_get(['secret_key'])['secret_key'] + + self.assertEqual(test_key, test_random) + + def test_default_user_id_no_uid_in_context(self, pyotp_mock): + '''Should return empty user recordset when no uid in context''' + test_wiz = self.env['res.users.authenticator.create'].with_context( + uid=None, + ) + + self.assertFalse(test_wiz._default_user_id()) + self.assertEqual(test_wiz._default_user_id()._name, 'res.users') + + def test_default_user_id_uid_in_context(self, pyotp_mock): + '''Should return correct user record when there is a uid in context''' + test_wiz = self.env['res.users.authenticator.create'].with_context( + uid=self.test_user.id, + ) + + self.assertEqual(test_wiz._default_user_id(), self.test_user) + + def test_compute_qr_code_tag_no_user_id(self, pyotp_mock): + '''Should not call PyOTP or set field if no user_id present''' + test_wiz = self.env['res.users.authenticator.create'].with_context( + uid=None, + ).new() + pyotp_mock.reset_mock() + + self.assertFalse(test_wiz.qr_code_tag) + pyotp_mock.assert_not_called() + + def test_compute_qr_code_tag_user_id(self, pyotp_mock): + '''Should set field to image with encoded PyOTP URI if user present''' + pyotp_mock.TOTP().provisioning_uri.return_value = 'test:uri' + test_wiz = self._new_wizard() + + self.assertEqual( + test_wiz.qr_code_tag, + '' % 'test%3Auri', + ) + + def test_compute_qr_code_tag_pyotp_use(self, pyotp_mock): + '''Should call PyOTP twice with correct arguments if user_id present''' + test_wiz = self._new_wizard() + pyotp_mock.reset_mock() + test_wiz._compute_qr_code_tag() + + pyotp_mock.TOTP.assert_called_once_with(test_wiz.secret_key) + pyotp_mock.TOTP().provisioning_uri.assert_called_once_with( + self.test_user.display_name, + issuer_name=self.test_user.company_id.display_name, + ) + + def test_perform_validations_wrong_confirmation(self, pyotp_mock): + '''Should raise correct error if PyOTP cannot verify code''' + test_wiz = self._new_wizard() + pyotp_mock.TOTP().verify.return_value = False + + with self.assertRaisesRegexp(ValidationError, 'confirmation code'): + test_wiz._perform_validations() + + def test_perform_validations_right_confirmation(self, pyotp_mock): + '''Should not raise error if PyOTP can verify code''' + test_wiz = self._new_wizard() + pyotp_mock.TOTP().verify.return_value = True + + try: + test_wiz._perform_validations() + except ValidationError: + self.fail('A ValidationError was raised and should not have been.') + + def test_perform_validations_pyotp_use(self, pyotp_mock): + '''Should call PyOTP twice with correct arguments''' + test_wiz = self._new_wizard() + pyotp_mock.reset_mock() + test_wiz._perform_validations() + + pyotp_mock.TOTP.assert_called_once_with(test_wiz.secret_key) + pyotp_mock.TOTP().verify.assert_called_once_with( + test_wiz.confirmation_code, + ) + + def test_create_authenticator(self, pyotp_mock): + '''Should create single authenticator record with correct info''' + test_wiz = self._new_wizard() + auth_model = self.env['res.users.authenticator'] + auth_model.search([('id', '>', 0)]).unlink() + test_wiz._create_authenticator() + test_auth = auth_model.search([('id', '>', 0)]) + + self.assertEqual(len(test_auth), 1) + self.assertEqual( + (test_auth.name, test_auth.secret_key, test_auth.user_id), + (test_wiz.name, test_wiz.secret_key, test_wiz.user_id), + ) + + def test_action_create_return_info(self, pyotp_mock): + '''Should return info of user preferences action with user_id added''' + test_wiz = self._new_wizard() + test_info = self.env.ref('base.action_res_users_my').read()[0] + test_info.update({'res_id': test_wiz.user_id.id}) + + self.assertEqual(test_wiz.action_create(), test_info) + + def test_action_create_helper_use(self, pyotp_mock): + '''Should call correct helper methods with proper arguments''' + test_wiz = self._new_wizard() + + with mock.patch.multiple( + test_wiz, + _perform_validations=mock.DEFAULT, + _create_authenticator=mock.DEFAULT, + ): + test_wiz.action_create() + test_wiz._perform_validations.assert_called_once_with() + test_wiz._create_authenticator.assert_called_once_with() diff --git a/auth_totp/views/auth_totp.xml b/auth_totp/views/auth_totp.xml new file mode 100644 index 000000000..33c2a21d0 --- /dev/null +++ b/auth_totp/views/auth_totp.xml @@ -0,0 +1,34 @@ + + + + + + + diff --git a/auth_totp/views/res_users.xml b/auth_totp/views/res_users.xml new file mode 100644 index 000000000..6a584c4f0 --- /dev/null +++ b/auth_totp/views/res_users.xml @@ -0,0 +1,43 @@ + + + + + + + User Form - MFA Settings + res.users + + + + +

Note: Please have user add at least one authentication app/device before enabling MFA.

+
+
+
+
+ + + Change My Preferences - MFA Settings + res.users + + + + +

Note: Please add at least one authentication app/device before enabling MFA.

+