From fd76c9db75bbcedc61bed757ff6e62896a7a6b38 Mon Sep 17 00:00:00 2001 From: bartes Date: Wed, 7 Nov 2018 22:20:20 +0100 Subject: [PATCH] f --- lib/castle/middleware.rb | 83 +++++++++-------- lib/castle/middleware/authenticating.rb | 79 ++++++++++++++++ lib/castle/middleware/configuration.rb | 27 +++--- .../middleware/configuration_options.rb | 19 ++-- .../middleware/configuration_services.rb | 21 +++++ lib/castle/middleware/event_mapper.rb | 2 +- lib/castle/middleware/params_flattener.rb | 2 +- lib/castle/middleware/railtie.rb | 19 ++-- lib/castle/middleware/request_config.rb | 2 +- lib/castle/middleware/secure_headers.rb | 21 +++++ lib/castle/middleware/sensor.rb | 25 +++-- lib/castle/middleware/tracking.rb | 92 +++++-------------- lib/castle/middleware/transport/sync.rb | 14 --- lib/castle/middleware/version.rb | 2 +- spec/castle/middleware/castle_config.yml | 18 +++- spec/castle/middleware/transport/sync_spec.rb | 15 --- spec/castle/middleware_spec.rb | 27 ++---- spec/spec_helper.rb | 6 +- 18 files changed, 264 insertions(+), 210 deletions(-) create mode 100644 lib/castle/middleware/authenticating.rb create mode 100644 lib/castle/middleware/configuration_services.rb create mode 100644 lib/castle/middleware/secure_headers.rb delete mode 100644 lib/castle/middleware/transport/sync.rb delete mode 100644 spec/castle/middleware/transport/sync_spec.rb diff --git a/lib/castle/middleware.rb b/lib/castle/middleware.rb index 2c7bafe..6e5b8b4 100644 --- a/lib/castle/middleware.rb +++ b/lib/castle/middleware.rb @@ -2,57 +2,68 @@ require 'castle/middleware/configuration' require 'castle/middleware/configuration_options' +require 'castle/middleware/configuration_services' require 'castle/middleware/event_mapper' require 'castle/middleware/params_flattener' require 'castle/middleware/sensor' require 'castle/middleware/tracking' +require 'castle/middleware/authenticating' require 'castle/middleware/version' +require 'singleton' module Castle # Main middleware definition - module Middleware - class << self - attr_writer :configuration + class Middleware + include ::Singleton - def call_error_handler(exception) - return unless configuration.error_handler - configuration.error_handler.call(exception) - end + def call_error_handler(exception) + return unless configuration.services.error_handler + configuration.services.error_handler.call(exception) + end - def configure - raise ArgumentError unless block_given? - @configuration_options = ConfigurationOptions.new - yield(@configuration_options) - @event_mapping = nil - end + def configure + raise ArgumentError unless block_given? + @configuration_options = ConfigurationOptions.new + yield(@configuration_options) + @event_mapping = nil + end - def configuration - @configuration ||= Configuration.new(@configuration_options) - end + def configuration=(value) + @configuration = value + end - def event_mapping - @event_mapping ||= EventMapper.build(configuration.events) - end + def configuration + @configuration ||= Configuration.new(@configuration_options) + end - def log(level, message) - return unless Middleware.configuration.logger - Middleware.configuration.logger.public_send(level.to_s, message) - end + def event_mapping + @event_mapping ||= EventMapper.build(configuration.events) + end - def track(context, options) - log(:debug, "[Castle] Tracking #{options[:event]}") - ::Castle::Client.new(context).track(options) - rescue Castle::Error => e - log(:warn, "[Castle] Can't send tracking request because #{e} exception") - call_error_handler(e) - end + def log(level, message) + return unless configuration.logger + configuration.logger.public_send(level.to_s, message) + end - def authenticate(context, options) - log(:debug, "[Castle] Authenticating #{options[:event]}") - ::Castle::Client.new(context).authenticate(options) - rescue Castle::Error => e - log(:warn, "[Castle] Can't send authenticating request because #{e} exception") - call_error_handler(e) + def track(context, options) + log(:debug, "[Castle] Tracking #{options[:event]}") + ::Castle::Client.new(context).track(options) + rescue Castle::Error => e + log(:warn, "[Castle] Can't send tracking request because #{e} exception") + call_error_handler(e) + end + + def authenticate(context, options) + log(:debug, "[Castle] Authenticating #{options[:event]}") + ::Castle::Client.new(context).authenticate(options) + rescue Castle::Error => e + log(:warn, "[Castle] Can't send authenticating request because #{e} exception") + call_error_handler(e) + end + + class << self + def configure(&block) + instance.configure(&block) end end end diff --git a/lib/castle/middleware/authenticating.rb b/lib/castle/middleware/authenticating.rb new file mode 100644 index 0000000..2713d9b --- /dev/null +++ b/lib/castle/middleware/authenticating.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'castle/middleware/request_config' + +module Castle + class Middleware + class Authenticating + extend Forwardable + def_delegators :@middleware, :log, :configuration, :event_mapping, :authenticate + + attr_reader :app + + def initialize(app) + @app = app + @middleware = Middleware.instance + end + + def call(env) + env['castle'] = RequestConfig.new + req = Rack::Request.new(env) + + if login?(req) + byebug + redirect_result = authentication_verdict(req, env) + return [301, {'Location' => redirect_result}, []] if redirect_result + end + + env['castle'].identify(req.session['castle_user_id'], {}) if req.session['castle_user_id'] + + # [status, headers, body] + app.call(env) + end + + private + + def authentication_verdict(req, env) + key = req.params.dig(*configuration.login.dig('authentication', 'key').split('.')) + pass = req.params.dig(*configuration.login.dig('authentication', 'password').split('.')) + + resource = configuration.services.provide_by_login_key.call(key) + return if resource.nil? + + env['castle'].identify(resource.public_send(configuration.login['user_id']), {}) + + return unless configuration.services.validate_password.call(resource, pass) + + verdict = castle_authenticate(req, env) + + case verdict[:action] + when 'allow' + req.session['castle_user_id'] = env['castle'].user_id + when 'challenge' + redirect_result = configuration.challenge.call(req, resource) + configuration.logout.call(req, env) + redirect_result + when 'deny' + redirect_result = configuration.services.deny.call(req, resource) + configuration.services.logout.call(req, env) + redirect_result + end + end + + def castle_authenticate(req, env) + authenticate( + Castle::Client.to_context(req), + Castle::Client.to_options( + user_id: env['castle'].user_id, + event: '$login.succeeded' + ) + ) + end + + def login?(req) + req.path == configuration.login['path'] && req.request_method == configuration.login['method'] && req.form_data? + end + + end + end +end diff --git a/lib/castle/middleware/configuration.rb b/lib/castle/middleware/configuration.rb index f7b7d4e..cd54d3c 100644 --- a/lib/castle/middleware/configuration.rb +++ b/lib/castle/middleware/configuration.rb @@ -4,23 +4,28 @@ require 'yaml' module Castle - module Middleware + class Middleware # Configuration object for Middleware class Configuration extend Forwardable - attr_accessor :options, :events, :login - def_delegators :@options, :logger, :transport, :error_handler, :api_secret, :app_id, :deny, :challenge, :logout, :provide_by_id, :provide_by_login_key, :validate_password, :tracker_url + attr_reader :options + def_delegators :@options, + :logger, :transport, :api_secret, :app_id, :tracker_url, :services, + :events, :login + # :deny, :challenge, :logout, :provide_by_id, :provide_by_login_key, :validate_password + def_delegators :@middleware, :log def initialize(options = nil) - self.options = options + @options = options + @middleware = Middleware.instance setup end # Reset to default options def setup options.file_path ||= 'config/castle.yml' - options.transport = lambda do |context, options| - Castle::Middleware.track(context, options) + services.transport ||= lambda do |context, options| + Middleware.instance.track(context, options) end # Forward setting to Castle SDK Castle.api_secret = api_secret @@ -29,12 +34,12 @@ def setup def load_config_file file_config = YAML.load_file(options.file_path) - self.events = file_config['events'] || {} - self.login = file_config['login'] || {} - rescue Errno::ENOENT - Castle::Middleware.log(:error, '[Castle] No config file found') + options.events = (options.events || {}).merge(file_config['events'] || {}) + options.login = (options.events || {}).merge(file_config['login'] || {}) + rescue Errno::ENOENT => e + log(:error, '[Castle] No config file found') rescue Psych::SyntaxError - Castle::Middleware.log(:error, '[Castle] Invalid YAML in config file') + log(:error, '[Castle] Invalid YAML in config file') end end end diff --git a/lib/castle/middleware/configuration_options.rb b/lib/castle/middleware/configuration_options.rb index ea8dd1c..fb587c6 100644 --- a/lib/castle/middleware/configuration_options.rb +++ b/lib/castle/middleware/configuration_options.rb @@ -1,29 +1,26 @@ # frozen_string_literal: true module Castle - module Middleware + class Middleware + # Configuration options accessible for configure in mounted app + class ConfigurationOptions %i[ api_secret app_id tracker_url - auto_insert_middleware - error_handler file_path logger - transport - deny - challenge - logout - provide_by_id - provide_by_login_key - validate_password + events + login ].each do |opt| attr_accessor opt end + attr_reader :services + def initialize - self.auto_insert_middleware = false + @services = ConfigurationServices.new end end end diff --git a/lib/castle/middleware/configuration_services.rb b/lib/castle/middleware/configuration_services.rb new file mode 100644 index 0000000..122c403 --- /dev/null +++ b/lib/castle/middleware/configuration_services.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Castle + class Middleware + # Configuration services (procs, lambdas) available to setup in configure block + class ConfigurationServices + %i[ + error_handler + transport + deny + challenge + logout + provide_by_id + provide_by_login_key + validate_password + ].each do |opt| + attr_accessor opt + end + end + end +end diff --git a/lib/castle/middleware/event_mapper.rb b/lib/castle/middleware/event_mapper.rb index cee7208..ad66591 100644 --- a/lib/castle/middleware/event_mapper.rb +++ b/lib/castle/middleware/event_mapper.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Castle - module Middleware + class Middleware # Map a request path to a Castle event name class EventMapper Object = Struct.new(:event, :method, :path, :status, :properties) diff --git a/lib/castle/middleware/params_flattener.rb b/lib/castle/middleware/params_flattener.rb index 4321b83..d6ccb6c 100644 --- a/lib/castle/middleware/params_flattener.rb +++ b/lib/castle/middleware/params_flattener.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Castle - module Middleware + class Middleware # Flatten nested Hashes class ParamsFlattener def self.call(object, prefix = nil) diff --git a/lib/castle/middleware/railtie.rb b/lib/castle/middleware/railtie.rb index 189b955..80ecb43 100644 --- a/lib/castle/middleware/railtie.rb +++ b/lib/castle/middleware/railtie.rb @@ -3,18 +3,15 @@ require 'rails/railtie' module Castle - module Middleware + class Middleware class Railtie < ::Rails::Railtie - initializer 'rollbar.middleware.rails' do |app| - # TODO(wallin): Flash middleware might not exist. Look for common - # Rack Middlewares instead? - # https://github.com/rails/rails/blob/ac3564693c6df9c9f9a46f681f4f6a4ea84997e6/guides/source/rails_on_rack.md#internal-middleware-stack - if Middleware.configuration.auto_insert_middleware - app.config.middleware.insert_after ActionDispatch::Flash, - Castle::Middleware::Tracking - app.config.middleware.insert_after ActionDispatch::Flash, - Castle::Middleware::Sensor - end + initializer 'castle.middleware.rails' do |app| + app.config.middleware.insert_after ActionDispatch::Flash, + Castle::Middleware::Authenticating + app.config.middleware.insert_after ActionDispatch::Flash, + Castle::Middleware::Tracking + app.config.middleware.insert_after ActionDispatch::Flash, + Castle::Middleware::Sensor end end end diff --git a/lib/castle/middleware/request_config.rb b/lib/castle/middleware/request_config.rb index 7bca735..520742d 100644 --- a/lib/castle/middleware/request_config.rb +++ b/lib/castle/middleware/request_config.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Castle - module Middleware + class Middleware class RequestConfig attr_reader :user_id attr_reader :traits diff --git a/lib/castle/middleware/secure_headers.rb b/lib/castle/middleware/secure_headers.rb new file mode 100644 index 0000000..b6a0b90 --- /dev/null +++ b/lib/castle/middleware/secure_headers.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Castle + class Middleware + # Configuration services (procs, lambdas) available to setup in configure block + class SecureHeaders + def initialize + @can_append_nonce = ::SecureHeaders.respond_to?(:content_security_policy_script_nonce) && + defined?(::SecureHeaders::Configuration) && + !::SecureHeaders::Configuration.get.csp.opt_out? && + !::SecureHeaders::Configuration.get.current_csp[:script_src].to_a.include?("'unsafe-inline'") + end + + def call(env) + return unless @can_append_nonce + nonce = ::SecureHeaders.content_security_policy_script_nonce(::Rack::Request.new(env)) + " nonce=\"#{nonce}\"" + end + end + end +end diff --git a/lib/castle/middleware/sensor.rb b/lib/castle/middleware/sensor.rb index 90ebb0e..7b2c4e9 100644 --- a/lib/castle/middleware/sensor.rb +++ b/lib/castle/middleware/sensor.rb @@ -1,17 +1,19 @@ # frozen_string_literal: true module Castle - module Middleware + class Middleware class Sensor + extend Forwardable + def_delegators :@middleware, :log, :configuration + attr_reader :app - attr_reader :config JS_IS_INJECTED_KEY = 'castle.injected'.freeze CJS_PATH = 'https://d2t77mnxyo7adj.cloudfront.net/v1/c.js'.freeze def initialize(app) @app = app - @config = config + @middleware = Middleware end def call(env) @@ -28,12 +30,9 @@ def call(env) end end - def log(level, message) - Middleware.log(level, message) - end - def add_js?(env, status, headers) - status == 200 && !env[JS_IS_INJECTED_KEY] && + req = Rack::Request.new(env) + !req.xhr? && status == 200 && !env[JS_IS_INJECTED_KEY] && html?(headers) && !attachment?(headers) && !streaming?(headers) end @@ -53,8 +52,6 @@ def add_js(env, response) body = join_body(response) close_old_response(response) - return nil unless body - head_open_end = find_end_of_head_open(body) return nil unless head_open_end @@ -110,7 +107,7 @@ def complete_js_content(env) end def tracker_url_command - return unless Castle::Middleware.configuration.tracker_url + return unless configuration.tracker_url "_castle('setTrackerUrl', '#{Castle::Middleware.configuration.tracker_url}');" end @@ -123,14 +120,14 @@ def secure_command(env) return unless env['castle'].user_id hmac = OpenSSL::HMAC.hexdigest( 'sha256', - Castle::Middleware.configuration.api_secret, + configuration.api_secret, env['castle'].user_id.to_s ) "_castle('secure', '#{hmac}');" end def snippet_cjs_tag - "" + "" end def script_tag(content, env) @@ -139,7 +136,7 @@ def script_tag(content, env) end def html_safe_if_needed(string) - string = string.html_safe if string.respond_to?(:html_safe) + #string = string.html_safe if string.respond_to?(:html_safe) string end end diff --git a/lib/castle/middleware/tracking.rb b/lib/castle/middleware/tracking.rb index 3c2d291..a18a28f 100644 --- a/lib/castle/middleware/tracking.rb +++ b/lib/castle/middleware/tracking.rb @@ -3,34 +3,31 @@ require 'castle/middleware/request_config' module Castle - module Middleware + class Middleware class Tracking + extend Forwardable + def_delegators :@middleware, :log, :configuration, :event_mapping + attr_reader :app def initialize(app) @app = app + @middleware = Middleware.instance end def call(env) env['castle'] = RequestConfig.new req = Rack::Request.new(env) - if login?(req) - redirect_result = authentication_verdict(req, env) - return [301, {'Location' => redirect_result}, []] if redirect_result - end - - env['castle'].identify(req.session['castle_user_id'], {}) if req.session['castle_user_id'] - # [status, headers, body] app_result = app.call(env) # Find a matching event from the config - mapping = Castle::Middleware.event_mapping.find_by_rack_request(app_result, req) + mapping = event_mapping.find_by_rack_request(app_result, req) return app_result if mapping.nil? - event_properties = self.class.collect_event_properties( + event_properties = collect_event_properties( req.params, mapping.properties ).merge(env['castle'].props || {}) @@ -42,47 +39,10 @@ def call(env) private - def authentication_verdict(req, env) - key = req.params.dig(*login_config.dig('authentication', 'key').split('.')) - pass = req.params.dig(*login_config.dig('authentication', 'password').split('.')) - - resource = Castle::Middleware.configuration.provide_by_login_key.call(key) - return if resource.nil? - - env['castle'].identify(resource.public_send(login_config['user_id']), {}) - - return unless Castle::Middleware.configuration.validate_password.call(resource, pass) - - verdict = authenticate(req, env) - - case verdict[:action] - when 'allow' - req.session['castle_user_id'] = env['castle'].user_id - when 'challenge' - redirect_result = Castle::Middleware.configuration.challenge.call(req, resource) - Castle::Middleware.configuration.logout.call(req, env) - redirect_result - when 'deny' - redirect_result = Castle::Middleware.configuration.deny.call(req, resource) - Castle::Middleware.configuration.logout.call(req, env) - redirect_result - end - end - - def authenticate(req, env) - Castle::Middleware.authenticate( - Castle::Client.to_context(req), - Castle::Client.to_options( - user_id: env['castle'].user_id, - event: '$login.succeeded' - ) - ) - end - def track(req, env, mapping, event_properties) - Castle::Middleware.configuration.transport.call( - Castle::Client.to_context(req), - Castle::Client.to_options( + configuration.transport.call( + ::Castle::Client.to_context(req), + ::Castle::Client.to_options( user_id: env['castle'].user_id, user_traits: env['castle'].traits, event: mapping.event, @@ -91,30 +51,20 @@ def track(req, env, mapping, event_properties) ) end - def login_config - Castle::Middleware.configuration.login - end - - def login?(req) - req.path == login_config['path'] && req.request_method == login_config['method'] && req.form_data? - end - - class << self - def collect_event_properties(request_params, properties_map) - flat_params = Middleware::ParamsFlattener.(request_params) + def collect_event_properties(request_params, properties_map) + flat_params = ParamsFlattener.call(request_params) - event_properties = properties_map.each_with_object({}) do |(property, param), hash| - hash[property] = flat_params[param] - end - - # Convert password to a boolean - # TODO: Check agains list of known password field names - if event_properties.key?(:password) - event_properties[:password] = !event_properties[:password].to_s.empty? - end + event_properties = properties_map.each_with_object({}) do |(property, param), hash| + hash[property] = flat_params[param] + end - event_properties + # Convert password to a boolean + # TODO: Check agains list of known password field names + if event_properties.key?(:password) + event_properties[:password] = !event_properties[:password].to_s.empty? end + + event_properties end end end diff --git a/lib/castle/middleware/transport/sync.rb b/lib/castle/middleware/transport/sync.rb deleted file mode 100644 index 1ce9a3f..0000000 --- a/lib/castle/middleware/transport/sync.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Castle - module Middleware - module Transport - # Send a track request to castle in sync mode - module Sync - def self.call(context, options) - Middleware.track(context, options) - end - end - end - end -end diff --git a/lib/castle/middleware/version.rb b/lib/castle/middleware/version.rb index 58d2a89..eca2339 100644 --- a/lib/castle/middleware/version.rb +++ b/lib/castle/middleware/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Castle - module Middleware + class Middleware VERSION = '0.0.9'.freeze end end diff --git a/spec/castle/middleware/castle_config.yml b/spec/castle/middleware/castle_config.yml index c77f2de..63a995c 100644 --- a/spec/castle/middleware/castle_config.yml +++ b/spec/castle/middleware/castle_config.yml @@ -1,6 +1,20 @@ --- +login: + method: POST + path: "/login" + user_id: uuid + status: '301' + authentication: + key: 'user.email' + password: 'user.password' events: $login.failed: - path: !ruby/regexp '/\/sign_in\/new/i' - status: 404 + status: '302' method: POST + path: !ruby/regexp '/\/sign_in\/new/i' + properties: + email: user.email + $logout.succeeded: + status: '302' + method: DELETE + path: "/logout" diff --git a/spec/castle/middleware/transport/sync_spec.rb b/spec/castle/middleware/transport/sync_spec.rb deleted file mode 100644 index e87af71..0000000 --- a/spec/castle/middleware/transport/sync_spec.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -describe Castle::Middleware::Transport::Sync do - describe '#call' do - let(:params) { spy } - let(:context) { spy } - - before do - allow(::Castle::Middleware).to receive(:track) - described_class.call(params, context) - end - - it { expect(::Castle::Middleware).to have_received(:track).with(params, context) } - end -end diff --git a/spec/castle/middleware_spec.rb b/spec/castle/middleware_spec.rb index 67c802f..024c387 100644 --- a/spec/castle/middleware_spec.rb +++ b/spec/castle/middleware_spec.rb @@ -2,43 +2,33 @@ describe Castle::Middleware do describe '::configuration' do - subject(:config) { described_class.configuration } - - before do - described_class.configure do |config| - config.api_secret = 'secret' - end - end + subject(:config) { described_class.instance.configuration } it { expect(config.api_secret).to be_eql('secret') } end context '.event_mapping' do - subject { described_class.event_mapping.events } + subject { described_class.instance.event_mapping.events } context 'when configured' do before do - described_class.configuration.events = { + described_class.instance.configuration.options.events = { '$login.failed' => { status: 400, path: '/', method: 'POST' } } end it { is_expected.to include '$login.failed' } end - - context 'when not configured' do - it { is_expected.to be_empty } - end end describe '::configure' do - let(:configuration) { described_class.configuration } + let(:configuration) { described_class.instance.configuration } context 'without block' do it { expect { described_class.configure }.to raise_error(ArgumentError) } end context 'with block' do - it { expect { |b| described_class.configure(&b) }.to yield_with_args(configuration) } + it { expect { |b| described_class.configure(&b) }.to yield_with_args(anything) } end end @@ -47,12 +37,11 @@ let(:error_handler) { spy } let(:exception) { Exception.new } - before { described_class.configuration.error_handler = error_handler } + before { described_class.instance.configuration.options.services.error_handler = error_handler } context 'with a Proc' do before do - allow(error_handler).to receive(:is_a?).and_return(Proc) - described_class.call_error_handler(exception) + described_class.instance.call_error_handler(exception) end it { expect(error_handler).to have_received(:call).with(exception).once } @@ -68,7 +57,7 @@ context 'when request raises exception' do before do allow(api).to receive(:track).and_raise(::Castle::Error) - allow(described_class).to receive(:call_error_handler) + allow(described_class.instance).to receive(:call_error_handler) described_class.track({}, {}) end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 1a680a6..34e4e9a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -10,7 +10,9 @@ RSpec.configure do |config| config.before(:each) do - ::Castle::Middleware.configuration.reset! - ::Castle::Middleware.configure {} + ::Castle::Middleware.configure do |c| + c.api_secret = 'secret' + c.file_path = "./spec/castle/middleware/castle_config.yml" + end end end