defmodule Paddle do @moduledoc ~S""" Module handling ldap requests and translate them to the `:eldap` syntax. ## Configuration The configuration should be in the dev.secret.exs or prod.secret.exs depending on the environment you're working on. Here's an example config: config :penguin_paddle, Paddle, host: "ldap.my-organisation.org", base: "dc=myorganisation,dc=org", ssl: true, port: 636, ipv6: true, tcpopts: [], sslopts: [certfile: '/path/to/certificate.crt'], timeout: 3000, account_subdn: "ou=People", schema_files: Path.wildcard("/etc/openldap/schema/*.schema"), filter_passwords: true Option | Description | Default --------- | ----------- | ------- `:host` | The host(s) containing the LDAP server(s). Can be a bitstring for a single host, or a list of bitstrings, which will make Paddle try to connect to each host in the specified order. See also the `:timeout` option. | **Mandatory** `:base` | The base DN. | `""` `:ssl` | When set to `true`, use SSL to connect to the LDAP server. | `false` `:port` | The port the LDAP server listen to. | `389` `:ipv6` | When set to `true`, connect to the LDAP server using IPv6. | `false` `:tcpopts`| Additionnal `:gen_tcp.connect/4` / `:ssl.connect/4` options. Must not have the `:active`, `:binary`, `:deliver`, `:list`, `:mode` or `:packet` options. See [`:gen_tcp`'s option documentation](http://erlang.org/doc/man/gen_tcp.html#type-connect_option). | `[]` `:sslopts`| Additionnal `:ssl.connect/4` options. Ineffective if the `:ssl` option is set to `false`. See [`:ssl`'s option documentation](http://erlang.org/doc/man/ssl.html). | `[]` `:timeout`| The timeout in milliseconds, or `nil` for the default TCP stack timeout value (which may be very long), for each request to the LDAP server. | `nil` `:account_subdn` | The DN (without the base) where the accounts are located. Used by the `Paddle.authenticate/2` function. | `"ou=People"` `:account_identifier` | The identifier by which users are identified. Used by the `Paddle.authenticate/2` function. | `:uid` `:schema_files` | Files which are to be parsed to help generate classes using [`Paddle.Class.Helper`](Paddle.Class.Helper.html#module-using-schema-files). | `[]` `:filter_passwords` | Filter passwords from appearing in the logs | `true` ## Usage To check a user's credentials and/or authenticate the connection, simply do: Paddle.authenticate("username", "password") You can also specify the partial DN like so: Paddle.authenticate([cn: "admin"], "adminpassword") Many functions support passing both a base and a filter via a keyword list or a map like so: Paddle.get(filter: [uid: "testuser"], base: [ou: "People"]) But you can also use structs which implements the `Paddle.Class` protocol (called [class objects](#module-class-objects)). If we take as example the classes defined in `test/support/classes.ex`, we could do: Paddle.get %MyApp.PosixAccount{uid: "user"} The previous example will return every accounts which are in a given subDN (defined in the `Paddle.Class` protocol), which have the right objectClass (also defined in the same protocol), and have an uid of "user". You can also specify an additional [filter](#module-filters) as second argument. ## Class objects A class object is simply a struct implementing the `Paddle.Class` protocol. If you're in need of some examples, you can see the `test/support/classes.ex` file which defines `MyApp.PosixAccount`, and `MyApp.PosixGroup` (but only in test mode, so you would have to define your own). For more informations, see the `Paddle.Class` module documentation. ## Filters A filter in Paddle is a keyword list or a map. This is equivalent to a filter where each attribute name (key from the map / keyword list) must have a corresponding value (value from the map / keyword list). Example: [uid: "user", cn: "User", homeDirectory: "/home/user"] If you are missing some filtering capabilities, you can always pass as argument an `:eldap` filter like so: Paddle.get(filter: :eldap.substrings('uid', initial: 'b')) For more informations and examples, see `Paddle.Filters.construct_filter/1` ## Bases A base in Paddle can be a Keyword list that will be converted to a charlist to be passed on to the `:eldap` module. A direct string can also be passed. For more informations and examples, see `Paddle.Filters.construct_dn/2` """ use GenServer require Logger alias Paddle.Parsing alias Paddle.Filters alias Paddle.Attributes @typep ldap_conn :: :eldap.handle() | {:not_connected, binary} @type ldap_entry :: %{required(binary) => binary} @type auth_status :: :ok | {:error, atom} @type dn :: keyword | binary unless Application.get_env(:penguin_paddle, __MODULE__) do raise """ Please configure the LDAP in the config files See the `Paddle` module documentation. """ end @spec start_link(term) :: Genserver.on_start() @doc false def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @spec init([]) :: {:ok, ldap_conn} @impl GenServer def init(opts \\ []) do case do_connect(opts) do {:ok, ldap_conn} -> {:ok, ldap_conn} {:error, reason} -> {:ok, {:not_connected, reason}} end end @type reason :: :normal | :shutdown | {:shutdown, term} | term @spec terminate(reason, ldap_conn) :: :ok @impl GenServer def terminate(_shutdown_reason, {:not_connected, _reason}) do :ok Logger.info("Stopped LDAP, state was not connected") end @impl GenServer def terminate(_shutdown_reason, ldap_conn) do :eldap.close(ldap_conn) Logger.info("Stopped LDAP") end @spec handle_call( {:authenticate, charlist, charlist} | {:reconnect, list} | {:get, Paddle.Filters.t(), dn, atom} | {:get_single, Paddle.Filters.t(), dn, atom} | {:add, dn, attributes, atom} | {:delete, dn, atom} | {:modify, dn, atom, [mod]}, GenServer.from(), ldap_conn ) :: {:reply, term, ldap_conn} @impl GenServer def handle_call({:reconnect, opts}, _from, ldap_conn) do case ldap_conn do {:not_connected, _reason} -> nil pid -> :eldap.close(pid) end Logger.info("Reconnecting") case do_connect(opts) do {:ok, ldap_conn} -> {:reply, {:ok, :connected}, ldap_conn} {:error, reason} -> {:reply, {:error, {:not_connected, reason}}, {:not_connected, reason}} end end @impl GenServer def handle_call(_message, _from, {:not_connected, _reason} = state) do {:reply, {:error, :not_connected}, state} end @impl GenServer def handle_call({:authenticate, dn, password}, _from, ldap_conn) do Logger.debug("Authenticating with dn: #{dn}") status = :eldap.simple_bind(ldap_conn, dn, password) case status do :ok -> {:reply, status, ldap_conn} {:error, _} -> {:reply, status, ldap_conn} end end @impl GenServer def handle_call({:get, filter, kwdn, base}, _from, ldap_conn) do base = config(base) dn = Parsing.construct_dn(kwdn, base) filter = Filters.construct_filter(filter) Logger.debug("Getting entries with dn: #{dn} and filter: #{inspect(filter, pretty: true)}") {:reply, :eldap.search(ldap_conn, base: dn, filter: filter) |> Parsing.clean_eldap_search_results(base), ldap_conn} end @impl GenServer def handle_call({:get_single, filter, kwdn, base}, _from, ldap_conn) do base = config(base) dn = Parsing.construct_dn(kwdn, base) filter = Filters.construct_filter(filter) Logger.debug( "Getting single entry with dn: #{dn} and filter: #{inspect(filter, pretty: true)}" ) {:reply, :eldap.search(ldap_conn, base: dn, scope: :eldap.baseObject(), filter: filter ) |> Parsing.clean_eldap_search_results(base) |> ensure_single_result, ldap_conn} end @impl GenServer def handle_call({:add, kwdn, attributes, base}, _from, ldap_conn) do dn = Parsing.construct_dn(kwdn, config(base)) Logger.info("Adding entry with dn: #{dn}") attributes = attributes |> Enum.filter_map( fn {_key, value} -> value != nil end, fn {key, value} -> {'#{key}', Parsing.list_wrap(value)} end ) {:reply, :eldap.add(ldap_conn, dn, attributes), ldap_conn} end @impl GenServer def handle_call({:delete, kwdn, base}, _from, ldap_conn) do dn = Parsing.construct_dn(kwdn, config(base)) Logger.info("Deleting entry with dn: #{dn}") {:reply, :eldap.delete(ldap_conn, dn), ldap_conn} end @impl GenServer def handle_call({:modify, kwdn, base, mods}, _from, ldap_conn) do dn = Parsing.construct_dn(kwdn, config(base)) Logger.info("Modifying entry: \"#{dn}\" with mods: #{inspect(mods)}") mods = mods |> Enum.map(&Parsing.mod_convert/1) {:reply, :eldap.modify(ldap_conn, dn, mods), ldap_conn} end @type authenticate_ldap_error :: :operationsError | :protocolError | :authMethodNotSupported | :strongAuthRequired | :referral | :saslBindInProgress | :inappropriateAuthentication | :invalidCredentials | :unavailable | :anonymous_auth @spec authenticate(dn, binary) :: :ok | {:error, authenticate_ldap_error} @doc ~S""" Check the given credentials and authenticate the current connection. When given the wrong credentials, returns `{:error, :invalidCredentials}` The user id can be passed as a binary, which will expand to `=,,`, or with a keyword list if you want to specify the whole DN (but still without the base DN). Example: iex> Paddle.authenticate("testuser", "test") :ok iex> Paddle.authenticate("testuser", "wrong password") {:error, :invalidCredentials} iex> Paddle.authenticate([cn: "admin"], "test") :ok """ def authenticate(kwdn, password) when is_list(kwdn) do dn = Parsing.construct_dn(kwdn, config(:base)) GenServer.call(Paddle, {:authenticate, dn, :binary.bin_to_list(password)}) end def authenticate(username, password) do dn = Parsing.construct_dn([{config(:account_identifier), username}], config(:account_base)) GenServer.call(Paddle, {:authenticate, dn, :binary.bin_to_list(password)}) end @doc ~S""" Closes the current connection and opens a new one. Accepts connection information as arguments. Not specified values will be fetched from the config. Example: iex> Paddle.reconnect(host: ['example.com']) {:error, {:not_connected, "connect failed"}} iex> Paddle.reconnect() {:ok, :connected} """ def reconnect(opts \\ []) do GenServer.call(Paddle, {:reconnect, opts}) end @spec get_dn(Paddle.Class.t()) :: {:ok, binary} | {:error, :missing_unique_identifier} @doc ~S""" Get the DN of an entry. Example: iex> Paddle.get_dn(%MyApp.PosixAccount{uid: "testuser"}) {:ok, "uid=testuser,ou=People"} """ def get_dn(object) do subdn = Paddle.Class.location(object) id_field = Paddle.Class.unique_identifier(object) id_value = Map.get(object, id_field) if id_value do id_value = Paddle.Parsing.ldap_escape(id_value) {:ok, "#{id_field}=#{id_value},#{subdn}"} else {:error, :missing_unique_identifier} end end # ============= # == Getting == # ============= @type search_ldap_error :: :noSuchObject | :sizeLimitExceeded | :timeLimitExceeded | :undefinedAttributeType | :insufficientAccessRights @spec get(dn) :: {:ok, [ldap_entry]} | {:error, search_ldap_error} @doc ~S""" Get one or more LDAP entries given a partial DN and a filter. Example: iex> Paddle.get(base: [uid: "testuser", ou: "People"]) {:ok, [%{"cn" => ["Test User"], "dn" => "uid=testuser,ou=People", "gecos" => ["Test User,,,,"], "gidNumber" => ["120"], "homeDirectory" => ["/home/testuser"], "loginShell" => ["/bin/bash"], "objectClass" => ["account", "posixAccount", "top"], "uid" => ["testuser"], "uidNumber" => ["500"], "userPassword" => ["{SSHA}AIzygLSXlArhAMzddUriXQxf7UlkqopP"]}]} iex> Paddle.get(base: "uid=testuser,ou=People") {:ok, [%{"cn" => ["Test User"], "dn" => "uid=testuser,ou=People", "gecos" => ["Test User,,,,"], "gidNumber" => ["120"], "homeDirectory" => ["/home/testuser"], "loginShell" => ["/bin/bash"], "objectClass" => ["account", "posixAccount", "top"], "uid" => ["testuser"], "uidNumber" => ["500"], "userPassword" => ["{SSHA}AIzygLSXlArhAMzddUriXQxf7UlkqopP"]}]} iex> Paddle.get(base: [uid: "nothing"]) {:error, :noSuchObject} iex> Paddle.get(filter: [uid: "testuser"], base: [ou: "People"]) {:ok, [%{"cn" => ["Test User"], "dn" => "uid=testuser,ou=People", "gecos" => ["Test User,,,,"], "gidNumber" => ["120"], "homeDirectory" => ["/home/testuser"], "loginShell" => ["/bin/bash"], "objectClass" => ["account", "posixAccount", "top"], "uid" => ["testuser"], "uidNumber" => ["500"], "userPassword" => ["{SSHA}AIzygLSXlArhAMzddUriXQxf7UlkqopP"]}]} """ def get(kwdn) when is_list(kwdn) do GenServer.call( Paddle, {:get, Keyword.get(kwdn, :filter), Keyword.get(kwdn, :base), :base} ) end @spec get(Paddle.Class.t()) :: {:ok, [Paddle.Class.t()]} | {:error, search_ldap_error} @spec get(Paddle.Class.t(), Paddle.Filters.t()) :: {:ok, [Paddle.Class.t()]} | {:error, search_ldap_error} @doc ~S""" Get an entry in the LDAP given a class object. You can specify an optional additional filter as second argument. Example: iex> Paddle.get(%MyApp.PosixAccount{}) {:ok, [%MyApp.PosixAccount{cn: ["Test User"], description: nil, gecos: ["Test User,,,,"], gidNumber: ["120"], homeDirectory: ["/home/testuser"], host: nil, l: nil, loginShell: ["/bin/bash"], o: nil, ou: nil, seeAlso: nil, uid: ["testuser"], uidNumber: ["500"], userPassword: ["{SSHA}AIzygLSXlArhAMzddUriXQxf7UlkqopP"]}]} iex> Paddle.get(%MyApp.PosixGroup{cn: "users"}) {:ok, [%MyApp.PosixGroup{cn: ["users"], description: nil, gidNumber: ["2"], memberUid: ["testuser"], userPassword: nil}]} iex> Paddle.get(%MyApp.PosixGroup{}, :eldap.substrings('cn', initial: 'a')) {:ok, [%MyApp.PosixGroup{cn: ["adm"], description: nil, gidNumber: ["3"], memberUid: nil, userPassword: nil}]} """ def get(object, additional_filter \\ nil) when is_map(object) do fields_filter = object |> Map.from_struct() |> Enum.filter(fn {_key, value} -> value != nil end) filter = object |> Paddle.Class.object_classes() |> Filters.class_filter() |> Filters.merge_filter(fields_filter) |> Filters.merge_filter(additional_filter) location = Paddle.Class.location(object) with {:ok, entries} <- GenServer.call(Paddle, {:get, filter, location, :base}) do {:ok, entries |> Enum.map(&Parsing.entry_to_class_object(&1, object))} end end @spec get!(dn) :: [ldap_entry] @doc ~S""" Same as `get/1` but throws in case of an error. """ def get!(kwdn) when is_list(kwdn) do {:ok, result} = get(kwdn) result end @spec get!(Paddle.Class.t()) :: [Paddle.Class.t()] @spec get!(Paddle.Class.t(), Paddle.Filters.t()) :: [Paddle.Class.t()] @doc ~S""" Same as `get/2` but throws in case of an error. """ def get!(object, additional_filter \\ []) do {:ok, result} = get(object, additional_filter) result end @spec get_single(dn) :: {:ok, ldap_entry} | {:error, search_ldap_error} @doc ~S""" Get a single LDAP entry given an optional partial DN and an optional filter. Example: iex> Paddle.get_single(base: [ou: "People"]) {:ok, %{"dn" => "ou=People", "objectClass" => ["top", "organizationalUnit"], "ou" => ["People"]}} iex> Paddle.get_single(filter: [uid: "nothing"]) {:error, :noSuchObject} """ def get_single(kwdn) do GenServer.call( Paddle, {:get_single, Keyword.get(kwdn, :filter), Keyword.get(kwdn, :base), :base} ) end # ============ # == Adding == # ============ @type attributes :: keyword | %{required(binary) => binary} | [{binary, binary}] @type add_ldap_error :: :undefinedAttributeType | :objectClassViolation | :invalidAttributeSyntax | :noSuchObject | :insufficientAccessRights | :entryAlreadyExists @spec add(dn, attributes) :: :ok | {:error, add_ldap_error} @doc ~S""" Add an entry to the LDAP given a DN and a list of attributes. The first argument is the DN given as a string or keyword list. The second argument is the list of attributes in the new entry as a keyword list like so: [objectClass: ["account", "posixAccount"], cn: "User", loginShell: "/bin/bash", homeDirectory: "/home/user", uidNumber: 501, gidNumber: 100] Please note that due to the limitation of char lists you cannot pass directly a char list as an attribute value. But, you can wrap it in an array like this: `homeDirectory: ['/home/user']` """ def add(kwdn, attributes), do: GenServer.call(Paddle, {:add, kwdn, attributes, :base}) @spec add(Paddle.Class.t()) :: :ok | {:error, :missing_unique_identifier} | {:error, :missing_req_attributes, [atom]} | {:error, add_ldap_error} @doc ~S""" Add an entry to the LDAP given a class object. Example: Paddle.add(%MyApp.PosixAccount{uid: "myUser", cn: "My User", gidNumber: "501", homeDirectory: "/home/myUser"}) """ def add(class_object) do with {:ok, dn} <- get_dn(class_object), {:ok, attributes} <- Attributes.get(class_object) do add(dn, attributes) end end # ============== # == Deleting == # ============== @type delete_ldap_error :: :noSuchObject | :notAllowedOnNonLeaf | :insufficientAccessRights @spec delete(Paddle.Class.t() | dn) :: :ok | {:error, delete_ldap_error} @doc ~S""" Delete a LDAP entry given a DN or a class object. Examples: Paddle.delete("uid=testuser,ou=People") Paddle.delete([uid: "testuser", ou: "People"]) Paddle.delete(%MyApp.PosixAccount{uid: "testuser"}) The three examples above do exactly the same thing (provided that the `MyApp.PosixAccount` is configured appropriately). """ def delete(kwdn) when is_list(kwdn) or is_binary(kwdn) do GenServer.call(Paddle, {:delete, kwdn, :base}) end def delete(class_object) when is_map(class_object) do with {:ok, dn} <- get_dn(class_object) do GenServer.call(Paddle, {:delete, dn, :base}) end end # =============== # == Modifying == # =============== @type mod :: {:add, {binary | atom, binary | [binary]}} | {:delete, binary} | {:replace, {binary | atom, binary | [binary]}} @type modify_ldap_error :: :noSuchObject | :undefinedAttributeType | :namingViolation | :attributeOrValueExists | :invalidAttributeSyntax | :notAllowedOnRDN | :objectClassViolation | :objectClassModsProhibited | :insufficientAccessRights @spec modify(Paddle.Class.t() | dn, [mod]) :: :ok | {:error, modify_ldap_error} @doc ~S""" Modify an LDAP entry given a DN or a class object and a list of modifications. A modification is specified like so: {action, {parameters...}} Available modifications: - `{:add, {field, value}}` - `{:delete, field}` - `{:replace, {field, value}}` For example, adding a "description" field: {:add, {"description", "This is a description"}} This allows you to do things like this: Paddle.modify([uid: "testuser", ou: "People"], add: {"description", "This is a description"}, delete: "gecos", replace: {"o", ["Club *Nix", "Linux Foundation"]}) Or, using class objects: Paddle.modify(%MyApp.PosixAccount{uid: "testuser"}, add: {"description", "This is a description"}, delete: "gecos", replace: {"o", ["Club *Nix", "Linux Foundation"]}) """ def modify(kwdn, mods) when is_list(kwdn) or is_binary(kwdn) do GenServer.call(Paddle, {:modify, kwdn, :base, mods}) end def modify(class_object, mods) when is_map(class_object) do with {:ok, dn} <- get_dn(class_object) do GenServer.call(Paddle, {:modify, dn, :base, mods}) end end # ======================= # == Private Utilities == # ======================= @spec config :: keyword @doc ~S""" Get the whole configuration of the Paddle application. """ def config, do: Application.get_env(:penguin_paddle, Paddle) @spec config(atom) :: any @doc ~S""" Get the environment configuration of the Paddle application under a certain key. """ def config(:host) do case Keyword.get(config(), :host) do host when is_bitstring(host) -> [String.to_charlist(host)] hosts when is_list(hosts) -> Enum.map(hosts, &String.to_charlist/1) end end def config(:ssl), do: config(:ssl, false) def config(:ipv6), do: config(:ipv6, false) def config(:tcpopts), do: config(:tcpopts, []) def config(:sslopts), do: config(:sslopts, []) def config(:port), do: config(:port, 389) def config(:timeout), do: config(:timeout, nil) def config(:base), do: config(:base, "") |> :binary.bin_to_list() def config(:account_base), do: config(:account_subdn) ++ ',' ++ config(:base) def config(:account_subdn), do: config(:account_subdn, "ou=People") |> :binary.bin_to_list() def config(:account_identifier), do: config(:account_identifier, :uid) def config(:schema_files), do: config(:schema_files, []) @spec config(atom, any) :: any @doc ~S""" Same as `config/1` but allows you to specify a default value. """ def config(key, default), do: Keyword.get(config(), key, default) @spec ensure_single_result({:ok, [ldap_entry]} | {:error, atom}) :: {:ok, ldap_entry} | {:error, :noSuchObject} defp ensure_single_result({:error, error}) do case error do :noSuchObject -> {:error, :noSuchObject} end end defp ensure_single_result({:ok, []}), do: {:error, :noSuchObject} defp ensure_single_result({:ok, [result]}), do: {:ok, result} @spec eldap_log_callback(pos_integer, charlist, [term]) :: :ok @doc false def eldap_log_callback(level, format_string, format_args) do message = case Application.get_env(:penguin_paddle, :filter_passwords, true) do true -> :io_lib.format(format_string, format_args) |> to_string() |> String.replace(~r/{simple,".*"}/, ~s({simple,"filtered"})) false -> :io_lib.format(format_string, format_args) end case level do # Level 1 seems unused by :eldap 1 -> Logger.info(message) 2 -> Logger.debug(message) end end defp do_connect(opts) do ssl = Keyword.get(opts, :ssl, config(:ssl)) ipv6 = Keyword.get(opts, :ipv6, config(:ipv6)) tcpopts = Keyword.get(opts, :tcpopts, config(:tcpopts)) sslopts = Keyword.get(opts, :sslopts, config(:sslopts)) host = Keyword.get(opts, :host, config(:host)) port = Keyword.get(opts, :port, config(:port)) timeout = Keyword.get(opts, :timeout, config(:timeout)) Logger.info("Connecting to ldap#{if ssl, do: "s"}://#{inspect(host)}:#{port}") tcpopts = if ipv6 do [:inet6 | tcpopts] else tcpopts end options = [ssl: ssl, port: port, tcpopts: tcpopts, log: &eldap_log_callback/3] options = if timeout do Keyword.put(options, :timeout, timeout) else options end options = if ssl do Keyword.put(options, :sslopts, sslopts) else options end Logger.debug("Effective :eldap options: #{inspect(options)}") case :eldap.open(host, options) do {:ok, ldap_conn} -> :eldap.controlling_process(ldap_conn, self()) Logger.info("Connected to LDAP") {:ok, ldap_conn} {:error, reason} -> Logger.info("Failed to connect to LDAP") {:error, Kernel.to_string(reason)} end end end