Fixed OAuth restrictions for :api routes. Made auth info dropped for :api routes if OAuth check was neither performed nor explicitly skipped.

This commit is contained in:
Ivan Tashkinov 2020-04-22 18:50:25 +03:00
parent f685cbd309
commit 2958a7d246
14 changed files with 101 additions and 53 deletions

View File

@ -4,29 +4,19 @@ This document contains notes and guidelines for Pleroma developers.
## OAuth token-based authentication & authorization ## OAuth token-based authentication & authorization
* Pleroma supports hierarchical OAuth scopes, just like Mastodon but with added granularity of admin scopes. * Pleroma supports hierarchical OAuth scopes, just like Mastodon but with added granularity of admin scopes. For a reference, see [Mastodon OAuth scopes](https://docs.joinmastodon.org/api/oauth-scopes/).
For a reference, see [Mastodon OAuth scopes](https://docs.joinmastodon.org/api/oauth-scopes/).
* It is important to either define OAuth scope restrictions or explicitly mark OAuth scope check as skipped, for every * It is important to either define OAuth scope restrictions or explicitly mark OAuth scope check as skipped, for every controller action. To define scopes, call `plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: [...]})`. To explicitly set OAuth scopes check skipped, call `plug(:skip_plug, Pleroma.Plugs.OAuthScopesPlug <when ...>)`.
controller action. To define scopes, call `plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: [...]})`. To explicitly set
OAuth scopes check skipped, call `plug(:skip_plug, Pleroma.Plugs.OAuthScopesPlug <when ...>)`.
* In controllers, `use Pleroma.Web, :controller` will result in `action/2` (see `Pleroma.Web.controller/0` for definition) * In controllers, `use Pleroma.Web, :controller` will result in `action/2` (see `Pleroma.Web.controller/0` for definition) be called prior to actual controller action, and it'll perform security / privacy checks before passing control to actual controller action.
be called prior to actual controller action, and it'll perform security / privacy checks before passing control to
actual controller action. For routes with `:authenticated_api` pipeline, authentication & authorization are expected, For routes with `:authenticated_api` pipeline, authentication & authorization are expected, thus `OAuthScopesPlug` will be run unless explicitly skipped (also `EnsureAuthenticatedPlug` will be executed immediately before action even if there was an early run to give an early error, since `OAuthScopesPlug` supports `:proceed_unauthenticated` option, and other plugs may support similar options as well).
thus `OAuthScopesPlug` will be run unless explicitly skipped (also `EnsureAuthenticatedPlug` will be executed
immediately before action even if there was an early run to give an early error, since `OAuthScopesPlug` supports 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.
`:proceed_unauthenticated` option, and other plugs may support similar options as well). For `:api` pipeline routes,
`EnsurePublicOrAuthenticatedPlug` will be called to ensure that 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) ## [HTTP Basic Authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization)
* With HTTP Basic Auth, OAuth scopes check is _not_ performed for any action (since password is provided during the auth, * 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.Plugs.AuthenticationPlug` and `Pleroma.Plugs.LegacyAuthenticationPlug` both call `Pleroma.Plugs.OAuthScopesPlug.skip_plug(conn)` when password is provided.
requester is able to obtain a token with full permissions anyways). `Pleroma.Plugs.AuthenticationPlug` and
`Pleroma.Plugs.LegacyAuthenticationPlug` both call `Pleroma.Plugs.OAuthScopesPlug.skip_plug(conn)` when password
is provided.
## Auth-related configuration, OAuth consumer mode etc. ## Auth-related configuration, OAuth consumer mode etc.

View File

@ -28,9 +28,7 @@ def perform(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do
conn conn
options[:fallback] == :proceed_unauthenticated -> options[:fallback] == :proceed_unauthenticated ->
conn drop_auth_info(conn)
|> assign(:user, nil)
|> assign(:token, nil)
true -> true ->
missing_scopes = scopes -- matched_scopes missing_scopes = scopes -- matched_scopes
@ -46,6 +44,15 @@ def perform(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do
end end
end end
@doc "Drops authentication info from connection"
def drop_auth_info(conn) do
# To simplify debugging, setting a private variable on `conn` if auth info is dropped
conn
|> put_private(:authentication_ignored, true)
|> assign(:user, nil)
|> assign(:token, nil)
end
@doc "Filters descendants of supported scopes" @doc "Filters descendants of supported scopes"
def filter_descendants(scopes, supported_scopes) do def filter_descendants(scopes, supported_scopes) do
Enum.filter( Enum.filter(

View File

@ -37,7 +37,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
plug( plug(
OAuthScopesPlug, OAuthScopesPlug,
%{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]} %{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]}
when action in [:show, :endorsements] when action in [:show, :followers, :following, :endorsements]
) )
plug( plug(
@ -49,7 +49,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
plug( plug(
OAuthScopesPlug, OAuthScopesPlug,
%{scopes: ["read:accounts"]} %{scopes: ["read:accounts"]}
when action in [:endorsements, :verify_credentials, :followers, :following] when action in [:endorsements, :verify_credentials]
) )
plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :update_credentials) plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :update_credentials)

View File

@ -5,6 +5,7 @@
defmodule Pleroma.Web.MastodonAPI.AppController do defmodule Pleroma.Web.MastodonAPI.AppController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug
alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.App
@ -13,7 +14,14 @@ defmodule Pleroma.Web.MastodonAPI.AppController do
action_fallback(Pleroma.Web.MastodonAPI.FallbackController) action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(
:skip_plug,
[OAuthScopesPlug, EnsurePublicOrAuthenticatedPlug]
when action == :create
)
plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :verify_credentials) plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :verify_credentials)
plug(OpenApiSpex.Plug.CastAndValidate) plug(OpenApiSpex.Plug.CastAndValidate)
@local_mastodon_name "Mastodon-Local" @local_mastodon_name "Mastodon-Local"

View File

@ -7,6 +7,12 @@ defmodule Pleroma.Web.MastodonAPI.CustomEmojiController do
plug(OpenApiSpex.Plug.CastAndValidate) plug(OpenApiSpex.Plug.CastAndValidate)
plug(
:skip_plug,
[Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug]
when action == :index
)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.CustomEmojiOperation defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.CustomEmojiOperation
def index(conn, _params) do def index(conn, _params) do

View File

@ -5,6 +5,12 @@
defmodule Pleroma.Web.MastodonAPI.InstanceController do defmodule Pleroma.Web.MastodonAPI.InstanceController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
plug(
:skip_plug,
[Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug]
when action in [:show, :peers]
)
@doc "GET /api/v1/instance" @doc "GET /api/v1/instance"
def show(conn, _params) do def show(conn, _params) do
render(conn, "show.json") render(conn, "show.json")

View File

@ -15,7 +15,11 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
require Logger require Logger
plug(:skip_plug, Pleroma.Plugs.OAuthScopesPlug when action in [:empty_array, :empty_object]) plug(
:skip_plug,
[Pleroma.Plugs.OAuthScopesPlug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug]
when action in [:empty_array, :empty_object]
)
action_fallback(Pleroma.Web.MastodonAPI.FallbackController) action_fallback(Pleroma.Web.MastodonAPI.FallbackController)

View File

@ -21,6 +21,8 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
# Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search) # Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search)
plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated}) plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated})
# Note: on private instances auth is required (EnsurePublicOrAuthenticatedPlug is not skipped)
plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search]) plug(RateLimiter, [name: :search] when action in [:search, :search2, :account_search])
def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do def account_search(%{assigns: %{user: user}} = conn, %{"q" => query} = params) do

View File

@ -9,6 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
only: [add_link_headers: 2, add_link_headers: 3, truthy_param?: 1, skip_relationships?: 1] only: [add_link_headers: 2, add_link_headers: 3, truthy_param?: 1, skip_relationships?: 1]
alias Pleroma.Pagination alias Pleroma.Pagination
alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug
alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Plugs.RateLimiter alias Pleroma.Plugs.RateLimiter
alias Pleroma.User alias Pleroma.User
@ -26,7 +27,13 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct]) plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct])
plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list) plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list)
plug(:skip_plug, Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action == :public) plug(
OAuthScopesPlug,
%{scopes: ["read:statuses"], fallback: :proceed_unauthenticated}
when action in [:public, :hashtag]
)
plug(:skip_plug, EnsurePublicOrAuthenticatedPlug when action in [:public, :hashtag])
plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) plug(:put_view, Pleroma.Web.MastodonAPI.StatusView)
@ -93,7 +100,9 @@ def public(%{assigns: %{user: user}} = conn, params) do
restrict? = Pleroma.Config.get([:restrict_unauthenticated, :timelines, cfg_key]) restrict? = Pleroma.Config.get([:restrict_unauthenticated, :timelines, cfg_key])
if not (restrict? and is_nil(user)) do if restrict? and is_nil(user) do
render_error(conn, :unauthorized, "authorization required for timeline view")
else
activities = activities =
params params
|> Map.put("type", ["Create", "Announce"]) |> Map.put("type", ["Create", "Announce"])
@ -110,12 +119,10 @@ def public(%{assigns: %{user: user}} = conn, params) do
as: :activity, as: :activity,
skip_relationships: skip_relationships?(params) skip_relationships: skip_relationships?(params)
) )
else
render_error(conn, :unauthorized, "authorization required for timeline view")
end end
end end
def hashtag_fetching(params, user, local_only) do defp hashtag_fetching(params, user, local_only) do
tags = tags =
[params["tag"], params["any"]] [params["tag"], params["any"]]
|> List.flatten() |> List.flatten()

View File

@ -26,6 +26,12 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do
when action in [:conversation, :conversation_statuses] when action in [:conversation, :conversation_statuses]
) )
plug(
OAuthScopesPlug,
%{scopes: ["read:statuses"], fallback: :proceed_unauthenticated}
when action == :emoji_reactions_by
)
plug( plug(
OAuthScopesPlug, OAuthScopesPlug,
%{scopes: ["write:statuses"]} %{scopes: ["write:statuses"]}

View File

@ -13,7 +13,11 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MastodonAPI.StatusView
plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :user_scrobbles) plug(
OAuthScopesPlug,
%{scopes: ["read"], fallback: :proceed_unauthenticated} when action == :user_scrobbles
)
plug(OAuthScopesPlug, %{scopes: ["write"]} when action != :user_scrobbles) plug(OAuthScopesPlug, %{scopes: ["write"]} when action != :user_scrobbles)
def new_scrobble(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do def new_scrobble(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do

View File

@ -312,14 +312,10 @@ defmodule Pleroma.Web.Router do
post("/scrobble", ScrobbleController, :new_scrobble) post("/scrobble", ScrobbleController, :new_scrobble)
end end
scope [] do
pipe_through(:api)
get("/accounts/:id/favourites", AccountController, :favourites)
end
scope [] do scope [] do
pipe_through(:authenticated_api) pipe_through(:authenticated_api)
get("/accounts/:id/favourites", AccountController, :favourites)
post("/accounts/:id/subscribe", AccountController, :subscribe) post("/accounts/:id/subscribe", AccountController, :subscribe)
post("/accounts/:id/unsubscribe", AccountController, :unsubscribe) post("/accounts/:id/unsubscribe", AccountController, :unsubscribe)
end end
@ -353,6 +349,8 @@ defmodule Pleroma.Web.Router do
post("/accounts/:id/mute", AccountController, :mute) post("/accounts/:id/mute", AccountController, :mute)
post("/accounts/:id/unmute", AccountController, :unmute) post("/accounts/:id/unmute", AccountController, :unmute)
get("/apps/verify_credentials", AppController, :verify_credentials)
get("/conversations", ConversationController, :index) get("/conversations", ConversationController, :index)
post("/conversations/:id/read", ConversationController, :mark_as_read) post("/conversations/:id/read", ConversationController, :mark_as_read)
@ -431,6 +429,7 @@ defmodule Pleroma.Web.Router do
get("/timelines/home", TimelineController, :home) get("/timelines/home", TimelineController, :home)
get("/timelines/direct", TimelineController, :direct) get("/timelines/direct", TimelineController, :direct)
get("/timelines/list/:list_id", TimelineController, :list)
end end
scope "/api/web", Pleroma.Web do scope "/api/web", Pleroma.Web do
@ -442,15 +441,24 @@ defmodule Pleroma.Web.Router do
scope "/api/v1", Pleroma.Web.MastodonAPI do scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:api) pipe_through(:api)
post("/accounts", AccountController, :create)
get("/accounts/search", SearchController, :account_search) get("/accounts/search", SearchController, :account_search)
get("/search", SearchController, :search)
get("/accounts/:id/statuses", AccountController, :statuses)
get("/accounts/:id/followers", AccountController, :followers)
get("/accounts/:id/following", AccountController, :following)
get("/accounts/:id", AccountController, :show)
post("/accounts", AccountController, :create)
get("/instance", InstanceController, :show) get("/instance", InstanceController, :show)
get("/instance/peers", InstanceController, :peers) get("/instance/peers", InstanceController, :peers)
post("/apps", AppController, :create) post("/apps", AppController, :create)
get("/apps/verify_credentials", AppController, :verify_credentials)
get("/statuses", StatusController, :index)
get("/statuses/:id", StatusController, :show)
get("/statuses/:id/context", StatusController, :context)
get("/statuses/:id/card", StatusController, :card) get("/statuses/:id/card", StatusController, :card)
get("/statuses/:id/favourited_by", StatusController, :favourited_by) get("/statuses/:id/favourited_by", StatusController, :favourited_by)
get("/statuses/:id/reblogged_by", StatusController, :reblogged_by) get("/statuses/:id/reblogged_by", StatusController, :reblogged_by)
@ -461,20 +469,8 @@ defmodule Pleroma.Web.Router do
get("/timelines/public", TimelineController, :public) get("/timelines/public", TimelineController, :public)
get("/timelines/tag/:tag", TimelineController, :hashtag) get("/timelines/tag/:tag", TimelineController, :hashtag)
get("/timelines/list/:list_id", TimelineController, :list)
get("/statuses", StatusController, :index)
get("/statuses/:id", StatusController, :show)
get("/statuses/:id/context", StatusController, :context)
get("/polls/:id", PollController, :show) get("/polls/:id", PollController, :show)
get("/accounts/:id/statuses", AccountController, :statuses)
get("/accounts/:id/followers", AccountController, :followers)
get("/accounts/:id/following", AccountController, :following)
get("/accounts/:id", AccountController, :show)
get("/search", SearchController, :search)
end end
scope "/api/v2", Pleroma.Web.MastodonAPI do scope "/api/v2", Pleroma.Web.MastodonAPI do

View File

@ -18,7 +18,7 @@ defmodule Pleroma.Web.TwitterAPI.Controller do
%{scopes: ["write:notifications"]} when action == :mark_notifications_as_read %{scopes: ["write:notifications"]} when action == :mark_notifications_as_read
) )
plug(:skip_plug, OAuthScopesPlug when action in [:oauth_tokens, :revoke_token]) plug(:skip_plug, OAuthScopesPlug when action in [:confirm_email, :oauth_tokens, :revoke_token])
action_fallback(:errors) action_fallback(:errors)
@ -47,13 +47,13 @@ def revoke_token(%{assigns: %{user: user}} = conn, %{"id" => id} = _params) do
json_reply(conn, 201, "") json_reply(conn, 201, "")
end end
def errors(conn, {:param_cast, _}) do defp errors(conn, {:param_cast, _}) do
conn conn
|> put_status(400) |> put_status(400)
|> json("Invalid parameters") |> json("Invalid parameters")
end end
def errors(conn, _) do defp errors(conn, _) do
conn conn
|> put_status(500) |> put_status(500)
|> json("Something went wrong") |> json("Something went wrong")

View File

@ -67,13 +67,25 @@ defp skip_plug(conn, plug_modules) do
# Executed just before actual controller action, invokes before-action hooks (callbacks) # 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_perform_public_or_authenticated_check(conn), with %Plug.Conn{halted: false} <- maybe_drop_authentication_if_oauth_check_ignored(conn),
%Plug.Conn{halted: false} <- maybe_perform_public_or_authenticated_check(conn),
%Plug.Conn{halted: false} <- maybe_perform_authenticated_check(conn), %Plug.Conn{halted: false} <- maybe_perform_authenticated_check(conn),
%Plug.Conn{halted: false} <- maybe_halt_on_missing_oauth_scopes_check(conn) do %Plug.Conn{halted: false} <- maybe_halt_on_missing_oauth_scopes_check(conn) do
super(conn, params) super(conn, params)
end end
end end
# For non-authenticated API actions, drops auth info if OAuth scopes check was ignored
# (neither performed nor explicitly skipped)
defp maybe_drop_authentication_if_oauth_check_ignored(conn) do
if PlugHelper.plug_called?(conn, ExpectPublicOrAuthenticatedCheckPlug) and
not PlugHelper.plug_called_or_skipped?(conn, OAuthScopesPlug) do
OAuthScopesPlug.drop_auth_info(conn)
else
conn
end
end
# Ensures instance is public -or- user is authenticated if such check was scheduled # Ensures instance is public -or- user is authenticated if such check was scheduled
defp maybe_perform_public_or_authenticated_check(conn) do defp maybe_perform_public_or_authenticated_check(conn) do
if PlugHelper.plug_called?(conn, ExpectPublicOrAuthenticatedCheckPlug) do if PlugHelper.plug_called?(conn, ExpectPublicOrAuthenticatedCheckPlug) do