[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).
This commit is contained in:
Xavier Morel
2020-08-14 23:06:11 +00:00
committed by Olivier Dony
parent d0dbbe23f4
commit ee1b67d607
15 changed files with 293 additions and 7 deletions
@@ -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; }
+51 -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,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
+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">
+10 -2
View File
@@ -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__
+1 -1
View File
@@ -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"/>