Merge remote-tracking branch 'remotes/upstream/develop' into 1260-rate-limited-auth-actions

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Ivan Tashkinov 2019-10-07 11:06:30 +03:00
commit 28fb98d69e
47 changed files with 759 additions and 301 deletions

View file

@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Pleroma API: `POST /api/v1/pleroma/scrobble` to scrobble a media item - Pleroma API: `POST /api/v1/pleroma/scrobble` to scrobble a media item
- Mastodon API: Add `upload_limit`, `avatar_upload_limit`, `background_upload_limit`, and `banner_upload_limit` to `/api/v1/instance` - Mastodon API: Add `upload_limit`, `avatar_upload_limit`, `background_upload_limit`, and `banner_upload_limit` to `/api/v1/instance`
- Mastodon API: Add `pleroma.unread_conversation_count` to the Account entity - Mastodon API: Add `pleroma.unread_conversation_count` to the Account entity
- OAuth: support for hierarchical permissions / [Mastodon 2.4.3 OAuth permissions](https://docs.joinmastodon.org/api/permissions/)
- Authentication: Added rate limit for password-authorized actions / login existence checks - Authentication: Added rate limit for password-authorized actions / login existence checks
### Changed ### Changed
@ -28,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Mastodon API: Fix private and direct statuses not being filtered out from the public timeline for an authenticated user (`GET /api/v1/timelines/public`) - Mastodon API: Fix private and direct statuses not being filtered out from the public timeline for an authenticated user (`GET /api/v1/timelines/public`)
- Mastodon API: Inability to get some local users by nickname in `/api/v1/accounts/:id_or_nickname` - Mastodon API: Inability to get some local users by nickname in `/api/v1/accounts/:id_or_nickname`
- Added `:instance, extended_nickname_format` setting to the default config - Added `:instance, extended_nickname_format` setting to the default config
- Report emails now include functional links to profiles of remote user accounts
## [1.1.0] - 2019-??-?? ## [1.1.0] - 2019-??-??
### Security ### Security

View file

@ -17,7 +17,7 @@ defp instance_notify_email do
end end
defp user_url(user) do defp user_url(user) do
Helpers.o_status_url(Pleroma.Web.Endpoint, :feed_redirect, user.nickname) Helpers.o_status_url(Pleroma.Web.Endpoint, :feed_redirect, user.id)
end end
def report(to, reporter, account, statuses, comment) do def report(to, reporter, account, statuses, comment) do

View file

@ -6,6 +6,8 @@ defmodule Pleroma.Plugs.OAuthScopesPlug do
import Plug.Conn import Plug.Conn
import Pleroma.Web.Gettext import Pleroma.Web.Gettext
alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug
@behaviour Plug @behaviour Plug
def init(%{scopes: _} = options), do: options def init(%{scopes: _} = options), do: options
@ -13,24 +15,26 @@ def init(%{scopes: _} = options), do: options
def call(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do def call(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do
op = options[:op] || :| op = options[:op] || :|
token = assigns[:token] token = assigns[:token]
matched_scopes = token && filter_descendants(scopes, token.scopes)
cond do cond do
is_nil(token) -> is_nil(token) ->
maybe_perform_instance_privacy_check(conn, options)
op == :| && Enum.any?(matched_scopes) ->
conn conn
op == :| && scopes -- token.scopes != scopes -> op == :& && matched_scopes == scopes ->
conn
op == :& && scopes -- token.scopes == [] ->
conn conn
options[:fallback] == :proceed_unauthenticated -> options[:fallback] == :proceed_unauthenticated ->
conn conn
|> assign(:user, nil) |> assign(:user, nil)
|> assign(:token, nil) |> assign(:token, nil)
|> maybe_perform_instance_privacy_check(options)
true -> true ->
missing_scopes = scopes -- token.scopes missing_scopes = scopes -- matched_scopes
permissions = Enum.join(missing_scopes, " #{op} ") permissions = Enum.join(missing_scopes, " #{op} ")
error_message = error_message =
@ -42,4 +46,25 @@ def call(%Plug.Conn{assigns: assigns} = conn, %{scopes: scopes} = options) do
|> halt() |> halt()
end end
end end
@doc "Filters descendants of supported scopes"
def filter_descendants(scopes, supported_scopes) do
Enum.filter(
scopes,
fn scope ->
Enum.find(
supported_scopes,
&(scope == &1 || String.starts_with?(scope, &1 <> ":"))
)
end
)
end
defp maybe_perform_instance_privacy_check(%Plug.Conn{} = conn, options) do
if options[:skip_instance_privacy_check] do
conn
else
EnsurePublicOrAuthenticatedPlug.call(conn, [])
end
end
end end

View file

@ -48,7 +48,7 @@ def refetch_public_key(conn) do
end end
def sign(%User{} = user, headers) do def sign(%User{} = user, headers) do
with {:ok, %{info: %{keys: keys}}} <- User.ensure_keys_present(user), with {:ok, %{keys: keys}} <- User.ensure_keys_present(user),
{:ok, private_key, _} <- Keys.keys_from_pem(keys) do {:ok, private_key, _} <- Keys.keys_from_pem(keys) do
HTTPSignatures.sign(private_key, user.ap_id <> "#main-key", headers) HTTPSignatures.sign(private_key, user.ap_id <> "#main-key", headers)
end end

View file

@ -51,6 +51,7 @@ defmodule Pleroma.User do
field(:password_hash, :string) field(:password_hash, :string)
field(:password, :string, virtual: true) field(:password, :string, virtual: true)
field(:password_confirmation, :string, virtual: true) field(:password_confirmation, :string, virtual: true)
field(:keys, :string)
field(:following, {:array, :string}, default: []) field(:following, {:array, :string}, default: [])
field(:ap_id, :string) field(:ap_id, :string)
field(:avatar, :map) field(:avatar, :map)
@ -1554,11 +1555,14 @@ def get_mascot(%{info: %{mascot: mascot}}) when is_nil(mascot) do
} }
end end
def ensure_keys_present(%{info: %{keys: keys}} = user) when not is_nil(keys), do: {:ok, user} def ensure_keys_present(%{keys: keys} = user) when not is_nil(keys), do: {:ok, user}
def ensure_keys_present(%User{} = user) do def ensure_keys_present(%User{} = user) do
with {:ok, pem} <- Keys.generate_rsa_pem() do with {:ok, pem} <- Keys.generate_rsa_pem() do
update_info(user, &User.Info.set_keys(&1, pem)) user
|> cast(%{keys: pem}, [:keys])
|> validate_required([:keys])
|> update_and_set_cache()
end end
end end

View file

@ -33,7 +33,7 @@ def render("endpoints.json", _), do: %{}
def render("service.json", %{user: user}) do def render("service.json", %{user: user}) do
{:ok, user} = User.ensure_keys_present(user) {:ok, user} = User.ensure_keys_present(user)
{:ok, _, public_key} = Keys.keys_from_pem(user.info.keys) {:ok, _, public_key} = Keys.keys_from_pem(user.keys)
public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key)
public_key = :public_key.pem_encode([public_key]) public_key = :public_key.pem_encode([public_key])
@ -69,7 +69,7 @@ def render("user.json", %{user: %User{nickname: "internal." <> _} = user}),
def render("user.json", %{user: user}) do def render("user.json", %{user: user}) do
{:ok, user} = User.ensure_keys_present(user) {:ok, user} = User.ensure_keys_present(user)
{:ok, _, public_key} = Keys.keys_from_pem(user.info.keys) {:ok, _, public_key} = Keys.keys_from_pem(user.keys)
public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) public_key = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key)
public_key = :public_key.pem_encode([public_key]) public_key = :public_key.pem_encode([public_key])

View file

