From 8cd367163ae80b95346dfd0f1426db853c144a69 Mon Sep 17 00:00:00 2001 From: Oleg Bulkin Date: Thu, 1 Dec 2016 18:25:23 -0800 Subject: [PATCH 1/7] [ADD] auth_totp: MFA Support * Add relevant fields and methods to the res.users model * Overload check_credentials in res.users to allow for logins using an MFA login token rather than a password * Add the res.users.authenticator and res.users.device models, along with appropriate ACLs and record rules * Add the res.users.authenticator.create wizard model and an associated view to facilitate creation of res.users.authenticator records * Extend base.view_users_form_simple_modif with fields needed to manage the new functionality * Add an AuthTotp controller that inherits from Home in the web module and an associated view to introduce MFA logic to the login process * Add several new exception classes that inherit from AccessDenied --- auth_totp/README.rst | 105 +++++ auth_totp/__init__.py | 8 + auth_totp/__openerp__.py | 30 ++ auth_totp/controllers/__init__.py | 5 + auth_totp/controllers/main.py | 155 +++++++ auth_totp/data/ir_config_parameter.xml | 14 + auth_totp/exceptions.py | 20 + auth_totp/models/__init__.py | 7 + auth_totp/models/res_users.py | 97 +++++ auth_totp/models/res_users_authenticator.py | 55 +++ auth_totp/models/res_users_device.py | 16 + auth_totp/security/ir.model.access.csv | 3 + .../res_users_authenticator_security.xml | 18 + auth_totp/static/description/icon.png | Bin 0 -> 10319 bytes auth_totp/tests/__init__.py | 8 + auth_totp/tests/test_main.py | 393 ++++++++++++++++++ auth_totp/tests/test_res_users.py | 202 +++++++++ .../tests/test_res_users_authenticator.py | 80 ++++ .../test_res_users_authenticator_create.py | 151 +++++++ auth_totp/views/auth_totp.xml | 34 ++ auth_totp/views/res_users.xml | 27 ++ auth_totp/wizards/__init__.py | 5 + .../wizards/res_users_authenticator_create.py | 117 ++++++ .../res_users_authenticator_create.xml | 43 ++ 24 files changed, 1593 insertions(+) create mode 100644 auth_totp/README.rst create mode 100644 auth_totp/__init__.py create mode 100644 auth_totp/__openerp__.py create mode 100644 auth_totp/controllers/__init__.py create mode 100644 auth_totp/controllers/main.py create mode 100644 auth_totp/data/ir_config_parameter.xml create mode 100644 auth_totp/exceptions.py create mode 100644 auth_totp/models/__init__.py create mode 100644 auth_totp/models/res_users.py create mode 100644 auth_totp/models/res_users_authenticator.py create mode 100644 auth_totp/models/res_users_device.py create mode 100644 auth_totp/security/ir.model.access.csv create mode 100644 auth_totp/security/res_users_authenticator_security.xml create mode 100644 auth_totp/static/description/icon.png create mode 100644 auth_totp/tests/__init__.py create mode 100644 auth_totp/tests/test_main.py create mode 100644 auth_totp/tests/test_res_users.py create mode 100644 auth_totp/tests/test_res_users_authenticator.py create mode 100644 auth_totp/tests/test_res_users_authenticator_create.py create mode 100644 auth_totp/views/auth_totp.xml create mode 100644 auth_totp/views/res_users.xml create mode 100644 auth_totp/wizards/__init__.py create mode 100644 auth_totp/wizards/res_users_authenticator_create.py create mode 100644 auth_totp/wizards/res_users_authenticator_create.xml diff --git a/auth_totp/README.rst b/auth_totp/README.rst new file mode 100644 index 000000000..85a18c141 --- /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 +=========== + +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/9.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/__openerp__.py b/auth_totp/__openerp__.py new file mode 100644 index 000000000..d0ba987a7 --- /dev/null +++ b/auth_totp/__openerp__.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': '9.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..52638d6dd --- /dev/null +++ b/auth_totp/controllers/main.py @@ -0,0 +1,155 @@ +# -*- 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 openerp import _, http, registry, SUPERUSER_ID +from openerp.api import Environment +from openerp.http import Response, request +from openerp.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) + 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='none', methods=['GET']) + 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) + + redirect = request.params.get('redirect') + if not redirect: + redirect = '/web' + response = Response(http.redirect_with_hash(redirect)) + + 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..2ea711515 --- /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 openerp.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..91c9a05a7 --- /dev/null +++ b/auth_totp/models/res_users.py @@ -0,0 +1,97 @@ +# -*- 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 openerp import _, api, fields, models +from openerp.exceptions import AccessDenied, ValidationError +from ..exceptions import MfaTokenInvalidError, MfaTokenExpiredError + + +class ResUsers(models.Model): + _inherit = 'res.users' + + 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.', + ) + 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..b7a86a8fc --- /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 openerp 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..2b33bb538 --- /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 openerp 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..a96b9cc9a --- /dev/null +++ b/auth_totp/security/res_users_authenticator_security.xml @@ -0,0 +1,18 @@ + + + + + + + MFA Authenticators - Owner Only + + [('user_id', '=?', user.id)] + + + + + + diff --git a/auth_totp/static/description/icon.png b/auth_totp/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..bb990930a39969c9e9bbd071819473f246a37aa2 GIT binary patch literal 10319 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 a*DemdjFG_u', 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..948043872 --- /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 openerp.tests.common import TransactionCase + +MOCK_PATH = 'openerp.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..a364fb5b2 --- /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 openerp.exceptions import ValidationError +from openerp.tests.common import TransactionCase + + +@mock.patch( + 'openerp.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..c4aa01518 --- /dev/null +++ b/auth_totp/views/res_users.xml @@ -0,0 +1,27 @@ + + + + + + + Change My Preferences - MFA Settings + res.users + + + + +
+ Note: Please add at least one authentication app/device before enabling MFA. +
+ + + +