[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:
committed by
Olivier Dony
parent
d0dbbe23f4
commit
ee1b67d607
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,9 @@ odoo.define('portal.portal', function (require) {
|
||||
'use strict';
|
||||
|
||||
var publicWidget = require('web.public.widget');
|
||||
const Dialog = require('web.Dialog');
|
||||
const {_t, qweb} = require('web.core');
|
||||
const ajax = require('web.ajax');
|
||||
|
||||
publicWidget.registry.portalDetails = publicWidget.Widget.extend({
|
||||
selector: '.o_portal_details',
|
||||
@@ -119,4 +122,83 @@ publicWidget.registry.portalSearchPanel = publicWidget.Widget.extend({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const tmpl = ajax.loadXML('/portal/static/src/xml/portal_security.xml', qweb);
|
||||
|
||||
/**
|
||||
* Wraps an RPC call in a check for the result being an identity check action
|
||||
* descriptor. If no such result is found, just returns the wrapped promise's
|
||||
* result as-is; otherwise shows an identity check dialog and resumes the call
|
||||
* on success.
|
||||
*
|
||||
* Warning: does not in and of itself trigger an identity check, a promise which
|
||||
* never triggers and identity check internally will do nothing of use.
|
||||
*
|
||||
* @param {Function} rpc Widget#_rpc bound do the widget
|
||||
* @param {Promise} wrapped promise to check for an identity check request
|
||||
* @returns {Promise} result of the original call
|
||||
*/
|
||||
function handleCheckIdentity(rpc, wrapped) {
|
||||
return wrapped.then((r) => {
|
||||
if (!_.isMatch(r, {type: 'ir.actions.act_window', res_model: 'res.users.identitycheck'})) {
|
||||
return r;
|
||||
}
|
||||
const check_id = r.res_id;
|
||||
return tmpl.then(() => new Promise((resolve, reject) => {
|
||||
const d = new Dialog(null, {
|
||||
title: _t("Security Control"),
|
||||
$content: qweb.render('portal.identitycheck'),
|
||||
buttons: [{
|
||||
text: _t("Confirm Password"), classes: 'btn btn-primary',
|
||||
// nb: if click & close, waits for click to resolve before closing
|
||||
click() {
|
||||
const password_input = this.el.querySelector('[name=password]');
|
||||
if (!password_input.reportValidity()) {
|
||||
password_input.classList.add('is-invalid');
|
||||
return;
|
||||
}
|
||||
return rpc({
|
||||
model: 'res.users.identitycheck',
|
||||
method: 'write',
|
||||
args: [check_id, {password: password_input.value}]
|
||||
}).then(() => rpc({
|
||||
model: 'res.users.identitycheck',
|
||||
method: 'run_check',
|
||||
args: [check_id]
|
||||
})).then((r) => {
|
||||
this.close();
|
||||
resolve(r);
|
||||
}, (err) => {
|
||||
err.event.preventDefault(); // suppress crashmanager
|
||||
password_input.classList.add('is-invalid');
|
||||
password_input.setCustomValidity(_t("Check failed"));
|
||||
password_input.reportValidity();
|
||||
});
|
||||
}
|
||||
}, {
|
||||
text: _t('Cancel'), close: true
|
||||
}]
|
||||
}).on('close', null, () => {
|
||||
// unlink wizard object?
|
||||
reject();
|
||||
});
|
||||
d.opened(() => {
|
||||
const pw = d.el.querySelector('[name="password"]');
|
||||
pw.focus();
|
||||
pw.addEventListener('input', () => {
|
||||
pw.classList.remove('is-invalid');
|
||||
pw.setCustomValidity('');
|
||||
});
|
||||
d.el.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
d.$footer.find('.btn-primary').click();
|
||||
});
|
||||
});
|
||||
d.open();
|
||||
}));
|
||||
});
|
||||
}
|
||||
return {
|
||||
handleCheckIdentity,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -536,6 +536,25 @@ form label {
|
||||
}
|
||||
}
|
||||
|
||||
.o_portal_security_body {
|
||||
@extend .mx-auto;
|
||||
max-width: map-get($container-max-widths, sm);
|
||||
section {
|
||||
@extend .mt-4;
|
||||
form.oe_reset_password_form {
|
||||
max-width: initial;
|
||||
margin: initial;
|
||||
}
|
||||
.form-group {
|
||||
// for the absolutely positioned meter on new password
|
||||
position: relative;
|
||||
}
|
||||
label, button {
|
||||
@extend .text-nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copyright
|
||||
.o_footer_copyright {
|
||||
|
||||
@@ -551,3 +570,4 @@ form label {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="portal.identitycheck">
|
||||
<form>
|
||||
<div class="row container">
|
||||
<div class="col-10 offset-1">
|
||||
<div class="card bg-dark my-b mx-5">
|
||||
<div class="card-header">
|
||||
<i class="fa fa-bell mx-2"/>
|
||||
<span class="text-uppercase">Security Control</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<a href="/web/reset_password/" class="float-right">Forgot password?</a>
|
||||
<h4 class="card-title">Please confirm your password to continue</h4>
|
||||
<p class="card-text">
|
||||
This is necessary for security-related changes. The authorization will last for a few minutes.
|
||||
</p>
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<input class="form-control" autocomplete="current-password"
|
||||
name="password" type="password" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -1,4 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_load_process
|
||||
from . import test_tours
|
||||
|
||||
@@ -117,6 +117,11 @@
|
||||
<hr class="mt-1 mb-0"/>
|
||||
<div t-field="user_id.partner_id" t-options='{"widget": "contact", "fields": ["email", "phone", "address", "name"]}'/>
|
||||
</div>
|
||||
<div class="o_portal_my_security mt-3">
|
||||
<h4>Account Security </h4>
|
||||
<hr class="mt-1 mb-1"/>
|
||||
<a href="/my/security"><i class="fa fa-pencil mx-1"/>Edit Security Settings</a>
|
||||
</div>
|
||||
<div class="o_my_archives" t-if="archive_groups">
|
||||
<t t-call="portal.portal_archive_groups"/>
|
||||
</div>
|
||||
@@ -438,6 +443,58 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<template id="portal_my_security">
|
||||
<t t-call="portal.portal_layout"><div class="o_portal_security_body">
|
||||
<t t-set="additional_title">Security</t>
|
||||
<t t-set="no_breadcrumbs" t-value="1"/>
|
||||
<div class="alert alert-danger" role="alert" t-if="get_error(errors)">
|
||||
<t t-esc="errors"/>
|
||||
</div>
|
||||
<section>
|
||||
<h3>Change Password</h3>
|
||||
<t t-set="path">password</t>
|
||||
<div class="alert alert-success" role="alert" t-if="success and success.get('password')">
|
||||
Password Updated!
|
||||
</div>
|
||||
<div class="alert alert-danger" role="alert" t-if="get_error(errors, 'password')">
|
||||
<t t-esc="errors['password']"/>
|
||||
</div>
|
||||
<form action="/my/security" method="post" class="oe_reset_password_form">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="op" value="password"/>
|
||||
<div class="form-group">
|
||||
<label for="current">Password:</label>
|
||||
<input type="password" t-attf-class="form-control form-control-sm {{ 'is-invalid' if get_error(errors, 'password.old') else '' }}"
|
||||
id="current" name="old"
|
||||
autocomplete="current-password" required="required"/>
|
||||
<div class="invalid-feedback">
|
||||
<t t-esc="get_error(errors, 'password.old')"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new">New Password:</label>
|
||||
<input type="password" t-attf-class="form-control form-control-sm {{ 'is-invalid' if get_error(errors, 'password.new1') else '' }}"
|
||||
id="new" name="new1"
|
||||
autocomplete="new-password" required="required"/>
|
||||
<div class="invalid-feedback">
|
||||
<t t-esc="get_error(errors, 'password.new1')"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new2">Verify New Password:</label>
|
||||
<input type="password" t-attf-class="form-control form-control-sm {{ 'is-invalid' if get_error(errors, 'password.new2') else '' }}"
|
||||
id="new2" name="new2"
|
||||
autocomplete="new-password" required="required"/>
|
||||
<div class="invalid-feedback">
|
||||
<t t-esc="get_error(errors, 'password.new2')"/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger">Change Password</button>
|
||||
</form>
|
||||
</section>
|
||||
</div></t>
|
||||
</template>
|
||||
|
||||
<template id="record_pager" name="Portal Record Pager">
|
||||
<t t-if='prev_record or next_record'>
|
||||
<div class="record_pager btn-group" role="group">
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
Reference in New Issue
Block a user