[#1682] Fixed Basic Auth permissions issue by disabling OAuth scopes checks when password is provided. Refactored plugs skipping functionality.

This commit is contained in:
Ivan Tashkinov 2020-04-17 21:21:10 +03:00
parent e0d7847bc5
commit 66f55106bd
9 changed files with 101 additions and 23 deletions

View file

@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [unreleased-patch] ## [unreleased-patch]
### Fixed ### Fixed
- Logger configuration through AdminFE - Logger configuration through AdminFE
- HTTP Basic Authentication permissions issue
### Added ### Added
<details> <details>

View file

@ -4,8 +4,11 @@
defmodule Pleroma.Plugs.AuthenticationPlug do defmodule Pleroma.Plugs.AuthenticationPlug do
alias Comeonin.Pbkdf2 alias Comeonin.Pbkdf2
import Plug.Conn alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
import Plug.Conn
require Logger require Logger
def init(options), do: options def init(options), do: options
@ -37,6 +40,7 @@ def call(
if Pbkdf2.checkpw(password, password_hash) do if Pbkdf2.checkpw(password, password_hash) do
conn conn
|> assign(:user, auth_user) |> assign(:user, auth_user)
|> OAuthScopesPlug.skip_plug()
else else
conn conn
end end

View file

@ -4,6 +4,8 @@
defmodule Pleroma.Plugs.LegacyAuthenticationPlug do defmodule Pleroma.Plugs.LegacyAuthenticationPlug do
import Plug.Conn import Plug.Conn
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
def init(options) do def init(options) do
@ -27,6 +29,7 @@ def call(
conn conn
|> assign(:auth_user, user) |> assign(:auth_user, user)
|> assign(:user, user) |> assign(:user, user)
|> OAuthScopesPlug.skip_plug()
else else
_ -> _ ->
conn conn

View file

@ -5,30 +5,32 @@
defmodule Pleroma.Plugs.PlugHelper do defmodule Pleroma.Plugs.PlugHelper do
@moduledoc "Pleroma Plug helper" @moduledoc "Pleroma Plug helper"
def append_to_called_plugs(conn, plug_module) do @called_plugs_list_id :called_plugs
append_to_private_list(conn, :called_plugs, plug_module) def called_plugs_list_id, do: @called_plugs_list_id
end
def append_to_skipped_plugs(conn, plug_module) do @skipped_plugs_list_id :skipped_plugs
append_to_private_list(conn, :skipped_plugs, plug_module) def skipped_plugs_list_id, do: @skipped_plugs_list_id
end
@doc "Returns `true` if specified plug was called."
def plug_called?(conn, plug_module) do def plug_called?(conn, plug_module) do
contained_in_private_list?(conn, :called_plugs, plug_module) contained_in_private_list?(conn, @called_plugs_list_id, plug_module)
end end
@doc "Returns `true` if specified plug was explicitly marked as skipped."
def plug_skipped?(conn, plug_module) do def plug_skipped?(conn, plug_module) do
contained_in_private_list?(conn, :skipped_plugs, plug_module) contained_in_private_list?(conn, @skipped_plugs_list_id, plug_module)
end end
@doc "Returns `true` if specified plug was either called or explicitly marked as skipped."
def plug_called_or_skipped?(conn, plug_module) do def plug_called_or_skipped?(conn, plug_module) do
plug_called?(conn, plug_module) || plug_skipped?(conn, plug_module) plug_called?(conn, plug_module) || plug_skipped?(conn, plug_module)
end end
defp append_to_private_list(conn, private_variable, value) do # Appends plug to known list (skipped, called). Intended to be used from within plug code only.
list = conn.private[private_variable] || [] def append_to_private_list(conn, list_id, value) do
list = conn.private[list_id] || []
modified_list = Enum.uniq(list ++ [value]) modified_list = Enum.uniq(list ++ [value])
Plug.Conn.put_private(conn, private_variable, modified_list) Plug.Conn.put_private(conn, list_id, modified_list)
end end
defp contained_in_private_list?(conn, private_variable, value) do defp contained_in_private_list?(conn, private_variable, value) do

View file

@ -40,17 +40,22 @@ defp set_put_layout(conn, _) do
# Marks a plug intentionally skipped and blocks its execution if it's present in plugs chain # Marks a plug intentionally skipped and blocks its execution if it's present in plugs chain
defp skip_plug(conn, plug_module) do defp skip_plug(conn, plug_module) do
try do try do
plug_module.ensure_skippable() plug_module.skip_plug(conn)
rescue rescue
UndefinedFunctionError -> UndefinedFunctionError ->
raise "#{plug_module} is not skippable. Append `use Pleroma.Web, :plug` to its code." raise "#{plug_module} is not skippable. Append `use Pleroma.Web, :plug` to its code."
end end
PlugHelper.append_to_skipped_plugs(conn, plug_module)
end end
# Here we can apply before-action hooks (e.g. verify whether auth checks were preformed) # Executed just before actual controller action, invokes before-action hooks (callbacks)
defp action(conn, params) do defp action(conn, params) do
with %Plug.Conn{halted: false} <- maybe_halt_on_missing_oauth_scopes_check(conn) do
super(conn, params)
end
end
# Halts if authenticated API action neither performs nor explicitly skips OAuth scopes check
defp maybe_halt_on_missing_oauth_scopes_check(conn) do
if Pleroma.Plugs.AuthExpectedPlug.auth_expected?(conn) && if Pleroma.Plugs.AuthExpectedPlug.auth_expected?(conn) &&
not PlugHelper.plug_called_or_skipped?(conn, Pleroma.Plugs.OAuthScopesPlug) do not PlugHelper.plug_called_or_skipped?(conn, Pleroma.Plugs.OAuthScopesPlug) do
conn conn
@ -60,7 +65,7 @@ defp action(conn, params) do
) )
|> halt() |> halt()
else else
super(conn, params) conn
end end
end end
end end
@ -129,7 +134,16 @@ def plug do
quote do quote do
alias Pleroma.Plugs.PlugHelper alias Pleroma.Plugs.PlugHelper
def ensure_skippable, do: :noop @doc """
Marks a plug intentionally skipped and blocks its execution if it's present in plugs chain.
"""
def skip_plug(conn) do
PlugHelper.append_to_private_list(
conn,
PlugHelper.skipped_plugs_list_id(),
__MODULE__
)
end
@impl Plug @impl Plug
@doc "If marked as skipped, returns `conn`, and calls `perform/2` otherwise." @doc "If marked as skipped, returns `conn`, and calls `perform/2` otherwise."
@ -138,7 +152,7 @@ def call(%Plug.Conn{} = conn, options) do
conn conn
else else
conn conn
|> PlugHelper.append_to_called_plugs(__MODULE__) |> PlugHelper.append_to_private_list(PlugHelper.called_plugs_list_id(), __MODULE__)
|> perform(options) |> perform(options)
end end
end end

View file

@ -6,6 +6,8 @@ defmodule Pleroma.Plugs.AuthenticationPlugTest do
use Pleroma.Web.ConnCase, async: true use Pleroma.Web.ConnCase, async: true
alias Pleroma.Plugs.AuthenticationPlug alias Pleroma.Plugs.AuthenticationPlug
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Plugs.PlugHelper
alias Pleroma.User alias Pleroma.User
import ExUnit.CaptureLog import ExUnit.CaptureLog
@ -36,13 +38,16 @@ test "it does nothing if a user is assigned", %{conn: conn} do
assert ret_conn == conn assert ret_conn == conn
end end
test "with a correct password in the credentials, it assigns the auth_user", %{conn: conn} do test "with a correct password in the credentials, " <>
"it assigns the auth_user and marks OAuthScopesPlug as skipped",
%{conn: conn} do
conn = conn =
conn conn
|> assign(:auth_credentials, %{password: "guy"}) |> assign(:auth_credentials, %{password: "guy"})
|> AuthenticationPlug.call(%{}) |> AuthenticationPlug.call(%{})
assert conn.assigns.user == conn.assigns.auth_user assert conn.assigns.user == conn.assigns.auth_user
assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug)
end end
test "with a wrong password in the credentials, it does nothing", %{conn: conn} do test "with a wrong password in the credentials, it does nothing", %{conn: conn} do

