diff options
| author | Xavier Morel <xmo@odoo.com> | 2020-02-18 09:29:28 +0000 |
|---|---|---|
| committer | Olivier Dony <odo@odoo.com> | 2020-08-14 23:06:11 +0000 |
| commit | ee1b67d60732f1dc964c90d13368243f49fdef59 (patch) | |
| tree | 0bef399f0ac1b2ba04cc0713fe1685a5b6c1d4c4 | |
| parent | d0dbbe23f4b1f797a9ed8c7a4726146d6df902ad (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__.py | 2 | ||||
| -rw-r--r-- | addons/auth_password_policy_portal/__manifest__.py | 7 | ||||
| -rw-r--r-- | addons/auth_password_policy_portal/controllers.py | 10 | ||||
| -rw-r--r-- | addons/auth_password_policy_portal/static/src/scss/portal_policy.scss | 6 | ||||
| -rw-r--r-- | addons/auth_password_policy_portal/views/templates.xml | 17 | ||||
| -rw-r--r-- | addons/auth_password_policy_signup/static/src/js/signup_policy.js | 2 | ||||
| -rw-r--r-- | addons/portal/controllers/portal.py | 53 | ||||
| -rw-r--r-- | addons/portal/static/src/js/portal.js | 82 | ||||
| -rw-r--r-- | addons/portal/static/src/scss/portal.scss | 20 | ||||
| -rw-r--r-- | addons/portal/static/src/xml/portal_security.xml | 28 | ||||
| -rw-r--r-- | addons/portal/tests/__init__.py | 2 | ||||
| -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.xml | 57 | ||||
| -rw-r--r-- | odoo/addons/base/models/res_users.py | 12 | ||||
| -rw-r--r-- | odoo/addons/base/views/res_users_views.xml | 2 |
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"/> |
