Auth subsystem refactoring and tweaks.

Added proper OAuth skipping for SessionAuthenticationPlug. Integrated LegacyAuthenticationPlug into AuthenticationPlug. Adjusted tests & docs.
This commit is contained in:
Ivan Tashkinov 2020-10-31 13:38:35 +03:00
parent 4fbdd1c8a1
commit 04f6b48ac1
15 changed files with 97 additions and 182 deletions

View File

@ -14,9 +14,9 @@ This document contains notes and guidelines for Pleroma developers.
For `:api` pipeline routes, it'll be verified whether `OAuthScopesPlug` was called or explicitly skipped, and if it was not then auth information will be dropped for request. Then `EnsurePublicOrAuthenticatedPlug` will be called to ensure that either the instance is not private or user is authenticated (unless explicitly skipped). Such automated checks help to prevent human errors and result in higher security / privacy for users. For `:api` pipeline routes, it'll be verified whether `OAuthScopesPlug` was called or explicitly skipped, and if it was not then auth information will be dropped for request. Then `EnsurePublicOrAuthenticatedPlug` will be called to ensure that either the instance is not private or user is authenticated (unless explicitly skipped). Such automated checks help to prevent human errors and result in higher security / privacy for users.
## [HTTP Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) ## Non-OAuth authentication
* With HTTP Basic Auth, OAuth scopes check is _not_ performed for any action (since password is provided during the auth, requester is able to obtain a token with full permissions anyways). `Pleroma.Web.Plugs.AuthenticationPlug` and `Pleroma.Web.Plugs.LegacyAuthenticationPlug` both call `Pleroma.Web.Plugs.OAuthScopesPlug.skip_plug(conn)` when password is provided. * With non-OAuth authentication ([HTTP Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) or HTTP header- or params-provided auth), OAuth scopes check is _not_ performed for any action (since password is provided during the auth, requester is able to obtain a token with full permissions anyways); auth plugs invoke `Pleroma.Helpers.AuthHelper.skip_oauth(conn)` in this case.
## Auth-related configuration, OAuth consumer mode etc. ## Auth-related configuration, OAuth consumer mode etc.

View File

@ -0,0 +1,17 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Helpers.AuthHelper do
alias Pleroma.Web.Plugs.OAuthScopesPlug
@doc """
Skips OAuth permissions (scopes) checks, assigns nil `:token`.
Intended to be used with explicit authentication and only when OAuth token cannot be determined.
"""
def skip_oauth(conn) do
conn
|> Plug.Conn.assign(:token, nil)
|> OAuthScopesPlug.skip_plug()
end
end

View File

@ -5,8 +5,8 @@
defmodule Pleroma.Web.Plugs.AdminSecretAuthenticationPlug do defmodule Pleroma.Web.Plugs.AdminSecretAuthenticationPlug do
import Plug.Conn import Plug.Conn
alias Pleroma.Helpers.AuthHelper
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.Plugs.RateLimiter alias Pleroma.Web.Plugs.RateLimiter
def init(options) do def init(options) do
@ -51,7 +51,7 @@ def authenticate(conn) do
defp assign_admin_user(conn) do defp assign_admin_user(conn) do
conn conn
|> assign(:user, %User{is_admin: true}) |> assign(:user, %User{is_admin: true})
|> OAuthScopesPlug.skip_plug() |> AuthHelper.skip_oauth()
end end
defp handle_bad_token(conn) do defp handle_bad_token(conn) do

View File