View file

@ -8,6 +8,8 @@ defmodule Pleroma.Plugs.LegacyAuthenticationPlugTest do
import Pleroma.Factory import Pleroma.Factory
alias Pleroma.Plugs.LegacyAuthenticationPlug alias Pleroma.Plugs.LegacyAuthenticationPlug
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Plugs.PlugHelper
alias Pleroma.User alias Pleroma.User
setup do setup do
@ -36,7 +38,8 @@ test "it does nothing if a user is assigned", %{conn: conn, user: user} do
end end
@tag :skip_on_mac @tag :skip_on_mac
test "it authenticates the auth_user if present and password is correct and resets the password", test "if `auth_user` is present and password is correct, " <>
"it authenticates the user, resets the password, marks OAuthScopesPlug as skipped",
%{ %{
conn: conn, conn: conn,
user: user user: user
@ -49,6 +52,7 @@ test "it authenticates the auth_user if present and password is correct and rese
conn = LegacyAuthenticationPlug.call(conn, %{}) conn = LegacyAuthenticationPlug.call(conn, %{})
assert conn.assigns.user.id == user.id assert conn.assigns.user.id == user.id
assert PlugHelper.plug_skipped?(conn, OAuthScopesPlug)
end end
@tag :skip_on_mac @tag :skip_on_mac

View file

@ -7,7 +7,6 @@ defmodule Pleroma.Plugs.OAuthScopesPlugTest do
alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug
alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Plugs.PlugHelper
alias Pleroma.Repo alias Pleroma.Repo
import Mock import Mock
@ -21,7 +20,7 @@ test "is not performed if marked as skipped", %{conn: conn} do
with_mock OAuthScopesPlug, [:passthrough], perform: &passthrough([&1, &2]) do with_mock OAuthScopesPlug, [:passthrough], perform: &passthrough([&1, &2]) do
conn = conn =
conn conn
|> PlugHelper.append_to_skipped_plugs(OAuthScopesPlug) |> OAuthScopesPlug.skip_plug()
|> OAuthScopesPlug.call(%{scopes: ["random_scope"]}) |> OAuthScopesPlug.call(%{scopes: ["random_scope"]})
refute called(OAuthScopesPlug.perform(:_, :_)) refute called(OAuthScopesPlug.perform(:_, :_))

View file

@ -0,0 +1,46 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Auth.BasicAuthTest do
use Pleroma.Web.ConnCase
import Pleroma.Factory
test "with HTTP Basic Auth used, grants access to OAuth scope-restricted endpoints", %{
conn: conn
} do
user = insert(:user)
assert Comeonin.Pbkdf2.checkpw("test", user.password_hash)
basic_auth_contents =
(URI.encode_www_form(user.nickname) <> ":" <> URI.encode_www_form("test"))
|> Base.encode64()
# Succeeds with HTTP Basic Auth
response =
conn
|> put_req_header("authorization", "Basic " <> basic_auth_contents)
|> get("/api/v1/accounts/verify_credentials")
|> json_response(200)
user_nickname = user.nickname
assert %{"username" => ^user_nickname} = response
# Succeeds with a properly scoped OAuth token
valid_token = insert(:oauth_token, scopes: ["read:accounts"])
conn
|> put_req_header("authorization", "Bearer #{valid_token.token}")
|> get("/api/v1/accounts/verify_credentials")
|> json_response(200)
# Fails with a wrong-scoped OAuth token (proof of restriction)
invalid_token = insert(:oauth_token, scopes: ["read:something"])
conn
|> put_req_header("authorization", "Bearer #{invalid_token.token}")
|> get("/api/v1/accounts/verify_credentials")
|> json_response(403)
end
end