@ -6,6 +6,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.ModerationLog alias Pleroma.ModerationLog
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
alias Pleroma.UserInviteToken alias Pleroma.UserInviteToken
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
@ -26,6 +27,67 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
require Logger require Logger
plug(
OAuthScopesPlug,
%{scopes: ["read:accounts"]}
when action in [:list_users, :user_show, :right_get, :invites]
)
plug(
OAuthScopesPlug,
%{scopes: ["write:accounts"]}
when action in [
:get_invite_token,
:revoke_invite,
:email_invite,
:get_password_reset,
:user_follow,
:user_unfollow,
:user_delete,
:users_create,
:user_toggle_activation,
:tag_users,
:untag_users,
:right_add,
:right_delete,
:set_activation_status
]
)
plug(
OAuthScopesPlug,
%{scopes: ["read:reports"]} when action in [:list_reports, :report_show]
)
plug(
OAuthScopesPlug,
%{scopes: ["write:reports"]}
when action in [:report_update_state, :report_respond]
)
plug(
OAuthScopesPlug,
%{scopes: ["read:statuses"]} when action == :list_user_statuses
)
plug(
OAuthScopesPlug,
%{scopes: ["write:statuses"]}
when action in [:status_update, :status_delete]
)
plug(
OAuthScopesPlug,
%{scopes: ["read"]}
when action in [:config_show, :migrate_to_db, :migrate_from_db, :list_log]
)
plug(
OAuthScopesPlug,
%{scopes: ["write"]}
when action in [:relay_follow, :relay_unfollow, :config_update]
)
@users_page_size 50 @users_page_size 50
action_fallback(:errors) action_fallback(:errors)

View file

@ -5,8 +5,20 @@
defmodule Pleroma.Web.MastoFEController do defmodule Pleroma.Web.MastoFEController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :put_settings)
# Note: :index action handles attempt of unauthenticated access to private instance with redirect
plug(
OAuthScopesPlug,
%{scopes: ["read"], fallback: :proceed_unauthenticated, skip_instance_privacy_check: true}
when action == :index
)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug when action != :index)
@doc "GET /web/*path" @doc "GET /web/*path"
def index(%{assigns: %{user: user}} = conn, _params) do def index(%{assigns: %{user: user}} = conn, _params) do
token = get_session(conn, :oauth_token) token = get_session(conn, :oauth_token)

View file

@ -9,6 +9,7 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
only: [add_link_headers: 2, truthy_param?: 1, assign_account_by_id: 2, json_response: 3] only: [add_link_headers: 2, truthy_param?: 1, assign_account_by_id: 2, json_response: 3]
alias Pleroma.Emoji alias Pleroma.Emoji
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Plugs.RateLimiter alias Pleroma.Plugs.RateLimiter
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
@ -19,6 +20,49 @@ defmodule Pleroma.Web.MastodonAPI.AccountController do
alias Pleroma.Web.OAuth.Token alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.TwitterAPI.TwitterAPI alias Pleroma.Web.TwitterAPI.TwitterAPI
plug(
OAuthScopesPlug,
%{fallback: :proceed_unauthenticated, scopes: ["read:accounts"]}
when action == :show
)
plug(
OAuthScopesPlug,
%{scopes: ["read:accounts"]}
when action in [:endorsements, :verify_credentials, :followers, :following]
)
plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action == :update_credentials)
plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :lists)
plug(
OAuthScopesPlug,
%{scopes: ["follow", "read:blocks"]} when action == :blocks
)
plug(
OAuthScopesPlug,
%{scopes: ["follow", "write:blocks"]} when action in [:block, :unblock]
)
plug(OAuthScopesPlug, %{scopes: ["read:follows"]} when action == :relationships)
# Note: :follows (POST /api/v1/follows) is the same as :follow, consider removing :follows
plug(
OAuthScopesPlug,
%{scopes: ["follow", "write:follows"]} when action in [:follows, :follow, :unfollow]
)
plug(OAuthScopesPlug, %{scopes: ["follow", "read:mutes"]} when action == :mutes)
plug(OAuthScopesPlug, %{scopes: ["follow", "write:mutes"]} when action in [:mute, :unmute])
plug(
Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug
when action != :create
)
@relations [:follow, :unfollow] @relations [:follow, :unfollow]
@needs_account ~W(followers following lists follow unfollow mute unmute block unblock)a @needs_account ~W(followers following lists follow unfollow mute unmute block unblock)a
@ -342,4 +386,8 @@ def mutes(%{assigns: %{user: user}} = conn, _) do
def blocks(%{assigns: %{user: user}} = conn, _) do def blocks(%{assigns: %{user: user}} = conn, _) do
render(conn, "index.json", users: User.blocked_users(user), for: user, as: :user) render(conn, "index.json", users: User.blocked_users(user), for: user, as: :user)
end end
@doc "GET /api/v1/endorsements"
def endorsements(conn, params),
do: Pleroma.Web.MastodonAPI.MastodonAPIController.empty_array(conn, params)
end end

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.OAuthScopesPlug
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.Web.OAuth.App alias Pleroma.Web.OAuth.App
alias Pleroma.Web.OAuth.Scopes alias Pleroma.Web.OAuth.Scopes
@ -12,6 +13,8 @@ defmodule Pleroma.Web.MastodonAPI.AppController do
action_fallback(Pleroma.Web.MastodonAPI.FallbackController) action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :verify_credentials)
@local_mastodon_name "Mastodon-Local" @local_mastodon_name "Mastodon-Local"
@doc "POST /api/v1/apps" @doc "POST /api/v1/apps"

View file

@ -8,10 +8,16 @@ defmodule Pleroma.Web.MastodonAPI.ConversationController do
import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
alias Pleroma.Conversation.Participation alias Pleroma.Conversation.Participation
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Repo alias Pleroma.Repo
action_fallback(Pleroma.Web.MastodonAPI.FallbackController) action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action == :index)
plug(OAuthScopesPlug, %{scopes: ["write:conversations"]} when action == :read)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
@doc "GET /api/v1/conversations" @doc "GET /api/v1/conversations"
def index(%{assigns: %{user: user}} = conn, params) do def index(%{assigns: %{user: user}} = conn, params) do
participations = Participation.for_user_with_last_activity_id(user, params) participations = Participation.for_user_with_last_activity_id(user, params)

View file

@ -5,8 +5,21 @@
defmodule Pleroma.Web.MastodonAPI.DomainBlockController do defmodule Pleroma.Web.MastodonAPI.DomainBlockController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
plug(
OAuthScopesPlug,
%{scopes: ["follow", "read:blocks"]} when action == :index
)
plug(
OAuthScopesPlug,
%{scopes: ["follow", "write:blocks"]} when action != :index
)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
@doc "GET /api/v1/domain_blocks" @doc "GET /api/v1/domain_blocks"
def index(%{assigns: %{user: %{info: info}}} = conn, _) do def index(%{assigns: %{user: %{info: info}}} = conn, _) do
json(conn, Map.get(info, :domain_blocks, [])) json(conn, Map.get(info, :domain_blocks, []))

View file

@ -6,6 +6,18 @@ defmodule Pleroma.Web.MastodonAPI.FilterController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Filter alias Pleroma.Filter
alias Pleroma.Plugs.OAuthScopesPlug
@oauth_read_actions [:show, :index]
plug(OAuthScopesPlug, %{scopes: ["read:filters"]} when action in @oauth_read_actions)
plug(
OAuthScopesPlug,
%{scopes: ["write:filters"]} when action not in @oauth_read_actions
)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
@doc "GET /api/v1/filters" @doc "GET /api/v1/filters"
def index(%{assigns: %{user: user}} = conn, _) do def index(%{assigns: %{user: user}} = conn, _) do

View file

