Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CAS-5278/Add webhooks verification #230

Merged
merged 8 commits into from
Jan 15, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/castle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
castle/utils/merge
castle/utils/clone
castle/utils/get_timestamp
castle/utils/secure_compare
castle/validators/present
castle/validators/not_supported
castle/webhooks/verify
castle/context/merge
castle/context/sanitize
castle/context/get_default
Expand Down Expand Up @@ -59,6 +61,7 @@
castle/core/get_connection
castle/core/process_response
castle/core/send_request
castle/core/process_webhook
castle/session
castle/api
].each(&method(:require))
Expand Down
2 changes: 1 addition & 1 deletion lib/castle/commands/approve_device.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module Castle
module Commands
# Generated the payload for the PUT devices/#{device_token}/approve request
# Generates the payload for the PUT devices/#{device_token}/approve request
class ApproveDevice
class << self
# @param options [Hash]
Expand Down
1 change: 1 addition & 0 deletions lib/castle/commands/authenticate.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

module Castle
module Commands
# Generates the payload for the authenticate request
class Authenticate
class << self
# @param options [Hash]
Expand Down
2 changes: 1 addition & 1 deletion lib/castle/commands/get_device.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module Castle
module Commands
# Generated the payload for the GET devices/#{device_token} request
# Generates the payload for the GET devices/#{device_token} request
class GetDevice
class << self
# @param options [Hash]
Expand Down
2 changes: 1 addition & 1 deletion lib/castle/commands/get_devices_for_user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module Castle
module Commands
# Generated the payload for the GET users/#{user_id}/devices request
# Generates the payload for the GET users/#{user_id}/devices request
class GetDevicesForUser
class << self
# @param options [Hash]
Expand Down
2 changes: 1 addition & 1 deletion lib/castle/commands/report_device.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module Castle
module Commands
# Generated the payload for the PUT devices/#{device_token}/report request
# Generates the payload for the PUT devices/#{device_token}/report request
class ReportDevice
class << self
# @param options [Hash]
Expand Down
1 change: 1 addition & 0 deletions lib/castle/commands/review.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

module Castle
module Commands
# Generates the payload for the GET reviews/#{review_id} request
class Review
class << self
# @param options [Hash]
Expand Down
20 changes: 20 additions & 0 deletions lib/castle/core/process_webhook.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

module Castle
module Core
# Parses a webhook
module ProcessWebhook
class << self
# Checks if webhook is valid
# @param webhook [Request]
def call(webhook)
webhook.body.read.tap do |result|
raise Castle::ApiError, 'Invalid webhook from Castle API' if result.blank?

Castle::Logger.call('webhook:', result.to_s)
end
end
end
end
end
end
2 changes: 2 additions & 0 deletions lib/castle/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class SecurityError < Castle::Error; end
class ConfigurationError < Castle::Error; end
# error returned by api
class ApiError < Castle::Error; end
# webhook signature verification error
class WebhookVerificationError < Castle::Error; end

# api error bad request 400
class BadRequestError < Castle::ApiError; end
Expand Down
2 changes: 1 addition & 1 deletion lib/castle/events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
module Castle
# list of events based on https://docs.castle.io/api_reference/#list-of-recognized-events
module Events
# Record when a user succesfully logs in.
# Record when a user successfully logs in.
LOGIN_SUCCEEDED = '$login.succeeded'
# Record when a user failed to log in.
LOGIN_FAILED = '$login.failed'
Expand Down
8 changes: 6 additions & 2 deletions lib/castle/utils/clone.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@

module Castle
module Utils
# Clones any object
class Clone
def self.call(object)
Marshal.load(Marshal.dump(object))
class << self
# Returns a cloned object of any type
def call(object)
Marshal.load(Marshal.dump(object))
end
end
end
end
Expand Down
9 changes: 6 additions & 3 deletions lib/castle/utils/get_timestamp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@

module Castle
module Utils
# generates proper timestamp
# Generates a timestamp
class GetTimestamp
def self.call
Time.now.utc.iso8601(3)
class << self
# Returns current time as ISO8601 formatted string
def call
Time.now.utc.iso8601(3)
end
end
end
end
Expand Down
22 changes: 22 additions & 0 deletions lib/castle/utils/secure_compare.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module Castle
module Utils
# Code borrowed from ActiveSupport
class SecureCompare
class << self
# @param str_a [String] first string to be compared
# @param str_b [String] second string to be compared
def call(str_a, str_b)
return false unless str_a.bytesize == str_b.bytesize

