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
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ gem 'bootsnap', '>= 1.4.5', require: false
gem 'rack-cors', require: 'rack/cors'

gem 'devise'
gem 'devise_ldap_authenticatable'
gem 'devise-security'
gem 'net-ldap'

gem 'doorkeeper'
gem 'pundit'
Expand Down
6 changes: 1 addition & 5 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,6 @@ GEM
rails-i18n
devise-security (0.18.0)
devise (>= 4.3.0)
devise_ldap_authenticatable (0.8.7)
devise (>= 3.4.1)
net-ldap (>= 0.16.0)
docile (1.4.1)
doorkeeper (5.8.2)
railties (>= 5)
Expand Down Expand Up @@ -312,12 +309,12 @@ DEPENDENCIES
devise
devise-i18n
devise-security
devise_ldap_authenticatable
doorkeeper
doorkeeper-i18n
dotenv-rails
factory_bot_rails
mime-types (~> 3.7)
net-ldap
pg
puma
pundit
Expand Down Expand Up @@ -360,7 +357,6 @@ CHECKSUMS
devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8
devise-i18n (1.15.0) sha256=aff406e8a2941cd1d7614643b1619031a403b2855bb6057e391d1c7c273205cd
devise-security (0.18.0) sha256=fc06be1624b5151044ff9c5d8e61abdfa7d56eb16bfdaec16a11235d54708513
devise_ldap_authenticatable (0.8.7) sha256=8af6f839661e24ca9afc5a1508a7ec7e1327e93af4516f2baabacdf511ee5a2e
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
doorkeeper (5.8.2) sha256=a73d07aeaf590b1e7e2a35390446f23131c9f37bc0561653e514d3973f4d50d3
doorkeeper-i18n (5.2.8) sha256=e4054701df464892f3c0fd6c96507188e9b082cb52938da3aab45f4f9fdaf552
Expand Down
4 changes: 0 additions & 4 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
class ApplicationController < ActionController::API
rescue_from DeviseLdapAuthenticatable::LdapException do |exception|
render text: exception, status: :internal_server_error
end

include Pundit::Authorization if ENV['ENABLE_AUTHENTICATION'].present?
before_action :doorkeeper_authorize!, except: %i[info]
before_action :active_storage_url_options
Expand Down
4 changes: 2 additions & 2 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def validate_login_uniqueness

def check_ldap_email
if email.blank?
mail = Devise::LDAP::Adapter.get_ldap_param(login, 'mail')
mail = LdapAdapter.get_ldap_param(login, 'mail')
self.email = if mail.present?
mail.first
else # No email, use login
Expand All @@ -42,7 +42,7 @@ def check_ldap_email
end

def self.check_ldap_credentials(login, password)
Devise::LDAP::Adapter.valid_credentials?(login, password)
LdapAdapter.valid_credentials?(login, password)
end

# create user with ldap
Expand Down
176 changes: 176 additions & 0 deletions app/services/ldap_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# frozen_string_literal: true

require 'net/ldap'

class LdapAdapter
class << self
# Validates user credentials against LDAP server
# @param login [String] the user's login identifier
# @param password [String] the user's password
# @return [Boolean] true if credentials are valid
def valid_credentials?(login, password)
return false if login.blank? || password.blank?

ldap = create_ldap_connection
return false unless ldap
Comment on lines +14 to +15
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance concern: A new LDAP connection is created for each authentication attempt and parameter retrieval. This could lead to performance issues under high load. Consider implementing connection pooling or reusing connections where appropriate, especially if multiple LDAP operations are performed in sequence.

Copilot uses AI. Check for mistakes.

# Bind with admin credentials first (if configured)
unless bind_as_admin(ldap)
Rails.logger.error "LDAP: Failed to bind as admin"
return false
end

# Search for user
user_dn = find_user_dn(ldap, login)
return false unless user_dn

# Now attempt to bind as the user to validate their password
ldap.auth(user_dn, password)
result = ldap.bind

if result
Rails.logger.info "LDAP: Successfully authenticated user: #{login}"
else
Rails.logger.warn "LDAP: Failed to authenticate user: #{login} - #{ldap.get_operation_result.message}"
Comment on lines +32 to +34
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security concern: The login identifier is being logged in error and warning messages. This could expose sensitive user information in log files. Consider sanitizing or redacting the login identifier in log messages, especially in production environments, to comply with security and privacy best practices.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

end

result
rescue StandardError => e
Rails.logger.error "LDAP: Error validating credentials for #{login}: #{e.message}"
false
end
Comment on lines +38 to +41
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rescue clause catches all StandardError exceptions but only logs them. Consider providing more specific error handling for different LDAP error scenarios (e.g., connection timeouts, invalid filters, server unavailable). This would allow for better error reporting and potentially different retry strategies based on the error type.