@ -5,6 +5,7 @@
defmodule Pleroma.Web.MastodonAPI.FollowRequestController do defmodule Pleroma.Web.MastodonAPI.FollowRequestController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
@ -13,6 +14,15 @@ defmodule Pleroma.Web.MastodonAPI.FollowRequestController do
action_fallback(:errors) action_fallback(:errors)
plug(OAuthScopesPlug, %{scopes: ["follow", "read:follows"]} when action == :index)
plug(
OAuthScopesPlug,
%{scopes: ["follow", "write:follows"]} when action != :index
)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
@doc "GET /api/v1/follow_requests" @doc "GET /api/v1/follow_requests"
def index(%{assigns: %{user: followed}} = conn, _params) do def index(%{assigns: %{user: followed}} = conn, _params) do
follow_requests = User.get_follow_requests(followed) follow_requests = User.get_follow_requests(followed)

View file

@ -5,11 +5,22 @@
defmodule Pleroma.Web.MastodonAPI.ListController do defmodule Pleroma.Web.MastodonAPI.ListController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.AccountView
plug(:list_by_id_and_user when action not in [:index, :create]) plug(:list_by_id_and_user when action not in [:index, :create])
plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action in [:index, :show, :list_accounts])
plug(
OAuthScopesPlug,
%{scopes: ["write:lists"]}
when action in [:create, :update, :delete, :add_to_list, :remove_from_list]
)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
action_fallback(Pleroma.Web.MastodonAPI.FallbackController) action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
# GET /api/v1/lists # GET /api/v1/lists

View file

@ -6,12 +6,17 @@ defmodule Pleroma.Web.MastodonAPI.MediaController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
action_fallback(Pleroma.Web.MastodonAPI.FallbackController) action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) plug(:put_view, Pleroma.Web.MastodonAPI.StatusView)
plug(OAuthScopesPlug, %{scopes: ["write:media"]})
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
@doc "POST /api/v1/media" @doc "POST /api/v1/media"
def create(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do def create(%{assigns: %{user: user}} = conn, %{"file" => file} = data) do
with {:ok, object} <- with {:ok, object} <-

View file

@ -8,8 +8,20 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.MastodonAPI.MastodonAPI
@oauth_read_actions [:show, :index]
plug(
OAuthScopesPlug,
%{scopes: ["read:notifications"]} when action in @oauth_read_actions
)
plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action not in @oauth_read_actions)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
# GET /api/v1/notifications # GET /api/v1/notifications
def index(%{assigns: %{user: user}} = conn, params) do def index(%{assigns: %{user: user}} = conn, params) do
notifications = MastodonAPI.get_notifications(user, params) notifications = MastodonAPI.get_notifications(user, params)

View file

@ -9,11 +9,21 @@ defmodule Pleroma.Web.MastodonAPI.PollController do
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Web.ActivityPub.Visibility alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
action_fallback(Pleroma.Web.MastodonAPI.FallbackController) action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(
OAuthScopesPlug,
%{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} when action == :show
)
plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :vote)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
@doc "GET /api/v1/polls/:id" @doc "GET /api/v1/polls/:id"
def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do
with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60), with %Object{} = object <- Object.get_by_id_and_maybe_refetch(id, interval: 60),

View file

@ -3,10 +3,16 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.ReportController do defmodule Pleroma.Web.MastodonAPI.ReportController do
alias Pleroma.Plugs.OAuthScopesPlug
use Pleroma.Web, :controller use Pleroma.Web, :controller
action_fallback(Pleroma.Web.MastodonAPI.FallbackController) action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(OAuthScopesPlug, %{scopes: ["write:reports"]} when action == :create)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
@doc "POST /api/v1/reports" @doc "POST /api/v1/reports"
def create(%{assigns: %{user: user}} = conn, params) do def create(%{assigns: %{user: user}} = conn, params) do
with {:ok, activity} <- Pleroma.Web.CommonAPI.report(user, params) do with {:ok, activity} <- Pleroma.Web.CommonAPI.report(user, params) do

View file

@ -7,11 +7,19 @@ defmodule Pleroma.Web.MastodonAPI.ScheduledActivityController do
import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.ScheduledActivity alias Pleroma.ScheduledActivity
alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.MastodonAPI.MastodonAPI
plug(:assign_scheduled_activity when action != :index) plug(:assign_scheduled_activity when action != :index)
@oauth_read_actions [:show, :index]
plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in @oauth_read_actions)
plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action not in @oauth_read_actions)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
action_fallback(Pleroma.Web.MastodonAPI.FallbackController) action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
@doc "GET /api/v1/scheduled_statuses" @doc "GET /api/v1/scheduled_statuses"

View file

@ -6,6 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Plugs.RateLimiter alias Pleroma.Plugs.RateLimiter
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
@ -15,6 +16,12 @@ defmodule Pleroma.Web.MastodonAPI.SearchController do
alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MastodonAPI.StatusView
require Logger require Logger
# Note: Mastodon doesn't allow unauthenticated access (requires read:accounts / read:search)
plug(OAuthScopesPlug, %{scopes: ["read:search"], fallback: :proceed_unauthenticated})
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
plug(RateLimiter, :search when action in [:search, :search2, :account_search]) plug(RateLimiter, :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

@ -12,6 +12,7 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Bookmark alias Pleroma.Bookmark
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Plugs.RateLimiter alias Pleroma.Plugs.RateLimiter
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.ScheduledActivity alias Pleroma.ScheduledActivity
@ -22,6 +23,61 @@ defmodule Pleroma.Web.MastodonAPI.StatusController do
alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MastodonAPI.ScheduledActivityView alias Pleroma.Web.MastodonAPI.ScheduledActivityView
@unauthenticated_access %{fallback: :proceed_unauthenticated, scopes: []}
plug(
OAuthScopesPlug,
%{@unauthenticated_access | scopes: ["read:statuses"]}
when action in [
:index,
:show,
:card,
:context
]
)
plug(
OAuthScopesPlug,
%{scopes: ["write:statuses"]}
when action in [
:create,
:delete,
:reblog,
:unreblog
]
)
plug(OAuthScopesPlug, %{scopes: ["read:favourites"]} when action == :favourites)
plug(
OAuthScopesPlug,
%{scopes: ["write:favourites"]} when action in [:favourite, :unfavourite]
)
plug(
OAuthScopesPlug,
%{scopes: ["write:mutes"]} when action in [:mute_conversation, :unmute_conversation]
)
plug(
OAuthScopesPlug,
%{@unauthenticated_access | scopes: ["read:accounts"]}
when action in [:favourited_by, :reblogged_by]
)
plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action in [:pin, :unpin])
# Note: scope not present in Mastodon: read:bookmarks
plug(OAuthScopesPlug, %{scopes: ["read:bookmarks"]} when action == :bookmarks)
# Note: scope not present in Mastodon: write:bookmarks
plug(
OAuthScopesPlug,
%{scopes: ["write:bookmarks"]} when action in [:bookmark, :unbookmark]
)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
@rate_limited_status_actions ~w(reblog unreblog favourite unfavourite create delete)a @rate_limited_status_actions ~w(reblog unreblog favourite unfavourite create delete)a
plug( plug(

View file

@ -12,6 +12,10 @@ defmodule Pleroma.Web.MastodonAPI.SubscriptionController do
action_fallback(:errors) action_fallback(:errors)
plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["push"]})
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
# Creates PushSubscription # Creates PushSubscription
# POST /api/v1/push/subscription # POST /api/v1/push/subscription
# #

View file