l = str_a.unpack "C#{str_a.bytesize}"

res = 0
str_b.each_byte { |byte| res |= byte ^ l.shift }
res.zero?
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/castle/validators/not_supported.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

module Castle
module Validators
# Checks if required keys are supported
class NotSupported
class << self
def call(options, keys)
Expand Down
1 change: 1 addition & 0 deletions lib/castle/validators/present.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

module Castle
module Validators
# Checks if required keys are present
class Present
class << self
def call(options, keys)
Expand Down
41 changes: 41 additions & 0 deletions lib/castle/webhooks/verify.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

module Castle
module Webhooks
# Verify a webhook
class Verify
class << self
# Checks if webhook is valid
# @param webhook [Request]
def call(webhook)
expected_signature = compute_signature(webhook)
signature = webhook.env['HTTP_X_CASTLE_SIGNATURE']
verify_signature(signature, expected_signature)
end

private

# Computes a webhook signature using provided user_id
# @param webhook [Request]
def compute_signature(webhook)
Base64.encode64(
OpenSSL::HMAC.digest(
OpenSSL::Digest.new('sha256'),
Castle.config.api_secret,
Castle::Core::ProcessWebhook.call(webhook)
)
).strip
end

# Check if the signatures are matching
# @param signature [String] first signature to be compared
# @param expected_signature [String] second signature to be compared
def verify_signature(signature, expected_signature)
return if Castle::Utils::SecureCompare.call(signature, expected_signature)

raise Castle::WebhookVerificationError, 'Signature not matching the expected signature'
end
end
end
end
end
46 changes: 46 additions & 0 deletions spec/lib/castle/core/process_webhook_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true

describe Castle::Core::ProcessWebhook do
describe '#call' do
subject(:call) { described_class.call(webhook) }

let(:webhook_body) do
{
api_version: 'v1',
app_id: '12345',
type: '$incident.confirmed',
created_at: '2020-12-18T12:55:21.779Z',
data: {
id: 'test',
device_token: 'token',
user_id: '',
trigger: '$login.succeeded',
context: {},
location: {},
user_agent: {}
},
user_traits: {},
properties: {},
policy: {}
}.to_json
end

let(:webhook) { OpenStruct.new(body: StringIO.new(webhook_body)) }

context 'when success' do
it { expect(call).to eql(webhook_body) }
end

context 'when webhook empty' do
let(:webhook) { OpenStruct.new(body: StringIO.new('')) }

it { expect { call }.to raise_error(Castle::ApiError, 'Invalid webhook from Castle API') }
end

context 'when webhook nil' do
let(:webhook) { OpenStruct.new(body: StringIO.new) }

it { expect { call }.to raise_error(Castle::ApiError, 'Invalid webhook from Castle API') }
end
end
end
69 changes: 69 additions & 0 deletions spec/lib/castle/webhooks/verify_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

describe Castle::Webhooks::Verify do
describe '#call' do
subject(:call) { described_class.call(webhook) }

let(:env) do
Rack::MockRequest.env_for(
'/',
'HTTP_X_CASTLE_SIGNATURE' => signature
)
end

let(:webhook) { Rack::Request.new(env) }
let(:user_id) { '12345' }
let(:webhook_body) do
{
api_version: 'v1',
app_id: '12345',
type: '$incident.confirmed',
created_at: '2020-12-18T12:55:21.779Z',
data: {
id: 'test',
device_token: 'token',
user_id: user_id,
trigger: '$login.succeeded',
context: {},
location: {},
user_agent: {}
},
user_traits: {},
properties: {},
policy: {}
}.to_json
end

context 'when success' do
let(:signature) { '3ptx3rUOBnGEqPjMrbcJn2UUfzwTKP54dFyP5uyPY+Y=' }

before do
allow(Castle::Core::ProcessWebhook)
.to receive(:call)
.and_return(webhook_body)
end

it do
expect { call }.not_to raise_error
end
end

context 'when signature is malformed' do
let(:signature) { '123' }

before do
allow(Castle::Core::ProcessWebhook)
.to receive(:call)
.and_return(webhook_body)
end

it do
expect { call }
.to raise_error(
Castle::WebhookVerificationError,
'Signature not matching the expected signature'
)
end
end
end
end