[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:
Odoo's Mergebot
2020-08-17 07:39:12 +02:00
committed by GitHub
66 changed files with 2527 additions and 317 deletions
@@ -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,
+6 -5
View File
@@ -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):
+8 -5
View File
@@ -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; }
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import controllers
from . import models
+25
View File
@@ -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',
],
}
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import home
+38
View File
@@ -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,
})
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import ir_http
from . import res_users
+13
View File
@@ -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
+197
View File
@@ -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
+16
View File
@@ -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>
+249
View File
@@ -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");
}
}
}])
});
+1
View File
@@ -0,0 +1 @@
from . import test_totp
+57
View File
@@ -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)
+38
View File
@@ -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>
+108
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
# -*- coding: utf-8 -*-
+10
View File
@@ -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" />
+1
View File
@@ -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
View File
@@ -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>
+2 -2
View File
@@ -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>
+56 -2
View File
@@ -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
+1 -1
View File
@@ -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")
+82
View File
@@ -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,
}
});
+20
View File
@@ -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 -1
View File
@@ -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
+57
View File
@@ -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">
+2 -2
View File
@@ -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
+22 -3
View File
@@ -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'});
+11 -6
View File
@@ -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
+1
View File
@@ -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
+261 -44
View File
@@ -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 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
69 access_res_users_all res_users all model_res_users 1 0 0 0
70 access_res_users_group_erp_manager res_users group_erp_manager model_res_users group_erp_manager 1 1 1 1
71 access_res_users_log_all res_users_log_all model_res_users_log 1 0 1 0
72 access_res_users_identitycheck_employee id check employees model_res_users_identitycheck group_user 1 1 1 0
73 access_res_users_identitycheck_portal id check portal model_res_users_identitycheck group_portal 1 1 1 0
74 access_res_users_apikeys_access_employee API keys access employees model_res_users_apikeys group_user 1 0 0 0
75 access_res_users_apikeys_access_portal API keys access portal model_res_users_apikeys group_portal 1 0 0 0
76 access_res_users_apikeys_description_employee API key wizard employees model_res_users_apikeys_description group_user 1 0 1 0
77 access_res_users_apikeys_description_portal API key wizard model_res_users_apikeys_description group_portal 1 0 1 0
78 access_res_users_apikeys_show_employee API key result employees model_res_users_apikeys_show group_user 1 0 1 0
79 access_ir_actions_all ir_actions_all model_ir_actions_actions 1 0 0 0
80 access_ir_actions_group_system ir_actions_group_system model_ir_actions_actions group_system 1 1 1 1
81 access_ir_actions_act_window_all ir_actions_act_window_all model_ir_actions_act_window 1 0 0 0
+112
View File
@@ -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', []
])
+169 -30
View File
@@ -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">&amp;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>
+8
View File
@@ -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
View File
@@ -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
+5 -8
View File
@@ -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
+4 -9
View File
@@ -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
-8
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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):