Browse Source

Merge pull request #703 from LasLabs/release/10.0/LABS-306-auth_totp-implement-time-based-one-time

[10.0][ADD] auth_totp: MFA Support
pull/943/merge
Daniel Reis 7 years ago
committed by GitHub
parent
commit
f542ac3b04
  1. 105
      auth_totp/README.rst
  2. 8
      auth_totp/__init__.py
  3. 30
      auth_totp/__manifest__.py
  4. 5
      auth_totp/controllers/__init__.py
  5. 166
      auth_totp/controllers/main.py
  6. 14
      auth_totp/data/ir_config_parameter.xml
  7. 20
      auth_totp/exceptions.py
  8. 7
      auth_totp/models/__init__.py
  9. 104
      auth_totp/models/res_users.py
  10. 55
      auth_totp/models/res_users_authenticator.py
  11. 16
      auth_totp/models/res_users_device.py
  12. 3
      auth_totp/security/ir.model.access.csv
  13. 29
      auth_totp/security/res_users_authenticator_security.xml
  14. BIN
      auth_totp/static/description/icon.png
  15. 8
      auth_totp/tests/__init__.py
  16. 411
      auth_totp/tests/test_main.py
  17. 208
      auth_totp/tests/test_res_users.py
  18. 80
      auth_totp/tests/test_res_users_authenticator.py
  19. 151
      auth_totp/tests/test_res_users_authenticator_create.py
  20. 34
      auth_totp/views/auth_totp.xml
  21. 43
      auth_totp/views/res_users.xml
  22. 5
      auth_totp/wizards/__init__.py
  23. 118
      auth_totp/wizards/res_users_authenticator_create.py
  24. 43
      auth_totp/wizards/res_users_authenticator_create.xml
  25. 1
      requirements.txt

105
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
<https://github.com/OCA/server-tools/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 <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_.
Contributors
------------
* Oleg Bulkin <obulkin@laslabs.com>
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.

8
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

30
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',
],
}

5
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

166
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

14
auth_totp/data/ir_config_parameter.xml

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2016-2017 LasLabs Inc.
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
-->
<odoo noupdate="1">
<record id="cookie_security" model="ir.config_parameter">
<field name="key">auth_totp.secure_cookie</field>
<field name="value" eval="1"/>
<field name="group_ids" eval="[(4, ref('base.group_system'), 0)]"/>
</record>
</odoo>

20
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

7
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

104
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)

55
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

16
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,
)

3
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

29
auth_totp/security/res_users_authenticator_security.xml

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2016-2017 LasLabs Inc.
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
-->
<odoo>
<record id="auth_access_owners" model="ir.rule">
<field name="name">MFA Authenticators - Owner Access</field>
<field name="model_id" ref="model_res_users_authenticator"/>
<field name="domain_force">[('user_id', '=?', user.id)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<record id="auth_access_admins" model="ir.rule">
<field name="name">MFA Authenticators - Admin Read/Unlink</field>
<field name="model_id" ref="model_res_users_authenticator"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="True"/>
<field name="groups" eval="[(4, ref('base.group_erp_manager'))]"/>
</record>
</odoo>

BIN
auth_totp/static/description/icon.png

After

Width: 128  |  Height: 128  |  Size: 9.2 KiB

8
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

411
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)

208
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',
)

80
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)

151
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,
'<img src="/report/barcode/?type=QR&amp;value='
'%s&amp;width=300&amp;height=300">' % '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()

34
auth_totp/views/auth_totp.xml

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2016-2017 LasLabs Inc.
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
-->
<odoo>
<template id="mfa_login" name="MFA Login Page">
<t t-call="web.login_layout">
<form class="oe_login_form" role="form" t-attf-action="/auth_totp/login" method="post" onsubmit="this.action = this.action + location.hash">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="redirect" t-att-value="redirect"/>
<input type="hidden" name="mfa_login_token" t-att-value="mfa_login_token"/>
<div class="form-group field-login">
<label for="confirmation_code" class="control-label">MFA Confirmation Code</label>
<input type="text" name="confirmation_code" id="confirmation_code" class="form-control" required="required" autofocus="autofocus" autocapitalize="off"/>
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" name="remember_device" id="remember_device"/>
<span>Remember this device</span>
</label>
</div>
<p class="alert alert-danger" t-if="error">
<t t-esc="error"/>
</p>
<div class="clearfix oe_login_buttons">
<button type="submit" class="btn btn-primary">Confirm</button>
</div>
</form>
</t>
</template>
</odoo>

