client authentication from any domain

This commit is contained in:
pvincent
2026-06-09 06:06:57 +00:00
parent 79d31f61c4
commit 5b07e0ea9f
5 changed files with 313 additions and 296 deletions
+15
View File
@@ -1,5 +1,20 @@
# ruby-ldapserver # 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). 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. Since it's written entirely in Ruby, it benefits from Ruby's threading engine.
+12
View File
@@ -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')
+23 -23
View File
@@ -17,16 +17,16 @@ $logger = Logger.new($stderr)
class LDAPController class LDAPController
def self.bind(request, version, dn, password, params) def self.bind(request, version, dn, password, params)
$logger.debug "Catchall bind request" $logger.debug 'Catchall bind request'
raise LDAP::ResultError::UnwillingToPerform, "Invalid bind DN" raise LDAP::ResultError::UnwillingToPerform, 'Invalid bind DN'
end end
def self.bindUser(request, version, dn, password, params) def self.bindUser(request, version, dn, password, params)
if params[:uid].nil? or if params[:uid].nil? or
params[:uid] != 'admin' or params[:uid] != 'admin' or
password != 'adminpassword' password != 'adminpassword'
$logger.warn "Denied access for user #{params[:uid]}: Invalid credentials" $logger.warn "Denied access for user #{params[:uid]}: Invalid credentials"
raise LDAP::ResultError::InvalidCredentials, "Invalid credentials" raise LDAP::ResultError::InvalidCredentials, 'Invalid credentials'
end end
$logger.info "Authenticated user #{params[:uid]}" $logger.info "Authenticated user #{params[:uid]}"
@@ -34,28 +34,28 @@ class LDAPController
def self.search(request, baseObject, scope, deref, filter, params) def self.search(request, baseObject, scope, deref, filter, params)
$logger.info "Catchall search request for #{baseObject}" $logger.info "Catchall search request for #{baseObject}"
raise LDAP::ResultError::UnwillingToPerform, "Invalid search DN" raise LDAP::ResultError::UnwillingToPerform, 'Invalid search DN'
end end
def self.searchUsers(request, baseObject, scope, deref, filter, params) def self.searchUsers(request, baseObject, scope, deref, filter, params)
$logger.info "Search users" $logger.info 'Search users'
end end
end end
router = LDAP::Server::Router.new($logger) do router = LDAP::Server::Router.new($logger) do
# Different syntax but same thing # Different syntax but same thing
bind nil => "LDAPController#bind" bind nil => 'LDAPController#bind'
route :bind, nil => "LDAPController#bind" route :bind, nil => 'LDAPController#bind'
# Bind a route using variables. A hash with the variables will be passed # Bind a route using variables. A hash with the variables will be passed
# to your function as last argument. # 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 nil => 'LDAPController#search'
search "ou=Users,dc=mydomain,dc=com" => "LDAPController#searchUsers" search 'ou=Users,dc=mydomain,dc=com' => 'LDAPController#searchUsers'
end end
# This is the shared object which carries our actual directory entries. # 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,...]} # It's just a hash of {dn=>entry}, where each entry is {attr=>[val,val,...]}
@@ -65,26 +65,26 @@ directory = {}
require 'yaml' require 'yaml'
begin begin
File.open("ldapdb.yaml") { |f| directory = YAML::load(f.read) } File.open('ldapdb.yaml') { |f| directory = YAML.load(f.read) }
rescue Errno::ENOENT rescue Errno::ENOENT
end end
at_exit do at_exit do
File.open("ldapdb.new","w") { |f| f.write(YAML::dump(directory)) } File.open('ldapdb.new', 'w') { |f| f.write(YAML.dump(directory)) }
File.rename("ldapdb.new","ldapdb.yaml") File.rename('ldapdb.new', 'ldapdb.yaml')
end end
# Listen for incoming LDAP connections. For each one, create a Connection # Listen for incoming LDAP connections. For each one, create a Connection
# object, which will invoke a HashOperation object for each request. # object, which will invoke a HashOperation object for each request.
s = LDAP::Server.new( s = LDAP::Server.new(
:port => 1389, port: 1389,
:nodelay => true, nodelay: true,
:listen => 10, listen: 10,
# :ssl_key_file => "key.pem", # :ssl_key_file => "key.pem",
# :ssl_cert_file => "cert.pem", # :ssl_cert_file => "cert.pem",
# :ssl_on_connect => true, # :ssl_on_connect => true,
:router => router router: router
) )
s.run_tcpserver s.run_tcpserver
s.join s.join
+198 -205
View File
@@ -5,216 +5,209 @@ require 'ldap/server/request'
require 'ldap/server/filter' require 'ldap/server/filter'
module LDAP module LDAP
class Server class Server
class Router class Router
@logger # Scope
@routes BaseObject = 0
SingleLevel = 1
WholeSubtree = 2
# Scope # DerefAliases
BaseObject = 0 NeverDerefAliases = 0
SingleLevel = 1 DerefInSearching = 1
WholeSubtree = 2 DerefFindingBaseObj = 2
DerefAlways = 3
# DerefAliases def initialize(logger, &block)
NeverDerefAliases = 0 @logger = logger
DerefInSearching = 1
DerefFindingBaseObj = 2
DerefAlways = 3
def initialize(logger, &block) @routes = {}
@logger = logger @routes = Trie.new do |trie|
# Add an artificial LDAP component
trie << 'op=bind'
trie << 'op=search'
end
@routes = Hash.new instance_eval(&block)
@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}"
end 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}", 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 method_missing(name, *args, &block)
if %i[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}")
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
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
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
###########################################################
### 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 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 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
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
###########################################################
### 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 end
+65 -68
View File
@@ -1,88 +1,85 @@
require 'ldap/server/result' require 'ldap/server/result'
module LDAP module LDAP
class Server class Server
class Operation
# Return true if connection is not authenticated
class Operation def anonymous?
@connection.binddn.nil?
end
# Return true if connection is not authenticated # 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 anonymous? def self.split_dn(dn)
@connection.binddn.nil? # convert \\ to \5c, \+ to \2b etc
end p "dn=#{dn}"
dn.gsub!(/\\([ #,+"\\<>;])/) { |match| format '\\%02x', match[1].ord }
# Split dn string into its component parts, returning # Now we know that \\ and \, do not exist, it's safe to split
# [ {attr=>val}, {attr=>val}, ... ] parts = dn.split(/\s*[,;]\s*/)
#
# 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) parts.collect do |part|
# convert \\ to \5c, \+ to \2b etc res = {}
dn.gsub!(/\\([ #,+"\\<>;])/) { |match| format "\\%02x", match[1].ord }
# Now we know that \\ and \, do not exist, it's safe to split # Split each part into attr=val+attr=val
parts = dn.split(/\s*[,;]\s*/) avs = part.split(/\+/)
parts.collect do |part| avs.each do |av|
res = {} # These should all be of form attr=value
raise LDAP::ResultError::ProtocolError, "Bad DN component: #{av}" unless av =~ /^([^=]+)=(.*)$/
# Split each part into attr=val+attr=val attr = ::Regexp.last_match(1).downcase
avs = part.split(/\+/) val = ::Regexp.last_match(2)
# Now we can decode those bits
avs.each do |av| attr.gsub!(/\\([a-f0-9][a-f0-9])/i) { ::Regexp.last_match(1).hex.chr }
# These should all be of form attr=value val.gsub!(/\\([a-f0-9][a-f0-9])/i) { ::Regexp.last_match(1).hex.chr }
unless av =~ /^([^=]+)=(.*)$/ res[attr] = val
raise LDAP::ResultError::ProtocolError, "Bad DN component: #{av}"
end end
attr, val = $1.downcase, $2 res
# 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
end end
res
end end
end
# Reverse of split_dn. Join [elements...] # Reverse of split_dn. Join [elements...]
# where each element can be {attr=>val,...} or [[attr,val],...] # where each element can be {attr=>val,...} or [[attr,val],...]
# or just [attr,val] # or just [attr,val]
def self.join_dn(elements) def self.join_dn(elements)
dn = "" dn = ''
elements.each do |elem| elements.each do |elem|
av = "" av = ''
elem = [elem] if elem[0].is_a?(String) elem = [elem] if elem[0].is_a?(String)
elem.each do |attr,val| elem.each do |attr, val|
av << "+" unless av == "" av << '+' unless av == ''
av << attr << "=" << av << attr << '=' <<
val.sub(/^([# ])/, '\\\\\\1'). val.sub(/^([# ])/, '\\\\\\1')
sub(/( )$/, '\\\\\\1'). .sub(/( )$/, '\\\\\\1')
gsub(/([,+"\\<>;])/, '\\\\\\1') .gsub(/([,+"\\<>;])/, '\\\\\\1')
end
dn << ',' unless dn == ''
dn << av
end end
dn << "," unless dn == "" dn
dn << av
end end
dn end # class Operation
end end # class Server
end # class Operation
end # class Server
end # module LDAP end # module LDAP