@ -3,6 +3,9 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.AuthenticationPlug do defmodule Pleroma.Web.Plugs.AuthenticationPlug do
@moduledoc "Password authentication plug."
alias Pleroma.Helpers.AuthHelper
alias Pleroma.User alias Pleroma.User
import Plug.Conn import Plug.Conn
@ -11,6 +14,30 @@ defmodule Pleroma.Web.Plugs.AuthenticationPlug do
def init(options), do: options def init(options), do: options
def call(%{assigns: %{user: %User{}}} = conn, _), do: conn
def call(
%{
assigns: %{
auth_user: %{password_hash: password_hash} = auth_user,
auth_credentials: %{password: password}
}
} = conn,
_
) do
if checkpw(password, password_hash) do
{:ok, auth_user} = maybe_update_password(auth_user, password)
conn
|> assign(:user, auth_user)
|> AuthHelper.skip_oauth()
else
conn
end
end
def call(conn, _), do: conn
def checkpw(password, "$6" <> _ = password_hash) do def checkpw(password, "$6" <> _ = password_hash) do
:crypt.crypt(password, password_hash) == password_hash :crypt.crypt(password, password_hash) == password_hash
end end
@ -40,40 +67,6 @@ def maybe_update_password(%User{password_hash: "$6" <> _} = user, password) do
def maybe_update_password(user, _), do: {:ok, user} def maybe_update_password(user, _), do: {:ok, user}
defp do_update_password(user, password) do defp do_update_password(user, password) do
user User.reset_password(user, %{password: password, password_confirmation: password})
|> User.password_update_changeset(%{
"password" => password,
"password_confirmation" => password
})
|> Pleroma.Repo.update()
end end
def call(%{assigns: %{user: %User{}}} = conn, _), do: conn
def call(
%{
assigns: %{
auth_user: %{password_hash: password_hash} = auth_user,
auth_credentials: %{password: password}
}
} = conn,
_
) do
if checkpw(password, password_hash) do
{:ok, auth_user} = maybe_update_password(auth_user, password)
conn
|> assign(:user, auth_user)
|> Pleroma.Web.Plugs.OAuthScopesPlug.skip_plug()
else
conn
end
end
def call(%{assigns: %{auth_credentials: %{password: _}}} = conn, _) do
Pbkdf2.no_user_verify()
conn
end
def call(conn, _), do: conn
end end

View File

@ -3,6 +3,12 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.BasicAuthDecoderPlug do defmodule Pleroma.Web.Plugs.BasicAuthDecoderPlug do
@moduledoc """
Decodes HTTP Basic Auth information and assigns `:auth_credentials`.
NOTE: no checks are performed at this step, auth_credentials/username could be easily faked.
"""
import Plug.Conn import Plug.Conn
def init(options) do def init(options) do

View File

@ -5,6 +5,8 @@
defmodule Pleroma.Web.Plugs.EnsureUserKeyPlug do defmodule Pleroma.Web.Plugs.EnsureUserKeyPlug do
import Plug.Conn import Plug.Conn
@moduledoc "Ensures `conn.assigns.user` is initialized."
def init(opts) do def init(opts) do
opts opts
end end
@ -12,7 +14,6 @@ def init(opts) do
def call(%{assigns: %{user: _}} = conn, _), do: conn def call(%{assigns: %{user: _}} = conn, _), do: conn
def call(conn, _) do def call(conn, _) do
conn assign(conn, :user, nil)
|> assign(:user, nil)
end end
end end

View File

@ -1,41 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.LegacyAuthenticationPlug do
import Plug.Conn
alias Pleroma.User
def init(options) do
options
end
def call(%{assigns: %{user: %User{}}} = conn, _), do: conn
def call(
%{
assigns: %{
auth_user: %{password_hash: "$6$" <> _ = password_hash} = auth_user,
auth_credentials: %{password: password}
}
} = conn,
_
) do
with ^password_hash <- :crypt.crypt(password, password_hash),
{:ok, user} <-
User.reset_password(auth_user, %{password: password, password_confirmation: password}) do
conn
|> assign(:auth_user, user)
|> assign(:user, user)
|> Pleroma.Web.Plugs.OAuthScopesPlug.skip_plug()
else
_ ->
conn
end
end
def call(conn, _) do
conn
end
end

View File

@ -3,17 +3,27 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.SessionAuthenticationPlug do defmodule Pleroma.Web.Plugs.SessionAuthenticationPlug do
@moduledoc """
Authenticates user by session-stored `:user_id` and request-contained username.
Username can be provided via HTTP Basic Auth (the password is not checked and can be anything).
"""
import Plug.Conn import Plug.Conn
alias Pleroma.Helpers.AuthHelper
def init(options) do def init(options) do
options options
end end
def call(%{assigns: %{user: %Pleroma.User{}}} = conn, _), do: conn
def call(conn, _) do def call(conn, _) do
with saved_user_id <- get_session(conn, :user_id), with saved_user_id <- get_session(conn, :user_id),
%{auth_user: %{id: ^saved_user_id}} <- conn.assigns do %{auth_user: %{id: ^saved_user_id}} <- conn.assigns do
conn conn
|> assign(:user, conn.assigns.auth_user) |> assign(:user, conn.assigns.auth_user)
|> AuthHelper.skip_oauth()
else else
_ -> conn _ -> conn
end end

