payu_pl 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/DOCUMENTATION_UPDATES.md +144 -0
- data/README.md +149 -0
- data/WEBHOOK_INTEGRATION_SUMMARY.md +153 -0
- data/examples/WEBHOOK_GUIDE.md +383 -0
- data/examples/rack_webhook_example.ru +66 -0
- data/examples/sinatra_webhook_example.rb +58 -0
- data/examples/webhooks_controller.rb +104 -0
- data/lib/payu_pl/configuration.rb +2 -1
- data/lib/payu_pl/version.rb +1 -1
- data/lib/payu_pl/webhooks/result.rb +32 -0
- data/lib/payu_pl/webhooks/validator.rb +236 -0
- data/lib/payu_pl.rb +3 -0
- metadata +9 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module PayuPl
|
|
8
|
+
module Webhooks
|
|
9
|
+
# Validates PayU webhook signatures and parses payloads
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage in Rails controller
|
|
12
|
+
# result = PayuPl::Webhooks::Validator.new(request).validate_and_parse
|
|
13
|
+
# if result.success?
|
|
14
|
+
# payload = result.data
|
|
15
|
+
# # Process payload...
|
|
16
|
+
# else
|
|
17
|
+
# # Handle error: result.error
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# @example With custom secret key
|
|
21
|
+
# validator = PayuPl::Webhooks::Validator.new(request, second_key: 'custom_secret')
|
|
22
|
+
# result = validator.validate_and_parse
|
|
23
|
+
#
|
|
24
|
+
# @example With custom logger
|
|
25
|
+
# validator = PayuPl::Webhooks::Validator.new(request, logger: Logger.new(STDOUT))
|
|
26
|
+
# result = validator.validate_and_parse
|
|
27
|
+
class Validator
|
|
28
|
+
attr_reader :request, :logger, :second_key
|
|
29
|
+
|
|
30
|
+
# Initialize a new webhook validator
|
|
31
|
+
#
|
|
32
|
+
# @param request [Rack::Request, ActionDispatch::Request] The request object
|
|
33
|
+
# @param second_key [String, nil] The PayU second key for signature verification
|
|
34
|
+
# Defaults to PayuPl.configuration.second_key or ENV['PAYU_SECOND_KEY']
|
|
35
|
+
# @param logger [Logger, nil] Optional logger for debugging
|
|
36
|
+
def initialize(request, second_key: nil, logger: nil)
|
|
37
|
+
@request = request
|
|
38
|
+
@second_key = second_key || fetch_second_key
|
|
39
|
+
@logger = logger
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Validates the webhook signature and parses the payload
|
|
43
|
+
#
|
|
44
|
+
# @return [PayuPl::Webhooks::Result] Result object with data or error
|
|
45
|
+
def validate_and_parse
|
|
46
|
+
log_header
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
verify_signature!
|
|
50
|
+
log_signature_passed
|
|
51
|
+
|
|
52
|
+
payload = parse_payload
|
|
53
|
+
log_payload_parsed(payload)
|
|
54
|
+
|
|
55
|
+
Result.success(payload)
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
log_error(e)
|
|
58
|
+
Result.failure(e.message)
|
|
59
|
+
ensure
|
|
60
|
+
log_footer
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Validates only the signature without parsing the payload
|
|
65
|
+
#
|
|
66
|
+
# @return [Boolean] true if signature is valid
|
|
67
|
+
# @raise [RuntimeError] if signature is invalid or missing
|
|
68
|
+
def verify_signature!
|
|
69
|
+
header = request.env['HTTP_OPENPAYU_SIGNATURE']
|
|
70
|
+
raise 'Missing OpenPayU signature header' unless header
|
|
71
|
+
|
|
72
|
+
log_signature_header(header)
|
|
73
|
+
|
|
74
|
+
signature_parts = parse_signature_header(header)
|
|
75
|
+
incoming_signature = signature_parts['signature']
|
|
76
|
+
algorithm = (signature_parts['algorithm'] || 'SHA256').downcase
|
|
77
|
+
|
|
78
|
+
body = read_body
|
|
79
|
+
|
|
80
|
+
expected_signature = compute_expected_signature(algorithm, body)
|
|
81
|
+
|
|
82
|
+
log_signature_comparison(algorithm, incoming_signature, expected_signature)
|
|
83
|
+
|
|
84
|
+
# For MD5, PayU might use either body+key or key+body
|
|
85
|
+
# Try both approaches
|
|
86
|
+
if algorithm == 'md5'
|
|
87
|
+
alternative_signature = Digest::MD5.hexdigest(second_key + body)
|
|
88
|
+
|
|
89
|
+
unless secure_compare(expected_signature, incoming_signature) ||
|
|
90
|
+
secure_compare(alternative_signature, incoming_signature)
|
|
91
|
+
raise "Signature verification failed for algorithm #{algorithm}"
|
|
92
|
+
end
|
|
93
|
+
else
|
|
94
|
+
unless secure_compare(expected_signature, incoming_signature)
|
|
95
|
+
raise "Signature verification failed for algorithm #{algorithm}"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Parses the webhook payload
|
|
103
|
+
#
|
|
104
|
+
# @return [Hash] The parsed JSON payload
|
|
105
|
+
def parse_payload
|
|
106
|
+
raw_payload = read_body
|
|
107
|
+
log_raw_payload(raw_payload)
|
|
108
|
+
JSON.parse(raw_payload)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def fetch_second_key
|
|
114
|
+
if PayuPl.config.second_key
|
|
115
|
+
PayuPl.config.second_key
|
|
116
|
+
elsif ENV['PAYU_SECOND_KEY']
|
|
117
|
+
ENV['PAYU_SECOND_KEY']
|
|
118
|
+
else
|
|
119
|
+
raise KeyError, "PayU second_key not configured. Set it via PayuPl.configure or ENV['PAYU_SECOND_KEY']"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parse_signature_header(header)
|
|
124
|
+
header.split(';').to_h { |part| part.split('=', 2) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def read_body
|
|
128
|
+
body = request.body.read
|
|
129
|
+
request.body.rewind
|
|
130
|
+
body
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def compute_expected_signature(algorithm, body)
|
|
134
|
+
case algorithm
|
|
135
|
+
when 'md5'
|
|
136
|
+
# PayU uses MD5(body + key) or MD5(key + body)
|
|
137
|
+
first_attempt = Digest::MD5.hexdigest(body + second_key)
|
|
138
|
+
log_debug("MD5(body+key): #{first_attempt}")
|
|
139
|
+
|
|
140
|
+
first_attempt
|
|
141
|
+
when 'sha', 'sha1'
|
|
142
|
+
OpenSSL::HMAC.hexdigest('sha1', second_key, body)
|
|
143
|
+
when 'sha256'
|
|
144
|
+
OpenSSL::HMAC.hexdigest('sha256', second_key, body)
|
|
145
|
+
when 'sha384'
|
|
146
|
+
OpenSSL::HMAC.hexdigest('sha384', second_key, body)
|
|
147
|
+
when 'sha512'
|
|
148
|
+
OpenSSL::HMAC.hexdigest('sha512', second_key, body)
|
|
149
|
+
else
|
|
150
|
+
# Default to the algorithm provided
|
|
151
|
+
OpenSSL::HMAC.hexdigest(algorithm, second_key, body)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Constant-time string comparison to prevent timing attacks
|
|
156
|
+
def secure_compare(a, b)
|
|
157
|
+
return false if a.nil? || b.nil? || a.bytesize != b.bytesize
|
|
158
|
+
|
|
159
|
+
# Use Rack's secure_compare if available
|
|
160
|
+
if defined?(Rack::Utils) && Rack::Utils.respond_to?(:secure_compare)
|
|
161
|
+
Rack::Utils.secure_compare(a, b)
|
|
162
|
+
else
|
|
163
|
+
# Fallback implementation
|
|
164
|
+
l = a.unpack("C*")
|
|
165
|
+
r = 0
|
|
166
|
+
b.each_byte { |byte| r |= byte ^ l.shift }
|
|
167
|
+
r == 0
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Logging methods
|
|
172
|
+
def log_header
|
|
173
|
+
return unless logger
|
|
174
|
+
|
|
175
|
+
logger.info("=" * 80)
|
|
176
|
+
logger.info("PayU Webhook Validation Started")
|
|
177
|
+
logger.info("=" * 80)
|
|
178
|
+
logger.info("Remote IP: #{request.ip}") if request.respond_to?(:ip)
|
|
179
|
+
logger.info("Method: #{request.request_method}") if request.respond_to?(:request_method)
|
|
180
|
+
logger.info("Path: #{request.path}") if request.respond_to?(:path)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def log_signature_header(header)
|
|
184
|
+
logger&.info("Signature Header: #{header}")
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def log_signature_comparison(algorithm, incoming, expected)
|
|
188
|
+
return unless logger
|
|
189
|
+
|
|
190
|
+
logger.info("Algorithm: #{algorithm}")
|
|
191
|
+
logger.info("Incoming Signature: #{incoming}")
|
|
192
|
+
logger.info("Expected Signature: #{expected}")
|
|
193
|
+
logger.info("Match: #{incoming == expected}")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def log_signature_passed
|
|
197
|
+
logger&.info("✓ Signature verification passed")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def log_raw_payload(payload)
|
|
201
|
+
logger&.debug("Raw Payload: #{payload}")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def log_payload_parsed(payload)
|
|
205
|
+
return unless logger
|
|
206
|
+
|
|
207
|
+
logger.info("✓ Payload parsed successfully")
|
|
208
|
+
logger.info("Order ID: #{payload.dig('order', 'orderId')}")
|
|
209
|
+
logger.info("Status: #{payload.dig('order', 'status')}")
|
|
210
|
+
|
|
211
|
+
# Format amount (PayU sends in minor units: 2900 = 29.00 PLN)
|
|
212
|
+
total_amount = payload.dig('order', 'totalAmount')
|
|
213
|
+
currency = payload.dig('order', 'currencyCode')
|
|
214
|
+
if total_amount
|
|
215
|
+
formatted_amount = "%.2f" % (total_amount.to_i / 100.0)
|
|
216
|
+
logger.info("Amount: #{formatted_amount} #{currency}")
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def log_error(error)
|
|
221
|
+
return unless logger
|
|
222
|
+
|
|
223
|
+
logger.error("✗ Error: #{error.class} - #{error.message}")
|
|
224
|
+
logger.error("Backtrace: #{error.backtrace.first(5).join("\n")}") if error.backtrace
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def log_footer
|
|
228
|
+
logger&.info("=" * 80)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def log_debug(message)
|
|
232
|
+
logger&.debug(message)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
data/lib/payu_pl.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: payu_pl
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Dmytro Koval
|
|
@@ -61,11 +61,17 @@ extra_rdoc_files: []
|
|
|
61
61
|
files:
|
|
62
62
|
- CHANGELOG.md
|
|
63
63
|
- CODE_OF_CONDUCT.md
|
|
64
|
+
- DOCUMENTATION_UPDATES.md
|
|
64
65
|
- LICENSE.txt
|
|
65
66
|
- README.md
|
|
66
67
|
- Rakefile
|
|
68
|
+
- WEBHOOK_INTEGRATION_SUMMARY.md
|
|
67
69
|
- config/locales/en.yml
|
|
68
70
|
- config/locales/pl.yml
|
|
71
|
+
- examples/WEBHOOK_GUIDE.md
|
|
72
|
+
- examples/rack_webhook_example.ru
|
|
73
|
+
- examples/sinatra_webhook_example.rb
|
|
74
|
+
- examples/webhooks_controller.rb
|
|
69
75
|
- lib/payu_pl.rb
|
|
70
76
|
- lib/payu_pl/authorize/oauth_token.rb
|
|
71
77
|
- lib/payu_pl/client.rb
|
|
@@ -92,6 +98,8 @@ files:
|
|
|
92
98
|
- lib/payu_pl/statements/retrieve.rb
|
|
93
99
|
- lib/payu_pl/transport.rb
|
|
94
100
|
- lib/payu_pl/version.rb
|
|
101
|
+
- lib/payu_pl/webhooks/result.rb
|
|
102
|
+
- lib/payu_pl/webhooks/validator.rb
|
|
95
103
|
- sig/payu_pl.rbs
|
|
96
104
|
homepage: https://developers.payu.com/europe/
|
|
97
105
|
licenses:
|