Делаю заказчику приём уведомлений об оплате. Платёжка шлёт POST с JSON на /payments/callback, а Rails 7.0 отвечает 422, в логе ActionController::InvalidAuthenticityToken. Контроллер обычный, от ApplicationController. Нашёл совет убрать protect_from_forgery из ApplicationController, но это как-то совсем грубо. Как принято?
Отключать глобально не надо. Для вебхуков удобно завести отдельный контроллер от ActionController::API: там нет ни CSRF, ни сессий, ни куки. И раз CSRF нет, подпись проверять обязательно, иначе любой сможет прислать «оплачено».
class Webhooks::PaymentsController < ActionController::API
before_action :verify_signature
def create
PaymentEventJob.perform_later(request.raw_post)
head :ok
end
private
def verify_signature
secret = Rails.application.credentials.dig(:payments, :webhook_secret)
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, request.raw_post)
given = request.headers["X-Signature"].to_s
head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(expected, given)
end
end
Подпись считается от сырого тела, поэтому request.raw_post, а не params.to_json: после разбора JSON порядок ключей и пробелы другие, подпись не сойдётся. Заголовок у каждой платёжки свой, смотри их документацию. И отвечай 200 быстро, обработку в джобу: если держать соединение секунд десять, они решат, что не дошло, и пришлют ещё раз.
В Laravel для этого был массив $except в мидлваре VerifyCsrfToken, и я по привычке тоже искал такой список. В Rails, как понял, ближайший аналог это skip_forgery_protection прямо в контроллере. Но вариант с ActionController::API чище, там и сессия не создаётся.
И сохраняй сырое тело в таблицу до обработки. Когда придут разбираться, что именно прислала платёжка, это будет единственный источник правды.
Сделал через ActionController::API. Подпись сошлась с первого раза, как только перешёл на raw_post, до этого считал от params и получал unauthorized на каждом запросе. Тело кладу в таблицу payment_events с уникальным индексом по id события, чтобы повторная отправка не создавала второй платёж. Ещё словил забавное: в тестовом режиме платёжка присылала подпись маленькими буквами, а в боевом большими, и secure_compare, конечно, не сходился. Теперь привожу заголовок к нижнему регистру перед сравнением. Неделю ходит без ошибок, заказчик доволен.