View File

@ -11,8 +11,7 @@ def init(opts) do
end end
def call(%{assigns: %{user: %User{id: id}}} = conn, _) do def call(%{assigns: %{user: %User{id: id}}} = conn, _) do
conn put_session(conn, :user_id, id)
|> put_session(:user_id, id)
end end
def call(conn, _), do: conn def call(conn, _), do: conn

View File

@ -3,6 +3,12 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.UserFetcherPlug do defmodule Pleroma.Web.Plugs.UserFetcherPlug do
@moduledoc """
Assigns `:auth_user` basing on `:auth_credentials`.
NOTE: no checks are performed at this step, auth_credentials/username could be easily faked.
"""
alias Pleroma.User alias Pleroma.User
import Plug.Conn import Plug.Conn

View File

@ -49,7 +49,6 @@ defmodule Pleroma.Web.Router do
plug(Pleroma.Web.Plugs.BasicAuthDecoderPlug) plug(Pleroma.Web.Plugs.BasicAuthDecoderPlug)
plug(Pleroma.Web.Plugs.UserFetcherPlug) plug(Pleroma.Web.Plugs.UserFetcherPlug)
plug(Pleroma.Web.Plugs.SessionAuthenticationPlug) plug(Pleroma.Web.Plugs.SessionAuthenticationPlug)
plug(Pleroma.Web.Plugs.LegacyAuthenticationPlug)
plug(Pleroma.Web.Plugs.AuthenticationPlug) plug(Pleroma.Web.Plugs.AuthenticationPlug)
end end

View File

@ -49,6 +49,7 @@ test "with `admin_token` query parameter", %{conn: conn} do
|> AdminSecretAuthenticationPlug.call(%{}) |> AdminSecretAuthenticationPlug.call(%{})
assert conn.assigns[:user].is_admin assert conn.assigns[:user].is_admin
assert conn.assigns[:token] == nil
assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug)
end end
@ -69,6 +70,7 @@ test "with `x-admin-token` HTTP header", %{conn: conn} do
|> AdminSecretAuthenticationPlug.call(%{}) |> AdminSecretAuthenticationPlug.call(%{})
assert conn.assigns[:user].is_admin assert conn.assigns[:user].is_admin
assert conn.assigns[:token] == nil
assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug)
end end
end end

View File

@ -48,6 +48,7 @@ test "with a correct password in the credentials, " <>
|> AuthenticationPlug.call(%{}) |> AuthenticationPlug.call(%{})
assert conn.assigns.user == conn.assigns.auth_user assert conn.assigns.user == conn.assigns.auth_user
assert conn.assigns.token == nil
assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug)
end end
@ -62,6 +63,7 @@ test "with a bcrypt hash, it updates to a pkbdf2 hash", %{conn: conn} do
|> AuthenticationPlug.call(%{}) |> AuthenticationPlug.call(%{})
assert conn.assigns.user.id == conn.assigns.auth_user.id assert conn.assigns.user.id == conn.assigns.auth_user.id
assert conn.assigns.token == nil
assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug)
user = User.get_by_id(user.id) user = User.get_by_id(user.id)
@ -83,6 +85,7 @@ test "with a crypt hash, it updates to a pkbdf2 hash", %{conn: conn} do
|> AuthenticationPlug.call(%{}) |> AuthenticationPlug.call(%{})
assert conn.assigns.user.id == conn.assigns.auth_user.id assert conn.assigns.user.id == conn.assigns.auth_user.id
assert conn.assigns.token == nil
assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug) assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug)
user = User.get_by_id(user.id) user = User.get_by_id(user.id)

View File

