summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorXavier Morel <xmo@odoo.com>2020-02-18 09:29:28 +0000
committerOlivier Dony <odo@odoo.com>2020-08-14 23:06:11 +0000
commitee1b67d60732f1dc964c90d13368243f49fdef59 (patch)
tree0bef399f0ac1b2ba04cc0713fe1685a5b6c1d4c4
parentd0dbbe23f4b1f797a9ed8c7a4726146d6df902ad (diff)
[IMP] portal: /my/security page
* allows portal users to update their password * and manage their API keys * any anything technical / security related we may need to add in the future Also bridge module for the password meter (auth password policy).
-rw-r--r--addons/auth_password_policy_portal/__init__.py2
-rw-r--r--addons/auth_password_policy_portal/__manifest__.py7
-rw-r--r--addons/auth_password_policy_portal/controllers.py10
-rw-r--r--addons/auth_password_policy_portal/static/src/scss/portal_policy.scss6
-rw-r--r--addons/auth_password_policy_portal/views/templates.xml17
-rw-r--r--addons/auth_password_policy_signup/static/src/js/signup_policy.js2
-rw-r--r--addons/portal/controllers/portal.py53
-rw-r--r--addons/portal/static/src/js/portal.js82
-rw-r--r--addons/portal/static/src/scss/portal.scss20
-rw-r--r--addons/portal/static/src/xml/portal_security.xml28
-rw-r--r--addons/portal/tests/__init__.py2
-rw-r--r--addons/portal/tests/test_tours.py (renamed from addons/portal/tests/test_load_process.py)0
-rw-r--r--addons/portal/views/portal_templates.xml57
-rw-r--r--odoo/addons/base/models/res_users.py12
-rw-r--r--odoo/addons/base/views/res_users_views.xml2
15 files changed, 293 insertions, 7 deletions
diff --git a/addons/auth_password_policy_portal/__init__.py b/addons/auth_password_policy_portal/__init__.py
new file mode 100644
index 00000000000..153a9e31ebb
--- /dev/null
+++ b/addons/auth_password_policy_portal/__init__.py
@@ -0,0 +1,2 @@
+# -*- coding: utf-8 -*-
+from . import controllers
diff --git a/addons/auth_password_policy_portal/__manifest__.py b/addons/auth_password_policy_portal/__manifest__.py
new file mode 100644
index 00000000000..8241c90a670
--- /dev/null
+++ b/addons/auth_password_policy_portal/__manifest__.py
@@ -0,0 +1,7 @@
+{
+ 'name': "Password Policy support for Signup",
+ 'depends': ['auth_password_policy', 'portal'],
+ 'category': 'Tools',
+ 'auto_install': True,
+ 'data': ['views/templates.xml']
+}
diff --git a/addons/auth_password_policy_portal/controllers.py b/addons/auth_password_policy_portal/controllers.py
new file mode 100644
index 00000000000..89678520b97
--- /dev/null
+++ b/addons/auth_password_policy_portal/controllers.py
@@ -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
diff --git a/addons/auth_password_policy_portal/static/src/scss/portal_policy.scss b/addons/auth_password_policy_portal/static/src/scss/portal_policy.scss
new file mode 100644
index 00000000000..8a3cb187ece
--- /dev/null
+++ b/addons/auth_password_policy_portal/static/src/scss/portal_policy.scss
@@ -0,0 +1,6 @@
+.o_portal_security_body #new-password-group {
+ position: relative;
+ meter {
+ bottom: calc(#{$input-height-sm} / 2 - 7px);
+ }
+}
diff --git a/addons/auth_password_policy_portal/views/templates.xml b/addons/auth_password_policy_portal/views/templates.xml
new file mode 100644
index 00000000000..427ee055e51
--- /dev/null
+++ b/addons/auth_password_policy_portal/views/templates.xml
@@ -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>
diff --git a/addons/auth_password_policy_signup/static/src/js/signup_policy.js b/addons/auth_password_policy_signup/static/src/js/signup_policy.js
index 1d37484a34c..14e3d8d19ca 100644
--- a/addons/auth_password_policy_signup/static/src/js/signup_policy.js
+++ b/addons/auth_password_policy_signup/static/src/js/signup_policy.js
@@ -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; }
diff --git a/addons/portal/controllers/portal.py b/addons/portal/controllers/portal.py
index 7e063c763fb..18f7a01d582 100644
--- a/addons/portal/controllers/portal.py
+++ b/addons/portal/controllers/portal.py
@@ -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,41 @@ 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}}}
+ 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 +429,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
diff --git a/addons/portal/static/src/js/portal.js b/addons/portal/static/src/js/portal.js
index 8c117167699..ee6bbe16eb5 100644
--- a/addons/portal/static/src/js/portal.js
+++ b/addons/portal/static/src/js/portal.js
@@ -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,
+}
});
diff --git a/addons/portal/static/src/scss/portal.scss b/addons/portal/static/src/scss/portal.scss
index 604c0a70a25..be50f1c20e8 100644
--- a/addons/portal/static/src/scss/portal.scss
+++ b/addons/portal/static/src/scss/portal.scss
@@ -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 {
}
}
}
+
diff --git a/addons/portal/static/src/xml/portal_security.xml b/addons/portal/static/src/xml/portal_security.xml
new file mode 100644
index 00000000000..1f905563ede
--- /dev/null
+++ b/addons/portal/static/src/xml/portal_security.xml
@@ -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>
diff --git a/addons/portal/tests/__init__.py b/addons/portal/tests/__init__.py
index 06a8d4cd529..f0834f66623 100644
--- a/addons/portal/tests/__init__.py
+++ b/addons/portal/tests/__init__.py
@@ -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
diff --git a/addons/portal/tests/test_load_process.py b/addons/portal/tests/test_tours.py
index 5f9d9802aa1..5f9d9802aa1 100644
--- a/addons/portal/tests/test_load_process.py
+++ b/addons/portal/tests/test_tours.py
diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml
index 642b818981b..be4a35f2db0 100644
--- a/addons/portal/views/portal_templates.xml
+++ b/addons/portal/views/portal_templates.xml
@@ -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">
diff --git a/odoo/addons/base/models/res_users.py b/odoo/addons/base/models/res_users.py
index 2ae88636719..336be363e36 100644
--- a/odoo/addons/base/models/res_users.py
+++ b/odoo/addons/base/models/res_users.py
@@ -88,6 +88,11 @@ 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
@@ -105,8 +110,11 @@ def check_identity(fn, self):
return fn(self)
w = self.sudo().env['res.users.identitycheck'].create({
- 'request': json.dumps([ # assume self.env.context can be serialised to json
- self.env.context,
+ '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__
diff --git a/odoo/addons/base/views/res_users_views.xml b/odoo/addons/base/views/res_users_views.xml
index b560aa8ade9..e46587ce88c 100644
--- a/odoo/addons/base/views/res_users_views.xml
+++ b/odoo/addons/base/views/res_users_views.xml
@@ -486,7 +486,7 @@
<button type="object" name="remove" icon="fa-trash"/>
</tree>
</field>
- <button string="Add Key" type="action" name="%(action_res_users_keys_description)d"/>
+ <button string="Add Key" type="object" name="api_key_wizard"/>
</group>
<footer>
<button name="preference_save" type="object" string="Save" class="btn-primary"/>