zai_payment 1.1.0 → 1.2.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 +44 -0
- data/README.md +44 -40
- data/docs/AUTHENTICATION.md +647 -0
- data/docs/README.md +81 -0
- data/docs/WEBHOOKS.md +261 -1
- data/docs/WEBHOOK_SECURITY_QUICKSTART.md +141 -0
- data/docs/WEBHOOK_SIGNATURE.md +244 -0
- data/examples/webhooks.md +489 -0
- data/lib/zai_payment/resources/webhook.rb +174 -0
- data/lib/zai_payment/version.rb +1 -1
- metadata +33 -1
data/examples/webhooks.md
CHANGED
|
@@ -16,6 +16,228 @@ ZaiPayment.configure do |config|
|
|
|
16
16
|
end
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
+
## Webhook Security: Complete Setup Guide
|
|
20
|
+
|
|
21
|
+
### Step 1: Generate and Register a Secret Key
|
|
22
|
+
|
|
23
|
+
Before setting up webhooks, you should establish a secure secret key for signature verification:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require 'securerandom'
|
|
27
|
+
|
|
28
|
+
# Generate a cryptographically secure secret key (minimum 32 bytes)
|
|
29
|
+
secret_key = SecureRandom.alphanumeric(32)
|
|
30
|
+
# Example output: "aB3xYz9mKpQrTuVwXy2zAbCdEfGhIjKl"
|
|
31
|
+
|
|
32
|
+
# Store this in your environment variables or secure vault
|
|
33
|
+
# NEVER commit this to version control!
|
|
34
|
+
puts "Add this to your environment variables:"
|
|
35
|
+
puts "ZAI_WEBHOOK_SECRET=#{secret_key}"
|
|
36
|
+
|
|
37
|
+
# Register the secret key with Zai
|
|
38
|
+
response = ZaiPayment.webhooks.create_secret_key(secret_key: secret_key)
|
|
39
|
+
|
|
40
|
+
if response.success?
|
|
41
|
+
puts "✅ Secret key registered successfully with Zai!"
|
|
42
|
+
puts "Store this key securely - you'll need it to verify webhook signatures"
|
|
43
|
+
else
|
|
44
|
+
puts "❌ Failed to register secret key"
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Step 2: Create a Webhook
|
|
49
|
+
|
|
50
|
+
Now create a webhook to receive notifications:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
response = ZaiPayment.webhooks.create(
|
|
54
|
+
url: 'https://your-app.com/webhooks/zai',
|
|
55
|
+
object_type: 'transactions',
|
|
56
|
+
enabled: true,
|
|
57
|
+
description: 'Production webhook for transaction updates'
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
webhook = response.data
|
|
61
|
+
puts "Created webhook: #{webhook['id']}"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Step 3: Implement Webhook Endpoint
|
|
65
|
+
|
|
66
|
+
Here's a complete Rails controller example with signature verification:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# app/controllers/webhooks_controller.rb
|
|
70
|
+
class WebhooksController < ApplicationController
|
|
71
|
+
# Skip CSRF token verification for webhook endpoints
|
|
72
|
+
skip_before_action :verify_authenticity_token
|
|
73
|
+
|
|
74
|
+
# Add basic rate limiting (if using Rack::Attack or similar)
|
|
75
|
+
# throttle('webhooks/ip', limit: 100, period: 1.minute)
|
|
76
|
+
|
|
77
|
+
def zai_webhook
|
|
78
|
+
# Read the raw request body - IMPORTANT: Don't parse it first!
|
|
79
|
+
payload = request.body.read
|
|
80
|
+
signature_header = request.headers['Webhooks-signature']
|
|
81
|
+
secret_key = ENV['ZAI_WEBHOOK_SECRET']
|
|
82
|
+
|
|
83
|
+
# Verify the signature
|
|
84
|
+
unless verify_webhook_signature(payload, signature_header, secret_key)
|
|
85
|
+
Rails.logger.warn "Invalid webhook signature received from #{request.remote_ip}"
|
|
86
|
+
return render json: { error: 'Invalid signature' }, status: :unauthorized
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Parse and process the webhook
|
|
90
|
+
webhook_data = JSON.parse(payload)
|
|
91
|
+
process_webhook(webhook_data)
|
|
92
|
+
|
|
93
|
+
# Return 200 to acknowledge receipt
|
|
94
|
+
render json: { status: 'success' }, status: :ok
|
|
95
|
+
rescue JSON::ParserError => e
|
|
96
|
+
Rails.logger.error "Invalid JSON in webhook: #{e.message}"
|
|
97
|
+
render json: { error: 'Invalid JSON' }, status: :bad_request
|
|
98
|
+
rescue StandardError => e
|
|
99
|
+
Rails.logger.error "Webhook processing error: #{e.message}"
|
|
100
|
+
render json: { error: 'Processing error' }, status: :internal_server_error
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def verify_webhook_signature(payload, signature_header, secret_key)
|
|
106
|
+
return false if signature_header.blank?
|
|
107
|
+
|
|
108
|
+
ZaiPayment.webhooks.verify_signature(
|
|
109
|
+
payload: payload,
|
|
110
|
+
signature_header: signature_header,
|
|
111
|
+
secret_key: secret_key,
|
|
112
|
+
tolerance: 300 # 5 minutes - adjust based on your needs
|
|
113
|
+
)
|
|
114
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
115
|
+
Rails.logger.warn "Webhook signature validation failed: #{e.message}"
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def process_webhook(data)
|
|
120
|
+
# Log the webhook for debugging
|
|
121
|
+
Rails.logger.info "Processing Zai webhook: #{data['event']}"
|
|
122
|
+
|
|
123
|
+
# Handle different webhook events
|
|
124
|
+
case data['event']
|
|
125
|
+
when 'transaction.created'
|
|
126
|
+
handle_transaction_created(data)
|
|
127
|
+
when 'transaction.updated'
|
|
128
|
+
handle_transaction_updated(data)
|
|
129
|
+
when 'transaction.completed'
|
|
130
|
+
handle_transaction_completed(data)
|
|
131
|
+
else
|
|
132
|
+
Rails.logger.info "Unhandled webhook event: #{data['event']}"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def handle_transaction_created(data)
|
|
137
|
+
# Your logic here
|
|
138
|
+
Rails.logger.info "Transaction created: #{data['transaction']['id']}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def handle_transaction_updated(data)
|
|
142
|
+
# Your logic here
|
|
143
|
+
Rails.logger.info "Transaction updated: #{data['transaction']['id']}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def handle_transaction_completed(data)
|
|
147
|
+
# Your logic here
|
|
148
|
+
Rails.logger.info "Transaction completed: #{data['transaction']['id']}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Step 4: Configure Routes
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
# config/routes.rb
|
|
157
|
+
Rails.application.routes.draw do
|
|
158
|
+
# Webhook endpoint
|
|
159
|
+
post '/webhooks/zai', to: 'webhooks#zai_webhook'
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Step 5: Test Your Webhook
|
|
164
|
+
|
|
165
|
+
Create a test to verify your implementation:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
# spec/controllers/webhooks_controller_spec.rb
|
|
169
|
+
require 'rails_helper'
|
|
170
|
+
|
|
171
|
+
RSpec.describe WebhooksController, type: :controller do
|
|
172
|
+
let(:secret_key) { SecureRandom.alphanumeric(32) }
|
|
173
|
+
let(:webhook_payload) do
|
|
174
|
+
{
|
|
175
|
+
event: 'transaction.updated',
|
|
176
|
+
transaction: {
|
|
177
|
+
id: 'txn_123',
|
|
178
|
+
state: 'completed',
|
|
179
|
+
amount: 1000
|
|
180
|
+
}
|
|
181
|
+
}.to_json
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
before do
|
|
185
|
+
ENV['ZAI_WEBHOOK_SECRET'] = secret_key
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
describe 'POST #zai_webhook' do
|
|
189
|
+
context 'with valid signature' do
|
|
190
|
+
it 'processes the webhook successfully' do
|
|
191
|
+
timestamp = Time.now.to_i
|
|
192
|
+
signature = ZaiPayment::Resources::Webhook.new.generate_signature(
|
|
193
|
+
webhook_payload, secret_key, timestamp
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
request.headers['Webhooks-signature'] = "t=#{timestamp},v=#{signature}"
|
|
197
|
+
post :zai_webhook, body: webhook_payload
|
|
198
|
+
|
|
199
|
+
expect(response).to have_http_status(:ok)
|
|
200
|
+
expect(JSON.parse(response.body)['status']).to eq('success')
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
context 'with invalid signature' do
|
|
205
|
+
it 'rejects the webhook' do
|
|
206
|
+
timestamp = Time.now.to_i
|
|
207
|
+
request.headers['Webhooks-signature'] = "t=#{timestamp},v=invalid_signature"
|
|
208
|
+
post :zai_webhook, body: webhook_payload
|
|
209
|
+
|
|
210
|
+
expect(response).to have_http_status(:unauthorized)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
context 'with missing signature header' do
|
|
215
|
+
it 'rejects the webhook' do
|
|
216
|
+
post :zai_webhook, body: webhook_payload
|
|
217
|
+
|
|
218
|
+
expect(response).to have_http_status(:unauthorized)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
context 'with expired timestamp' do
|
|
223
|
+
it 'rejects old webhooks to prevent replay attacks' do
|
|
224
|
+
old_timestamp = Time.now.to_i - 600 # 10 minutes ago
|
|
225
|
+
signature = ZaiPayment::Resources::Webhook.new.generate_signature(
|
|
226
|
+
webhook_payload, secret_key, old_timestamp
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
request.headers['Webhooks-signature'] = "t=#{old_timestamp},v=#{signature}"
|
|
230
|
+
post :zai_webhook, body: webhook_payload
|
|
231
|
+
|
|
232
|
+
expect(response).to have_http_status(:unauthorized)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Basic Webhook Operations
|
|
240
|
+
|
|
19
241
|
## List Webhooks
|
|
20
242
|
|
|
21
243
|
```ruby
|
|
@@ -144,3 +366,270 @@ response.headers # => Response headers
|
|
|
144
366
|
response.status # => HTTP status code
|
|
145
367
|
```
|
|
146
368
|
|
|
369
|
+
## Additional Webhook Security Examples
|
|
370
|
+
|
|
371
|
+
### Generate Signature for Testing
|
|
372
|
+
|
|
373
|
+
You can generate signatures for testing your webhook implementation:
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
# Useful for integration tests or webhook simulation
|
|
377
|
+
payload = '{"event": "transaction.updated", "id": "txn_123"}'
|
|
378
|
+
secret_key = ENV['ZAI_WEBHOOK_SECRET']
|
|
379
|
+
timestamp = Time.now.to_i
|
|
380
|
+
|
|
381
|
+
webhook = ZaiPayment::Resources::Webhook.new
|
|
382
|
+
signature = webhook.generate_signature(payload, secret_key, timestamp)
|
|
383
|
+
|
|
384
|
+
puts "Signature header: t=#{timestamp},v=#{signature}"
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Verify Signature Manually
|
|
388
|
+
|
|
389
|
+
If you need more control over the verification process:
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
webhook = ZaiPayment::Resources::Webhook.new
|
|
393
|
+
|
|
394
|
+
begin
|
|
395
|
+
is_valid = webhook.verify_signature(
|
|
396
|
+
payload: request_body,
|
|
397
|
+
signature_header: request.headers['Webhooks-signature'],
|
|
398
|
+
secret_key: ENV['ZAI_WEBHOOK_SECRET'],
|
|
399
|
+
tolerance: 300
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if is_valid
|
|
403
|
+
puts "✅ Webhook signature is valid"
|
|
404
|
+
else
|
|
405
|
+
puts "❌ Webhook signature is invalid"
|
|
406
|
+
end
|
|
407
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
408
|
+
puts "⚠️ Validation error: #{e.message}"
|
|
409
|
+
end
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Sinatra Example
|
|
413
|
+
|
|
414
|
+
If you're using Sinatra instead of Rails:
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
require 'sinatra'
|
|
418
|
+
require 'json'
|
|
419
|
+
require 'zai_payment'
|
|
420
|
+
|
|
421
|
+
# Configure ZaiPayment
|
|
422
|
+
ZaiPayment.configure do |config|
|
|
423
|
+
config.environment = :prelive
|
|
424
|
+
config.client_id = ENV['ZAI_CLIENT_ID']
|
|
425
|
+
config.client_secret = ENV['ZAI_CLIENT_SECRET']
|
|
426
|
+
config.scope = ENV['ZAI_SCOPE']
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
post '/webhooks/zai' do
|
|
430
|
+
# Read raw request body
|
|
431
|
+
payload = request.body.read
|
|
432
|
+
signature_header = request.env['HTTP_WEBHOOKS_SIGNATURE']
|
|
433
|
+
secret_key = ENV['ZAI_WEBHOOK_SECRET']
|
|
434
|
+
|
|
435
|
+
# Verify signature
|
|
436
|
+
webhook = ZaiPayment::Resources::Webhook.new
|
|
437
|
+
|
|
438
|
+
begin
|
|
439
|
+
unless webhook.verify_signature(
|
|
440
|
+
payload: payload,
|
|
441
|
+
signature_header: signature_header,
|
|
442
|
+
secret_key: secret_key
|
|
443
|
+
)
|
|
444
|
+
halt 401, { error: 'Invalid signature' }.to_json
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Process webhook
|
|
448
|
+
data = JSON.parse(payload)
|
|
449
|
+
logger.info "Received webhook: #{data['event']}"
|
|
450
|
+
|
|
451
|
+
# Your processing logic here
|
|
452
|
+
|
|
453
|
+
status 200
|
|
454
|
+
{ status: 'success' }.to_json
|
|
455
|
+
rescue ZaiPayment::Errors::ValidationError => e
|
|
456
|
+
logger.error "Webhook validation failed: #{e.message}"
|
|
457
|
+
halt 401, { error: e.message }.to_json
|
|
458
|
+
rescue StandardError => e
|
|
459
|
+
logger.error "Webhook processing error: #{e.message}"
|
|
460
|
+
halt 500, { error: 'Processing error' }.to_json
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### Rack Middleware Example
|
|
466
|
+
|
|
467
|
+
Create reusable middleware for webhook verification:
|
|
468
|
+
|
|
469
|
+
```ruby
|
|
470
|
+
# lib/middleware/zai_webhook_verifier.rb
|
|
471
|
+
module Middleware
|
|
472
|
+
class ZaiWebhookVerifier
|
|
473
|
+
def initialize(app, options = {})
|
|
474
|
+
@app = app
|
|
475
|
+
@secret_key = options[:secret_key] || ENV['ZAI_WEBHOOK_SECRET']
|
|
476
|
+
@tolerance = options[:tolerance] || 300
|
|
477
|
+
@webhook_path = options[:path] || '/webhooks/zai'
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def call(env)
|
|
481
|
+
request = Rack::Request.new(env)
|
|
482
|
+
|
|
483
|
+
# Only verify requests to the webhook path
|
|
484
|
+
if request.path == @webhook_path && request.post?
|
|
485
|
+
unless verify_request(request)
|
|
486
|
+
return [401, { 'Content-Type' => 'application/json' },
|
|
487
|
+
[{ error: 'Invalid webhook signature' }.to_json]]
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
@app.call(env)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
private
|
|
495
|
+
|
|
496
|
+
def verify_request(request)
|
|
497
|
+
body = request.body.read
|
|
498
|
+
request.body.rewind # Important: rewind for downstream processing
|
|
499
|
+
|
|
500
|
+
signature_header = request.env['HTTP_WEBHOOKS_SIGNATURE']
|
|
501
|
+
return false unless signature_header
|
|
502
|
+
|
|
503
|
+
webhook = ZaiPayment::Resources::Webhook.new
|
|
504
|
+
webhook.verify_signature(
|
|
505
|
+
payload: body,
|
|
506
|
+
signature_header: signature_header,
|
|
507
|
+
secret_key: @secret_key,
|
|
508
|
+
tolerance: @tolerance
|
|
509
|
+
)
|
|
510
|
+
rescue ZaiPayment::Errors::ValidationError
|
|
511
|
+
false
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Usage in config.ru or Rails application.rb:
|
|
517
|
+
use Middleware::ZaiWebhookVerifier,
|
|
518
|
+
secret_key: ENV['ZAI_WEBHOOK_SECRET'],
|
|
519
|
+
tolerance: 300,
|
|
520
|
+
path: '/webhooks/zai'
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
### Background Job Processing
|
|
524
|
+
|
|
525
|
+
For production systems, process webhooks asynchronously:
|
|
526
|
+
|
|
527
|
+
```ruby
|
|
528
|
+
# app/controllers/webhooks_controller.rb
|
|
529
|
+
class WebhooksController < ApplicationController
|
|
530
|
+
skip_before_action :verify_authenticity_token
|
|
531
|
+
|
|
532
|
+
def zai_webhook
|
|
533
|
+
payload = request.body.read
|
|
534
|
+
signature_header = request.headers['Webhooks-signature']
|
|
535
|
+
|
|
536
|
+
# Quick verification
|
|
537
|
+
unless verify_signature(payload, signature_header)
|
|
538
|
+
return render json: { error: 'Invalid signature' }, status: :unauthorized
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# Enqueue for background processing
|
|
542
|
+
ZaiWebhookJob.perform_later(payload)
|
|
543
|
+
|
|
544
|
+
# Return immediately
|
|
545
|
+
render json: { status: 'accepted' }, status: :accepted
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
private
|
|
549
|
+
|
|
550
|
+
def verify_signature(payload, signature_header)
|
|
551
|
+
ZaiPayment.webhooks.verify_signature(
|
|
552
|
+
payload: payload,
|
|
553
|
+
signature_header: signature_header,
|
|
554
|
+
secret_key: ENV['ZAI_WEBHOOK_SECRET']
|
|
555
|
+
)
|
|
556
|
+
rescue ZaiPayment::Errors::ValidationError
|
|
557
|
+
false
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
# app/jobs/zai_webhook_job.rb
|
|
562
|
+
class ZaiWebhookJob < ApplicationJob
|
|
563
|
+
queue_as :webhooks
|
|
564
|
+
|
|
565
|
+
# Retry logic for transient failures
|
|
566
|
+
retry_on StandardError, wait: :exponentially_longer, attempts: 5
|
|
567
|
+
|
|
568
|
+
def perform(payload)
|
|
569
|
+
data = JSON.parse(payload)
|
|
570
|
+
|
|
571
|
+
# Idempotent processing - check if already processed
|
|
572
|
+
return if WebhookEvent.exists?(external_id: data['id'])
|
|
573
|
+
|
|
574
|
+
# Process the webhook
|
|
575
|
+
WebhookEvent.create!(
|
|
576
|
+
external_id: data['id'],
|
|
577
|
+
event_type: data['event'],
|
|
578
|
+
payload: data,
|
|
579
|
+
processed_at: Time.current
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# Handle event
|
|
583
|
+
case data['event']
|
|
584
|
+
when 'transaction.completed'
|
|
585
|
+
TransactionProcessor.process_completion(data['transaction'])
|
|
586
|
+
when 'transaction.failed'
|
|
587
|
+
TransactionProcessor.process_failure(data['transaction'])
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
### Idempotency Pattern
|
|
594
|
+
|
|
595
|
+
Ensure webhooks are processed only once:
|
|
596
|
+
|
|
597
|
+
```ruby
|
|
598
|
+
# app/models/webhook_event.rb
|
|
599
|
+
class WebhookEvent < ApplicationRecord
|
|
600
|
+
# Columns: external_id, event_type, payload (jsonb), processed_at, created_at
|
|
601
|
+
|
|
602
|
+
validates :external_id, presence: true, uniqueness: true
|
|
603
|
+
|
|
604
|
+
def self.process_if_new(webhook_data)
|
|
605
|
+
# Use database constraint to ensure atomicity
|
|
606
|
+
transaction do
|
|
607
|
+
event = create!(
|
|
608
|
+
external_id: webhook_data['id'],
|
|
609
|
+
event_type: webhook_data['event'],
|
|
610
|
+
payload: webhook_data
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
yield event if block_given?
|
|
614
|
+
|
|
615
|
+
event.update!(processed_at: Time.current)
|
|
616
|
+
end
|
|
617
|
+
rescue ActiveRecord::RecordNotUnique
|
|
618
|
+
Rails.logger.info "Webhook already processed: #{webhook_data['id']}"
|
|
619
|
+
false
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
# Usage:
|
|
624
|
+
def process_webhook(data)
|
|
625
|
+
WebhookEvent.process_if_new(data) do |event|
|
|
626
|
+
# Your processing logic here
|
|
627
|
+
# This block only runs if the webhook is new
|
|
628
|
+
case event.event_type
|
|
629
|
+
when 'transaction.completed'
|
|
630
|
+
handle_completion(data)
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
```
|
|
635
|
+
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'base64'
|
|
5
|
+
|
|
3
6
|
module ZaiPayment
|
|
4
7
|
module Resources
|
|
5
8
|
# Webhook resource for managing Zai webhooks
|
|
@@ -130,6 +133,102 @@ module ZaiPayment
|
|
|
130
133
|
client.delete("/webhooks/#{webhook_id}")
|
|
131
134
|
end
|
|
132
135
|
|
|
136
|
+
# Create a secret key for webhook signature verification
|
|
137
|
+
#
|
|
138
|
+
# @param secret_key [String] the secret key to use for HMAC signature generation
|
|
139
|
+
# Must be ASCII characters and at least 32 bytes in size
|
|
140
|
+
# @return [Response] the API response
|
|
141
|
+
#
|
|
142
|
+
# @example
|
|
143
|
+
# webhooks = ZaiPayment::Resources::Webhook.new
|
|
144
|
+
# secret_key = SecureRandom.alphanumeric(32)
|
|
145
|
+
# response = webhooks.create_secret_key(secret_key: secret_key)
|
|
146
|
+
#
|
|
147
|
+
# @see https://developer.hellozai.com/reference/createsecretkey
|
|
148
|
+
# @see https://developer.hellozai.com/docs/verify-webhook-signatures
|
|
149
|
+
def create_secret_key(secret_key:)
|
|
150
|
+
validate_presence!(secret_key, 'secret_key')
|
|
151
|
+
validate_secret_key!(secret_key)
|
|
152
|
+
|
|
153
|
+
body = { secret_key: secret_key }
|
|
154
|
+
client.post('/webhooks/secret_key', body: body)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Verify webhook signature
|
|
158
|
+
#
|
|
159
|
+
# This method verifies that a webhook request came from Zai by validating
|
|
160
|
+
# the HMAC SHA256 signature in the Webhooks-signature header.
|
|
161
|
+
#
|
|
162
|
+
# @param payload [String] the raw request body (JSON string)
|
|
163
|
+
# @param signature_header [String] the Webhooks-signature header value
|
|
164
|
+
# @param secret_key [String] your secret key used for signature generation
|
|
165
|
+
# @param tolerance [Integer] maximum age of webhook in seconds (default: 300 = 5 minutes)
|
|
166
|
+
# @return [Boolean] true if signature is valid and within tolerance
|
|
167
|
+
# @raise [Errors::ValidationError] if signature is invalid or timestamp is outside tolerance
|
|
168
|
+
#
|
|
169
|
+
# @example
|
|
170
|
+
# # In your webhook endpoint (e.g., Rails controller)
|
|
171
|
+
# def webhook
|
|
172
|
+
# payload = request.body.read
|
|
173
|
+
# signature_header = request.headers['Webhooks-signature']
|
|
174
|
+
# secret_key = ENV['ZAI_WEBHOOK_SECRET']
|
|
175
|
+
#
|
|
176
|
+
# if ZaiPayment.webhooks.verify_signature(
|
|
177
|
+
# payload: payload,
|
|
178
|
+
# signature_header: signature_header,
|
|
179
|
+
# secret_key: secret_key
|
|
180
|
+
# )
|
|
181
|
+
# # Process webhook
|
|
182
|
+
# render json: { status: 'success' }
|
|
183
|
+
# else
|
|
184
|
+
# render json: { error: 'Invalid signature' }, status: :unauthorized
|
|
185
|
+
# end
|
|
186
|
+
# end
|
|
187
|
+
#
|
|
188
|
+
# @see https://developer.hellozai.com/docs/verify-webhook-signatures
|
|
189
|
+
def verify_signature(payload:, signature_header:, secret_key:, tolerance: 300)
|
|
190
|
+
validate_presence!(payload, 'payload')
|
|
191
|
+
validate_presence!(signature_header, 'signature_header')
|
|
192
|
+
validate_presence!(secret_key, 'secret_key')
|
|
193
|
+
|
|
194
|
+
# Extract timestamp and signature from header
|
|
195
|
+
timestamp, signatures = parse_signature_header(signature_header)
|
|
196
|
+
|
|
197
|
+
# Verify timestamp is within tolerance (prevent replay attacks)
|
|
198
|
+
verify_timestamp!(timestamp, tolerance)
|
|
199
|
+
|
|
200
|
+
# Generate expected signature
|
|
201
|
+
expected_signature = generate_signature(payload, secret_key, timestamp)
|
|
202
|
+
|
|
203
|
+
# Compare signatures using constant-time comparison
|
|
204
|
+
signatures.any? { |sig| secure_compare(expected_signature, sig) }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Generate a signature for webhook verification
|
|
208
|
+
#
|
|
209
|
+
# This is a utility method that can be used for testing or generating
|
|
210
|
+
# signatures for webhook simulation.
|
|
211
|
+
#
|
|
212
|
+
# @param payload [String] the request body (JSON string)
|
|
213
|
+
# @param secret_key [String] the secret key
|
|
214
|
+
# @param timestamp [Integer] the Unix timestamp (defaults to current time)
|
|
215
|
+
# @return [String] the base64url-encoded HMAC SHA256 signature
|
|
216
|
+
#
|
|
217
|
+
# @example
|
|
218
|
+
# webhooks = ZaiPayment::Resources::Webhook.new
|
|
219
|
+
# signature = webhooks.generate_signature(
|
|
220
|
+
# '{"event": "status_updated"}',
|
|
221
|
+
# 'my_secret_key'
|
|
222
|
+
# )
|
|
223
|
+
#
|
|
224
|
+
# @see https://developer.hellozai.com/docs/verify-webhook-signatures
|
|
225
|
+
def generate_signature(payload, secret_key, timestamp = Time.now.to_i)
|
|
226
|
+
signed_payload = "#{timestamp}.#{payload}"
|
|
227
|
+
digest = OpenSSL::Digest.new('sha256')
|
|
228
|
+
hash = OpenSSL::HMAC.digest(digest, secret_key, signed_payload)
|
|
229
|
+
Base64.urlsafe_encode64(hash, padding: false)
|
|
230
|
+
end
|
|
231
|
+
|
|
133
232
|
private
|
|
134
233
|
|
|
135
234
|
def validate_id!(value, field_name)
|
|
@@ -152,6 +251,81 @@ module ZaiPayment
|
|
|
152
251
|
rescue URI::InvalidURIError
|
|
153
252
|
raise Errors::ValidationError, 'url must be a valid URL'
|
|
154
253
|
end
|
|
254
|
+
|
|
255
|
+
def validate_secret_key!(secret_key)
|
|
256
|
+
# Check if it's ASCII
|
|
257
|
+
raise Errors::ValidationError, 'secret_key must contain only ASCII characters' unless secret_key.ascii_only?
|
|
258
|
+
|
|
259
|
+
# Check minimum length (32 bytes)
|
|
260
|
+
return unless secret_key.bytesize < 32
|
|
261
|
+
|
|
262
|
+
raise Errors::ValidationError, 'secret_key must be at least 32 bytes in size'
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def parse_signature_header(header)
|
|
266
|
+
# Format: "t=1257894000,v=signature1,v=signature2"
|
|
267
|
+
parts = header.split(',').map(&:strip)
|
|
268
|
+
|
|
269
|
+
timestamp, signatures = extract_timestamp_and_signatures(parts)
|
|
270
|
+
|
|
271
|
+
validate_timestamp_presence!(timestamp)
|
|
272
|
+
validate_signatures_presence!(signatures)
|
|
273
|
+
|
|
274
|
+
[timestamp, signatures]
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def extract_timestamp_and_signatures(parts)
|
|
278
|
+
timestamp = nil
|
|
279
|
+
signatures = []
|
|
280
|
+
|
|
281
|
+
parts.each do |part|
|
|
282
|
+
key, value = part.split('=', 2)
|
|
283
|
+
case key
|
|
284
|
+
when 't'
|
|
285
|
+
timestamp = value.to_i
|
|
286
|
+
when 'v'
|
|
287
|
+
signatures << value
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
[timestamp, signatures]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def validate_timestamp_presence!(timestamp)
|
|
295
|
+
return unless timestamp.nil? || timestamp.zero?
|
|
296
|
+
|
|
297
|
+
raise Errors::ValidationError, 'Invalid signature header: missing or invalid timestamp'
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def validate_signatures_presence!(signatures)
|
|
301
|
+
raise Errors::ValidationError, 'Invalid signature header: missing signature' if signatures.empty?
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def verify_timestamp!(timestamp, tolerance)
|
|
305
|
+
current_time = Time.now.to_i
|
|
306
|
+
time_diff = (current_time - timestamp).abs
|
|
307
|
+
|
|
308
|
+
return unless time_diff > tolerance
|
|
309
|
+
|
|
310
|
+
raise Errors::ValidationError,
|
|
311
|
+
"Webhook timestamp is outside tolerance (#{time_diff}s vs #{tolerance}s max). " \
|
|
312
|
+
'This may be a replay attack.'
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Constant-time string comparison to prevent timing attacks
|
|
316
|
+
# Uses OpenSSL's secure_compare if available, otherwise falls back to manual comparison
|
|
317
|
+
def secure_compare(str_a, str_b)
|
|
318
|
+
return false unless str_a.bytesize == str_b.bytesize
|
|
319
|
+
|
|
320
|
+
if defined?(OpenSSL.fixed_length_secure_compare)
|
|
321
|
+
OpenSSL.fixed_length_secure_compare(str_a, str_b)
|
|
322
|
+
else
|
|
323
|
+
# Fallback for older Ruby versions
|
|
324
|
+
result = 0
|
|
325
|
+
str_a.bytes.zip(str_b.bytes) { |x, y| result |= x ^ y }
|
|
326
|
+
result.zero?
|
|
327
|
+
end
|
|
328
|
+
end
|
|
155
329
|
end
|
|
156
330
|
end
|
|
157
331
|
end
|
data/lib/zai_payment/version.rb
CHANGED