lib/net/ldap.rb in net-ldap-0.0.5 vs lib/net/ldap.rb in net-ldap-0.1.0

- old
+ new

@@ -1,43 +1,17 @@ -# $Id$ -# -# Net::LDAP for Ruby -# -# -# Copyright (C) 2006 by Francis Cianfrocca. All Rights Reserved. -# -# Written and maintained by Francis Cianfrocca, gmail: garbagecat10. -# -# This program is free software. -# You may re-distribute and/or modify this program under the same terms -# as Ruby itself: Ruby Distribution License or GNU General Public License. -# -# -# See Net::LDAP for documentation and usage samples. -# - - -require 'socket' +require 'openssl' require 'ostruct' -begin - require 'openssl' - $net_ldap_openssl_available = true -rescue LoadError -end - require 'net/ber' require 'net/ldap/pdu' require 'net/ldap/filter' require 'net/ldap/dataset' require 'net/ldap/psw' require 'net/ldap/entry' +require 'net/ldap/core_ext/all' - module Net - - # == Net::LDAP # # This library provides a pure-Ruby implementation of the # LDAP client protocol, per RFC-2251. # It can be used to access any server which implements the @@ -255,50 +229,46 @@ # In general, Net::LDAP instance methods which invoke server operations make a connection # to the server when the method is called. They execute the operation (typically binding first) # and then disconnect from the server. The exception is Net::LDAP#open, which makes a connection # to the server and then keeps it open while it executes a user-supplied block. Net::LDAP#open # closes the connection on completion of the block. - # - class LDAP - class LdapError < StandardError; end - VERSION = "0.0.5" + VERSION = "0.1.0" - SearchScope_BaseObject = 0 SearchScope_SingleLevel = 1 SearchScope_WholeSubtree = 2 SearchScopes = [SearchScope_BaseObject, SearchScope_SingleLevel, SearchScope_WholeSubtree] - AsnSyntax = BER.compile_syntax({ + AsnSyntax = Net::BER.compile_syntax({ :application => { - :primitive => { - 2 => :null # UnbindRequest body - }, - :constructed => { - 0 => :array, # BindRequest - 1 => :array, # BindResponse - 2 => :array, # UnbindRequest - 3 => :array, # SearchRequest - 4 => :array, # SearchData - 5 => :array, # SearchResult - 6 => :array, # ModifyRequest - 7 => :array, # ModifyResponse - 8 => :array, # AddRequest - 9 => :array, # AddResponse - 10 => :array, # DelRequest - 11 => :array, # DelResponse - 12 => :array, # ModifyRdnRequest - 13 => :array, # ModifyRdnResponse - 14 => :array, # CompareRequest - 15 => :array, # CompareResponse - 16 => :array, # AbandonRequest - 19 => :array, # SearchResultReferral - 24 => :array, # Unsolicited Notification - } + :primitive => { + 2 => :null # UnbindRequest body + }, + :constructed => { + 0 => :array, # BindRequest + 1 => :array, # BindResponse + 2 => :array, # UnbindRequest + 3 => :array, # SearchRequest + 4 => :array, # SearchData + 5 => :array, # SearchResult + 6 => :array, # ModifyRequest + 7 => :array, # ModifyResponse + 8 => :array, # AddRequest + 9 => :array, # AddResponse + 10 => :array, # DelRequest + 11 => :array, # DelResponse + 12 => :array, # ModifyRdnRequest + 13 => :array, # ModifyRdnResponse + 14 => :array, # CompareRequest + 15 => :array, # CompareResponse + 16 => :array, # AbandonRequest + 19 => :array, # SearchResultReferral + 24 => :array, # Unsolicited Notification + } }, :context_specific => { :primitive => { 0 => :string, # password 1 => :string, # Kerberos v4 @@ -346,27 +316,21 @@ 53 => "Unwilling to perform", 65 => "Object Class Violation", 68 => "Entry Already Exists" } - module LdapControls PagedResults = "1.2.840.113556.1.4.319" # Microsoft evil from RFC 2696 end - - # # LDAP::result2string - # def LDAP::result2string code # :nodoc: ResultStrings[code] || "unknown result (#{code})" end - attr_accessor :host, :port, :base - # Instantiate an object of type Net::LDAP to perform directory operations. # This constructor takes a Hash containing arguments, all of which are either optional or may be specified later with other methods as described below. The following arguments # are supported: # * :host => the LDAP server's IP-address (default 127.0.0.1) # * :port => the LDAP server's TCP port (default 389) @@ -476,16 +440,15 @@ # unencrypted connections.]</i> # def encryption args case args when :simple_tls, :start_tls - args = {:method => args} + args = {:method => args} end @encryption = args end - # #open takes the same parameters as #new. #open makes a network connection to the # LDAP server and then passes a newly-created Net::LDAP object to the caller-supplied block. # Within the block, you can call any of the instance methods of Net::LDAP to # perform operations against the LDAP directory. #open will perform all the # operations in the user-supplied block on the same network connection, which @@ -502,44 +465,44 @@ def LDAP::open args ldap1 = LDAP.new args ldap1.open {|ldap| yield ldap } end - # Returns a meaningful result any time after - # a protocol operation (#bind, #search, #add, #modify, #rename, #delete) - # has completed. - # It returns an #OpenStruct containing an LDAP result code (0 means success), - # and a human-readable string. + # Returns a meaningful result any time after a protocol operation + # (#bind, #search, #add, #modify, #rename, #delete) has completed. + # It returns an #OpenStruct containing an LDAP result code (0 means + # success), and a human-readable string. + # # unless ldap.bind # puts "Result: #{ldap.get_operation_result.code}" # puts "Message: #{ldap.get_operation_result.message}" # end # - # Certain operations return additional information, accessible through members - # of the object returned from #get_operation_result. Check #get_operation_result.error_message - # and #get_operation_result.matched_dn. + # Certain operations return additional information, accessible through + # members of the object returned from #get_operation_result. Check + # #get_operation_result.error_message and + # #get_operation_result.matched_dn. # #-- - # Modified the implementation, 20Mar07. We might get a hash of LDAP response codes - # instead of a simple numeric code. - # + # Modified the implementation, 20Mar07. We might get a hash of LDAP + # response codes instead of a simple numeric code. + #++ def get_operation_result os = OpenStruct.new if @result.is_a?(Hash) - os.code = (@result[:resultCode] || "").to_i - os.error_message = @result[:errorMessage] - os.matched_dn = @result[:matchedDN] + os.code = (@result[:resultCode] || "").to_i + os.error_message = @result[:errorMessage] + os.matched_dn = @result[:matchedDN] elsif @result os.code = @result else os.code = 0 end os.message = LDAP.result2string( os.code ) os end - # Opens a network connection to the server and then # passes <tt>self</tt> to the caller-supplied block. The connection is # closed when the block completes. Used for executing multiple # LDAP operations without requiring a separate network connection # (and authentication) for each one. @@ -559,10 +522,11 @@ # First we make a connection and then a binding, but we don't # do anything with the bind results. # We then pass self to the caller's block, where he will execute # his LDAP operations. Of course they will all generate auth failures # if the bind was unsuccessful. + #++ def open raise LdapError.new( "open already in progress" ) if @open_connection begin @open_connection = Connection.new( :host => @host, :port => @port, :encryption => @encryption ) @open_connection.bind @auth @@ -571,11 +535,10 @@ @open_connection.close if @open_connection @open_connection = nil end end - # Searches the LDAP directory for directory entries. # Takes a hash argument with parameters. Supported parameters include: # * :base (a string specifying the tree-base for the search); # * :filter (an object of type Net::LDAP::Filter, defaults to objectclass=*); # * :attributes (a string or array of strings specifying the LDAP attributes to return from the server); @@ -649,11 +612,11 @@ # REINTERPRETED the result set, 04May06. Originally this was a hash # of entries keyed by DNs. But let's get away from making users # handle DNs. Change it to a plain array. Eventually we may # want to return a Dataset object that delegates to an internal # array, so we can provide sort methods and what-not. - # + #++ def search args = {} unless args[:ignore_server_caps] args[:paged_searches_supported] = paged_searches_supported? end @@ -742,12 +705,12 @@ # #-- # If there is an @open_connection, then perform the bind # on it. Otherwise, connect, bind, and disconnect. # The latter operation is obviously useful only as an auth check. - # - def bind auth=@auth + #++ + def bind(auth=@auth) if @open_connection @result = @open_connection.bind auth else begin conn = Connection.new( :host => @host, :port => @port , :encryption => @encryption) @@ -758,11 +721,10 @@ end @result == 0 end - # # #bind_as is for testing authentication credentials. # # As described under #bind, most LDAP servers require that you supply a complete DN # as a binding-credential, along with an authenticator such as a password. @@ -818,11 +780,10 @@ end } result end - # Adds a new entry to the remote LDAP server. # Supported arguments: # :dn :: Full DN of the new entry # :attributes :: Attributes of the new entry. # @@ -845,11 +806,11 @@ # ldap.add( :dn => dn, :attributes => attr ) # end #-- # Provisional modification: Connection#add returns a full hash with LDAP status values, # instead of the simple result number we're used to getting. - # + #++ def add args if @open_connection @result = @open_connection.add( args ) else @result = 0 @@ -863,11 +824,10 @@ end end @result == 0 end - # Modifies the attribute values of a particular entry on the LDAP directory. # Takes a hash with arguments. Supported arguments are: # :dn :: (the full DN of the entry whose attributes are to be modified) # :operations :: (the modifications to be performed, detailed next) # @@ -961,11 +921,10 @@ end end @result == 0 end - # Add a value to an attribute. # Takes the full DN of the entry to modify, # the name (Symbol or String) of the attribute, and the value (String or # Array). If the attribute does not exist (and there are no schema violations), # #add_attribute will create it with the caller-specified values. @@ -1015,11 +974,10 @@ # def delete_attribute dn, attribute modify :dn => dn, :operations => [[:delete, attribute, nil]] end - # Rename an entry on the remote DIS by changing the last RDN of its DN. # _Documentation_ _stub_ # def rename args if @open_connection @@ -1069,11 +1027,10 @@ end end @result == 0 end - # (Experimental, subject to change). # Return the rootDSE record from the LDAP server as a Net::LDAP::Entry, or an # empty Entry if the server doesn't return the record. #-- # cf. RFC4512 graf 5.1. @@ -1082,532 +1039,521 @@ # so it can be changed if desired. # The funky number-disagreements in the set of attribute names is correct per the RFC. # We may be called by #search itself, which may need to determine things like paged # search capabilities. So to avoid an infinite regress, set :ignore_server_caps, # which prevents us getting called recursively. - # + #++ def search_root_dse rs = search( :ignore_server_caps=>true, :base=>"", :scope=>SearchScope_BaseObject, :attributes=>[:namingContexts,:supportedLdapVersion,:altServer,:supportedControl,:supportedExtension,:supportedFeatures,:supportedSASLMechanisms] ) (rs and rs.first) or Entry.new end + # Return the root Subschema record from the LDAP server as a Net::LDAP::Entry, + # or an empty Entry if the server doesn't return the record. On success, the + # Net::LDAP::Entry returned from this call will have the attributes :dn, + # :objectclasses, and :attributetypes. If there is an error, call #get_operation_result + # for more information. + # + # ldap = Net::LDAP.new + # ldap.host = "your.ldap.host" + # ldap.auth "your-user-dn", "your-psw" + # subschema_entry = ldap.search_subschema_entry + # + # subschema_entry.attributetypes.each do |attrtype| + # # your code + # end + # + # subschema_entry.objectclasses.each do |attrtype| + # # your code + # end + #-- + # cf. RFC4512 section 4, particulary graff 4.4. + # The :dn attribute in the returned Entry is the subschema name as returned from + # the server. + # Set :ignore_server_caps, see the notes in search_root_dse. + #++ + def search_subschema_entry + rs = search( + :ignore_server_caps=>true, + :base=>"", + :scope=>SearchScope_BaseObject, + :attributes=>[:subschemaSubentry] + ) + return Entry.new unless (rs and rs.first) + subschema_name = rs.first.subschemasubentry + return Entry.new unless (subschema_name and subschema_name.first) - # Return the root Subschema record from the LDAP server as a Net::LDAP::Entry, - # or an empty Entry if the server doesn't return the record. On success, the - # Net::LDAP::Entry returned from this call will have the attributes :dn, - # :objectclasses, and :attributetypes. If there is an error, call #get_operation_result - # for more information. - # - # ldap = Net::LDAP.new - # ldap.host = "your.ldap.host" - # ldap.auth "your-user-dn", "your-psw" - # subschema_entry = ldap.search_subschema_entry - # - # subschema_entry.attributetypes.each do |attrtype| - # # your code - # end - # - # subschema_entry.objectclasses.each do |attrtype| - # # your code - # end - #-- - # cf. RFC4512 section 4, particulary graff 4.4. - # The :dn attribute in the returned Entry is the subschema name as returned from - # the server. - # Set :ignore_server_caps, see the notes in search_root_dse. - # - def search_subschema_entry - rs = search( - :ignore_server_caps=>true, - :base=>"", - :scope=>SearchScope_BaseObject, - :attributes=>[:subschemaSubentry] - ) - return Entry.new unless (rs and rs.first) - subschema_name = rs.first.subschemasubentry - return Entry.new unless (subschema_name and subschema_name.first) + rs = search( + :ignore_server_caps=>true, + :base=>subschema_name.first, + :scope=>SearchScope_BaseObject, + :filter=>"objectclass=subschema", + :attributes=>[:objectclasses, :attributetypes] + ) - rs = search( - :ignore_server_caps=>true, - :base=>subschema_name.first, - :scope=>SearchScope_BaseObject, - :filter=>"objectclass=subschema", - :attributes=>[:objectclasses, :attributetypes] - ) + (rs and rs.first) or Entry.new + end - (rs and rs.first) or Entry.new - end - - #-- # Convenience method to query server capabilities. # Only do this once per Net::LDAP object. # Note, we call a search, and we might be called from inside a search! # MUST refactor the root_dse call out. + #++ def paged_searches_supported? @server_caps ||= search_root_dse @server_caps[:supportedcontrol].include?(LdapControls::PagedResults) end - end # class LDAP - - class LDAP - # This is a private class used internally by the library. It should not be called by user code. - class Connection # :nodoc: + # This is a private class used internally by the library. It should not + # be called by user code. + class Connection # :nodoc: + LdapVersion = 3 + MaxSaslChallenges = 10 - LdapVersion = 3 - MaxSaslChallenges = 10 + def initialize server + begin + @conn = TCPSocket.new( server[:host], server[:port] ) + rescue + raise LdapError.new( "no connection to server" ) + end + if server[:encryption] + setup_encryption server[:encryption] + end - #-- - # initialize - # - def initialize server - begin - @conn = TCPSocket.new( server[:host], server[:port] ) - rescue - raise LdapError.new( "no connection to server" ) + yield self if block_given? end - if server[:encryption] - setup_encryption server[:encryption] + module GetbyteForSSLSocket + def getbyte + getc.ord + end end - yield self if block_given? - end - - - #-- - # Helper method called only from new, and only after we have a successfully-opened - # @conn instance variable, which is a TCP connection. - # Depending on the received arguments, we establish SSL, potentially replacing - # the value of @conn accordingly. - # Don't generate any errors here if no encryption is requested. - # DO raise LdapError objects if encryption is requested and we have trouble setting - # it up. That includes if OpenSSL is not set up on the machine. (Question: - # how does the Ruby OpenSSL wrapper react in that case?) - # DO NOT filter exceptions raised by the OpenSSL library. Let them pass back - # to the user. That should make it easier for us to debug the problem reports. - # Presumably (hopefully?) that will also produce recognizable errors if someone - # tries to use this on a machine without OpenSSL. - # - # The simple_tls method is intended as the simplest, stupidest, easiest solution - # for people who want nothing more than encrypted comms with the LDAP server. - # It doesn't do any server-cert validation and requires nothing in the way - # of key files and root-cert files, etc etc. - # OBSERVE: WE REPLACE the value of @conn, which is presumed to be a connected - # TCPSocket object. - # - # The start_tls method is supported by many servers over the standard LDAP port. - # It does not require an alternative port for encrypted communications, as with - # simple_tls. - # Thanks for Kouhei Sutou for generously contributing the :start_tls path. - # - def setup_encryption args - case args[:method] - when :simple_tls - raise LdapError.new("openssl unavailable") unless $net_ldap_openssl_available + def self.wrap_with_ssl(io) ctx = OpenSSL::SSL::SSLContext.new - @conn = OpenSSL::SSL::SSLSocket.new(@conn, ctx) - @conn.connect - @conn.sync_close = true - # additional branches requiring server validation and peer certs, etc. go here. - when :start_tls - raise LdapError.new("openssl unavailable") unless $net_ldap_openssl_available - msgid = next_msgid.to_ber - request = [StartTlsOid.to_ber].to_ber_appsequence( Net::LdapPdu::ExtendedRequest ) - request_pkt = [msgid, request].to_ber_sequence - @conn.write request_pkt - be = @conn.read_ber(AsnSyntax) - raise LdapError.new("no start_tls result") if be.nil? - pdu = Net::LdapPdu.new(be) - raise LdapError.new("no start_tls result") if pdu.nil? - if pdu.result_code.zero? - ctx = OpenSSL::SSL::SSLContext.new - @conn = OpenSSL::SSL::SSLSocket.new(@conn, ctx) - @conn.connect - @conn.sync_close = true - else - raise LdapError.new("start_tls failed: #{pdu.result_code}") - end - else - raise LdapError.new( "unsupported encryption method #{args[:method]}" ) + conn = OpenSSL::SSL::SSLSocket.new(io, ctx) + conn.connect + conn.sync_close = true + + conn.extend(GetbyteForSSLSocket) unless conn.respond_to?(:getbyte) + + conn end - end - #-- - # close - # This is provided as a convenience method to make - # sure a connection object gets closed without waiting - # for a GC to happen. Clients shouldn't have to call it, - # but perhaps it will come in handy someday. - def close - @conn.close - @conn = nil - end + #-- + # Helper method called only from new, and only after we have a successfully-opened + # @conn instance variable, which is a TCP connection. + # Depending on the received arguments, we establish SSL, potentially replacing + # the value of @conn accordingly. + # Don't generate any errors here if no encryption is requested. + # DO raise LdapError objects if encryption is requested and we have trouble setting + # it up. That includes if OpenSSL is not set up on the machine. (Question: + # how does the Ruby OpenSSL wrapper react in that case?) + # DO NOT filter exceptions raised by the OpenSSL library. Let them pass back + # to the user. That should make it easier for us to debug the problem reports. + # Presumably (hopefully?) that will also produce recognizable errors if someone + # tries to use this on a machine without OpenSSL. + # + # The simple_tls method is intended as the simplest, stupidest, easiest solution + # for people who want nothing more than encrypted comms with the LDAP server. + # It doesn't do any server-cert validation and requires nothing in the way + # of key files and root-cert files, etc etc. + # OBSERVE: WE REPLACE the value of @conn, which is presumed to be a connected + # TCPSocket object. + # + # The start_tls method is supported by many servers over the standard LDAP port. + # It does not require an alternative port for encrypted communications, as with + # simple_tls. + # Thanks for Kouhei Sutou for generously contributing the :start_tls path. + #++ + def setup_encryption args + case args[:method] + when :simple_tls + @conn = self.class.wrap_with_ssl(@conn) + # additional branches requiring server validation and peer certs, etc. go here. + when :start_tls + msgid = next_msgid.to_ber + request = [StartTlsOid.to_ber].to_ber_appsequence( Net::LdapPdu::ExtendedRequest ) + request_pkt = [msgid, request].to_ber_sequence + @conn.write request_pkt + be = @conn.read_ber(AsnSyntax) + raise LdapError.new("no start_tls result") if be.nil? + pdu = Net::LdapPdu.new(be) + raise LdapError.new("no start_tls result") if pdu.nil? + if pdu.result_code.zero? + @conn = self.class.wrap_with_ssl(@conn) + else + raise LdapError.new("start_tls failed: #{pdu.result_code}") + end + else + raise LdapError.new( "unsupported encryption method #{args[:method]}" ) + end + end - #-- - # next_msgid - # - def next_msgid - @msgid ||= 0 - @msgid += 1 - end + #-- + # close + # This is provided as a convenience method to make + # sure a connection object gets closed without waiting + # for a GC to happen. Clients shouldn't have to call it, + # but perhaps it will come in handy someday. + #++ + def close + @conn.close + @conn = nil + end - - #-- - # bind - # - def bind auth - meth = auth[:method] - if [:simple, :anonymous, :anon].include?( meth ) - bind_simple auth - elsif meth == :sasl - bind_sasl( auth ) - elsif meth == :gss_spnego - bind_gss_spnego( auth ) - else - raise LdapError.new( "unsupported auth method (#{meth})" ) + #-- + # next_msgid + #++ + def next_msgid + @msgid ||= 0 + @msgid += 1 end - end - #-- - # bind_simple - # Implements a simple user/psw authentication. - # Accessed by calling #bind with a method of :simple or :anonymous. - # - def bind_simple auth - user,psw = if auth[:method] == :simple - [auth[:username] || auth[:dn], auth[:password]] - else - ["",""] + #-- + # bind + #++ + def bind auth + meth = auth[:method] + if [:simple, :anonymous, :anon].include?( meth ) + bind_simple auth + elsif meth == :sasl + bind_sasl( auth ) + elsif meth == :gss_spnego + bind_gss_spnego( auth ) + else + raise LdapError.new( "unsupported auth method (#{meth})" ) + end end - raise LdapError.new( "invalid binding information" ) unless (user && psw) + #-- + # bind_simple + # Implements a simple user/psw authentication. + # Accessed by calling #bind with a method of :simple or :anonymous. + #++ + def bind_simple auth + user,psw = if auth[:method] == :simple + [auth[:username] || auth[:dn], auth[:password]] + else + ["",""] + end - msgid = next_msgid.to_ber - request = [LdapVersion.to_ber, user.to_ber, psw.to_ber_contextspecific(0)].to_ber_appsequence(0) - request_pkt = [msgid, request].to_ber_sequence - @conn.write request_pkt + raise LdapError.new( "invalid binding information" ) unless (user && psw) - (be = @conn.read_ber(AsnSyntax) and pdu = Net::LdapPdu.new( be )) or raise LdapError.new( "no bind result" ) - pdu.result_code - end - - #-- - # bind_sasl - # Required parameters: :mechanism, :initial_credential and :challenge_response - # Mechanism is a string value that will be passed in the SASL-packet's "mechanism" field. - # Initial credential is most likely a string. It's passed in the initial BindRequest - # that goes to the server. In some protocols, it may be empty. - # Challenge-response is a Ruby proc that takes a single parameter and returns an object - # that will typically be a string. The challenge-response block is called when the server - # returns a BindResponse with a result code of 14 (saslBindInProgress). The challenge-response - # block receives a parameter containing the data returned by the server in the saslServerCreds - # field of the LDAP BindResponse packet. The challenge-response block may be called multiple - # times during the course of a SASL authentication, and each time it must return a value - # that will be passed back to the server as the credential data in the next BindRequest packet. - # - def bind_sasl auth - mech,cred,chall = auth[:mechanism],auth[:initial_credential],auth[:challenge_response] - raise LdapError.new( "invalid binding information" ) unless (mech && cred && chall) - - n = 0 - loop { msgid = next_msgid.to_ber - sasl = [mech.to_ber, cred.to_ber].to_ber_contextspecific(3) - request = [LdapVersion.to_ber, "".to_ber, sasl].to_ber_appsequence(0) + request = [LdapVersion.to_ber, user.to_ber, psw.to_ber_contextspecific(0)].to_ber_appsequence(0) request_pkt = [msgid, request].to_ber_sequence @conn.write request_pkt (be = @conn.read_ber(AsnSyntax) and pdu = Net::LdapPdu.new( be )) or raise LdapError.new( "no bind result" ) - return pdu.result_code unless pdu.result_code == 14 # saslBindInProgress - raise LdapError.new("sasl-challenge overflow") if ((n += 1) > MaxSaslChallenges) + pdu.result_code + end - cred = chall.call( pdu.result_server_sasl_creds ) - } + #-- + # bind_sasl + # Required parameters: :mechanism, :initial_credential and :challenge_response + # Mechanism is a string value that will be passed in the SASL-packet's "mechanism" field. + # Initial credential is most likely a string. It's passed in the initial BindRequest + # that goes to the server. In some protocols, it may be empty. + # Challenge-response is a Ruby proc that takes a single parameter and returns an object + # that will typically be a string. The challenge-response block is called when the server + # returns a BindResponse with a result code of 14 (saslBindInProgress). The challenge-response + # block receives a parameter containing the data returned by the server in the saslServerCreds + # field of the LDAP BindResponse packet. The challenge-response block may be called multiple + # times during the course of a SASL authentication, and each time it must return a value + # that will be passed back to the server as the credential data in the next BindRequest packet. + #++ + def bind_sasl auth + mech,cred,chall = auth[:mechanism],auth[:initial_credential],auth[:challenge_response] + raise LdapError.new( "invalid binding information" ) unless (mech && cred && chall) - raise LdapError.new( "why are we here?") - end - private :bind_sasl + n = 0 + loop { + msgid = next_msgid.to_ber + sasl = [mech.to_ber, cred.to_ber].to_ber_contextspecific(3) + request = [LdapVersion.to_ber, "".to_ber, sasl].to_ber_appsequence(0) + request_pkt = [msgid, request].to_ber_sequence + @conn.write request_pkt - #-- - # bind_gss_spnego - # PROVISIONAL, only for testing SASL implementations. DON'T USE THIS YET. - # Uses Kohei Kajimoto's Ruby/NTLM. We have to find a clean way to integrate it without - # introducing an external dependency. - # This authentication method is accessed by calling #bind with a :method parameter of - # :gss_spnego. It requires :username and :password attributes, just like the :simple - # authentication method. It performs a GSS-SPNEGO authentication with the server, which - # is presumed to be a Microsoft Active Directory. - # - def bind_gss_spnego auth - require 'ntlm.rb' + (be = @conn.read_ber(AsnSyntax) and pdu = Net::LdapPdu.new( be )) or raise LdapError.new( "no bind result" ) + return pdu.result_code unless pdu.result_code == 14 # saslBindInProgress + raise LdapError.new("sasl-challenge overflow") if ((n += 1) > MaxSaslChallenges) - user,psw = [auth[:username] || auth[:dn], auth[:password]] - raise LdapError.new( "invalid binding information" ) unless (user && psw) + cred = chall.call( pdu.result_server_sasl_creds ) + } - nego = proc {|challenge| - t2_msg = NTLM::Message.parse( challenge ) - t3_msg = t2_msg.response( {:user => user, :password => psw}, {:ntlmv2 => true} ) - t3_msg.serialize - } + raise LdapError.new( "why are we here?") + end + private :bind_sasl - bind_sasl( { - :method => :sasl, - :mechanism => "GSS-SPNEGO", - :initial_credential => NTLM::Message::Type1.new.serialize, - :challenge_response => nego - }) - end - private :bind_gss_spnego + #-- + # bind_gss_spnego + # PROVISIONAL, only for testing SASL implementations. DON'T USE THIS YET. + # Uses Kohei Kajimoto's Ruby/NTLM. We have to find a clean way to integrate it without + # introducing an external dependency. + # This authentication method is accessed by calling #bind with a :method parameter of + # :gss_spnego. It requires :username and :password attributes, just like the :simple + # authentication method. It performs a GSS-SPNEGO authentication with the server, which + # is presumed to be a Microsoft Active Directory. + #++ + def bind_gss_spnego auth + require 'ntlm.rb' - #-- - # search - # Alternate implementation, this yields each search entry to the caller - # as it are received. - # TODO, certain search parameters are hardcoded. - # TODO, if we mis-parse the server results or the results are wrong, we can block - # forever. That's because we keep reading results until we get a type-5 packet, - # which might never come. We need to support the time-limit in the protocol. - #-- - # WARNING: this code substantially recapitulates the searchx method. - # - # 02May06: Well, I added support for RFC-2696-style paged searches. - # This is used on all queries because the extension is marked non-critical. - # As far as I know, only A/D uses this, but it's required for A/D. Otherwise - # you won't get more than 1000 results back from a query. - # This implementation is kindof clunky and should probably be refactored. - # Also, is it my imagination, or are A/Ds the slowest directory servers ever??? - # OpenLDAP newer than version 2.2.0 supports paged searches. - # - def search args = {} - search_filter = (args && args[:filter]) || Filter.eq( "objectclass", "*" ) - search_filter = Filter.construct(search_filter) if search_filter.is_a?(String) - search_base = (args && args[:base]) || "dc=example,dc=com" - search_attributes = ((args && args[:attributes]) || []).map {|attr| attr.to_s.to_ber} - return_referrals = args && args[:return_referrals] == true - sizelimit = (args && args[:size].to_i) || 0 - raise LdapError.new( "invalid search-size" ) unless sizelimit >= 0 - paged_searches_supported = (args && args[:paged_searches_supported]) + user,psw = [auth[:username] || auth[:dn], auth[:password]] + raise LdapError.new( "invalid binding information" ) unless (user && psw) - attributes_only = (args and args[:attributes_only] == true) - scope = args[:scope] || Net::LDAP::SearchScope_WholeSubtree - raise LdapError.new( "invalid search scope" ) unless SearchScopes.include?(scope) + nego = proc {|challenge| + t2_msg = NTLM::Message.parse( challenge ) + t3_msg = t2_msg.response( {:user => user, :password => psw}, {:ntlmv2 => true} ) + t3_msg.serialize + } - # An interesting value for the size limit would be close to A/D's built-in - # page limit of 1000 records, but openLDAP newer than version 2.2.0 chokes - # on anything bigger than 126. You get a silent error that is easily visible - # by running slapd in debug mode. Go figure. + bind_sasl( { + :method => :sasl, + :mechanism => "GSS-SPNEGO", + :initial_credential => NTLM::Message::Type1.new.serialize, + :challenge_response => nego + }) + end + private :bind_gss_spnego + + #-- + # search + # Alternate implementation, this yields each search entry to the caller + # as it are received. + # TODO, certain search parameters are hardcoded. + # TODO, if we mis-parse the server results or the results are wrong, we can block + # forever. That's because we keep reading results until we get a type-5 packet, + # which might never come. We need to support the time-limit in the protocol. + #-- + # WARNING: this code substantially recapitulates the searchx method. # - # Changed this around 06Sep06 to support a caller-specified search-size limit. - # Because we ALWAYS do paged searches, we have to work around the problem that - # it's not legal to specify a "normal" sizelimit (in the body of the search request) - # that is larger than the page size we're requesting. Unfortunately, I have the - # feeling that this will break with LDAP servers that don't support paged searches!!! - # (Because we pass zero as the sizelimit on search rounds when the remaining limit - # is larger than our max page size of 126. In these cases, I think the caller's - # search limit will be ignored!) - # CONFIRMED: This code doesn't work on LDAPs that don't support paged searches - # when the size limit is larger than 126. We're going to have to do a root-DSE record - # search and not do a paged search if the LDAP doesn't support it. Yuck. - # - rfc2696_cookie = [126, ""] - result_code = 0 - n_results = 0 + # 02May06: Well, I added support for RFC-2696-style paged searches. + # This is used on all queries because the extension is marked non-critical. + # As far as I know, only A/D uses this, but it's required for A/D. Otherwise + # you won't get more than 1000 results back from a query. + # This implementation is kindof clunky and should probably be refactored. + # Also, is it my imagination, or are A/Ds the slowest directory servers ever??? + # OpenLDAP newer than version 2.2.0 supports paged searches. + #++ + def search args = {} + search_filter = (args && args[:filter]) || Filter.eq( "objectclass", "*" ) + search_filter = Filter.construct(search_filter) if search_filter.is_a?(String) + search_base = (args && args[:base]) || "dc=example,dc=com" + search_attributes = ((args && args[:attributes]) || []).map {|attr| attr.to_s.to_ber} + return_referrals = args && args[:return_referrals] == true + sizelimit = (args && args[:size].to_i) || 0 + raise LdapError.new( "invalid search-size" ) unless sizelimit >= 0 + paged_searches_supported = (args && args[:paged_searches_supported]) - loop { - # should collect this into a private helper to clarify the structure + attributes_only = (args and args[:attributes_only] == true) + scope = args[:scope] || Net::LDAP::SearchScope_WholeSubtree + raise LdapError.new( "invalid search scope" ) unless SearchScopes.include?(scope) - query_limit = 0 - if sizelimit > 0 - if paged_searches_supported - query_limit = (((sizelimit - n_results) < 126) ? (sizelimit - n_results) : 0) - else - query_limit = sizelimit + # An interesting value for the size limit would be close to A/D's built-in + # page limit of 1000 records, but openLDAP newer than version 2.2.0 chokes + # on anything bigger than 126. You get a silent error that is easily visible + # by running slapd in debug mode. Go figure. + # + # Changed this around 06Sep06 to support a caller-specified search-size limit. + # Because we ALWAYS do paged searches, we have to work around the problem that + # it's not legal to specify a "normal" sizelimit (in the body of the search request) + # that is larger than the page size we're requesting. Unfortunately, I have the + # feeling that this will break with LDAP servers that don't support paged searches!!! + # (Because we pass zero as the sizelimit on search rounds when the remaining limit + # is larger than our max page size of 126. In these cases, I think the caller's + # search limit will be ignored!) + # CONFIRMED: This code doesn't work on LDAPs that don't support paged searches + # when the size limit is larger than 126. We're going to have to do a root-DSE record + # search and not do a paged search if the LDAP doesn't support it. Yuck. + # + rfc2696_cookie = [126, ""] + result_code = 0 + n_results = 0 + + loop { + # should collect this into a private helper to clarify the structure + + query_limit = 0 + if sizelimit > 0 + if paged_searches_supported + query_limit = (((sizelimit - n_results) < 126) ? (sizelimit - n_results) : 0) + else + query_limit = sizelimit + end end - end - request = [ - search_base.to_ber, - scope.to_ber_enumerated, - 0.to_ber_enumerated, - query_limit.to_ber, # size limit - 0.to_ber, - attributes_only.to_ber, - search_filter.to_ber, - search_attributes.to_ber_sequence - ].to_ber_appsequence(3) + request = [ + search_base.to_ber, + scope.to_ber_enumerated, + 0.to_ber_enumerated, + query_limit.to_ber, # size limit + 0.to_ber, + attributes_only.to_ber, + search_filter.to_ber, + search_attributes.to_ber_sequence + ].to_ber_appsequence(3) - controls = [ - [ - LdapControls::PagedResults.to_ber, - false.to_ber, # criticality MUST be false to interoperate with normal LDAPs. - rfc2696_cookie.map{|v| v.to_ber}.to_ber_sequence.to_s.to_ber - ].to_ber_sequence - ].to_ber_contextspecific(0) + controls = [ + [ + LdapControls::PagedResults.to_ber, + false.to_ber, # criticality MUST be false to interoperate with normal LDAPs. + rfc2696_cookie.map{|v| v.to_ber}.to_ber_sequence.to_s.to_ber + ].to_ber_sequence + ].to_ber_contextspecific(0) - pkt = [next_msgid.to_ber, request, controls].to_ber_sequence - @conn.write pkt + pkt = [next_msgid.to_ber, request, controls].to_ber_sequence + @conn.write pkt - result_code = 0 - controls = [] + result_code = 0 + controls = [] - while (be = @conn.read_ber(AsnSyntax)) && (pdu = LdapPdu.new( be )) - case pdu.app_tag - when 4 # search-data - n_results += 1 - yield( pdu.search_entry ) if block_given? - when 19 # search-referral - if return_referrals - if block_given? - se = Net::LDAP::Entry.new - se[:search_referrals] = (pdu.search_referrals || []) - yield se + while (be = @conn.read_ber(AsnSyntax)) && (pdu = LdapPdu.new( be )) + case pdu.app_tag + when 4 # search-data + n_results += 1 + yield( pdu.search_entry ) if block_given? + when 19 # search-referral + if return_referrals + if block_given? + se = Net::LDAP::Entry.new + se[:search_referrals] = (pdu.search_referrals || []) + yield se + end end + #p pdu.referrals + when 5 # search-result + result_code = pdu.result_code + controls = pdu.result_controls + break + else + raise LdapError.new( "invalid response-type in search: #{pdu.app_tag}" ) end - #p pdu.referrals - when 5 # search-result - result_code = pdu.result_code - controls = pdu.result_controls - break - else - raise LdapError.new( "invalid response-type in search: #{pdu.app_tag}" ) end - end - # When we get here, we have seen a type-5 response. - # If there is no error AND there is an RFC-2696 cookie, - # then query again for the next page of results. - # If not, we're done. - # Don't screw this up or we'll break every search we do. - # - # Noticed 02Sep06, look at the read_ber call in this loop, - # shouldn't that have a parameter of AsnSyntax? Does this - # just accidentally work? According to RFC-2696, the value - # expected in this position is of type OCTET STRING, covered - # in the default syntax supported by read_ber, so I guess - # we're ok. - # - more_pages = false - if result_code == 0 and controls - controls.each do |c| - if c.oid == LdapControls::PagedResults - more_pages = false # just in case some bogus server sends us >1 of these. - if c.value and c.value.length > 0 - cookie = c.value.read_ber[1] - if cookie and cookie.length > 0 - rfc2696_cookie[1] = cookie - more_pages = true + # When we get here, we have seen a type-5 response. + # If there is no error AND there is an RFC-2696 cookie, + # then query again for the next page of results. + # If not, we're done. + # Don't screw this up or we'll break every search we do. + # + # Noticed 02Sep06, look at the read_ber call in this loop, + # shouldn't that have a parameter of AsnSyntax? Does this + # just accidentally work? According to RFC-2696, the value + # expected in this position is of type OCTET STRING, covered + # in the default syntax supported by read_ber, so I guess + # we're ok. + # + more_pages = false + if result_code == 0 and controls + controls.each do |c| + if c.oid == LdapControls::PagedResults + more_pages = false # just in case some bogus server sends us >1 of these. + if c.value and c.value.length > 0 + cookie = c.value.read_ber[1] + if cookie and cookie.length > 0 + rfc2696_cookie[1] = cookie + more_pages = true + end end end end end - end - break unless more_pages - } # loop + break unless more_pages + } # loop - result_code - end + result_code + end + #-- + # modify + # TODO, need to support a time limit, in case the server fails to respond. + # TODO!!! We're throwing an exception here on empty DN. + # Should return a proper error instead, probaby from farther up the chain. + # TODO!!! If the user specifies a bogus opcode, we'll throw a + # confusing error here ("to_ber_enumerated is not defined on nil"). + #++ + def modify args + modify_dn = args[:dn] or raise "Unable to modify empty DN" + modify_ops = [] + a = args[:operations] and a.each {|op, attr, values| + # TODO, fix the following line, which gives a bogus error + # if the opcode is invalid. + op_1 = {:add => 0, :delete => 1, :replace => 2} [op.to_sym].to_ber_enumerated + modify_ops << [op_1, [attr.to_s.to_ber, values.to_a.map {|v| v.to_ber}.to_ber_set].to_ber_sequence].to_ber_sequence + } + request = [modify_dn.to_ber, modify_ops.to_ber_sequence].to_ber_appsequence(6) + pkt = [next_msgid.to_ber, request].to_ber_sequence + @conn.write pkt - #-- - # modify - # TODO, need to support a time limit, in case the server fails to respond. - # TODO!!! We're throwing an exception here on empty DN. - # Should return a proper error instead, probaby from farther up the chain. - # TODO!!! If the user specifies a bogus opcode, we'll throw a - # confusing error here ("to_ber_enumerated is not defined on nil"). - # - def modify args - modify_dn = args[:dn] or raise "Unable to modify empty DN" - modify_ops = [] - a = args[:operations] and a.each {|op, attr, values| - # TODO, fix the following line, which gives a bogus error - # if the opcode is invalid. - op_1 = {:add => 0, :delete => 1, :replace => 2} [op.to_sym].to_ber_enumerated - modify_ops << [op_1, [attr.to_s.to_ber, values.to_a.map {|v| v.to_ber}.to_ber_set].to_ber_sequence].to_ber_sequence - } + (be = @conn.read_ber(AsnSyntax)) && (pdu = LdapPdu.new( be )) && (pdu.app_tag == 7) or raise LdapError.new( "response missing or invalid" ) + pdu.result + end - request = [modify_dn.to_ber, modify_ops.to_ber_sequence].to_ber_appsequence(6) - pkt = [next_msgid.to_ber, request].to_ber_sequence - @conn.write pkt + #-- + # add + # TODO, need to support a time limit, in case the server fails to respond. + # Unlike other operation-methods in this class, we return a result hash rather + # than a simple result number. This is experimental, and eventually we'll want + # to do this with all the others. The point is to have access to the error message + # and the matched-DN returned by the server. + #++ + def add args + add_dn = args[:dn] or raise LdapError.new("Unable to add empty DN") + add_attrs = [] + a = args[:attributes] and a.each {|k,v| + add_attrs << [ k.to_s.to_ber, v.to_a.map {|m| m.to_ber}.to_ber_set ].to_ber_sequence + } - (be = @conn.read_ber(AsnSyntax)) && (pdu = LdapPdu.new( be )) && (pdu.app_tag == 7) or raise LdapError.new( "response missing or invalid" ) - pdu.result - end + request = [add_dn.to_ber, add_attrs.to_ber_sequence].to_ber_appsequence(8) + pkt = [next_msgid.to_ber, request].to_ber_sequence + @conn.write pkt + (be = @conn.read_ber(AsnSyntax)) && (pdu = LdapPdu.new( be )) && (pdu.app_tag == 9) or raise LdapError.new( "response missing or invalid" ) + pdu.result + end - #-- - # add - # TODO, need to support a time limit, in case the server fails to respond. - # Unlike other operation-methods in this class, we return a result hash rather - # than a simple result number. This is experimental, and eventually we'll want - # to do this with all the others. The point is to have access to the error message - # and the matched-DN returned by the server. - # - def add args - add_dn = args[:dn] or raise LdapError.new("Unable to add empty DN") - add_attrs = [] - a = args[:attributes] and a.each {|k,v| - add_attrs << [ k.to_s.to_ber, v.to_a.map {|m| m.to_ber}.to_ber_set ].to_ber_sequence - } + #-- + # rename + # TODO, need to support a time limit, in case the server fails to respond. + #++ + def rename args + old_dn = args[:olddn] or raise "Unable to rename empty DN" + new_rdn = args[:newrdn] or raise "Unable to rename to empty RDN" + delete_attrs = args[:delete_attributes] ? true : false - request = [add_dn.to_ber, add_attrs.to_ber_sequence].to_ber_appsequence(8) - pkt = [next_msgid.to_ber, request].to_ber_sequence - @conn.write pkt + request = [old_dn.to_ber, new_rdn.to_ber, delete_attrs.to_ber].to_ber_appsequence(12) + pkt = [next_msgid.to_ber, request].to_ber_sequence + @conn.write pkt - (be = @conn.read_ber(AsnSyntax)) && (pdu = LdapPdu.new( be )) && (pdu.app_tag == 9) or raise LdapError.new( "response missing or invalid" ) - pdu.result - end + (be = @conn.read_ber(AsnSyntax)) && (pdu = LdapPdu.new( be )) && (pdu.app_tag == 13) or raise LdapError.new( "response missing or invalid" ) + pdu.result_code + end + #-- + # delete + # TODO, need to support a time limit, in case the server fails to respond. + #++ + def delete args + dn = args[:dn] or raise "Unable to delete empty DN" - #-- - # rename - # TODO, need to support a time limit, in case the server fails to respond. - # - def rename args - old_dn = args[:olddn] or raise "Unable to rename empty DN" - new_rdn = args[:newrdn] or raise "Unable to rename to empty RDN" - delete_attrs = args[:delete_attributes] ? true : false + request = dn.to_s.to_ber_application_string(10) + pkt = [next_msgid.to_ber, request].to_ber_sequence + @conn.write pkt - request = [old_dn.to_ber, new_rdn.to_ber, delete_attrs.to_ber].to_ber_appsequence(12) - pkt = [next_msgid.to_ber, request].to_ber_sequence - @conn.write pkt - - (be = @conn.read_ber(AsnSyntax)) && (pdu = LdapPdu.new( be )) && (pdu.app_tag == 13) or raise LdapError.new( "response missing or invalid" ) - pdu.result_code - end - - - #-- - # delete - # TODO, need to support a time limit, in case the server fails to respond. - # - def delete args - dn = args[:dn] or raise "Unable to delete empty DN" - - request = dn.to_s.to_ber_application_string(10) - pkt = [next_msgid.to_ber, request].to_ber_sequence - @conn.write pkt - - (be = @conn.read_ber(AsnSyntax)) && (pdu = LdapPdu.new( be )) && (pdu.app_tag == 11) or raise LdapError.new( "response missing or invalid" ) - pdu.result_code - end - - - end # class Connection + (be = @conn.read_ber(AsnSyntax)) && (pdu = LdapPdu.new( be )) && (pdu.app_tag == 11) or raise LdapError.new( "response missing or invalid" ) + pdu.result_code + end + end # class Connection end # class LDAP - - end # module Net - -