webhook_system 1.0.4 → 2.0.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/.codeclimate.yml +5 -0
- data/CHANGELOG.md +5 -0
- data/README.md +35 -20
- data/Rakefile +0 -2
- data/lib/webhook_system/encoder.rb +90 -35
- data/lib/webhook_system/job.rb +7 -3
- data/lib/webhook_system/version.rb +1 -1
- data/webhook_system.gemspec +3 -4
- metadata +17 -21
- data/.hound.yml +0 -2
- data/.reek +0 -13
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d120e25bddd3b90b7f79a51f5ee17aaf9e161336
|
4
|
+
data.tar.gz: e872d6ca6dad0879a5c71b9ca0ace3c129bc33c1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 793b9a205d0244f79aad3fad61ffd2aadbde2f65b16545acebb7c9192859a85d1f6aec764371905baf373eeaa8950e1cd31c8906f9a47a4a27370b550e672733
|
7
|
+
data.tar.gz: fb7a2493ef2d3a666162c9eb3b4d1ce1b9cc8b1b58261869ec11a672514eb83b06458a874701dd602bdedf696df3cecdb87e5e2f510038884b578faddffaff3b
|
data/.codeclimate.yml
CHANGED
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,10 @@
|
|
1
1
|
# Change Log
|
2
2
|
|
3
|
+
## [v1.0.4](https://github.com/payrollhero/webhook_system/tree/v1.0.4) (2016-02-19)
|
4
|
+
[Full Changelog](https://github.com/payrollhero/webhook_system/compare/v1.0.3...v1.0.4)
|
5
|
+
|
6
|
+
- Log any exception which occurs while we submit data to the webhook url. [\#8](https://github.com/payrollhero/webhook_system/pull/8) ([mykola-kyryk](https://github.com/mykola-kyryk))
|
7
|
+
|
3
8
|
## [v1.0.3](https://github.com/payrollhero/webhook_system/tree/v1.0.3) (2016-02-18)
|
4
9
|
[Full Changelog](https://github.com/payrollhero/webhook_system/compare/v1.0.2...v1.0.3)
|
5
10
|
|
data/README.md
CHANGED
@@ -29,37 +29,39 @@ tables first:
|
|
29
29
|
```ruby
|
30
30
|
create_table :webhook_subscriptions do |t|
|
31
31
|
t.string :url, null: false
|
32
|
-
t.boolean :active, null: false
|
32
|
+
t.boolean :active, null: false, index: true
|
33
|
+
t.boolean :encrypt, null: false, default: false
|
33
34
|
t.text :secret
|
34
|
-
|
35
|
-
t.index :active
|
36
35
|
end
|
37
36
|
|
38
37
|
create_table :webhook_subscription_topics do |t|
|
39
|
-
t.string :name, null: false
|
40
|
-
t.belongs_to :subscription, null: false
|
41
|
-
|
42
|
-
t.index :subscription_id
|
43
|
-
t.index :name
|
38
|
+
t.string :name, null: false, index: true
|
39
|
+
t.belongs_to :subscription, null: false, index: true
|
44
40
|
end
|
45
41
|
|
46
42
|
create_table :webhook_event_logs do |t|
|
47
|
-
t.belongs_to :subscription, null: false
|
43
|
+
t.belongs_to :subscription, null: false, index: true
|
48
44
|
|
49
|
-
t.string :event_name, null: false
|
50
|
-
t.string :event_id, null: false
|
51
|
-
t.integer :status, null: false
|
45
|
+
t.string :event_name, null: false, index: true
|
46
|
+
t.string :event_id, null: false, index: true
|
47
|
+
t.integer :status, null: false, index: true
|
52
48
|
|
53
49
|
t.text :request, limit: 64_000, null: false
|
54
50
|
t.text :response, limit: 64_000, null: false
|
55
51
|
|
56
|
-
t.datetime :created_at, null: false
|
52
|
+
t.datetime :created_at, null: false, index: true
|
53
|
+
end
|
54
|
+
```
|
57
55
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
56
|
+
### Migrating from version 1.x
|
57
|
+
|
58
|
+
The main new change is the addition of a new 'encrypt' column on subscriptions
|
59
|
+
|
60
|
+
Add this migration to get this added (and retain original behavior)
|
61
|
+
|
62
|
+
```ruby
|
63
|
+
def change
|
64
|
+
add_column :webhook_subscriptions, :encrypt, :boolean, default: true, null: false, after: :active
|
63
65
|
end
|
64
66
|
```
|
65
67
|
|
@@ -207,9 +209,22 @@ WebhookSystem.dispatch(event_object)
|
|
207
209
|
This is meant to be fairly fire and forget. Internally this will create an ActiveJob for each subscription
|
208
210
|
interested in the event.
|
209
211
|
|
212
|
+
# Payload Format
|
213
|
+
|
214
|
+
Payloads can either be plain json or encrypted. On top of that, they're also signed. The format for the signature
|
215
|
+
follows GitHub's own format: [https://developer.github.com/webhooks/securing/](https://developer.github.com/webhooks/securing/).
|
216
|
+
The subscription's secret is used to create the signature.
|
217
|
+
|
218
|
+
The payload can be encrypted based on the `encrypt` boolean column of a subscription.
|
219
|
+
|
220
|
+
## Payload Verification
|
221
|
+
|
222
|
+
This library can be used as a helper to decode and verify the payloads as well. The same usage as the Decryption below
|
223
|
+
will also verify the signature if present in the headers passed.
|
224
|
+
|
210
225
|
## Payload Encryption
|
211
226
|
|
212
|
-
The payload
|
227
|
+
The payload can be encrypted using AES-256. Each subscription is meant to have the recipient's shared secret on it.
|
213
228
|
This secret is then used to encrypt the payload, so the other side needs that same secret again to open it.
|
214
229
|
|
215
230
|
The payload then will be a json post body, with the Base64 encoded payload inside it.
|
@@ -221,7 +236,7 @@ There is a utility function available to decode the entire POST body of the webh
|
|
221
236
|
Example use would be:
|
222
237
|
|
223
238
|
```ruby
|
224
|
-
payload = WebhookSystem::Encoder.decode(secret_string, request.body)
|
239
|
+
payload = WebhookSystem::Encoder.decode(secret_string, request.body, request.headers)
|
225
240
|
```
|
226
241
|
|
227
242
|
You will need your webhook secret, and you get back a Hash of the event's data.
|
data/Rakefile
CHANGED
@@ -8,60 +8,115 @@ module WebhookSystem
|
|
8
8
|
# @param [String] secret_string some secret string
|
9
9
|
# @param [Object#to_json] payload Any object that responds to to_json
|
10
10
|
# @return [String] The encoded string payload (its a JSON string)
|
11
|
-
def self.encode(secret_string, payload)
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
encoded = cipher.update(payload.to_json) + cipher.final
|
17
|
-
Payload.encode(encoded, iv)
|
11
|
+
def self.encode(secret_string, payload, format:)
|
12
|
+
response_hash = Payload.encode(payload, secret: secret_string, format: format)
|
13
|
+
payload_string = JSON.generate(response_hash)
|
14
|
+
signature = hub_signature(payload_string, secret_string)
|
15
|
+
[payload_string, { 'X-Hub-Signature' => signature, 'Content-Type' => content_type_for_format(format) }]
|
18
16
|
end
|
19
17
|
|
20
18
|
# Given a secret string, and an encrypted payload, unwrap it, bas64 decode it
|
21
19
|
# decrypt it, and JSON decode it
|
22
20
|
#
|
23
21
|
# @param [String] secret_string some secret string
|
24
|
-
# @param [String]
|
22
|
+
# @param [String] payload_string String as returned from #encode
|
25
23
|
# @return [Object] return the JSON decode of the encrypted payload
|
26
|
-
def self.decode(secret_string,
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
24
|
+
def self.decode(secret_string, payload_string, headers = {})
|
25
|
+
signature = headers['X-Hub-Signature']
|
26
|
+
format = format_for_content_type(headers.fetch('Content-Type'))
|
27
|
+
|
28
|
+
payload_signature = hub_signature(payload_string, secret_string)
|
29
|
+
if signature && signature != payload_signature
|
30
|
+
raise DecodingError, 'signature mismatch'
|
31
|
+
end
|
32
|
+
|
33
|
+
Payload.decode(payload_string, secret: secret_string, format: format)
|
36
34
|
end
|
37
35
|
|
38
36
|
class << self
|
39
37
|
private
|
40
38
|
|
41
|
-
def
|
42
|
-
|
39
|
+
def content_type_format_map
|
40
|
+
{
|
41
|
+
'base64+aes256' => 'application/json; base64+aes256',
|
42
|
+
'json' => 'application/json'
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
def format_for_content_type(content_type)
|
47
|
+
content_type_format_map.invert.fetch(content_type)
|
48
|
+
end
|
49
|
+
|
50
|
+
def content_type_for_format(format)
|
51
|
+
content_type_format_map.fetch(format)
|
52
|
+
end
|
53
|
+
|
54
|
+
def hub_signature(payload_string, secret)
|
55
|
+
'sha1=' + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), secret, payload_string)
|
43
56
|
end
|
44
57
|
end
|
45
58
|
end
|
46
59
|
|
47
|
-
# private class to just wrap the outer wrapping of the response format
|
48
|
-
# not exposed to the outside
|
49
|
-
# :nodoc:
|
50
60
|
module Payload
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
'
|
55
|
-
|
56
|
-
|
57
|
-
|
61
|
+
class << self
|
62
|
+
def encode(payload, secret:, format:)
|
63
|
+
case format
|
64
|
+
when 'base64+aes256'
|
65
|
+
encode_aes(payload, secret)
|
66
|
+
when 'json'
|
67
|
+
payload
|
68
|
+
else
|
69
|
+
raise ArgumentError, "don't know how to handle: #{payload['format']} payload"
|
70
|
+
end
|
71
|
+
end
|
58
72
|
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
73
|
+
def decode(response_body, secret:, format:)
|
74
|
+
payload = JSON.load(response_body)
|
75
|
+
|
76
|
+
case format
|
77
|
+
when 'base64+aes256'
|
78
|
+
decode_aes(payload, secret)
|
79
|
+
when 'json'
|
80
|
+
payload
|
81
|
+
else
|
82
|
+
raise ArgumentError, "don't know how to handle: #{payload['format']} payload"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def encode_aes(payload, secret)
|
89
|
+
cipher = OpenSSL::Cipher::AES256.new(:CBC)
|
90
|
+
cipher.encrypt
|
91
|
+
iv = cipher.random_iv
|
92
|
+
cipher.key = key_from_secret(iv, secret)
|
93
|
+
encoded = cipher.update(payload.to_json) + cipher.final
|
94
|
+
|
95
|
+
{
|
96
|
+
format: 'base64+aes256',
|
97
|
+
payload: Base64.encode64(encoded),
|
98
|
+
iv: Base64.encode64(iv),
|
99
|
+
}
|
100
|
+
end
|
101
|
+
|
102
|
+
def decode_aes(payload, secret)
|
103
|
+
encoded = Base64.decode64(payload['payload'])
|
104
|
+
iv = Base64.decode64(payload['iv'])
|
105
|
+
|
106
|
+
cipher = OpenSSL::Cipher::AES256.new(:CBC)
|
107
|
+
cipher.decrypt
|
108
|
+
cipher.iv = iv
|
109
|
+
cipher.key = key_from_secret(iv, secret)
|
110
|
+
decoded = cipher.update(encoded) + cipher.final
|
111
|
+
|
112
|
+
JSON.load(decoded)
|
113
|
+
rescue OpenSSL::Cipher::CipherError
|
114
|
+
raise DecodingError, 'Decoding Failed, probably mismatched secret'
|
115
|
+
end
|
116
|
+
|
117
|
+
def key_from_secret(iv, secret_string)
|
118
|
+
OpenSSL::PKCS5.pbkdf2_hmac(secret_string, iv, 100_000, 256 / 8, 'SHA256')
|
63
119
|
end
|
64
|
-
[Base64.decode64(payload['payload']), Base64.decode64(payload['iv'])]
|
65
120
|
end
|
66
121
|
end
|
67
122
|
end
|
data/lib/webhook_system/job.rb
CHANGED
@@ -41,7 +41,7 @@ module WebhookSystem
|
|
41
41
|
response =
|
42
42
|
begin
|
43
43
|
client.builder.build_response(client, request)
|
44
|
-
rescue
|
44
|
+
rescue RuntimeError => exception
|
45
45
|
ErrorResponse.new(exception)
|
46
46
|
end
|
47
47
|
|
@@ -57,14 +57,18 @@ module WebhookSystem
|
|
57
57
|
end
|
58
58
|
|
59
59
|
def self.build_request(client, subscription, event)
|
60
|
-
payload = Encoder.encode(subscription.secret, event)
|
60
|
+
payload, headers = Encoder.encode(subscription.secret, event, format: format_for_subscription(subscription))
|
61
61
|
client.build_request(:post) do |req|
|
62
62
|
req.url subscription.url
|
63
|
-
req.headers
|
63
|
+
req.headers.merge!(headers)
|
64
64
|
req.body = payload.to_s
|
65
65
|
end
|
66
66
|
end
|
67
67
|
|
68
|
+
def self.format_for_subscription(subscription)
|
69
|
+
subscription.encrypt ? 'base64+aes256' : 'json'
|
70
|
+
end
|
71
|
+
|
68
72
|
def self.log_response(subscription, event, request, response)
|
69
73
|
event_log = EventLog.construct(subscription, event, request, response)
|
70
74
|
|
data/webhook_system.gemspec
CHANGED
@@ -18,8 +18,8 @@ Gem::Specification.new do |gem|
|
|
18
18
|
gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
|
19
19
|
gem.require_paths = ['lib']
|
20
20
|
|
21
|
-
gem.add_runtime_dependency 'activesupport', '> 3.2'
|
22
|
-
gem.add_runtime_dependency 'activerecord', '> 3.2'
|
21
|
+
gem.add_runtime_dependency 'activesupport', '> 3.2', '< 5.0' # have to drop support for ruby 2.1 if we enable 5.0
|
22
|
+
gem.add_runtime_dependency 'activerecord', '> 3.2', '< 5.0' # have to drop support for ruby 2.1 if we enable 5.0
|
23
23
|
gem.add_runtime_dependency 'activejob'
|
24
24
|
gem.add_runtime_dependency 'faraday', '~> 0.9'
|
25
25
|
gem.add_runtime_dependency 'faraday-encoding', '>= 0.0.2', '< 1.0'
|
@@ -37,6 +37,5 @@ Gem::Specification.new do |gem|
|
|
37
37
|
gem.add_development_dependency 'webmock'
|
38
38
|
|
39
39
|
# static analysis gems
|
40
|
-
gem.add_development_dependency 'rubocop', '~> 0.
|
41
|
-
gem.add_development_dependency 'reek', '~> 3.7'
|
40
|
+
gem.add_development_dependency 'rubocop', '~> 0.41.2'
|
42
41
|
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: webhook_system
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Piotr Banasik
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: exe
|
11
11
|
cert_chain: []
|
12
|
-
date: 2016-
|
12
|
+
date: 2016-07-19 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: activesupport
|
@@ -18,6 +18,9 @@ dependencies:
|
|
18
18
|
- - ">"
|
19
19
|
- !ruby/object:Gem::Version
|
20
20
|
version: '3.2'
|
21
|
+
- - "<"
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: '5.0'
|
21
24
|
type: :runtime
|
22
25
|
prerelease: false
|
23
26
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -25,6 +28,9 @@ dependencies:
|
|
25
28
|
- - ">"
|
26
29
|
- !ruby/object:Gem::Version
|
27
30
|
version: '3.2'
|
31
|
+
- - "<"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '5.0'
|
28
34
|
- !ruby/object:Gem::Dependency
|
29
35
|
name: activerecord
|
30
36
|
requirement: !ruby/object:Gem::Requirement
|
@@ -32,6 +38,9 @@ dependencies:
|
|
32
38
|
- - ">"
|
33
39
|
- !ruby/object:Gem::Version
|
34
40
|
version: '3.2'
|
41
|
+
- - "<"
|
42
|
+
- !ruby/object:Gem::Version
|
43
|
+
version: '5.0'
|
35
44
|
type: :runtime
|
36
45
|
prerelease: false
|
37
46
|
version_requirements: !ruby/object:Gem::Requirement
|
@@ -39,6 +48,9 @@ dependencies:
|
|
39
48
|
- - ">"
|
40
49
|
- !ruby/object:Gem::Version
|
41
50
|
version: '3.2'
|
51
|
+
- - "<"
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '5.0'
|
42
54
|
- !ruby/object:Gem::Dependency
|
43
55
|
name: activejob
|
44
56
|
requirement: !ruby/object:Gem::Requirement
|
@@ -247,28 +259,14 @@ dependencies:
|
|
247
259
|
requirements:
|
248
260
|
- - "~>"
|
249
261
|
- !ruby/object:Gem::Version
|
250
|
-
version: 0.
|
251
|
-
type: :development
|
252
|
-
prerelease: false
|
253
|
-
version_requirements: !ruby/object:Gem::Requirement
|
254
|
-
requirements:
|
255
|
-
- - "~>"
|
256
|
-
- !ruby/object:Gem::Version
|
257
|
-
version: 0.37.1
|
258
|
-
- !ruby/object:Gem::Dependency
|
259
|
-
name: reek
|
260
|
-
requirement: !ruby/object:Gem::Requirement
|
261
|
-
requirements:
|
262
|
-
- - "~>"
|
263
|
-
- !ruby/object:Gem::Version
|
264
|
-
version: '3.7'
|
262
|
+
version: 0.41.2
|
265
263
|
type: :development
|
266
264
|
prerelease: false
|
267
265
|
version_requirements: !ruby/object:Gem::Requirement
|
268
266
|
requirements:
|
269
267
|
- - "~>"
|
270
268
|
- !ruby/object:Gem::Version
|
271
|
-
version:
|
269
|
+
version: 0.41.2
|
272
270
|
description: A pluggable webhook subscription system
|
273
271
|
email: piotr@payrollhero.com
|
274
272
|
executables: []
|
@@ -277,8 +275,6 @@ extra_rdoc_files: []
|
|
277
275
|
files:
|
278
276
|
- ".codeclimate.yml"
|
279
277
|
- ".gitignore"
|
280
|
-
- ".hound.yml"
|
281
|
-
- ".reek"
|
282
278
|
- ".rubocop.hound.yml"
|
283
279
|
- ".rubocop.yml"
|
284
280
|
- ".travis.yml"
|
@@ -318,7 +314,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
318
314
|
version: '0'
|
319
315
|
requirements: []
|
320
316
|
rubyforge_project:
|
321
|
-
rubygems_version: 2.
|
317
|
+
rubygems_version: 2.5.1
|
322
318
|
signing_key:
|
323
319
|
specification_version: 4
|
324
320
|
summary: Webhook system
|
data/.hound.yml
DELETED