@ -8,11 +8,16 @@ defmodule Pleroma.Web.MastodonAPI.SuggestionController do
require Logger require Logger
alias Pleroma.Config alias Pleroma.Config
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.MediaProxy alias Pleroma.Web.MediaProxy
action_fallback(Pleroma.Web.MastodonAPI.FallbackController) action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(OAuthScopesPlug, %{scopes: ["read"]} when action == :index)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
@doc "GET /api/v1/suggestions" @doc "GET /api/v1/suggestions"
def index(%{assigns: %{user: user}} = conn, _) do def index(%{assigns: %{user: user}} = conn, _) do
if Config.get([:suggestions, :enabled], false) do if Config.get([:suggestions, :enabled], false) do

View file

@ -9,8 +9,14 @@ defmodule Pleroma.Web.MastodonAPI.TimelineController do
only: [add_link_headers: 2, add_link_headers: 3, truthy_param?: 1] only: [add_link_headers: 2, add_link_headers: 3, truthy_param?: 1]
alias Pleroma.Pagination alias Pleroma.Pagination
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
plug(OAuthScopesPlug, %{scopes: ["read:statuses"]} when action in [:home, :direct])
plug(OAuthScopesPlug, %{scopes: ["read:lists"]} when action == :list)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
plug(:put_view, Pleroma.Web.MastodonAPI.StatusView) plug(:put_view, Pleroma.Web.MastodonAPI.StatusView)
# GET /api/v1/timelines/home # GET /api/v1/timelines/home

View file

@ -475,7 +475,7 @@ defp put_session_registration_id(%Plug.Conn{} = conn, registration_id),
defp validate_scopes(app, params) do defp validate_scopes(app, params) do
params params
|> Scopes.fetch_scopes(app.scopes) |> Scopes.fetch_scopes(app.scopes)
|> Scopes.validates(app.scopes) |> Scopes.validate(app.scopes)
end end
def default_redirect_uri(%App{} = app) do def default_redirect_uri(%App{} = app) do

View file

@ -8,7 +8,7 @@ defmodule Pleroma.Web.OAuth.Scopes do
""" """
@doc """ @doc """
Fetch scopes from requiest params. Fetch scopes from request params.
Note: `scopes` is used by Mastodon supporting it but sticking to Note: `scopes` is used by Mastodon supporting it but sticking to
OAuth's standard `scope` wherever we control it OAuth's standard `scope` wherever we control it
@ -53,14 +53,14 @@ def to_string(scopes), do: Enum.join(scopes, " ")
@doc """ @doc """
Validates scopes. Validates scopes.
""" """
@spec validates(list() | nil, list()) :: @spec validate(list() | nil, list()) ::
{:ok, list()} | {:error, :missing_scopes | :unsupported_scopes} {:ok, list()} | {:error, :missing_scopes | :unsupported_scopes}
def validates([], _app_scopes), do: {:error, :missing_scopes} def validate([], _app_scopes), do: {:error, :missing_scopes}
def validates(nil, _app_scopes), do: {:error, :missing_scopes} def validate(nil, _app_scopes), do: {:error, :missing_scopes}
def validates(scopes, app_scopes) do def validate(scopes, app_scopes) do
case scopes -- app_scopes do case Pleroma.Plugs.OAuthScopesPlug.filter_descendants(scopes, app_scopes) do
[] -> {:ok, scopes} ^scopes -> {:ok, scopes}
_ -> {:error, :unsupported_scopes} _ -> {:error, :unsupported_scopes}
end end
end end

View file

@ -9,6 +9,7 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do
only: [json_response: 3, add_link_headers: 2, assign_account_by_id: 2] only: [json_response: 3, add_link_headers: 2, assign_account_by_id: 2]
alias Ecto.Changeset alias Ecto.Changeset
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Plugs.RateLimiter alias Pleroma.Plugs.RateLimiter
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
@ -17,6 +18,30 @@ defmodule Pleroma.Web.PleromaAPI.AccountController do
require Pleroma.Constants require Pleroma.Constants
plug(
OAuthScopesPlug,
%{scopes: ["follow", "write:follows"]} when action in [:subscribe, :unsubscribe]
)
plug(
OAuthScopesPlug,
%{scopes: ["write:accounts"]}
# Note: the following actions are not permission-secured in Mastodon:
when action in [
:update_avatar,
:update_banner,
:update_background
]
)
plug(OAuthScopesPlug, %{scopes: ["read:favourites"]} when action == :favourites)
# An extra safety measure for possible actions not guarded by OAuth permissions specification
plug(
Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug
when action != :confirmation_resend
)
plug(RateLimiter, :account_confirmation_resend when action == :confirmation_resend) plug(RateLimiter, :account_confirmation_resend when action == :confirmation_resend)
plug(:assign_account_by_id when action in [:favourites, :subscribe, :unsubscribe]) plug(:assign_account_by_id when action in [:favourites, :subscribe, :unsubscribe])
plug(:put_view, Pleroma.Web.MastodonAPI.AccountView) plug(:put_view, Pleroma.Web.MastodonAPI.AccountView)

View file

