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