From 40f6ea51ae53eb7a884a6adf42a384a112961a14 Mon Sep 17 00:00:00 2001 From: FloatingGhost Date: Wed, 27 Jul 2022 19:42:47 +0100 Subject: [PATCH] Revert "purge ldap authenticator (#92)" This reverts commit 729f45ccd243bd01db63949ba6f51033e718f9bf. --- CHANGELOG.md | 1 - config/config.exs | 12 +- config/description.exs | 98 +++++++++++++ docs/docs/configuration/cheatsheet.md | 22 +++ docs/docs/installation/alpine_linux_en.md | 6 + lib/pleroma/user.ex | 28 ++++ lib/pleroma/web/auth/ldap_authenticator.ex | 129 +++++++++++++++++ mix.exs | 1 + .../web/o_auth/ldap_authorization_test.exs | 135 ++++++++++++++++++ 9 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 lib/pleroma/web/auth/ldap_authenticator.ex create mode 100644 test/pleroma/web/o_auth/ldap_authorization_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index b75720f8d..98f434aaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `/api/v1/notifications/dismiss` - `/api/v1/search` - `/api/v1/statuses/{id}/card` -- LDAP authenticator (use the akkoma-contrib-authenticator-ldap runtime module) - Chats, they were half-baked. Just use PMs. - Prometheus, it causes massive slowdown diff --git a/config/config.exs b/config/config.exs index bac167c29..197887c93 100644 --- a/config/config.exs +++ b/config/config.exs @@ -509,7 +509,6 @@ config :pleroma, Pleroma.User, "~", "about", "activities", - "akkoma", "api", "auth", "check_password", @@ -590,6 +589,17 @@ config :pleroma, Pleroma.Formatter, extra: true, validate_tld: :no_scheme +config :pleroma, :ldap, + enabled: System.get_env("LDAP_ENABLED") == "true", + host: System.get_env("LDAP_HOST") || "localhost", + port: String.to_integer(System.get_env("LDAP_PORT") || "389"), + ssl: System.get_env("LDAP_SSL") == "true", + sslopts: [], + tls: System.get_env("LDAP_TLS") == "true", + tlsopts: [], + base: System.get_env("LDAP_BASE") || "dc=example,dc=com", + uid: System.get_env("LDAP_UID") || "cn" + oauth_consumer_strategies = "OAUTH_CONSUMER_STRATEGIES" |> System.get_env() diff --git a/config/description.exs b/config/description.exs index b8a053c3c..e864f090c 100644 --- a/config/description.exs +++ b/config/description.exs @@ -2140,6 +2140,104 @@ config :pleroma, :config_description, [ } ] }, + %{ + group: :pleroma, + key: :ldap, + label: "LDAP", + type: :group, + description: + "Use LDAP for user authentication. When a user logs in to the Pleroma instance, the name and password" <> + " will be verified by trying to authenticate (bind) to a LDAP server." <> + " If a user exists in the LDAP directory but there is no account with the same name yet on the" <> + " Pleroma instance then a new Pleroma account will be created with the same name as the LDAP user name.", + children: [ + %{ + key: :enabled, + type: :boolean, + description: "Enables LDAP authentication" + }, + %{ + key: :host, + type: :string, + description: "LDAP server hostname", + suggestions: ["localhosts"] + }, + %{ + key: :port, + type: :integer, + description: "LDAP port, e.g. 389 or 636", + suggestions: [389, 636] + }, + %{ + key: :ssl, + label: "SSL", + type: :boolean, + description: "Enable to use SSL, usually implies the port 636" + }, + %{ + key: :sslopts, + label: "SSL options", + type: :keyword, + description: "Additional SSL options", + suggestions: [cacertfile: "path/to/file/with/PEM/cacerts", verify: :verify_peer], + children: [ + %{ + key: :cacertfile, + type: :string, + description: "Path to file with PEM encoded cacerts", + suggestions: ["path/to/file/with/PEM/cacerts"] + }, + %{ + key: :verify, + type: :atom, + description: "Type of cert verification", + suggestions: [:verify_peer] + } + ] + }, + %{ + key: :tls, + label: "TLS", + type: :boolean, + description: "Enable to use STARTTLS, usually implies the port 389" + }, + %{ + key: :tlsopts, + label: "TLS options", + type: :keyword, + description: "Additional TLS options", + suggestions: [cacertfile: "path/to/file/with/PEM/cacerts", verify: :verify_peer], + children: [ + %{ + key: :cacertfile, + type: :string, + description: "Path to file with PEM encoded cacerts", + suggestions: ["path/to/file/with/PEM/cacerts"] + }, + %{ + key: :verify, + type: :atom, + description: "Type of cert verification", + suggestions: [:verify_peer] + } + ] + }, + %{ + key: :base, + type: :string, + description: "LDAP base, e.g. \"dc=example,dc=com\"", + suggestions: ["dc=example,dc=com"] + }, + %{ + key: :uid, + label: "UID", + type: :string, + description: + "LDAP attribute name to authenticate the user, e.g. when \"cn\", the filter will be \"cn=username,base\"", + suggestions: ["cn"] + } + ] + }, %{ group: :pleroma, key: :auth, diff --git a/docs/docs/configuration/cheatsheet.md b/docs/docs/configuration/cheatsheet.md index fdc39c0de..bac20070f 100644 --- a/docs/docs/configuration/cheatsheet.md +++ b/docs/docs/configuration/cheatsheet.md @@ -891,6 +891,28 @@ Authentication / authorization settings. ### Pleroma.Web.Auth.Authenticator * `Pleroma.Web.Auth.PleromaAuthenticator`: default database authenticator. +* `Pleroma.Web.Auth.LDAPAuthenticator`: LDAP authentication. + +### :ldap + +Use LDAP for user authentication. When a user logs in to the Akkoma +instance, the name and password will be verified by trying to authenticate +(bind) to an LDAP server. If a user exists in the LDAP directory but there +is no account with the same name yet on the Akkoma instance then a new +Akkoma account will be created with the same name as the LDAP user name. + +* `enabled`: enables LDAP authentication +* `host`: LDAP server hostname +* `port`: LDAP port, e.g. 389 or 636 +* `ssl`: true to use SSL, usually implies the port 636 +* `sslopts`: additional SSL options +* `tls`: true to start TLS, usually implies the port 389 +* `tlsopts`: additional TLS options +* `base`: LDAP base, e.g. "dc=example,dc=com" +* `uid`: LDAP attribute name to authenticate the user, e.g. when "cn", the filter will be "cn=username,base" + +Note, if your LDAP server is an Active Directory server the correct value is commonly `uid: "cn"`, but if you use an +OpenLDAP server the value may be `uid: "uid"`. ### :oauth2 (Akkoma as OAuth 2.0 provider settings) diff --git a/docs/docs/installation/alpine_linux_en.md b/docs/docs/installation/alpine_linux_en.md index 3be69af6e..f98998fb8 100644 --- a/docs/docs/installation/alpine_linux_en.md +++ b/docs/docs/installation/alpine_linux_en.md @@ -41,6 +41,12 @@ doas apk add git build-base cmake file-dev doas apk add erlang elixir ``` +* Install `erlang-eldap` if you want to enable ldap authenticator + +```shell +doas apk add erlang-eldap +``` + ### Install PostgreSQL * Install Postgresql server: diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 275ad9506..077d5ffa6 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -660,6 +660,34 @@ defmodule Pleroma.User do @spec force_password_reset(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} def force_password_reset(user), do: update_password_reset_pending(user, true) + # Used to auto-register LDAP accounts which won't have a password hash stored locally + def register_changeset_ldap(struct, params = %{password: password}) + when is_nil(password) do + params = Map.put_new(params, :accepts_chat_messages, true) + + params = + if Map.has_key?(params, :email) do + Map.put_new(params, :email, params[:email]) + else + params + end + + struct + |> cast(params, [ + :name, + :nickname, + :email, + :accepts_chat_messages + ]) + |> validate_required([:name, :nickname]) + |> unique_constraint(:nickname) + |> validate_exclusion(:nickname, Config.get([User, :restricted_nicknames])) + |> validate_format(:nickname, local_nickname_regex()) + |> put_ap_id() + |> unique_constraint(:ap_id) + |> put_following_and_follower_and_featured_address() + end + def register_changeset(struct, params \\ %{}, opts \\ []) do bio_limit = Config.get([:instance, :user_bio_length], 5000) name_limit = Config.get([:instance, :user_name_length], 100) diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex new file mode 100644 index 000000000..f77e8d203 --- /dev/null +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -0,0 +1,129 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.LDAPAuthenticator do + alias Pleroma.User + + require Logger + + import Pleroma.Web.Auth.Helpers, only: [fetch_credentials: 1, fetch_user: 1] + + @behaviour Pleroma.Web.Auth.Authenticator + @base Pleroma.Web.Auth.PleromaAuthenticator + + @connection_timeout 10_000 + @search_timeout 10_000 + + defdelegate get_registration(conn), to: @base + defdelegate create_from_registration(conn, registration), to: @base + defdelegate handle_error(conn, error), to: @base + defdelegate auth_template, to: @base + defdelegate oauth_consumer_template, to: @base + + def get_user(%Plug.Conn{} = conn) do + with {:ldap, true} <- {:ldap, Pleroma.Config.get([:ldap, :enabled])}, + {:ok, {name, password}} <- fetch_credentials(conn), + %User{} = user <- ldap_user(name, password) do + {:ok, user} + else + {:ldap, _} -> + @base.get_user(conn) + + error -> + error + 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) + sslopts = Keyword.get(ldap, :sslopts, []) + + options = + [{:port, port}, {:ssl, ssl}, {:timeout, @connection_timeout}] ++ + if sslopts != [], do: [{:sslopts, sslopts}], else: [] + + case :eldap.open([to_charlist(host)], options) do + {:ok, connection} -> + try do + if Keyword.get(ldap, :tls, false) do + :application.ensure_all_started(:ssl) + + case :eldap.start_tls( + connection, + Keyword.get(ldap, :tlsopts, []), + @connection_timeout + ) do + :ok -> + :ok + + error -> + Logger.error("Could not start TLS: #{inspect(error)}") + end + 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 -> + 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 + {:ok, {:eldap_search_result, [{:eldap_entry, _, attributes}], _}} -> + params = %{ + name: name, + nickname: name, + password: nil + } + + params = + case List.keyfind(attributes, '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 + + error -> + error + end + end +end diff --git a/mix.exs b/mix.exs index ff54c79b4..71384c755 100644 --- a/mix.exs +++ b/mix.exs @@ -9,6 +9,7 @@ defmodule Pleroma.Mixfile do elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix, :gettext] ++ Mix.compilers(), elixirc_options: [warnings_as_errors: warnings_as_errors()], + xref: [exclude: [:eldap]], start_permanent: Mix.env() == :prod, aliases: aliases(), deps: deps(), diff --git a/test/pleroma/web/o_auth/ldap_authorization_test.exs b/test/pleroma/web/o_auth/ldap_authorization_test.exs new file mode 100644 index 000000000..61b9ce6b7 --- /dev/null +++ b/test/pleroma/web/o_auth/ldap_authorization_test.exs @@ -0,0 +1,135 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2021 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.OAuth.LDAPAuthorizationTest do + use Pleroma.Web.ConnCase + alias Pleroma.Repo + alias Pleroma.Web.OAuth.Token + import Pleroma.Factory + import Mock + + @skip if !Code.ensure_loaded?(:eldap), do: :skip + + setup_all do: clear_config([:ldap, :enabled], true) + + setup_all do: clear_config(Pleroma.Web.Auth.Authenticator, Pleroma.Web.Auth.LDAPAuthenticator) + + @tag @skip + test "authorizes the existing user using LDAP credentials" do + password = "testpassword" + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt(password)) + app = insert(:oauth_app, scopes: ["read", "write"]) + + host = Pleroma.Config.get([:ldap, :host]) |> to_charlist + port = Pleroma.Config.get([:ldap, :port]) + + with_mocks [ + {:eldap, [], + [ + open: fn [^host], [{:port, ^port}, {:ssl, false} | _] -> {:ok, self()} end, + simple_bind: fn _connection, _dn, ^password -> :ok end, + close: fn _connection -> + send(self(), :close_connection) + :ok + end + ]} + ] do + conn = + build_conn() + |> post("/oauth/token", %{ + "grant_type" => "password", + "username" => user.nickname, + "password" => password, + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + + assert %{"access_token" => token} = json_response(conn, 200) + + token = Repo.get_by(Token, token: token) + + assert token.user_id == user.id + assert_received :close_connection + end + end + + @tag @skip + test "creates a new user after successful LDAP authorization" do + password = "testpassword" + user = build(:user) + app = insert(:oauth_app, scopes: ["read", "write"]) + + host = Pleroma.Config.get([:ldap, :host]) |> to_charlist + port = Pleroma.Config.get([:ldap, :port]) + + with_mocks [ + {:eldap, [], + [ + open: fn [^host], [{:port, ^port}, {:ssl, false} | _] -> {:ok, self()} end, + simple_bind: fn _connection, _dn, ^password -> :ok end, + equalityMatch: fn _type, _value -> :ok end, + wholeSubtree: fn -> :ok end, + search: fn _connection, _options -> + {:ok, {:eldap_search_result, [{:eldap_entry, '', []}], []}} + end, + close: fn _connection -> + send(self(), :close_connection) + :ok + end + ]} + ] do + conn = + build_conn() + |> post("/oauth/token", %{ + "grant_type" => "password", + "username" => user.nickname, + "password" => password, + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + + assert %{"access_token" => token} = json_response(conn, 200) + + token = Repo.get_by(Token, token: token) |> Repo.preload(:user) + + assert token.user.nickname == user.nickname + assert_received :close_connection + end + end + + @tag @skip + test "disallow authorization for wrong LDAP credentials" do + password = "testpassword" + user = insert(:user, password_hash: Pleroma.Password.Pbkdf2.hash_pwd_salt(password)) + app = insert(:oauth_app, scopes: ["read", "write"]) + + host = Pleroma.Config.get([:ldap, :host]) |> to_charlist + port = Pleroma.Config.get([:ldap, :port]) + + with_mocks [ + {:eldap, [], + [ + open: fn [^host], [{:port, ^port}, {:ssl, false} | _] -> {:ok, self()} end, + simple_bind: fn _connection, _dn, ^password -> {:error, :invalidCredentials} end, + close: fn _connection -> + send(self(), :close_connection) + :ok + end + ]} + ] do + conn = + build_conn() + |> post("/oauth/token", %{ + "grant_type" => "password", + "username" => user.nickname, + "password" => password, + "client_id" => app.client_id, + "client_secret" => app.client_secret + }) + + assert %{"error" => "Invalid credentials"} = json_response(conn, 400) + assert_received :close_connection + end + end +end