@ -1,82 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Plugs.LegacyAuthenticationPlugTest do
use Pleroma.Web.ConnCase
import Pleroma.Factory
alias Pleroma.User
alias Pleroma.Web.Plugs.LegacyAuthenticationPlug
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.Plugs.PlugHelper
setup do
user =
insert(:user,
password: "password",
password_hash:
"$6$9psBWV8gxkGOZWBz$PmfCycChoxeJ3GgGzwvhlgacb9mUoZ.KUXNCssekER4SJ7bOK53uXrHNb2e4i8yPFgSKyzaW9CcmrDXWIEMtD1"
)
%{user: user}
end
test "it does nothing if a user is assigned", %{conn: conn, user: user} do
conn =
conn
|> assign(:auth_credentials, %{username: "dude", password: "password"})
|> assign(:auth_user, user)
|> assign(:user, %User{})
ret_conn =
conn
|> LegacyAuthenticationPlug.call(%{})
assert ret_conn == conn
end
@tag :skip_on_mac
test "if `auth_user` is present and password is correct, " <>
"it authenticates the user, resets the password, marks OAuthScopesPlug as skipped",
%{
conn: conn,
user: user
} do
conn =
conn
|> assign(:auth_credentials, %{username: "dude", password: "password"})
|> assign(:auth_user, user)
conn = LegacyAuthenticationPlug.call(conn, %{})
assert conn.assigns.user.id == user.id
assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug)
end
@tag :skip_on_mac
test "it does nothing if the password is wrong", %{
conn: conn,
user: user
} do
conn =
conn
|> assign(:auth_credentials, %{username: "dude", password: "wrong_password"})
|> assign(:auth_user, user)
ret_conn =
conn
|> LegacyAuthenticationPlug.call(%{})
assert conn == ret_conn
end
test "with no credentials or user it does nothing", %{conn: conn} do
ret_conn =
conn
|> LegacyAuthenticationPlug.call(%{})
assert ret_conn == conn
end
end

View File

@ -6,6 +6,8 @@ defmodule Pleroma.Web.Plugs.SessionAuthenticationPlugTest do
use Pleroma.Web.ConnCase, async: true use Pleroma.Web.ConnCase, async: true
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.Plugs.OAuthScopesPlug
alias Pleroma.Web.Plugs.PlugHelper
alias Pleroma.Web.Plugs.SessionAuthenticationPlug alias Pleroma.Web.Plugs.SessionAuthenticationPlug
setup %{conn: conn} do setup %{conn: conn} do
@ -18,24 +20,20 @@ defmodule Pleroma.Web.Plugs.SessionAuthenticationPlugTest do
conn = conn =
conn conn
|> Plug.Session.call(Plug.Session.init(session_opts)) |> Plug.Session.call(Plug.Session.init(session_opts))
|> fetch_session |> fetch_session()
|> assign(:auth_user, %User{id: 1}) |> assign(:auth_user, %User{id: 1})
%{conn: conn} %{conn: conn}
end end
test "it does nothing if a user is assigned", %{conn: conn} do test "it does nothing if a user is assigned", %{conn: conn} do
conn = conn = assign(conn, :user, %User{})
conn ret_conn = SessionAuthenticationPlug.call(conn, %{})
|> assign(:user, %User{})
ret_conn =
conn
|> SessionAuthenticationPlug.call(%{})
assert ret_conn == conn assert ret_conn == conn
end end
# Scenario: requester has the cookie and knows the username (not necessarily knows the password)
test "if the auth_user has the same id as the user_id in the session, it assigns the user", %{ test "if the auth_user has the same id as the user_id in the session, it assigns the user", %{
conn: conn conn: conn
} do } do
@ -45,19 +43,23 @@ test "if the auth_user has the same id as the user_id in the session, it assigns
|> SessionAuthenticationPlug.call(%{}) |> SessionAuthenticationPlug.call(%{})
assert conn.assigns.user == conn.assigns.auth_user assert conn.assigns.user == conn.assigns.auth_user
assert conn.assigns.token == nil
assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug)
end end
# Scenario: requester has the cookie but doesn't know the username
test "if the auth_user has a different id as the user_id in the session, it does nothing", %{ test "if the auth_user has a different id as the user_id in the session, it does nothing", %{
conn: conn conn: conn
} do } do
conn = conn = put_session(conn, :user_id, -1)
conn ret_conn = SessionAuthenticationPlug.call(conn, %{})
|> put_session(:user_id, -1)
ret_conn =
conn
|> SessionAuthenticationPlug.call(%{})
assert ret_conn == conn assert ret_conn == conn
end end
test "if the session does not contain user_id, it does nothing", %{
conn: conn
} do
assert conn == SessionAuthenticationPlug.call(conn, %{})
end
end end