[IMP] portal: allow portal users to deactivate their accounts

Purpose
=======
Allow portal users to deactivate their accounts from the portal view.

After the deactivation, they are redirected to the login page, so
they can verify that they can not longer login with their credentials.

We first archived the record and remove sensitive information (password,
login, so he can not log in again with the same credentials).

After the deactivation, we blacklist the email and the phone of the
user, so we are sure that we never send him again email / SMS.

Then, we create a <res.users.deletion> to delete the user and the
partner in a CRON because this operation can be heavy (write_uid,
create_uid field on all models).

Task-2629544

Part-of: odoo/odoo#78298
This commit is contained in:
std-odoo
2022-06-30 12:10:56 +02:00
parent e46a04ce30
commit 66f5bc4dbf
15 changed files with 462 additions and 3 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
<odoo>
<template id="totp_portal_hook" name="TOTP Portal hook" inherit_id="portal.portal_my_security">
<xpath expr="//div[hasclass('o_portal_security_body')]">
<xpath expr="//section[@name='portal_change_password']" position="after">
<!--
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
+29 -1
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, exceptions, fields, models, modules
from odoo import _, api, exceptions, fields, models, modules, tools
from odoo.addons.base.models.res_users import is_selection_groups
@@ -148,6 +148,34 @@ class Users(models.Model):
return '%s (%s)' % (body, self.partner_id.email)
return body
def _deactivate_portal_user(self, **post):
"""Blacklist the email of the user after deleting it.
Log a note on the related partner so we know why it's archived.
"""
current_user = self.env.user
for user in self:
user.partner_id._message_log(
body=_('Archived because %(user_name)s (#%(user_id)s) deleted the portal account',
user_name=current_user.name, user_id=current_user.id)
)
if post.get('request_blacklist'):
users_to_blacklist = self.filtered(
lambda user: tools.email_normalize(user.email))
else:
users_to_blacklist = []
super(Users, self)._deactivate_portal_user(**post)
for user in users_to_blacklist:
blacklist = self.env['mail.blacklist']._add(user.email)
blacklist._message_log(
body=_('Blocked by deletion of portal account %(portal_user_name)s by %(user_name)s (#%(user_id)s)',
user_name=current_user.name, user_id=current_user.id,
portal_user_name=user.name),
)
# ------------------------------------------------------------
# DISCUSS
# ------------------------------------------------------------
@@ -4,3 +4,4 @@
from . import phone_blacklist
from . import mail_thread_phone
from . import res_partner
from . import res_users
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, models
from odoo.addons.phone_validation.tools import phone_validation
class Users(models.Model):
_inherit = 'res.users'
def _deactivate_portal_user(self, **post):
"""Blacklist the phone of the user after deleting it."""
numbers_to_blacklist = {} # numbers to blacklist and the related user
if post.get('request_blacklist'):
for user in self:
sanitized = phone_validation.phone_sanitize_numbers_w_record([user.phone, user.mobile], user)
user_phone = sanitized[user.phone]['sanitized']
user_mobile = sanitized[user.mobile]['sanitized']
if user_phone:
numbers_to_blacklist[user_phone] = user
if user_mobile:
numbers_to_blacklist[user_mobile] = user
super(Users, self)._deactivate_portal_user(**post)
if numbers_to_blacklist:
current_user = self.env.user
blacklists = self.env['phone.blacklist']._add(
list(numbers_to_blacklist.keys()))
for blacklist in blacklists:
user = numbers_to_blacklist[blacklist.number]
blacklist._message_log(
body=_('Blocked by deletion of portal account %(portal_user_name)s by %(user_name)s (#%(user_id)s)',
user_name=current_user.name, user_id=current_user.id,
portal_user_name=user.name),
)
+25
View File
@@ -225,6 +225,7 @@ class CustomerPortal(Controller):
values = self._prepare_portal_layout_values()
values['get_error'] = get_error
values['allow_api_keys'] = bool(request.env['ir.config_parameter'].sudo().get_param('portal.allow_api_keys'))
values['open_deactivate_modal'] = False
if request.httprequest.method == 'POST':
values.update(self._update_password(
@@ -262,6 +263,30 @@ class CustomerPortal(Controller):
return {'success': {'password': True}}
@route('/my/deactivate_account', type='http', auth='user', website=True, methods=['POST'])
def deactivate_account(self, validation, password, **post):
values = self._prepare_portal_layout_values()
values['get_error'] = get_error
values['open_deactivate_modal'] = True
if validation != request.env.user.login:
values['errors'] = {'deactivate': 'validation'}
else:
try:
request.env['res.users']._check_credentials(password, {'interactive': True})
request.env.user.sudo()._deactivate_portal_user(**post)
request.session.logout()
return request.redirect('/web/login?message=%s' % urls.url_quote(_('Account deleted!')))
except AccessDenied:
values['errors'] = {'deactivate': 'password'}
except UserError as e:
values['errors'] = {'deactivate': {'other': str(e)}}
return request.render('portal.portal_my_security', values, headers={
'X-Frame-Options': 'SAMEORIGIN',
'Content-Security-Policy': "frame-ancestors 'self'",
})
@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
+24
View File
@@ -237,6 +237,30 @@ publicWidget.registry.RemoveAPIKeyButton = publicWidget.Widget.extend({
}
});
publicWidget.registry.portalSecurity = publicWidget.Widget.extend({
selector: '.o_portal_security_body',
/**
* @override
*/
init: function () {
// Show the "deactivate your account" modal if needed
$('.modal.show#portal_deactivate_account_modal').removeClass('d-block').modal('show');
// Remove the error messages when we close the modal,
// so when we re-open it again we get a fresh new form
$('.modal#portal_deactivate_account_modal').on('hide.bs.modal', (event) => {
const $target = $(event.currentTarget);
$target.find('.alert').remove();
$target.find('.invalid-feedback').remove();
$target.find('.is-invalid').removeClass('is-invalid');
});
return this._super(...arguments);
},
});
/**
* 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
@@ -542,6 +542,11 @@ form label {
@extend .text-nowrap;
}
}
section[name="portal_deactivate_account"] {
label {
white-space: normal!important;
}
}
}
// Copyright
+68 -1
View File
@@ -479,7 +479,7 @@
<div class="alert alert-danger" role="alert" t-if="get_error(errors)">
<t t-esc="errors"/>
</div>
<section>
<section name="portal_change_password">
<h3>Change Password</h3>
<t t-set="path">password</t>
<div class="alert alert-success" role="alert" t-if="success and success.get('password')">
@@ -556,6 +556,73 @@
<button type="submit" class="btn btn-secondary o_portal_new_api_key">New API Key</button>
</div>
</section>
<section name="portal_deactivate_account" groups="base.group_portal">
<h3>Delete Account</h3>
<t t-set="deactivate_error" t-value="get_error(errors, 'deactivate')"/>
<button class="btn btn-secondary" data-toggle="modal"
data-target="#portal_deactivate_account_modal">
Delete Account
</button>
<div t-attf-class="modal #{'show d-block' if open_deactivate_modal else ''}"
id="portal_deactivate_account_modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header bg-danger">
<h5 class="modal-title">Are you sure you want to do this?</h5>
<button type="button" class="close" data-dismiss="modal">
×
</button>
</div>
<form action="/my/deactivate_account" method="post" class="modal-body"
id="portal_deactivate_account_form">
<div>
<div class="alert alert-danger"
t-esc="get_error(errors, 'deactivate.other')"/>
<p class="text-muted">
Disable your account, preventing any further login.<br/>
<b>
<i class="fa fa-exclamation-triangle text-danger"></i>
This action cannot be undone.
</b>
</p>
<hr/>
<p>1. Enter your password to confirm you own this account</p>
<input name="password" type="password" required="1"
t-attf-class="form-control #{'is-invalid' if deactivate_error == 'password' else ''}"
placeholder="Password"/>
<div t-if="deactivate_error == 'password'" class="invalid-feedback">
Wrong password.
</div>
<hr/>
<p>
2. Confirm you want to delete your account by
copying down your login (<t t-esc="env.user.login"/>).
</p>
<input name="validation" type="text" required="1"
t-attf-class="form-control #{'is-invalid' if deactivate_error == 'validation' else ''}"/>
<div t-if="deactivate_error == 'validation'" class="invalid-feedback">
You should enter "<t t-esc="env.user.login"/>" to validate your action.
</div>
<div class="d-flex flex-row align-items-center">
<input type="checkbox" name="request_blacklist" id="request_blacklist" checked="1"/>
<label for="request_blacklist" class="ml-2 mw-100 font-weight-normal mt-3">
Put my email and phone in a block list to make sure I'm never contacted again
</label>
</div>
</div>
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
</form>
<div class="modal-footer justify-content-start">
<input type="submit" class="btn btn-danger" form="portal_deactivate_account_form"
value="Delete Account"/>
<button type="button" class="btn" data-dismiss="modal">
Cancel
</button>
</div>
</div>
</div>
</div>
</section>
</div></t>
</template>
+1
View File
@@ -9,6 +9,7 @@ from . import test_mass_mailing_statistics
from . import test_mass_sms
from . import test_portal
from . import test_rating
from . import test_res_users
from . import test_sms_composer
from . import test_sms_management
from . import test_sms_performance
@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mail.tests.common import MailCommon, mail_new_test_user
class TestResUsers(MailCommon):
@classmethod
def setUpClass(cls):
super(TestResUsers, cls).setUpClass()
cls.portal_user = mail_new_test_user(
cls.env,
login='portal_user',
mobile='+32 494 12 34 56',
phone='+32 494 12 34 89',
password='password',
name='Portal User',
email='portal@test.example.com',
groups='base.group_portal',
)
cls.portal_user_2 = mail_new_test_user(
cls.env,
login='portal_user_2',
mobile='+32 494 12 34 22',
phone='invalid phone',
password='password',
name='Portal User 2',
email='portal_2@test.example.com',
groups='base.group_portal',
)
# Remove existing blacklisted email / phone (they will be sanitized, so we avoid to sanitize them here)
cls.env['mail.blacklist'].search([]).unlink()
cls.env['phone.blacklist'].search([]).unlink()
def test_deactivate_portal_users_blacklist(self):
"""Test that the email and the phone are blacklisted
when a portal user deactivate his own account.
"""
(self.portal_user | self.portal_user_2)._deactivate_portal_user(request_blacklist=True)
self.assertFalse(self.portal_user.active, 'Should have archived the user')
self.assertFalse(self.portal_user.partner_id.active, 'Should have archived the partner')
self.assertFalse(self.portal_user_2.active, 'Should have archived the user')
self.assertFalse(self.portal_user_2.partner_id.active, 'Should have archived the partner')
blacklist = self.env['mail.blacklist'].search([
('email', 'in', ('portal@test.example.com', 'portal_2@test.example.com')),
])
self.assertEqual(len(blacklist), 2, 'Should have blacklisted the users email')
blacklists = self.env['phone.blacklist'].search([
('number', 'in', ('+32494123489', '+32494123456', '+32494123422')),
])
self.assertEqual(len(blacklists), 3, 'Should have blacklisted the user phone and mobile')
blacklist = self.env['phone.blacklist'].search([('number', '=', 'invalid phone')])
self.assertFalse(blacklist, 'Should have skipped invalid phone')
def test_deactivate_portal_users_no_blacklist(self):
"""Test the case when the user do not want to blacklist his email / phone."""
(self.portal_user | self.portal_user_2)._deactivate_portal_user(request_blacklist=False)
self.assertFalse(self.portal_user.active, 'Should have archived the user')
self.assertFalse(self.portal_user.partner_id.active, 'Should have archived the partner')
self.assertFalse(self.portal_user_2.active, 'Should have archived the user')
self.assertFalse(self.portal_user_2.partner_id.active, 'Should have archived the partner')
blacklists = self.env['mail.blacklist'].search([])
self.assertFalse(blacklists, 'Should not have blacklisted the users email')
blacklists = self.env['phone.blacklist'].search([])
self.assertFalse(blacklists, 'Should not have blacklisted the user phone and mobile')
+1
View File
@@ -44,5 +44,6 @@ from . import res_config
from . import res_currency
from . import res_company
from . import res_users
from . import res_users_deletion
from . import decimal_precision
+55
View File
@@ -816,6 +816,61 @@ class Users(models.Model):
# use self.env.user here, because it has uid=SUPERUSER_ID
return self.env.user.write({'password': new_passwd})
def _deactivate_portal_user(self, **post):
"""Try to remove the current portal user.
This is used to give the opportunity to portal users to de-activate their accounts.
Indeed, as the portal users can easily create accounts, they will sometimes wish
it removed because they don't use this Odoo portal anymore.
Before this feature, they would have to contact the website or the support to get
their account removed, which could be tedious.
"""
non_portal_users = self.filtered(lambda user: not user.share)
if non_portal_users:
raise AccessDenied(_(
'Only the portal users can delete their accounts. '
'The user(s) %s can not be deleted.',
', '.join(non_portal_users.mapped('name')),
))
ip = request.httprequest.environ['REMOTE_ADDR'] if request else 'n/a'
res_users_deletion_values = []
for user in self:
_logger.info(
'Account deletion asked for "%s" (#%i) from %s. '
'Archive the user and remove login information.',
user.login, user.id, ip,
)
user.write({
'login': f'__deleted_user_{user.id}_{time.time()}',
'password': '',
'api_key_ids': Command.clear(),
})
res_users_deletion_values.append({
'user_id': user.id,
'state': 'todo',
})
# Here we try to archive the user / partner, and then add the user in a deletion
# queue, to remove it from the database. As the deletion might fail (if the
# partner is related to an invoice e.g.) it's important to archive it here.
try:
# A user can not self-deactivate
self.with_user(SUPERUSER_ID).action_archive()
except Exception:
pass
try:
self.partner_id.action_archive()
except Exception:
pass
# Add users in the deletion queue
self.env['res.users.deletion'].create(res_users_deletion_values)
def preference_save(self):
return {
'type': 'ir.actions.client',
@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class ResUsersDeletion(models.Model):
"""User deletion requests.
Those requests are logged in a different model to keep a trace of this action and the
deletion is done in a CRON. Indeed, removing a user can be a heavy operation on
large database (because of create_uid, write_uid on each model, which are not always
indexed). This model just remove the users added in the deletion queue, remaining code
must deal with other consideration (archiving, blacklist email...).
"""
_name = 'res.users.deletion'
_description = 'Users Deletion Request'
_rec_name = 'user_id'
# Integer field because the related user might be deleted from the database
user_id = fields.Many2one('res.users', string='User', ondelete='set null')
user_id_int = fields.Integer('User Id', compute='_compute_user_id_int', store=True)
state = fields.Selection([('todo', 'To Do'), ('done', 'Done'), ('fail', 'Failed')],
string='State', required=True, default='todo')
@api.depends('user_id')
def _compute_user_id_int(self):
for user_deletion in self:
if user_deletion.user_id:
user_deletion.user_id_int = user_deletion.user_id.id
@api.autovacuum
def _gc_portal_users(self):
"""Remove the portal users that asked to deactivate their account.
(see <res.users>::_deactivate_portal_user)
Removing a user can be an heavy operation on large database (because of
create_uid, write_uid on each models, which are not always indexed). Because of
that, this operation is done in a CRON.
"""
delete_requests = self.search([('state', '=', 'todo')])
# filter the requests related to a deleted user
done_requests = delete_requests.filtered(lambda request: not request.user_id)
done_requests.state = 'done'
for delete_request in (delete_requests - done_requests):
user = delete_request.user_id
user_name = user.name
try:
with self.env.cr.savepoint():
partner = user.partner_id
user.unlink()
partner.unlink()
_logger.info('User #%i %r, deleted. Original request from %r.',
user.id, user_name, delete_request.create_uid.name)
delete_request.state = 'done'
except Exception:
delete_request.state = 'fail'
@@ -70,6 +70,8 @@
"access_res_partner_title_group_partner_manager","res_partner_title group_user","model_res_partner_title",,1,0,0,0
"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_deletion_all","res_users_deletion all","model_res_users_deletion",,0,0,0,0
"access_res_users_deletion_group_erp_manager","res_users_deletion group_erp_manager","model_res_users_deletion","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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
70 access_res_partner_title_group_partner_manager res_partner_title group_user model_res_partner_title 1 0 0 0
71 access_res_users_all res_users all model_res_users 1 0 0 0
72 access_res_users_group_erp_manager res_users group_erp_manager model_res_users group_erp_manager 1 1 1 1
73 access_res_users_deletion_all res_users_deletion all model_res_users_deletion 0 0 0 0
74 access_res_users_deletion_group_erp_manager res_users_deletion group_erp_manager model_res_users_deletion group_erp_manager 1 1 1 1
75 access_res_users_log_all res_users_log_all model_res_users_log 1 0 1 0
76 access_res_users_identitycheck_employee id check employees model_res_users_identitycheck group_user 1 1 1 0
77 access_res_users_identitycheck_portal id check portal model_res_users_identitycheck group_portal 1 1 1 0
+73
View File
@@ -2,7 +2,9 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.base.models.res_users import is_selection_groups, get_selection_groups
from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase, Form, tagged
from odoo.tools import mute_logger
class TestUsers(TransactionCase):
@@ -116,6 +118,77 @@ class TestUsers(TransactionCase):
"the company_id of the partner_id shall be updated"
)
@mute_logger('odoo.sql_db')
def test_deactivate_portal_users_access(self):
"""Test that only a portal users can deactivate his account."""
user_internal = self.env['res.users'].create({
'name': 'Internal',
'login': 'user_internal',
'password': 'password',
'groups_id': [self.env.ref('base.group_user').id],
})
with self.assertRaises(UserError, msg='Internal users should not be able to deactivate their account'):
user_internal._deactivate_portal_user()
@mute_logger('odoo.sql_db')
def test_deactivate_portal_users_archive_and_remove(self):
"""Test that if the account can not be removed, it's archived instead
and sensitive information are removed.
In this test, the deletion of "portal_user" will succeed,
but the deletion of "portal_user_2" will fail.
"""
User = self.env['res.users']
portal_user = User.create({
'name': 'Portal',
'login': 'portal_user',
'password': 'password',
'groups_id': [self.env.ref('base.group_portal').id],
})
portal_partner = portal_user.partner_id
portal_user_2 = User.create({
'name': 'Portal',
'login': 'portal_user_2',
'password': 'password',
'groups_id': [self.env.ref('base.group_portal').id],
})
portal_partner_2 = portal_user_2.partner_id
(portal_user | portal_user_2)._deactivate_portal_user()
self.assertTrue(portal_user.exists() and not portal_user.active, 'Should have archived the user 1')
self.assertEqual(portal_user.name, 'Portal', 'Should have kept the user name')
self.assertEqual(portal_user.partner_id.name, 'Portal', 'Should have kept the partner name')
self.assertNotEqual(portal_user.login, 'portal_user', 'Should have removed the user login')
asked_deletion_1 = self.env['res.users.deletion'].search([('user_id', '=', portal_user.id)])
asked_deletion_2 = self.env['res.users.deletion'].search([('user_id', '=', portal_user_2.id)])
self.assertTrue(asked_deletion_1, 'Should have added the user 1 in the deletion queue')
self.assertTrue(asked_deletion_2, 'Should have added the user 2 in the deletion queue')
# The deletion will fail for "portal_user_2",
# because of the absence of "ondelete=cascade"
self.cron = self.env['ir.cron'].create({
'name': 'Test Cron',
'user_id': portal_user_2.id,
'model_id': self.env.ref('base.model_res_partner').id,
})
self.env['res.users.deletion']._gc_portal_users()
self.assertFalse(portal_user.exists(), 'Should have removed the user')
self.assertFalse(portal_partner.exists(), 'Should have removed the partner')
self.assertEqual(asked_deletion_1.state, 'done', 'Should have marked the deletion as done')
self.assertTrue(portal_user_2.exists(), 'Should have kept the user')
self.assertTrue(portal_partner_2.exists(), 'Should have kept the partner')
self.assertEqual(asked_deletion_2.state, 'fail', 'Should have marked the deletion as failed')
@tagged('post_install', '-at_install')
class TestUsers2(TransactionCase):
def test_reified_groups(self):