# 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