@ -1,8 +1,26 @@
defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do defmodule Pleroma.Web.PleromaAPI.EmojiAPIController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Plugs.OAuthScopesPlug
require Logger require Logger
plug(
OAuthScopesPlug,
%{scopes: ["write"]}
when action in [
:create,
:delete,
:download_from,
:list_from,
:import_from_fs,
:update_file,
:update_metadata
]
)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
def emoji_dir_path do def emoji_dir_path do
Path.join( Path.join(
Pleroma.Config.get!([:instance, :static_dir]), Pleroma.Config.get!([:instance, :static_dir]),

View file

@ -5,9 +5,15 @@
defmodule Pleroma.Web.PleromaAPI.MascotController do defmodule Pleroma.Web.PleromaAPI.MascotController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
plug(OAuthScopesPlug, %{scopes: ["read:accounts"]} when action == :show)
plug(OAuthScopesPlug, %{scopes: ["write:accounts"]} when action != :show)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
@doc "GET /api/v1/pleroma/mascot" @doc "GET /api/v1/pleroma/mascot"
def show(%{assigns: %{user: user}} = conn, _params) do def show(%{assigns: %{user: user}} = conn, _params) do
json(conn, User.get_mascot(user)) json(conn, User.get_mascot(user))

View file

@ -9,11 +9,26 @@ defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do
alias Pleroma.Conversation.Participation alias Pleroma.Conversation.Participation
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.MastodonAPI.ConversationView alias Pleroma.Web.MastodonAPI.ConversationView
alias Pleroma.Web.MastodonAPI.NotificationView alias Pleroma.Web.MastodonAPI.NotificationView
alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MastodonAPI.StatusView
plug(
OAuthScopesPlug,
%{scopes: ["read:statuses"]} when action in [:conversation, :conversation_statuses]
)
plug(
OAuthScopesPlug,
%{scopes: ["write:conversations"]} when action == :update_conversation
)
plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :read_notification)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
def conversation(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do def conversation(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do
with %Participation{} = participation <- Participation.get(participation_id), with %Participation{} = participation <- Participation.get(participation_id),
true <- user.id == participation.user_id do true <- user.id == participation.user_id do

View file

@ -7,11 +7,17 @@ defmodule Pleroma.Web.PleromaAPI.ScrobbleController do
import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, fetch_integer_param: 2] import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, fetch_integer_param: 2]
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
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: ["write"]} when action != :user_scrobbles)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
def new_scrobble(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do def new_scrobble(%{assigns: %{user: user}} = conn, %{"title" => _} = params) do
params = params =
if !params["length"] do if !params["length"] do

View file

@ -87,31 +87,6 @@ defmodule Pleroma.Web.Router do
plug(Pleroma.Plugs.EnsureUserKeyPlug) plug(Pleroma.Plugs.EnsureUserKeyPlug)
end end
pipeline :oauth_read_or_public do
plug(Pleroma.Plugs.OAuthScopesPlug, %{
scopes: ["read"],
fallback: :proceed_unauthenticated
})
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
end
pipeline :oauth_read do
plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["read"]})
end
pipeline :oauth_write do
plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["write"]})
end
pipeline :oauth_follow do
plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["follow"]})
end
pipeline :oauth_push do
plug(Pleroma.Plugs.OAuthScopesPlug, %{scopes: ["push"]})
end
pipeline :well_known do pipeline :well_known do
plug(:accepts, ["json", "jrd+json", "xml", "xrd+xml"]) plug(:accepts, ["json", "jrd+json", "xml", "xrd+xml"])
end end
@ -154,7 +129,7 @@ defmodule Pleroma.Web.Router do
end end
scope "/api/pleroma/admin", Pleroma.Web.AdminAPI do scope "/api/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through([:admin_api, :oauth_write]) pipe_through(:admin_api)
post("/users/follow", AdminAPIController, :user_follow) post("/users/follow", AdminAPIController, :user_follow)
post("/users/unfollow", AdminAPIController, :user_unfollow) post("/users/unfollow", AdminAPIController, :user_unfollow)
@ -213,7 +188,7 @@ defmodule Pleroma.Web.Router do
scope "/api/pleroma/emoji", Pleroma.Web.PleromaAPI do scope "/api/pleroma/emoji", Pleroma.Web.PleromaAPI do
scope "/packs" do scope "/packs" do
# Modifying packs # Modifying packs
pipe_through([:admin_api, :oauth_write]) pipe_through(:admin_api)
post("/import_from_fs", EmojiAPIController, :import_from_fs) post("/import_from_fs", EmojiAPIController, :import_from_fs)
@ -238,32 +213,21 @@ defmodule Pleroma.Web.Router do
post("/main/ostatus", UtilController, :remote_subscribe) post("/main/ostatus", UtilController, :remote_subscribe)
get("/ostatus_subscribe", UtilController, :remote_follow) get("/ostatus_subscribe", UtilController, :remote_follow)
scope [] do
pipe_through(:oauth_follow)
post("/ostatus_subscribe", UtilController, :do_remote_follow) post("/ostatus_subscribe", UtilController, :do_remote_follow)
end end
end
scope "/api/pleroma", Pleroma.Web.TwitterAPI do scope "/api/pleroma", Pleroma.Web.TwitterAPI do
pipe_through(:authenticated_api) pipe_through(:authenticated_api)
scope [] do
pipe_through(:oauth_write)
post("/change_email", UtilController, :change_email) post("/change_email", UtilController, :change_email)
post("/change_password", UtilController, :change_password) post("/change_password", UtilController, :change_password)
post("/delete_account", UtilController, :delete_account) post("/delete_account", UtilController, :delete_account)
put("/notification_settings", UtilController, :update_notificaton_settings) put("/notification_settings", UtilController, :update_notificaton_settings)
post("/disable_account", UtilController, :disable_account) post("/disable_account", UtilController, :disable_account)
end
scope [] do
pipe_through(:oauth_follow)
post("/blocks_import", UtilController, :blocks_import) post("/blocks_import", UtilController, :blocks_import)
post("/follow_import", UtilController, :follow_import) post("/follow_import", UtilController, :follow_import)
end end
end
scope "/oauth", Pleroma.Web.OAuth do scope "/oauth", Pleroma.Web.OAuth do
scope [] do scope [] do
@ -289,14 +253,14 @@ defmodule Pleroma.Web.Router do
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
scope [] do scope [] do
pipe_through(:authenticated_api) pipe_through(:authenticated_api)
pipe_through(:oauth_read)
get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses) get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses)
get("/conversations/:id", PleromaAPIController, :conversation) get("/conversations/:id", PleromaAPIController, :conversation)
end end
scope [] do scope [] do
pipe_through(:authenticated_api) pipe_through(:authenticated_api)
pipe_through(:oauth_write)
patch("/conversations/:id", PleromaAPIController, :update_conversation) patch("/conversations/:id", PleromaAPIController, :update_conversation)
post("/notifications/read", PleromaAPIController, :read_notification) post("/notifications/read", PleromaAPIController, :read_notification)
@ -312,13 +276,11 @@ defmodule Pleroma.Web.Router do
scope [] do scope [] do
pipe_through(:api) pipe_through(:api)
pipe_through(:oauth_read_or_public)
get("/accounts/:id/favourites", AccountController, :favourites) get("/accounts/:id/favourites", AccountController, :favourites)
end end
scope [] do scope [] do
pipe_through(:authenticated_api) pipe_through(:authenticated_api)
pipe_through(:oauth_follow)
post("/accounts/:id/subscribe", AccountController, :subscribe) post("/accounts/:id/subscribe", AccountController, :subscribe)
post("/accounts/:id/unsubscribe", AccountController, :unsubscribe) post("/accounts/:id/unsubscribe", AccountController, :unsubscribe)
@ -328,17 +290,13 @@ defmodule Pleroma.Web.Router do
end end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
pipe_through([:api, :oauth_read_or_public]) pipe_through(:api)
get("/accounts/:id/scrobbles", ScrobbleController, :user_scrobbles) get("/accounts/:id/scrobbles", ScrobbleController, :user_scrobbles)
end end
scope "/api/v1", Pleroma.Web.MastodonAPI do scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:authenticated_api) pipe_through(:authenticated_api)
scope [] do
pipe_through(:oauth_read)
get("/accounts/verify_credentials", AccountController, :verify_credentials) get("/accounts/verify_credentials", AccountController, :verify_credentials)
get("/accounts/relationships", AccountController, :relationships) get("/accounts/relationships", AccountController, :relationships)
@ -378,11 +336,7 @@ defmodule Pleroma.Web.Router do
get("/conversations", ConversationController, :index) get("/conversations", ConversationController, :index)
post("/conversations/:id/read", ConversationController, :read) post("/conversations/:id/read", ConversationController, :read)
get("/endorsements", MastodonAPIController, :empty_array) get("/endorsements", AccountController, :endorsements)
end
scope [] do
pipe_through(:oauth_write)
patch("/accounts/update_credentials", AccountController, :update_credentials) patch("/accounts/update_credentials", AccountController, :update_credentials)
@ -421,10 +375,6 @@ defmodule Pleroma.Web.Router do
delete("/filters/:id", FilterController, :delete) delete("/filters/:id", FilterController, :delete)
post("/reports", ReportController, :create) post("/reports", ReportController, :create)
end
scope [] do
pipe_through(:oauth_follow)
post("/follows", AccountController, :follows) post("/follows", AccountController, :follows)
post("/accounts/:id/follow", AccountController, :follow) post("/accounts/:id/follow", AccountController, :follow)
@ -439,20 +389,15 @@ defmodule Pleroma.Web.Router do
post("/domain_blocks", DomainBlockController, :create) post("/domain_blocks", DomainBlockController, :create)
delete("/domain_blocks", DomainBlockController, :delete) delete("/domain_blocks", DomainBlockController, :delete)
end
scope [] do
pipe_through(:oauth_push)
post("/push/subscription", SubscriptionController, :create) post("/push/subscription", SubscriptionController, :create)
get("/push/subscription", SubscriptionController, :get) get("/push/subscription", SubscriptionController, :get)
put("/push/subscription", SubscriptionController, :update) put("/push/subscription", SubscriptionController, :update)
delete("/push/subscription", SubscriptionController, :delete) delete("/push/subscription", SubscriptionController, :delete)
end end
end
scope "/api/web", Pleroma.Web do scope "/api/web", Pleroma.Web do
pipe_through([:authenticated_api, :oauth_write]) pipe_through(:authenticated_api)
put("/settings", MastoFEController, :put_settings) put("/settings", MastoFEController, :put_settings)
end end
@ -477,9 +422,6 @@ defmodule Pleroma.Web.Router do
get("/trends", MastodonAPIController, :empty_array) get("/trends", MastodonAPIController, :empty_array)
scope [] do
pipe_through(:oauth_read_or_public)
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("/timelines/list/:list_id", TimelineController, :list)
@ -497,10 +439,9 @@ defmodule Pleroma.Web.Router do
get("/search", SearchController, :search) get("/search", SearchController, :search)
end end
end
scope "/api/v2", Pleroma.Web.MastodonAPI do scope "/api/v2", Pleroma.Web.MastodonAPI do
pipe_through([:api, :oauth_read_or_public]) pipe_through(:api)
get("/search", SearchController, :search2) get("/search", SearchController, :search2)
end end
@ -531,12 +472,8 @@ defmodule Pleroma.Web.Router do
get("/oauth_tokens", TwitterAPI.Controller, :oauth_tokens) get("/oauth_tokens", TwitterAPI.Controller, :oauth_tokens)
delete("/oauth_tokens/:id", TwitterAPI.Controller, :revoke_token) delete("/oauth_tokens/:id", TwitterAPI.Controller, :revoke_token)
scope [] do
pipe_through(:oauth_read)
post("/qvitter/statuses/notifications/read", TwitterAPI.Controller, :notifications_read) post("/qvitter/statuses/notifications/read", TwitterAPI.Controller, :notifications_read)
end end
end
pipeline :ap_service_actor do pipeline :ap_service_actor do
plug(:accepts, ["activity+json", "json"]) plug(:accepts, ["activity+json", "json"])
@ -599,24 +536,15 @@ defmodule Pleroma.Web.Router do
scope "/", Pleroma.Web.ActivityPub do scope "/", Pleroma.Web.ActivityPub do
pipe_through([:activitypub_client]) pipe_through([:activitypub_client])
scope [] do
pipe_through(:oauth_read)
get("/api/ap/whoami", ActivityPubController, :whoami) get("/api/ap/whoami", ActivityPubController, :whoami)
get("/users/:nickname/inbox", ActivityPubController, :read_inbox) get("/users/:nickname/inbox", ActivityPubController, :read_inbox)
end
scope [] do
pipe_through(:oauth_write)
post("/users/:nickname/outbox", ActivityPubController, :update_outbox) post("/users/:nickname/outbox", ActivityPubController, :update_outbox)
post("/api/ap/upload_media", ActivityPubController, :upload_media) post("/api/ap/upload_media", ActivityPubController, :upload_media)
end
scope [] do
pipe_through(:oauth_read_or_public)
get("/users/:nickname/followers", ActivityPubController, :followers) get("/users/:nickname/followers", ActivityPubController, :followers)
get("/users/:nickname/following", ActivityPubController, :following) get("/users/:nickname/following", ActivityPubController, :following)
end end
end
scope "/", Pleroma.Web.ActivityPub do scope "/", Pleroma.Web.ActivityPub do
pipe_through(:activitypub) pipe_through(:activitypub)
@ -665,11 +593,8 @@ defmodule Pleroma.Web.Router do
post("/auth/password", MastodonAPI.AuthController, :password_reset) post("/auth/password", MastodonAPI.AuthController, :password_reset)
scope [] do
pipe_through(:oauth_read)
get("/web/*path", MastoFEController, :index) get("/web/*path", MastoFEController, :index)
end end
end
pipeline :remote_media do pipeline :remote_media do
end end