43
auth_totp/views/res_users.xml

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2016-2017 LasLabs Inc.
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
-->
<odoo>
<record id="view_users_form" model="ir.ui.view">
<field name="name">User Form - MFA Settings</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='messaging']" position="after">
<group string="MFA Settings" name="mfa_settings" col="8">
<p colspan="8">Note: Please have user add at least one authentication app/device before enabling MFA.</p>
<label for="mfa_enabled" colspan="3"/>
<field name="mfa_enabled" colspan="5" nolabel="1"/>
<label for="authenticator_ids" colspan="3"/>
<field name="authenticator_ids" widget="many2many_tags" options="{'no_create': True}" domain="[('user_id', '=', id)]" colspan="5" nolabel="1"/>
</group>
</xpath>
</field>
</record>
<record id="view_users_form_simple_modif" model="ir.ui.view">
<field name="name">Change My Preferences - MFA Settings</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
<field name="arch" type="xml">
<xpath expr="//footer" position="before">
<group string="MFA Settings" name="mfa_settings" col="8">
<p colspan="8">Note: Please add at least one authentication app/device before enabling MFA.</p>
<label for="mfa_enabled" colspan="3"/>
<field name="mfa_enabled" readonly="0" colspan="5" nolabel="1"/>
<label for="authenticator_ids" colspan="3"/>
<field name="authenticator_ids" widget="many2many_tags" options="{'no_create': True}" colspan="4" readonly="0" nolabel="1"/>
<button string="Add New App/Device" type="action" name="%(res_users_authenticator_create_action)d" colspan="1"/>
</group>
</xpath>
</field>
</record>
</odoo>

5
auth_totp/wizards/__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 res_users_authenticator_create

118
auth_totp/wizards/res_users_authenticator_create.py

@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
# Copyright 2016-2017 LasLabs Inc.
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
import logging
import urllib
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
_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 ResUsersAuthenticatorCreate(models.TransientModel):
_name = 'res.users.authenticator.create'
_description = 'MFA App/Device Creation Wizard'
name = fields.Char(
string='Authentication App/Device Name',
help='A name that will help you remember this authentication'
' app/device',
required=True,
index=True,
)
secret_key = fields.Char(
default=lambda s: pyotp.random_base32(),
required=True,
)
qr_code_tag = fields.Html(
compute='_compute_qr_code_tag',
string='QR Code',
help='Scan this image with your authentication app to add your'
' account',
)
user_id = fields.Many2one(
comodel_name='res.users',
default=lambda s: s._default_user_id(),
required=True,
string='Associated User',
help='This is the user whose account the new authentication app/device'
' will be tied to',
readonly=True,
index=True,
ondelete='cascade',
)
confirmation_code = fields.Char(
string='Confirmation Code',
help='Enter the latest six digit code generated by your authentication'
' app',
required=True,
)
@api.model
def _default_user_id(self):
user_id = self.env.context.get('uid')
return self.env['res.users'].browse(user_id)
@api.multi
@api.depends(
'secret_key',
'user_id.display_name',
'user_id.company_id.display_name',
)
def _compute_qr_code_tag(self):
for record in self:
if not record.user_id:
continue
totp = pyotp.TOTP(record.secret_key)
provisioning_uri = totp.provisioning_uri(
record.user_id.display_name,
issuer_name=record.user_id.company_id.display_name,
)
provisioning_uri = urllib.quote(provisioning_uri)
qr_width = qr_height = 300
tag_base = '<img src="/report/barcode/?type=QR&amp;'
tag_params = 'value=%s&amp;width=%s&amp;height=%s">' % (
provisioning_uri,
qr_width,
qr_height
)
record.qr_code_tag = tag_base + tag_params
@api.multi
def action_create(self):
self.ensure_one()
self._perform_validations()
self._create_authenticator()
action_data = self.env.ref('base.action_res_users_my').read()[0]
action_data.update({'res_id': self.user_id.id})
return action_data
@api.multi
def _perform_validations(self):
totp = pyotp.TOTP(self.secret_key)
if not totp.verify(self.confirmation_code):
raise ValidationError(_(
'Your confirmation code is not correct. Please try again,'
' making sure that your MFA device is set to the correct time'
' and that you have entered the most recent code generated by'
' your authentication app.'
))
@api.multi
def _create_authenticator(self):
self.env['res.users.authenticator'].create({
'name': self.name,
'secret_key': self.secret_key,
'user_id': self.user_id.id,
})

43
auth_totp/wizards/res_users_authenticator_create.xml

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2016-2017 LasLabs Inc.
License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
-->
<odoo>
<record id="res_users_authenticator_create_view_form" model="ir.ui.view">
<field name="name">MFA App/Device Creation Wizard</field>
<field name="model">res.users.authenticator.create</field>
<field name="arch" type="xml">
<form string="Authenticator Info">
<header/>
<sheet>
<div>
<span>Please provide a name for your app/device. </span>
<span>Then scan the QR code below to add this account to your authenticator app and enter in the six digit code produced by the app.</span>
</div>
<group name="data">
<field name="name"/>
<field name="user_id"/>
<field name="qr_code_tag"/>
<field name="confirmation_code"/>
<field name="secret_key" invisible="1"/>
</group>
</sheet>
<footer>
<button special="cancel" string="Cancel" class="pull-left"/>
<button name="action_create" type="object" string="Create" class="oe_highlight pull-right"/>
</footer>
</form>
</field>
</record>
<record id="res_users_authenticator_create_action" model="ir.actions.act_window">
<field name="name">MFA App/Device Creation Wizard</field>
<field name="res_model">res.users.authenticator.create</field>
<field name="view_type">form</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

1
requirements.txt

@ -3,6 +3,7 @@ unidecode
acme_tiny acme_tiny
IPy IPy
validate_email validate_email
pyotp
pysftp pysftp
fdb fdb
sqlalchemy sqlalchemy

Loading…
Cancel
Save