diff --git a/README.md b/README.md index 9fb9f71..982f68e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,20 @@ # ruby-ldapserver +## ZOURIT-ADMIN + +# dependencies +* gem install simple_ldap_authenticator net-ldap + +# server +* ruby rbslapd4.rb + +# client +* ruby client.rb + + +--------------- + + ruby-ldapserver is a lightweight, pure Ruby framework for implementing LDAP server applications. It is intended primarily for building a gateway from LDAP queries into some other protocol or database. It does not attempt to be a full or correct implementation of the standard LDAP data model itself (although you could build one using this as a frontend). Since it's written entirely in Ruby, it benefits from Ruby's threading engine. diff --git a/examples/client.rb b/examples/client.rb new file mode 100644 index 0000000..cb69c64 --- /dev/null +++ b/examples/client.rb @@ -0,0 +1,12 @@ +require 'simple_ldap_authenticator' + +DOMAIN = 'artcode' +TLD = 're' + +SimpleLdapAuthenticator.servers = ['127.0.0.1'] +SimpleLdapAuthenticator.port = 1389 +SimpleLdapAuthenticator.use_ssl = false +# SimpleLdapAuthenticator.login_format = '%s@mydomain.com' +SimpleLdapAuthenticator.login_format = "uid=%s,ou=Users,dc=#{DOMAIN},dc=#{TLD}" + +p SimpleLdapAuthenticator.valid?('admin', 'adminpassword') diff --git a/examples/rbslapd4.rb b/examples/rbslapd4.rb index 65eb368..5e704a1 100644 --- a/examples/rbslapd4.rb +++ b/examples/rbslapd4.rb @@ -17,16 +17,16 @@ $logger = Logger.new($stderr) class LDAPController def self.bind(request, version, dn, password, params) - $logger.debug "Catchall bind request" - raise LDAP::ResultError::UnwillingToPerform, "Invalid bind DN" + $logger.debug 'Catchall bind request' + raise LDAP::ResultError::UnwillingToPerform, 'Invalid bind DN' end def self.bindUser(request, version, dn, password, params) if params[:uid].nil? or - params[:uid] != 'admin' or - password != 'adminpassword' + params[:uid] != 'admin' or + password != 'adminpassword' $logger.warn "Denied access for user #{params[:uid]}: Invalid credentials" - raise LDAP::ResultError::InvalidCredentials, "Invalid credentials" + raise LDAP::ResultError::InvalidCredentials, 'Invalid credentials' end $logger.info "Authenticated user #{params[:uid]}" @@ -34,28 +34,28 @@ class LDAPController def self.search(request, baseObject, scope, deref, filter, params) $logger.info "Catchall search request for #{baseObject}" - raise LDAP::ResultError::UnwillingToPerform, "Invalid search DN" + raise LDAP::ResultError::UnwillingToPerform, 'Invalid search DN' end def self.searchUsers(request, baseObject, scope, deref, filter, params) - $logger.info "Search users" + $logger.info 'Search users' end end router = LDAP::Server::Router.new($logger) do # Different syntax but same thing - bind nil => "LDAPController#bind" - route :bind, nil => "LDAPController#bind" + bind nil => 'LDAPController#bind' + route :bind, nil => 'LDAPController#bind' # Bind a route using variables. A hash with the variables will be passed # to your function as last argument. - bind "uid=:uid,ou=Users,dc=mydomain,dc=com" => "LDAPController#bindUser" + bind 'uid=:uid,ou=Users,dc=mydomain,dc=com' => 'LDAPController#bindUser' + bind 'uid=:uid,ou=Users,dc=:domain,dc=:tld' => 'LDAPController#bindUser' - search nil => "LDAPController#search" - search "ou=Users,dc=mydomain,dc=com" => "LDAPController#searchUsers" + search nil => 'LDAPController#search' + search 'ou=Users,dc=mydomain,dc=com' => 'LDAPController#searchUsers' end - # This is the shared object which carries our actual directory entries. # It's just a hash of {dn=>entry}, where each entry is {attr=>[val,val,...]} @@ -65,26 +65,26 @@ directory = {} require 'yaml' begin - File.open("ldapdb.yaml") { |f| directory = YAML::load(f.read) } + File.open('ldapdb.yaml') { |f| directory = YAML.load(f.read) } rescue Errno::ENOENT end at_exit do - File.open("ldapdb.new","w") { |f| f.write(YAML::dump(directory)) } - File.rename("ldapdb.new","ldapdb.yaml") + File.open('ldapdb.new', 'w') { |f| f.write(YAML.dump(directory)) } + File.rename('ldapdb.new', 'ldapdb.yaml') end # Listen for incoming LDAP connections. For each one, create a Connection # object, which will invoke a HashOperation object for each request. s = LDAP::Server.new( - :port => 1389, - :nodelay => true, - :listen => 10, -# :ssl_key_file => "key.pem", -# :ssl_cert_file => "cert.pem", -# :ssl_on_connect => true, - :router => router + port: 1389, + nodelay: true, + listen: 10, + # :ssl_key_file => "key.pem", + # :ssl_cert_file => "cert.pem", + # :ssl_on_connect => true, + router: router ) s.run_tcpserver s.join diff --git a/lib/ldap/server/router.rb b/lib/ldap/server/router.rb index 6edc373..93e6d62 100644 --- a/lib/ldap/server/router.rb +++ b/lib/ldap/server/router.rb @@ -5,216 +5,209 @@ require 'ldap/server/request' require 'ldap/server/filter' module LDAP -class Server -class Router - @logger - @routes - - # Scope - BaseObject = 0 - SingleLevel = 1 - WholeSubtree = 2 - - # DerefAliases - NeverDerefAliases = 0 - DerefInSearching = 1 - DerefFindingBaseObj = 2 - DerefAlways = 3 - - def initialize(logger, &block) - @logger = logger - - @routes = Hash.new - @routes = Trie.new do |trie| - # Add an artificial LDAP component - trie << "op=bind" - trie << "op=search" - end - - self.instance_eval(&block) - end - - def log_exception(e, level = :error) - @logger.send level, e.message - e.backtrace.each { |line| @logger.send level, "\tfrom#{line}" } if e.backtrace - end - - ###################### - ### Initialization ### - ###################### - - def route(operation, hash) - hash.each do |key, value| - if key.nil? - @routes.insert "op=#{operation.to_s}", value - @logger.debug "map operation #{operation.to_s} all routes to #{value}" - else - @routes.insert "#{key},op=#{operation.to_s}", value - @logger.debug "map #{operation.to_s} #{key} to #{value}" + class Server + class Router + # Scope + BaseObject = 0 + SingleLevel = 1 + WholeSubtree = 2 + + # DerefAliases + NeverDerefAliases = 0 + DerefInSearching = 1 + DerefFindingBaseObj = 2 + DerefAlways = 3 + + def initialize(logger, &block) + @logger = logger + + @routes = {} + @routes = Trie.new do |trie| + # Add an artificial LDAP component + trie << 'op=bind' + trie << 'op=search' + end + + instance_eval(&block) end - end - end - - def method_missing(name, *args, &block) - if [:bind, :search, :add, :modify, :modifydn, :del, :compare].include? name - send :route, name, *args - else - super - end - end - - #################################################### - ### Methods to parse and route each request type ### - #################################################### - def parse_route(dn, method) - route, action = @routes.match("#{dn},op=#{method.to_s}") - if not route or route.empty? - @logger.warn "No route defined for \'#{route}\'" - raise LDAP::ResultError::UnwillingToPerform - end - if action.nil? - @logger.error "No action defined for route \'#{route}\'" - raise LDAP::ResultError::UnwillingToPerform - end - - class_name = action.split('#').first - method_name = action.split('#').last - - params = LDAP::Server::DN.new("#{dn},op=#{method.to_s}").parse(route) - - return class_name, method_name, params - end + def log_exception(e, level = :error) + @logger.send level, e.message + e.backtrace.each { |line| @logger.send level, "\tfrom#{line}" } if e.backtrace + end - def do_bind(connection, messageId, protocolOp, controls) # :nodoc: - request = Request.new(connection, messageId) - version = protocolOp.value[0].value - dn = protocolOp.value[1].value - dn = nil if dn.empty? - authentication = protocolOp.value[2] - - @logger.debug "subject:#{connection.binddn} predicate:bind object:#{dn}" - - # Find a route in the routing tree - class_name, method_name, params = parse_route(dn, :bind) - - case authentication.tag # tag_class == :CONTEXT_SPECIFIC (check why) - when 0 - Object.const_get(class_name).send method_name, request, version, dn, authentication.value, params - when 3 - mechanism = authentication.value[0].value - credentials = authentication.value[1].value - # sasl_bind(version, dn, mechanism, credentials) - # FIXME: needs to exchange further BindRequests - # route_sasl_bind(request, version, dn, mechanism, credentials) - raise LDAP::ResultError::AuthMethodNotSupported - else - raise LDAP::ResultError::ProtocolError, "BindRequest bad AuthenticationChoice" - end - request.send_BindResponse(0) - return dn, version - rescue NoMethodError => e - log_exception e - request.send_BindResponse(LDAP::ResultError::OperationsError.new.to_i, :errorMessage => e.message) - return nil, version - rescue LDAP::ResultError => e - log_exception e - request.send_BindResponse(e.to_i, :errorMessage => e.message) - return nil, version - end + ###################### + ### Initialization ### + ###################### + + def route(operation, hash) + hash.each do |key, value| + if key.nil? + @routes.insert "op=#{operation}", value + @logger.debug "map operation #{operation} all routes to #{value}" + else + @routes.insert "#{key},op=#{operation}", value + @logger.debug "map #{operation} #{key} to #{value}" + end + end + end - def do_search(connection, messageId, protocolOp, controls) # :nodoc: - request = Request.new(connection, messageId) - server = connection.opt[:server] - schema = connection.opt[:schema] - baseObject = protocolOp.value[0].value - scope = protocolOp.value[1].value - deref = protocolOp.value[2].value - client_sizelimit = protocolOp.value[3].value - client_timelimit = protocolOp.value[4].value.to_i - request.typesOnly = protocolOp.value[5].value - filter = LDAP::Server::Filter::parse(protocolOp.value[6], schema) - request.attributes = protocolOp.value[7].value.collect {|x| x.value} - - sizelimit = request.server_sizelimit - sizelimit = client_sizelimit if client_sizelimit > 0 and - (sizelimit.nil? or client_sizelimit < sizelimit) - request.sizelimit = sizelimit - - if baseObject.empty? and scope == BaseObject - request.send_SearchResultEntry("", server.root_dse) if - server.root_dse and LDAP::Server::Filter.run(filter, server.root_dse) - request.send_SearchResultDone(0) - return - elsif schema and baseObject == schema.subschema_dn - request.send_SearchResultEntry(baseObject, schema.subschema_subentry) if - schema and schema.subschema_subentry and - LDAP::Server::Filter.run(filter, schema.subschema_subentry) - request.send_SearchResultDone(0) - return - end + def method_missing(name, *args, &block) + if %i[bind search add modify modifydn del compare].include? name + send :route, name, *args + else + super + end + end - t = request.server_timelimit || 10 - t = client_timelimit if client_timelimit > 0 and client_timelimit < t + #################################################### + ### Methods to parse and route each request type ### + #################################################### + def parse_route(dn, method) + route, action = @routes.match("#{dn},op=#{method}") + if !route or route.empty? + @logger.warn "No route defined for \'#{route}\'" + raise LDAP::ResultError::UnwillingToPerform + end + if action.nil? + @logger.error "No action defined for route \'#{route}\'" + raise LDAP::ResultError::UnwillingToPerform + end + + class_name = action.split('#').first + method_name = action.split('#').last + + params = LDAP::Server::DN.new("#{dn},op=#{method}").parse(route) + + [class_name, method_name, params] + end - @logger.debug "subject:#{connection.binddn} predicate:search object:#{baseObject}" + def do_bind(connection, messageId, protocolOp, _controls) # :nodoc: + request = Request.new(connection, messageId) + version = protocolOp.value[0].value + dn = protocolOp.value[1].value + dn = nil if dn.empty? + authentication = protocolOp.value[2] + + @logger.debug "subject:#{connection.binddn} predicate:bind object:#{dn}" + + # Find a route in the routing tree + class_name, method_name, params = parse_route(dn, :bind) + + case authentication.tag # tag_class == :CONTEXT_SPECIFIC (check why) + when 0 + Object.const_get(class_name).send method_name, request, version, dn, authentication.value, params + when 3 + authentication.value[0].value + authentication.value[1].value + # sasl_bind(version, dn, mechanism, credentials) + # FIXME: needs to exchange further BindRequests + # route_sasl_bind(request, version, dn, mechanism, credentials) + raise LDAP::ResultError::AuthMethodNotSupported + else + raise LDAP::ResultError::ProtocolError, 'BindRequest bad AuthenticationChoice' + end + request.send_BindResponse(0) + [dn, version] + rescue NoMethodError => e + log_exception e + request.send_BindResponse(LDAP::ResultError::OperationsError.new.to_i, errorMessage: e.message) + [nil, version] + rescue LDAP::ResultError => e + log_exception e + request.send_BindResponse(e.to_i, errorMessage: e.message) + [nil, version] + end - # Find a route in the routing tree - class_name, method_name, params = parse_route(baseObject, :search) + def do_search(connection, messageId, protocolOp, _controls) # :nodoc: + request = Request.new(connection, messageId) + server = connection.opt[:server] + schema = connection.opt[:schema] + baseObject = protocolOp.value[0].value + scope = protocolOp.value[1].value + deref = protocolOp.value[2].value + client_sizelimit = protocolOp.value[3].value + client_timelimit = protocolOp.value[4].value.to_i + request.typesOnly = protocolOp.value[5].value + filter = LDAP::Server::Filter.parse(protocolOp.value[6], schema) + request.attributes = protocolOp.value[7].value.collect { |x| x.value } + + sizelimit = request.server_sizelimit + sizelimit = client_sizelimit if client_sizelimit > 0 and + (sizelimit.nil? or client_sizelimit < sizelimit) + request.sizelimit = sizelimit + + if baseObject.empty? and scope == BaseObject + request.send_SearchResultEntry('', server.root_dse) if + server.root_dse and LDAP::Server::Filter.run(filter, server.root_dse) + request.send_SearchResultDone(0) + return + elsif schema and baseObject == schema.subschema_dn + request.send_SearchResultEntry(baseObject, schema.subschema_subentry) if + schema and schema.subschema_subentry and + LDAP::Server::Filter.run(filter, schema.subschema_subentry) + request.send_SearchResultDone(0) + return + end + + t = request.server_timelimit || 10 + t = client_timelimit if client_timelimit > 0 and client_timelimit < t + + @logger.debug "subject:#{connection.binddn} predicate:search object:#{baseObject}" + + # Find a route in the routing tree + class_name, method_name, params = parse_route(baseObject, :search) + + Timeout.timeout(t, LDAP::ResultError::TimeLimitExceeded) do + Object.const_get(class_name).send method_name, request, baseObject, scope, deref, filter, params + end + request.send_SearchResultDone(0) + + # Note that TimeLimitExceeded is a subclass of LDAP::ResultError + rescue LDAP::ResultError => e + request.send_SearchResultDone(e.to_i, errorMessage: e.message) + rescue Abandon + # send no response + + # Since this Operation is running in its own thread, we have to + # catch all other exceptions. Otherwise, in the event of a programming + # error, this thread will silently terminate and the client will wait + # forever for a response. + rescue Exception => e + log_exception e + request.send_SearchResultDone(LDAP::ResultError::OperationsError.new.to_i, errorMessage: e.message) + end - Timeout::timeout(t, LDAP::ResultError::TimeLimitExceeded) do - Object.const_get(class_name).send method_name, request, baseObject, scope, deref, filter, params + ########################################################### + ### Methods to actually perform the work requested ### + ### Use the signatures below to write your own handlers ### + ########################################################### + + # Handle a simple bind request; raise an exception if the bind is + # not acceptable, otherwise just return to accept the bind. + # + # Write your own class method using this signature + + # def simple_bind(request, version, dn, password, params) + # if version != 3 + # raise LDAP::ResultError::ProtocolError, "version 3 only" + # end + # if dn + # raise LDAP::ResultError::InappropriateAuthentication, "This server only supports anonymous bind" + # end + # end + + # Handle a search request + # + # Call request. send_SearchResultEntry for each result found. Raise + # an exception if there is a problem. timeLimit, sizeLimit and + # typesOnly are taken care of, but you need to perform all + # authorisation checks yourself, using @connection.binddn + + # def search(basedn, scope, deref, filter) + # debug "search(#{basedn}, #{scope}, #{deref}, #{filter})" + # raise LDAP::ResultError::UnwillingToPerform, "search not implemented" + # end end - request.send_SearchResultDone(0) - - # Note that TimeLimitExceeded is a subclass of LDAP::ResultError - rescue LDAP::ResultError => e - request.send_SearchResultDone(e.to_i, :errorMessage=>e.message) - - rescue Abandon - # send no response - - # Since this Operation is running in its own thread, we have to - # catch all other exceptions. Otherwise, in the event of a programming - # error, this thread will silently terminate and the client will wait - # forever for a response. - - rescue Exception => e - log_exception e - request.send_SearchResultDone(LDAP::ResultError::OperationsError.new.to_i, :errorMessage=>e.message) end - - - ########################################################### - ### Methods to actually perform the work requested ### - ### Use the signatures below to write your own handlers ### - ########################################################### - - # Handle a simple bind request; raise an exception if the bind is - # not acceptable, otherwise just return to accept the bind. - # - # Write your own class method using this signature - -# def simple_bind(request, version, dn, password, params) -# if version != 3 -# raise LDAP::ResultError::ProtocolError, "version 3 only" -# end -# if dn -# raise LDAP::ResultError::InappropriateAuthentication, "This server only supports anonymous bind" -# end -# end - - # Handle a search request - # - # Call request. send_SearchResultEntry for each result found. Raise - # an exception if there is a problem. timeLimit, sizeLimit and - # typesOnly are taken care of, but you need to perform all - # authorisation checks yourself, using @connection.binddn - -# def search(basedn, scope, deref, filter) -# debug "search(#{basedn}, #{scope}, #{deref}, #{filter})" -# raise LDAP::ResultError::UnwillingToPerform, "search not implemented" -# end -end -end end diff --git a/lib/ldap/server/util.rb b/lib/ldap/server/util.rb index e6feaa3..edca1b4 100644 --- a/lib/ldap/server/util.rb +++ b/lib/ldap/server/util.rb @@ -1,88 +1,85 @@ require 'ldap/server/result' module LDAP -class Server + class Server + class Operation + # Return true if connection is not authenticated - class Operation - - # Return true if connection is not authenticated + def anonymous? + @connection.binddn.nil? + end - def anonymous? - @connection.binddn.nil? - end + # Split dn string into its component parts, returning + # [ {attr=>val}, {attr=>val}, ... ] + # + # This is pretty horrible legacy stuff from X500; see RFC2253 for the + # full gore. It's stupid that the LDAP protocol sends the DN in string + # form, rather than in ASN1 form (as it does with search filters, for + # example), even though the DN syntax is defined in terms of ASN1! + # + # Attribute names are downcased, but values are not. For any + # case-insensitive attributes it's up to you to downcase them. + # + # Note that only v2 clients should add extra space around the comma. + # This is accepted, and so is semicolon instead of comma, but the + # full RFC1779 backwards-compatibility rules (e.g. quoted values) + # are not implemented. + # + # I *think* these functions will work correctly with UTF8-encoded + # characters, given that a multibyte UTF8 character does not contain + # the bytes 00-7F and therefore we cannot confuse '\', '+' etc - # Split dn string into its component parts, returning - # [ {attr=>val}, {attr=>val}, ... ] - # - # This is pretty horrible legacy stuff from X500; see RFC2253 for the - # full gore. It's stupid that the LDAP protocol sends the DN in string - # form, rather than in ASN1 form (as it does with search filters, for - # example), even though the DN syntax is defined in terms of ASN1! - # - # Attribute names are downcased, but values are not. For any - # case-insensitive attributes it's up to you to downcase them. - # - # Note that only v2 clients should add extra space around the comma. - # This is accepted, and so is semicolon instead of comma, but the - # full RFC1779 backwards-compatibility rules (e.g. quoted values) - # are not implemented. - # - # I *think* these functions will work correctly with UTF8-encoded - # characters, given that a multibyte UTF8 character does not contain - # the bytes 00-7F and therefore we cannot confuse '\', '+' etc + def self.split_dn(dn) + # convert \\ to \5c, \+ to \2b etc + p "dn=#{dn}" + dn.gsub!(/\\([ #,+"\\<>;])/) { |match| format '\\%02x', match[1].ord } - def self.split_dn(dn) - # convert \\ to \5c, \+ to \2b etc - dn.gsub!(/\\([ #,+"\\<>;])/) { |match| format "\\%02x", match[1].ord } + # Now we know that \\ and \, do not exist, it's safe to split + parts = dn.split(/\s*[,;]\s*/) - # Now we know that \\ and \, do not exist, it's safe to split - parts = dn.split(/\s*[,;]\s*/) + parts.collect do |part| + res = {} - parts.collect do |part| - res = {} + # Split each part into attr=val+attr=val + avs = part.split(/\+/) - # Split each part into attr=val+attr=val - avs = part.split(/\+/) + avs.each do |av| + # These should all be of form attr=value + raise LDAP::ResultError::ProtocolError, "Bad DN component: #{av}" unless av =~ /^([^=]+)=(.*)$/ - avs.each do |av| - # These should all be of form attr=value - unless av =~ /^([^=]+)=(.*)$/ - raise LDAP::ResultError::ProtocolError, "Bad DN component: #{av}" + attr = ::Regexp.last_match(1).downcase + val = ::Regexp.last_match(2) + # Now we can decode those bits + attr.gsub!(/\\([a-f0-9][a-f0-9])/i) { ::Regexp.last_match(1).hex.chr } + val.gsub!(/\\([a-f0-9][a-f0-9])/i) { ::Regexp.last_match(1).hex.chr } + res[attr] = val end - attr, val = $1.downcase, $2 - # Now we can decode those bits - attr.gsub!(/\\([a-f0-9][a-f0-9])/i) { $1.hex.chr } - val.gsub!(/\\([a-f0-9][a-f0-9])/i) { $1.hex.chr } - res[attr] = val + res end - res end - end - # Reverse of split_dn. Join [elements...] - # where each element can be {attr=>val,...} or [[attr,val],...] - # or just [attr,val] + # Reverse of split_dn. Join [elements...] + # where each element can be {attr=>val,...} or [[attr,val],...] + # or just [attr,val] - def self.join_dn(elements) - dn = "" - elements.each do |elem| - av = "" - elem = [elem] if elem[0].is_a?(String) - elem.each do |attr,val| - av << "+" unless av == "" + def self.join_dn(elements) + dn = '' + elements.each do |elem| + av = '' + elem = [elem] if elem[0].is_a?(String) + elem.each do |attr, val| + av << '+' unless av == '' - av << attr << "=" << - val.sub(/^([# ])/, '\\\\\\1'). - sub(/( )$/, '\\\\\\1'). - gsub(/([,+"\\<>;])/, '\\\\\\1') + av << attr << '=' << + val.sub(/^([# ])/, '\\\\\\1') + .sub(/( )$/, '\\\\\\1') + .gsub(/([,+"\\<>;])/, '\\\\\\1') + end + dn << ',' unless dn == '' + dn << av end - dn << "," unless dn == "" - dn << av + dn end - dn - end - - end # class Operation - -end # class Server + end # class Operation + end # class Server end # module LDAP