From e278d470232f4e8081bbbe358137400074673e75 Mon Sep 17 00:00:00 2001 From: link0ff Date: Fri, 22 Feb 2019 15:03:43 +0200 Subject: [PATCH 1/4] OpenLDAP support --- config/config.exs | 9 +++ docs/config.md | 9 +++ lib/pleroma/ldap.ex | 84 +++++++++++++++++++++++ lib/pleroma/web/oauth/oauth_controller.ex | 27 +++++++- 4 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 lib/pleroma/ldap.ex diff --git a/config/config.exs b/config/config.exs index 6119aaea1..28d93e086 100644 --- a/config/config.exs +++ b/config/config.exs @@ -344,6 +344,15 @@ federator_outgoing: [max_jobs: 50], mailer: [max_jobs: 10] +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: [], + 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 14723b727..a497fb4ca 100644 --- a/docs/config.md +++ b/docs/config.md @@ -301,3 +301,12 @@ For each pool, the options are: * `max_connections` - how much connections a pool can hold * `timeout` - retention duration for connections +## :ldap + +* `enabled`: enables LDAP authentication +* `host`: LDAP server hostname +* `port`: LDAP port, e.g. 389 or 636 +* `ssl`: true to use SSL +* `sslopts`: additional SSL options +* `base`: LDAP base, e.g. "dc=example,dc=com" +* `uid`: attribute type to authenticate the user, e.g. when "cn", the filter will be "cn=username,base" diff --git a/lib/pleroma/ldap.ex b/lib/pleroma/ldap.ex new file mode 100644 index 000000000..282d8e553 --- /dev/null +++ b/lib/pleroma/ldap.ex @@ -0,0 +1,84 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2019 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Pleroma.LDAP do + alias Pleroma.User + + require Logger + + @connection_timeout 10_000 + @search_timeout 10_000 + + def get_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 + 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 + after + :eldap.close(connection) + end + + {:error, error} -> + Logger.error("Could not open LDAP connection: #{inspect(error)}") + {:error, {:ldap_connection_error, error}} + end + end + + def 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()}, + {: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 + _ -> {:error, :ldap_registration_missing_attributes} + end + + error -> + error + end + end +end diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 7c1a3adbd..654beb2c4 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -130,8 +130,7 @@ def token_exchange( %{"grant_type" => "password", "username" => name, "password" => 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), + %User{} = user <- get_user(name, password), {:auth_active, true} <- {:auth_active, User.auth_active?(user)}, scopes <- oauth_scopes(params, app.scopes), [] <- scopes -- app.scopes, @@ -215,4 +214,28 @@ defp get_app_from_request(conn, params) do nil end end + + defp get_user(name, password) do + if Pleroma.Config.get([:ldap, :enabled]) do + case Pleroma.LDAP.get_user(name, password) do + %User{} = user -> + user + + {:error, {:ldap_connection_error, _}} -> + # When LDAP is unavailable, try default login + with %User{} = user <- User.get_by_nickname_or_email(name), + true <- Pbkdf2.checkpw(password, user.password_hash) do + user + end + + error -> + error + end + else + with %User{} = user <- User.get_by_nickname_or_email(name), + true <- Pbkdf2.checkpw(password, user.password_hash) do + user + end + end + end end From 88a672fe88deae53d5459d651859be65555e6af2 Mon Sep 17 00:00:00 2001 From: link0ff Date: Sun, 3 Mar 2019 21:20:36 +0200 Subject: [PATCH 2/4] Move LDAP code to LDAPAuthenticator. Use Authenticator for token_exchange with grant_type as well --- docs/config.md | 5 +++ .../auth/ldap_authenticator.ex} | 42 +++++++++++++++++-- lib/pleroma/web/auth/pleroma_authenticator.ex | 9 +++- lib/pleroma/web/oauth/oauth_controller.ex | 31 ++------------ 4 files changed, 55 insertions(+), 32 deletions(-) rename lib/pleroma/{ldap.ex => web/auth/ldap_authenticator.ex} (68%) diff --git a/docs/config.md b/docs/config.md index d7349d54f..8d6770959 100644 --- a/docs/config.md +++ b/docs/config.md @@ -336,3 +336,8 @@ config :auto_linker, * `sslopts`: additional SSL options * `base`: LDAP base, e.g. "dc=example,dc=com" * `uid`: attribute type 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/ldap.ex b/lib/pleroma/web/auth/ldap_authenticator.ex similarity index 68% rename from lib/pleroma/ldap.ex rename to lib/pleroma/web/auth/ldap_authenticator.ex index 282d8e553..56f2f5aed 100644 --- a/lib/pleroma/ldap.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -2,15 +2,51 @@ # Copyright © 2017-2019 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only -defmodule Pleroma.LDAP do +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(name, password) do + 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) @@ -50,7 +86,7 @@ def get_user(name, password) do end end - def register_user(connection, base, uid, name, password) do + 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))}, diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex index 3cc19af01..360772895 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 c2b6dd477..7d5a5b9c5 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -11,7 +11,6 @@ defmodule Pleroma.Web.OAuth.OAuthController do alias Pleroma.Web.OAuth.App alias Pleroma.Repo alias Pleroma.User - alias Comeonin.Pbkdf2 import Pleroma.Web.ControllerHelper, only: [oauth_scopes: 2] @@ -126,10 +125,10 @@ def token_exchange(conn, %{"grant_type" => "authorization_code"} = params) 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 <- get_user(name, password), + 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, @@ -213,28 +212,4 @@ defp get_app_from_request(conn, params) do nil end end - - defp get_user(name, password) do - if Pleroma.Config.get([:ldap, :enabled]) do - case Pleroma.LDAP.get_user(name, password) do - %User{} = user -> - user - - {:error, {:ldap_connection_error, _}} -> - # When LDAP is unavailable, try default login - with %User{} = user <- User.get_by_nickname_or_email(name), - true <- Pbkdf2.checkpw(password, user.password_hash) do - user - end - - error -> - error - end - else - with %User{} = user <- User.get_by_nickname_or_email(name), - true <- Pbkdf2.checkpw(password, user.password_hash) do - user - end - end - end end From 9338f061a303ae3d57a8ea1af524c2ca51929f8d Mon Sep 17 00:00:00 2001 From: link0ff Date: Tue, 12 Mar 2019 18:20:02 +0200 Subject: [PATCH 3/4] Support LDAP method start_tls --- config/config.exs | 2 + docs/config.md | 12 ++++- lib/pleroma/web/auth/ldap_authenticator.ex | 51 ++++++++++++++++------ 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/config/config.exs b/config/config.exs index 8544dcc0d..1ed330df2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -361,6 +361,8 @@ 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" diff --git a/docs/config.md b/docs/config.md index 8d6770959..7052b1eb4 100644 --- a/docs/config.md +++ b/docs/config.md @@ -329,13 +329,21 @@ config :auto_linker, ## :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 +* `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`: attribute type to authenticate the user, e.g. when "cn", the filter will be "cn=username,base" +* `uid`: LDAP attribute name to authenticate the user, e.g. when "cn", the filter will be "cn=username,base" ## Pleroma.Web.Auth.Authenticator diff --git a/lib/pleroma/web/auth/ldap_authenticator.ex b/lib/pleroma/web/auth/ldap_authenticator.ex index 56f2f5aed..88217aab8 100644 --- a/lib/pleroma/web/auth/ldap_authenticator.ex +++ b/lib/pleroma/web/auth/ldap_authenticator.ex @@ -60,22 +60,23 @@ defp ldap_user(name, password) do case :eldap.open([to_charlist(host)], options) do {:ok, connection} -> try do - uid = Keyword.get(ldap, :uid, "cn") - base = Keyword.get(ldap, :base) + if Keyword.get(ldap, :tls, false) do + :application.ensure_all_started(:ssl) - case :eldap.simple_bind(connection, "#{uid}=#{name},#{base}", password) do - :ok -> - case User.get_by_nickname_or_email(name) do - %User{} = user -> - user + case :eldap.start_tls( + connection, + Keyword.get(ldap, :tlsopts, []), + @connection_timeout + ) do + :ok -> + :ok - _ -> - register_user(connection, base, uid, name, password) - end - - error -> - error + error -> + Logger.error("Could not start TLS: #{inspect(error)}") + end end + + bind_user(connection, ldap, name, password) after :eldap.close(connection) end @@ -86,11 +87,31 @@ defp ldap_user(name, password) do 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}], _}} -> @@ -110,7 +131,9 @@ defp register_user(connection, base, uid, name, password) do error -> error end else - _ -> {:error, :ldap_registration_missing_attributes} + _ -> + Logger.error("Could not find LDAP attribute mail: #{inspect(attributes)}") + {:error, :ldap_registration_missing_attributes} end error -> From a070dd4a83788dfd76809d8b4ee4111b05cdff47 Mon Sep 17 00:00:00 2001 From: link0ff Date: Fri, 15 Mar 2019 16:28:46 +0200 Subject: [PATCH 4/4] Add tests for LDAP authorization --- test/web/oauth/ldap_authorization_test.exs | 187 +++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 test/web/oauth/ldap_authorization_test.exs 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 +# 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