[FIX] pos_{adyen,self_order{,_adyen}}: permissions

- Adds some permissions to protect sensitive pos_adyen data and methods.
 - Reduces the risks of abuses of the pos_adyen features.

Those changes are not optimal. The whole system should be changed.

 - Fixes the printing of Adyen receipts data in the receipt.
 - Fixes an error when receiving a successful Adyen payment notification for the kiosk.
 - Fixes an error when Adyen returns an error for a payment for kiosk.
 - Fixes an error when requesting adjustment/capture to Adyen (pos_restaurant_adyen and kiosk).
 - Removes unused handling of adyen_latest_diagnosis field (the field will be removed in master).

closes odoo/odoo#140327

Task-id: 3500598
Signed-off-by: Théo Vincent (thvi) <thvi@odoo.com>
This commit is contained in:
Theo VINCENT (thvi)
2023-12-28 09:40:08 +00:00
parent d766d63b11
commit 7b95532d23
9 changed files with 281 additions and 92 deletions
+60 -15
View File
@@ -2,14 +2,17 @@
import logging
import pprint
import json
from urllib.parse import parse_qs
from odoo import http
from odoo.http import request
from odoo.tools import consteq
_logger = logging.getLogger(__name__)
class PosAdyenController(http.Controller):
@http.route('/pos_adyen/notification', type='json', methods=['POST'], auth='none', csrf=False)
@http.route('/pos_adyen/notification', type='json', methods=['POST'], auth='public', csrf=False, save_session=False)
def notification(self):
data = json.loads(request.httprequest.data)
@@ -18,18 +21,60 @@ class PosAdyenController(http.Controller):
return
_logger.info('notification received from adyen:\n%s', pprint.pformat(data))
terminal_identifier = data['SaleToPOIResponse']['MessageHeader']['POIID']
payment_method_sudo = request.env['pos.payment.method'].sudo().search([('adyen_terminal_identifier', '=', terminal_identifier)], limit=1)
pos_session_id = int(data["SaleToPOIResponse"]["PaymentResponse"]["SaleData"]["SaleTransactionID"]["TransactionID"].split("-")[1])
pos_session_sudo = request.env["pos.session"].sudo().browse(pos_session_id)
if payment_method_sudo:
# These are only used to see if the terminal is reachable,
# store the most recent ID we received.
if data['SaleToPOIResponse'].get('DiagnosisResponse'):
payment_method_sudo.adyen_latest_diagnosis = data['SaleToPOIResponse']['MessageHeader']['ServiceID']
else:
payment_method_sudo.adyen_latest_response = json.dumps(data)
request.env["bus.bus"].sudo()._sendone(pos_session_sudo._get_bus_channel_name(), "ADYEN_LATEST_RESPONSE", pos_session_sudo.config_id.id)
else:
_logger.error('received a message for a terminal not registered in Odoo: %s', terminal_identifier)
msg_header = data['SaleToPOIResponse'].get('MessageHeader')
if not msg_header \
or msg_header.get('ProtocolVersion') != '3.0' \
or msg_header.get('MessageClass') != 'Service' \
or msg_header.get('MessageType') != 'Response' \
or msg_header.get('MessageCategory') != 'Payment' \
or not msg_header.get('POIID'):
_logger.warning('Received an unexpected Adyen notification')
return
terminal_identifier = msg_header['POIID']
adyen_pm_sudo = request.env['pos.payment.method'].sudo().search([('adyen_terminal_identifier', '=', terminal_identifier)], limit=1)
if not adyen_pm_sudo:
_logger.warning('Received an Adyen event notification for a terminal not registered in Odoo: %s', terminal_identifier)
return
try:
adyen_additional_response = data['SaleToPOIResponse']['PaymentResponse']['Response']['AdditionalResponse']
pos_hmac = PosAdyenController._get_additional_data_from_unparsed(adyen_additional_response, 'metadata.pos_hmac')
if not pos_hmac or not consteq(pos_hmac, adyen_pm_sudo._get_hmac(msg_header['SaleID'], msg_header['ServiceID'], msg_header['POIID'], data['SaleToPOIResponse']['PaymentResponse']['SaleData']['SaleTransactionID']['TransactionID'])):
_logger.warning('Received an invalid Adyen event notification (invalid hmac): \n%s', pprint.pformat(data))
return
# The HMAC is removed to prevent anyone from using it in place of Adyen.
pos_hmac_metadata_raw = 'metadata.pos_hmac='+pos_hmac
safe_additional_response = adyen_additional_response.replace('&'+pos_hmac_metadata_raw, '').replace(pos_hmac_metadata_raw, '')
data['SaleToPOIResponse']['PaymentResponse']['Response']['AdditionalResponse'] = safe_additional_response
except (KeyError, AttributeError):
_logger.warning('Received an invalid Adyen event notification: \n%s', pprint.pformat(data))
return
return self._process_payment_response(data, adyen_pm_sudo)
@staticmethod
def _get_additional_data_from_unparsed(adyen_additional_response, data_key):
parsed_adyen_additional_response = parse_qs(adyen_additional_response)
return PosAdyenController._get_additional_data_from_parsed(parsed_adyen_additional_response, data_key)
@staticmethod
def _get_additional_data_from_parsed(parsed_adyen_additional_response, data_key):
data_value = parsed_adyen_additional_response.get(data_key)
return data_value[0] if data_value and len(data_value) == 1 else None
def _process_payment_response(self, data, adyen_pm_sudo):
transaction_id = data['SaleToPOIResponse']['PaymentResponse']['SaleData']['SaleTransactionID']['TransactionID']
if not transaction_id:
return
transaction_id_parts = transaction_id.split("--")
if len(transaction_id_parts) != 2:
return
pos_session_id = int(transaction_id_parts[1])
pos_session_sudo = request.env["pos.session"].sudo().browse(pos_session_id)
adyen_pm_sudo.adyen_latest_response = json.dumps(data)
request.env['bus.bus'].sudo()._sendone(pos_session_sudo._get_bus_channel_name(), 'ADYEN_LATEST_RESPONSE', pos_session_sudo.config_id.id)
return request.make_json_response('[accepted]') # https://docs.adyen.com/point-of-sale/design-your-integration/choose-your-architecture/cloud/#guarantee
+157 -28
View File
@@ -6,13 +6,17 @@ import pprint
import random
import requests
import string
from urllib.parse import parse_qs
from werkzeug.exceptions import Forbidden
from odoo import fields, models, api, _
from odoo.exceptions import ValidationError
from odoo.exceptions import ValidationError, UserError, AccessDenied
from odoo.tools import hmac
_logger = logging.getLogger(__name__)
UNPREDICTABLE_ADYEN_DATA = object() # sentinel
class PosPaymentMethod(models.Model):
_inherit = 'pos.payment.method'
@@ -20,9 +24,9 @@ class PosPaymentMethod(models.Model):
return super(PosPaymentMethod, self)._get_payment_terminal_selection() + [('adyen', 'Adyen')]
# Adyen
adyen_api_key = fields.Char(string="Adyen API key", help='Used when connecting to Adyen: https://docs.adyen.com/user-management/how-to-get-the-api-key/#description', copy=False)
adyen_api_key = fields.Char(string="Adyen API key", help='Used when connecting to Adyen: https://docs.adyen.com/user-management/how-to-get-the-api-key/#description', copy=False, groups='base.group_erp_manager')
adyen_terminal_identifier = fields.Char(help='[Terminal model]-[Serial number], for example: P400Plus-123456789', copy=False)
adyen_test_mode = fields.Boolean(help='Run transactions in the test environment.')
adyen_test_mode = fields.Boolean(help='Run transactions in the test environment.', groups='base.group_erp_manager')
adyen_latest_response = fields.Char(copy=False, groups='base.group_erp_manager') # used to buffer the latest asynchronous notification from Adyen.
adyen_latest_diagnosis = fields.Char(copy=False, groups='base.group_erp_manager') # used to determine if the terminal is still connected.
@@ -52,54 +56,179 @@ class PosPaymentMethod(models.Model):
}
def _is_write_forbidden(self, fields):
whitelisted_fields = {'adyen_latest_response', 'adyen_latest_diagnosis'}
return super(PosPaymentMethod, self)._is_write_forbidden(fields - whitelisted_fields)
def _adyen_diagnosis_request_data(self, pos_config_name):
service_id = ''.join(random.choices(string.ascii_letters + string.digits, k=10))
return {
"SaleToPOIRequest": {
"MessageHeader": {
"ProtocolVersion": "3.0",
"MessageClass": "Service",
"MessageCategory": "Diagnosis",
"MessageType": "Request",
"ServiceID": service_id,
"SaleID": pos_config_name,
"POIID": self.adyen_terminal_identifier,
},
"DiagnosisRequest": {
"HostDiagnosisFlag": False
}
}
}
return super(PosPaymentMethod, self)._is_write_forbidden(fields - {'adyen_latest_response'})
def get_latest_adyen_status(self):
self.ensure_one()
if not self.env.su and not self.user_has_groups('point_of_sale.group_pos_user'):
raise AccessDenied()
latest_response = self.sudo().adyen_latest_response
latest_response = json.loads(latest_response) if latest_response else False
return latest_response
def proxy_adyen_request(self, data, operation=False):
''' Necessary because Adyen's endpoints don't have CORS enabled '''
if data.get('SaleToPOIRequest') and data['SaleToPOIRequest']['MessageHeader']['MessageCategory'] == 'Payment': # Clear only if it is a payment request
self.ensure_one()
if not self.env.su and not self.user_has_groups('point_of_sale.group_pos_user'):
raise AccessDenied()
if not data:
raise UserError(_('Invalid Adyen request'))
if 'SaleToPOIRequest' in data and data['SaleToPOIRequest']['MessageHeader']['MessageCategory'] == 'Payment': # Clear only if it is a payment request
self.sudo().adyen_latest_response = '' # avoid handling old responses multiple times
if not operation:
operation = 'terminal_request'
# These checks are not optimal. This RPC method should be changed.
is_capture_data = operation == 'capture' and hasattr(self, 'adyen_merchant_account') and self._is_valid_adyen_request_data(data, {
'originalReference': UNPREDICTABLE_ADYEN_DATA,
'modificationAmount': {
'value': UNPREDICTABLE_ADYEN_DATA,
'currency': UNPREDICTABLE_ADYEN_DATA,
},
'merchantAccount': self.adyen_merchant_account,
})
is_adjust_data = operation == 'adjust' and hasattr(self, 'adyen_merchant_account') and self._is_valid_adyen_request_data(data, {
'originalReference': UNPREDICTABLE_ADYEN_DATA,
'modificationAmount': {
'value': UNPREDICTABLE_ADYEN_DATA,
'currency': UNPREDICTABLE_ADYEN_DATA,
},
'merchantAccount': self.adyen_merchant_account,
'additionalData': {
'industryUsage': 'DelayedCharge',
},
})
is_cancel_data = operation == 'terminal_request' and self._is_valid_adyen_request_data(data, {
'SaleToPOIRequest': {
'MessageHeader': self._get_expected_message_header('Abort'),
'AbortRequest': {
'AbortReason': 'MerchantAbort',
'MessageReference': {
'MessageCategory': 'Payment',
'SaleID': UNPREDICTABLE_ADYEN_DATA,
'ServiceID': UNPREDICTABLE_ADYEN_DATA,
},
},
},
})
is_payment_request_with_acquirer_data = operation == 'terminal_request' and self._is_valid_adyen_request_data(data, self._get_expected_payment_request(True))
if is_payment_request_with_acquirer_data:
parsed_sale_to_acquirer_data = parse_qs(data['SaleToPOIRequest']['PaymentRequest']['SaleData']['SaleToAcquirerData'])
valid_acquirer_data = self._get_valid_acquirer_data()
is_payment_request_with_acquirer_data = len(parsed_sale_to_acquirer_data.keys()) <= len(valid_acquirer_data.keys())
if is_payment_request_with_acquirer_data:
for key, values in parsed_sale_to_acquirer_data.items():
if len(values) != 1:
is_payment_request_with_acquirer_data = False
break
value = values[0]
valid_value = valid_acquirer_data.get(key)
if valid_value == UNPREDICTABLE_ADYEN_DATA:
continue
if value != valid_value:
is_payment_request_with_acquirer_data = False
break
is_payment_request_without_acquirer_data = operation == 'terminal_request' and self._is_valid_adyen_request_data(data, self._get_expected_payment_request(False))
if not is_payment_request_without_acquirer_data and not is_payment_request_with_acquirer_data and not is_adjust_data and not is_cancel_data and not is_capture_data:
raise UserError(_('Invalid Adyen request'))
if is_payment_request_with_acquirer_data or is_payment_request_without_acquirer_data:
acquirer_data = data['SaleToPOIRequest']['PaymentRequest']['SaleData'].get('SaleToAcquirerData')
msg_header = data['SaleToPOIRequest']['MessageHeader']
metadata = 'metadata.pos_hmac=' + self._get_hmac(msg_header['SaleID'], msg_header['ServiceID'], msg_header['POIID'], data['SaleToPOIRequest']['PaymentRequest']['SaleData']['SaleTransactionID']['TransactionID'])
data['SaleToPOIRequest']['PaymentRequest']['SaleData']['SaleToAcquirerData'] = acquirer_data + '&' + metadata if acquirer_data else metadata
return self._proxy_adyen_request_direct(data, operation)
@api.model
def _is_valid_adyen_request_data(self, provided_data, expected_data):
if not isinstance(provided_data, dict) or set(provided_data.keys()) != set(expected_data.keys()):
return False
for provided_key, provided_value in provided_data.items():
expected_value = expected_data[provided_key]
if expected_value == UNPREDICTABLE_ADYEN_DATA:
continue
if isinstance(expected_value, dict):
if not self._is_valid_adyen_request_data(provided_value, expected_value):
return False
else:
if provided_value != expected_value:
return False
return True
def _get_expected_message_header(self, expected_message_category):
return {
'ProtocolVersion': '3.0',
'MessageClass': 'Service',
'MessageType': 'Request',
'MessageCategory': expected_message_category,
'SaleID': UNPREDICTABLE_ADYEN_DATA,
'ServiceID': UNPREDICTABLE_ADYEN_DATA,
'POIID': self.adyen_terminal_identifier,
}
def _get_expected_payment_request(self, with_acquirer_data):
res = {
'SaleToPOIRequest': {
'MessageHeader': self._get_expected_message_header('Payment'),
'PaymentRequest': {
'SaleData': {
'SaleTransactionID': {
'TransactionID': UNPREDICTABLE_ADYEN_DATA,
'TimeStamp': UNPREDICTABLE_ADYEN_DATA,
},
},
'PaymentTransaction': {
'AmountsReq': {
'Currency': UNPREDICTABLE_ADYEN_DATA,
'RequestedAmount': UNPREDICTABLE_ADYEN_DATA,
},
},
},
},
}
if with_acquirer_data:
res['SaleToPOIRequest']['PaymentRequest']['SaleData']['SaleToAcquirerData'] = UNPREDICTABLE_ADYEN_DATA
return res
@api.model
def _get_valid_acquirer_data(self):
return {
'tenderOption': 'AskGratuity',
'authorisationType': 'PreAuth'
}
@api.model
def _get_hmac(self, sale_id, service_id, poi_id, sale_transaction_id):
return hmac(
env=self.env(su=True),
scope='pos_adyen_payment',
message=(sale_id, service_id, poi_id, sale_transaction_id)
)
def _proxy_adyen_request_direct(self, data, operation):
self.ensure_one()
TIMEOUT = 10
_logger.info('request to adyen\n%s', pprint.pformat(data))
_logger.info('Request to Adyen by user #%d:\n%s', self.env.uid, pprint.pformat(data))
environment = 'test' if self.adyen_test_mode else 'live'
environment = 'test' if self.sudo().adyen_test_mode else 'live'
endpoint = self._get_adyen_endpoints()[operation] % environment
headers = {
'x-api-key': self.adyen_api_key,
'x-api-key': self.sudo().adyen_api_key,
}
req = requests.post(endpoint, json=data, headers=headers, timeout=TIMEOUT)
@@ -264,7 +264,7 @@ export class PaymentAdyen extends PaymentInterface {
const payment_result = payment_response.PaymentResult;
const cashier_receipt = payment_response.PaymentReceipt.find((receipt) => {
receipt.DocumentQualifier == "CashierReceipt";
return receipt.DocumentQualifier == "CashierReceipt";
});
if (cashier_receipt) {
@@ -274,7 +274,7 @@ export class PaymentAdyen extends PaymentInterface {
}
const customer_receipt = payment_response.PaymentReceipt.find((receipt) => {
receipt.DocumentQualifier == "CustomerReceipt";
return receipt.DocumentQualifier == "CustomerReceipt";
});
if (customer_receipt) {
@@ -7,7 +7,7 @@
<field name="arch" type="xml">
<xpath expr="//field[@name='use_payment_terminal']" position="after">
<!-- Adyen -->
<field name="adyen_api_key" invisible="use_payment_terminal != 'adyen'" required="use_payment_terminal == 'adyen'"/>
<field name="adyen_api_key" invisible="use_payment_terminal != 'adyen'" required="use_payment_terminal == 'adyen'" password="True"/>
<field name="adyen_terminal_identifier" invisible="use_payment_terminal != 'adyen'" required="use_payment_terminal == 'adyen'"/>
<field name="adyen_test_mode" invisible="use_payment_terminal != 'adyen'" required="use_payment_terminal == 'adyen'"/>
</xpath>
+1 -1
View File
@@ -193,7 +193,7 @@ class PosSelfOrderController(http.Controller):
if not order_sudo or not payment_method_sudo or payment_method_sudo not in order_sudo.config_id.payment_method_ids:
raise NotFound("Order or payment method not found")
status = payment_method_sudo.payment_request_from_kiosk(order_sudo)
status = payment_method_sudo._payment_request_from_kiosk(order_sudo)
if not status:
raise BadRequest("Something went wrong")
@@ -5,5 +5,5 @@ class PosPaymentMethod(models.Model):
_inherit = "pos.payment.method"
# will be overridden.
def payment_request_from_kiosk(self, order):
def _payment_request_from_kiosk(self, order):
pass
+45 -35
View File
@@ -1,43 +1,53 @@
import json
import logging
from odoo.addons.pos_adyen.controllers.main import PosAdyenController
from odoo import http, fields
from odoo import fields
from odoo.http import request
_logger = logging.getLogger(__name__)
class PosSelfAdyenController(PosAdyenController):
@http.route()
def notification(self):
super().notification()
data = json.loads(request.httprequest.data)
if data.get('SaleToPOIResponse'):
order_reference = data['SaleToPOIResponse']['PaymentResponse']['SaleData']['SaleTransactionID']['TransactionID']
payment_result = data['SaleToPOIResponse']['PaymentResponse']['Response']['Result']
order_sudo = request.env['pos.order'].sudo().search([('pos_reference', '=', order_reference)], limit=1)
order = order_sudo.sudo(False).with_user(order_sudo.session_id.config_id.self_ordering_default_user_id).with_company(order_sudo.session_id.config_id.company_id)
terminal_identifier = data['SaleToPOIResponse']['MessageHeader']['POIID']
payment_method = request.env['pos.payment.method'].sudo().search([('adyen_terminal_identifier', '=', terminal_identifier)], limit=1)
def _process_payment_response(self, data, adyen_pm_sudo):
self_order_id = None
try:
self_order_id = PosAdyenController._get_additional_data_from_unparsed(data['SaleToPOIResponse']['PaymentResponse']['Response']['AdditionalResponse'], 'metadata.self_order_id')
except KeyError:
self_order_id = None
if payment_result == 'Success' and order.config_id.self_ordering_mode == 'kiosk':
payment_amount = data['SaleToPOIResponse']['PaymentResponse']['PaymentResult']['AmountsResp']['AuthorizedAmount']
card_type = data['SaleToPOIResponse']['PaymentResponse']['PaymentResult']['PaymentInstrumentData']['CardData']['PaymentBrand']
transaction_id = data['SaleToPOIResponse']['PaymentResponse']['SaleData']['SaleTransactionID']['TransactionID']
order.add_payment({
'amount': payment_amount,
'payment_date': fields.Datetime.now(),
'payment_method_id': payment_method.id,
'card_type': card_type,
'cardholder_name': '',
'transaction_id': transaction_id,
'payment_status': payment_result,
'ticket': '',
'pos_order_id': order.id
})
order.action_pos_order_paid()
order._send_order()
if not self_order_id:
return super()._process_payment_response(data, adyen_pm_sudo)
if order.config_id.self_ordering_mode == 'kiosk':
order.env['bus.bus']._sendone(f'pos_config-{order.config_id.access_token}', 'PAYMENT_STATUS', {
'payment_result': payment_result,
'order': order._export_for_self_order(),
})
order_sudo = request.env['pos.order'].sudo().search([('id', '=', self_order_id)], limit=1)
if not order_sudo:
_logger.warning('Received an Adyen event notification for the self order #%d that does not exist (anymore)', self_order_id)
return request.make_json_response('[accepted]') # https://docs.adyen.com/point-of-sale/design-your-integration/choose-your-architecture/cloud/#guarantee
order = order_sudo.sudo(False).with_user(order_sudo.session_id.config_id.self_ordering_default_user_id).with_company(order_sudo.session_id.config_id.company_id)
payment_result = data['SaleToPOIResponse']['PaymentResponse']['Response']['Result']
if payment_result == 'Success' and order.config_id.self_ordering_mode == 'kiosk':
payment_amount = data['SaleToPOIResponse']['PaymentResponse']['PaymentResult']['AmountsResp']['AuthorizedAmount']
card_type = data['SaleToPOIResponse']['PaymentResponse']['PaymentResult']['PaymentInstrumentData']['CardData']['PaymentBrand']
transaction_id = data['SaleToPOIResponse']['PaymentResponse']['SaleData']['SaleTransactionID']['TransactionID']
order.add_payment({
'amount': payment_amount,
'payment_date': fields.Datetime.now(),
'payment_method_id': adyen_pm_sudo.id,
'card_type': card_type,
'cardholder_name': '',
'transaction_id': transaction_id,
'payment_status': payment_result,
'ticket': '',
'pos_order_id': order.id
})
order.action_pos_order_paid()
order._send_order()
if order.config_id.self_ordering_mode == 'kiosk':
order.env['bus.bus']._sendone(f'pos_config-{order.config_id.access_token}', 'PAYMENT_STATUS', {
'payment_result': payment_result,
'order': order._export_for_self_order(),
})
return request.make_json_response('[accepted]') # https://docs.adyen.com/point-of-sale/design-your-integration/choose-your-architecture/cloud/#guarantee
@@ -1,14 +1,21 @@
from datetime import datetime, timezone
import random
from odoo import models
from odoo import models, api
from odoo.addons.pos_adyen.models.pos_payment_method import UNPREDICTABLE_ADYEN_DATA
class PosPaymentMethod(models.Model):
_inherit = "pos.payment.method"
def payment_request_from_kiosk(self, order):
@api.model
def _get_valid_acquirer_data(self):
res = super()._get_valid_acquirer_data()
res['metadata.self_order_id'] = UNPREDICTABLE_ADYEN_DATA
return res
def _payment_request_from_kiosk(self, order):
if self.use_payment_terminal != 'adyen':
return super().payment_request_from_kiosk(order)
return super()._payment_request_from_kiosk(order)
else:
pos_config = order.session_id.config_id
random_number = random.randrange(10**9, 10**10 - 1)
@@ -31,6 +38,7 @@ class PosPaymentMethod(models.Model):
'TransactionID': order.pos_reference, # your reference to identify a payment.
'TimeStamp': datetime.now(tz=timezone.utc).isoformat(timespec='seconds'), # date and time of the request in UTC format.
},
'SaleToAcquirerData': 'metadata.self_order_id=' + str(order.id),
},
'PaymentTransaction': {
'AmountsReq': {
@@ -44,7 +52,4 @@ class PosPaymentMethod(models.Model):
req = self.proxy_adyen_request(data)
if not req and req.get('error'):
return False
else:
return True
return req and (isinstance(req, bool) or not req.get('error'))
@@ -4,8 +4,8 @@ from odoo import models
class PosPaymentMethod(models.Model):
_inherit = "pos.payment.method"
def payment_request_from_kiosk(self, order):
def _payment_request_from_kiosk(self, order):
if self.use_payment_terminal != 'stripe':
return super().payment_request_from_kiosk(order)
return super()._payment_request_from_kiosk(order)
else:
return self.stripe_payment_intent(order.amount_total)