[MERGE] Support two-factor authentication + API keys
- Adds support for two-factor authentication (2FA) via time-based one-time-passwords (TOTP), compatible with mainstream authenticators like Google Auth, 1Password, etc. - Add support for personal API key that can be used for non-interactive RPC scripting, including when 2FA is enabled (password-based non-interactive RPC auth gets disabled by 2FA) - Both features can only be enabled/disabled after an extra password check, made possible by a new "identitycheck" feature. - Adapt user profile and portal to allow managing those new security settings. closes odoo/odoo#33928 Related: odoo/enterprise#9112 Signed-off-by: Olivier Dony (odo) <odo@openerp.com>
This commit is contained in:
@@ -32,6 +32,8 @@ class TestPortalAttachment(AccountTestInvoicingHttpCommon):
|
||||
def test_01_portal_attachment(self):
|
||||
"""Test the portal chatter attachment route."""
|
||||
|
||||
self.authenticate(None, None)
|
||||
|
||||
# Test public user can't create attachment without token of document
|
||||
res = self.url_open(
|
||||
url='%s/portal/attachment/add' % self.base_url,
|
||||
|
||||
@@ -10,9 +10,9 @@ class Users(models.Model):
|
||||
_inherit = "res.users"
|
||||
|
||||
@classmethod
|
||||
def _login(cls, db, login, password):
|
||||
def _login(cls, db, login, password, user_agent_env):
|
||||
try:
|
||||
return super(Users, cls)._login(db, login, password)
|
||||
return super(Users, cls)._login(db, login, password, user_agent_env=user_agent_env)
|
||||
except AccessDenied as e:
|
||||
with registry(db).cursor() as cr:
|
||||
cr.execute("SELECT id FROM res_users WHERE lower(login)=%s", (login,))
|
||||
@@ -28,11 +28,12 @@ class Users(models.Model):
|
||||
return Ldap._get_or_create_user(conf, login, entry)
|
||||
raise e
|
||||
|
||||
def _check_credentials(self, password):
|
||||
def _check_credentials(self, password, env):
|
||||
try:
|
||||
super(Users, self)._check_credentials(password)
|
||||
return super(Users, self)._check_credentials(password, env)
|
||||
except AccessDenied:
|
||||
if self.env.user.active:
|
||||
passwd_allowed = env['interactive'] or not self.env.user._rpc_api_keys_only()
|
||||
if passwd_allowed and self.env.user.active:
|
||||
Ldap = self.env['res.company.ldap']
|
||||
for conf in Ldap._get_ldap_dicts():
|
||||
if Ldap._authenticate(conf, self.env.user.login, password):
|
||||
|
||||
@@ -109,13 +109,16 @@ class ResUsers(models.Model):
|
||||
# return user credentials
|
||||
return (self.env.cr.dbname, login, access_token)
|
||||
|
||||
def _check_credentials(self, password):
|
||||
def _check_credentials(self, password, env):
|
||||
try:
|
||||
return super(ResUsers, self)._check_credentials(password)
|
||||
return super(ResUsers, self)._check_credentials(password, env)
|
||||
except AccessDenied:
|
||||
res = self.sudo().search([('id', '=', self.env.uid), ('oauth_access_token', '=', password)])
|
||||
if not res:
|
||||
raise
|
||||
passwd_allowed = env['interactive'] or not self.env.user._rpc_api_keys_only()
|
||||
if passwd_allowed and self.env.user.active:
|
||||
res = self.sudo().search([('id', '=', self.env.uid), ('oauth_access_token', '=', password)])
|
||||
if res:
|
||||
return
|
||||
raise
|
||||
|
||||
def _get_session_token_fields(self):
|
||||
return super(ResUsers, self)._get_session_token_fields() | {'oauth_access_token'}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import controllers
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
'name': "Password Policy support for Signup",
|
||||
'depends': ['auth_password_policy', 'portal'],
|
||||
'category': 'Tools',
|
||||
'auto_install': True,
|
||||
'data': ['views/templates.xml']
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.http import request
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
|
||||
class CustomerPortalPasswordPolicy(CustomerPortal):
|
||||
def _prepare_portal_layout_values(self):
|
||||
d = super()._prepare_portal_layout_values()
|
||||
d['password_minimum_length'] = request.env['ir.config_parameter'].sudo().get_param('auth_password_policy.minlength')
|
||||
return d
|
||||
@@ -0,0 +1,6 @@
|
||||
.o_portal_security_body #new-password-group {
|
||||
position: relative;
|
||||
meter {
|
||||
bottom: calc(#{$input-height-sm} / 2 - 7px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<odoo>
|
||||
<template id="assets_frontend" inherit_id="web.assets_frontend" name="Portal Password Policy Assets">
|
||||
<xpath expr="." position="inside">
|
||||
<link rel="stylesheet" type="text/scss" href="/auth_password_policy_portal/static/src/scss/portal_policy.scss"/>
|
||||
</xpath>
|
||||
</template>
|
||||
<template id="portal_my_security" inherit_id="portal.portal_my_security"
|
||||
name="Password policy data for portal">
|
||||
<xpath expr="//div[hasclass('form-group')][input[@name='new1']]" position="attributes">
|
||||
<attribute name="id">new-password-group</attribute>
|
||||
</xpath>
|
||||
<!-- only put meter on first "new password" field since both must be identical -->
|
||||
<xpath expr="//input[@name='new1']" position="attributes">
|
||||
<attribute name="t-att-minlength">password_minimum_length</attribute>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -11,7 +11,7 @@ if (!$signupForm.length) { return; }
|
||||
// hook in password strength meter
|
||||
// * requirement is the password field's minlength
|
||||
// * recommendations are from the module
|
||||
var $password = $signupForm.find('#password');
|
||||
var $password = $('[type=password][minlength]');
|
||||
var minlength = Number($password.attr('minlength'));
|
||||
if (isNaN(minlength)) { return; }
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import controllers
|
||||
from . import models
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
'name': 'Two-Factor Authentication (TOTP)',
|
||||
'description': """
|
||||
Two-Factor Authentication (TOTP)
|
||||
================================
|
||||
Allows users to configure two-factor authentication on their user account
|
||||
for extra security, using time-based one-time passwords (TOTP).
|
||||
|
||||
Once enabled, the user will need to enter a 6-digit code as provided
|
||||
by their authenticator app before being granted access to the system.
|
||||
All popular authenticator apps are supported.
|
||||
|
||||
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'],
|
||||
'category': 'Extra Tools',
|
||||
'auto_install': True,
|
||||
'data': [
|
||||
'security/security.xml',
|
||||
'views/user_preferences.xml',
|
||||
'views/templates.xml',
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import home
|
||||
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import odoo.addons.web.controllers.main
|
||||
from odoo import http, _
|
||||
from odoo.exceptions import AccessDenied
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class Home(odoo.addons.web.controllers.main.Home):
|
||||
@http.route(
|
||||
'/web/login/totp',
|
||||
type='http', auth='public', methods=['GET', 'POST'], sitemap=False,
|
||||
website=True, # website breaks the login layout...
|
||||
)
|
||||
def web_totp(self, redirect=None, **kwargs):
|
||||
if request.session.uid:
|
||||
return http.redirect_with_hash(self._login_redirect(request.session.uid, redirect=redirect))
|
||||
|
||||
if not request.session.pre_uid:
|
||||
return http.redirect_with_hash('/web/login')
|
||||
|
||||
error = None
|
||||
if request.httprequest.method == 'POST':
|
||||
user = request.env['res.users'].browse(request.session.pre_uid)
|
||||
try:
|
||||
with user._assert_can_auth():
|
||||
user._totp_check(int(kwargs['totp_token']))
|
||||
except AccessDenied:
|
||||
error = _("Verification failed, please double-check the 6-digit code")
|
||||
except ValueError:
|
||||
error = _("Invalid authentication code format.")
|
||||
else:
|
||||
request.session.finalize()
|
||||
return http.redirect_with_hash(self._login_redirect(request.session.uid, redirect=redirect))
|
||||
|
||||
return request.render('auth_totp.auth_totp_form', {
|
||||
'error': error,
|
||||
'redirect': redirect,
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import ir_http
|
||||
from . import res_users
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models
|
||||
from odoo.http import request
|
||||
|
||||
class IrHttp(models.AbstractModel):
|
||||
_inherit = 'ir.http'
|
||||
|
||||
def session_info(self):
|
||||
info = super().session_info()
|
||||
# because frontend session_info uses this key and is embedded in
|
||||
# the view source
|
||||
info["user_id"] = request.session.uid,
|
||||
return info
|
||||
@@ -0,0 +1,197 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
|
||||
import qrcode
|
||||
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
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class Users(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
totp_secret = fields.Char(copy=False, groups=".") # no access
|
||||
totp_enabled = fields.Boolean(string="TOTP enabled", compute='_compute_totp_enabled')
|
||||
|
||||
def __init__(self, pool, cr):
|
||||
init_res = super().__init__(pool, cr)
|
||||
type(self).SELF_READABLE_FIELDS = self.SELF_READABLE_FIELDS + ['totp_enabled']
|
||||
return init_res
|
||||
|
||||
def _mfa_url(self):
|
||||
r = super()._mfa_url()
|
||||
if r is not None:
|
||||
return r
|
||||
if self.totp_enabled:
|
||||
return '/web/login/totp'
|
||||
|
||||
@api.depends('totp_secret')
|
||||
def _compute_totp_enabled(self):
|
||||
for r, v in zip(self, self.sudo()):
|
||||
r.totp_enabled = bool(v.totp_secret)
|
||||
|
||||
def _rpc_api_keys_only(self):
|
||||
# 2FA enabled means we can't allow password-based RPC
|
||||
self.ensure_one()
|
||||
return self.totp_enabled or super()._rpc_api_keys_only()
|
||||
|
||||
def _totp_check(self, code):
|
||||
sudo = self.sudo()
|
||||
key = base64.b32decode(sudo.totp_secret.upper())
|
||||
match = TOTP(key).match(code)
|
||||
if match is None:
|
||||
_logger.info("2FA check: FAIL for '%s' (#%s)", self.login, self.id)
|
||||
raise AccessDenied()
|
||||
_logger.info("2FA check: SUCCESS for '%s' (#%s)", self.login, self.id)
|
||||
|
||||
def _totp_try_setting(self, secret, code):
|
||||
if self.totp_enabled or self != self.env.user:
|
||||
_logger.info("2FA enable: REJECT for '%s' (#%s)", self.login, self.id)
|
||||
return False
|
||||
|
||||
match = TOTP(base64.b32decode(secret.upper())).match(code)
|
||||
if match is None:
|
||||
_logger.info("2FA enable: REJECT CODE for '%s' (#%s)", self.login, self.id)
|
||||
return False
|
||||
|
||||
self.sudo().totp_secret = secret
|
||||
_logger.info("2FA enable: SUCCESS for '%s' (#%s)", self.login, self.id)
|
||||
return True
|
||||
|
||||
@check_identity
|
||||
def totp_disable(self):
|
||||
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.login, self.id, self.env.user.id)
|
||||
return False
|
||||
|
||||
self.sudo().write({'totp_secret': False})
|
||||
_logger.info("2FA disable: SUCCESS for '%s' (#%s) by uid #%s", self.login, self.id, self.env.user.id)
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
@check_identity
|
||||
def totp_enable_wizard(self):
|
||||
if self.env.user != self:
|
||||
raise UserError(_("Two-factor authentication can only be enabled for yourself"))
|
||||
|
||||
if self.totp_enabled:
|
||||
raise UserError(_("Two-factor authentication already enabled"))
|
||||
|
||||
secret_bytes_count = TOTP_SECRET_SIZE // 8
|
||||
w = self.env['auth_totp.wizard'].create({
|
||||
'user_id': self.id,
|
||||
'secret': base64.b32encode(os.urandom(secret_bytes_count)).decode(),
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'target': 'new',
|
||||
'res_model': 'auth_totp.wizard',
|
||||
'name': _("Enable Two-Factor Authentication"),
|
||||
'res_id': w.id,
|
||||
'views': [(False, 'form')],
|
||||
}
|
||||
|
||||
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", placeholder="6-digit code", size=6)
|
||||
|
||||
@api.depends('user_id.login', 'user_id.company_id.display_name', 'secret')
|
||||
def _compute_qrcode(self):
|
||||
for w in self:
|
||||
label = '{0.company_id.display_name}:{0.login}'.format(w.user_id)
|
||||
w.url = url = werkzeug.urls.url_unparse((
|
||||
'otpauth', 'totp',
|
||||
werkzeug.urls.url_quote(label, safe=''),
|
||||
werkzeug.urls.url_encode({
|
||||
'secret': w.secret,
|
||||
'issuer': w.user_id.company_id.display_name,
|
||||
# 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(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.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
|
||||
@@ -0,0 +1,16 @@
|
||||
<odoo>
|
||||
<record model="ir.model.access" id="access_auth_totp_wizard">
|
||||
<field name="name">auth_totp wizard access rules</field>
|
||||
<field name="model_id" ref="model_auth_totp_wizard"/>
|
||||
<field name="group_id" ref="base.group_user"/>
|
||||
<field name="perm_read">1</field>
|
||||
<field name="perm_write">1</field>
|
||||
<field name="perm_create">1</field>
|
||||
<field name="perm_unlink">1</field>
|
||||
</record>
|
||||
<record model="ir.rule" id="rule_auth_totp_wizard">
|
||||
<field name="name">Users can only access their own wizard</field>
|
||||
<field name="model_id" ref="model_auth_totp_wizard"/>
|
||||
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,249 @@
|
||||
odoo.define('auth_totp.tours', function(require) {
|
||||
"use strict";
|
||||
|
||||
const tour = require('web_tour.tour');
|
||||
const ajax = require('web.ajax');
|
||||
|
||||
tour.register('totp_tour_setup', {
|
||||
test: true,
|
||||
url: '/web'
|
||||
}, [{
|
||||
content: 'Open user account menu',
|
||||
trigger: '.o_user_menu .oe_topbar_name',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Open preferences / profile screen",
|
||||
trigger: '[data-menu=settings]',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Switch to security tab",
|
||||
trigger: 'a[role=tab]:contains("Account Security")',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Open totp wizard",
|
||||
trigger: 'button[name=totp_enable_wizard]',
|
||||
}, {
|
||||
content: "Check that we have to enter enhanced security mode",
|
||||
trigger: '.card-title:contains("confirm your password")',
|
||||
run: () => {},
|
||||
}, {
|
||||
content: "Input password",
|
||||
trigger: '[name=password]',
|
||||
run: 'text demo', // FIXME: better way to do this?
|
||||
}, {
|
||||
content: "Confirm",
|
||||
trigger: "button:contains(Confirm Password)",
|
||||
}, {
|
||||
content: "Check the wizard has opened",
|
||||
trigger: 'div:contains("Scan the image below")',
|
||||
run: () => {}
|
||||
}, {
|
||||
content: "Get secret from collapsed div",
|
||||
trigger: 'a:contains("show the code")',
|
||||
run: async function(helpers) {
|
||||
const secret = this.$anchor.closest('div').find('code').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)'));
|
||||
}
|
||||
}, { // re-navigate to the profile as unless hr is installed the preference dialog will close
|
||||
content: 'Open user account menu',
|
||||
trigger: '.o_user_menu .oe_topbar_name',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Open preferences / profile screen",
|
||||
trigger: '[data-menu=settings]',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Check that the button has changed",
|
||||
trigger: 'button:contains(Disable two-factor authentication)',
|
||||
run: () => {}
|
||||
}]);
|
||||
|
||||
tour.register('totp_login_enabled', {
|
||||
test: true,
|
||||
url: '/'
|
||||
}, [{
|
||||
content: "check that we're on the login page or go to it",
|
||||
trigger: 'input#login, a:contains(Sign in)'
|
||||
}, {
|
||||
content: "input login",
|
||||
trigger: 'input#login',
|
||||
run: 'text demo',
|
||||
}, {
|
||||
content: 'input password',
|
||||
trigger: 'input#password',
|
||||
run: 'text demo',
|
||||
}, {
|
||||
content: "click da button",
|
||||
trigger: 'button:contains("Log in")',
|
||||
}, {
|
||||
content: "expect totp screen",
|
||||
trigger: 'label:contains(Authentication Code)',
|
||||
}, {
|
||||
content: "input code",
|
||||
trigger: 'input[name=totp_token]',
|
||||
run: async function (helpers) {
|
||||
const token = await ajax.jsonRpc('/totphook', 'call', {});
|
||||
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")'));
|
||||
}
|
||||
}, {
|
||||
content: "check we're logged in",
|
||||
trigger: ".o_user_menu .oe_topbar_name",
|
||||
run: () => {}
|
||||
}, {
|
||||
// now go and disable totp 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
|
||||
content: 'Open user account menu',
|
||||
trigger: '.o_user_menu .oe_topbar_name',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Open preferences / profile screen",
|
||||
trigger: '[data-menu=settings]',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Switch to security tab",
|
||||
trigger: 'a[role=tab]:contains("Account Security")',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Open totp wizard",
|
||||
trigger: 'button[name=totp_disable]',
|
||||
}, {
|
||||
content: "Check that we have to enter enhanced security mode",
|
||||
trigger: '.card-title:contains("confirm your password")',
|
||||
run: () => {},
|
||||
}, {
|
||||
content: "Input password",
|
||||
trigger: '[name=password]',
|
||||
run: 'text demo', // FIXME: better way to do this?
|
||||
}, {
|
||||
content: "Confirm",
|
||||
trigger: "button:contains(Confirm Password)",
|
||||
}, {
|
||||
content: "Reopen the preference / profile as we don't know whether HR is installed",
|
||||
trigger: '.o_user_menu .oe_topbar_name',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Open preferences / profile screen",
|
||||
trigger: '[data-menu=settings]',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Check that the button has changed",
|
||||
trigger: 'button:contains(Enable two-factor authentication)',
|
||||
run: () => {}
|
||||
}]);
|
||||
|
||||
tour.register('totp_login_disabled', {
|
||||
test: true,
|
||||
url: '/'
|
||||
}, [{
|
||||
content: "check that we're on the login page or go to it",
|
||||
trigger: 'input#login, a:contains(Sign in)'
|
||||
}, {
|
||||
content: "input login",
|
||||
trigger: 'input#login',
|
||||
run: 'text demo',
|
||||
}, {
|
||||
content: 'input password',
|
||||
trigger: 'input#password',
|
||||
run: 'text demo',
|
||||
}, {
|
||||
content: "click da button",
|
||||
trigger: 'button:contains("Log in")',
|
||||
}, { // normally we'd end there but being sure there's no more queries is a
|
||||
// pain in the ass so go and open the profile screen
|
||||
content: 'Open user account menu',
|
||||
trigger: '.o_user_menu .oe_topbar_name',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Open preferences / profile screen",
|
||||
trigger: '[data-menu=settings]',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Check the pref screen has opened by looking for the Account Security tab",
|
||||
trigger: 'a[role=tab]:contains("Account Security")',
|
||||
run: () => {}
|
||||
}]);
|
||||
|
||||
const columns = {};
|
||||
tour.register('totp_admin_disables', {
|
||||
test: true,
|
||||
url: '/web'
|
||||
}, [tour.stepUtils.showAppsMenuItem(), {
|
||||
content: 'Go to settings',
|
||||
trigger: '[data-menu-xmlid="base.menu_administration"]'
|
||||
}, {
|
||||
content: 'Wait for page',
|
||||
trigger: '.o_menu_brand:contains("Settings")',
|
||||
run: () => {}
|
||||
}, {
|
||||
content: "Open Users menu",
|
||||
trigger: '[data-menu-xmlid="base.menu_users"]'
|
||||
}, {
|
||||
content: "Open Users view",
|
||||
trigger: '[data-menu-xmlid="base.menu_action_res_users"]',
|
||||
run: function (helpers) {
|
||||
// funny story: the users view we're trying to reach, sometimes we're
|
||||
// already there, but if we re-click the next step executes before the
|
||||
// action has the time to re-load, the one after that doesn't, and our
|
||||
// selection get discarded by the action reloading, so here try to
|
||||
// see if we're already on the users action through the breadcrumb and
|
||||
// just close the menu if so
|
||||
const $crumb = $('.breadcrumb');
|
||||
if ($crumb.text().indexOf('Users') === -1) {
|
||||
// on general settings page, click menu
|
||||
helpers.click();
|
||||
} else {
|
||||
// else close menu
|
||||
helpers.click($('[data-menu-xmlid="base.menu_users"]'));
|
||||
}
|
||||
}
|
||||
}, {
|
||||
content: "Find Demo User",
|
||||
trigger: 'td.o_data_cell:contains("demo")',
|
||||
run: function (helpers) {
|
||||
const $titles = this.$anchor.closest('table').find('tr:first th');
|
||||
for (let i=0; i<$titles.length; ++i) {
|
||||
columns[$titles[i].getAttribute('data-name')] = i;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
content: "Open Actions menu",
|
||||
trigger: 'button.o_dropdown_toggler_btn:contains("Action")'
|
||||
}, {
|
||||
content: "Select totp remover",
|
||||
trigger: 'a.dropdown-item:contains(Disable TOTP on users)'
|
||||
}, { // enhanced security yo
|
||||
content: "Check that we have to enter enhanced security mode",
|
||||
trigger: '.card-title:contains("confirm your password")',
|
||||
run: () => {},
|
||||
}, {
|
||||
content: "Input password",
|
||||
trigger: '[name=password]',
|
||||
run: 'text admin', // FIXME: better way to do this?
|
||||
}, {
|
||||
content: "Confirm",
|
||||
trigger: "button:contains(Confirm Password)",
|
||||
}, {
|
||||
content: "check that demo user has been de-totp'd",
|
||||
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");
|
||||
}
|
||||
}
|
||||
}])
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
from . import test_totp
|
||||
@@ -0,0 +1,57 @@
|
||||
import time
|
||||
|
||||
from passlib.totp import TOTP
|
||||
|
||||
from odoo import http
|
||||
from odoo.exceptions import AccessDenied
|
||||
from odoo.service import common as auth, model
|
||||
from odoo.tests import tagged, HttpCase
|
||||
|
||||
from ..controllers.home import Home
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestTOTP(HttpCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
totp = None
|
||||
# test endpoint as doing totp on the client side is not really an option
|
||||
# (needs sha1 and hmac + BE packing of 64b integers)
|
||||
def totp_hook(self, secret=None):
|
||||
nonlocal totp
|
||||
if totp is None:
|
||||
totp = TOTP(secret)
|
||||
if secret:
|
||||
return totp.generate().token
|
||||
else:
|
||||
# on check, take advantage of window because previous token has been
|
||||
# "burned" so we can't generate the same, but tour is so fast
|
||||
# we're pretty certainly within the same 30s
|
||||
return totp.generate(time.time() + 30).token
|
||||
# because not preprocessed by ControllerType metaclass
|
||||
totp_hook.routing_type = 'json'
|
||||
self.env['ir.http']._clear_routing_map()
|
||||
# patch Home to add test endpoint
|
||||
Home.totp_hook = http.route('/totphook', type='json', auth='none')(totp_hook)
|
||||
# remove endpoint and destroy routing map
|
||||
@self.addCleanup
|
||||
def _cleanup():
|
||||
del Home.totp_hook
|
||||
self.env['ir.http']._clear_routing_map()
|
||||
|
||||
def test_totp(self):
|
||||
# 1. Enable 2FA
|
||||
self.start_tour('/web', 'totp_tour_setup', login='demo')
|
||||
|
||||
# 2. Check 2FA is required
|
||||
self.start_tour('/', 'totp_login_enabled', login=None)
|
||||
|
||||
# 3. TODO: verify that RPC is blocked because 2FA is on.
|
||||
|
||||
# 4. Finally, disable 2FA otherwise we can't re-login
|
||||
self.start_tour('/', 'totp_login_disabled', login=None)
|
||||
|
||||
def test_totp_administration(self):
|
||||
self.start_tour('/web', 'totp_tour_setup', login='demo')
|
||||
self.start_tour('/web', 'totp_admin_disables', login='admin')
|
||||
self.start_tour('/', 'totp_login_disabled', login=None)
|
||||
@@ -0,0 +1,38 @@
|
||||
<odoo>
|
||||
<template id="assets_tests" inherit_id="web.assets_tests">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/auth_totp/static/tests/totp_flow.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
<template id="auth_totp_form">
|
||||
<t t-call="web.login_layout">
|
||||
<t t-set="disable_footer">1</t>
|
||||
<h5 class="card-title">Two-factor Authentication</h5>
|
||||
<div class="oe_login_form">
|
||||
<form method="POST" action="" class="">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="redirect" t-att-value="redirect"/>
|
||||
<div class="form-group">
|
||||
<label for="totp_token">Authentication Code</label>
|
||||
<input name="totp_token" class="form-control mb-2"
|
||||
autofocus="autofocus" required="required" placeholder="6-digit code"/>
|
||||
</div>
|
||||
<p class="alert alert-danger" t-if="error" role="alert">
|
||||
<t t-esc="error"/>
|
||||
</p>
|
||||
<div t-attf-class="clearfix oe_login_buttons text-center mb-1 {{'pt-2' if form_small else 'pt-3'}}">
|
||||
<button type="submit" class="btn btn-primary btn-block">
|
||||
Verify
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="POST" action="/web/session/logout" class="form-inline">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<button type="submit" class="btn btn-link btn-sm mb-2">
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,108 @@
|
||||
<odoo>
|
||||
<record model="ir.ui.view" id="view_totp_list">
|
||||
<field name="name">users list: add totp status</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="totp_enabled"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
<record model="ir.actions.server" id="action_disable_totp">
|
||||
<field name="name">Disable TOTP on users</field>
|
||||
<field name="model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_model_id" ref="base.model_res_users"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
action = records.totp_disable()
|
||||
</field>
|
||||
<field name="groups_id" eval="[(4, ref('base.group_erp_manager'), 0)]"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="view_totp_wizard">
|
||||
<field name="name">auth_totp wizard</field>
|
||||
<field name="model">auth_totp.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<div class="row container">
|
||||
<div class="col-lg-6 offset-lg-3 mb-3">
|
||||
<h3 class="font-weight-bold">Scan this barcode with your app</h3>
|
||||
<div>
|
||||
Scan the image below with the authenticator app on your phone.<br/>
|
||||
If you cannot scan the barcode, here are some alternative options:
|
||||
<ul>
|
||||
<li><field class="text-wrap" name="url" widget="url"
|
||||
options="{'website_path': True}"
|
||||
text="Click on this link to open your authenticator app"/></li>
|
||||
|
||||
<li>Or enter the secret code manually:
|
||||
<a data-toggle="collapse"
|
||||
href="#collapseTotpSecret" role="button" aria-expanded="false"
|
||||
aria-controls="collapseTotpSecret">show the code</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- code outside list to have more horiz space on mobile -->
|
||||
<div class="collapse" id="collapseTotpSecret">
|
||||
<div class="card card-body">
|
||||
<h3>Your two-factor secret:</h3>
|
||||
<code><field name="secret"/></code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<field class="offset-1 offset-lg-2" name="qrcode" readonly="True" widget="image"/>
|
||||
|
||||
<h3 class="font-weight-bold">Enter the 6-digit code from your app</h3>
|
||||
<div class="text-justify">
|
||||
After scanning the barcode, the app will display a 6-digit code that you
|
||||
should enter below. Don't worry if the code changes in the app,
|
||||
it stays valid a bit longer.
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label for="code">Verification Code</label>
|
||||
<field required="True" name="code"/>
|
||||
</div>
|
||||
<button type="object" name="enable" class="btn btn-primary"
|
||||
string="Enable two-factor authentication"/>
|
||||
</div>
|
||||
</div>
|
||||
</sheet>
|
||||
<footer><!-- no buttons --></footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="view_totp_field">
|
||||
<field name="name">users preference: totp</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
|
||||
<field name="arch" type="xml">
|
||||
<button name="preference_change_password" position="after">
|
||||
<field name="totp_enabled" invisible="1"/>
|
||||
<group attrs="{'invisible': [('totp_enabled', '!=', False)]}">
|
||||
<div>
|
||||
<span class="alert alert-info" role="status">
|
||||
<i class="fa fa-warning"/>
|
||||
Two-factor authentication not enabled
|
||||
</span>
|
||||
<button name="totp_enable_wizard" type="object" string="Enable two-factor authentication"
|
||||
class="btn btn-info mx-3"/>
|
||||
</div>
|
||||
</group>
|
||||
<group attrs="{'invisible': [('totp_enabled', '=', False)]}">
|
||||
<div>
|
||||
<span class="text-success">
|
||||
<i class="fa fa-check-circle"/>
|
||||
Two-factor authentication enabled
|
||||
</span>
|
||||
<button name="totp_disable" type="object" string="(Disable two-factor authentication)"
|
||||
class="btn btn-link text-muted"/>
|
||||
</div>
|
||||
</group>
|
||||
</button>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
'name': "TOTPortal",
|
||||
'category': 'Hidden',
|
||||
'depends': ['portal', 'auth_totp'],
|
||||
'auto_install': True,
|
||||
'data': [
|
||||
'security/security.xml',
|
||||
'views/templates.xml',
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<odoo>
|
||||
<!-- copies ACL for portal users -->
|
||||
<record model="ir.model.access" id="access_auth_totp_portal_wizard">
|
||||
<field name="name">auth_totp_portal wizard access rules</field>
|
||||
<field name="model_id" ref="auth_totp.model_auth_totp_wizard"/>
|
||||
<field name="group_id" ref="base.group_portal"/>
|
||||
<field name="perm_read">1</field>
|
||||
<field name="perm_write">1</field>
|
||||
<field name="perm_create">1</field>
|
||||
<field name="perm_unlink">1</field>
|
||||
</record>
|
||||
<!-- adds portal users to rule -->
|
||||
<record model="ir.rule" id="auth_totp.rule_auth_totp_wizard">
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,226 @@
|
||||
odoo.define('auth_totp_portal.button', function (require) {
|
||||
'use strict';
|
||||
|
||||
const {_t} = require('web.core');
|
||||
const publicWidget = require('web.public.widget');
|
||||
const Dialog = require('web.Dialog');
|
||||
const {handleCheckIdentity} = require('portal.portal');
|
||||
|
||||
/**
|
||||
* Replaces specific <field> elements by normal HTML, strip out the rest entirely
|
||||
*/
|
||||
function fromField(f, record) {
|
||||
switch (f.getAttribute('name')) {
|
||||
case 'qrcode':
|
||||
const qrcode = document.createElement('img');
|
||||
qrcode.setAttribute('class', 'img img-fluid offset-1 offset-lg-2');
|
||||
qrcode.setAttribute('src', 'data:image/png;base64,' + record['qrcode']);
|
||||
return qrcode;
|
||||
case 'url':
|
||||
const url = document.createElement('a');
|
||||
url.setAttribute('href', record['url']);
|
||||
url.textContent = f.getAttribute('text') || record['url'];
|
||||
return url;
|
||||
case 'code':
|
||||
const code = document.createElement('input');
|
||||
code.setAttribute('name', 'code');
|
||||
code.setAttribute('class', 'form-control');
|
||||
code.setAttribute('placeholder', '6-digit code');
|
||||
code.required = true;
|
||||
code.maxLength = 6;
|
||||
code.minLength = 6;
|
||||
return code;
|
||||
default: // just display the field's data
|
||||
return document.createTextNode(record[f.getAttribute('name')] || '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apparently chrome literally absolutely can't handle parsing XML and using
|
||||
* those nodes in an HTML document (even when parsing as application/xhtml+xml),
|
||||
* this results in broken rendering and a number of things not working (e.g.
|
||||
* classes) without any specific warning in the console or anything, things are
|
||||
* just broken with no indication of why.
|
||||
*
|
||||
* So... rebuild the entire f'ing body using document.createElement to ensure
|
||||
* we have HTML elements.
|
||||
*
|
||||
* This is a recursive implementation so it's not super efficient but the views
|
||||
* to fixup *should* be relatively simple.
|
||||
*/
|
||||
function fixupViewBody(oldNode, record) {
|
||||
let qrcode = null, code = null, node = null;
|
||||
|
||||
switch (oldNode.nodeType) {
|
||||
case 1: // element
|
||||
if (oldNode.tagName === 'field') {
|
||||
node = fromField(oldNode, record);
|
||||
switch (oldNode.getAttribute('name')) {
|
||||
case 'qrcode':
|
||||
qrcode = node;
|
||||
break;
|
||||
case 'code':
|
||||
code = node;
|
||||
break
|
||||
}
|
||||
break; // no need to recurse here
|
||||
}
|
||||
node = document.createElement(oldNode.tagName);
|
||||
for(let i=0; i<oldNode.attributes.length; ++i) {
|
||||
const attr = oldNode.attributes[i];
|
||||
node.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
for(let j=0; j<oldNode.childNodes.length; ++j) {
|
||||
const [ch, qr, co] = fixupViewBody(oldNode.childNodes[j], record);
|
||||
if (ch) { node.appendChild(ch); }
|
||||
if (qr) { qrcode = qr; }
|
||||
if (co) { code = co; }
|
||||
}
|
||||
break;
|
||||
case 3: case 4: // text, cdata
|
||||
node = document.createTextNode(oldNode.data);
|
||||
break;
|
||||
default:
|
||||
// don't care about PI & al
|
||||
}
|
||||
|
||||
return [node, qrcode, code]
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a backend <button> element and a bunch of metadata into a structure
|
||||
* which can kinda be of use to Dialog.
|
||||
*/
|
||||
class Button {
|
||||
constructor(parent, model, record_id, input_node, button_node) {
|
||||
this._parent = parent;
|
||||
this.model = model;
|
||||
this.record_id = record_id;
|
||||
this.input = input_node;
|
||||
this.text = button_node.getAttribute('string');
|
||||
this.classes = button_node.getAttribute('class') || null;
|
||||
this.action = button_node.getAttribute('name');
|
||||
if (button_node.getAttribute('special') === 'cancel') {
|
||||
this.close = true;
|
||||
this.click = null;
|
||||
} else {
|
||||
this.close = false;
|
||||
// because Dialog doesnt' call() click on the descriptor object
|
||||
this.click = this._click.bind(this);
|
||||
}
|
||||
if (!button_node.closest('footer')) {
|
||||
// remove non-footer buttons, otherwise they will appear twice
|
||||
button_node.parentNode.removeChild(button_node);
|
||||
}
|
||||
}
|
||||
async _click() {
|
||||
if (!this.input.reportValidity()) {
|
||||
this.input.classList.add('is-invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.callAction(this.record_id, {code: this.input.value});
|
||||
} catch (e) {
|
||||
this.input.classList.add('is-invalid');
|
||||
// show custom validity error message
|
||||
this.input.setCustomValidity(e.message);
|
||||
this.input.reportValidity();
|
||||
return;
|
||||
}
|
||||
this.input.classList.remove('is-invalid');
|
||||
// reloads page, avoid window.location.reload() because it re-posts forms
|
||||
window.location = window.location;
|
||||
}
|
||||
async callAction(id, update) {
|
||||
try {
|
||||
await this._parent._rpc({model: this.model, method: 'write', args: [id, update]});
|
||||
await handleCheckIdentity(
|
||||
this._parent.proxy('_rpc'),
|
||||
this._parent._rpc({model: this.model, method: this.action, args: [id]})
|
||||
);
|
||||
} catch(e) {
|
||||
// avoid error toast (crashmanager)
|
||||
e.event.preventDefault();
|
||||
// try to unwrap mess of an error object to a usable error message
|
||||
throw new Error(
|
||||
!e.message ? e.toString()
|
||||
: !e.message.data ? e.message.message
|
||||
: e.message.data.message || _t("Operation failed for unknown reason.")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
publicWidget.registry.TOTPButton = publicWidget.Widget.extend({
|
||||
selector: '#auth_totp_portal_enable',
|
||||
events: {
|
||||
click: '_onClick',
|
||||
},
|
||||
|
||||
async _onClick(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const w = await handleCheckIdentity(this.proxy('_rpc'), this._rpc({
|
||||
model: 'res.users',
|
||||
method: 'totp_enable_wizard',
|
||||
args: [this.getSession().user_id]
|
||||
}));
|
||||
|
||||
if (!w) {
|
||||
// TOTP probably already enabled, just reload page
|
||||
window.location = window.location;
|
||||
return;
|
||||
}
|
||||
|
||||
const {res_model: model, res_id: wizard_id} = w;
|
||||
|
||||
const record = await this._rpc({
|
||||
model, method: 'read', args: [wizard_id, []]
|
||||
}).then(ar => ar[0]);
|
||||
|
||||
const doc = new DOMParser().parseFromString(
|
||||
document.getElementById('totp_wizard_view').textContent,
|
||||
'application/xhtml+xml'
|
||||
);
|
||||
console.log(doc);
|
||||
|
||||
const xmlBody = doc.querySelector('sheet *');
|
||||
const [body, , codeInput] = fixupViewBody(xmlBody, record);
|
||||
// remove custom validity error message any time the field changes
|
||||
// otherwise it sticks and browsers suppress submit
|
||||
codeInput.addEventListener('input', () => codeInput.setCustomValidity(''));
|
||||
|
||||
const buttons = [];
|
||||
for(const button of body.querySelectorAll('button')) {
|
||||
buttons.push(new Button(this, model, record.id, codeInput, button));
|
||||
}
|
||||
|
||||
// wrap in a root host of .modal-body otherwise it breaks our neat flex layout
|
||||
const $content = document.createElement('form');
|
||||
$content.appendChild(body);
|
||||
// implicit submission by pressing [return] from within input
|
||||
$content.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
// sadness: footer not available as normal element
|
||||
dialog.$footer.find('.btn-primary').click();
|
||||
});
|
||||
var dialog = new Dialog(this, {$content, buttons}).open();
|
||||
}
|
||||
});
|
||||
publicWidget.registry.DisableTOTPButton = publicWidget.Widget.extend({
|
||||
selector: '#auth_totp_portal_disable',
|
||||
events: {
|
||||
click: '_onClick'
|
||||
},
|
||||
|
||||
async _onClick(e) {
|
||||
e.preventDefault();
|
||||
await handleCheckIdentity(
|
||||
this.proxy('_rpc'),
|
||||
this._rpc({model: 'res.users', method: 'totp_disable', args: [this.getSession().user_id]})
|
||||
)
|
||||
window.location = window.location;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
odoo.define('auth_totp_portal.tours', function(require) {
|
||||
"use strict";
|
||||
|
||||
const tour = require('web_tour.tour');
|
||||
const ajax = require('web.ajax');
|
||||
|
||||
tour.register('totportal_tour_setup', {
|
||||
test: true,
|
||||
url: '/my/security'
|
||||
}, [{
|
||||
content: "Open totp wizard",
|
||||
trigger: 'button#auth_totp_portal_enable',
|
||||
}, {
|
||||
content: "Check that we have to enter enhanced security mode",
|
||||
trigger: '.card-title:contains("confirm your password")',
|
||||
run: () => {},
|
||||
}, {
|
||||
content: "Input password",
|
||||
trigger: '[name=password]',
|
||||
run: 'text portal', // FIXME: better way to do this?
|
||||
}, {
|
||||
content: "Confirm",
|
||||
trigger: "button:contains(Confirm Password)",
|
||||
}, {
|
||||
content: "Check the wizard has opened",
|
||||
trigger: 'div:contains("Scan the image below")',
|
||||
run: () => {}
|
||||
}, {
|
||||
content: "Get secret from collapsed div",
|
||||
trigger: 'a:contains("show the code")',
|
||||
run: async function(helpers) {
|
||||
const secret = this.$anchor.closest('div').find('code').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)'));
|
||||
}
|
||||
}, {
|
||||
content: "Check that the button has changed",
|
||||
trigger: 'button:contains(Disable two-factor authentication)',
|
||||
run: () => {}
|
||||
}]);
|
||||
|
||||
tour.register('totportal_login_enabled', {
|
||||
test: true,
|
||||
url: '/'
|
||||
}, [{
|
||||
content: "check that we're on the login page or go to it",
|
||||
trigger: 'input#login, a:contains(Sign in)'
|
||||
}, {
|
||||
content: "input login",
|
||||
trigger: 'input#login',
|
||||
run: 'text portal',
|
||||
}, {
|
||||
content: 'input password',
|
||||
trigger: 'input#password',
|
||||
run: 'text portal',
|
||||
}, {
|
||||
content: "click da button",
|
||||
trigger: 'button:contains("Log in")',
|
||||
}, {
|
||||
content: "expect totp screen",
|
||||
trigger: 'label:contains(Authentication Code)',
|
||||
}, {
|
||||
content: "input code",
|
||||
trigger: 'input[name=totp_token]',
|
||||
run: async function (helpers) {
|
||||
const token = await ajax.jsonRpc('/totphook', 'call', {});
|
||||
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")'));
|
||||
}
|
||||
}, {
|
||||
content: "check we're logged in",
|
||||
trigger: "h3:contains(Documents)",
|
||||
run: () => {}
|
||||
}, {
|
||||
content: "go back to security",
|
||||
trigger: "a:contains(Security)",
|
||||
},{
|
||||
content: "Open totp wizard",
|
||||
trigger: 'button#auth_totp_portal_disable',
|
||||
}, {
|
||||
content: "Check that we have to enter enhanced security mode",
|
||||
trigger: '.card-title:contains("confirm your password")',
|
||||
run: () => {},
|
||||
}, {
|
||||
content: "Input password",
|
||||
trigger: '[name=password]',
|
||||
run: 'text portal', // FIXME: better way to do this?
|
||||
}, {
|
||||
content: "Confirm",
|
||||
trigger: "button:contains(Confirm Password)",
|
||||
}, {
|
||||
content: "Check that the button has changed",
|
||||
trigger: 'button:contains(Enable two-factor authentication)',
|
||||
run: () => {}
|
||||
}]);
|
||||
|
||||
tour.register('totportal_login_disabled', {
|
||||
test: true,
|
||||
url: '/'
|
||||
}, [{
|
||||
content: "check that we're on the login page or go to it",
|
||||
trigger: 'input#login, a:contains(Sign in)'
|
||||
}, {
|
||||
content: "input login",
|
||||
trigger: 'input#login',
|
||||
run: 'text portal',
|
||||
}, {
|
||||
content: 'input password',
|
||||
trigger: 'input#password',
|
||||
run: 'text portal',
|
||||
}, {
|
||||
content: "click da button",
|
||||
trigger: 'button:contains("Log in")',
|
||||
}, {
|
||||
content: "check we're logged in",
|
||||
trigger: "h3:contains(Documents)",
|
||||
run: () => {}
|
||||
}]);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
from . import test_tour
|
||||
@@ -0,0 +1,44 @@
|
||||
import time
|
||||
|
||||
from passlib.totp import TOTP
|
||||
|
||||
from odoo import http
|
||||
from odoo.tests import tagged, HttpCase
|
||||
from odoo.addons.auth_totp.controllers.home import Home
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestTOTPortal(HttpCase):
|
||||
"""
|
||||
Largely replicates TestTOTP
|
||||
"""
|
||||
def test_totp(self):
|
||||
totp = None
|
||||
# test endpoint as doing totp on the client side is not really an option
|
||||
# (needs sha1 and hmac + BE packing of 64b integers)
|
||||
def totp_hook(self, secret=None):
|
||||
nonlocal totp
|
||||
if totp is None:
|
||||
totp = TOTP(secret)
|
||||
if secret:
|
||||
return totp.generate().token
|
||||
else:
|
||||
# on check, take advantage of window because previous token has been
|
||||
# "burned" so we can't generate the same, but tour is so fast
|
||||
# we're pretty certainly within the same 30s
|
||||
return totp.generate(time.time() + 30).token
|
||||
# because not preprocessed by ControllerType metaclass
|
||||
totp_hook.routing_type = 'json'
|
||||
# patch Home to add test endpoint
|
||||
Home.totp_hook = http.route('/totphook', type='json', auth='none')(totp_hook)
|
||||
self.env['ir.http']._clear_routing_map()
|
||||
# remove endpoint and destroy routing map
|
||||
@self.addCleanup
|
||||
def _cleanup():
|
||||
del Home.totp_hook
|
||||
self.env['ir.http']._clear_routing_map()
|
||||
|
||||
self.start_tour('/my/security', 'totportal_tour_setup', login='portal')
|
||||
# also disables totp otherwise we can't re-login
|
||||
self.start_tour('/', 'totportal_login_enabled', login=None)
|
||||
self.start_tour('/', 'totportal_login_disabled', login=None)
|
||||
@@ -0,0 +1,48 @@
|
||||
<odoo>
|
||||
<template id="auth_totp_frontend" name="TOTP frontend (portal)" inherit_id="portal.assets_frontend">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="application/javascript"
|
||||
src="/auth_totp_portal/static/src/js/totp_frontend.js"/>
|
||||
</xpath>
|
||||
</template>
|
||||
<template id="assets_tests" inherit_id="web.assets_tests">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/auth_totp_portal/static/tests/totp_portal.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="totp_portal_hook" name="TOTP Portal hook" inherit_id="portal.portal_my_security">
|
||||
<xpath expr="//div[hasclass('o_portal_security_body')]">
|
||||
<!--
|
||||
portal users don't have access to non-qweb views anymore, so
|
||||
embed the target view as a *data island* of sorts, a JSON embed
|
||||
of the fields_view_get could have been nice but at 14k it'd be a
|
||||
bit ridiculous
|
||||
-->
|
||||
<div class="d-none" id="totp_wizard_view">
|
||||
<t t-esc="env.ref('auth_totp.view_totp_wizard').sudo().read_combined(['arch'])['arch']"/>
|
||||
</div>
|
||||
<section>
|
||||
<h3>Two-factor authentication</h3>
|
||||
<t t-if="not user_id.totp_enabled">
|
||||
<div class="alert alert-secondary" role="status">
|
||||
<i class="fa fa-warning"/>
|
||||
Two-factor authentication not enabled
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" id="auth_totp_portal_enable">
|
||||
Enable two-factor authentication
|
||||
</button>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-success">
|
||||
<i class="fa fa-check-circle"/>
|
||||
Two-factor authentication enabled
|
||||
</span>
|
||||
<button type="button" class="btn btn-link" id="auth_totp_portal_disable">
|
||||
(Disable two-factor authentication)
|
||||
</button>
|
||||
</t>
|
||||
</section>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -180,6 +180,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box"
|
||||
groups="base.group_system">
|
||||
<div class="o_setting_left_pane"/>
|
||||
<div class="o_setting_right_pane">
|
||||
<button type="action" name="%(base.action_apikeys_admin)d" string="Manage API Keys" icon="fa-arrow-right" class="btn-link"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box" groups="base.group_no_one" id="allow_import">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="module_base_import" />
|
||||
|
||||
@@ -58,6 +58,7 @@ class TestSelfAccessProfile(TestHrCommon):
|
||||
field.groups.split(',')
|
||||
for field in self.env['res.users']._fields.values()
|
||||
if field.groups
|
||||
if field.groups != '.' # "no-access" group on purpose
|
||||
])
|
||||
all_groups = self.env['res.groups']
|
||||
for xml_id in all_groups_xml_ids:
|
||||
|
||||
+137
-138
@@ -37,150 +37,149 @@
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="res_users_view_form_simple_modif"/>
|
||||
<field name="arch" type="xml">
|
||||
<form position="replace">
|
||||
<form create="false" js_class="hr_employee_profile_form">
|
||||
<field name="hr_presence_state" invisible="1"/>
|
||||
<header>
|
||||
<button name="preference_change_password" type="object" string="Change password" class="btn btn-secondary"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button
|
||||
id="hr_presence_button"
|
||||
class="oe_stat_button"
|
||||
disabled="1"
|
||||
invisible="context.get('from_my_profile', False)"
|
||||
attrs="{'invisible': [('hr_presence_state', '=', 'absent')]}">
|
||||
<div role="img" class="fa fa-fw fa-circle text-success o_button_icon" attrs="{'invisible': [('hr_presence_state', '!=', 'present')]}" aria-label="Available" title="Available"/>
|
||||
<div role="img" class="fa fa-fw fa-circle text-warning o_button_icon" attrs="{'invisible': [('hr_presence_state', '!=', 'to_define')]}" aria-label="Away" title="Away"/>
|
||||
<div role="img" class="fa fa-fw fa-circle text-danger o_button_icon" attrs="{'invisible': [('hr_presence_state', '!=', 'absent')]}" aria-label="Not available" title="Not available"/>
|
||||
<form position="attributes">
|
||||
<attribute name="create">false</attribute>
|
||||
<attribute name="js_class">hr_employee_profile_form</attribute>
|
||||
</form>
|
||||
<notebook position="replace">
|
||||
<field name="hr_presence_state" invisible="1"/>
|
||||
<header>
|
||||
</header>
|
||||
<sheet>$0</sheet>
|
||||
</notebook>
|
||||
<notebook position="before">
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button
|
||||
id="hr_presence_button"
|
||||
class="oe_stat_button"
|
||||
disabled="1"
|
||||
invisible="context.get('from_my_profile', False)"
|
||||
attrs="{'invisible': [('hr_presence_state', '=', 'absent')]}">
|
||||
<div role="img" class="fa fa-fw fa-circle text-success o_button_icon" attrs="{'invisible': [('hr_presence_state', '!=', 'present')]}" aria-label="Available" title="Available"/>
|
||||
<div role="img" class="fa fa-fw fa-circle text-warning o_button_icon" attrs="{'invisible': [('hr_presence_state', '!=', 'to_define')]}" aria-label="Away" title="Away"/>
|
||||
<div role="img" class="fa fa-fw fa-circle text-danger o_button_icon" attrs="{'invisible': [('hr_presence_state', '!=', 'absent')]}" aria-label="Not available" title="Not available"/>
|
||||
|
||||
<div class="o_stat_info" attrs="{'invisible': [('hr_presence_state', '=', 'present')]}">
|
||||
<span class="o_stat_text">
|
||||
Not Connected
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_stat_info" attrs="{'invisible': [('hr_presence_state', '!=', 'present')]}">
|
||||
<span class="o_stat_value" attrs="{'invisible': [('last_activity_time', '=', False)]}">
|
||||
<field name="last_activity_time"/>
|
||||
</span>
|
||||
<span class="o_stat_value" attrs="{'invisible': [('last_activity_time', '!=', False)]}">
|
||||
<field name="last_activity"/>
|
||||
</span>
|
||||
<span class="o_stat_text">Connected Since</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<field name="image_1920" widget='image' class="oe_avatar" options='{"zoom": true, "preview_image":"image_128"}'/>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Employee's Name" required="True" readonly="context.get('from_my_profile', False)"/>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="row">
|
||||
<h2 class="col-6">
|
||||
<field name="job_title" placeholder="Job Position" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="o_stat_info" attrs="{'invisible': [('hr_presence_state', '=', 'present')]}">
|
||||
<span class="o_stat_text">
|
||||
Not Connected
|
||||
</span>
|
||||
</div>
|
||||
<div class="o_stat_info" attrs="{'invisible': [('hr_presence_state', '!=', 'present')]}">
|
||||
<span class="o_stat_value" attrs="{'invisible': [('last_activity_time', '=', False)]}">
|
||||
<field name="last_activity_time"/>
|
||||
</span>
|
||||
<span class="o_stat_value" attrs="{'invisible': [('last_activity_time', '!=', False)]}">
|
||||
<field name="last_activity"/>
|
||||
</span>
|
||||
<span class="o_stat_text">Connected Since</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<field name="image_1920" widget='image' class="oe_avatar" options='{"zoom": true, "preview_image":"image_128"}'/>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Employee's Name" required="True" readonly="context.get('from_my_profile', False)"/>
|
||||
</h1>
|
||||
</div>
|
||||
<div class="row">
|
||||
<h2 class="col-6">
|
||||
<field name="job_title" placeholder="Job Position" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</h2>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<group>
|
||||
<field name="can_edit" invisible="1"/>
|
||||
<field name="mobile_phone" widget="phone" attrs="{'readonly': [('can_edit', '=', False)]}" options="{'enable_sms': false}"/>
|
||||
<field name="work_phone" widget="phone" attrs="{'readonly': [('can_edit', '=', False)]}" options="{'enable_sms': false}"/>
|
||||
<field name="can_edit" invisible="1"/>
|
||||
<field name="mobile_phone" widget="phone" attrs="{'readonly': [('can_edit', '=', False)]}" options="{'enable_sms': false}"/>
|
||||
<field name="work_phone" widget="phone" attrs="{'readonly': [('can_edit', '=', False)]}" options="{'enable_sms': false}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="work_email" widget="email" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="work_location" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="company_id" invisible="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="employee_parent_id" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="coach_id" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
</group>
|
||||
</notebook>
|
||||
<notebook position="inside">
|
||||
<page name="public" string="Work Information">
|
||||
<div id="o_work_employee_container"> <!-- These two div are used to position org_chart -->
|
||||
<div id="o_work_employee_main">
|
||||
<group string="Location">
|
||||
<field name="department_id" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="address_id"
|
||||
context="{'show_address': 1}"
|
||||
options='{"always_reload": True, "highlight_first_line": True}'
|
||||
attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
<group name="managers" string="Approvers" class="hide-group-if-empty">
|
||||
<!-- overridden in other modules -->
|
||||
</group>
|
||||
</div>
|
||||
</div>
|
||||
</page>
|
||||
<page name="personal_information" string="Private Information">
|
||||
<group>
|
||||
<group string="Contact Information">
|
||||
<field name="employee_ids" invisible="1"/>
|
||||
<field name="address_home_id"
|
||||
context="{
|
||||
'show_address': 1,
|
||||
'default_employee_ids': employee_ids,
|
||||
'default_type': 'private',
|
||||
'form_view_ref': 'base.res_partner_view_form_private'}"
|
||||
options='{"always_reload": True, "highlight_first_line": True}'
|
||||
attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="private_email" string="Email" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="employee_phone" string="Phone" class="o_force_ltr" attrs="{'readonly': [('can_edit', '=', False)]}" options="{'enable_sms': false}"/>
|
||||
<field name="employee_bank_account_id" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="km_home_work" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="work_email" widget="email" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="work_location" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="company_id" invisible="1"/>
|
||||
<group string="Citizenship">
|
||||
<field name="employee_country_id" options='{"no_open": True, "no_create": True}' attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="identification_id" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="passport_id" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="gender" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="birthday" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="place_of_birth" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="country_of_birth" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="employee_parent_id" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="coach_id" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<group string="Marital Status">
|
||||
<field name="marital" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="spouse_complete_name" attrs="{'invisible': [('marital', 'not in', ['married', 'cohabitant'])], 'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="spouse_birthdate" attrs="{'invisible': [('marital', 'not in', ['married', 'cohabitant'])], 'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
<group string="Education">
|
||||
<field name="certificate" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="study_field" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="study_school" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
<group string="Dependant">
|
||||
<field name="children" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
<group string="Emergency">
|
||||
<field name="emergency_contact" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="emergency_phone" widget="phone" attrs="{'readonly': [('can_edit', '=', False)]}" options="{'enable_sms': false}"/>
|
||||
</group>
|
||||
<group string="Work Permit" name="work_permit">
|
||||
<field name="visa_no" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="permit_no" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="visa_expire" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page name="preferences" string="Preferences">
|
||||
<div>$0</div>
|
||||
</page>
|
||||
<page name="public" string="Work Information">
|
||||
<div id="o_work_employee_container"> <!-- These two div are used to position org_chart -->
|
||||
<div id="o_work_employee_main">
|
||||
<group string="Location">
|
||||
<field name="department_id" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="address_id"
|
||||
context="{'show_address': 1}"
|
||||
options='{"always_reload": True, "highlight_first_line": True}'
|
||||
attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
<group name="managers" string="Approvers" class="hide-group-if-empty">
|
||||
<!-- overridden in other modules -->
|
||||
</group>
|
||||
</div>
|
||||
</div>
|
||||
</page>
|
||||
<page name="personal_information" string="Private Information">
|
||||
<group>
|
||||
<group string="Contact Information">
|
||||
<field name="employee_ids" invisible="1"/>
|
||||
<field name="address_home_id"
|
||||
context="{
|
||||
'show_address': 1,
|
||||
'default_employee_ids': employee_ids,
|
||||
'default_type': 'private',
|
||||
'form_view_ref': 'base.res_partner_view_form_private'}"
|
||||
options='{"always_reload": True, "highlight_first_line": True}'
|
||||
attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="private_email" string="Email" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="employee_phone" string="Phone" class="o_force_ltr" attrs="{'readonly': [('can_edit', '=', False)]}" options="{'enable_sms': false}"/>
|
||||
<field name="employee_bank_account_id" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="km_home_work" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
<group string="Citizenship">
|
||||
<field name="employee_country_id" options='{"no_open": True, "no_create": True}' attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="identification_id" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="passport_id" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="gender" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="birthday" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="place_of_birth" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="country_of_birth" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
<group string="Marital Status">
|
||||
<field name="marital" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="spouse_complete_name" attrs="{'invisible': [('marital', 'not in', ['married', 'cohabitant'])], 'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="spouse_birthdate" attrs="{'invisible': [('marital', 'not in', ['married', 'cohabitant'])], 'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
<group string="Education">
|
||||
<field name="certificate" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="study_field" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="study_school" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
<group string="Dependant">
|
||||
<field name="children" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
<group string="Emergency">
|
||||
<field name="emergency_contact" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="emergency_phone" widget="phone" attrs="{'readonly': [('can_edit', '=', False)]}" options="{'enable_sms': false}"/>
|
||||
</group>
|
||||
<group string="Work Permit" name="work_permit">
|
||||
<field name="visa_no" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="permit_no" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="visa_expire" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
<page name="hr_settings" string="HR Settings">
|
||||
<group>
|
||||
<group string='Status' name="active_group" invisible="1"></group>
|
||||
<group string="Attendance" name="identification_group">
|
||||
<field name="pin" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="barcode" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</form>
|
||||
</page>
|
||||
<page name="hr_settings" string="HR Settings">
|
||||
<group>
|
||||
<group string='Status' name="active_group" invisible="1"></group>
|
||||
<group string="Attendance" name="identification_group">
|
||||
<field name="pin" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
<field name="barcode" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="hr.res_users_view_form_profile"/>
|
||||
<field name="arch" type="xml">
|
||||
<button name="preference_change_password" position="before">
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="%(hr_leave_action_new_request)d"
|
||||
string="Request Time off"
|
||||
type="action"
|
||||
@@ -268,7 +268,7 @@
|
||||
string="Request Allocation"
|
||||
type="action"
|
||||
class="btn btn-primary"/>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='managers']" position="inside">
|
||||
<field name="leave_manager_id" attrs="{'readonly': [('can_edit', '=', False)]}"/>
|
||||
</xpath>
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import fields as odoo_fields, http, tools, _, SUPERUSER_ID
|
||||
from odoo.exceptions import ValidationError, AccessError, MissingError, UserError
|
||||
from odoo.exceptions import ValidationError, AccessError, MissingError, UserError, AccessDenied
|
||||
from odoo.http import content_disposition, Controller, request, route
|
||||
from odoo.tools import consteq
|
||||
|
||||
@@ -17,7 +19,7 @@ from odoo.tools import consteq
|
||||
# Misc tools
|
||||
# --------------------------------------------------
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
def pager(url, total, page=1, step=30, scope=5, url_args=None):
|
||||
""" Generate a dict with required value to render `website.pager` template. This method compute
|
||||
url, page range to display, ... in the pager.
|
||||
@@ -212,6 +214,46 @@ class CustomerPortal(Controller):
|
||||
response.headers['X-Frame-Options'] = 'DENY'
|
||||
return response
|
||||
|
||||
@route('/my/security', type='http', auth='user', website=True, methods=['GET', 'POST'])
|
||||
def security(self, **post):
|
||||
values = self._prepare_portal_layout_values()
|
||||
values['get_error'] = get_error
|
||||
|
||||
if request.httprequest.method == 'POST':
|
||||
values.update(self._update_password(
|
||||
post['old'].strip(),
|
||||
post['new1'].strip(),
|
||||
post['new2'].strip()
|
||||
))
|
||||
|
||||
return request.render('portal.portal_my_security', values, headers={
|
||||
'X-Frame-Options': 'DENY'
|
||||
})
|
||||
|
||||
def _update_password(self, old, new1, new2):
|
||||
for k, v in [('old', old), ('new1', new1), ('new2', new2)]:
|
||||
if not v:
|
||||
return {'errors': {'password': {k: _("You cannot leave any password empty.")}}}
|
||||
|
||||
if new1 != new2:
|
||||
return {'errors': {'password': {'new2': _("The new password and its confirmation must be identical.")}}}
|
||||
|
||||
try:
|
||||
request.env['res.users'].change_password(old, new1)
|
||||
except UserError as e:
|
||||
return {'errors': {'password': e.name}}
|
||||
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.')
|
||||
return {'errors': {'password': {'old': msg}}}
|
||||
|
||||
# update session token so the user does not get logged out (cache cleared by passwd change)
|
||||
new_token = request.env.user._compute_session_token(request.session.sid)
|
||||
request.session.session_token = new_token
|
||||
|
||||
return {'success': {'password': True}}
|
||||
|
||||
@http.route('/portal/attachment/add', type='http', auth='public', methods=['POST'], website=True)
|
||||
def attachment_add(self, name, file, res_model, res_id, access_token=None, **kwargs):
|
||||
"""Process a file uploaded from the portal chatter and create the
|
||||
@@ -392,3 +434,15 @@ class CustomerPortal(Controller):
|
||||
filename = "%s.pdf" % (re.sub('\W+', '-', model._get_report_base_filename()))
|
||||
reporthttpheaders.append(('Content-Disposition', content_disposition(filename)))
|
||||
return request.make_response(report, headers=reporthttpheaders)
|
||||
|
||||
def get_error(e, path=''):
|
||||
""" Recursively dereferences `path` (a period-separated sequence of dict
|
||||
keys) in `e` (an error dict or value), returns the final resolution IIF it's
|
||||
an str, otherwise returns None
|
||||
"""
|
||||
for k in (path.split('.') if path else []):
|
||||
if not isinstance(e, dict):
|
||||
return None
|
||||
e = e.get(k)
|
||||
|
||||
return e if isinstance(e, str) else None
|
||||
|
||||
@@ -16,7 +16,7 @@ class Home(Home):
|
||||
|
||||
def _login_redirect(self, uid, redirect=None):
|
||||
if not redirect and not request.env['res.users'].sudo().browse(uid).has_group('base.group_user'):
|
||||
return '/my'
|
||||
redirect = '/my'
|
||||
return super(Home, self)._login_redirect(uid, redirect=redirect)
|
||||
|
||||
@http.route('/web', type='http', auth="none")
|
||||
|
||||
@@ -2,6 +2,9 @@ odoo.define('portal.portal', function (require) {
|
||||
'use strict';
|
||||
|
||||
var publicWidget = require('web.public.widget');
|
||||
const Dialog = require('web.Dialog');
|
||||
const {_t, qweb} = require('web.core');
|
||||
const ajax = require('web.ajax');
|
||||
|
||||
publicWidget.registry.portalDetails = publicWidget.Widget.extend({
|
||||
selector: '.o_portal_details',
|
||||
@@ -119,4 +122,83 @@ publicWidget.registry.portalSearchPanel = publicWidget.Widget.extend({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const tmpl = ajax.loadXML('/portal/static/src/xml/portal_security.xml', qweb);
|
||||
|
||||
/**
|
||||
* Wraps an RPC call in a check for the result being an identity check action
|
||||
* descriptor. If no such result is found, just returns the wrapped promise's
|
||||
* result as-is; otherwise shows an identity check dialog and resumes the call
|
||||
* on success.
|
||||
*
|
||||
* Warning: does not in and of itself trigger an identity check, a promise which
|
||||
* never triggers and identity check internally will do nothing of use.
|
||||
*
|
||||
* @param {Function} rpc Widget#_rpc bound do the widget
|
||||
* @param {Promise} wrapped promise to check for an identity check request
|
||||
* @returns {Promise} result of the original call
|
||||
*/
|
||||
function handleCheckIdentity(rpc, wrapped) {
|
||||
return wrapped.then((r) => {
|
||||
if (!_.isMatch(r, {type: 'ir.actions.act_window', res_model: 'res.users.identitycheck'})) {
|
||||
return r;
|
||||
}
|
||||
const check_id = r.res_id;
|
||||
return tmpl.then(() => new Promise((resolve, reject) => {
|
||||
const d = new Dialog(null, {
|
||||
title: _t("Security Control"),
|
||||
$content: qweb.render('portal.identitycheck'),
|
||||
buttons: [{
|
||||
text: _t("Confirm Password"), classes: 'btn btn-primary',
|
||||
// nb: if click & close, waits for click to resolve before closing
|
||||
click() {
|
||||
const password_input = this.el.querySelector('[name=password]');
|
||||
if (!password_input.reportValidity()) {
|
||||
password_input.classList.add('is-invalid');
|
||||
return;
|
||||
}
|
||||
return rpc({
|
||||
model: 'res.users.identitycheck',
|
||||
method: 'write',
|
||||
args: [check_id, {password: password_input.value}]
|
||||
}).then(() => rpc({
|
||||
model: 'res.users.identitycheck',
|
||||
method: 'run_check',
|
||||
args: [check_id]
|
||||
})).then((r) => {
|
||||
this.close();
|
||||
resolve(r);
|
||||
}, (err) => {
|
||||
err.event.preventDefault(); // suppress crashmanager
|
||||
password_input.classList.add('is-invalid');
|
||||
password_input.setCustomValidity(_t("Check failed"));
|
||||
password_input.reportValidity();
|
||||
});
|
||||
}
|
||||
}, {
|
||||
text: _t('Cancel'), close: true
|
||||
}]
|
||||
}).on('close', null, () => {
|
||||
// unlink wizard object?
|
||||
reject();
|
||||
});
|
||||
d.opened(() => {
|
||||
const pw = d.el.querySelector('[name="password"]');
|
||||
pw.focus();
|
||||
pw.addEventListener('input', () => {
|
||||
pw.classList.remove('is-invalid');
|
||||
pw.setCustomValidity('');
|
||||
});
|
||||
d.el.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
d.$footer.find('.btn-primary').click();
|
||||
});
|
||||
});
|
||||
d.open();
|
||||
}));
|
||||
});
|
||||
}
|
||||
return {
|
||||
handleCheckIdentity,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -536,6 +536,25 @@ form label {
|
||||
}
|
||||
}
|
||||
|
||||
.o_portal_security_body {
|
||||
@extend .mx-auto;
|
||||
max-width: map-get($container-max-widths, sm);
|
||||
section {
|
||||
@extend .mt-4;
|
||||
form.oe_reset_password_form {
|
||||
max-width: initial;
|
||||
margin: initial;
|
||||
}
|
||||
.form-group {
|
||||
// for the absolutely positioned meter on new password
|
||||
position: relative;
|
||||
}
|
||||
label, button {
|
||||
@extend .text-nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copyright
|
||||
.o_footer_copyright {
|
||||
|
||||
@@ -551,3 +570,4 @@ form label {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="portal.identitycheck">
|
||||
<form>
|
||||
<div class="row container">
|
||||
<div class="col-10 offset-1">
|
||||
<div class="card bg-dark my-b mx-5">
|
||||
<div class="card-header">
|
||||
<i class="fa fa-bell mx-2"/>
|
||||
<span class="text-uppercase">Security Control</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<a href="/web/reset_password/" class="float-right">Forgot password?</a>
|
||||
<h4 class="card-title">Please confirm your password to continue</h4>
|
||||
<p class="card-text">
|
||||
This is necessary for security-related changes. The authorization will last for a few minutes.
|
||||
</p>
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<input class="form-control" autocomplete="current-password"
|
||||
name="password" type="password" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,4 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_load_process
|
||||
from . import test_tours
|
||||
|
||||
@@ -117,6 +117,11 @@
|
||||
<hr class="mt-1 mb-0"/>
|
||||
<div t-field="user_id.partner_id" t-options='{"widget": "contact", "fields": ["email", "phone", "address", "name"]}'/>
|
||||
</div>
|
||||
<div class="o_portal_my_security mt-3">
|
||||
<h4>Account Security </h4>
|
||||
<hr class="mt-1 mb-1"/>
|
||||
<a href="/my/security"><i class="fa fa-pencil mx-1"/>Edit Security Settings</a>
|
||||
</div>
|
||||
<div class="o_my_archives" t-if="archive_groups">
|
||||
<t t-call="portal.portal_archive_groups"/>
|
||||
</div>
|
||||
@@ -438,6 +443,58 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="portal_my_security">
|
||||
<t t-call="portal.portal_layout"><div class="o_portal_security_body">
|
||||
<t t-set="additional_title">Security</t>
|
||||
<t t-set="no_breadcrumbs" t-value="1"/>
|
||||
<div class="alert alert-danger" role="alert" t-if="get_error(errors)">
|
||||
<t t-esc="errors"/>
|
||||
</div>
|
||||
<section>
|
||||
<h3>Change Password</h3>
|
||||
<t t-set="path">password</t>
|
||||
<div class="alert alert-success" role="alert" t-if="success and success.get('password')">
|
||||
Password Updated!
|
||||
</div>
|
||||
<div class="alert alert-danger" role="alert" t-if="get_error(errors, 'password')">
|
||||
<t t-esc="errors['password']"/>
|
||||
</div>
|
||||
<form action="/my/security" method="post" class="oe_reset_password_form">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="op" value="password"/>
|
||||
<div class="form-group">
|
||||
<label for="current">Password:</label>
|
||||
<input type="password" t-attf-class="form-control form-control-sm {{ 'is-invalid' if get_error(errors, 'password.old') else '' }}"
|
||||
id="current" name="old"
|
||||
autocomplete="current-password" required="required"/>
|
||||
<div class="invalid-feedback">
|
||||
<t t-esc="get_error(errors, 'password.old')"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new">New Password:</label>
|
||||
<input type="password" t-attf-class="form-control form-control-sm {{ 'is-invalid' if get_error(errors, 'password.new1') else '' }}"
|
||||
id="new" name="new1"
|
||||
autocomplete="new-password" required="required"/>
|
||||
<div class="invalid-feedback">
|
||||
<t t-esc="get_error(errors, 'password.new1')"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new2">Verify New Password:</label>
|
||||
<input type="password" t-attf-class="form-control form-control-sm {{ 'is-invalid' if get_error(errors, 'password.new2') else '' }}"
|
||||
id="new2" name="new2"
|
||||
autocomplete="new-password" required="required"/>
|
||||
<div class="invalid-feedback">
|
||||
<t t-esc="get_error(errors, 'password.new2')"/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger">Change Password</button>
|
||||
</form>
|
||||
</section>
|
||||
</div></t>
|
||||
</template>
|
||||
|
||||
<template id="record_pager" name="Portal Record Pager">
|
||||
<t t-if='prev_record or next_record'>
|
||||
<div class="record_pager btn-group" role="group">
|
||||
|
||||
@@ -22,8 +22,8 @@ class XlsxCreatorCase(common.HttpCase):
|
||||
super().setUp()
|
||||
self.model = self.env[self.model_name]
|
||||
|
||||
u = mail_new_test_user(self.env, login='fof', password='123456789', groups='base.group_user,base.group_allow_export')
|
||||
self.authenticate('fof', '123456789')
|
||||
mail_new_test_user(self.env, login='fof', password='123456789', groups='base.group_user,base.group_allow_export')
|
||||
self.session = self.authenticate('fof', '123456789')
|
||||
|
||||
self.worksheet = {} # mock worksheet
|
||||
|
||||
|
||||
@@ -297,8 +297,26 @@ def make_conditional(response, last_modified=None, etag=None, max_age=0):
|
||||
response.set_etag(etag)
|
||||
return response.make_conditional(request.httprequest)
|
||||
|
||||
def _get_login_redirect_url(uid, redirect=None):
|
||||
""" Decide if user requires a specific post-login redirect, e.g. for 2FA, or if they are
|
||||
fully logged and can proceed to the requested URL
|
||||
"""
|
||||
if request.session.uid: # fully logged
|
||||
return redirect or '/web'
|
||||
|
||||
# partial session (MFA)
|
||||
url = request.env(user=uid)['res.users'].browse(uid)._mfa_url()
|
||||
if not redirect:
|
||||
return url
|
||||
|
||||
parsed = werkzeug.urls.url_parse(url)
|
||||
qs = parsed.decode_query()
|
||||
qs['redirect'] = redirect
|
||||
return parsed.replace(query=werkzeug.urls.url_encode(qs)).to_url()
|
||||
|
||||
def login_and_redirect(db, login, key, redirect_url='/web'):
|
||||
request.session.authenticate(db, login, key)
|
||||
uid = request.session.authenticate(db, login, key)
|
||||
redirect_url = _get_login_redirect_url(uid, redirect_url)
|
||||
return set_cookie_and_redirect(redirect_url)
|
||||
|
||||
def set_cookie_and_redirect(redirect_url):
|
||||
@@ -869,7 +887,7 @@ class Home(http.Controller):
|
||||
return response
|
||||
|
||||
def _login_redirect(self, uid, redirect=None):
|
||||
return redirect if redirect else '/web'
|
||||
return _get_login_redirect_url(uid, redirect)
|
||||
|
||||
@http.route('/web/login', type='http', auth="none")
|
||||
def web_login(self, redirect=None, **kw):
|
||||
@@ -918,7 +936,8 @@ class Home(http.Controller):
|
||||
uid = request.env.user.id
|
||||
if request.env.user._is_system():
|
||||
uid = request.session.uid = odoo.SUPERUSER_ID
|
||||
request.env['res.users']._invalidate_session_cache()
|
||||
# invalidate session token cache as we've changed the uid
|
||||
request.env['res.users'].clear_caches()
|
||||
request.session.session_token = security.compute_session_token(request.session, request.env)
|
||||
|
||||
return http.local_redirect(self._login_redirect(uid), keep_hash=True)
|
||||
|
||||
@@ -262,7 +262,7 @@ var InputField = DebouncedField.extend({
|
||||
var inputAttrs = { placeholder: this.attrs.placeholder || "" };
|
||||
var inputVal;
|
||||
if (this.nodeOptions.isPassword) {
|
||||
inputAttrs = _.extend(inputAttrs, { type: 'password', autocomplete: 'new-password' });
|
||||
inputAttrs = _.extend(inputAttrs, { type: 'password', autocomplete: this.attrs.autocomplete || 'new-password' });
|
||||
inputVal = this.value || '';
|
||||
} else {
|
||||
inputAttrs = _.extend(inputAttrs, { type: 'text', autocomplete: this.attrs.autocomplete || 'none'});
|
||||
|
||||
@@ -95,16 +95,21 @@ class Website(Home):
|
||||
# while portal users are redirected to the frontend by default
|
||||
# ------------------------------------------------------
|
||||
|
||||
@http.route(website=True, auth="public", sitemap=False)
|
||||
def web_login(self, redirect=None, *args, **kw):
|
||||
response = super(Website, self).web_login(redirect=redirect, *args, **kw)
|
||||
def _login_redirect(self, uid, redirect=None):
|
||||
""" Redirect regular users (employees) to the backend) and others to
|
||||
the frontend
|
||||
"""
|
||||
if not redirect and request.params['login_success']:
|
||||
if request.env['res.users'].browse(request.uid).has_group('base.group_user'):
|
||||
if request.env['res.users'].browse(uid).has_group('base.group_user'):
|
||||
redirect = b'/web?' + request.httprequest.query_string
|
||||
else:
|
||||
redirect = '/my'
|
||||
return http.redirect_with_hash(redirect)
|
||||
return response
|
||||
return super()._login_redirect(uid, redirect=redirect)
|
||||
|
||||
# Force website=True + auth='public', required for login form layout
|
||||
@http.route(website=True, auth="public", sitemap=False)
|
||||
def web_login(self, *args, **kw):
|
||||
return super().web_login(*args, **kw)
|
||||
|
||||
# ------------------------------------------------------
|
||||
# Business
|
||||
|
||||
@@ -222,6 +222,7 @@ class WithContext(HttpCase):
|
||||
specific_page = self.page.copy({'website_id': self.env['website'].get_current_website().id})
|
||||
specific_page.write({'is_published': False, 'arch': self.page.arch.replace('I am a generic page', 'I am a specific page')})
|
||||
|
||||
self.authenticate(None, None)
|
||||
r = self.url_open(specific_page.url)
|
||||
self.assertEqual(r.status_code, 404, "Restricted users should see a 404 and not the generic one as we unpublished the specific one")
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ from odoo.http import request
|
||||
class ResUsers(models.Model):
|
||||
_inherit = "res.users"
|
||||
|
||||
def _check_credentials(self, password):
|
||||
def _check_credentials(self, password, env):
|
||||
"""Make all wishlists from session belong to its owner user."""
|
||||
result = super(ResUsers, self)._check_credentials(password)
|
||||
result = super(ResUsers, self)._check_credentials(password, env)
|
||||
if request and request.session.get('wishlist_ids'):
|
||||
self.env["product.wishlist"]._check_wishlist_from_session()
|
||||
return result
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import contextlib
|
||||
|
||||
import base64
|
||||
import pytz
|
||||
import binascii
|
||||
import contextlib
|
||||
import datetime
|
||||
import hmac
|
||||
import ipaddress
|
||||
import itertools
|
||||
import json
|
||||
import logging
|
||||
import hmac
|
||||
|
||||
import os
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from hashlib import sha256
|
||||
from itertools import chain, repeat
|
||||
|
||||
import decorator
|
||||
import passlib.context
|
||||
import pytz
|
||||
from lxml import etree
|
||||
from lxml.builder import E
|
||||
import passlib.context
|
||||
|
||||
from odoo import api, fields, models, tools, SUPERUSER_ID, _
|
||||
from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
|
||||
from odoo.exceptions import AccessDenied, AccessError, UserError, ValidationError
|
||||
from odoo.http import request
|
||||
from odoo.modules.module import get_module_resource
|
||||
from odoo.osv import expression
|
||||
from odoo.service.db import check_super
|
||||
from odoo.tools import partition, collections, frozendict, lazy_property, image_process
|
||||
from odoo.modules.module import get_module_resource
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -83,6 +88,47 @@ def parse_m2m(commands):
|
||||
ids.append(command)
|
||||
return ids
|
||||
|
||||
def _jsonable(o):
|
||||
try: json.dumps(o)
|
||||
except TypeError: return False
|
||||
else: return True
|
||||
|
||||
@decorator.decorator
|
||||
def check_identity(fn, self):
|
||||
""" Wrapped method should be an *action method* (called from a button
|
||||
type=object), and requires extra security to be executed. This decorator
|
||||
checks if the identity (password) has been checked in the last 10mn, and
|
||||
pops up an identity check wizard if not.
|
||||
|
||||
Prevents access outside of interactive contexts (aka with a request)
|
||||
"""
|
||||
if not request:
|
||||
raise UserError(_("This method can only be accessed over HTTP"))
|
||||
|
||||
if request.session.get('identity-check-last', 0) > time.time() - 10 * 60:
|
||||
# update identity-check-last like github?
|
||||
return fn(self)
|
||||
|
||||
w = self.sudo().env['res.users.identitycheck'].create({
|
||||
'request': json.dumps([
|
||||
{ # strip non-jsonable keys (e.g. mapped to recordsets like binary_field_real_user)
|
||||
k: v for k, v in self.env.context.items()
|
||||
if _jsonable(v)
|
||||
},
|
||||
self._name,
|
||||
self.ids,
|
||||
fn.__name__
|
||||
])
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'res.users.identitycheck',
|
||||
'res_id': w.id,
|
||||
'name': _("Security Control"),
|
||||
'target': 'new',
|
||||
'views': [(False, 'form')],
|
||||
}
|
||||
|
||||
#----------------------------------------------------------
|
||||
# Basic res.groups and res.users
|
||||
#----------------------------------------------------------
|
||||
@@ -214,7 +260,6 @@ class Users(models.Model):
|
||||
_description = 'Users'
|
||||
_inherits = {'res.partner': 'partner_id'}
|
||||
_order = 'name, login'
|
||||
__uid_cache = defaultdict(dict) # {dbname: {uid: password}}
|
||||
|
||||
# User can write on a few of his own fields (but not his groups for example)
|
||||
SELF_WRITEABLE_FIELDS = ['signature', 'action_id', 'company_id', 'email', 'name', 'image_1920', 'lang', 'tz']
|
||||
@@ -318,7 +363,7 @@ class Users(models.Model):
|
||||
)
|
||||
self.invalidate_cache(['password'], [uid])
|
||||
|
||||
def _check_credentials(self, password):
|
||||
def _check_credentials(self, password, env):
|
||||
""" Validates the current user's password.
|
||||
|
||||
Override this method to plug additional authentication methods.
|
||||
@@ -545,26 +590,24 @@ class Users(models.Model):
|
||||
# DLE P139: Calling invalidate_cache on a new, well you lost everything as you wont be able to take it back from the cache
|
||||
# `test_00_equipment_multicompany_user`
|
||||
self.env['ir.model.access'].call_cache_clearing_methods()
|
||||
self.env['ir.rule'].clear_caches()
|
||||
self.has_group.clear_cache(self)
|
||||
if any(key.startswith('context_') or key in ('lang', 'tz') for key in values):
|
||||
self.context_get.clear_cache(self)
|
||||
if any(key in values for key in ['active'] + USER_PRIVATE_FIELDS):
|
||||
db = self._cr.dbname
|
||||
for id in self.ids:
|
||||
self.__uid_cache[db].pop(id, None)
|
||||
if any(key in values for key in self._get_session_token_fields()):
|
||||
self._invalidate_session_cache()
|
||||
|
||||
# per-method / per-model caches have been removed so the various
|
||||
# clear_cache/clear_caches methods pretty much just end up calling
|
||||
# Registry._clear_cache
|
||||
invalidation_fields = {
|
||||
'groups_id', 'active', 'lang', 'tz', 'company_id',
|
||||
*USER_PRIVATE_FIELDS,
|
||||
*self._get_session_token_fields()
|
||||
}
|
||||
if (invalidation_fields & values.keys()) or any(key.startswith('context_') for key in values):
|
||||
self.clear_caches()
|
||||
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
if SUPERUSER_ID in self.ids:
|
||||
raise UserError(_('You can not remove the admin user as it is used internally for resources created by Odoo (updates, module installation, ...)'))
|
||||
db = self._cr.dbname
|
||||
for id in self.ids:
|
||||
self.__uid_cache[db].pop(id, None)
|
||||
self._invalidate_session_cache()
|
||||
self.clear_caches()
|
||||
return super(Users, self).unlink()
|
||||
|
||||
@api.model
|
||||
@@ -630,7 +673,7 @@ class Users(models.Model):
|
||||
return self._order
|
||||
|
||||
@classmethod
|
||||
def _login(cls, db, login, password):
|
||||
def _login(cls, db, login, password, user_agent_env):
|
||||
if not password:
|
||||
raise AccessDenied()
|
||||
ip = request.httprequest.environ['REMOTE_ADDR'] if request else 'n/a'
|
||||
@@ -642,7 +685,7 @@ class Users(models.Model):
|
||||
if not user:
|
||||
raise AccessDenied()
|
||||
user = user.with_user(user)
|
||||
user._check_credentials(password)
|
||||
user._check_credentials(password, user_agent_env)
|
||||
tz = request.httprequest.cookies.get('tz') if request else None
|
||||
if tz in pytz.all_timezones and (not user.tz or not user.login_date):
|
||||
# first login or missing tz -> set tz to browser tz
|
||||
@@ -667,7 +710,7 @@ class Users(models.Model):
|
||||
:param dict user_agent_env: environment dictionary describing any
|
||||
relevant environment attributes
|
||||
"""
|
||||
uid = cls._login(db, login, password)
|
||||
uid = cls._login(db, login, password, user_agent_env=user_agent_env)
|
||||
if user_agent_env and user_agent_env.get('base_location'):
|
||||
with cls.pool.cursor() as cr:
|
||||
env = api.Environment(cr, uid, {})
|
||||
@@ -684,23 +727,20 @@ class Users(models.Model):
|
||||
return uid
|
||||
|
||||
@classmethod
|
||||
@tools.ormcache('uid', 'passwd')
|
||||
def check(cls, db, uid, passwd):
|
||||
"""Verifies that the given (uid, password) is authorized for the database ``db`` and
|
||||
raise an exception if it is not."""
|
||||
if not passwd:
|
||||
# empty passwords disallowed for obvious security reasons
|
||||
raise AccessDenied()
|
||||
db = cls.pool.db_name
|
||||
if cls.__uid_cache[db].get(uid) == passwd:
|
||||
return
|
||||
cr = cls.pool.cursor()
|
||||
try:
|
||||
|
||||
with contextlib.closing(cls.pool.cursor()) as cr:
|
||||
self = api.Environment(cr, uid, {})[cls._name]
|
||||
with self._assert_can_auth():
|
||||
self._check_credentials(passwd)
|
||||
cls.__uid_cache[db][uid] = passwd
|
||||
finally:
|
||||
cr.close()
|
||||
if not self.env.user.active:
|
||||
raise AccessDenied()
|
||||
self._check_credentials(passwd, {'interactive': False})
|
||||
|
||||
def _get_session_token_fields(self):
|
||||
return {'id', 'login', 'password', 'active'}
|
||||
@@ -714,7 +754,7 @@ class Users(models.Model):
|
||||
FROM res_users
|
||||
WHERE id=%%s""" % (session_fields), (self.id,))
|
||||
if self.env.cr.rowcount != 1:
|
||||
self._invalidate_session_cache()
|
||||
self.clear_caches()
|
||||
return False
|
||||
data_fields = self.env.cr.fetchone()
|
||||
# generate hmac key
|
||||
@@ -725,10 +765,6 @@ class Users(models.Model):
|
||||
# keep in the cache the token
|
||||
return h.hexdigest()
|
||||
|
||||
def _invalidate_session_cache(self):
|
||||
""" Clear the sessions cache """
|
||||
self._compute_session_token.clear_cache(self)
|
||||
|
||||
@api.model
|
||||
def change_password(self, old_passwd, new_passwd):
|
||||
"""Change current user password. Old password must be provided explicitly
|
||||
@@ -739,11 +775,19 @@ class Users(models.Model):
|
||||
:raise: odoo.exceptions.AccessDenied when old password is wrong
|
||||
:raise: odoo.exceptions.UserError when new password is not set or empty
|
||||
"""
|
||||
self.check(self._cr.dbname, self._uid, old_passwd)
|
||||
if new_passwd:
|
||||
# use self.env.user here, because it has uid=SUPERUSER_ID
|
||||
return self.env.user.write({'password': new_passwd})
|
||||
raise UserError(_("Setting empty passwords is not allowed for security reasons!"))
|
||||
if not old_passwd:
|
||||
raise AccessDenied()
|
||||
if not new_passwd:
|
||||
raise UserError(_("Setting empty passwords is not allowed for security reasons!"))
|
||||
|
||||
# alternatively: use identitycheck wizard?
|
||||
self._check_credentials(old_passwd, {'interactive': True})
|
||||
|
||||
ip = request.httprequest.environ['REMOTE_ADDR'] if request else 'n/a'
|
||||
_logger.info("Password change for '%s' (#%s) from %s", self.env.user.login, self.env.uid, ip)
|
||||
|
||||
# use self.env.user here, because it has uid=SUPERUSER_ID
|
||||
return self.env.user.write({'password': new_passwd})
|
||||
|
||||
def preference_save(self):
|
||||
return {
|
||||
@@ -954,6 +998,9 @@ class Users(models.Model):
|
||||
return 'base/static/img/user-slash.png'
|
||||
return super()._get_placeholder_filename(field=field)
|
||||
|
||||
def _mfa_url(self):
|
||||
""" If an MFA method is enabled, returns the URL for its second step. """
|
||||
return
|
||||
#
|
||||
# Implied groups
|
||||
#
|
||||
@@ -1417,6 +1464,29 @@ class UsersView(models.Model):
|
||||
}
|
||||
return res
|
||||
|
||||
class CheckIdentity(models.TransientModel):
|
||||
""" Wizard used to re-check the user's credentials (password)
|
||||
|
||||
Might be useful before the more security-sensitive operations, users might be
|
||||
leaving their computer unlocked & unattended. Re-checking credentials mitigates
|
||||
some of the risk of a third party using such an unattended device to manipulate
|
||||
the account.
|
||||
"""
|
||||
_name = 'res.users.identitycheck'
|
||||
_description = "Password Check Wizard"
|
||||
|
||||
request = fields.Char(readonly=True, groups='.') # no access
|
||||
password = fields.Char()
|
||||
|
||||
def run_check(self):
|
||||
assert request, "This method can only be accessed over HTTP"
|
||||
self.create_uid._check_credentials(self.password, {'interactive': True})
|
||||
self.password = False
|
||||
|
||||
request.session['identity-check-last'] = time.time()
|
||||
ctx, model, ids, method = json.loads(self.sudo().request)
|
||||
return getattr(self.env(context=ctx)[model].browse(ids), method)()
|
||||
|
||||
#----------------------------------------------------------
|
||||
# change password wizard
|
||||
#----------------------------------------------------------
|
||||
@@ -1460,3 +1530,150 @@ class ChangePasswordUser(models.TransientModel):
|
||||
line.user_id.write({'password': line.new_passwd})
|
||||
# don't keep temporary passwords in the database longer than necessary
|
||||
self.write({'new_passwd': False})
|
||||
|
||||
# API keys support
|
||||
API_KEY_SIZE = 20 # in bytes
|
||||
INDEX_SIZE = 8 # in hex digits, so 4 bytes, or 20% of the key
|
||||
KEY_CRYPT_CONTEXT = passlib.context.CryptContext(
|
||||
# default is 29000 rounds which is 25~50ms, which is probably unnecessary
|
||||
# given in this case all the keys are completely random data: dictionary
|
||||
# attacks on API keys isn't much of a concern
|
||||
['pbkdf2_sha512'], pbkdf2_sha512__rounds=6000,
|
||||
)
|
||||
hash_api_key = getattr(KEY_CRYPT_CONTEXT, 'hash', None) or KEY_CRYPT_CONTEXT.encrypt
|
||||
class APIKeysUser(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
api_key_ids = fields.One2many('res.users.apikeys', 'user_id', string="API Keys")
|
||||
|
||||
def __init__(self, pool, cr):
|
||||
init_res = super().__init__(pool, cr)
|
||||
# duplicate list to avoid modifying the original reference
|
||||
type(self).SELF_WRITEABLE_FIELDS = self.SELF_WRITEABLE_FIELDS + ['api_key_ids']
|
||||
type(self).SELF_READABLE_FIELDS = self.SELF_READABLE_FIELDS + ['api_key_ids']
|
||||
return init_res
|
||||
|
||||
def _rpc_api_keys_only(self):
|
||||
""" To be overridden if RPC access needs to be restricted to API keys, e.g. for 2FA """
|
||||
return False
|
||||
|
||||
def _check_credentials(self, password, user_agent_env):
|
||||
if user_agent_env['interactive']:
|
||||
return super()._check_credentials(password, user_agent_env)
|
||||
|
||||
if not self.env.user._rpc_api_keys_only():
|
||||
try:
|
||||
return super()._check_credentials(password, user_agent_env)
|
||||
except AccessDenied:
|
||||
pass
|
||||
|
||||
# 'rpc' scope does not really exist, we basically require a global key (scope NULL)
|
||||
if self.env['res.users.apikeys']._check_credentials(scope='rpc', key=password) == self.env.uid:
|
||||
return
|
||||
|
||||
raise AccessDenied()
|
||||
|
||||
@check_identity
|
||||
def api_key_wizard(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'res.users.apikeys.description',
|
||||
'name': 'New API Key',
|
||||
'target': 'new',
|
||||
'views': [(False, 'form')],
|
||||
}
|
||||
|
||||
class APIKeys(models.Model):
|
||||
_name = _description = 'res.users.apikeys'
|
||||
_auto = False # so we can have a secret column
|
||||
|
||||
name = fields.Char("Description", required=True, readonly=True)
|
||||
user_id = fields.Many2one('res.users', index=True, required=True, readonly=True, ondelete="cascade")
|
||||
scope = fields.Char("Scope", readonly=True)
|
||||
create_date = fields.Datetime("Creation Date", readonly=True)
|
||||
|
||||
def init(self):
|
||||
# pylint: disable=sql-injection
|
||||
self.env.cr.execute("""
|
||||
CREATE TABLE IF NOT EXISTS {table} (
|
||||
id serial primary key,
|
||||
name varchar not null,
|
||||
user_id integer not null REFERENCES res_users(id),
|
||||
scope varchar,
|
||||
index varchar({index_size}) not null CHECK (char_length(index) = {index_size}),
|
||||
key varchar not null,
|
||||
create_date timestamp without time zone DEFAULT (now() at time zone 'utc')
|
||||
);
|
||||
CREATE INDEX ON {table} (user_id, index);
|
||||
""".format(table=self._table, index_size=INDEX_SIZE))
|
||||
|
||||
@check_identity
|
||||
def remove(self):
|
||||
if self.env.is_system() or self.mapped('user_id') == self.env.user:
|
||||
ip = request.httprequest.environ['REMOTE_ADDR'] if request else 'n/a'
|
||||
_logger.info("API key(s) removed: scope: <%s> for '%s' (#%s) from %s",
|
||||
self.mapped('scope'), self.env.user.login, self.env.uid, ip)
|
||||
self.sudo().unlink()
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
raise AccessError(_("You can not remove API keys unless they're yours or you are a system user"))
|
||||
|
||||
def _check_credentials(self, *, scope, key):
|
||||
assert scope, "scope is required"
|
||||
index = key[:INDEX_SIZE]
|
||||
self.env.cr.execute('SELECT user_id, key FROM res_users_apikeys WHERE index = %s AND (scope IS NULL OR scope = %s)', [index, scope])
|
||||
for user_id, current_key in self.env.cr.fetchall():
|
||||
if KEY_CRYPT_CONTEXT.verify(key, current_key):
|
||||
return user_id
|
||||
|
||||
def _generate(self, scope, name):
|
||||
"""Generates an api key.
|
||||
:param str scope: the scope of the key. If None, the key will give access to any rpc.
|
||||
:param str name: the name of the key, mainly intended to be displayed in the UI.
|
||||
:return: str: the key.
|
||||
|
||||
"""
|
||||
# no need to clear the LRU when *adding* a key, only when removing
|
||||
k = binascii.hexlify(os.urandom(API_KEY_SIZE)).decode()
|
||||
self.env.cr.execute("""
|
||||
INSERT INTO res_users_apikeys (name, user_id, scope, key, index)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
[name, self.env.user.id, scope, hash_api_key(k), k[:INDEX_SIZE]])
|
||||
|
||||
ip = request.httprequest.environ['REMOTE_ADDR'] if request else 'n/a'
|
||||
_logger.info("API key generated: scope: <%s> for '%s' (#%s) from %s",
|
||||
scope, self.env.user.login, self.env.uid, ip)
|
||||
|
||||
return k
|
||||
|
||||
class APIKeyDescription(models.TransientModel):
|
||||
_name = _description = 'res.users.apikeys.description'
|
||||
|
||||
name = fields.Char("Description", required=True)
|
||||
|
||||
@check_identity
|
||||
def make_key(self):
|
||||
# only create keys for users who can delete their keys
|
||||
if not self.user_has_groups('base.group_user'):
|
||||
raise AccessError(_("Only internal users can create API keys"))
|
||||
|
||||
description = self.sudo()
|
||||
k = self.env['res.users.apikeys']._generate(None, self.sudo().name)
|
||||
description.unlink()
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'res.users.apikeys.show',
|
||||
'name': 'API Key Ready',
|
||||
'views': [(False, 'form')],
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_key': k,
|
||||
}
|
||||
}
|
||||
|
||||
class APIKeyShow(models.AbstractModel):
|
||||
_name = _description = 'res.users.apikeys.show'
|
||||
|
||||
key = fields.Char(readonly=True)
|
||||
|
||||
@@ -171,6 +171,12 @@
|
||||
<field name="domain_force">[(1,'=',1)]</field>
|
||||
</record>
|
||||
|
||||
<record id="res_users_identity_check" model="ir.rule">
|
||||
<field name="name">users can only access their own id check</field>
|
||||
<field name="model_id" ref="model_res_users_identitycheck"/>
|
||||
<field name="domain_force">[('create_uid', '=', user.id)]</field>
|
||||
</record>
|
||||
|
||||
<!-- Record Rule For User -->
|
||||
<record id="res_users_rule" model="ir.rule">
|
||||
<field name="name">user rule</field>
|
||||
@@ -178,5 +184,28 @@
|
||||
<field eval="True" name="global"/>
|
||||
<field name="domain_force">['|', ('share', '=', False), ('company_ids', 'in', company_ids)]</field>
|
||||
</record>
|
||||
|
||||
<!-- rules for API token -->
|
||||
<record id="api_key_public" model="ir.rule">
|
||||
<field name="name">Public users can't interact with keys at all</field>
|
||||
<field name="model_id" ref="model_res_users_apikeys"/>
|
||||
<field name="domain_force">[(0, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_public'))]"/>
|
||||
</record>
|
||||
<record id="api_key_user" model="ir.rule">
|
||||
<field name="name">Users can read and delete their own keys</field>
|
||||
<field name="model_id" ref="model_res_users_apikeys"/>
|
||||
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[
|
||||
(4, ref('base.group_portal')),
|
||||
(4, ref('base.group_user')),
|
||||
]"/>
|
||||
</record>
|
||||
<record id="api_key_admin" model="ir.rule">
|
||||
<field name="name">Administrators can view user keys to revoke them</field>
|
||||
<field name="model_id" ref="model_res_users_apikeys"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_system'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -69,6 +69,13 @@
|
||||
"access_res_users_all","res_users all","model_res_users",,1,0,0,0
|
||||
"access_res_users_group_erp_manager","res_users group_erp_manager","model_res_users","group_erp_manager",1,1,1,1
|
||||
"access_res_users_log_all","res_users_log_all","model_res_users_log",,1,0,1,0
|
||||
"access_res_users_identitycheck_employee","id check employees","model_res_users_identitycheck","group_user",1,1,1,0
|
||||
"access_res_users_identitycheck_portal","id check portal","model_res_users_identitycheck","group_portal",1,1,1,0
|
||||
"access_res_users_apikeys_access_employee","API keys access employees","model_res_users_apikeys","group_user",1,0,0,0
|
||||
"access_res_users_apikeys_access_portal","API keys access portal","model_res_users_apikeys","group_portal",1,0,0,0
|
||||
"access_res_users_apikeys_description_employee","API key wizard employees","model_res_users_apikeys_description","group_user",1,0,1,0
|
||||
"access_res_users_apikeys_description_portal","API key wizard","model_res_users_apikeys_description","group_portal",1,0,1,0
|
||||
"access_res_users_apikeys_show_employee","API key result employees","model_res_users_apikeys_show","group_user",1,0,1,0
|
||||
"access_ir_actions_all","ir_actions_all","model_ir_actions_actions",,1,0,0,0
|
||||
"access_ir_actions_group_system","ir_actions_group_system","model_ir_actions_actions","group_system",1,1,1,1
|
||||
"access_ir_actions_act_window_all","ir_actions_act_window_all","model_ir_actions_act_window",,1,0,0,0
|
||||
|
||||
|
@@ -1,8 +1,14 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import time
|
||||
|
||||
from odoo.exceptions import AccessDenied, AccessError
|
||||
from odoo.http import _request_stack
|
||||
|
||||
import odoo.tools
|
||||
from odoo.tests import common
|
||||
from odoo.service import common as auth, model
|
||||
from odoo.tools import DotDict
|
||||
|
||||
|
||||
@common.tagged('post_install', '-at_install')
|
||||
@@ -64,3 +70,109 @@ class TestXMLRPC(common.HttpCase):
|
||||
'args': args
|
||||
}
|
||||
})
|
||||
|
||||
# really just for the test cursor
|
||||
@common.tagged('post_install', '-at_install')
|
||||
class TestAPIKeys(common.HttpCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self._user = self.env['res.users'].create({
|
||||
'name': "Bylan",
|
||||
'login': 'byl',
|
||||
'password': 'ananananan',
|
||||
'tz': 'Australia/Eucla',
|
||||
})
|
||||
|
||||
# needs a fake request in order to call methods protected with check_identity
|
||||
fake_req = DotDict({
|
||||
# various things go and access request items
|
||||
'httprequest': DotDict({
|
||||
'environ': {'REMOTE_ADDR': 'localhost'},
|
||||
'cookies': {},
|
||||
}),
|
||||
# bypass check_identity flow
|
||||
'session': {'identity-check-last': time.time()}
|
||||
})
|
||||
_request_stack.push(fake_req)
|
||||
self.addCleanup(_request_stack.pop)
|
||||
|
||||
def test_trivial(self):
|
||||
uid = auth.dispatch('authenticate', [self.env.cr.dbname, 'byl', 'ananananan', {}])
|
||||
self.assertEqual(uid, self._user.id)
|
||||
|
||||
ctx = model.dispatch('execute_kw', [
|
||||
self.env.cr.dbname, uid, 'ananananan',
|
||||
'res.users', 'context_get', []
|
||||
])
|
||||
self.assertEqual(ctx['tz'], 'Australia/Eucla')
|
||||
|
||||
def test_wrongpw(self):
|
||||
# User.authenticate raises but RPC.authenticate returns False
|
||||
uid = auth.dispatch('authenticate', [self.env.cr.dbname, 'byl', 'aws', {}])
|
||||
self.assertFalse(uid)
|
||||
with self.assertRaises(AccessDenied):
|
||||
model.dispatch('execute_kw', [
|
||||
self.env.cr.dbname, self._user.id, 'aws',
|
||||
'res.users', 'context_get', []
|
||||
])
|
||||
|
||||
def test_key(self):
|
||||
env = self.env(user=self._user)
|
||||
r = env['res.users.apikeys.description'].create({
|
||||
'name': 'a',
|
||||
}).make_key()
|
||||
k = r['context']['default_key']
|
||||
|
||||
uid = auth.dispatch('authenticate', [self.env.cr.dbname, 'byl', 'ananananan', {}])
|
||||
self.assertEqual(uid, self._user.id)
|
||||
|
||||
uid = auth.dispatch('authenticate', [self.env.cr.dbname, 'byl', k, {}])
|
||||
self.assertEqual(uid, self._user.id)
|
||||
|
||||
ctx = model.dispatch('execute_kw', [
|
||||
self.env.cr.dbname, uid, k,
|
||||
'res.users', 'context_get', []
|
||||
])
|
||||
self.assertEqual(ctx['tz'], 'Australia/Eucla')
|
||||
|
||||
def test_delete(self):
|
||||
env = self.env(user=self._user)
|
||||
env['res.users.apikeys.description'].create({'name': 'b',}).make_key()
|
||||
env['res.users.apikeys.description'].create({'name': 'b',}).make_key()
|
||||
env['res.users.apikeys.description'].create({'name': 'b',}).make_key()
|
||||
k0, k1, k2 = env['res.users.apikeys'].search([])
|
||||
|
||||
# user can remove their own keys
|
||||
k0.remove()
|
||||
self.assertFalse(k0.exists())
|
||||
|
||||
# admin can remove user keys
|
||||
k1.with_user(self.env.ref('base.user_admin')).remove ()
|
||||
self.assertFalse(k1.exists())
|
||||
|
||||
# other user can't remove user keys
|
||||
u = self.env['res.users'].create({
|
||||
'name': 'a',
|
||||
'login': 'a',
|
||||
'groups_id': self.env.ref('base.group_user').ids,
|
||||
})
|
||||
with self.assertRaises(AccessError):
|
||||
k2.with_user(u).remove()
|
||||
|
||||
def test_disabled(self):
|
||||
env = self.env(user=self._user)
|
||||
k = env['res.users.apikeys.description'].create({'name': 'b',}).make_key()['context']['default_key']
|
||||
|
||||
self._user.active = False
|
||||
|
||||
with self.assertRaises(AccessDenied):
|
||||
model.dispatch('execute_kw', [
|
||||
self.env.cr.dbname, self._user.id, 'ananananan',
|
||||
'res.users', 'context_get', []
|
||||
])
|
||||
|
||||
with self.assertRaises(AccessDenied):
|
||||
model.dispatch('execute_kw', [
|
||||
self.env.cr.dbname, self._user.id, k,
|
||||
'res.users', 'context_get', []
|
||||
])
|
||||
|
||||
@@ -35,6 +35,40 @@
|
||||
<field name="binding_model_id" ref="base.model_res_users"/>
|
||||
</record>
|
||||
|
||||
<record id="identity_check_wizard" model="ir.ui.view">
|
||||
<field name="model">res.users.identitycheck</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Security Control">
|
||||
<sheet>
|
||||
<div class="row container">
|
||||
<div class="col-8 offset-2">
|
||||
<div class="card bg-dark my-b mx-5">
|
||||
<div class="card-header">
|
||||
<span class="fa fa-bell mx-2" t-translation="off">&nbsp;</span>
|
||||
<span class="text-uppercase">Security Control</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<a href="/web/reset_password/" class="float-right">Forgot password?</a>
|
||||
<h4 class="card-title">Please confirm your password to continue</h4>
|
||||
<p class="card-text">
|
||||
This is necessary for security-related changes. The authorization will last for a few minutes.
|
||||
</p>
|
||||
<div>
|
||||
<label for="password"/>
|
||||
<field class="col-4" name="password" autocomplete="current-password"
|
||||
required="True" password="True"/>
|
||||
</div>
|
||||
<button string="Confirm Password" type="object" name="run_check" class="btn btn-primary mt-3"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer><!-- no buttons --></footer>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- res.groups -->
|
||||
<record id="view_groups_search" model="ir.ui.view">
|
||||
<field name="name">res.groups.search</field>
|
||||
@@ -338,6 +372,65 @@
|
||||
</record>
|
||||
<menuitem action="action_res_users" id="menu_action_res_users" parent="base.menu_users" sequence="0"/>
|
||||
|
||||
|
||||
<record id="action_res_users_keys_description" model="ir.actions.act_window">
|
||||
<field name="name">API Key: description input wizard</field>
|
||||
<field name="res_model">res.users.apikeys.description</field>
|
||||
<field name="target">new</field>
|
||||
<field name="view_mode">form</field>
|
||||
</record>
|
||||
<record id="form_res_users_key_description" model="ir.ui.view">
|
||||
<field name="name">API Key: description input form</field>
|
||||
<field name="model">res.users.apikeys.description</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<h3 class="font-weight-bold">
|
||||
Name your key
|
||||
</h3>
|
||||
<p>Enter a description of and purpose for the key.</p>
|
||||
<field name="name" placeholder="What's this key for?"/>
|
||||
<p>
|
||||
It is very important that this description be clear
|
||||
and complete, <strong>it will be the only way to
|
||||
identify the key once created</strong>.
|
||||
</p>
|
||||
<footer>
|
||||
<button name="make_key" type="object" string="Generate key" class="btn-primary"/>
|
||||
<button special="cancel" string="Cancel" class="btn-secondary"/>
|
||||
</footer>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="form_res_users_key_show" model="ir.ui.view">
|
||||
<field name="name">API Key: show</field>
|
||||
<field name="model">res.users.apikeys.show</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<h3 class="font-weight-bold">
|
||||
Write down your key
|
||||
</h3>
|
||||
<p>
|
||||
Here is your new API key, use it instead of a password for RPC access.
|
||||
Your login is still necessary for interactive usage.
|
||||
</p>
|
||||
<p><code><field name="key" readonly="True"/></code></p>
|
||||
<p class="alert alert-warning" role="alert">
|
||||
<strong>Important:</strong>
|
||||
The key cannot be retrieved later and provides <b>full access</b>
|
||||
to your user account, it is very important to store it securely.
|
||||
</p>
|
||||
<footer>
|
||||
<button special="cancel" string="Done!"/>
|
||||
</footer>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_users_form_simple_modif" model="ir.ui.view">
|
||||
<field name="name">res.users.preferences.form</field>
|
||||
<field name="model">res.users</field>
|
||||
@@ -348,39 +441,64 @@
|
||||
<h1>
|
||||
<field name="name" readonly="1" class="oe_inline"/>
|
||||
</h1>
|
||||
<group name="preferences">
|
||||
<group>
|
||||
<field name="email" widget="email" readonly="0"/>
|
||||
</group>
|
||||
<group>
|
||||
<label for="lang"/>
|
||||
<div class="o_row">
|
||||
<field name="lang" required="1" readonly="0"/>
|
||||
<button
|
||||
type="action"
|
||||
name="%(base.res_lang_act_window)d"
|
||||
class="btn-sm btn-link mb4 fa fa-globe"
|
||||
aria-label="More languages"
|
||||
groups="base.group_system"
|
||||
title="More languages"
|
||||
/>
|
||||
</div>
|
||||
<field name="tz" widget="timezone_mismatch" options="{'tz_offset_field': 'tz_offset'}" readonly="0"/>
|
||||
<field name="tz_offset" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group name="signature">
|
||||
<field name="signature" readonly="0"/>
|
||||
</group>
|
||||
<group name="status" string="Status" invisible="1">
|
||||
<field name="company_id" options="{'no_create': True}" readonly="0"
|
||||
groups="base.group_multi_company"/>
|
||||
</group>
|
||||
<group name="preference_contact"></group>
|
||||
<notebook>
|
||||
<page string="Preferences">
|
||||
<group name="preferences">
|
||||
<group>
|
||||
<field name="email" widget="email" readonly="0"/>
|
||||
</group>
|
||||
<group>
|
||||
<label for="lang"/>
|
||||
<div class="o_row">
|
||||
<field name="lang" required="1" readonly="0"/>
|
||||
<button
|
||||
type="action"
|
||||
name="%(base.res_lang_act_window)d"
|
||||
class="btn-sm btn-link mb4 fa fa-globe"
|
||||
aria-label="More languages"
|
||||
groups="base.group_system"
|
||||
title="More languages"
|
||||
/>
|
||||
</div>
|
||||
<field name="tz" widget="timezone_mismatch" options="{'tz_offset_field': 'tz_offset'}" readonly="0"/>
|
||||
<field name="tz_offset" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group name="signature">
|
||||
<field name="signature" readonly="0"/>
|
||||
</group>
|
||||
<group name="status" string="Status" invisible="1">
|
||||
<field name="company_id" options="{'no_create': True}" readonly="0"
|
||||
groups="base.group_multi_company"/>
|
||||
</group>
|
||||
<group name="preference_contact"></group>
|
||||
</page>
|
||||
<page string="Account Security">
|
||||
<group name="auth" string="Authentication">
|
||||
<div>
|
||||
<button name="preference_change_password" type="object" string="Change password" class="btn btn-secondary"/>
|
||||
</div>
|
||||
</group>
|
||||
<group string="Developer API Keys">
|
||||
<div colspan="2" attrs="{'invisible': [('api_key_ids', '=', [])]}">
|
||||
<field name="api_key_ids" nolabel="1" colspan="4" readonly="0">
|
||||
<tree editable="bottom" create="false" delete="false">
|
||||
<field name="name"/>
|
||||
<field name="scope"/>
|
||||
<field name="create_date"/>
|
||||
<button type="object" name="remove" icon="fa-trash"/>
|
||||
</tree>
|
||||
</field>
|
||||
</div>
|
||||
<div colspan="2">
|
||||
<button name="api_key_wizard" string="New API Key" type="object" class="btn btn-secondary"/>
|
||||
</div>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
<footer>
|
||||
<button name="preference_save" type="object" string="Save" class="btn-primary"/>
|
||||
<button name="preference_cancel" string="Cancel" special="cancel" class="btn-secondary"/>
|
||||
<button name="preference_change_password" type="object" string="Change password" class="btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
@@ -399,6 +517,27 @@
|
||||
<field name="act_window_id" ref="action_res_users_my"/>
|
||||
</record>
|
||||
|
||||
<record model="ir.ui.view" id="view_apikeys">
|
||||
<field name="name">API Keys Listing</field>
|
||||
<field name="model">res.users.apikeys</field>
|
||||
<field name="groups_id" eval="[(4, ref('base.group_system'))]"/>
|
||||
<field name="arch" type="xml">
|
||||
<tree create="false" edit="false" delete="false" editable="bottom">
|
||||
<field name="user_id"/>
|
||||
<field name="name"/>
|
||||
<field name="scope"/>
|
||||
<field name="create_date"/>
|
||||
<button type="object" name="remove" icon="fa-trash"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
<record model="ir.actions.act_window" id="action_apikeys_admin">
|
||||
<field name="name">API Keys Listing</field>
|
||||
<field name="res_model">res.users.apikeys</field>
|
||||
<field name="view_mode">tree</field>
|
||||
<field name="groups_id" eval="[(4, ref('base.group_system'))]"/>
|
||||
<field name="view_id" ref="base.view_apikeys"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
{
|
||||
'name': 'Tests flow of API keys',
|
||||
'category': 'Tools',
|
||||
'depends': ['web_tour'],
|
||||
'data': ['views/assets.xml'],
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
odoo.define('test_apikeys.tour', function(require) {
|
||||
"use strict";
|
||||
|
||||
const tour = require('web_tour.tour');
|
||||
const ajax = require('web.ajax');
|
||||
|
||||
tour.register('apikeys_tour_setup', {
|
||||
test: true,
|
||||
url: '/web',
|
||||
}, [{
|
||||
content: 'Open user account menu',
|
||||
trigger: '.o_user_menu .oe_topbar_name',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Open preferences / profile screen",
|
||||
trigger: '[data-menu=settings]',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Switch to security tab",
|
||||
trigger: 'a[role=tab]:contains("Account Security")',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Open API keys wizard",
|
||||
trigger: 'button:contains("New API Key")',
|
||||
}, {
|
||||
content: "Check that we have to enter enhanced security mode",
|
||||
trigger: '.card-title:contains("confirm your password")',
|
||||
run: () => {},
|
||||
}, {
|
||||
content: "Input password",
|
||||
trigger: '[name=password]',
|
||||
run: 'text demo', // FIXME: better way to do this?
|
||||
}, {
|
||||
content: "Confirm",
|
||||
trigger: "button:contains(Confirm Password)",
|
||||
}, {
|
||||
content: "Check that we're now on the key description dialog",
|
||||
trigger: 'p:contains("Enter a description of and purpose for the key.")',
|
||||
run: () => {},
|
||||
}, {
|
||||
content: "Enter description",
|
||||
trigger: 'input[name=name]',
|
||||
run: 'text my key',
|
||||
}, {
|
||||
content: "Confirm key creation",
|
||||
trigger: 'button:contains("Generate key")'
|
||||
}, {
|
||||
content: "Check that we're on the last step & grab key",
|
||||
trigger: 'p:contains("Here is your new API key")',
|
||||
run: async () => {
|
||||
const key = $('code span[name=key]').text();
|
||||
await ajax.jsonRpc('/web/dataset/call', 'call', {
|
||||
model: 'ir.logging', method: 'send_key',
|
||||
args: [key],
|
||||
});
|
||||
$('button:contains("Done")').click();
|
||||
}
|
||||
}, {
|
||||
content: 'Re-open preferences',
|
||||
trigger: '.o_user_menu .oe_topbar_name',
|
||||
}, {
|
||||
trigger: '[data-menu=settings]',
|
||||
}, {
|
||||
content: "Switch to security tab",
|
||||
trigger: 'a[role=tab]:contains("Account Security")',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "check that our key is present",
|
||||
trigger: '[name=api_key_ids] td:contains("my key")',
|
||||
}]);
|
||||
|
||||
// deletes the previously created key
|
||||
tour.register('apikeys_tour_teardown', {
|
||||
test: true,
|
||||
url: '/web',
|
||||
}, [{
|
||||
content: 'Open preferences',
|
||||
trigger: '.o_user_menu .oe_topbar_name',
|
||||
}, {
|
||||
trigger: '[data-menu=settings]',
|
||||
}, {
|
||||
content: "Switch to security tab",
|
||||
trigger: 'a[role=tab]:contains("Account Security")',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "delete key",
|
||||
trigger: '[name=api_key_ids] i.fa-trash',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Input password for security mode again",
|
||||
trigger: '[name=password]',
|
||||
run: 'text demo', // FIXME: better way to do this?
|
||||
}, {
|
||||
content: "And confirm",
|
||||
trigger: 'button:contains(Confirm Password)',
|
||||
}, {
|
||||
content: 'Re-open preferences again',
|
||||
trigger: '.o_user_menu .oe_topbar_name',
|
||||
}, {
|
||||
trigger: '[data-menu=settings]',
|
||||
}, {
|
||||
content: "Switch to security tab",
|
||||
trigger: 'a[role=tab]:contains("Account Security")',
|
||||
run: 'click',
|
||||
}, {
|
||||
content: "Check that there's no more keys",
|
||||
trigger: '.o_notebook',
|
||||
run: function() {
|
||||
if (this.$anchor.find('[name=api_key_ids]:visible').length) {
|
||||
throw new Error("Expected API keys to be hidden (because empty), but it's not");
|
||||
};
|
||||
}
|
||||
}]);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
from . import test_flow
|
||||
@@ -0,0 +1,37 @@
|
||||
import json
|
||||
|
||||
from odoo import api
|
||||
from odoo.tests import tagged, HttpCase, get_db_name
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAPIKeys(HttpCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.messages = []
|
||||
@api.model
|
||||
def log(inst, *args, **kwargs):
|
||||
self.messages.append((inst, args, kwargs))
|
||||
self.registry['ir.logging'].send_key = log
|
||||
@self.addCleanup
|
||||
def remove_callback():
|
||||
del self.registry['ir.logging'].send_key
|
||||
|
||||
def test_addremove(self):
|
||||
db = get_db_name()
|
||||
self.start_tour('/web', 'apikeys_tour_setup', login='demo')
|
||||
demo_user = self.env['res.users'].search([('login', '=', 'demo')])
|
||||
self.assertEqual(len(demo_user.api_key_ids), 1, "the demo user should now have a key")
|
||||
|
||||
[(_, [key], [])] = self.messages
|
||||
|
||||
uid = self.xmlrpc_common.authenticate(db, 'demo', key, {})
|
||||
[r] = self.xmlrpc_object.execute_kw(
|
||||
db, uid, key,
|
||||
'res.users', 'read', [uid, ['login']]
|
||||
)
|
||||
self.assertEqual(
|
||||
r['login'], 'demo',
|
||||
"the key should be usable as a way to perform RPC calls"
|
||||
)
|
||||
self.start_tour('/web', 'apikeys_tour_teardown', login='demo')
|
||||
@@ -0,0 +1,7 @@
|
||||
<odoo>
|
||||
<template id="assets_tests" inherit_id="web.assets_tests">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/test_apikeys/static/tests/apikey_flow.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
+28
-20
@@ -48,7 +48,6 @@ except ImportError:
|
||||
psutil = None
|
||||
|
||||
import odoo
|
||||
from odoo import fields
|
||||
from .service.server import memory_info
|
||||
from .service import security, model as service_model
|
||||
from .sql_db import flush_env
|
||||
@@ -974,37 +973,46 @@ class OpenERPSession(sessions.Session):
|
||||
return self.__setitem__(k, v)
|
||||
object.__setattr__(self, k, v)
|
||||
|
||||
def authenticate(self, db, login=None, password=None, uid=None):
|
||||
def authenticate(self, db, login=None, password=None):
|
||||
"""
|
||||
Authenticate the current user with the given db, login and
|
||||
password. If successful, store the authentication parameters in the
|
||||
current session and request.
|
||||
|
||||
:param uid: If not None, that user id will be used instead the login
|
||||
to authenticate the user.
|
||||
current session and request, unless multi-factor-authentication
|
||||
is activated. In that case, that last part will be done by
|
||||
:ref:`finalize`.
|
||||
"""
|
||||
|
||||
if uid is None:
|
||||
wsgienv = request.httprequest.environ
|
||||
env = dict(
|
||||
base_location=request.httprequest.url_root.rstrip('/'),
|
||||
HTTP_HOST=wsgienv['HTTP_HOST'],
|
||||
REMOTE_ADDR=wsgienv['REMOTE_ADDR'],
|
||||
)
|
||||
uid = odoo.registry(db)['res.users'].authenticate(db, login, password, env)
|
||||
else:
|
||||
security.check(db, uid, password)
|
||||
wsgienv = request.httprequest.environ
|
||||
env = dict(
|
||||
interactive=True,
|
||||
base_location=request.httprequest.url_root.rstrip('/'),
|
||||
HTTP_HOST=wsgienv['HTTP_HOST'],
|
||||
REMOTE_ADDR=wsgienv['REMOTE_ADDR'],
|
||||
)
|
||||
uid = odoo.registry(db)['res.users'].authenticate(db, login, password, env)
|
||||
self.pre_uid = uid
|
||||
|
||||
user = request.env(user=uid)['res.users'].browse(uid)
|
||||
if not user._mfa_url():
|
||||
self.finalize()
|
||||
|
||||
self.rotate = True
|
||||
self.db = db
|
||||
self.uid = uid
|
||||
self.login = login
|
||||
self.session_token = uid and security.compute_session_token(self, request.env)
|
||||
request.uid = uid
|
||||
request.disable_db = False
|
||||
|
||||
if uid: self.get_context()
|
||||
return uid
|
||||
|
||||
def finalize(self):
|
||||
""" Finalizes a partial session, should be called on MFA validation to
|
||||
convert a partial / pre-session into a full-fledged "logged-in" one
|
||||
"""
|
||||
self.rotate = True
|
||||
request.uid = self.uid = self.pop('pre_uid')
|
||||
user = request.env(user=self.uid)['res.users'].browse(self.uid)
|
||||
self.session_token = user._compute_session_token(self.sid)
|
||||
self.get_context()
|
||||
|
||||
def check_security(self):
|
||||
"""
|
||||
Check the current authentication parameters to know if those are still
|
||||
|
||||
@@ -114,6 +114,7 @@ class Registry(Mapping):
|
||||
self._fields_by_model = None
|
||||
self._ordinary_tables = None
|
||||
self._constraint_queue = deque()
|
||||
self.__cache = LRU(8192)
|
||||
|
||||
# modules fully loaded (maintained during init phase by `loading` module)
|
||||
self._init_modules = set()
|
||||
@@ -218,6 +219,7 @@ class Registry(Mapping):
|
||||
"""
|
||||
from .. import models
|
||||
|
||||
self.clear_caches()
|
||||
lazy_property.reset_all(self)
|
||||
|
||||
# Instantiate registered classes (via the MetaModel automatic discovery
|
||||
@@ -234,6 +236,7 @@ class Registry(Mapping):
|
||||
""" Complete the setup of models.
|
||||
This must be called after loading modules and before using the ORM.
|
||||
"""
|
||||
self.clear_caches()
|
||||
lazy_property.reset_all(self)
|
||||
self.registry_invalidated = True
|
||||
env = odoo.api.Environment(cr, SUPERUSER_ID, {})
|
||||
@@ -504,15 +507,9 @@ class Registry(Mapping):
|
||||
for table in missing_tables:
|
||||
_logger.error("Model %s has no table.", table2model[table])
|
||||
|
||||
@lazy_property
|
||||
def cache(self):
|
||||
""" A cache for model methods. """
|
||||
# this lazy_property is automatically reset by lazy_property.reset_all()
|
||||
return LRU(8192)
|
||||
|
||||
def _clear_cache(self):
|
||||
""" Clear the cache and mark it as invalidated. """
|
||||
self.cache.clear()
|
||||
self.__cache.clear()
|
||||
self.cache_invalidated = True
|
||||
|
||||
def clear_caches(self):
|
||||
@@ -618,7 +615,7 @@ class Registry(Mapping):
|
||||
self.setup_models(cr)
|
||||
self.registry_invalidated = False
|
||||
if self.cache_invalidated:
|
||||
self.cache.clear()
|
||||
self.__cache.clear()
|
||||
self.cache_invalidated = False
|
||||
|
||||
@contextmanager
|
||||
|
||||
@@ -7,8 +7,6 @@ import odoo.tools
|
||||
from odoo.exceptions import AccessDenied
|
||||
from odoo.tools.translate import _
|
||||
|
||||
from . import security
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
RPC_VERSION_1 = {
|
||||
@@ -19,17 +17,14 @@ RPC_VERSION_1 = {
|
||||
}
|
||||
|
||||
def exp_login(db, login, password):
|
||||
# TODO: legacy indirection through 'security', should use directly
|
||||
# the res.users model
|
||||
res = security.login(db, login, password)
|
||||
msg = res and 'successful login' or 'bad login or password'
|
||||
_logger.info("%s from '%s' using database '%s'", msg, login, db.lower())
|
||||
return res or False
|
||||
return exp_authenticate(db, login, password, None)
|
||||
|
||||
def exp_authenticate(db, login, password, user_agent_env):
|
||||
if user_agent_env is None:
|
||||
user_agent_env = {}
|
||||
res_users = odoo.registry(db)['res.users']
|
||||
try:
|
||||
return res_users.authenticate(db, login, password, user_agent_env)
|
||||
return res_users.authenticate(db, login, password, {**user_agent_env, 'interactive': False})
|
||||
except AccessDenied:
|
||||
return False
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@
|
||||
import odoo
|
||||
import odoo.exceptions
|
||||
|
||||
def login(db, login, password):
|
||||
res_users = odoo.registry(db)['res.users']
|
||||
try:
|
||||
return res_users._login(db, login, password)
|
||||
except odoo.exceptions.AccessDenied:
|
||||
return False
|
||||
|
||||
def check(db, uid, passwd):
|
||||
res_users = odoo.registry(db)['res.users']
|
||||
return res_users.check(db, uid, passwd)
|
||||
@@ -24,5 +17,4 @@ def check_session(session, env):
|
||||
expected = self._compute_session_token(session.sid)
|
||||
if expected and odoo.tools.misc.consteq(expected, session.session_token):
|
||||
return True
|
||||
self._invalidate_session_cache()
|
||||
return False
|
||||
|
||||
+31
-27
@@ -1255,14 +1255,8 @@ class HttpCaseCommon(BaseCase):
|
||||
if self.registry_test_mode:
|
||||
self.registry.enter_test_mode(self.cr)
|
||||
self.addCleanup(self.registry.leave_test_mode)
|
||||
# setup a magic session_id that will be rollbacked
|
||||
self.session = odoo.http.root.session_store.new()
|
||||
self.session_id = self.session.sid
|
||||
self.session.db = get_db_name()
|
||||
odoo.http.root.session_store.save(self.session)
|
||||
# setup an url opener helper
|
||||
self.opener = requests.Session()
|
||||
self.opener.cookies['session_id'] = self.session_id
|
||||
|
||||
@classmethod
|
||||
def start_browser(cls):
|
||||
@@ -1311,34 +1305,44 @@ class HttpCaseCommon(BaseCase):
|
||||
odoo.http.root.session_store.save(self.session)
|
||||
|
||||
def authenticate(self, user, password):
|
||||
# stay non-authenticated
|
||||
if user is None:
|
||||
if self.session:
|
||||
odoo.http.root.session_store.delete(self.session)
|
||||
self.browser.delete_cookie('session_id', domain=HOST)
|
||||
return
|
||||
|
||||
db = get_db_name()
|
||||
uid = self.registry['res.users'].authenticate(db, user, password, None)
|
||||
env = api.Environment(self.cr, uid, {})
|
||||
|
||||
# self.session.authenticate(db, user, password, uid=uid)
|
||||
# OpenERPSession.authenticate accesses the current request, which we
|
||||
# don't have, so reimplement it manually...
|
||||
session = self.session
|
||||
if getattr(self, 'session', None):
|
||||
odoo.http.root.session_store.delete(self.session)
|
||||
|
||||
self.session = session = odoo.http.root.session_store.new()
|
||||
session.db = db
|
||||
session.uid = uid
|
||||
session.login = user
|
||||
session.session_token = uid and security.compute_session_token(session, env)
|
||||
session.context = dict(env['res.users'].context_get() or {})
|
||||
session.context['uid'] = uid
|
||||
session._fix_lang(session.context)
|
||||
|
||||
if user: # if authenticated
|
||||
uid = self.registry['res.users'].authenticate(db, user, password, {'interactive': False})
|
||||
env = api.Environment(self.cr, uid, {})
|
||||
session.uid = uid
|
||||
session.login = user
|
||||
session.session_token = uid and security.compute_session_token(session, env)
|
||||
session.context = dict(env['res.users'].context_get() or {})
|
||||
session.context['uid'] = uid
|
||||
session._fix_lang(session.context)
|
||||
|
||||
odoo.http.root.session_store.save(session)
|
||||
# Reset the opener: turns out when we set cookies['foo'] we're really
|
||||
# setting a cookie on domain='' path='/'.
|
||||
#
|
||||
# But then our friendly neighborhood server might set a cookie for
|
||||
# domain='localhost' path='/' (with the same value) which is considered
|
||||
# a *different* cookie following ours rather than the same.
|
||||
#
|
||||
# When we update our cookie, it's done in-place, so the server-set
|
||||
# cookie is still present and (as it follows ours and is more precise)
|
||||
# very likely to still be used, therefore our session change is ignored.
|
||||
#
|
||||
# An alternative would be to set the cookie to None (unsetting it
|
||||
# completely) or clear-ing session.cookies.
|
||||
self.opener = requests.Session()
|
||||
self.opener.cookies['session_id'] = session.sid
|
||||
if self.browser:
|
||||
self._logger.info('Setting session cookie in browser')
|
||||
self.browser.set_cookie('session_id', self.session_id, '/', HOST)
|
||||
self.browser.set_cookie('session_id', session.sid, '/', HOST)
|
||||
|
||||
return session
|
||||
|
||||
def browser_js(self, url_path, code, ready='', login=None, timeout=60, **kw):
|
||||
""" Test js code running in the browser
|
||||
|
||||
+2
-2
@@ -76,7 +76,7 @@ class ormcache(object):
|
||||
|
||||
def lru(self, model):
|
||||
counter = STAT[(model.pool.db_name, model._name, self.method)]
|
||||
return model.pool.cache, (model._name, self.method), counter
|
||||
return model.pool._Registry__cache, (model._name, self.method), counter
|
||||
|
||||
def lookup(self, method, *args, **kwargs):
|
||||
d, key0, counter = self.lru(args[0])
|
||||
@@ -207,7 +207,7 @@ def log_ormcache_stats(sig=None, frame=None):
|
||||
for dbname, reg in sorted(Registry.registries.d.items()):
|
||||
# set logger prefix to dbname
|
||||
me.dbname = dbname
|
||||
entries = Counter(k[:2] for k in reg.cache.d)
|
||||
entries = Counter(k[:2] for k in reg._Registry__cache.d)
|
||||
# show entries sorted by model name, method name
|
||||
for key in sorted(entries, key=lambda key: (key[0], key[1].__name__)):
|
||||
model, method = key
|
||||
|
||||
@@ -16,6 +16,8 @@ class lazy_property(object):
|
||||
get it again.
|
||||
"""
|
||||
def __init__(self, fget):
|
||||
assert not fget.__name__.startswith('__'),\
|
||||
"lazy_property does not support mangled names"
|
||||
self.fget = fget
|
||||
|
||||
def __get__(self, obj, cls):
|
||||
|
||||
Reference in New Issue
Block a user