diff options
| -rw-r--r-- | config/config.exs | 11 | ||||
| -rw-r--r-- | docs/config.md | 23 | ||||
| -rw-r--r-- | lib/pleroma/web/auth/ldap_authenticator.ex | 143 | ||||
| -rw-r--r-- | lib/pleroma/web/auth/pleroma_authenticator.ex | 9 | ||||
| -rw-r--r-- | lib/pleroma/web/oauth/oauth_controller.ex | 8 | ||||
| -rw-r--r-- | test/web/oauth/ldap_authorization_test.exs | 187 | 
6 files changed, 375 insertions, 6 deletions
| diff --git a/config/config.exs b/config/config.exs index 326c4b601..ccdd35777 100644 --- a/config/config.exs +++ b/config/config.exs @@ -370,6 +370,17 @@ config :auto_linker,      rel: false    ] +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" +  # Import environment specific config. This must remain at the bottom  # of this file so it overrides the configuration defined above.  import_config "#{Mix.env()}.exs" diff --git a/docs/config.md b/docs/config.md index e34ffe980..201180373 100644 --- a/docs/config.md +++ b/docs/config.md @@ -331,3 +331,26 @@ config :auto_linker,      rel: false    ]  ``` + +## :ldap + +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 an 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. + +* `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" + +## Pleroma.Web.Auth.Authenticator + +* `Pleroma.Web.Auth.PleromaAuthenticator`: default database authenticator +* `Pleroma.Web.Auth.LDAPAuthenticator`: LDAP authentication diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex new file mode 100644 index 000000000..88217aab8 --- /dev/null +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -0,0 +1,143 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.Web.Auth.LDAPAuthenticator do +  alias Pleroma.User + +  require Logger + +  @behaviour Pleroma.Web.Auth.Authenticator + +  @connection_timeout 10_000 +  @search_timeout 10_000 + +  def get_user(%Plug.Conn{} = conn) do +    if Pleroma.Config.get([:ldap, :enabled]) do +      {name, password} = +        case conn.params do +          %{"authorization" => %{"name" => name, "password" => password}} -> +            {name, password} + +          %{"grant_type" => "password", "username" => name, "password" => password} -> +            {name, password} +        end + +      case ldap_user(name, password) do +        %User{} = user -> +          {:ok, user} + +        {:error, {:ldap_connection_error, _}} -> +          # When LDAP is unavailable, try default authenticator +          Pleroma.Web.Auth.PleromaAuthenticator.get_user(conn) + +        error -> +          error +      end +    else +      # Fall back to default authenticator +      Pleroma.Web.Auth.PleromaAuthenticator.get_user(conn) +    end +  end + +  def handle_error(%Plug.Conn{} = _conn, error) do +    error +  end + +  def auth_template, do: nil + +  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 User.get_by_nickname_or_email(name) do +          %User{} = user -> +            user + +          _ -> +            register_user(connection, base, uid, name, password) +        end + +      error -> +        error +    end +  end + +  defp register_user(connection, base, uid, name, password) do +    case :eldap.search(connection, [ +           {:base, to_charlist(base)}, +           {:filter, :eldap.equalityMatch(to_charlist(uid), to_charlist(name))}, +           {:scope, :eldap.wholeSubtree()}, +           {:attributes, ['mail', 'email']}, +           {:timeout, @search_timeout} +         ]) do +      {:ok, {:eldap_search_result, [{:eldap_entry, _, attributes}], _}} -> +        with {_, [mail]} <- List.keyfind(attributes, 'mail', 0) do +          params = %{ +            email: :erlang.list_to_binary(mail), +            name: name, +            nickname: name, +            password: password, +            password_confirmation: password +          } + +          changeset = User.register_changeset(%User{}, params) + +          case User.register(changeset) do +            {:ok, user} -> user +            error -> error +          end +        else +          _ -> +            Logger.error("Could not find LDAP attribute mail: #{inspect(attributes)}") +            {:error, :ldap_registration_missing_attributes} +        end + +      error -> +        error +    end +  end +end diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex index 333446bef..94a19ad49 100644 --- a/lib/pleroma/web/auth/pleroma_authenticator.ex +++ b/lib/pleroma/web/auth/pleroma_authenticator.ex @@ -9,7 +9,14 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator do    @behaviour Pleroma.Web.Auth.Authenticator    def get_user(%Plug.Conn{} = conn) do -    %{"authorization" => %{"name" => name, "password" => password}} = conn.params +    {name, password} = +      case conn.params do +        %{"authorization" => %{"name" => name, "password" => password}} -> +          {name, password} + +        %{"grant_type" => "password", "username" => name, "password" => password} -> +          {name, password} +      end      with {_, %User{} = user} <- {:user, User.get_by_nickname_or_email(name)},           {_, true} <- {:checkpw, Pbkdf2.checkpw(password, user.password_hash)} do diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index ec70b7ccc..d69383d40 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -5,7 +5,6 @@  defmodule Pleroma.Web.OAuth.OAuthController do    use Pleroma.Web, :controller -  alias Comeonin.Pbkdf2    alias Pleroma.Repo    alias Pleroma.User    alias Pleroma.Web.Auth.Authenticator @@ -126,11 +125,10 @@ defmodule Pleroma.Web.OAuth.OAuthController do    def token_exchange(          conn, -        %{"grant_type" => "password", "username" => name, "password" => password} = params +        %{"grant_type" => "password"} = params        ) do -    with %App{} = app <- get_app_from_request(conn, params), -         %User{} = user <- User.get_by_nickname_or_email(name), -         true <- Pbkdf2.checkpw(password, user.password_hash), +    with {_, {:ok, %User{} = user}} <- {:get_user, Authenticator.get_user(conn)}, +         %App{} = app <- get_app_from_request(conn, params),           {:auth_active, true} <- {:auth_active, User.auth_active?(user)},           scopes <- oauth_scopes(params, app.scopes),           [] <- scopes -- app.scopes, diff --git a/test/web/oauth/ldap_authorization_test.exs b/test/web/oauth/ldap_authorization_test.exs new file mode 100644 index 000000000..570e41f3e --- /dev/null +++ b/test/web/oauth/ldap_authorization_test.exs @@ -0,0 +1,187 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/> +# 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 ExUnit.CaptureLog +  import Mock + +  setup_all do +    ldap_authenticator = Pleroma.Config.get([Pleroma.Web.Auth.Authenticator]) +    ldap_enabled = Pleroma.Config.get([:ldap, :enabled]) + +    on_exit(fn -> +      Pleroma.Config.put([Pleroma.Web.Auth.Authenticator], ldap_authenticator) +      Pleroma.Config.put([:ldap, :enabled], ldap_enabled) +    end) + +    Pleroma.Config.put([Pleroma.Web.Auth.Authenticator], Pleroma.Web.Auth.LDAPAuthenticator) +    Pleroma.Config.put([:ldap, :enabled], true) + +    :ok +  end + +  test "authorizes the existing user using LDAP credentials" do +    password = "testpassword" +    user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(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 + +  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, '', [{'mail', [to_charlist(user.email)]}]}], +             []}} +         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 + +  test "falls back to the default authorization when LDAP is unavailable" do +    password = "testpassword" +    user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(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} | _] -> {:error, 'connect failed'} end, +         simple_bind: fn _connection, _dn, ^password -> :ok end, +         close: fn _connection -> +           send(self(), :close_connection) +           :ok +         end +       ]} +    ] do +      log = +        capture_log(fn -> +          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 +        end) + +      assert log =~ "Could not open LDAP connection: 'connect failed'" +      refute_received :close_connection +    end +  end + +  test "disallow authorization for wrong LDAP credentials" do +    password = "testpassword" +    user = insert(:user, password_hash: Comeonin.Pbkdf2.hashpwsalt(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 | 