Copilot uses AI. Check for mistakes.

# Retrieves a specific LDAP parameter for a user
# @param login [String] the user's login identifier
# @param param [String] the LDAP attribute to retrieve (e.g., 'mail')
# @return [Array, nil] array of values for the attribute, or nil if not found
def get_ldap_param(login, param)
return nil if login.blank? || param.blank?

ldap = create_ldap_connection
return nil unless ldap

# Bind with admin credentials
unless bind_as_admin(ldap)
Rails.logger.error "LDAP: Failed to bind as admin"
return nil
end

# Search for user and retrieve the specified parameter
filter = Net::LDAP::Filter.eq(config[:attribute], login)
result = ldap.search(
base: config[:base],
filter: filter,
attributes: [param]
)

if result && result.first
value = result.first[param]
Rails.logger.info "LDAP: Retrieved #{param} for user #{login}"
value
else
Rails.logger.warn "LDAP: Could not find #{param} for user #{login}"
Comment on lines +69 to +72
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security concern: The login identifier is being logged in log messages. This could expose sensitive user information in log files. Consider sanitizing or redacting the login identifier in log messages, especially in production environments, to comply with security and privacy best practices.

Copilot uses AI. Check for mistakes.
nil
end
rescue StandardError => e
Rails.logger.error "LDAP: Error retrieving #{param} for #{login}: #{e.message}"
nil
end

# Loads LDAP configuration from ldap.yml
# @return [Hash] LDAP configuration hash
def config
@config ||= begin
config_file = Rails.root.join('config', 'ldap.yml')
if File.exist?(config_file)
# Process ERB template before parsing YAML
erb_content = ERB.new(File.read(config_file)).result
yaml_config = YAML.safe_load(erb_content, aliases: true)
Comment on lines +86 to +88
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security concern: Using ERB.new without safe_level parameter could expose the application to code injection if the ldap.yml file contains malicious ERB code. While this file is typically developer-controlled, consider using ERB with appropriate safety measures or validating the configuration source to follow security best practices.

Suggested change
# Process ERB template before parsing YAML
erb_content = ERB.new(File.read(config_file)).result
yaml_config = YAML.safe_load(erb_content, aliases: true)
yaml_content = File.read(config_file)
yaml_config = YAML.safe_load(yaml_content, aliases: true)

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The YAML.safe_load method with the 'aliases: true' parameter may not be supported in older versions of Ruby. In Ruby < 3.1, the parameter name is 'aliases' not in keyword argument form. Consider using 'permitted_classes' and 'aliases' parameters in a way that's compatible with the Ruby version being used, or verify that the project requires Ruby 3.1+.

Suggested change
yaml_config = YAML.safe_load(erb_content, aliases: true)
yaml_config = YAML.safe_load(erb_content, [], [], true)

Copilot uses AI. Check for mistakes.
env_config = yaml_config[Rails.env] || {}

# Convert string keys to symbols
env_config.transform_keys(&:to_sym)
else
Rails.logger.error "LDAP: Configuration file not found at #{config_file}"
{}
end
end
end
Comment on lines +82 to +98
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrency concern: The @config class instance variable is not thread-safe. In a multi-threaded environment (like Puma in threaded mode), race conditions could occur when multiple threads call config() simultaneously before @config is initialized. Consider using a Mutex or Rails' built-in thread-safe memoization patterns like ActiveSupport::PerThreadRegistry or class_attribute with thread safety.

Copilot uses AI. Check for mistakes.

# Resets the cached configuration (useful for testing)
def reset_config!
@config = nil
end

private

# Creates and returns an LDAP connection
# @return [Net::LDAP, nil] LDAP connection object or nil if config is invalid
def create_ldap_connection
return nil unless config[:host]

ldap_options = {
host: config[:host],
port: config[:port] || 389,
base: config[:base]
}
Comment on lines +109 to +116
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method only checks if config[:host] is present but doesn't validate other required configuration values like :base or :attribute. If these are missing, operations will fail with unclear errors later. Consider validating all required configuration parameters upfront and providing clear error messages about which configuration values are missing.

Copilot uses AI. Check for mistakes.

# Configure encryption
if config[:ssl].present?
case config[:ssl].to_s.downcase
when 'simple_tls', 'ssl', 'tls'
ldap_options[:encryption] = { method: :simple_tls }
when 'start_tls'
ldap_options[:encryption] = { method: :start_tls }
end
end

