Pleroma.LDAP

This adds a GenServer which will keep an LDAP connection open and auto reconnect on failure with a 5 second wait between retries. Another benefit is this prevents parsing the Root CAs for every login attempt as we only need to do it once per connection.
This commit is contained in:
Mark Felder 2024-09-16 00:26:57 -04:00
parent e7176bb998
commit 9264b21907
3 changed files with 236 additions and 145 deletions

View file

@ -94,6 +94,7 @@ defmodule Pleroma.Application do
children = children =
[ [
Pleroma.PromEx, Pleroma.PromEx,
Pleroma.LDAP,
Pleroma.Repo, Pleroma.Repo,
Config.TransferTask, Config.TransferTask,
Pleroma.Emoji, Pleroma.Emoji,

233
lib/pleroma/ldap.ex Normal file
View file

@ -0,0 +1,233 @@
defmodule Pleroma.LDAP do
use GenServer
require Logger
alias Pleroma.Config
alias Pleroma.User
import Pleroma.Web.Auth.Helpers, only: [fetch_user: 1]
@connection_timeout 10_000
@search_timeout 10_000
def start_link(_) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
@impl true
def init(state) do
case {Config.get(Pleroma.Web.Auth.Authenticator), Config.get([:ldap, :enabled])} do
{Pleroma.Web.Auth.LDAPAuthenticator, true} ->
{:ok, state, {:continue, :connect}}
{Pleroma.Web.Auth.LDAPAuthenticator, false} ->
Logger.error(
"LDAP Authenticator enabled but :pleroma, :ldap is not enabled. Auth will not work."
)
{:ok, state}
{_, true} ->
Logger.warning(
":pleroma, :ldap is enabled but Pleroma.Web.Authenticator is not set to the LDAPAuthenticator. LDAP will not be used."
)
{:ok, state}
end
end
@impl true
def handle_continue(:connect, _state), do: do_handle_connect()
@impl true
def handle_info(:connect, _state), do: do_handle_connect()
def handle_info({:bind_after_reconnect, name, password, from}, state) do
result = bind_user(state[:connection], name, password)
GenServer.reply(from, result)
{:noreply, state}
end
defp do_handle_connect() do
state =
case connect() do
{:ok, connection} ->
:eldap.controlling_process(connection, self())
[connection: connection]
_ ->
Logger.error("Failed to connect to LDAP. Retrying in 5000ms")
Process.send_after(self(), :connect, 5_000)
[]
end
{:noreply, state}
end
@impl true
def handle_call({:bind_user, name, password}, from, state) do
case bind_user(state[:connection], name, password) do
:needs_reconnect ->
Process.send(self(), {:bind_after_reconnect, name, password, from}, [])
{:noreply, state, {:continue, :connect}}
result ->
{:reply, result, state, :hibernate}
end
end
@impl true
def terminate(_, state) do
:eldap.close(state[:connection])
:ok
end
defp connect() do
ldap = Config.get(:ldap, [])
host = Keyword.get(ldap, :host, "localhost")
port = Keyword.get(ldap, :port, 389)
ssl = Keyword.get(ldap, :ssl, false)
tls = Keyword.get(ldap, :tls, false)
cacertfile = Keyword.get(ldap, :cacertfile) || CAStore.file_path()
default_secure_opts = [
verify: :verify_peer,
cacerts: decode_certfile(cacertfile),
customize_hostname_check: [
fqdn_fun: fn _ -> to_charlist(host) end
]
]
sslopts = Keyword.merge(default_secure_opts, Keyword.get(ldap, :sslopts, []))
tlsopts = Keyword.merge(default_secure_opts, Keyword.get(ldap, :tlsopts, []))
default_options = [{:port, port}, {:ssl, ssl}, {:timeout, @connection_timeout}]
# :sslopts can only be included in :eldap.open/2 when {ssl: true}
# or the connection will fail
options =
if ssl do
default_options ++ [{:sslopts, sslopts}]
else
default_options
end
case :eldap.open([to_charlist(host)], options) do
{:ok, connection} ->
try do
cond do
ssl ->
:application.ensure_all_started(:ssl)
{:ok, connection}
tls ->
case :eldap.start_tls(
connection,
tlsopts,
@connection_timeout
) do
:ok ->
{:ok, connection}
error ->
Logger.error("Could not start TLS: #{inspect(error)}")
:eldap.close(connection)
end
true ->
{:ok, :connection}
end
after
:ok
end
{:error, error} ->
Logger.error("Could not open LDAP connection: #{inspect(error)}")
{:error, {:ldap_connection_error, error}}
end
end
defp bind_user(connection, name, password) do
uid = Config.get([:ldap, :uid], "cn")
base = Config.get([:ldap, :base])
case :eldap.simple_bind(connection, "#{uid}=#{name},#{base}", password) do
:ok ->
case fetch_user(name) do
%User{} = user ->
user
_ ->
register_user(connection, base, uid, name)
end
# eldap does not inform us of socket closure
# until it is used
{:error, {:gen_tcp_error, :closed}} ->
:eldap.close(connection)
:needs_reconnect
error ->
Logger.error("Could not bind LDAP user #{name}: #{inspect(error)}")
{:error, {:ldap_bind_error, error}}
end
end
defp register_user(connection, base, uid, name) do
case :eldap.search(connection, [
{:base, to_charlist(base)},
{:filter, :eldap.equalityMatch(to_charlist(uid), to_charlist(name))},
{:scope, :eldap.wholeSubtree()},
{:timeout, @search_timeout}
]) do
# The :eldap_search_result record structure changed in OTP 24.3 and added a controls field
# https://github.com/erlang/otp/pull/5538
{:ok, {:eldap_search_result, [{:eldap_entry, _object, attributes}], _referrals}} ->
try_register(name, attributes)
{:ok, {:eldap_search_result, [{:eldap_entry, _object, attributes}], _referrals, _controls}} ->
try_register(name, attributes)
error ->
Logger.error("Couldn't register user because LDAP search failed: #{inspect(error)}")
{:error, {:ldap_search_error, error}}
end
end
defp try_register(name, attributes) do
params = %{
name: name,
nickname: name,
password: nil
}
params =
case List.keyfind(attributes, ~c"mail", 0) do
{_, [mail]} -> Map.put_new(params, :email, :erlang.list_to_binary(mail))
_ -> params
end
changeset = User.register_changeset_ldap(%User{}, params)
case User.register(changeset) do
{:ok, user} -> user
error -> error
end
end
defp decode_certfile(file) do
with {:ok, data} <- File.read(file) do
data
|> :public_key.pem_decode()
|> Enum.map(fn {_, b, _} -> b end)
else
_ ->
Logger.error("Unable to read certfile: #{file}")
[]
end
end
end

View file

@ -5,16 +5,11 @@
defmodule Pleroma.Web.Auth.LDAPAuthenticator do defmodule Pleroma.Web.Auth.LDAPAuthenticator do
alias Pleroma.User alias Pleroma.User
require Logger import Pleroma.Web.Auth.Helpers, only: [fetch_credentials: 1]
import Pleroma.Web.Auth.Helpers, only: [fetch_credentials: 1, fetch_user: 1]
@behaviour Pleroma.Web.Auth.Authenticator @behaviour Pleroma.Web.Auth.Authenticator
@base Pleroma.Web.Auth.PleromaAuthenticator @base Pleroma.Web.Auth.PleromaAuthenticator
@connection_timeout 10_000
@search_timeout 10_000
defdelegate get_registration(conn), to: @base defdelegate get_registration(conn), to: @base
defdelegate create_from_registration(conn, registration), to: @base defdelegate create_from_registration(conn, registration), to: @base
defdelegate handle_error(conn, error), to: @base defdelegate handle_error(conn, error), to: @base
@ -24,7 +19,7 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do
def get_user(%Plug.Conn{} = conn) do def get_user(%Plug.Conn{} = conn) do
with {:ldap, true} <- {:ldap, Pleroma.Config.get([:ldap, :enabled])}, with {:ldap, true} <- {:ldap, Pleroma.Config.get([:ldap, :enabled])},
{:ok, {name, password}} <- fetch_credentials(conn), {:ok, {name, password}} <- fetch_credentials(conn),
%User{} = user <- ldap_user(name, password) do %User{} = user <- GenServer.call(Pleroma.LDAP, {:bind_user, name, password}) do
{:ok, user} {:ok, user}
else else
{:ldap, _} -> {:ldap, _} ->
@ -34,142 +29,4 @@ defmodule Pleroma.Web.Auth.LDAPAuthenticator do
error error
end end
end end
defp ldap_user(name, password) do
ldap = Pleroma.Config.get(:ldap, [])
host = Keyword.get(ldap, :host, "localhost")
port = Keyword.get(ldap, :port, 389)
ssl = Keyword.get(ldap, :ssl, false)
tls = Keyword.get(ldap, :tls, false)
cacertfile = Keyword.get(ldap, :cacertfile) || CAStore.file_path()
default_secure_opts = [
verify: :verify_peer,
cacerts: decode_certfile(cacertfile),
customize_hostname_check: [
fqdn_fun: fn _ -> to_charlist(host) end
]
]
sslopts = Keyword.merge(default_secure_opts, Keyword.get(ldap, :sslopts, []))
tlsopts = Keyword.merge(default_secure_opts, Keyword.get(ldap, :tlsopts, []))
# :sslopts can only be included in :eldap.open/2 when {ssl: true}
# or the connection will fail
options =
if ssl do
[{:port, port}, {:ssl, ssl}, {:sslopts, sslopts}, {:timeout, @connection_timeout}]
else
[{:port, port}, {:ssl, ssl}, {:timeout, @connection_timeout}]
end
case :eldap.open([to_charlist(host)], options) do
{:ok, connection} ->
try do
cond do
ssl ->
:application.ensure_all_started(:ssl)
tls ->
case :eldap.start_tls(
connection,
tlsopts,
@connection_timeout
) do
:ok ->
:ok
error ->
Logger.error("Could not start TLS: #{inspect(error)}")
:eldap.close(connection)
end
true ->
:ok
end
bind_user(connection, ldap, name, password)
after
:eldap.close(connection)
end
{:error, error} ->
Logger.error("Could not open LDAP connection: #{inspect(error)}")
{:error, {:ldap_connection_error, error}}
end
end
defp bind_user(connection, ldap, name, password) do
uid = Keyword.get(ldap, :uid, "cn")
base = Keyword.get(ldap, :base)
case :eldap.simple_bind(connection, "#{uid}=#{name},#{base}", password) do
:ok ->
case fetch_user(name) do
%User{} = user ->
user
_ ->
register_user(connection, base, uid, name)
end
error ->
Logger.error("Could not bind LDAP user #{name}: #{inspect(error)}")
{:error, {:ldap_bind_error, error}}
end
end
defp register_user(connection, base, uid, name) do
case :eldap.search(connection, [
{:base, to_charlist(base)},
{:filter, :eldap.equalityMatch(to_charlist(uid), to_charlist(name))},
{:scope, :eldap.wholeSubtree()},
{:timeout, @search_timeout}
]) do
# The :eldap_search_result record structure changed in OTP 24.3 and added a controls field
# https://github.com/erlang/otp/pull/5538
{:ok, {:eldap_search_result, [{:eldap_entry, _object, attributes}], _referrals}} ->
try_register(name, attributes)
{:ok, {:eldap_search_result, [{:eldap_entry, _object, attributes}], _referrals, _controls}} ->
try_register(name, attributes)
error ->
Logger.error("Couldn't register user because LDAP search failed: #{inspect(error)}")
{:error, {:ldap_search_error, error}}
end
end
defp try_register(name, attributes) do
params = %{
name: name,
nickname: name,
password: nil
}
params =
case List.keyfind(attributes, ~c"mail", 0) do
{_, [mail]} -> Map.put_new(params, :email, :erlang.list_to_binary(mail))
_ -> params
end
changeset = User.register_changeset_ldap(%User{}, params)
case User.register(changeset) do
{:ok, user} -> user
error -> error
end
end
defp decode_certfile(file) do
with {:ok, data} <- File.read(file) do
data
|> :public_key.pem_decode()
|> Enum.map(fn {_, b, _} -> b end)
else
_ ->
Logger.error("Unable to read certfile: #{file}")
[]
end
end
end end