diff --git a/addons/auth_oauth/views/res_users_views.xml b/addons/auth_oauth/views/res_users_views.xml index 369dcb331a9..a56b57dad5f 100644 --- a/addons/auth_oauth/views/res_users_views.xml +++ b/addons/auth_oauth/views/res_users_views.xml @@ -6,7 +6,7 @@ form - + diff --git a/addons/auth_totp/__init__.py b/addons/auth_totp/__init__.py index 9e5827f90ee..cec04a5b0e2 100644 --- a/addons/auth_totp/__init__.py +++ b/addons/auth_totp/__init__.py @@ -1,3 +1,4 @@ # -*- coding: utf-8 -*- from . import controllers from . import models +from . import wizard diff --git a/addons/auth_totp/__manifest__.py b/addons/auth_totp/__manifest__.py index 208ae9600bd..05ad46b63c3 100644 --- a/addons/auth_totp/__manifest__.py +++ b/addons/auth_totp/__manifest__.py @@ -14,17 +14,23 @@ Note: logically, two-factor prevents password-based RPC access for users where it is enabled. In order to be able to execute RPC scripts, the user can setup API keys to replace their main password. """, - 'depends': ['web'], + 'depends': ['web', 'mail'], 'category': 'Extra Tools', 'auto_install': True, 'data': [ + 'data/ir_action_data.xml', + 'data/mail_templates_data.xml', 'security/security.xml', - 'views/user_preferences.xml', + 'views/res_users_views.xml', 'views/templates.xml', + 'wizard/auth_totp_wizard_views.xml', ], 'assets': { 'web.assets_tests': [ - 'auth_totp/static/**/*', + 'auth_totp/static/tests/**/*', + ], + 'web.assets_backend': [ + 'auth_totp/static/src/**/*', ], }, 'license': 'LGPL-3', diff --git a/addons/auth_totp/data/ir_action_data.xml b/addons/auth_totp/data/ir_action_data.xml new file mode 100644 index 00000000000..9543556ed08 --- /dev/null +++ b/addons/auth_totp/data/ir_action_data.xml @@ -0,0 +1,40 @@ + + + + + Invite to use two-factor authentication + + + list + code + + action = records.action_totp_invite() + + + + + + + Disable two-factor authentication + + + list + code + + action = records.action_totp_disable() + + + + + + + Open two-factor authentication configuration + + code + +user = env.user +action = user.action_open_my_account_settings() + + + + diff --git a/addons/auth_totp/data/mail_templates_data.xml b/addons/auth_totp/data/mail_templates_data.xml new file mode 100644 index 00000000000..1e31d764915 --- /dev/null +++ b/addons/auth_totp/data/mail_templates_data.xml @@ -0,0 +1,31 @@ + + + + + TOTP on User: Invite by email + + Invitation to activate two-factor authentication on your Odoo account + ${object.partner_id.id} + ${object.partner_id.lang} + + +
+

+ Dear ${object.partner_id.name}

+ ${user.name} requested you activate two-factor authentication to protect your account.

+ Two-factor Authentication ("2FA") is a system of double authentication. + The first one is done with your password and the second one with a code you get from a dedicated mobile app. + Popular ones include Authy, Google Authenticator or the Microsoft Authenticator. + +

+ + Activate my two-factor Authentication + +

+