Net::LDAP.new(ldap_options)
rescue StandardError => e
Rails.logger.error "LDAP: Error creating connection: #{e.message}"
nil
end

# Binds to LDAP server with admin credentials
# @param ldap [Net::LDAP] the LDAP connection
# @return [Boolean] true if bind was successful
def bind_as_admin(ldap)
if config[:admin_user].present? && config[:admin_password].present?
ldap.auth(config[:admin_user], config[:admin_password])
result = ldap.bind
unless result
Rails.logger.error "LDAP: Admin bind failed - #{ldap.get_operation_result.message}"
end
result
else
# If no admin credentials, attempt anonymous bind
true
end
end

# Finds the distinguished name (DN) for a user
# @param ldap [Net::LDAP] the LDAP connection
# @param login [String] the user's login identifier
# @return [String, nil] the user's DN or nil if not found
def find_user_dn(ldap, login)
filter = Net::LDAP::Filter.eq(config[:attribute], login)
result = ldap.search(
base: config[:base],
filter: filter,
attributes: ['dn']
)

if result && result.first
dn = result.first.dn
Rails.logger.info "LDAP: Found DN for user #{login}: #{dn}"
dn
else
Rails.logger.warn "LDAP: Could not find DN for user #{login}"
Comment on lines +165 to +168
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security concern: The Distinguished Name (DN) and login identifier are being logged. The DN may contain sensitive organizational information. Consider sanitizing or redacting this information in log messages, especially in production environments, to comply with security and privacy best practices.

Copilot uses AI. Check for mistakes.
nil
end
rescue StandardError => e
Rails.logger.error "LDAP: Error finding DN for #{login}: #{e.message}"
nil
end
end
end
12 changes: 0 additions & 12 deletions config/initializers/devise.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,6 @@
# Use this hook to configure devise mailer, warden hooks and so forth.
# Many of these configuration options can be set straight in your model.
Devise.setup do |config|
# ==> LDAP Configuration
config.ldap_logger = ENV['DEVISE_LDAP_LOGGER']
config.ldap_create_user = true
config.ldap_update_password = true
config.ldap_config = "#{Rails.root}/config/ldap.yml"
config.ldap_check_group_membership = false
config.ldap_check_group_membership_without_admin = false
config.ldap_check_attributes = false
config.ldap_check_attributes_presence = false
config.ldap_use_admin_to_bind = ENV['DEVISE_LDAP_LOGGER_ADMIN_BIND']
config.ldap_ad_group_check = false

# The secret key used by Devise. Devise uses this key to generate
# random tokens. Changing this key will render invalid all existing
# confirmation, reset password and unlock tokens in the database.
Expand Down
6 changes: 3 additions & 3 deletions config/initializers/doorkeeper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -371,11 +371,11 @@
user = nil

# LDAP IS ACTIVE
if ENV['DEVISE_LDAP_LOGGER'].present? && User.check_ldap_credentials(login, password) # ldap valide
if ActiveModel::Type::Boolean.new.cast(ENV['DEVISE_LDAP_LOGGER']) && User.check_ldap_credentials(login, password)
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The environment variable name 'DEVISE_LDAP_LOGGER' is misleading and inconsistent with its actual purpose. This variable is being used to enable/disable LDAP authentication, not configure logging. Consider renaming it to something more appropriate like 'ENABLE_LDAP_AUTH' or 'LDAP_ENABLED' to better reflect its function and improve code maintainability.

Suggested change
if ActiveModel::Type::Boolean.new.cast(ENV['DEVISE_LDAP_LOGGER']) && User.check_ldap_credentials(login, password)
ldap_enabled =
if ENV.key?('ENABLE_LDAP_AUTH')
ActiveModel::Type::Boolean.new.cast(ENV['ENABLE_LDAP_AUTH'])
else
# Backwards compatibility: fall back to legacy DEVISE_LDAP_LOGGER flag
ActiveModel::Type::Boolean.new.cast(ENV['DEVISE_LDAP_LOGGER'])
end
if ldap_enabled && User.check_ldap_credentials(login, password)

