diff --git a/app/channels/ocpp/rails/connection.rb b/app/channels/ocpp/rails/connection.rb new file mode 100644 index 0000000..9d780f9 --- /dev/null +++ b/app/channels/ocpp/rails/connection.rb @@ -0,0 +1,62 @@ +module Ocpp + module Rails + class Connection < ActionCable::Connection::Base + attr_reader :username, :password, :charge_point_id + + def connect + # Step 1: Extract credentials from HTTP Basic Auth header + username, password = extract_credentials_from_header + + # Step 2: Extract charge_point_id from URL path + charge_point_id = request.params[:charge_point_id] + + # Step 3: Execute sync hooks and get result + auth_result = ConnectionAuthHookManager.execute_hooks(username, password, charge_point_id) + + # Step 4: Enqueue async hooks (before connection decision is enforced) + ConnectionAuthHookManager.execute_async_hooks(username, password, charge_point_id) + + # Step 5: Make connection decision based on sync hook result + if auth_result == false + ::Rails.logger.warn("Connection authentication failed for charge_point_id: #{charge_point_id}") + reject_unauthorized_connection + end + + # Step 6: Store credentials in connection object (only reached if auth_result is true) + @username = username + @password = password + @charge_point_id = charge_point_id + end + + private + + def extract_credentials_from_header + auth_header = request.env['HTTP_AUTHORIZATION'] + + # Case 1: No Authorization header + if auth_header.nil? || !auth_header.start_with?('Basic ') + ::Rails.logger.warn("Malformed Authorization header received") if auth_header && !auth_header.start_with?('Basic ') + return [nil, nil] + end + + # Case 2: Decode credentials + begin + # Strip "Basic " prefix and decode Base64 + encoded_credentials = auth_header.sub(/^Basic /, '') + decoded_credentials = Base64.strict_decode64(encoded_credentials) + + # Case 4: Split on first ':' only (handles passwords with colons) + parts = decoded_credentials.split(':', 2) + username = parts[0] + password = parts[1] || "" # Case 5: No colon found, password is empty string + + return [username, password] + rescue ArgumentError => e + # Case 3: Base64 decoding failure + ::Rails.logger.error("Failed to decode Authorization header: #{e.message}") + reject_unauthorized_connection + end + end + end + end +end diff --git a/app/jobs/ocpp/rails/connection_auth_async_hook_job.rb b/app/jobs/ocpp/rails/connection_auth_async_hook_job.rb new file mode 100644 index 0000000..11adede --- /dev/null +++ b/app/jobs/ocpp/rails/connection_auth_async_hook_job.rb @@ -0,0 +1,18 @@ +module Ocpp + module Rails + class ConnectionAuthAsyncHookJob < ApplicationJob + queue_as :ocpp_hooks + + retry_on StandardError, wait: :exponentially_longer, attempts: 3 + + def perform(username, password, charge_point_id, hook_class_name) + hook_class = hook_class_name.constantize + hook = hook_class.new + hook.call(username, password, charge_point_id) + rescue => error + ::Rails.logger.error("ConnectionAuthAsyncHook #{hook_class_name} failed: #{error.message}") + raise + end + end + end +end diff --git a/app/services/ocpp/rails/connection_auth_hook_manager.rb b/app/services/ocpp/rails/connection_auth_hook_manager.rb new file mode 100644 index 0000000..a647545 --- /dev/null +++ b/app/services/ocpp/rails/connection_auth_hook_manager.rb @@ -0,0 +1,59 @@ +module Ocpp + module Rails + module ConnectionAuthHookManager + def self.execute_hooks(username, password, charge_point_id) + hooks = Ocpp::Rails.configuration.connection_auth_hooks + sync_hooks = hooks.reject { |hook| hook.respond_to?(:async?) && hook.async? } + + # Fail-secure: If no sync hooks registered, reject connection + if sync_hooks.empty? + ::Rails.logger.info("No connection auth hooks registered, rejecting connection") + return false + end + + # Execute sync hooks sequentially in registration order + sync_hooks.each do |hook| + begin + result = hook.call(username, password, charge_point_id) + + # Validate return value is boolean + unless result == true || result == false + ::Rails.logger.error("ConnectionAuthHook #{hook.class.name} returned non-boolean: #{result.inspect}") + return false + end + + # If hook rejects, return false immediately + if result == false + ::Rails.logger.info("ConnectionAuthHook #{hook.class.name} rejected connection") + return false + end + + # If result is true, continue to next hook + rescue => error + ::Rails.logger.error("ConnectionAuthHook #{hook.class.name} raised exception: #{error.message}") + ::Rails.logger.error(error.backtrace.join("\n")) + return false + end + end + + # All sync hooks returned true + true + end + + def self.execute_async_hooks(username, password, charge_point_id) + hooks = Ocpp::Rails.configuration.connection_auth_hooks + async_hooks = hooks.select { |hook| hook.respond_to?(:async?) && hook.async? } + + async_hooks.each do |hook| + begin + Ocpp::Rails::ConnectionAuthAsyncHookJob.perform_later(username, password, charge_point_id, hook.class.name) + rescue => error + ::Rails.logger.error("Failed to enqueue ConnectionAuthAsyncHookJob for #{hook.class.name}: #{error.message}") + end + end + + true + end + end + end +end diff --git a/lib/ocpp/rails.rb b/lib/ocpp/rails.rb index ab3070c..48aedd5 100644 --- a/lib/ocpp/rails.rb +++ b/lib/ocpp/rails.rb @@ -22,7 +22,8 @@ def self.supported_versions class Configuration attr_accessor :ocpp_version, :supported_versions, :heartbeat_interval, :connection_timeout, :state_change_hooks, :state_change_retention_days, :state_change_cleanup_enabled, - :authorization_hooks, :authorization_retention_days, :authorization_cleanup_enabled + :authorization_hooks, :authorization_retention_days, :authorization_cleanup_enabled, + :connection_auth_hooks def initialize @ocpp_version = "1.6" @@ -31,6 +32,7 @@ def initialize @connection_timeout = 30 @state_change_hooks = [] @authorization_hooks = [] + @connection_auth_hooks = [] @state_change_retention_days = 30 @state_change_cleanup_enabled = true @authorization_retention_days = 30 @@ -50,6 +52,13 @@ def register_authorization_hook(hook) end @authorization_hooks << hook end + + def register_connection_auth_hook(hook) + unless hook.respond_to?(:call) + raise ArgumentError, "Hook must respond to :call method" + end + @connection_auth_hooks << hook + end end end end