+
+
+
+
+
diff --git a/addons/auth_totp/models/__init__.py b/addons/auth_totp/models/__init__.py index 5f0fe0d93ac..563dd656946 100644 --- a/addons/auth_totp/models/__init__.py +++ b/addons/auth_totp/models/__init__.py @@ -1,3 +1,6 @@ # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + from . import ir_http from . import res_users +from . import totp diff --git a/addons/auth_totp/models/res_users.py b/addons/auth_totp/models/res_users.py index ac03ea05745..708c9bd87c5 100644 --- a/addons/auth_totp/models/res_users.py +++ b/addons/auth_totp/models/res_users.py @@ -1,20 +1,18 @@ # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + import base64 import functools -import hmac -import io import logging import os import re -import struct -import time - -import werkzeug.urls from odoo import _, api, fields, models from odoo.addons.base.models.res_users import check_identity from odoo.exceptions import AccessDenied, UserError -from odoo.http import request, db_list +from odoo.http import request + +from odoo.addons.auth_totp.models.totp import TOTP, TOTP_SECRET_SIZE _logger = logging.getLogger(__name__) @@ -41,6 +39,16 @@ class Users(models.Model): for r, v in zip(self, self.sudo()): r.totp_enabled = bool(v.totp_secret) + def action_open_my_account_settings(self): + action = { + "name": _("Account Security"), + "type": "ir.actions.act_window", + "res_model": "res.users", + "views": [[self.env.ref('auth_totp.res_users_view_form_security').id, "form"]], + "res_id": self.id, + } + return action + def _rpc_api_keys_only(self): # 2FA enabled means we can't allow password-based RPC self.ensure_one() @@ -79,8 +87,34 @@ class Users(models.Model): _logger.info("2FA enable: SUCCESS for %s %r", self, self.login) return True + def get_totp_invite_url(self): + return '/web#action=auth_totp.action_activate_two_factor_authentication' + + def action_totp_invite(self): + invite_template = self.env.ref('auth_totp.mail_template_totp_invite') + users_to_invite = self.sudo().filtered(lambda user: not user.totp_secret) + for user in users_to_invite: + email_values = { + 'email_from': self.env.user.email_formatted, + 'author_id': self.env.user.partner_id.id, + } + invite_template.send_mail(user.id, force_send=True, email_values=email_values, + notif_layout='mail.mail_notification_light') + + # Display a confirmation toaster + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'info', + 'sticky': False, + 'message': _("Invitation to use two-factor authentication sent for the following user(s): %s", + ', '.join(users_to_invite.mapped('name'))), + } + } + @check_identity - def totp_disable(self): + def action_totp_disable(self): logins = ', '.join(map(repr, self.mapped('login'))) if not (self == self.env.user or self.env.user._is_admin() or self.env.su): _logger.info("2FA disable: REJECT for %s (%s) by uid #%s", self, logins, self.env.user.id) @@ -99,13 +133,13 @@ class Users(models.Model): 'tag': 'display_notification', 'params': { 'type': 'warning', - 'message': _("Two-factor authentication disabled for user(s) %s", logins), + 'message': _("Two-factor authentication disabled for the following user(s): %s", ', '.join(self.mapped('name'))), 'next': {'type': 'ir.actions.act_window_close'}, } } @check_identity - def totp_enable_wizard(self): + def action_totp_enable_wizard(self): if self.env.user != self: raise UserError(_("Two-factor authentication can only be enabled for yourself")) @@ -124,114 +158,8 @@ class Users(models.Model): 'type': 'ir.actions.act_window', 'target': 'new', 'res_model': 'auth_totp.wizard', - 'name': _("Enable Two-Factor Authentication"), + 'name': _("Two-Factor Authentication Activation"), 'res_id': w.id, 'views': [(False, 'form')], + 'context': self.env.context, } - -class TOTPWizard(models.TransientModel): - _name = 'auth_totp.wizard' - _description = "Two-Factor Setup Wizard" - - user_id = fields.Many2one('res.users', required=True, readonly=True) - secret = fields.Char(required=True, readonly=True) - url = fields.Char(store=True, readonly=True, compute='_compute_qrcode') - qrcode = fields.Binary( - attachment=False, store=True, readonly=True, - compute='_compute_qrcode', - ) - code = fields.Char(string="Verification Code", size=7) - - @api.depends('user_id.login', 'user_id.company_id.display_name', 'secret') - def _compute_qrcode(self): - # TODO: make "issuer" configurable through config parameter? - global_issuer = request and request.httprequest.host.split(':', 1)[0] - for w in self: - issuer = global_issuer or w.user_id.company_id.display_name - w.url = url = werkzeug.urls.url_unparse(( - 'otpauth', 'totp', - werkzeug.urls.url_quote(f'{issuer}:{w.user_id.login}', safe=':'), - werkzeug.urls.url_encode({ - 'secret': compress(w.secret), - 'issuer': issuer, - # apparently a lowercase hash name is anathema to google - # authenticator (error) and passlib (no token) - 'algorithm': ALGORITHM.upper(), - 'digits': DIGITS, - 'period': TIMESTEP, - }), '' - )) - - data = io.BytesIO() - import qrcode - qrcode.make(url.encode(), box_size=4).save(data, optimise=True, format='PNG') - w.qrcode = base64.b64encode(data.getvalue()).decode() - - @check_identity - def enable(self): - try: - c = int(compress(self.code)) - except ValueError: - raise UserError(_("The verification code should only contain numbers")) - if self.user_id._totp_try_setting(self.secret, c): - self.secret = '' # empty it, because why keep it until GC? - return { - 'type': 'ir.actions.client', - 'tag': 'display_notification', - 'params': { - 'type': 'success', - 'message': _("Two-factor authentication is now enabled."), - 'next': {'type': 'ir.actions.act_window_close'}, - } - } - raise UserError(_('Verification failed, please double-check the 6-digit code')) - -# 160 bits, as recommended by HOTP RFC 4226, section 4, R6. -# Google Auth uses 80 bits by default but supports 160. -TOTP_SECRET_SIZE = 160 - -# The algorithm (and key URI format) allows customising these parameters but -# google authenticator doesn't support it -# https://github.com/google/google-authenticator/wiki/Key-Uri-Format -ALGORITHM = 'sha1' -DIGITS = 6 -TIMESTEP = 30 - -class TOTP: - def __init__(self, key): - self._key = key - - def match(self, code, t=None, window=TIMESTEP): - """ - :param code: authenticator code to check against this key - :param int t: current timestamp (seconds) - :param int window: fuzz window to account for slow fingers, network - latency, desynchronised clocks, ..., every code - valid between t-window an t+window is considered - valid - """ - if t is None: - t = time.time() - - low = int((t - window) / TIMESTEP) - high = int((t + window) / TIMESTEP) + 1 - - return next(( - counter for counter in range(low, high) - if hotp(self._key, counter) == code - ), None) - -def hotp(secret, counter): - # C is the 64b counter encoded in big-endian - C = struct.pack(">Q", counter) - mac = hmac.new(secret, msg=C, digestmod=ALGORITHM).digest() - # the data offset is the last nibble of the hash - offset = mac[-1] & 0xF - # code is the 4 bytes at the offset interpreted as a 31b big-endian uint - # (31b to avoid sign concerns). This effectively limits digits to 9 and - # hard-limits it to 10: each digit is normally worth 3.32 bits but the - # 10th is only worth 1.1 (9 digits encode 29.9 bits). - code = struct.unpack_from('>I', mac, offset)[0] & 0x7FFFFFFF - r = code % (10 ** DIGITS) - # NOTE: use text / bytes instead of int? - return r diff --git a/addons/auth_totp/models/totp.py b/addons/auth_totp/models/totp.py new file mode 100644 index 00000000000..642fb8fcc81 --- /dev/null +++ b/addons/auth_totp/models/totp.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import hmac +import struct +import time + +# 160 bits, as recommended by HOTP RFC 4226, section 4, R6. +# Google Auth uses 80 bits by default but supports 160. +TOTP_SECRET_SIZE = 160 + +# The algorithm (and key URI format) allows customising these parameters but +# google authenticator doesn't support it +# https://github.com/google/google-authenticator/wiki/Key-Uri-Format +ALGORITHM = 'sha1' +DIGITS = 6 +TIMESTEP = 30 + +class TOTP: + def __init__(self, key): + self._key = key + + def match(self, code, t=None, window=TIMESTEP): + """ + :param code: authenticator code to check against this key + :param int t: current timestamp (seconds) + :param int window: fuzz window to account for slow fingers, network + latency, desynchronised clocks, ..., every code + valid between t-window an t+window is considered + valid + """ + if t is None: + t = time.time() + + low = int((t - window) / TIMESTEP) + high = int((t + window) / TIMESTEP) + 1 + + return next(( + counter for counter in range(low, high) + if hotp(self._key, counter) == code + ), None) + +def hotp(secret, counter): + # C is the 64b counter encoded in big-endian + C = struct.pack(">Q", counter) + mac = hmac.new(secret, msg=C, digestmod=ALGORITHM).digest() + # the data offset is the last nibble of the hash + offset = mac[-1] & 0xF + # code is the 4 bytes at the offset interpreted as a 31b big-endian uint + # (31b to avoid sign concerns). This effectively limits digits to 9 and + # hard-limits it to 10: each digit is normally worth 3.32 bits but the + # 10th is only worth 1.1 (9 digits encode 29.9 bits). + code = struct.unpack_from('>I', mac, offset)[0] & 0x7FFFFFFF + r = code % (10 ** DIGITS) + # NOTE: use text / bytes instead of int? + return r diff --git a/addons/auth_totp/static/src/scss/res_users_view_form.scss b/addons/auth_totp/static/src/scss/res_users_view_form.scss new file mode 100644 index 00000000000..d5be28420d4 --- /dev/null +++ b/addons/auth_totp/static/src/scss/res_users_view_form.scss @@ -0,0 +1,3 @@ +.o_auth_2fa_btn { + font-size: 1.5em; +} diff --git a/addons/auth_totp/static/tests/totp_flow.js b/addons/auth_totp/static/tests/totp_flow.js index 6555b7c2d34..22a21af4e7c 100644 --- a/addons/auth_totp/static/tests/totp_flow.js +++ b/addons/auth_totp/static/tests/totp_flow.js @@ -39,10 +39,10 @@ tour.register('totp_tour_setup', { url: '/web' }, [...openUserProfileAtSecurityTab(), { content: "Open totp wizard", - trigger: 'button[name=totp_enable_wizard]', + trigger: 'button[name=action_totp_enable_wizard]', }, { content: "Check that we have to enter enhanced security mode", - trigger: 'div:contains("confirm your password")', + trigger: 'div:contains("enter your password")', run: () => {}, }, { content: "Input password", @@ -53,18 +53,21 @@ tour.register('totp_tour_setup', { trigger: "button:contains(Confirm Password)", }, { content: "Check the wizard has opened", - trigger: 'div:contains("Scan the image below")', + trigger: 'li:contains("When requested to do so")', run: () => {} }, { content: "Get secret from collapsed div", - trigger: 'a:contains("show the code")', + trigger: 'a:contains("Cannot scan it?")', run(helpers) { - const secret = this.$anchor.closest('div').find('code').text(); + const $secret = this.$anchor.closest('div').find('span[name=secret]'); + const $copyBtn = $secret.find('button'); + $copyBtn.remove(); + const secret = $secret.text(); ajax.jsonRpc('/totphook', 'call', { secret }).then((token) => { helpers._text(helpers._get_action_values('input[name=code]'), token); - helpers._click(helpers._get_action_values('button.btn-primary:contains(Enable)')); + helpers._click(helpers._get_action_values('button.btn-primary:contains(Activate)')); $('body').addClass('got-token') }); } @@ -77,7 +80,7 @@ tour.register('totp_tour_setup', { ...openUserProfileAtSecurityTab(), { content: "Check that the button has changed", - trigger: 'button:contains(Disable two-factor authentication)', + trigger: 'button[name=action_totp_disable]', run: () => {} }]); @@ -109,7 +112,7 @@ tour.register('totp_login_enabled', { helpers._text(helpers._get_action_values(), token); // FIXME: is there a way to put the button as its own step trigger without // the tour straight blowing through and not waiting for this? - helpers._click(helpers._get_action_values('button:contains("Verify")')); + helpers._click(helpers._get_action_values('button:contains("Login")')); }); } }, { @@ -117,16 +120,16 @@ tour.register('totp_login_enabled', { trigger: ".o_user_menu .oe_topbar_name", run: () => {} }, -// now go and disable totp would be annoying to do in a separate tour +// now go and disable two-factor authentication would be annoying to do in a separate tour // because we'd need to login & totp again as HttpCase.authenticate can't // succeed w/ totp enabled ...openUserProfileAtSecurityTab(), { content: "Open totp wizard", - trigger: 'button[name=totp_disable]', + trigger: 'button[name=action_totp_disable]', }, { content: "Check that we have to enter enhanced security mode", - trigger: 'div:contains("confirm your password")', + trigger: 'div:contains("enter your password")', run: () => {}, }, { content: "Input password", @@ -140,7 +143,7 @@ tour.register('totp_login_enabled', { ...openUserProfileAtSecurityTab(), { content: "Check that the button has changed", - trigger: 'button:contains(Enable two-factor authentication)', + trigger: 'button[name=action_totp_enable_wizard]', run: () => {} }]); @@ -212,20 +215,17 @@ tour.register('totp_admin_disables', { } const $row = this.$anchor.closest('tr'); const sel = $row.find('.o_list_record_selector input[type=checkbox]'); - const totp = $row[0].children[columns['totp_enabled']].querySelector('input'); - if (totp.checked) { - helpers.click(sel); - } + helpers.click(sel); } }, { content: "Open Actions menu", trigger: 'button.o_dropdown_toggler:contains("Action")' }, { content: "Select totp remover", - trigger: 'a.dropdown-item:contains(Disable TOTP on users)' + trigger: 'a.dropdown-item:contains(Disable two-factor authentication)' }, { // enhanced security yo content: "Check that we have to enter enhanced security mode", -trigger: 'div:contains("confirm your password")', +trigger: 'div:contains("enter your password")', run: () => {}, }, { content: "Input password", @@ -235,13 +235,13 @@ trigger: 'div:contains("confirm your password")', content: "Confirm", trigger: "button:contains(Confirm Password)", }, { - content: "check that demo user has been de-totp'd", + content: "open the user's form", trigger: "td.o_data_cell:contains(demo)", - run: function () { - const totpcell = this.$anchor.closest('tr')[0].children[columns['totp_enabled']]; - if (totpcell.querySelector('input').checked) { - throw new Error("totp should have been disabled on demo user"); - } - } +}, { + content: "go to Account security Tab", + trigger: "a.nav-link:contains(Account Security)", +}, { + content: "check that demo user has been de-totp'd", + trigger: "button:contains(Invite to use 2FA)", }]) }); diff --git a/addons/auth_totp/views/res_users_views.xml b/addons/auth_totp/views/res_users_views.xml new file mode 100644 index 00000000000..43d116ae405 --- /dev/null +++ b/addons/auth_totp/views/res_users_views.xml @@ -0,0 +1,115 @@ + + + + res.users.view.search.inherit.auth.totp + res.users + + + + + + + + + + + + + user form: add totp status + res.users + + + + + + + +
+
Two-factor Authentication +
+ + + +
+ +
+ + Two-factor Authentication ("2FA") is a system of double authentication. + The first one is done with your password and the second one with a code you get from a dedicated mobile app. + Popular ones include Authy, Google Authenticator or the Microsoft Authenticator. + Learn More + + Your account is protected! +
+
+
+
+
+
+ + + users preference: totp + res.users + + + + + +
+
Two-factor Authentication +
+ + Two-factor Authentication ("2FA") is a system of double authentication. + The first one is done with your password and the second one with a code you get from a dedicated mobile app. + Popular ones include Authy, Google Authenticator or the Microsoft Authenticator. + Learn More + + Your account is protected! +
+
+
+
+
+ + + + users preference: Account Security + res.users + + primary + +
+ 0 + 0 + 0 +
+