View file

@ -202,7 +202,7 @@ def is_representable?(_), do: false
@spec publish(User.t(), Pleroma.Activity.t()) :: none @spec publish(User.t(), Pleroma.Activity.t()) :: none
def publish(user, activity) def publish(user, activity)
def publish(%{info: %{keys: keys}} = user, %{data: %{"type" => type}} = activity) def publish(%{keys: keys} = user, %{data: %{"type" => type}} = activity)
when type in @supported_activities do when type in @supported_activities do
feed = ActivityRepresenter.to_simple_form(activity, user, true) feed = ActivityRepresenter.to_simple_form(activity, user, true)
@ -238,7 +238,7 @@ def publish(%{info: %{keys: keys}} = user, %{data: %{"type" => type}} = activity
def publish(%{id: id}, _), do: Logger.debug(fn -> "Keys missing for user #{id}" end) def publish(%{id: id}, _), do: Logger.debug(fn -> "Keys missing for user #{id}" end)
def gather_webfinger_links(%User{} = user) do def gather_webfinger_links(%User{} = user) do
{:ok, _private, public} = Keys.keys_from_pem(user.info.keys) {:ok, _private, public} = Keys.keys_from_pem(user.keys)
magic_key = encode_key(public) magic_key = encode_key(public)
[ [

View file

@ -13,11 +13,34 @@ defmodule Pleroma.Web.TwitterAPI.UtilController do
alias Pleroma.Healthcheck alias Pleroma.Healthcheck
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Plugs.AuthenticationPlug alias Pleroma.Plugs.AuthenticationPlug
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web alias Pleroma.Web
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
alias Pleroma.Web.WebFinger alias Pleroma.Web.WebFinger
plug(
OAuthScopesPlug,
%{scopes: ["follow", "write:follows"]}
when action in [:do_remote_follow, :follow_import]
)
plug(OAuthScopesPlug, %{scopes: ["follow", "write:blocks"]} when action == :blocks_import)
plug(
OAuthScopesPlug,
%{scopes: ["write:accounts"]}
when action in [
:change_email,
:change_password,
:delete_account,
:update_notificaton_settings,
:disable_account
]
)
plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :notifications_read)
plug(Pleroma.Plugs.SetFormatPlug when action in [:config, :version]) plug(Pleroma.Plugs.SetFormatPlug when action in [:config, :version])
def help_test(conn, _params) do def help_test(conn, _params) do

View file

@ -6,12 +6,17 @@ defmodule Pleroma.Web.TwitterAPI.Controller do
use Pleroma.Web, :controller use Pleroma.Web, :controller
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.OAuth.Token alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.TwitterAPI.TokenView alias Pleroma.Web.TwitterAPI.TokenView
require Logger require Logger
plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action == :notifications_read)
plug(Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug)
action_fallback(:errors) action_fallback(:errors)
def confirm_email(conn, %{"user_id" => uid, "token" => token}) do def confirm_email(conn, %{"user_id" => uid, "token" => token}) do

View file

@ -3,22 +3,6 @@ defmodule Pleroma.Repo.Migrations.CopyMutedToMutedNotifications do
alias Pleroma.User alias Pleroma.User
def change do def change do
query = execute("update users set info = jsonb_set(info, '{muted_notifications}', info->'mutes', true) where local = true")
User.Query.build(%{
local: true,
active: true,
order_by: :id
})
Pleroma.Repo.stream(query)
|> Enum.each(fn
%{info: %{mutes: mutes} = info} = user ->
info_cng =
Ecto.Changeset.cast(info, %{muted_notifications: mutes}, [:muted_notifications])
Ecto.Changeset.change(user)
|> Ecto.Changeset.put_embed(:info, info_cng)
|> Pleroma.Repo.update()
end)
end end
end end

View file

@ -0,0 +1,9 @@
defmodule Pleroma.Repo.Migrations.AddKeysColumn do
use Ecto.Migration
def change do
alter table("users") do
add_if_not_exists :keys, :text
end
end
end

View file

@ -0,0 +1,7 @@
defmodule Pleroma.Repo.Migrations.MoveKeysToSeparateColumn do
use Ecto.Migration
def change do
execute("update users set keys = info->>'keys' where local", "update users set info = jsonb_set(info, '{keys}'::text[], to_jsonb(keys)) where local")
end
end

View file

@ -19,8 +19,8 @@ test "build report email" do
AdminEmail.report(to_user, reporter, account, [%{name: "Test", id: "12"}], "Test comment") AdminEmail.report(to_user, reporter, account, [%{name: "Test", id: "12"}], "Test comment")
status_url = Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, "12") status_url = Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, "12")
reporter_url = Helpers.o_status_url(Pleroma.Web.Endpoint, :feed_redirect, reporter.nickname) reporter_url = Helpers.o_status_url(Pleroma.Web.Endpoint, :feed_redirect, reporter.id)
account_url = Helpers.o_status_url(Pleroma.Web.Endpoint, :feed_redirect, account.nickname) account_url = Helpers.o_status_url(Pleroma.Web.Endpoint, :feed_redirect, account.id)
assert res.to == [{to_user.name, to_user.email}] assert res.to == [{to_user.name, to_user.email}]
assert res.from == {config[:name], config[:notify_email]} assert res.from == {config[:name], config[:notify_email]}

