[REF] portal: posting message with signed token

Before this commit, it was possible to post a message on a document
the user has no access, thanks to a token. In addition to this token,
if a hash (signed token with a partner id), the author_id of the message
was forced.
This commit change a little bit that logic by distinguish 2 cases:
1/ Token only: anyone with the token can post a message on the document. If
  the user is not logged (public with token), the author_id will be the
  customer of the document (or the public user). This case is moslty used
  for business document, through the portal or with the "share link" for
  instance.
2/ Signed token: user has no write access to the document, but can post a
  message on it. The user have to be logged, as the token is signed with its
  identifier. The goal here is to avoid leaking the access token's document
  to all visitors. This case is mostly used for public content, such as blog
  posts, slides, ...

The token case was already existing. The second is now working with this
commit. To do so, it was required to
- move `_sign_token` from the portal mixin to mail.thread.
- transfering 'pid' and 'hash' parameters from the controller to template
to js widgets.

This commit also change the `_message_post_helper` signature by making
the 3 first parameters required, as to post a message you need at lease
res_model, res_id and the message body. The optional argurments and kwargs
are here to check the bypassing access rights mecanism.

Task-1902304
This commit is contained in:
jem-odoo
2019-02-06 09:55:13 +00:00
parent 4cb228d823
commit 17ac041bfa
6 changed files with 61 additions and 38 deletions
+28 -12
View File
@@ -19,43 +19,59 @@ def _has_token_access(res_model, res_id, token=''):
return (token and record and consteq(record[token_field], token))
def _message_post_helper(res_model='', res_id=None, message='', token='', nosubscribe=True, **kw):
""" Generic chatter function, allowing to write on *any* object that inherits mail.thread.
If a token is specified, all logged in users will be able to write a message regardless
of access rights; if the user is the public user, the message will be posted under the name
of the partner_id of the object (or the public user if there is no partner_id on the object).
def _message_post_helper(res_model, res_id, message, token='', nosubscribe=True, **kw):
""" Generic chatter function, allowing to write on *any* object that inherits mail.thread. We
distinguish 2 cases:
1/ If a token is specified, all logged in users will be able to write a message regardless
of access rights; if the user is the public user, the message will be posted under the name
of the partner_id of the object (or the public user if there is no partner_id on the object).
2/ If a signed token is specified (`hash`) and also a partner_id (`pid`), all post message will
be done under the name of the partner_id (as it is signed). This should be used to avoid leaking
token to all users.
Required parameters
:param string res_model: model name of the object
:param int res_id: id of the object
:param string message: content of the message
optional keywords arguments:
Optional keywords arguments:
:param string token: access token if the object's model uses some kind of public access
using tokens (usually a uuid4) to bypass access rules
:param string hash: signed token by a partner if model uses some token field to bypass access right
post messages.
:param string pid: identifier of the res.partner used to sign the hash
:param bool nosubscribe: set False if you want the partner to be set as follower of the object when posting (default to True)
The rest of the kwargs are passed on to message_post()
"""
record = request.env[res_model].browse(res_id)
author_id = request.env.user.partner_id.id if request.env.user.partner_id else False
# Token Case: token required to post message as document customer (if not logged) or as itself even if user has not the access
if token:
access_as_sudo = _has_token_access(res_model, res_id, token=token)
if access_as_sudo:
record = record.sudo()
if request.env.user._is_public():
if kw.get('pid') and consteq(kw.get('hash'), record._sign_token(int(kw.get('pid')))):
author_id = kw.get('pid')
else:
# TODO : After adding the pid and sign_token in access_url when send invoice by email, remove this line
# TODO : Author must be Public User (to rename to 'Anonymous')
author_id = record.partner_id.id if hasattr(record, 'partner_id') and record.partner_id.id else author_id
# TODO : After adding the pid and sign_token in access_url when send invoice by email, remove this line
# TODO : Author must be Public User (to rename to 'Anonymous')
author_id = record.partner_id.id if hasattr(record, 'partner_id') and record.partner_id.id else author_id
else:
if not author_id:
raise NotFound()
else:
raise Forbidden()
# Signed Token Case: hash implies token is signed by partner. So author_id is forced
elif kw.get('hash') and kw.get('pid'):
if consteq(kw['hash'], record._sign_token(int(kw['pid']))):
record = record.sudo()
author_id = kw.get('pid')
else:
raise Forbidden()
kw.pop('csrf_token', None)
kw.pop('attachment_ids', None)
kw.pop('hash', None)
kw.pop('pid', None)
return record.with_context(mail_create_nosubscribe=nosubscribe).message_post(body=message,
message_type=kw.pop('message_type', "comment"),
subtype=kw.pop('subtype', "mt_comment"),
+23 -1
View File
@@ -1,7 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
import hashlib
import hmac
from odoo import api, fields, models, _
class MailThread(models.AbstractModel):
@@ -12,3 +15,22 @@ class MailThread(models.AbstractModel):
website_message_ids = fields.One2many('mail.message', 'res_id', string='Website Messages',
domain=lambda self: [('model', '=', self._name), ('message_type', '=', 'comment')], auto_join=True,
help="Website communication history")
@api.multi
def _sign_token(self, pid):
"""Generate a secure hash for this record with the email of the recipient with whom the record have been shared.
This is used to determine who is opening the link
to be able for the recipient to post messages on the document's portal view.
:param str email:
Email of the recipient that opened the link.
"""
self.ensure_one()
# check token field exists
if self._mail_post_token_field not in self._fields:
raise NotImplementedError(_("Model %s does not support token signature, as it does not have %s field.") % (self._name, self._mail_post_token_field))
# sign token
secret = self.env["ir.config_parameter"].sudo().get_param("database.secret")
token = (self.env.cr.dbname, self[self._mail_post_token_field], pid)
return hmac.new(secret.encode('utf-8'), repr(token).encode('utf-8'), hashlib.sha256).hexdigest()
+1 -19
View File
@@ -1,10 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import uuid
import hashlib
import hmac
from werkzeug.urls import url_encode
from odoo import api, exceptions, fields, models, tools, _
from odoo import api, exceptions, fields, models, _
class PortalMixin(models.AbstractModel):
@@ -133,22 +131,6 @@ class PortalMixin(models.AbstractModel):
'active_model': self.env.context['active_model']}
return action
@api.multi
def _sign_token(self, pid):
"""Generate a secure hash for this record with the email of the recipient with whom the record have been shared.
This is used to determine who is opening the link
to be able for the recipient to post messages on the document's portal view.
:param str email:
Email of the recipient that opened the link.
"""
self.ensure_one()
secret = self.env["ir.config_parameter"].sudo().get_param(
"database.secret")
token = (self.env.cr.dbname, self.access_token, pid)
return hmac.new(secret.encode('utf-8'), repr(token).encode('utf-8'), hashlib.sha256).hexdigest()
@api.multi
def get_portal_url(self, suffix=None, report_type=None, download=None, query_string=None, anchor=None):
"""
@@ -83,8 +83,11 @@ var PortalChatter = Widget.extend({
'pager_start': 1,
'is_user_public': true,
'is_user_publisher': false,
'hash': false,
'pid': false,
'domain': [],
});
this.set('messages', []);
this.set('message_count', this.options['message_count']);
this.set('pager', {});
@@ -95,11 +98,11 @@ var PortalChatter = Widget.extend({
var self = this;
// load qweb template and init data
return $.when(
this._super.apply(this, arguments),
rpc.query({
route: '/mail/chatter_init',
params: this._messageFetchPrepareParams()
}),
this._super.apply(this, arguments),
).then(function(result){
self.result = result;
self.options = _.extend(self.options, self.result['options'] || {});
+2
View File
@@ -473,6 +473,8 @@
:token string (optional): if you want your chatter to be available for non-logged user,
you can use a token to verify the identity of the user;
the message will be posted with the identity of the partner_id of the object
:hash : signed token with the partner_id using `_sign_token` method (on mail.thread)
:pid : identifier of the partner signing the token
-->
<template id="message_thread">
<div id="discussion" class="d-print-none o_portal_chatter o_not_editable p-0"
+3 -5
View File
@@ -159,7 +159,7 @@ class CustomerPortal(CustomerPortal):
if order_sudo and request.session.get('view_quote_%s' % order_sudo.id) != now and request.env.user.share and access_token:
request.session['view_quote_%s' % order_sudo.id] = now
body = _('Quotation viewed by customer')
_message_post_helper(res_model='sale.order', res_id=order_sudo.id, message=body, token=order_sudo.access_token, message_type='notification', subtype="mail.mt_note", partner_ids=order_sudo.user_id.sudo().partner_id.ids)
_message_post_helper('sale.order', order_sudo.id, body, token=order_sudo.access_token, message_type='notification', subtype="mail.mt_note", partner_ids=order_sudo.user_id.sudo().partner_id.ids)
values = {
'sale_order': order_sudo,
@@ -223,9 +223,7 @@ class CustomerPortal(CustomerPortal):
pdf = request.env.ref('sale.action_report_saleorder').sudo().render_qweb_pdf([order_sudo.id])[0]
_message_post_helper(
res_model='sale.order',
res_id=order_sudo.id,
message=_('Order signed by %s') % (name,),
'sale.order', order_sudo.id, _('Order signed by %s') % (name,),
attachments=[('%s.pdf' % order_sudo.name, pdf)],
**({'token': access_token} if access_token else {}))
@@ -249,7 +247,7 @@ class CustomerPortal(CustomerPortal):
query_string = False
if order_sudo.has_to_be_signed() and message:
order_sudo.action_cancel()
_message_post_helper(message=message, res_id=order_id, res_model='sale.order', **{'token': access_token} if access_token else {})
_message_post_helper('sale.order', order_id, message, **{'token': access_token} if access_token else {})
else:
query_string = "&message=cant_reject"