+ + +
+
+ $0 +
+ + +
+

+ +

+
+
+ +
+ + + diff --git a/addons/auth_totp/views/templates.xml b/addons/auth_totp/views/templates.xml index cd6c90cad38..af73f829b7c 100644 --- a/addons/auth_totp/views/templates.xml +++ b/addons/auth_totp/views/templates.xml @@ -5,34 +5,37 @@ - - -
- - - Two-factor authentication enabled - - -
-
- - - - diff --git a/addons/auth_totp/wizard/__init__.py b/addons/auth_totp/wizard/__init__.py new file mode 100644 index 00000000000..ab00fe3257f --- /dev/null +++ b/addons/auth_totp/wizard/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import auth_totp_wizard diff --git a/addons/auth_totp/wizard/auth_totp_wizard.py b/addons/auth_totp/wizard/auth_totp_wizard.py new file mode 100644 index 00000000000..a8be71df27b --- /dev/null +++ b/addons/auth_totp/wizard/auth_totp_wizard.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import base64 +import functools +import io +import qrcode +import re +import werkzeug.urls + +from odoo import _, api, fields, models +from odoo.addons.base.models.res_users import check_identity +from odoo.exceptions import UserError +from odoo.http import request + +from odoo.addons.auth_totp.models.totp import ALGORITHM, DIGITS, TIMESTEP + +compress = functools.partial(re.sub, r'\s', '') + +class TOTPWizard(models.TransientModel): + _name = 'auth_totp.wizard' + _description = "2-Factor Setup Wizard" + + user_id = fields.Many2one('res.users', required=True, readonly=True) + secret = fields.Char(required=True, readonly=True) + url = fields.Char(store=True, readonly=True, compute='_compute_qrcode') + qrcode = fields.Binary( + attachment=False, store=True, readonly=True, + compute='_compute_qrcode', + ) + code = fields.Char(string="Verification Code", size=7) + + @api.depends('user_id.login', 'user_id.company_id.display_name', 'secret') + def _compute_qrcode(self): + # TODO: make "issuer" configurable through config parameter? + global_issuer = request and request.httprequest.host.split(':', 1)[0] + for w in self: + issuer = global_issuer or w.user_id.company_id.display_name + w.url = url = werkzeug.urls.url_unparse(( + 'otpauth', 'totp', + werkzeug.urls.url_quote(f'{issuer}:{w.user_id.login}', safe=':'), + werkzeug.urls.url_encode({ + 'secret': compress(w.secret), + 'issuer': issuer, + # apparently a lowercase hash name is anathema to google + # authenticator (error) and passlib (no token) + 'algorithm': ALGORITHM.upper(), + 'digits': DIGITS, + 'period': TIMESTEP, + }), '' + )) + + data = io.BytesIO() + qrcode.make(url.encode(), box_size=4).save(data, optimise=True, format='PNG') + w.qrcode = base64.b64encode(data.getvalue()).decode() + + @check_identity + def enable(self): + try: + c = int(compress(self.code)) + except ValueError: + raise UserError(_("The verification code should only contain numbers")) + if self.user_id._totp_try_setting(self.secret, c): + self.secret = '' # empty it, because why keep it until GC? + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'success', + 'message': _("2-Factor authentication is now enabled."), + 'next': {'type': 'ir.actions.act_window_close'}, + } + } + raise UserError(_('Verification failed, please double-check the 6-digit code')) diff --git a/addons/auth_totp/wizard/auth_totp_wizard_views.xml b/addons/auth_totp/wizard/auth_totp_wizard_views.xml new file mode 100644 index 00000000000..e5af62ebb6a --- /dev/null +++ b/addons/auth_totp/wizard/auth_totp_wizard_views.xml @@ -0,0 +1,76 @@ + + + + auth_totp wizard + auth_totp.wizard + +
+ +
+
+

