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.
@@ -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
@@ -34,5 +34,8 @@ require_relative "payu_pl/payouts/retrieve"
34
34
  require_relative "payu_pl/statements/retrieve"
35
35
  require_relative "payu_pl/client"
36
36
 
37
+ require_relative "payu_pl/webhooks/result"
38
+ require_relative "payu_pl/webhooks/validator"
39
+
37
40
  module PayuPl
38
41
  end
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.2.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: