Browse Source
Merge pull request #703 from LasLabs/release/10.0/LABS-306-auth_totp-implement-time-based-one-time
Merge pull request #703 from LasLabs/release/10.0/LABS-306-auth_totp-implement-time-based-one-time
[10.0][ADD] auth_totp: MFA Supportpull/943/merge
Daniel Reis
7 years ago
committed by
GitHub
25 changed files with 1664 additions and 0 deletions
-
105auth_totp/README.rst
-
8auth_totp/__init__.py
-
30auth_totp/__manifest__.py
-
5auth_totp/controllers/__init__.py
-
166auth_totp/controllers/main.py
-
14auth_totp/data/ir_config_parameter.xml
-
20auth_totp/exceptions.py
-
7auth_totp/models/__init__.py
-
104auth_totp/models/res_users.py
-
55auth_totp/models/res_users_authenticator.py
-
16auth_totp/models/res_users_device.py
-
3auth_totp/security/ir.model.access.csv
-
29auth_totp/security/res_users_authenticator_security.xml
-
BINauth_totp/static/description/icon.png
-
8auth_totp/tests/__init__.py
-
411auth_totp/tests/test_main.py
-
208auth_totp/tests/test_res_users.py
-
80auth_totp/tests/test_res_users_authenticator.py
-
151auth_totp/tests/test_res_users_authenticator_create.py
-
34auth_totp/views/auth_totp.xml
-
43auth_totp/views/res_users.xml
-
5auth_totp/wizards/__init__.py
-
118auth_totp/wizards/res_users_authenticator_create.py
-
43auth_totp/wizards/res_users_authenticator_create.xml
-
1requirements.txt
@ -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. |
@ -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 |
@ -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', |
|||
], |
|||
} |
@ -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 |
@ -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 |
@ -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> |
@ -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 |
@ -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 |
@ -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) |
@ -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 |
@ -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, |
|||
) |
@ -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 |
@ -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> |
After Width: 128 | Height: 128 | Size: 9.2 KiB |
@ -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 |
@ -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) |
@ -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', |
|||
) |
@ -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) |
@ -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&value=' |
|||
'%s&width=300&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() |
@ -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> |
@ -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> |
@ -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 |
@ -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&' |
|||
tag_params = 'value=%s&width=%s&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, |
|||
}) |
@ -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> |
Write
Preview
Loading…
Cancel
Save
Reference in new issue