Authenticator App Setup

+
    +
    +
  • +
  • +
    +
  • +
    + Or install an authenticator app + Install an authenticator app on your mobile device + +
    +
  • + + Popular ones include Authy, Google Authenticator or the Microsoft Authenticator. +
  • Look for an "Add an account" button
  • +
  • + When requested to do so, scan the barcode below + When requested to do so, copy the key below +
  • +
+ + +
+ + +

+
+ +
+
+ + +
+ +
+ +

Enter your six-digit code below

+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/auth_totp_portal/__init__.py b/addons/auth_totp_portal/__init__.py index 40a96afc6ff..dc5e6b693d1 100644 --- a/addons/auth_totp_portal/__init__.py +++ b/addons/auth_totp_portal/__init__.py @@ -1 +1,4 @@ # -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/auth_totp_portal/models/__init__.py b/addons/auth_totp_portal/models/__init__.py new file mode 100644 index 00000000000..74f3017f7e6 --- /dev/null +++ b/addons/auth_totp_portal/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import res_users diff --git a/addons/auth_totp_portal/models/res_users.py b/addons/auth_totp_portal/models/res_users.py new file mode 100644 index 00000000000..7f68d1f56e4 --- /dev/null +++ b/addons/auth_totp_portal/models/res_users.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class Users(models.Model): + _inherit = 'res.users' + + def get_totp_invite_url(self): + if not self.has_group('base.group_user'): + return '/my/security' + else: + return super(Users, self).get_totp_invite_url() diff --git a/addons/auth_totp_portal/static/src/js/totp_frontend.js b/addons/auth_totp_portal/static/src/js/totp_frontend.js index 5f55970344c..c5dd6dd0adc 100644 --- a/addons/auth_totp_portal/static/src/js/totp_frontend.js +++ b/addons/auth_totp_portal/static/src/js/totp_frontend.js @@ -13,7 +13,7 @@ function fromField(f, record) { switch (f.getAttribute('name')) { case 'qrcode': const qrcode = document.createElement('img'); - qrcode.setAttribute('class', 'img img-fluid offset-1'); + qrcode.setAttribute('class', 'img img-fluid'); qrcode.setAttribute('src', 'data:image/png;base64,' + record['qrcode']); return qrcode; case 'url': @@ -30,6 +30,53 @@ function fromField(f, record) { code.maxLength = 6; code.minLength = 6; return code; + case 'secret': + // As CopyClipboard wizard is backend only, mimic his behaviour to use it in frontend. + // Field + const secretSpan = document.createElement('span'); + secretSpan.setAttribute('name', 'secret'); + secretSpan.setAttribute('class', 'o_field_copy_url'); + secretSpan.textContent = record['secret']; + + // Copy Button + const copySpanIcon = document.createElement('span'); + copySpanIcon.setAttribute('class', 'fa fa-clipboard'); + const copySpanText = document.createElement('span'); + copySpanText.textContent = _t(' Copy'); + + const copyButton = document.createElement('button'); + copyButton.setAttribute('class', 'btn btn-sm btn-primary o_clipboard_button o_btn_char_copy py-0 px-2'); + copyButton.onclick = function(event) { + event.preventDefault(); + $(copyButton).tooltip({title: _t("Copied !"), trigger: "manual", placement: "bottom"}); + var clipboard = new ClipboardJS('.o_clipboard_button', { + target: function () { + return $(secretSpan)[0]; + }, + container: this.el + }); + clipboard.on('success', function () { + clipboard.destroy(); + $(copyButton).tooltip('show'); + _.delay(function () { + $(copyButton).tooltip("hide"); + }, 800); + }); + clipboard.on('error', function (e) { + clipboard.destroy(); + }); + }; + + copyButton.appendChild(copySpanIcon); + copyButton.appendChild(copySpanText); + + // CopyClipboard Div + const secretDiv = document.createElement('div'); + secretDiv.setAttribute('class', 'o_field_copy d-flex justify-content-center align-items-center'); + secretDiv.appendChild(secretSpan); + secretDiv.appendChild(copyButton); + + return secretDiv; default: // just display the field's data return document.createTextNode(record[f.getAttribute('name')] || ''); } @@ -159,7 +206,7 @@ publicWidget.registry.TOTPButton = publicWidget.Widget.extend({ const w = await handleCheckIdentity(this.proxy('_rpc'), this._rpc({ model: 'res.users', - method: 'totp_enable_wizard', + method: 'action_totp_enable_wizard', args: [this.getSession().user_id] })); @@ -213,7 +260,7 @@ publicWidget.registry.DisableTOTPButton = publicWidget.Widget.extend({ e.preventDefault(); await handleCheckIdentity( this.proxy('_rpc'), - this._rpc({model: 'res.users', method: 'totp_disable', args: [this.getSession().user_id]}) + this._rpc({model: 'res.users', method: 'action_totp_disable', args: [this.getSession().user_id]}) ) window.location = window.location; } diff --git a/addons/auth_totp_portal/static/src/scss/auth_totp_portal.scss b/addons/auth_totp_portal/static/src/scss/auth_totp_portal.scss new file mode 100644 index 00000000000..7380d51e15e --- /dev/null +++ b/addons/auth_totp_portal/static/src/scss/auth_totp_portal.scss @@ -0,0 +1,35 @@ +/* As we use the Enable TOTP backend wizard in frontend, we need to + import the needed css from backend to replicate backend copyclipboard + widget style. We cannot put this into the original view as all those + classes are generated in the backend widget. */ +.o_auth_totp_enable_2FA { + .o_field_copy { + height: 24px; + position: relative; + width: 100% !important; + border-radius: 5px; + border: 1px solid $primary; + font-size: $font-size-sm; + text-transform: uppercase; + color: $o-brand-primary; + font-weight: $badge-font-weight; + text-align: center; + padding-right: 6rem; + word-break: break-word; + .o_clipboard_button { + @include o-position-absolute($top: 0, $right: 0); + &.o_btn_text_copy { + position: absolute; + top: 0; + right: 0; + } + &.o_btn_char_copy { + height: 100%; + } + } + &.o_field_copy_url { + cursor: pointer; + padding-left: 3px; + } + } +} diff --git a/addons/auth_totp_portal/static/tests/totp_portal.js b/addons/auth_totp_portal/static/tests/totp_portal.js index 6424647aabe..a60d04e783b 100644 --- a/addons/auth_totp_portal/static/tests/totp_portal.js +++ b/addons/auth_totp_portal/static/tests/totp_portal.js @@ -12,7 +12,7 @@ tour.register('totportal_tour_setup', { trigger: 'button#auth_totp_portal_enable', }, { content: "Check that we have to enter enhanced security mode", - trigger: 'div:contains("confirm your password")', + trigger: 'div:contains("enter your password")', run: () => {}, }, { content: "Input password", @@ -23,18 +23,18 @@ tour.register('totportal_tour_setup', { trigger: "button:contains(Confirm Password)", }, { content: "Check the wizard has opened", - trigger: 'div:contains("Scan the image below")', + trigger: 'li:contains("scan the barcode below")', run: () => {} }, { content: "Get secret from collapsed div", - trigger: 'a:contains("show the code")', + trigger: 'a:contains("Cannot scan it?")', run: async function(helpers) { - const secret = this.$anchor.closest('div').find('code').text(); + const secret = this.$anchor.closest('div').find('span[name="secret"]').text(); const token = await ajax.jsonRpc('/totphook', 'call', { secret }); helpers._text(helpers._get_action_values('input[name=code]'), token); - helpers._click(helpers._get_action_values('button.btn-primary:contains(Enable)')); + helpers._click(helpers._get_action_values('button.btn-primary:contains(Activate)')); } }, { content: "Check that the button has changed", @@ -70,7 +70,7 @@ tour.register('totportal_login_enabled', { helpers._text(helpers._get_action_values(), token); // FIXME: is there a way to put the button as its own step trigger without // the tour straight blowing through and not waiting for this? - helpers._click(helpers._get_action_values('button:contains("Verify")')); + helpers._click(helpers._get_action_values('button:contains("Login")')); } }, { content: "check we're logged in", @@ -84,7 +84,7 @@ tour.register('totportal_login_enabled', { trigger: 'button#auth_totp_portal_disable', }, { content: "Check that we have to enter enhanced security mode", - trigger: 'div:contains("confirm your password")', + trigger: 'div:contains("enter your password")', run: () => {}, }, { content: "Input password", diff --git a/addons/base_setup/static/src/img/logo_apple_store.png b/addons/base_setup/static/src/img/logo_apple_store.png new file mode 100644 index 00000000000..1df39627c07 Binary files /dev/null and b/addons/base_setup/static/src/img/logo_apple_store.png differ diff --git a/addons/base_setup/static/src/img/logo_google_play.png b/addons/base_setup/static/src/img/logo_google_play.png new file mode 100644 index 00000000000..9dfe8e3c172 Binary files /dev/null and b/addons/base_setup/static/src/img/logo_google_play.png differ diff --git a/addons/portal/static/src/js/portal.js b/addons/portal/static/src/js/portal.js index 183f383b4ed..ac6dac67a6e 100644 --- a/addons/portal/static/src/js/portal.js +++ b/addons/portal/static/src/js/portal.js @@ -207,9 +207,6 @@ function handleCheckIdentity(rpc, wrapped) { } }, { text: _t('Cancel'), close: true - }, { - text: _t('Forgot password?'), classes: 'btn btn-link', - click() { window.location.href = "/web/reset_password/"; } }] }).on('close', null, () => { // unlink wizard object? diff --git a/addons/portal/static/src/xml/portal_security.xml b/addons/portal/static/src/xml/portal_security.xml index ff5e62e895a..2c19cfeaa0d 100644 --- a/addons/portal/static/src/xml/portal_security.xml +++ b/addons/portal/static/src/xml/portal_security.xml @@ -1,13 +1,13 @@
-