Copilot uses AI. Check for mistakes.
user = User.find_for_authentication(login: login)
# Check if user exists in database
# Check if the user exists in the database
if user.blank?
# Create user in database
# Create the user in the database
user = User.create_with_ldap(login)
end
end
Expand Down
73 changes: 28 additions & 45 deletions config/ldap.yml
Original file line number Diff line number Diff line change
@@ -1,31 +1,15 @@
## Authorizations
# Uncomment out the merging for each environment that you'd like to include.
# You can also just copy and paste the tree (do not include the "authorizations") to each
# environment if you need something different per environment.
authorizations: &AUTHORIZATIONS
allow_unauthenticated_bind: false
group_base: ou=groups,dc=test,dc=com
## Requires config.ldap_check_group_membership in devise.rb be true
# Can have multiple values, must match all to be authorized
required_groups:
# If only a group name is given, membership will be checked against "uniqueMember"
- cn=admins,ou=groups,dc=test,dc=com
- cn=users,ou=groups,dc=test,dc=com
# If an array is given, the first element will be the attribute to check against, the second the group name
- ["moreMembers", "cn=users,ou=groups,dc=test,dc=com"]
## Requires config.ldap_check_attributes in devise.rb to be true
## Can have multiple attributes and values, must match all to be authorized
require_attribute:
objectClass: inetOrgPerson
authorizationRole: postsAdmin
## Requires config.ldap_check_attributes_presence in devise.rb to be true
## Can have multiple attributes set to true or false to check presence, all must match all to be authorized
require_attribute_presence:
mail: true
telephoneNumber: true
serviceAccount: false

## Environment
# LDAP Configuration
# This file configures LDAP authentication settings for each environment.
# All sensitive values should be stored in Rails encrypted credentials.
#
# Required credentials for each environment:
# - ldap_host: LDAP server hostname
# - ldap_port: LDAP server port (typically 389 for non-SSL, 636 for SSL)
# - ldap_attribute: Search attribute (e.g., 'cn', 'uid', 'sAMAccountName')
# - ldap_base: Base DN for user searches (e.g., 'ou=people,dc=example,dc=com')
# - ldap_admin_user: Admin bind DN (e.g., 'cn=admin,dc=example,dc=com')
# - ldap_admin_user_password: Admin bind password
# - ldap_ssl: Encryption method ('simple_tls', 'start_tls', or false for no encryption)
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The configuration documentation states that ldap_ssl can be 'simple_tls', 'start_tls', or false for no encryption. However, the test environment defaults to 'simple_tls' as a string. Consider documenting that false should be passed as a boolean value, not the string 'false', to ensure proper encryption handling. The LdapAdapter code checks for presence with .present? which treats both empty strings and false differently.

Suggested change
# - ldap_ssl: Encryption method ('simple_tls', 'start_tls', or false for no encryption)
# - ldap_ssl: Encryption method ('simple_tls', 'start_tls', or boolean false for no encryption; use false, not the string 'false')

Copilot uses AI. Check for mistakes.

development:
host: <%= Rails.application.credentials.ldap_host %>
Expand All @@ -35,24 +19,23 @@ development:
admin_user: <%= Rails.application.credentials.ldap_admin_user %>
admin_password: <%= Rails.application.credentials.ldap_admin_user_password %>
ssl: <%= Rails.application.credentials.ldap_ssl %>
# <<: *AUTHORIZATIONS

test:
host: localhost
port: 3389
attribute: cn
base: ou=people,dc=test,dc=com
admin_user: cn=admin,dc=test,dc=com
admin_password: admin_password
ssl: simple_tls
# <<: *AUTHORIZATIONS
# Test environment can use mock LDAP server or real server configured via credentials
# If using mock server, update these values accordingly
host: <%= Rails.application.credentials.dig(:test, :ldap_host) || 'localhost' %>
port: <%= Rails.application.credentials.dig(:test, :ldap_port) || 389 %>
attribute: <%= Rails.application.credentials.dig(:test, :ldap_attribute) || 'cn' %>
base: <%= Rails.application.credentials.dig(:test, :ldap_base) || 'ou=people,dc=test,dc=com' %>
admin_user: <%= Rails.application.credentials.dig(:test, :ldap_admin_user) || 'cn=admin,dc=test,dc=com' %>
admin_password: <%= Rails.application.credentials.dig(:test, :ldap_admin_user_password) || 'admin_password' %>
ssl: <%= Rails.application.credentials.dig(:test, :ldap_ssl) || 'simple_tls' %>

production:
host: localhost
port: 636
attribute: cn
base: ou=people,dc=test,dc=com
admin_user: cn=admin,dc=test,dc=com
admin_password: admin_password
ssl: start_tls
# <<: *AUTHORIZATIONS
host: <%= Rails.application.credentials.ldap_host %>
port: <%= Rails.application.credentials.ldap_port %>
attribute: <%= Rails.application.credentials.ldap_attribute %>
base: <%= Rails.application.credentials.ldap_base %>
admin_user: <%= Rails.application.credentials.ldap_admin_user %>
admin_password: <%= Rails.application.credentials.ldap_admin_user_password %>
ssl: <%= Rails.application.credentials.ldap_ssl %>
Loading