Skip to content
Open
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
62 changes: 62 additions & 0 deletions app/channels/ocpp/rails/connection.rb
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions app/jobs/ocpp/rails/connection_auth_async_hook_job.rb
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions app/services/ocpp/rails/connection_auth_hook_manager.rb
Original file line number Diff line number Diff line change
@@ -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
11 changes: 10 additions & 1 deletion lib/ocpp/rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Loading