Please confirm your password to continue

-

This is necessary for security-related changes. The authorization will last for a few minutes.

+

Please enter your password to confirm you own this account

+
-
+ Forgot password?
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 78bc5117a8f..313f567cbd2 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -1200,21 +1200,21 @@ class Session(http.Controller): old_password, new_password,confirm_password = operator.itemgetter('old_pwd', 'new_password','confirm_pwd')( {f['name']: f['value'] for f in fields}) if not (old_password.strip() and new_password.strip() and confirm_password.strip()): - return {'error':_('You cannot leave any password empty.'),'title': _('Change Password')} + return {'error': _('You cannot leave any password empty.')} if new_password != confirm_password: - return {'error': _('The new password and its confirmation must be identical.'),'title': _('Change Password')} + return {'error': _('The new password and its confirmation must be identical.')} msg = _("Error, password not changed !") try: if request.env['res.users'].change_password(old_password, new_password): - return {'new_password':new_password} + return {'new_password': new_password} except AccessDenied as e: msg = e.args[0] if msg == AccessDenied().args[0]: msg = _('The old password you provided is incorrect, your password was not changed.') except UserError as e: msg = e.args[0] - return {'title': _('Change Password'), 'error': msg} + return {'error': msg} @http.route('/web/session/get_lang_list', type='json', auth="none") def get_lang_list(self): diff --git a/addons/web/static/src/legacy/js/widgets/change_password.js b/addons/web/static/src/legacy/js/widgets/change_password.js index e75d8dfda38..567da0806b7 100644 --- a/addons/web/static/src/legacy/js/widgets/change_password.js +++ b/addons/web/static/src/legacy/js/widgets/change_password.js @@ -39,33 +39,16 @@ var ChangePassword = AbstractAction.extend({ }) .then(function (result) { if (result.error) { - self._display_error(result); + self.displayNotification({ + message: result.error, + type: 'danger' + }); } else { - self.do_action('logout'); + self.do_action('logout'); } }); }); }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Displays the error in a dialog - * - * @private - * @param {Object} error - * @param {string} error.error - * @param {string} error.title - */ - _display_error: function (error) { - return new Dialog(this, { - size: 'medium', - title: error.title, - $content: $('
').html(error.error) - }).open(); - }, }); core.action_registry.add("change_password", ChangePassword); diff --git a/addons/web/static/src/legacy/xml/base.xml b/addons/web/static/src/legacy/xml/base.xml index 62827e01646..f2e4e3228ba 100644 --- a/addons/web/static/src/legacy/xml/base.xml +++ b/addons/web/static/src/legacy/xml/base.xml @@ -797,7 +797,7 @@ - + diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py index b85bf7fc699..d794249ab2e 100644 --- a/odoo/addons/base/models/res_users.py +++ b/odoo/addons/base/models/res_users.py @@ -1533,7 +1533,11 @@ class CheckIdentity(models.TransientModel): def run_check(self): assert request, "This method can only be accessed over HTTP" - self.create_uid._check_credentials(self.password, {'interactive': True}) + try: + self.create_uid._check_credentials(self.password, {'interactive': True}) + except AccessDenied: + raise UserError(_("Incorrect Password, try again or click on Forgot Password to reset your password.")) + self.password = False request.session['identity-check-last'] = time.time() diff --git a/odoo/addons/base/views/res_users_views.xml b/odoo/addons/base/views/res_users_views.xml index 3287782937d..2ba49f799b5 100644 --- a/odoo/addons/base/views/res_users_views.xml +++ b/odoo/addons/base/views/res_users_views.xml @@ -39,19 +39,18 @@ res.users.identitycheck -
+ -

Please confirm your password to continue

-

This is necessary for security-related changes. The authorization will last for a few minutes.

+

Please enter your password to confirm you own this account

+
-
+ Forgot password?
@@ -235,7 +234,7 @@ - + @@ -433,7 +432,7 @@

- + @@ -465,16 +464,17 @@ - +
- - - - Documentation - + +
Developer API Keys + + + +
diff --git a/odoo/addons/test_apikeys/static/tests/apikey_flow.js b/odoo/addons/test_apikeys/static/tests/apikey_flow.js index 8507f3e4001..2415c63e099 100644 --- a/odoo/addons/test_apikeys/static/tests/apikey_flow.js +++ b/odoo/addons/test_apikeys/static/tests/apikey_flow.js @@ -6,7 +6,7 @@ const ajax = require('web.ajax'); tour.register('apikeys_tour_setup', { test: true, - url: '/web', + url: '/web?debug=1', // Needed as API key part is now only displayed in debug mode }, [{ content: 'Open user account menu', trigger: '.o_user_menu .oe_topbar_name', @@ -24,7 +24,7 @@ tour.register('apikeys_tour_setup', { trigger: 'button:contains("New API Key")', }, { content: "Check that we have to enter enhanced security mode", - trigger: 'div:contains("confirm your password")', + trigger: 'div:contains("enter your password")', run: () => {}, }, { content: "Input password", @@ -72,7 +72,7 @@ tour.register('apikeys_tour_setup', { // deletes the previously created key tour.register('apikeys_tour_teardown', { test: true, - url: '/web', + url: '/web?debug=1', // Needed as API key part is now only displayed in debug mode }, [{ content: 'Open preferences', trigger: '.o_user_menu .oe_topbar_name',