View file

@ -5,12 +5,33 @@
defmodule Pleroma.Plugs.OAuthScopesPlugTest do defmodule Pleroma.Plugs.OAuthScopesPlugTest do
use Pleroma.Web.ConnCase, async: true use Pleroma.Web.ConnCase, async: true
alias Pleroma.Plugs.EnsurePublicOrAuthenticatedPlug
alias Pleroma.Plugs.OAuthScopesPlug alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Repo alias Pleroma.Repo
import Mock
import Pleroma.Factory import Pleroma.Factory
test "proceeds with no op if `assigns[:token]` is nil", %{conn: conn} do setup_with_mocks([{EnsurePublicOrAuthenticatedPlug, [], [call: fn conn, _ -> conn end]}]) do
:ok
end
describe "when `assigns[:token]` is nil, " do
test "with :skip_instance_privacy_check option, proceeds with no op", %{conn: conn} do
conn =
conn
|> assign(:user, insert(:user))
|> OAuthScopesPlug.call(%{scopes: ["read"], skip_instance_privacy_check: true})
refute conn.halted
assert conn.assigns[:user]
refute called(EnsurePublicOrAuthenticatedPlug.call(conn, :_))
end
test "without :skip_instance_privacy_check option, calls EnsurePublicOrAuthenticatedPlug", %{
conn: conn
} do
conn = conn =
conn conn
|> assign(:user, insert(:user)) |> assign(:user, insert(:user))
@ -18,11 +39,14 @@ test "proceeds with no op if `assigns[:token]` is nil", %{conn: conn} do
refute conn.halted refute conn.halted
assert conn.assigns[:user] assert conn.assigns[:user]
assert called(EnsurePublicOrAuthenticatedPlug.call(conn, :_))
end
end end
test "proceeds with no op if `token.scopes` fulfill specified 'any of' conditions", %{ test "if `token.scopes` fulfills specified 'any of' conditions, " <>
conn: conn "proceeds with no op",
} do %{conn: conn} do
token = insert(:oauth_token, scopes: ["read", "write"]) |> Repo.preload(:user) token = insert(:oauth_token, scopes: ["read", "write"]) |> Repo.preload(:user)
conn = conn =
@ -35,9 +59,9 @@ test "proceeds with no op if `token.scopes` fulfill specified 'any of' condition
assert conn.assigns[:user] assert conn.assigns[:user]
end end
test "proceeds with no op if `token.scopes` fulfill specified 'all of' conditions", %{ test "if `token.scopes` fulfills specified 'all of' conditions, " <>
conn: conn "proceeds with no op",
} do %{conn: conn} do
token = insert(:oauth_token, scopes: ["scope1", "scope2", "scope3"]) |> Repo.preload(:user) token = insert(:oauth_token, scopes: ["scope1", "scope2", "scope3"]) |> Repo.preload(:user)
conn = conn =
@ -50,8 +74,9 @@ test "proceeds with no op if `token.scopes` fulfill specified 'all of' condition
assert conn.assigns[:user] assert conn.assigns[:user]
end end
test "proceeds with cleared `assigns[:user]` if `token.scopes` doesn't fulfill specified 'any of' conditions " <> describe "with `fallback: :proceed_unauthenticated` option, " do
"and `fallback: :proceed_unauthenticated` option is specified", test "if `token.scopes` doesn't fulfill specified 'any of' conditions, " <>
"clears `assigns[:user]` and calls EnsurePublicOrAuthenticatedPlug",
%{conn: conn} do %{conn: conn} do
token = insert(:oauth_token, scopes: ["read", "write"]) |> Repo.preload(:user) token = insert(:oauth_token, scopes: ["read", "write"]) |> Repo.preload(:user)
@ -63,10 +88,12 @@ test "proceeds with cleared `assigns[:user]` if `token.scopes` doesn't fulfill s
refute conn.halted refute conn.halted
refute conn.assigns[:user] refute conn.assigns[:user]
assert called(EnsurePublicOrAuthenticatedPlug.call(conn, :_))
end end
test "proceeds with cleared `assigns[:user]` if `token.scopes` doesn't fulfill specified 'all of' conditions " <> test "if `token.scopes` doesn't fulfill specified 'all of' conditions, " <>
"and `fallback: :proceed_unauthenticated` option is specified", "clears `assigns[:user] and calls EnsurePublicOrAuthenticatedPlug",
%{conn: conn} do %{conn: conn} do
token = insert(:oauth_token, scopes: ["read", "write"]) |> Repo.preload(:user) token = insert(:oauth_token, scopes: ["read", "write"]) |> Repo.preload(:user)
@ -82,9 +109,36 @@ test "proceeds with cleared `assigns[:user]` if `token.scopes` doesn't fulfill s
refute conn.halted refute conn.halted
refute conn.assigns[:user] refute conn.assigns[:user]
assert called(EnsurePublicOrAuthenticatedPlug.call(conn, :_))
end end
test "returns 403 and halts in case of no :fallback option and `token.scopes` not fulfilling specified 'any of' conditions", test "with :skip_instance_privacy_check option, " <>
"if `token.scopes` doesn't fulfill specified conditions, " <>
"clears `assigns[:user]` and does not call EnsurePublicOrAuthenticatedPlug",
%{conn: conn} do
token = insert(:oauth_token, scopes: ["read:statuses", "write"]) |> Repo.preload(:user)
conn =
conn
|> assign(:user, token.user)
|> assign(:token, token)
|> OAuthScopesPlug.call(%{
scopes: ["read"],
fallback: :proceed_unauthenticated,
skip_instance_privacy_check: true
})
refute conn.halted
refute conn.assigns[:user]
refute called(EnsurePublicOrAuthenticatedPlug.call(conn, :_))
end
end
describe "without :fallback option, " do
test "if `token.scopes` does not fulfill specified 'any of' conditions, " <>
"returns 403 and halts",
%{conn: conn} do %{conn: conn} do
token = insert(:oauth_token, scopes: ["read", "write"]) token = insert(:oauth_token, scopes: ["read", "write"])
any_of_scopes = ["follow"] any_of_scopes = ["follow"]
@ -101,7 +155,8 @@ test "returns 403 and halts in case of no :fallback option and `token.scopes` no
assert Jason.encode!(%{error: expected_error}) == conn.resp_body assert Jason.encode!(%{error: expected_error}) == conn.resp_body
end end
test "returns 403 and halts in case of no :fallback option and `token.scopes` not fulfilling specified 'all of' conditions", test "if `token.scopes` does not fulfill specified 'all of' conditions, " <>
"returns 403 and halts",
%{conn: conn} do %{conn: conn} do
token = insert(:oauth_token, scopes: ["read", "write"]) token = insert(:oauth_token, scopes: ["read", "write"])
all_of_scopes = ["write", "follow"] all_of_scopes = ["write", "follow"]
@ -119,4 +174,54 @@ test "returns 403 and halts in case of no :fallback option and `token.scopes` no
assert Jason.encode!(%{error: expected_error}) == conn.resp_body assert Jason.encode!(%{error: expected_error}) == conn.resp_body
end end
end
describe "with hierarchical scopes, " do
test "if `token.scopes` fulfills specified 'any of' conditions, " <>
"proceeds with no op",
%{conn: conn} do
token = insert(:oauth_token, scopes: ["read", "write"]) |> Repo.preload(:user)
conn =
conn
|> assign(:user, token.user)
|> assign(:token, token)
|> OAuthScopesPlug.call(%{scopes: ["read:something"]})
refute conn.halted
assert conn.assigns[:user]
end
test "if `token.scopes` fulfills specified 'all of' conditions, " <>
"proceeds with no op",
%{conn: conn} do
token = insert(:oauth_token, scopes: ["scope1", "scope2", "scope3"]) |> Repo.preload(:user)
conn =
conn
|> assign(:user, token.user)
|> assign(:token, token)
|> OAuthScopesPlug.call(%{scopes: ["scope1:subscope", "scope2:subscope"], op: :&})
refute conn.halted
assert conn.assigns[:user]
end
end
describe "filter_descendants/2" do
test "filters scopes which directly match or are ancestors of supported scopes" do
f = fn scopes, supported_scopes ->
OAuthScopesPlug.filter_descendants(scopes, supported_scopes)
end
assert f.(["read", "follow"], ["write", "read"]) == ["read"]
assert f.(["read", "write:something", "follow"], ["write", "read"]) ==
["read", "write:something"]
assert f.(["admin:read"], ["write", "read"]) == []
assert f.(["admin:read"], ["write", "admin"]) == ["admin:read"]
end
end
end end

