├── .ci.gemfile ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG ├── LICENSE ├── README ├── Rakefile ├── lib └── simple_ldap_authenticator.rb ├── simple_ldap_authenticator.gemspec └── test ├── ldapserver.rb └── simple_ldap_authenticator_test.rb /.ci.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem "rake" 4 | gem "minitest-global_expectations" 5 | gem "net-ldap" 6 | gem "eventmachine" 7 | 8 | if RUBY_VERSION >= '3.4' 9 | # Needed for hidden dependency in net-ldap 10 | gem "base64" 11 | end 12 | 13 | if RUBY_VERSION >= '3.2' 14 | gem "ruby-ldap", :git => 'https://github.com/jeremyevans/ruby-ldap' 15 | else 16 | gem "ruby-ldap" 17 | end 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | tests: 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | os: [ubuntu-latest] 18 | ruby: [ "2.0.0", 2.1, 2.3, 2.4, 2.5, 2.6, 2.7, "3.0", 3.1, 3.2, 3.3, 3.4 ] 19 | include: 20 | - { os: ubuntu-22.04, ruby: "1.9.3" } 21 | runs-on: ${{ matrix.os }} 22 | name: ${{ matrix.ruby }} 23 | env: 24 | BUNDLE_GEMFILE: .ci.gemfile 25 | steps: 26 | - uses: actions/checkout@v4 27 | - run: sudo apt-get -yqq install libldap-dev libsasl2-dev 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{ matrix.ruby }} 31 | bundler-cache: true 32 | - run: bundle exec rake 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /rdoc 2 | /simple_ldap_authenticator-*.gem 3 | /coverage 4 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | = 1.2.0 (2024-09-16) 2 | 3 | * Do not require load_ldap_library be called before use of valid? (jeremyevans) (#3) 4 | 5 | * Explicitly check for null bytes in login and password in valid? (jeremyevans) 6 | 7 | = 1.1.0 (2022-07-19) 8 | 9 | * Make fallback to net/ldap work if ldap is not installed (jeremyevans) 10 | 11 | * Make default servers, use_ssl, and login_format settings apply (jeremyevans) 12 | 13 | * Work with newer net/ldap versions that use Net::LDAP::Error for errors (jeremyevans) 14 | 15 | * Avoid verbose mode warnings (jeremyevans) 16 | 17 | = 1.0.1 (2011-08-03) 18 | 19 | * Fix issue where an empty password would be accepted as a valid anonymous bind (jeremyevans) 20 | 21 | = 1.0.0 (2006-08-25) 22 | 23 | * Initial public release 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2006-2022 Jeremy Evans 2 | 3 | test/ldapserver.rb Copyright (c) 2006-2011 by Francis Cianfrocca and other contributors. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | SimpleLdapAuthenticator 2 | ======================= 3 | 4 | Allows for simple authentication to an LDAP server with a minimum of 5 | configuration. Requires either Ruby/LDAP or Net::LDAP. 6 | 7 | Example Usage: 8 | 9 | require 'simple_ldap_authenticator' 10 | require 'logger' 11 | 12 | SimpleLdapAuthenticator.servers = %w'dc1.domain.com dc2.domain.com' 13 | SimpleLdapAuthenticator.use_ssl = true 14 | SimpleLdapAuthenticator.login_format = '%s@domain.com' 15 | SimpleLdapAuthenticator.logger = Logger.new($stdout) 16 | 17 | SimpleLdapAuthenticator.valid?(username, password) 18 | # => true or false (or raise if there is an issue connecting to the server) 19 | 20 | github: http://github.com/jeremyevans/simple_ldap_authenticator 21 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/clean' 2 | 3 | CLEAN.include %w'*.gem coverage rdoc' 4 | 5 | desc "Package simple_ldap_authenticator" 6 | task :package do 7 | sh %{gem build simple_ldap_authenticator.gemspec} 8 | end 9 | 10 | ### Specs 11 | 12 | desc "Run tests" 13 | task :test do 14 | ruby = ENV['RUBY'] ||= FileUtils::RUBY 15 | sh "#{ruby} #{"-w" if RUBY_VERSION >= '3'} #{'-W:strict_unused_block' if RUBY_VERSION >= '3.4'} test/simple_ldap_authenticator_test.rb" 16 | end 17 | 18 | task :default => :test 19 | 20 | desc "Run tests with coverage" 21 | task :test_cov do 22 | ruby = ENV['RUBY'] ||= FileUtils::RUBY 23 | ENV['COVERAGE'] = '1' 24 | sh "#{ruby} test/simple_ldap_authenticator_test.rb" 25 | end 26 | 27 | ### RDoc 28 | 29 | desc "Generate rdoc" 30 | task :rdoc do 31 | rdoc_dir = "rdoc" 32 | rdoc_opts = ["--line-numbers", "--inline-source", '--title', 'simple_ldap_authenticator: Easy authentication to an LDAP server(s)'] 33 | 34 | begin 35 | gem 'hanna' 36 | rdoc_opts.concat(['-f', 'hanna']) 37 | rescue Gem::LoadError 38 | end 39 | 40 | rdoc_opts.concat(['--main', 'README', "-o", rdoc_dir] + 41 | %w"README CHANGELOG LICENSE lib/simple_ldap_authenticator.rb" 42 | ) 43 | 44 | FileUtils.rm_rf(rdoc_dir) 45 | 46 | require "rdoc" 47 | RDoc::RDoc.new.document(rdoc_opts) 48 | end 49 | -------------------------------------------------------------------------------- /lib/simple_ldap_authenticator.rb: -------------------------------------------------------------------------------- 1 | # SimpleLdapAuthenticator 2 | # 3 | # This plugin supports both Ruby/LDAP and Net::LDAP, defaulting to Ruby/LDAP 4 | # if it is available. If both are installed and you want to force the use of 5 | # Net::LDAP, set SimpleLdapAuthenticator.ldap_library = 'net/ldap'. 6 | 7 | # Allows for easily authenticating users via LDAP (or LDAPS). If authenticating 8 | # via LDAP to a server running on localhost, you should only have to configure 9 | # the login_format. 10 | # 11 | # Can be configured using the following accessors (with examples): 12 | # * login_format = '%s@domain.com' # Active Directory, OR 13 | # * login_format = 'cn=%s,cn=users,o=organization,c=us' # Other LDAP servers 14 | # * servers = ['dc1.domain.com', 'dc2.domain.com'] # names/addresses of LDAP servers to use 15 | # * use_ssl = true # for logging in via LDAPS 16 | # * port = 3289 # instead of 389 for LDAP or 636 for LDAPS 17 | # * logger = Logger.new($stdout) # for logging authentication successes/failures 18 | # 19 | # The class is used as a singleton, you are not supposed to create an 20 | # instance of it. For example: 21 | # 22 | # require 'simple_ldap_authenticator' 23 | # 24 | # SimpleLdapAuthenticator.servers = %w'dc1.domain.com dc2.domain.com' 25 | # SimpleLdapAuthenticator.use_ssl = true 26 | # SimpleLdapAuthenticator.login_format = '%s@domain.com' 27 | # 28 | # SimpleLdapAuthenticator.valid?(username, password) 29 | # # => true or false (or raise if there is an issue connecting to the server) 30 | class SimpleLdapAuthenticator 31 | @servers = ['127.0.0.1'] 32 | @use_ssl = false 33 | @login_format = '%s' 34 | 35 | class << self 36 | attr_accessor :servers, :use_ssl, :login_format, :logger, :ldap_library 37 | attr_writer :port, :connection 38 | 39 | # Load the required LDAP library, either 'ldap' or 'net/ldap' 40 | def load_ldap_library 41 | return if @ldap_library_loaded 42 | if @ldap_library 43 | if @ldap_library == 'net/ldap' 44 | require 'net/ldap' 45 | else 46 | require 'ldap' 47 | require 'ldap/control' 48 | end 49 | else 50 | begin 51 | require 'ldap' 52 | require 'ldap/control' 53 | @ldap_library = 'ldap' 54 | rescue LoadError 55 | require 'net/ldap' 56 | @ldap_library = 'net/ldap' 57 | end 58 | end 59 | @ldap_library_loaded = true 60 | end 61 | 62 | # The next LDAP server to which to connect 63 | def server 64 | servers[0] 65 | end 66 | 67 | # The connection to the LDAP server. A single connection is made and the 68 | # connection is only changed if a server returns an error other than 69 | # invalid password. 70 | def connection 71 | return @connection if @connection 72 | load_ldap_library 73 | @connection = if ldap_library == 'net/ldap' 74 | Net::LDAP.new(:host=>server, :port=>(port), :encryption=>(:simple_tls if use_ssl)) 75 | else 76 | (use_ssl ? LDAP::SSLConn : LDAP::Conn).new(server, port) 77 | end 78 | end 79 | 80 | # The port to use. Defaults to 389 for LDAP and 636 for LDAPS. 81 | def port 82 | @port ||= use_ssl ? 636 : 389 83 | end 84 | 85 | # Disconnect from current LDAP server and use a different LDAP server on the 86 | # next authentication attempt 87 | def switch_server 88 | self.connection = nil 89 | servers << servers.shift 90 | end 91 | 92 | # Check the validity of a login/password combination 93 | def valid?(login, password) 94 | login = login.to_s 95 | password = password.to_s 96 | connection = self.connection 97 | if password == '' || password.include?("\0") || login.include?("\0") 98 | false 99 | elsif ldap_library == 'net/ldap' 100 | connection.authenticate(login_format % login, password) 101 | begin 102 | if connection.bind 103 | logger.info("Authenticated #{login} by #{server}") if logger 104 | true 105 | else 106 | logger.info("Error attempting to authenticate #{login} by #{server}: #{connection.get_operation_result.code} #{connection.get_operation_result.message}") if logger 107 | switch_server unless connection.get_operation_result.code == 49 108 | false 109 | end 110 | rescue Net::LDAP::Error, SocketError, SystemCallError => error 111 | logger.info("Error attempting to authenticate #{login} by #{server}: #{error.message}") if logger 112 | switch_server 113 | false 114 | end 115 | else 116 | connection.unbind if connection.bound? 117 | begin 118 | connection.bind(login_format % login, password) 119 | connection.unbind 120 | logger.info("Authenticated #{login} by #{server}") if logger 121 | true 122 | rescue LDAP::ResultError => error 123 | connection.unbind if connection.bound? 124 | logger.info("Error attempting to authenticate #{login} by #{server}: #{error.message}") if logger 125 | switch_server unless error.message == 'Invalid credentials' 126 | false 127 | end 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /simple_ldap_authenticator.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = "simple_ldap_authenticator" 3 | s.version = "1.2.0" 4 | s.author = "Jeremy Evans" 5 | s.email = "code@jeremyevans.net" 6 | s.platform = Gem::Platform::RUBY 7 | s.summary = "Easy authentication to an LDAP server(s)" 8 | s.files = ["README", "LICENSE", "lib/simple_ldap_authenticator.rb"] 9 | s.extra_rdoc_files = ["LICENSE"] 10 | s.require_paths = ["lib"] 11 | s.rdoc_options = %w'--inline-source --line-numbers README lib' 12 | 13 | s.metadata = { 14 | 'bug_tracker_uri' => 'https://github.com/jeremyevans/simple_ldap_authenticator/issues', 15 | 'changelog_uri' => 'https://github.com/jeremyevans/simple_ldap_authenticator/blob/master/CHANGELOG', 16 | 'mailing_list_uri' => 'https://github.com/jeremyevans/simple_ldap_authenticator/discussions', 17 | "source_code_uri" => 'https://github.com/jeremyevans/simple_ldap_authenticator' 18 | } 19 | 20 | s.add_development_dependency "minitest-global_expectations" 21 | s.add_development_dependency "eventmachine" 22 | s.add_development_dependency "net-ldap" 23 | s.add_development_dependency "ruby-ldap" 24 | end 25 | -------------------------------------------------------------------------------- /test/ldapserver.rb: -------------------------------------------------------------------------------- 1 | # $Id$ 2 | # 3 | # Copyright (C) 2006 by Francis Cianfrocca. All Rights Reserved. 4 | # Gmail account: garbagecat10. 5 | # 6 | # This is an LDAP server intended for unit testing of Net::LDAP. 7 | # It implements as much of the protocol as we have the stomach 8 | # to implement but serves static data. Use ldapsearch to test 9 | # this server! 10 | # 11 | # To make this easier to write, we use the Ruby/EventMachine 12 | # reactor library. 13 | # 14 | 15 | #------------------------------------------------ 16 | 17 | module LdapServer 18 | LdapServerAsnSyntaxTemplate = { 19 | :application => { 20 | :constructed => { 21 | 0 => :array, # LDAP BindRequest 22 | 3 => :array # LDAP SearchRequest 23 | }, 24 | :primitive => { 25 | 2 => :string, # ldapsearch sends this to unbind 26 | }, 27 | }, 28 | :context_specific => { 29 | :primitive => { 30 | 0 => :string, # simple auth (password) 31 | 7 => :string # present filter 32 | }, 33 | :constructed => { 34 | 3 => :array # equality filter 35 | }, 36 | }, 37 | } 38 | 39 | def post_init 40 | #$logger.info "Accepted LDAP connection" 41 | @authenticated = false 42 | end 43 | 44 | def receive_data data 45 | @data ||= ""; @data << data 46 | while pdu = @data.read_ber!(LdapServerAsnSyntax) 47 | begin 48 | handle_ldap_pdu pdu 49 | rescue 50 | #$logger.error "closing connection due to error #{$!}" 51 | close_connection 52 | end 53 | end 54 | end 55 | 56 | def handle_ldap_pdu pdu 57 | tag_id = pdu[1].ber_identifier 58 | case tag_id 59 | when 0x60 60 | handle_bind_request pdu 61 | when 0x63 62 | handle_search_request pdu 63 | when 0x42 64 | # bizarre thing, it's a null object (primitive application-2) 65 | # sent by ldapsearch to request an unbind (or a kiss-off, not sure which) 66 | close_connection_after_writing 67 | else 68 | #$logger.error "received unknown packet-type #{tag_id}" 69 | close_connection_after_writing 70 | end 71 | end 72 | 73 | def handle_bind_request pdu 74 | # TODO, return a proper LDAP error instead of blowing up on version error 75 | if pdu[1][0] != 3 || pdu[1][1] == "bad_version" 76 | send_ldap_response 1, pdu[0].to_i, 2, "", "We only support version 3" 77 | elsif pdu[1][1] != "user" 78 | send_ldap_response 1, pdu[0].to_i, 48, "", "Who are you?" 79 | elsif pdu[1][2].ber_identifier != 0x80 80 | send_ldap_response 1, pdu[0].to_i, 7, "", "Keep it simple, man" 81 | elsif pdu[1][2] != "password" 82 | send_ldap_response 1, pdu[0].to_i, 49, "", "Make my day" 83 | else 84 | @authenticated = true 85 | send_ldap_response 1, pdu[0].to_i, 0, pdu[1][1], "I'll take it" 86 | end 87 | end 88 | 89 | # -- 90 | # Search Response ::= 91 | # CHOICE { 92 | # entry [APPLICATION 4] SEQUENCE { 93 | # objectName LDAPDN, 94 | # attributes SEQUENCE OF SEQUENCE { 95 | # AttributeType, 96 | # SET OF AttributeValue 97 | # } 98 | # }, 99 | # resultCode [APPLICATION 5] LDAPResult 100 | # } 101 | def handle_search_request pdu 102 | unless @authenticated 103 | # NOTE, early exit. 104 | send_ldap_response 5, pdu[0].to_i, 50, "", "Who did you say you were?" 105 | return 106 | end 107 | 108 | treebase = pdu[1][0] 109 | if treebase != "dc=bayshorenetworks,dc=com" 110 | send_ldap_response 5, pdu[0].to_i, 32, "", "unknown treebase" 111 | return 112 | end 113 | 114 | msgid = pdu[0].to_i.to_ber 115 | 116 | # pdu[1][7] is the list of requested attributes. 117 | # If it's an empty array, that means that *all* attributes were requested. 118 | requested_attrs = if pdu[1][7].length > 0 119 | pdu[1][7].map(&:downcase) 120 | else 121 | :all 122 | end 123 | 124 | filters = pdu[1][6] 125 | if filters.length == 0 126 | # NOTE, early exit. 127 | send_ldap_response 5, pdu[0].to_i, 53, "", "No filter specified" 128 | end 129 | 130 | # TODO, what if this returns nil? 131 | filter = Net::LDAP::Filter.parse_ldap_filter(filters) 132 | 133 | $ldif.each do |dn, entry| 134 | if filter.match(entry) 135 | attrs = [] 136 | entry.each do |k, v| 137 | if requested_attrs == :all || requested_attrs.include?(k.downcase) 138 | attrvals = v.map(&:to_ber).to_ber_set 139 | attrs << [k.to_ber, attrvals].to_ber_sequence 140 | end 141 | end 142 | 143 | appseq = [dn.to_ber, attrs.to_ber_sequence].to_ber_appsequence(4) 144 | pkt = [msgid.to_ber, appseq].to_ber_sequence 145 | send_data pkt 146 | end 147 | end 148 | 149 | send_ldap_response 5, pdu[0].to_i, 0, "", "Was that what you wanted?" 150 | end 151 | 152 | def send_ldap_response pkt_tag, msgid, code, dn, text 153 | send_data([msgid.to_ber, [code.to_ber, dn.to_ber, text.to_ber].to_ber_appsequence(pkt_tag)].to_ber) 154 | end 155 | end 156 | 157 | #------------------------------------------------ 158 | 159 | # Rather bogus, a global method, which reads a HARDCODED filename 160 | # parses out LDIF data. It will be used to serve LDAP queries out of this server. 161 | # 162 | def load_test_data 163 | ary = (<