bows 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/ribbon/intercom.rb +42 -0
- data/lib/ribbon/intercom/client.rb +61 -0
- data/lib/ribbon/intercom/client/mock_sdk.rb +13 -0
- data/lib/ribbon/intercom/client/sdk.rb +99 -0
- data/lib/ribbon/intercom/client/sdk/adapters.rb +10 -0
- data/lib/ribbon/intercom/client/sdk/adapters/adapter.rb +77 -0
- data/lib/ribbon/intercom/client/sdk/adapters/adapter/response.rb +13 -0
- data/lib/ribbon/intercom/client/sdk/adapters/http_adapter.rb +32 -0
- data/lib/ribbon/intercom/client/sdk/adapters/http_adapter/connection.rb +34 -0
- data/lib/ribbon/intercom/client/sdk/adapters/local_adapter.rb +55 -0
- data/lib/ribbon/intercom/client/sdk/adapters/mock_adapter.rb +40 -0
- data/lib/ribbon/intercom/errors.rb +66 -0
- data/lib/ribbon/intercom/package.rb +121 -0
- data/lib/ribbon/intercom/packageable.rb +6 -0
- data/lib/ribbon/intercom/packageable/mixin.rb +29 -0
- data/lib/ribbon/intercom/packet.rb +52 -0
- data/lib/ribbon/intercom/packet/method_queue.rb +28 -0
- data/lib/ribbon/intercom/railtie.rb +14 -0
- data/lib/ribbon/intercom/service.rb +273 -0
- data/lib/ribbon/intercom/service/channel.rb +203 -0
- data/lib/ribbon/intercom/service/channel/stores.rb +9 -0
- data/lib/ribbon/intercom/service/channel/stores/mock_store.rb +40 -0
- data/lib/ribbon/intercom/service/channel/stores/redis_store.rb +196 -0
- data/lib/ribbon/intercom/service/channel/stores/store.rb +31 -0
- data/lib/ribbon/intercom/utils.rb +72 -0
- data/lib/ribbon/intercom/utils/method_chain.rb +38 -0
- data/lib/ribbon/intercom/utils/mixins.rb +5 -0
- data/lib/ribbon/intercom/utils/mixins/mock_safe.rb +26 -0
- data/lib/ribbon/intercom/utils/signer.rb +71 -0
- data/lib/ribbon/intercom/version.rb +5 -0
- data/lib/tasks/intercom.rake +24 -0
- metadata +215 -0
@@ -0,0 +1,203 @@
|
|
1
|
+
require 'securerandom'
|
2
|
+
require 'bcrypt'
|
3
|
+
|
4
|
+
module Ribbon::Intercom
|
5
|
+
class Service
|
6
|
+
class Channel
|
7
|
+
autoload(:Stores, 'ribbon/intercom/service/channel/stores')
|
8
|
+
|
9
|
+
include BCrypt
|
10
|
+
|
11
|
+
attr_reader :name
|
12
|
+
attr_reader :store
|
13
|
+
attr_reader :token
|
14
|
+
attr_reader :signing_keys
|
15
|
+
attr_reader :secret_hash_crt
|
16
|
+
attr_reader :secret_hash_prv
|
17
|
+
|
18
|
+
def initialize(store, params={})
|
19
|
+
@store = store
|
20
|
+
@name = params[:name] or raise Errors::ChannelNameMissingError
|
21
|
+
@token = params[:token]
|
22
|
+
refresh(params)
|
23
|
+
end
|
24
|
+
|
25
|
+
##
|
26
|
+
# Refreshes the channel. To be called by the store after obtaining a lock.
|
27
|
+
def refresh(params={})
|
28
|
+
@secret_hash_crt = params[:secret_hash_crt]
|
29
|
+
@secret_hash_prv = params[:secret_hash_prv]
|
30
|
+
@signing_keys = params[:signing_keys] || {}
|
31
|
+
|
32
|
+
@_allowed_to = nil
|
33
|
+
may(*params.fetch(:may, []))
|
34
|
+
end
|
35
|
+
|
36
|
+
def rotate_secret
|
37
|
+
SecureRandom.hex(16).tap { |secret|
|
38
|
+
@secret_hash_prv = secret_hash_crt
|
39
|
+
@secret_hash_crt = Password.create(secret)
|
40
|
+
}
|
41
|
+
end
|
42
|
+
|
43
|
+
def rotate_secret!
|
44
|
+
rotate_secret.tap { save }
|
45
|
+
end
|
46
|
+
|
47
|
+
def sign(data)
|
48
|
+
key_id, key = _signing_key
|
49
|
+
"\x01" + [key_id].pack('N') + Utils::Signer.new(key).sign(data)
|
50
|
+
end
|
51
|
+
|
52
|
+
def verify(signed_data)
|
53
|
+
key_id = signed_data.slice(1, 4).unpack('N').first
|
54
|
+
|
55
|
+
if (key=_retrieve_signing_key(key_id))
|
56
|
+
Utils::Signer.new(key).verify(signed_data[5..-1])
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def may(*args)
|
61
|
+
(@_allowed_to ||= Hash.new(false)).merge!(
|
62
|
+
Hash[
|
63
|
+
args.map { |perm| [perm.to_s, true] }
|
64
|
+
]
|
65
|
+
).keys.to_set
|
66
|
+
end
|
67
|
+
|
68
|
+
def may!(*args)
|
69
|
+
may(*args).tap { save }
|
70
|
+
end
|
71
|
+
|
72
|
+
def may?(*perms)
|
73
|
+
perms.all? { |perm| _may?(perm) }
|
74
|
+
end
|
75
|
+
|
76
|
+
def permissions
|
77
|
+
@_allowed_to.keys.to_set
|
78
|
+
end
|
79
|
+
|
80
|
+
def valid_secret?(secret)
|
81
|
+
!!secret && (secret_crt == secret || secret_prv == secret)
|
82
|
+
end
|
83
|
+
|
84
|
+
def secret_crt
|
85
|
+
@__secret_crt ||= _to_bcrypt_pw(secret_hash_crt)
|
86
|
+
end
|
87
|
+
|
88
|
+
def secret_prv
|
89
|
+
@__secret_prv ||= _to_bcrypt_pw(secret_hash_prv)
|
90
|
+
end
|
91
|
+
|
92
|
+
def with_lock(&block)
|
93
|
+
store.with_lock(self, &block)
|
94
|
+
end
|
95
|
+
|
96
|
+
def save
|
97
|
+
# Loop until unique
|
98
|
+
unless token
|
99
|
+
loop { break unless store.token_exists?(@token = SecureRandom.hex(4)) }
|
100
|
+
end
|
101
|
+
|
102
|
+
_run_validations
|
103
|
+
store.persist(self)
|
104
|
+
end
|
105
|
+
|
106
|
+
def close
|
107
|
+
store.delete(self)
|
108
|
+
end
|
109
|
+
|
110
|
+
def ==(other)
|
111
|
+
other.is_a?(Channel) &&
|
112
|
+
name == other.name &&
|
113
|
+
store == other.store &&
|
114
|
+
token == other.token &&
|
115
|
+
secret_crt.to_s == other.secret_crt.to_s &&
|
116
|
+
secret_prv.to_s == other.secret_prv.to_s &&
|
117
|
+
permissions == other.permissions
|
118
|
+
end
|
119
|
+
|
120
|
+
private
|
121
|
+
|
122
|
+
def _may?(required_perm)
|
123
|
+
permissions.any? { |perm|
|
124
|
+
regex = /\A#{Regexp.escape(perm).gsub('\*', '.*')}\z/
|
125
|
+
regex.match(required_perm)
|
126
|
+
}
|
127
|
+
end
|
128
|
+
|
129
|
+
def _to_bcrypt_pw(pw)
|
130
|
+
Password.new(pw) if pw && !pw.empty?
|
131
|
+
end
|
132
|
+
|
133
|
+
def _run_validations
|
134
|
+
raise Errors::ChannelNameMissingError unless name
|
135
|
+
raise Errors::ChannelTokenMissingError unless token
|
136
|
+
raise Errors::ChannelSecretMissingError unless secret_hash_crt
|
137
|
+
end
|
138
|
+
|
139
|
+
def _latest_signing_key_id
|
140
|
+
((signing_keys || {}).sort_by { |k, v| v[:timestamp] }.last || []).first
|
141
|
+
end
|
142
|
+
|
143
|
+
def _retrieve_signing_key(key_id)
|
144
|
+
(data=_retrieve_signing_key_data(key_id)) && data[:key]
|
145
|
+
end
|
146
|
+
|
147
|
+
def _retrieve_signing_key_data(key_id)
|
148
|
+
if key_id && (data=signing_keys[key_id])
|
149
|
+
data.merge(key_id: key_id)
|
150
|
+
else
|
151
|
+
{}
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
##
|
156
|
+
# Returns whether the key is expired. Optionally, an offset may be specified
|
157
|
+
# to expire the key earlier or later.
|
158
|
+
def _signing_key_expired?(key_id)
|
159
|
+
_signing_key_ttl(key_id) <= 0
|
160
|
+
end
|
161
|
+
|
162
|
+
def _signing_key_ttl(key_id, ttl=3600)
|
163
|
+
timestamp = _retrieve_signing_key_data(key_id)[:timestamp]
|
164
|
+
(timestamp && (ttl - (Time.now.to_i - timestamp))).to_i
|
165
|
+
end
|
166
|
+
|
167
|
+
##
|
168
|
+
# Retrieve the latest signing key or generate a new one if the latest has
|
169
|
+
# expired (older than 1 hour).
|
170
|
+
def _signing_key
|
171
|
+
latest_id = _latest_signing_key_id
|
172
|
+
|
173
|
+
# Refresh the key if the current key has less than 5 minutes to live.
|
174
|
+
if _signing_key_ttl(latest_id) < 300
|
175
|
+
with_lock {
|
176
|
+
latest_id = _latest_signing_key_id
|
177
|
+
|
178
|
+
if _signing_key_ttl(latest_id) < 300
|
179
|
+
latest_id = _add_signing_key
|
180
|
+
_delete_expired_signing_keys
|
181
|
+
save # Need to persist changes to signing keys.
|
182
|
+
end
|
183
|
+
}
|
184
|
+
end
|
185
|
+
|
186
|
+
[latest_id, _retrieve_signing_key(latest_id)]
|
187
|
+
end
|
188
|
+
|
189
|
+
def _add_signing_key
|
190
|
+
(_latest_signing_key_id.to_i + 1).tap { |key_id|
|
191
|
+
signing_keys[key_id] = {
|
192
|
+
key: Utils::Signer.random_key,
|
193
|
+
timestamp: Time.now.to_i
|
194
|
+
}
|
195
|
+
}
|
196
|
+
end
|
197
|
+
|
198
|
+
def _delete_expired_signing_keys
|
199
|
+
signing_keys.reject! { |key_id| _signing_key_expired?(key_id) }
|
200
|
+
end
|
201
|
+
end # Service
|
202
|
+
end # Channel
|
203
|
+
end # Ribbon::Intercom
|
@@ -0,0 +1,9 @@
|
|
1
|
+
module Ribbon::Intercom
|
2
|
+
class Service::Channel
|
3
|
+
module Stores
|
4
|
+
autoload(:Store, 'ribbon/intercom/service/channel/stores/store')
|
5
|
+
autoload(:RedisStore, 'ribbon/intercom/service/channel/stores/redis_store')
|
6
|
+
autoload(:MockStore, 'ribbon/intercom/service/channel/stores/mock_store')
|
7
|
+
end # Stores
|
8
|
+
end # Service::Channel
|
9
|
+
end # Ribbon::Intercom
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Ribbon::Intercom
|
2
|
+
class Service
|
3
|
+
module Channel::Stores
|
4
|
+
class MockStore < Store
|
5
|
+
def channels
|
6
|
+
@__channels ||= {}
|
7
|
+
end
|
8
|
+
|
9
|
+
def lock
|
10
|
+
@__lock ||= Mutex.new
|
11
|
+
end
|
12
|
+
|
13
|
+
def token_exists?(token)
|
14
|
+
channels.key?(token)
|
15
|
+
end
|
16
|
+
|
17
|
+
def lookup_channel(token)
|
18
|
+
channels[token]
|
19
|
+
end
|
20
|
+
|
21
|
+
def persist(channel)
|
22
|
+
raise Errors::InvalidChannelError, channel.inspect unless channel.is_a?(Channel)
|
23
|
+
channels[channel.token] = channel
|
24
|
+
end
|
25
|
+
|
26
|
+
def delete(channel)
|
27
|
+
raise Errors::InvalidChannelError, channel.inspect unless channel.is_a?(Channel)
|
28
|
+
channels.delete(channel.token)
|
29
|
+
nil
|
30
|
+
end
|
31
|
+
|
32
|
+
def with_lock(channel, &block)
|
33
|
+
# This is a global lock, not a per-channel lock, but this store is only
|
34
|
+
# for testing so let's KISS.
|
35
|
+
lock.synchronize(&block)
|
36
|
+
end
|
37
|
+
end # RedisStore
|
38
|
+
end # Channel::Stores
|
39
|
+
end # Service
|
40
|
+
end # Ribbon::Intercom
|
@@ -0,0 +1,196 @@
|
|
1
|
+
require 'redis'
|
2
|
+
require 'securerandom'
|
3
|
+
require 'base64'
|
4
|
+
|
5
|
+
module Ribbon::Intercom
|
6
|
+
class Service
|
7
|
+
module Channel::Stores
|
8
|
+
class RedisStore < Store
|
9
|
+
def initialize(params={})
|
10
|
+
if params[:url]
|
11
|
+
@_redis = Redis.new(url: params[:url])
|
12
|
+
elsif params[:redis]
|
13
|
+
@_redis = params[:redis]
|
14
|
+
else
|
15
|
+
raise Errors::InvalidStoreParamsError
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def token_exists?(token)
|
20
|
+
@_redis.exists("channel:#{token}")
|
21
|
+
end
|
22
|
+
|
23
|
+
def lookup_channel(token)
|
24
|
+
if (data=_load_data(token))
|
25
|
+
Channel.new(self, data)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def persist(channel)
|
30
|
+
raise Errors::InvalidChannelError, channel.inspect unless channel.is_a?(Channel)
|
31
|
+
|
32
|
+
channel.tap { |channel|
|
33
|
+
data_hash = {}
|
34
|
+
[:name, :token, :secret_hash_crt, :secret_hash_prv].each { |key|
|
35
|
+
value = channel.send(key)
|
36
|
+
data_hash[key] = value if value
|
37
|
+
}
|
38
|
+
|
39
|
+
# Save channel data as hash
|
40
|
+
@_redis.mapped_hmset(_key_name(channel), data_hash)
|
41
|
+
|
42
|
+
# Associate permissions set to its channel
|
43
|
+
channel.permissions.each { |p|
|
44
|
+
@_redis.sadd(_key_name(channel, 'permissions'), p)
|
45
|
+
}
|
46
|
+
|
47
|
+
# Save signing keys
|
48
|
+
key_hash = Hash[
|
49
|
+
channel.signing_keys.map { |key_id, data|
|
50
|
+
[key_id, Base64.strict_encode64(Marshal.dump(data))]
|
51
|
+
}
|
52
|
+
]
|
53
|
+
|
54
|
+
@_redis.mapped_hmset(_key_name(channel, 'signing_keys'), key_hash) unless key_hash.empty?
|
55
|
+
}
|
56
|
+
end
|
57
|
+
|
58
|
+
def delete(channel)
|
59
|
+
raise Errors::InvalidChannelError, channel.inspect unless channel.is_a?(Channel)
|
60
|
+
|
61
|
+
@_redis.del(_key_name(channel))
|
62
|
+
@_redis.del(_key_name(channel, 'permissions'))
|
63
|
+
nil
|
64
|
+
end
|
65
|
+
|
66
|
+
def with_lock(channel, ttl=1000)
|
67
|
+
Mutex.new(@_redis, _key_name(channel, 'lock')).synchronize(ttl) { |validity|
|
68
|
+
start_time = Mutex.time_in_ms
|
69
|
+
channel.refresh(_load_data(channel.token))
|
70
|
+
yield validity - (Mutex.time_in_ms - start_time)
|
71
|
+
}
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def _load_data(token)
|
77
|
+
channel_data_future = nil
|
78
|
+
permissions_future = nil
|
79
|
+
signing_keys_future = nil
|
80
|
+
|
81
|
+
@_redis.pipelined {
|
82
|
+
channel_data_future = @_redis.hgetall(_key_name(token))
|
83
|
+
permissions_future = @_redis.smembers(_key_name(token, 'permissions'))
|
84
|
+
signing_keys_future = @_redis.hgetall(_key_name(token, 'signing_keys'))
|
85
|
+
}
|
86
|
+
|
87
|
+
channel_data = channel_data_future.value
|
88
|
+
|
89
|
+
if channel_data && !channel_data.empty?
|
90
|
+
permissions = permissions_future.value
|
91
|
+
|
92
|
+
signing_keys = signing_keys_future.value.map { |key_id, data|
|
93
|
+
[key_id.to_i, Marshal.load(Base64.strict_decode64(data))]
|
94
|
+
}.to_h
|
95
|
+
|
96
|
+
Utils.symbolize_keys(
|
97
|
+
channel_data.merge(
|
98
|
+
may: permissions,
|
99
|
+
signing_keys: signing_keys
|
100
|
+
)
|
101
|
+
)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
##
|
106
|
+
# A redis mutex inspired by <https://github.com/leandromoreira/redlock-rb>
|
107
|
+
class Mutex
|
108
|
+
class LockUnobtainableError < StandardError; end
|
109
|
+
|
110
|
+
##
|
111
|
+
# Lua script to send to Redis when releasing a lock.
|
112
|
+
# See: http://redis.io/commands/set
|
113
|
+
UNLOCK_SCRIPT = <<-LUA
|
114
|
+
if redis.call("get",KEYS[1]) == ARGV[1] then
|
115
|
+
return redis.call("del",KEYS[1])
|
116
|
+
else
|
117
|
+
return 0
|
118
|
+
end
|
119
|
+
LUA
|
120
|
+
|
121
|
+
DEFAULT_RETRY_COUNT = 3
|
122
|
+
DEFAULT_RETRY_DELAY = 200
|
123
|
+
CLOCK_DRIFT_FACTOR = 0.01
|
124
|
+
|
125
|
+
class << self
|
126
|
+
def time_in_ms
|
127
|
+
(Time.now.to_f * 1000).to_i
|
128
|
+
end
|
129
|
+
end # Class Methods
|
130
|
+
|
131
|
+
attr_reader :redis
|
132
|
+
attr_reader :name
|
133
|
+
attr_reader :retry_count
|
134
|
+
attr_reader :retry_delay
|
135
|
+
|
136
|
+
def initialize(redis, name, opts={})
|
137
|
+
@redis = redis
|
138
|
+
@name = name
|
139
|
+
@retry_count = opts[:retry_count] || DEFAULT_RETRY_COUNT
|
140
|
+
@retry_delay = opts[:retry_delay] || DEFAULT_RETRY_DELAY
|
141
|
+
end
|
142
|
+
|
143
|
+
def synchronize(ttl)
|
144
|
+
retry_count.times {
|
145
|
+
start_time = self.class.time_in_ms
|
146
|
+
|
147
|
+
if (handle=lock(ttl))
|
148
|
+
begin
|
149
|
+
time_elapsed = (self.class.time_in_ms - start_time).to_i
|
150
|
+
return yield(ttl - time_elapsed - _drift(ttl))
|
151
|
+
ensure
|
152
|
+
unlock(handle)
|
153
|
+
end
|
154
|
+
else
|
155
|
+
sleep(rand(retry_delay).to_f / 1000)
|
156
|
+
end
|
157
|
+
}
|
158
|
+
|
159
|
+
raise LockUnobtainableError, name
|
160
|
+
end
|
161
|
+
|
162
|
+
##
|
163
|
+
# Acquire a lock (non-blocking).
|
164
|
+
def lock(ttl=1000)
|
165
|
+
handle = SecureRandom.uuid
|
166
|
+
redis.set(name, handle, nx: true, px: ttl) && handle
|
167
|
+
end
|
168
|
+
|
169
|
+
##
|
170
|
+
# Release a lock.
|
171
|
+
def unlock(handle)
|
172
|
+
redis.eval(UNLOCK_SCRIPT, [name], [handle])
|
173
|
+
rescue
|
174
|
+
# Do nothing, unlocking is best effort.
|
175
|
+
end
|
176
|
+
|
177
|
+
private
|
178
|
+
|
179
|
+
##
|
180
|
+
# Approximate clock drift.
|
181
|
+
def _drift(ttl)
|
182
|
+
# Add 2 milliseconds to the drift to account for Redis expires
|
183
|
+
# precision, which is 1 millisecond, plus 1 millisecond min drift
|
184
|
+
# for small TTLs.
|
185
|
+
(ttl * CLOCK_DRIFT_FACTOR).to_i + 2
|
186
|
+
end
|
187
|
+
end # Mutex
|
188
|
+
|
189
|
+
def _key_name(channel_or_token, *sub_names)
|
190
|
+
token = channel_or_token.is_a?(Channel) ? channel_or_token.token : channel_or_token
|
191
|
+
['channel', token, *sub_names].join(':')
|
192
|
+
end
|
193
|
+
end # RedisStore
|
194
|
+
end # Channel::Stores
|
195
|
+
end # Service
|
196
|
+
end # Ribbon::Intercom
|