mirror of
https://github.com/czhu12/canine.git
synced 2025-12-17 00:44:33 -06:00
289 lines
8.2 KiB
Ruby
289 lines
8.2 KiB
Ruby
# app/services/ldap/authenticator.rb
|
||
require 'net/ldap'
|
||
|
||
module LDAP
|
||
class Authenticator
|
||
Result = Struct.new(
|
||
:success?,
|
||
:email,
|
||
:name,
|
||
:user_dn,
|
||
:entry,
|
||
:groups,
|
||
:error_message,
|
||
keyword_init: true
|
||
)
|
||
|
||
def initialize(ldap_configuration, logger: Rails.logger)
|
||
@config = ldap_configuration
|
||
@logger = logger
|
||
end
|
||
|
||
# Public API
|
||
# ----------
|
||
# test_connection -> Result (tests bind credentials only)
|
||
# call(username:, password:) -> Result (full authentication)
|
||
#
|
||
def test_connection
|
||
# Validate required configuration
|
||
if config.host.blank?
|
||
return Result.new(success?: false, error_message: "Host is required")
|
||
end
|
||
|
||
if config.bind_dn.blank? && !config.allow_anonymous_reads?
|
||
return Result.new(success?: false, error_message: "Bind DN and password are required when anonymous reads are disabled")
|
||
end
|
||
|
||
reader_ldap = build_reader_connection
|
||
|
||
if reader_ldap.bind
|
||
Result.new(success?: true, error_message: nil)
|
||
else
|
||
msg = "LDAP bind failed: #{reader_ldap.get_operation_result.message}"
|
||
@logger.warn msg
|
||
Result.new(success?: false, error_message: msg)
|
||
end
|
||
rescue => e
|
||
@logger.error "LDAP test connection: unexpected error - #{e.class}: #{e.message}"
|
||
Result.new(success?: false, error_message: e.message)
|
||
end
|
||
|
||
def call(username:, password:, fetch_groups:)
|
||
# 1) Bind as reader (service account or anonymous)
|
||
reader_ldap = build_reader_connection
|
||
|
||
unless reader_ldap.bind
|
||
msg = "LDAP reader bind failed: #{reader_ldap.get_operation_result.message}"
|
||
@logger.warn msg
|
||
return Result.new(success?: false, error_message: msg)
|
||
end
|
||
|
||
# 2) Search for the user entry using uid_attribute + filter
|
||
entry = search_user_entry(reader_ldap, username)
|
||
|
||
if entry.nil?
|
||
msg = "LDAP search: no user entry found for username=#{username.inspect}"
|
||
@logger.info msg
|
||
return Result.new(success?: false, error_message: msg)
|
||
end
|
||
|
||
user_dn = entry.dn
|
||
|
||
# 3) Bind as the user to verify the password
|
||
auth_ldap = build_user_auth_connection(user_dn, password)
|
||
|
||
unless auth_ldap.bind
|
||
msg = "LDAP user bind failed for DN=#{user_dn}: #{auth_ldap.get_operation_result.message}"
|
||
@logger.info msg
|
||
return Result.new(success?: false, error_message: msg)
|
||
end
|
||
|
||
# 4) Successful LDAP auth → map attributes, fetch groups
|
||
email = resolve_email(entry, username)
|
||
name = resolve_name(entry, username)
|
||
if fetch_groups
|
||
groups = fetch_group_membership(entry)
|
||
else
|
||
groups = []
|
||
end
|
||
|
||
Result.new(
|
||
success?: true,
|
||
email: email,
|
||
name: name,
|
||
user_dn: user_dn,
|
||
entry: entry,
|
||
groups: groups,
|
||
error_message: nil
|
||
)
|
||
rescue => e
|
||
@logger.error "LDAP auth: unexpected error - #{e.class}: #{e.message}"
|
||
Result.new(success?: false, error_message: e.message)
|
||
end
|
||
|
||
private
|
||
|
||
attr_reader :config, :logger
|
||
|
||
# ---------------- CONNECTION HELPERS ----------------
|
||
|
||
def build_reader_connection
|
||
# If we have bind_dn/bind_password, use them.
|
||
# Otherwise, only allow anonymous if allow_anonymous_reads is true.
|
||
options = {
|
||
host: config.host,
|
||
port: config.port
|
||
}
|
||
|
||
encryption = net_ldap_encryption
|
||
options[:encryption] = encryption if encryption
|
||
|
||
if config.bind_dn.present? && config.bind_password.present?
|
||
options[:auth] = {
|
||
method: :simple,
|
||
username: config.bind_dn,
|
||
password: config.bind_password
|
||
}
|
||
elsif !config.allow_anonymous_reads?
|
||
# No way to bind safely
|
||
# Let caller see failure via bind result
|
||
logger.info "LDAP: no reader credentials and anonymous reads disabled"
|
||
raise "LDAP: no reader credentials and anonymous reads disabled"
|
||
end
|
||
|
||
Net::LDAP.new(options)
|
||
end
|
||
|
||
def build_user_auth_connection(user_dn, password)
|
||
options = {
|
||
host: config.host,
|
||
port: config.port
|
||
}
|
||
|
||
encryption = net_ldap_encryption
|
||
options[:encryption] = encryption if encryption
|
||
|
||
ldap = Net::LDAP.new(options)
|
||
ldap.auth(user_dn, password)
|
||
ldap
|
||
end
|
||
|
||
# Map your `encryption` enum to Net::LDAP’s expectations
|
||
#
|
||
# Adjust the case branches here to match your actual enum:
|
||
# enum encryption: { plain: 0, start_tls: 1, simple_tls: 2 }
|
||
#
|
||
def net_ldap_encryption
|
||
case config.encryption.to_s
|
||
when 'plain', 'none'
|
||
nil
|
||
when 'start_tls'
|
||
{ method: :start_tls }
|
||
when 'simple_tls', 'ssl'
|
||
{ method: :simple_tls }
|
||
else
|
||
nil
|
||
end
|
||
end
|
||
|
||
# ---------------- SEARCH ----------------
|
||
|
||
def search_user_entry(ldap, username)
|
||
uid_attr = config.uid_attribute.presence || 'uid'
|
||
|
||
user_filter = Net::LDAP::Filter.eq(uid_attr, username)
|
||
|
||
# config.filter is an LDAP filter string, e.g. "(objectClass=person)"
|
||
base_filter =
|
||
if config.filter.present?
|
||
Net::LDAP::Filter.construct(config.filter)
|
||
else
|
||
Net::LDAP::Filter.eq('objectClass', '*') # match anything if no filter given
|
||
end
|
||
|
||
filter = base_filter & user_filter
|
||
|
||
entry = nil
|
||
ldap.search(base: config.base_dn, filter: filter, size: 2) do |e|
|
||
entry = e
|
||
break
|
||
end
|
||
|
||
entry
|
||
end
|
||
|
||
# ---------------- ATTRIBUTE MAPPING ----------------
|
||
|
||
def resolve_email(entry, username)
|
||
attr = config.email_attribute.presence || 'mail'
|
||
|
||
if entry[attr].present?
|
||
entry[attr].first
|
||
else
|
||
# Fallback to username or constructed email
|
||
construct_email(username)
|
||
end
|
||
end
|
||
|
||
def resolve_name(entry, username)
|
||
attr = config.name_attribute.presence || 'cn'
|
||
|
||
if entry[attr].present?
|
||
entry[attr].first
|
||
elsif entry[:cn].present?
|
||
entry[:cn].first
|
||
else
|
||
username
|
||
end
|
||
end
|
||
|
||
def construct_email(username)
|
||
return username if username.include?('@')
|
||
|
||
domain = config.try(:mail_domain) || config.host
|
||
"#{username}@#{domain}"
|
||
end
|
||
|
||
def fetch_group_membership(user_entry)
|
||
reader_ldap = build_reader_connection
|
||
|
||
unless reader_ldap.bind
|
||
if config.allow_anonymous_reads?
|
||
logger.warn "LDAP group lookup: anonymous/reader bind failed: #{reader_ldap.get_operation_result.message}"
|
||
else
|
||
logger.info "LDAP group lookup skipped: cannot bind and anonymous reads disabled"
|
||
end
|
||
return []
|
||
end
|
||
|
||
groups = []
|
||
|
||
# From the entry
|
||
dn_from_entry = user_entry.dn
|
||
|
||
uid_attr = config.uid_attribute.presence || 'uid'
|
||
uid_val = Array(user_entry[uid_attr]).first
|
||
|
||
# This is the DN your groups seem to be using:
|
||
# uid=czhu,dc=example,dc=org
|
||
dn_from_uid = if uid_val.present?
|
||
"#{uid_attr}=#{uid_val},#{config.base_dn}"
|
||
end
|
||
|
||
member_filters = []
|
||
|
||
# Try DN from entry (cn=... case)
|
||
member_filters << Net::LDAP::Filter.eq('member', dn_from_entry) if dn_from_entry.present?
|
||
|
||
# Try DN built from uid (uid=... case – this is the one that works for you)
|
||
member_filters << Net::LDAP::Filter.eq('member', dn_from_uid) if dn_from_uid.present?
|
||
|
||
# Try memberUid=uid (posixGroup style)
|
||
member_filters << Net::LDAP::Filter.eq('memberUid', uid_val) if uid_val.present?
|
||
|
||
# If for some reason we have no filters, bail out
|
||
return [] if member_filters.empty?
|
||
|
||
member_filter = member_filters.reduce do |memo, f|
|
||
memo | f
|
||
end
|
||
|
||
group_filter = Net::LDAP::Filter.eq('objectClass', 'groupOfNames') |
|
||
Net::LDAP::Filter.eq('objectClass', 'groupOfUniqueNames') |
|
||
Net::LDAP::Filter.eq('objectClass', 'posixGroup')
|
||
|
||
combined_filter = group_filter & member_filter
|
||
|
||
reader_ldap.search(base: config.base_dn, filter: combined_filter) do |entry|
|
||
groups << { name: entry.cn.first }
|
||
end
|
||
|
||
logger.info "Found #{groups.size} LDAP groups for user #{dn_from_entry}"
|
||
groups
|
||
rescue => e
|
||
logger.error "LDAP group lookup error for #{dn_from_entry}: #{e.class}: #{e.message}"
|
||
[]
|
||
end
|
||
end
|
||
end
|