View file

@ -80,7 +80,7 @@ test "it returns signature headers" do
user = user =
insert(:user, %{ insert(:user, %{
ap_id: "https://mastodon.social/users/lambadalambda", ap_id: "https://mastodon.social/users/lambadalambda",
info: %{keys: @private_key} keys: @private_key
}) })
assert Signature.sign( assert Signature.sign(
@ -94,8 +94,7 @@ test "it returns signature headers" do
end end
test "it returns error" do test "it returns error" do
user = user = insert(:user, %{ap_id: "https://mastodon.social/users/lambadalambda", keys: ""})
insert(:user, %{ap_id: "https://mastodon.social/users/lambadalambda", info: %{keys: ""}})
assert Signature.sign( assert Signature.sign(
user, user,

View file

@ -324,6 +324,7 @@ def oauth_token_factory do
%Pleroma.Web.OAuth.Token{ %Pleroma.Web.OAuth.Token{
token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(), token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(),
scopes: ["read"],
refresh_token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(), refresh_token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(),
user: build(:user), user: build(:user),
app_id: oauth_app.id, app_id: oauth_app.id,

View file

@ -1457,15 +1457,15 @@ test "if user is unconfirmed" do
describe "ensure_keys_present" do describe "ensure_keys_present" do
test "it creates keys for a user and stores them in info" do test "it creates keys for a user and stores them in info" do
user = insert(:user) user = insert(:user)
refute is_binary(user.info.keys) refute is_binary(user.keys)
{:ok, user} = User.ensure_keys_present(user) {:ok, user} = User.ensure_keys_present(user)
assert is_binary(user.info.keys) assert is_binary(user.keys)
end end
test "it doesn't create keys if there already are some" do test "it doesn't create keys if there already are some" do
user = insert(:user, %{info: %{keys: "xxx"}}) user = insert(:user, keys: "xxx")
{:ok, user} = User.ensure_keys_present(user) {:ok, user} = User.ensure_keys_present(user)
assert user.info.keys == "xxx" assert user.keys == "xxx"
end end
end end

View file

@ -272,7 +272,7 @@ test "updates the user's background", %{conn: conn} do
assert user_response["pleroma"]["background_image"] assert user_response["pleroma"]["background_image"]
end end
test "requires 'write' permission", %{conn: conn} do test "requires 'write:accounts' permission", %{conn: conn} do
token1 = insert(:oauth_token, scopes: ["read"]) token1 = insert(:oauth_token, scopes: ["read"])
token2 = insert(:oauth_token, scopes: ["write", "follow"]) token2 = insert(:oauth_token, scopes: ["write", "follow"])
@ -283,7 +283,8 @@ test "requires 'write' permission", %{conn: conn} do
|> patch("/api/v1/accounts/update_credentials", %{}) |> patch("/api/v1/accounts/update_credentials", %{})
if token == token1 do if token == token1 do
assert %{"error" => "Insufficient permissions: write."} == json_response(conn, 403) assert %{"error" => "Insufficient permissions: write:accounts."} ==
json_response(conn, 403)
else else
assert json_response(conn, 200) assert json_response(conn, 200)
end end

View file

@ -557,7 +557,7 @@ test "redirects with oauth authorization" do
"password" => "test", "password" => "test",
"client_id" => app.client_id, "client_id" => app.client_id,
"redirect_uri" => redirect_uri, "redirect_uri" => redirect_uri,
"scope" => "read write", "scope" => "read:subscope write",
"state" => "statepassed" "state" => "statepassed"
} }
}) })
@ -570,7 +570,7 @@ test "redirects with oauth authorization" do
assert %{"state" => "statepassed", "code" => code} = query assert %{"state" => "statepassed", "code" => code} = query
auth = Repo.get_by(Authorization, token: code) auth = Repo.get_by(Authorization, token: code)
assert auth assert auth
assert auth.scopes == ["read", "write"] assert auth.scopes == ["read:subscope", "write"]
end end
test "returns 401 for wrong credentials", %{conn: conn} do test "returns 401 for wrong credentials", %{conn: conn} do
@ -627,7 +627,7 @@ test "returns 401 for missing scopes", %{conn: conn} do
assert result =~ "This action is outside the authorized scopes" assert result =~ "This action is outside the authorized scopes"
end end
test "returns 401 for scopes beyond app scopes", %{conn: conn} do test "returns 401 for scopes beyond app scopes hierarchy", %{conn: conn} do
user = insert(:user) user = insert(:user)
app = insert(:oauth_app, scopes: ["read", "write"]) app = insert(:oauth_app, scopes: ["read", "write"])
redirect_uri = OAuthController.default_redirect_uri(app) redirect_uri = OAuthController.default_redirect_uri(app)

View file

@ -81,19 +81,21 @@ test "it imports new-style mastodon follow lists", %{conn: conn} do
assert response == "job started" assert response == "job started"
end end
test "requires 'follow' permission", %{conn: conn} do test "requires 'follow' or 'write:follows' permissions", %{conn: conn} do
token1 = insert(:oauth_token, scopes: ["read", "write"]) token1 = insert(:oauth_token, scopes: ["read", "write"])
token2 = insert(:oauth_token, scopes: ["follow"]) token2 = insert(:oauth_token, scopes: ["follow"])
token3 = insert(:oauth_token, scopes: ["something"])
another_user = insert(:user) another_user = insert(:user)
for token <- [token1, token2] do for token <- [token1, token2, token3] do
conn = conn =
conn conn
|> put_req_header("authorization", "Bearer #{token.token}") |> put_req_header("authorization", "Bearer #{token.token}")
|> post("/api/pleroma/follow_import", %{"list" => "#{another_user.ap_id}"}) |> post("/api/pleroma/follow_import", %{"list" => "#{another_user.ap_id}"})
if token == token1 do if token == token3 do
assert %{"error" => "Insufficient permissions: follow."} == json_response(conn, 403) assert %{"error" => "Insufficient permissions: follow | write:follows."} ==
json_response(conn, 403)
else else
assert json_response(conn, 200) assert json_response(conn, 200)
end end