1
0
Fork 0
forked from mirrors/akkoma

Merge branch 'develop' of git.pleroma.social:pleroma/pleroma into remake-remodel-dms

This commit is contained in:
lain 2020-05-07 15:05:40 +02:00
commit fb2d284d28
70 changed files with 2872 additions and 994 deletions

View file

@ -238,7 +238,18 @@
account_field_value_length: 2048,
external_user_synchronization: true,
extended_nickname_format: true,
cleanup_attachments: false
cleanup_attachments: false,
multi_factor_authentication: [
totp: [
# digits 6 or 8
digits: 6,
period: 30
],
backup_codes: [
number: 5,
length: 16
]
]
config :pleroma, :extensions, output_relationships_in_statuses_by_default: true

View file

@ -919,6 +919,62 @@
key: :external_user_synchronization,
type: :boolean,
description: "Enabling following/followers counters synchronization for external users"
},
%{
key: :multi_factor_authentication,
type: :keyword,
description: "Multi-factor authentication settings",
suggestions: [
[
totp: [digits: 6, period: 30],
backup_codes: [number: 5, length: 16]
]
],
children: [
%{
key: :totp,
type: :keyword,
description: "TOTP settings",
suggestions: [digits: 6, period: 30],
children: [
%{
key: :digits,
type: :integer,
suggestions: [6],
description:
"Determines the length of a one-time pass-code, in characters. Defaults to 6 characters."
},
%{
key: :period,
type: :integer,
suggestions: [30],
description:
"a period for which the TOTP code will be valid, in seconds. Defaults to 30 seconds."
}
]
},
%{
key: :backup_codes,
type: :keyword,
description: "MFA backup codes settings",
suggestions: [number: 5, length: 16],
children: [
%{
key: :number,
type: :integer,
suggestions: [5],
description: "number of backup codes to generate."
},
%{
key: :length,
type: :integer,
suggestions: [16],
description:
"Determines the length of backup one-time pass-codes, in characters. Defaults to 16 characters."
}
]
}
]
}
]
},

View file

@ -56,6 +56,19 @@
ignore_hosts: [],
ignore_tld: ["local", "localdomain", "lan"]
config :pleroma, :instance,
multi_factor_authentication: [
totp: [
# digits 6 or 8
digits: 6,
period: 30
],
backup_codes: [
number: 2,
length: 6
]
]
config :web_push_encryption, :vapid_details,
subject: "mailto:administrator@example.com",
public_key:

View file

@ -409,6 +409,7 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
### Get a password reset token for a given nickname
- Params: none
- Response:
@ -427,6 +428,14 @@ Note: Available `:permission_group` is currently moderator and admin. 404 is ret
- `nicknames`
- Response: none (code `204`)
## PUT `/api/pleroma/admin/users/disable_mfa`
### Disable mfa for user's account.
- Params:
- `nickname`
- Response: Users nickname
## `GET /api/pleroma/admin/users/:nickname/credentials`
### Get the user's email, password, display and settings-related fields

View file

@ -70,7 +70,49 @@ Request parameters can be passed via [query strings](https://en.wikipedia.org/wi
* Response: JSON. Returns `{"status": "success"}` if the account was successfully disabled, `{"error": "[error message]"}` otherwise
* Example response: `{"error": "Invalid password."}`
## `/api/pleroma/admin/`
## `/api/pleroma/accounts/mfa`
#### Gets current MFA settings
* method: `GET`
* Authentication: required
* OAuth scope: `read:security`
* Response: JSON. Returns `{"enabled": "false", "totp": false }`
## `/api/pleroma/accounts/mfa/setup/totp`
#### Pre-setup the MFA/TOTP method
* method: `GET`
* Authentication: required
* OAuth scope: `write:security`
* Response: JSON. Returns `{"key": [secret_key], "provisioning_uri": "[qr code uri]" }` when successful, otherwise returns HTTP 422 `{"error": "error_msg"}`
## `/api/pleroma/accounts/mfa/confirm/totp`
#### Confirms & enables MFA/TOTP support for user account.
* method: `POST`
* Authentication: required
* OAuth scope: `write:security`
* Params:
* `password`: user's password
* `code`: token from TOTP App
* Response: JSON. Returns `{}` if the enable was successful, HTTP 422 `{"error": "[error message]"}` otherwise
## `/api/pleroma/accounts/mfa/totp`
#### Disables MFA/TOTP method for user account.
* method: `DELETE`
* Authentication: required
* OAuth scope: `write:security`
* Params:
* `password`: user's password
* Response: JSON. Returns `{}` if the disable was successful, HTTP 422 `{"error": "[error message]"}` otherwise
* Example response: `{"error": "Invalid password."}`
## `/api/pleroma/accounts/mfa/backup_codes`
#### Generstes backup codes MFA for user account.
* method: `GET`
* Authentication: required
* OAuth scope: `write:security`
* Response: JSON. Returns `{"codes": codes}`when successful, otherwise HTTP 422 `{"error": "[error message]"}`
## `/api/pleroma/admin/`
See [Admin-API](admin_api.md)
## `/api/v1/pleroma/notifications/read`

View file

@ -907,12 +907,18 @@ config :auto_linker,
* `runtime_dir`: A path to custom Elixir modules (such as MRF policies).
## :configurable_from_database
Boolean, enables/disables in-database configuration. Read [Transfering the config to/from the database](../administration/CLI_tasks/config.md) for more information.
### Multi-factor authentication - :two_factor_authentication
* `totp` - a list containing TOTP configuration
- `digits` - Determines the length of a one-time pass-code in characters. Defaults to 6 characters.
- `period` - a period for which the TOTP code will be valid in seconds. Defaults to 30 seconds.
* `backup_codes` - a list containing backup codes configuration
- `number` - number of backup codes to generate.
- `length` - backup code length. Defaults to 16 characters.
## Restrict entities access for unauthenticated users
@ -930,6 +936,7 @@ Restrict access for unauthenticated users to timelines (public and federate), us
* `local`
* `remote`
## Pleroma.Web.ApiSpec.CastAndValidate
* `:strict` a boolean, enables strict input validation (useful in development, not recommended in production). Defaults to `false`.

View file

@ -173,7 +173,14 @@ defp chat_enabled?, do: Config.get([:chat, :enabled])
defp streamer_child(env) when env in [:test, :benchmark], do: []
defp streamer_child(_) do
[Pleroma.Web.Streamer.supervisor()]
[
{Registry,
[
name: Pleroma.Web.Streamer.registry(),
keys: :duplicate,
partitions: System.schedulers_online()
]}
]
end
defp chat_child(_env, true) do

156
lib/pleroma/mfa.ex Normal file
View file

@ -0,0 +1,156 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA do
@moduledoc """
The MFA context.
"""
alias Comeonin.Pbkdf2
alias Pleroma.User
alias Pleroma.MFA.BackupCodes
alias Pleroma.MFA.Changeset
alias Pleroma.MFA.Settings
alias Pleroma.MFA.TOTP
@doc """
Returns MFA methods the user has enabled.
## Examples
iex> Pleroma.MFA.supported_method(User)
"totp, u2f"
"""
@spec supported_methods(User.t()) :: String.t()
def supported_methods(user) do
settings = fetch_settings(user)
Settings.mfa_methods()
|> Enum.reduce([], fn m, acc ->
if method_enabled?(m, settings) do
acc ++ [m]
else
acc
end
end)
|> Enum.join(",")
end
@doc "Checks that user enabled MFA"
def require?(user) do
fetch_settings(user).enabled
end
@doc """
Display MFA settings of user
"""
def mfa_settings(user) do
settings = fetch_settings(user)
Settings.mfa_methods()
|> Enum.map(fn m -> [m, method_enabled?(m, settings)] end)
|> Enum.into(%{enabled: settings.enabled}, fn [a, b] -> {a, b} end)
end
@doc false
def fetch_settings(%User{} = user) do
user.multi_factor_authentication_settings || %Settings{}
end
@doc "clears backup codes"
def invalidate_backup_code(%User{} = user, hash_code) do
%{backup_codes: codes} = fetch_settings(user)
user
|> Changeset.cast_backup_codes(codes -- [hash_code])
|> User.update_and_set_cache()
end
@doc "generates backup codes"
@spec generate_backup_codes(User.t()) :: {:ok, list(binary)} | {:error, String.t()}
def generate_backup_codes(%User{} = user) do
with codes <- BackupCodes.generate(),
hashed_codes <- Enum.map(codes, &Pbkdf2.hashpwsalt/1),
changeset <- Changeset.cast_backup_codes(user, hashed_codes),
{:ok, _} <- User.update_and_set_cache(changeset) do
{:ok, codes}
else
{:error, msg} ->
%{error: msg}
end
end
@doc """
Generates secret key and set delivery_type to 'app' for TOTP method.
"""
@spec setup_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def setup_totp(user) do
user
|> Changeset.setup_totp(%{secret: TOTP.generate_secret(), delivery_type: "app"})
|> User.update_and_set_cache()
end
@doc """
Confirms the TOTP method for user.
`attrs`:
`password` - current user password
`code` - TOTP token
"""
@spec confirm_totp(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t() | atom()}
def confirm_totp(%User{} = user, attrs) do
with settings <- user.multi_factor_authentication_settings.totp,
{:ok, :pass} <- TOTP.validate_token(settings.secret, attrs["code"]) do
user
|> Changeset.confirm_totp()
|> User.update_and_set_cache()
end
end
@doc """
Disables the TOTP method for user.
`attrs`:
`password` - current user password
"""
@spec disable_totp(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def disable_totp(%User{} = user) do
user
|> Changeset.disable_totp()
|> Changeset.disable()
|> User.update_and_set_cache()
end
@doc """
Force disables all MFA methods for user.
"""
@spec disable(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def disable(%User{} = user) do
user
|> Changeset.disable_totp()
|> Changeset.disable(true)
|> User.update_and_set_cache()
end
@doc """
Checks if the user has MFA method enabled.
"""
def method_enabled?(method, settings) do
with {:ok, %{confirmed: true} = _} <- Map.fetch(settings, method) do
true
else
_ -> false
end
end
@doc """
Checks if the user has enabled at least one MFA method.
"""
def enabled?(settings) do
Settings.mfa_methods()
|> Enum.map(fn m -> method_enabled?(m, settings) end)
|> Enum.any?()
end
end

View file

@ -0,0 +1,31 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.BackupCodes do
@moduledoc """
This module contains functions for generating backup codes.
"""
alias Pleroma.Config
@config_ns [:instance, :multi_factor_authentication, :backup_codes]
@doc """
Generates backup codes.
"""
@spec generate(Keyword.t()) :: list(String.t())
def generate(opts \\ []) do
number_of_codes = Keyword.get(opts, :number_of_codes, default_backup_codes_number())
code_length = Keyword.get(opts, :length, default_backup_codes_code_length())
Enum.map(1..number_of_codes, fn _ ->
:crypto.strong_rand_bytes(div(code_length, 2))
|> Base.encode16(case: :lower)
end)
end
defp default_backup_codes_number, do: Config.get(@config_ns ++ [:number], 5)
defp default_backup_codes_code_length,
do: Config.get(@config_ns ++ [:length], 16)
end

View file

@ -0,0 +1,64 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.Changeset do
alias Pleroma.MFA
alias Pleroma.MFA.Settings
alias Pleroma.User
def disable(%Ecto.Changeset{} = changeset, force \\ false) do
settings =
changeset
|> Ecto.Changeset.apply_changes()
|> MFA.fetch_settings()
if force || not MFA.enabled?(settings) do
put_change(changeset, %Settings{settings | enabled: false})
else
changeset
end
end
def disable_totp(%User{multi_factor_authentication_settings: settings} = user) do
user
|> put_change(%Settings{settings | totp: %Settings.TOTP{}})
end
def confirm_totp(%User{multi_factor_authentication_settings: settings} = user) do
totp_settings = %Settings.TOTP{settings.totp | confirmed: true}
user
|> put_change(%Settings{settings | totp: totp_settings, enabled: true})
end
def setup_totp(%User{} = user, attrs) do
mfa_settings = MFA.fetch_settings(user)
totp_settings =
%Settings.TOTP{}
|> Ecto.Changeset.cast(attrs, [:secret, :delivery_type])
user
|> put_change(%Settings{mfa_settings | totp: Ecto.Changeset.apply_changes(totp_settings)})
end
def cast_backup_codes(%User{} = user, codes) do
user
|> put_change(%Settings{
user.multi_factor_authentication_settings
| backup_codes: codes
})
end
defp put_change(%User{} = user, settings) do
user
|> Ecto.Changeset.change()
|> put_change(settings)
end
defp put_change(%Ecto.Changeset{} = changeset, settings) do
changeset
|> Ecto.Changeset.put_change(:multi_factor_authentication_settings, settings)
end
end

View file

@ -0,0 +1,24 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.Settings do
use Ecto.Schema
@primary_key false
@mfa_methods [:totp]
embedded_schema do
field(:enabled, :boolean, default: false)
field(:backup_codes, {:array, :string}, default: [])
embeds_one :totp, TOTP, on_replace: :delete, primary_key: false do
field(:secret, :string)
# app | sms
field(:delivery_type, :string, default: "app")
field(:confirmed, :boolean, default: false)
end
end
def mfa_methods, do: @mfa_methods
end

106
lib/pleroma/mfa/token.ex Normal file
View file

@ -0,0 +1,106 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.Token do
use Ecto.Schema
import Ecto.Query
import Ecto.Changeset
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.OAuth.Authorization
alias Pleroma.Web.OAuth.Token, as: OAuthToken
@expires 300
schema "mfa_tokens" do
field(:token, :string)
field(:valid_until, :naive_datetime_usec)
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
belongs_to(:authorization, Authorization)
timestamps()
end
def get_by_token(token) do
from(
t in __MODULE__,
where: t.token == ^token,
preload: [:user, :authorization]
)
|> Repo.find_resource()
end
def validate(token) do
with {:fetch_token, {:ok, token}} <- {:fetch_token, get_by_token(token)},
{:expired, false} <- {:expired, is_expired?(token)} do
{:ok, token}
else
{:expired, _} -> {:error, :expired_token}
{:fetch_token, _} -> {:error, :not_found}
error -> {:error, error}
end
end
def create_token(%User{} = user) do
%__MODULE__{}
|> change
|> assign_user(user)
|> put_token
|> put_valid_until
|> Repo.insert()
end
def create_token(user, authorization) do
%__MODULE__{}
|> change
|> assign_user(user)
|> assign_authorization(authorization)
|> put_token
|> put_valid_until
|> Repo.insert()
end
defp assign_user(changeset, user) do
changeset
|> put_assoc(:user, user)
|> validate_required([:user])
end
defp assign_authorization(changeset, authorization) do
changeset
|> put_assoc(:authorization, authorization)
|> validate_required([:authorization])
end
defp put_token(changeset) do
changeset
|> change(%{token: OAuthToken.Utils.generate_token()})
|> validate_required([:token])
|> unique_constraint(:token)
end
defp put_valid_until(changeset) do
expires_in = NaiveDateTime.add(NaiveDateTime.utc_now(), @expires)
changeset
|> change(%{valid_until: expires_in})
|> validate_required([:valid_until])
end
def is_expired?(%__MODULE__{valid_until: valid_until}) do
NaiveDateTime.diff(NaiveDateTime.utc_now(), valid_until) > 0
end
def is_expired?(_), do: false
def delete_expired_tokens do
from(
q in __MODULE__,
where: fragment("?", q.valid_until) < ^Timex.now()
)
|> Repo.delete_all()
end
end

86
lib/pleroma/mfa/totp.ex Normal file
View file

@ -0,0 +1,86 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFA.TOTP do
@moduledoc """
This module represents functions to create secrets for
TOTP Application as well as validate them with a time based token.
"""
alias Pleroma.Config
@config_ns [:instance, :multi_factor_authentication, :totp]
@doc """
https://github.com/google/google-authenticator/wiki/Key-Uri-Format
"""
def provisioning_uri(secret, label, opts \\ []) do
query =
%{
secret: secret,
issuer: Keyword.get(opts, :issuer, default_issuer()),
digits: Keyword.get(opts, :digits, default_digits()),
period: Keyword.get(opts, :period, default_period())
}
|> Enum.filter(fn {_, v} -> not is_nil(v) end)
|> Enum.into(%{})
|> URI.encode_query()
%URI{scheme: "otpauth", host: "totp", path: "/" <> label, query: query}
|> URI.to_string()
end
defp default_period, do: Config.get(@config_ns ++ [:period])
defp default_digits, do: Config.get(@config_ns ++ [:digits])
defp default_issuer,
do: Config.get(@config_ns ++ [:issuer], Config.get([:instance, :name]))
@doc "Creates a random Base 32 encoded string"
def generate_secret do
Base.encode32(:crypto.strong_rand_bytes(10))
end
@doc "Generates a valid token based on a secret"
def generate_token(secret) do
:pot.totp(secret)
end
@doc """
Validates a given token based on a secret.
optional parameters:
`token_length` default `6`
`interval_length` default `30`
`window` default 0
Returns {:ok, :pass} if the token is valid and
{:error, :invalid_token} if it is not.
"""
@spec validate_token(String.t(), String.t()) ::
{:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token}
def validate_token(secret, token)
when is_binary(secret) and is_binary(token) do
opts = [
token_length: default_digits(),
interval_length: default_period()
]
validate_token(secret, token, opts)
end
def validate_token(_, _), do: {:error, :invalid_secret_and_token}
@doc "See `validate_token/2`"
@spec validate_token(String.t(), String.t(), Keyword.t()) ::
{:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token}
def validate_token(secret, token, options)
when is_binary(secret) and is_binary(token) do
case :pot.valid_totp(token, secret, options) do
true -> {:ok, :pass}
false -> {:error, :invalid_token}
end
end
def validate_token(_, _, _), do: {:error, :invalid_secret_and_token}
end

View file

@ -15,6 +15,20 @@ def init(options) do
end
@impl true
def perform(
%{
assigns: %{
auth_credentials: %{password: _},
user: %User{multi_factor_authentication_settings: %{enabled: true}}
}
} = conn,
_
) do
conn
|> render_error(:forbidden, "Two-factor authentication enabled, you must use a access token.")
|> halt()
end
def perform(%{assigns: %{user: %User{}}} = conn, _) do
conn
end

View file

@ -20,6 +20,7 @@ defmodule Pleroma.User do
alias Pleroma.Formatter
alias Pleroma.HTML
alias Pleroma.Keys
alias Pleroma.MFA
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.Registration
@ -190,6 +191,12 @@ defmodule Pleroma.User do
# `:subscribers` is deprecated (replaced with `subscriber_users` relation)
field(:subscribers, {:array, :string}, default: [])
embeds_one(
:multi_factor_authentication_settings,
MFA.Settings,
on_replace: :delete
)
timestamps()
end
@ -927,6 +934,7 @@ def get_cached_by_nickname_or_id(nickname_or_id, opts \\ []) do
end
end
@spec get_by_nickname(String.t()) :: User.t() | nil
def get_by_nickname(nickname) do
Repo.get_by(User, nickname: nickname) ||
if Regex.match?(~r(@#{Pleroma.Web.Endpoint.host()})i, nickname) do

View file

@ -177,12 +177,6 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when
BackgroundWorker.enqueue("fetch_data_for_activity", %{"activity_id" => activity.id})
Notification.create_notifications(activity)
conversation = create_or_bump_conversation(activity, map["actor"])
participations = get_participations(conversation)
stream_out(activity)
stream_out_participations(participations)
{:ok, activity}
else
%Activity{} = activity ->
@ -205,6 +199,15 @@ def insert(map, local \\ true, fake \\ false, bypass_actor_check \\ false) when
end
end
def notify_and_stream(activity) do
Notification.create_notifications(activity)
conversation = create_or_bump_conversation(activity, activity.actor)
participations = get_participations(conversation)
stream_out(activity)
stream_out_participations(participations)
end
defp create_or_bump_conversation(activity, actor) do
with {:ok, conversation} <- Conversation.create_or_bump_for(activity),
%User{} = user <- User.get_cached_by_ap_id(actor),
@ -281,6 +284,7 @@ defp do_create(%{to: to, actor: actor, context: context, object: object} = param
_ <- increase_poll_votes_if_vote(create_data),
{:quick_insert, false, activity} <- {:quick_insert, quick_insert?, activity},
{:ok, _actor} <- increase_note_count_if_public(actor, activity),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
{:ok, activity}
else
@ -308,6 +312,7 @@ def listen(%{to: to, actor: actor, context: context, object: object} = params) d
additional
),
{:ok, activity} <- insert(listen_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
{:ok, activity}
end
@ -332,6 +337,7 @@ def accept_or_reject(type, %{to: to, actor: actor, object: object} = params) do
%{"to" => to, "type" => type, "actor" => actor.ap_id, "object" => object}
|> Utils.maybe_put("id", activity_id),
{:ok, activity} <- insert(data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
{:ok, activity}
end
@ -351,6 +357,7 @@ def update(%{to: to, cc: cc, actor: actor, object: object} = params) do
},
data <- Utils.maybe_put(data, "id", activity_id),
{:ok, activity} <- insert(data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
{:ok, activity}
end
@ -372,6 +379,7 @@ defp do_react_with_emoji(user, object, emoji, options) do
reaction_data <- make_emoji_reaction_data(user, object, emoji, activity_id),
{:ok, activity} <- insert(reaction_data, local),
{:ok, object} <- add_emoji_reaction_to_object(activity, object),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
{:ok, activity, object}
else
@ -398,6 +406,7 @@ defp do_unreact_with_emoji(user, reaction_id, options) do
unreact_data <- make_undo_data(user, reaction_activity, activity_id),
{:ok, activity} <- insert(unreact_data, local),
{:ok, object} <- remove_emoji_reaction_from_object(reaction_activity, object),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
{:ok, activity, object}
else
@ -420,6 +429,7 @@ defp do_unlike(actor, object, activity_id, local) do
{:ok, unlike_activity} <- insert(unlike_data, local),
{:ok, _activity} <- Repo.delete(like_activity),
{:ok, object} <- remove_like_from_object(like_activity, object),
_ <- notify_and_stream(unlike_activity),
:ok <- maybe_federate(unlike_activity) do
{:ok, unlike_activity, like_activity, object}
else
@ -449,6 +459,7 @@ defp do_announce(user, object, activity_id, local, public) do
announce_data <- make_announce_data(user, object, activity_id, public),
{:ok, activity} <- insert(announce_data, local),
{:ok, object} <- add_announce_to_object(activity, object),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
{:ok, activity, object}
else
@ -475,6 +486,7 @@ defp do_unannounce(actor, object, activity_id, local) do
with %Activity{} = announce_activity <- get_existing_announce(actor.ap_id, object),
unannounce_data <- make_unannounce_data(actor, announce_activity, activity_id),
{:ok, unannounce_activity} <- insert(unannounce_data, local),
_ <- notify_and_stream(unannounce_activity),
:ok <- maybe_federate(unannounce_activity),
{:ok, _activity} <- Repo.delete(announce_activity),
{:ok, object} <- remove_announce_from_object(announce_activity, object) do
@ -497,6 +509,7 @@ def follow(follower, followed, activity_id \\ nil, local \\ true) do
defp do_follow(follower, followed, activity_id, local) do
with data <- make_follow_data(follower, followed, activity_id),
{:ok, activity} <- insert(data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
{:ok, activity}
else
@ -518,6 +531,7 @@ defp do_unfollow(follower, followed, activity_id, local) do
{:ok, follow_activity} <- update_follow_state(follow_activity, "cancelled"),
unfollow_data <- make_unfollow_data(follower, followed, follow_activity, activity_id),
{:ok, activity} <- insert(unfollow_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
{:ok, activity}
else
@ -547,6 +561,7 @@ defp do_block(blocker, blocked, activity_id, local) do
with true <- outgoing_blocks,
block_data <- make_block_data(blocker, blocked, activity_id),
{:ok, activity} <- insert(block_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
{:ok, activity}
else
@ -567,6 +582,7 @@ defp do_unblock(blocker, blocked, activity_id, local) do
with %Activity{} = block_activity <- fetch_latest_block(blocker, blocked),
unblock_data <- make_unblock_data(blocker, blocked, block_activity, activity_id),
{:ok, activity} <- insert(unblock_data, local),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(activity) do
{:ok, activity}
else
@ -601,6 +617,7 @@ def flag(
with flag_data <- make_flag_data(params, additional),
{:ok, activity} <- insert(flag_data, local),
{:ok, stripped_activity} <- strip_report_status_data(activity),
_ <- notify_and_stream(activity),
:ok <- maybe_federate(stripped_activity) do
User.all_superusers()
|> Enum.filter(fn user -> not is_nil(user.email) end)
@ -624,7 +641,8 @@ def move(%User{} = origin, %User{} = target, local \\ true) do
}
with true <- origin.ap_id in target.also_known_as,
{:ok, activity} <- insert(params, local) do
{:ok, activity} <- insert(params, local),
_ <- notify_and_stream(activity) do
maybe_federate(activity)
BackgroundWorker.enqueue("move_following", %{

View file

@ -10,6 +10,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
alias Pleroma.Activity
alias Pleroma.Config
alias Pleroma.ConfigDB
alias Pleroma.MFA
alias Pleroma.ModerationLog
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.ReportNote
@ -61,6 +62,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do
:right_add,
:right_add_multiple,
:right_delete,
:disable_mfa,
:right_delete_multiple,
:update_user_credentials
]
@ -674,6 +676,18 @@ def force_password_reset(%{assigns: %{user: admin}} = conn, %{"nicknames" => nic
json_response(conn, :no_content, "")
end
@doc "Disable mfa for user's account."
def disable_mfa(conn, %{"nickname" => nickname}) do
case User.get_by_nickname(nickname) do
%User{} = user ->
MFA.disable(user)
json(conn, nickname)
_ ->
{:error, :not_found}
end
end
@doc "Show a given user's credentials"
def show_user_credentials(%{assigns: %{user: admin}} = conn, %{"nickname" => nickname}) do
with %User{} = user <- User.get_cached_by_nickname_or_id(nickname) do

View file

@ -0,0 +1,76 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.PollOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
alias Pleroma.Web.ApiSpec.Schemas.Poll
import Pleroma.Web.ApiSpec.Helpers
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def show_operation do
%Operation{
tags: ["Polls"],
summary: "View a poll",
security: [%{"oAuth" => ["read:statuses"]}],
parameters: [id_param()],
operationId: "PollController.show",
responses: %{
200 => Operation.response("Poll", "application/json", Poll),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def vote_operation do
%Operation{
tags: ["Polls"],
summary: "Vote on a poll",
parameters: [id_param()],
operationId: "PollController.vote",
requestBody: vote_request(),
security: [%{"oAuth" => ["write:statuses"]}],
responses: %{
200 => Operation.response("Poll", "application/json", Poll),
422 => Operation.response("Error", "application/json", ApiError),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
defp id_param do
Operation.parameter(:id, :path, FlakeID, "Poll ID",
example: "123",
required: true
)
end
defp vote_request do
request_body(
"Parameters",
%Schema{
type: :object,
properties: %{
choices: %Schema{
type: :array,
items: %Schema{type: :integer},
description: "Array of own votes containing index for each option (starting from 0)"
}
},
required: [:choices]
},
required: true,
example: %{
"choices" => [0, 1, 2]
}
)
end
end

View file

@ -11,26 +11,72 @@ defmodule Pleroma.Web.ApiSpec.Schemas.Poll do
OpenApiSpex.schema(%{
title: "Poll",
description: "Response schema for account custom fields",
description: "Represents a poll attached to a status",
type: :object,
properties: %{
id: FlakeID,
expires_at: %Schema{type: :string, format: "date-time"},
expired: %Schema{type: :boolean},
multiple: %Schema{type: :boolean},
votes_count: %Schema{type: :integer},
voted: %Schema{type: :boolean},
emojis: %Schema{type: :array, items: Emoji},
expires_at: %Schema{
type: :string,
format: :"date-time",
nullable: true,
description: "When the poll ends"
},
expired: %Schema{type: :boolean, description: "Is the poll currently expired?"},
multiple: %Schema{
type: :boolean,
description: "Does the poll allow multiple-choice answers?"
},
votes_count: %Schema{
type: :integer,
nullable: true,
description: "How many votes have been received. Number, or null if `multiple` is false."
},
voted: %Schema{
type: :boolean,
nullable: true,
description:
"When called with a user token, has the authorized user voted? Boolean, or null if no current user."
},
emojis: %Schema{
type: :array,
items: Emoji,
description: "Custom emoji to be used for rendering poll options."
},
options: %Schema{
type: :array,
items: %Schema{
title: "PollOption",
type: :object,
properties: %{
title: %Schema{type: :string},
votes_count: %Schema{type: :integer}
}
}
},
description: "Possible answers for the poll."
}
},
example: %{
id: "34830",
expires_at: "2019-12-05T04:05:08.302Z",
expired: true,
multiple: false,
votes_count: 10,
voters_count: nil,
voted: true,
own_votes: [
1
],
options: [
%{
title: "accept",
votes_count: 6
},
%{
title: "deny",
votes_count: 4
}
],
emojis: []
}
})
end

View file

@ -19,8 +19,8 @@ def get_user(%Plug.Conn{} = conn) do
{_, true} <- {:checkpw, AuthenticationPlug.checkpw(password, user.password_hash)} do
{:ok, user}
else
error ->
{:error, error}
{:error, _reason} = error -> error
error -> {:error, error}
end
end

View file

@ -0,0 +1,45 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Auth.TOTPAuthenticator do
alias Comeonin.Pbkdf2
alias Pleroma.MFA
alias Pleroma.MFA.TOTP
alias Pleroma.User
@doc "Verify code or check backup code."
@spec verify(String.t(), User.t()) ::
{:ok, :pass} | {:error, :invalid_token | :invalid_secret_and_token}
def verify(
token,
%User{
multi_factor_authentication_settings:
%{enabled: true, totp: %{secret: secret, confirmed: true}} = _
} = _user
)
when is_binary(token) and byte_size(token) > 0 do
TOTP.validate_token(secret, token)
end
def verify(_, _), do: {:error, :invalid_token}
@spec verify_recovery_code(User.t(), String.t()) ::
{:ok, :pass} | {:error, :invalid_token}
def verify_recovery_code(
%User{multi_factor_authentication_settings: %{enabled: true, backup_codes: codes}} = user,
code
)
when is_list(codes) and is_binary(code) do
hash_code = Enum.find(codes, fn hash -> Pbkdf2.checkpw(code, hash) end)
if hash_code do
MFA.invalidate_backup_code(user, hash_code)
{:ok, :pass}
else
{:error, :invalid_token}
end
end
def verify_recovery_code(_, _), do: {:error, :invalid_token}
end

View file

@ -402,6 +402,7 @@ defp shortname(name) do
end
end
@spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
def confirm_current_password(user, password) do
with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do

View file

@ -15,6 +15,8 @@ defmodule Pleroma.Web.MastodonAPI.PollController do
action_fallback(Pleroma.Web.MastodonAPI.FallbackController)
plug(Pleroma.Web.ApiSpec.CastAndValidate)
plug(
OAuthScopesPlug,
%{scopes: ["read:statuses"], fallback: :proceed_unauthenticated} when action == :show
@ -22,8 +24,10 @@ defmodule Pleroma.Web.MastodonAPI.PollController do
plug(OAuthScopesPlug, %{scopes: ["write:statuses"]} when action == :vote)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.PollOperation
@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),
%Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
true <- Visibility.visible_for_user?(activity, user) do
@ -35,7 +39,7 @@ def show(%{assigns: %{user: user}} = conn, %{"id" => id}) do
end
@doc "POST /api/v1/polls/:id/votes"
def vote(%{assigns: %{user: user}} = conn, %{"id" => id, "choices" => choices}) do
def vote(%{assigns: %{user: user}, body_params: %{choices: choices}} = conn, %{id: id}) do
with %Object{data: %{"type" => "Question"}} = object <- Object.get_by_id(id),
%Activity{} = activity <- Activity.get_create_by_object_ap_id(object.data["id"]),
true <- Visibility.visible_for_user?(activity, user),

View file

@ -12,6 +12,11 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
@behaviour :cowboy_websocket
# Cowboy timeout period.
@timeout :timer.seconds(30)
# Hibernate every X messages
@hibernate_every 100
@streams [
"public",
"public:local",
@ -25,9 +30,6 @@ defmodule Pleroma.Web.MastodonAPI.WebsocketHandler do
]
@anonymous_streams ["public", "public:local", "hashtag"]
# Handled by periodic keepalive in Pleroma.Web.Streamer.Ping.
@timeout :infinity
def init(%{qs: qs} = req, state) do
with params <- :cow_qs.parse_qs(qs),
sec_websocket <- :cowboy_req.header("sec-websocket-protocol", req, nil),
@ -42,7 +44,7 @@ def init(%{qs: qs} = req, state) do
req
end
{:cowboy_websocket, req, %{user: user, topic: topic}, %{idle_timeout: @timeout}}
{:cowboy_websocket, req, %{user: user, topic: topic, count: 0}, %{idle_timeout: @timeout}}
else
{:error, code} ->
Logger.debug("#{__MODULE__} denied connection: #{inspect(code)} - #{inspect(req)}")
@ -57,7 +59,13 @@ def init(%{qs: qs} = req, state) do
end
def websocket_init(state) do
send(self(), :subscribe)
Logger.debug(
"#{__MODULE__} accepted websocket connection for user #{
(state.user || %{id: "anonymous"}).id
}, topic #{state.topic}"
)
Streamer.add_socket(state.topic, state.user)
{:ok, state}
end
@ -66,19 +74,24 @@ def websocket_handle(_frame, state) do
{:ok, state}
end
def websocket_info(:subscribe, state) do
Logger.debug(
"#{__MODULE__} accepted websocket connection for user #{
(state.user || %{id: "anonymous"}).id
}, topic #{state.topic}"
)
def websocket_info({:render_with_user, view, template, item}, state) do
user = %User{} = User.get_cached_by_ap_id(state.user.ap_id)
Streamer.add_socket(state.topic, streamer_socket(state))
{:ok, state}
unless Streamer.filtered_by_user?(user, item) do
websocket_info({:text, view.render(template, user, item)}, %{state | user: user})
else
{:ok, state}
end
end
def websocket_info({:text, message}, state) do
{:reply, {:text, message}, state}
# If the websocket processed X messages, force an hibernate/GC.
# We don't hibernate at every message to balance CPU usage/latency with RAM usage.
if state.count > @hibernate_every do
{:reply, {:text, message}, %{state | count: 0}, :hibernate}
else
{:reply, {:text, message}, %{state | count: state.count + 1}}
end
end
def terminate(reason, _req, state) do
@ -88,7 +101,7 @@ def terminate(reason, _req, state) do
}, topic #{state.topic || "?"}: #{inspect(reason)}"
)
Streamer.remove_socket(state.topic, streamer_socket(state))
Streamer.remove_socket(state.topic)
:ok
end
@ -136,8 +149,4 @@ defp expand_topic("list", params) do
end
defp expand_topic(topic, _), do: topic
defp streamer_socket(state) do
%{transport_pid: self(), assigns: state}
end
end

View file

@ -0,0 +1,97 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.OAuth.MFAController do
@moduledoc """
The model represents api to use Multi Factor authentications.
"""
use Pleroma.Web, :controller
alias Pleroma.MFA
alias Pleroma.Web.Auth.TOTPAuthenticator
alias Pleroma.Web.OAuth.MFAView, as: View
alias Pleroma.Web.OAuth.OAuthController
alias Pleroma.Web.OAuth.Token
plug(:fetch_session when action in [:show, :verify])
plug(:fetch_flash when action in [:show, :verify])
@doc """
Display form to input mfa code or recovery code.
"""
def show(conn, %{"mfa_token" => mfa_token} = params) do
template = Map.get(params, "challenge_type", "totp")
conn
|> put_view(View)
|> render("#{template}.html", %{
mfa_token: mfa_token,
redirect_uri: params["redirect_uri"],
state: params["state"]
})
end
@doc """
Verification code and continue authorization.
"""
def verify(conn, %{"mfa" => %{"mfa_token" => mfa_token} = mfa_params} = _) do
with {:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token),
{:ok, _} <- validates_challenge(user, mfa_params) do
conn
|> OAuthController.after_create_authorization(auth, %{
"authorization" => %{
"redirect_uri" => mfa_params["redirect_uri"],
"state" => mfa_params["state"]
}
})
else
_ ->
conn
|> put_flash(:error, "Two-factor authentication failed.")
|> put_status(:unauthorized)
|> show(mfa_params)
end
end
@doc """
Verification second step of MFA (or recovery) and returns access token.
## Endpoint
POST /oauth/mfa/challenge
params:
`client_id`
`client_secret`
`mfa_token` - access token to check second step of mfa
`challenge_type` - 'totp' or 'recovery'
`code`
"""
def challenge(conn, %{"mfa_token" => mfa_token} = params) do
with {:ok, app} <- Token.Utils.fetch_app(conn),
{:ok, %{user: user, authorization: auth}} <- MFA.Token.validate(mfa_token),
{:ok, _} <- validates_challenge(user, params),
{:ok, token} <- Token.exchange_token(app, auth) do
json(conn, Token.Response.build(user, token))
else
_error ->
conn
|> put_status(400)
|> json(%{error: "Invalid code"})
end
end
# Verify TOTP Code
defp validates_challenge(user, %{"challenge_type" => "totp", "code" => code} = _) do
TOTPAuthenticator.verify(code, user)
end
# Verify Recovery Code
defp validates_challenge(user, %{"challenge_type" => "recovery", "code" => code} = _) do
TOTPAuthenticator.verify_recovery_code(user, code)
end
defp validates_challenge(_, _), do: {:error, :unsupported_challenge_type}
end

View file

@ -0,0 +1,8 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.OAuth.MFAView do
use Pleroma.Web, :view
import Phoenix.HTML.Form
end

View file

@ -6,6 +6,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
use Pleroma.Web, :controller
alias Pleroma.Helpers.UriHelper
alias Pleroma.MFA
alias Pleroma.Plugs.RateLimiter
alias Pleroma.Registration
alias Pleroma.Repo
@ -14,6 +15,7 @@ defmodule Pleroma.Web.OAuth.OAuthController do
alias Pleroma.Web.ControllerHelper
alias Pleroma.Web.OAuth.App
alias Pleroma.Web.OAuth.Authorization
alias Pleroma.Web.OAuth.MFAController
alias Pleroma.Web.OAuth.Scopes
alias Pleroma.Web.OAuth.Token
alias Pleroma.Web.OAuth.Token.Strategy.RefreshToken
@ -121,7 +123,8 @@ def create_authorization(
%{"authorization" => _} = params,
opts \\ []
) do
with {:ok, auth} <- do_create_authorization(conn, params, opts[:user]) do
with {:ok, auth, user} <- do_create_authorization(conn, params, opts[:user]),
{:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)} do
after_create_authorization(conn, auth, params)
else
error ->
@ -179,6 +182,22 @@ defp handle_create_authorization_error(
|> authorize(params)
end
defp handle_create_authorization_error(
%Plug.Conn{} = conn,
{:mfa_required, user, auth, _},
params
) do
{:ok, token} = MFA.Token.create_token(user, auth)
data = %{
"mfa_token" => token.token,
"redirect_uri" => params["authorization"]["redirect_uri"],
"state" => params["authorization"]["state"]
}
MFAController.show(conn, data)
end
defp handle_create_authorization_error(
%Plug.Conn{} = conn,
{:account_status, :password_reset_pending},
@ -231,7 +250,8 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "authorization_code"}
json(conn, Token.Response.build(user, token, response_attrs))
else
_error -> render_invalid_credentials_error(conn)
error ->
handle_token_exchange_error(conn, error)
end
end
@ -244,6 +264,7 @@ def token_exchange(
{:account_status, :active} <- {:account_status, User.account_status(user)},
{:ok, scopes} <- validate_scopes(app, params),
{:ok, auth} <- Authorization.create_authorization(app, user, scopes),
{:mfa_required, _, _, false} <- {:mfa_required, user, auth, MFA.require?(user)},
{:ok, token} <- Token.exchange_token(app, auth) do
json(conn, Token.Response.build(user, token))
else
@ -270,13 +291,20 @@ def token_exchange(%Plug.Conn{} = conn, %{"grant_type" => "client_credentials"}
{:ok, token} <- Token.exchange_token(app, auth) do
json(conn, Token.Response.build_for_client_credentials(token))
else
_error -> render_invalid_credentials_error(conn)
_error ->
handle_token_exchange_error(conn, :invalid_credentails)
end
end
# Bad request
def token_exchange(%Plug.Conn{} = conn, params), do: bad_request(conn, params)
defp handle_token_exchange_error(%Plug.Conn{} = conn, {:mfa_required, user, auth, _}) do
conn
|> put_status(:forbidden)
|> json(build_and_response_mfa_token(user, auth))
end
defp handle_token_exchange_error(%Plug.Conn{} = conn, {:account_status, :deactivated}) do
render_error(
conn,
@ -434,7 +462,8 @@ def registration_details(%Plug.Conn{} = conn, %{"authorization" => auth_attrs})
def register(%Plug.Conn{} = conn, %{"authorization" => _, "op" => "connect"} = params) do
with registration_id when not is_nil(registration_id) <- get_session_registration_id(conn),
%Registration{} = registration <- Repo.get(Registration, registration_id),
{_, {:ok, auth}} <- {:create_authorization, do_create_authorization(conn, params)},
{_, {:ok, auth, _user}} <-
{:create_authorization, do_create_authorization(conn, params)},
%User{} = user <- Repo.preload(auth, :user).user,
{:ok, _updated_registration} <- Registration.bind_to_user(registration, user) do
conn
@ -500,8 +529,9 @@ defp do_create_authorization(
%App{} = app <- Repo.get_by(App, client_id: client_id),
true <- redirect_uri in String.split(app.redirect_uris),
{:ok, scopes} <- validate_scopes(app, auth_attrs),
{:account_status, :active} <- {:account_status, User.account_status(user)} do
Authorization.create_authorization(app, user, scopes)
{:account_status, :active} <- {:account_status, User.account_status(user)},
{:ok, auth} <- Authorization.create_authorization(app, user, scopes) do
{:ok, auth, user}
end
end
@ -515,6 +545,12 @@ defp get_session_registration_id(%Plug.Conn{} = conn), do: get_session(conn, :re
defp put_session_registration_id(%Plug.Conn{} = conn, registration_id),
do: put_session(conn, :registration_id, registration_id)
defp build_and_response_mfa_token(user, auth) do
with {:ok, token} <- MFA.Token.create_token(user, auth) do
Token.Response.build_for_mfa_token(user, token)
end
end
@spec validate_scopes(App.t(), map()) ::
{:ok, list()} | {:error, :missing_scopes | :unsupported_scopes}
defp validate_scopes(%App{} = app, params) do

View file

@ -0,0 +1,38 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.OAuth.Token.CleanWorker do
@moduledoc """
The module represents functions to clean an expired OAuth and MFA tokens.
"""
use GenServer
@ten_seconds 10_000
@one_day 86_400_000
alias Pleroma.MFA
alias Pleroma.Web.OAuth
alias Pleroma.Workers.BackgroundWorker
def start_link(_), do: GenServer.start_link(__MODULE__, %{})
def init(_) do
Process.send_after(self(), :perform, @ten_seconds)
{:ok, nil}
end
@doc false
def handle_info(:perform, state) do
BackgroundWorker.enqueue("clean_expired_tokens", %{})
interval = Pleroma.Config.get([:oauth2, :clean_expired_tokens_interval], @one_day)
Process.send_after(self(), :perform, interval)
{:noreply, state}
end
def perform(:clean) do
OAuth.Token.delete_expired_tokens()
MFA.Token.delete_expired_tokens()
end
end

View file

@ -5,6 +5,7 @@
defmodule Pleroma.Web.OAuth.Token.Response do
@moduledoc false
alias Pleroma.MFA
alias Pleroma.User
alias Pleroma.Web.OAuth.Token.Utils
@ -32,5 +33,13 @@ def build_for_client_credentials(token) do
}
end
def build_for_mfa_token(user, mfa_token) do
%{
error: "mfa_required",
mfa_token: mfa_token.token,
supported_challenge_types: MFA.supported_methods(user)
}
end
defp expires_in, do: Pleroma.Config.get([:oauth2, :token_expires_in], 600)
end

View file

@ -0,0 +1,133 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.TwoFactorAuthenticationController do
@moduledoc "The module represents actions to manage MFA"
use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper, only: [json_response: 3]
alias Pleroma.MFA
alias Pleroma.MFA.TOTP
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.Web.CommonAPI.Utils
plug(OAuthScopesPlug, %{scopes: ["read:security"]} when action in [:settings])
plug(
OAuthScopesPlug,
%{scopes: ["write:security"]} when action in [:setup, :confirm, :disable, :backup_codes]
)
@doc """
Gets user multi factor authentication settings
## Endpoint
GET /api/pleroma/accounts/mfa
"""
def settings(%{assigns: %{user: user}} = conn, _params) do
json(conn, %{settings: MFA.mfa_settings(user)})
end
@doc """
Prepare setup mfa method
## Endpoint
GET /api/pleroma/accounts/mfa/setup/[:method]
"""
def setup(%{assigns: %{user: user}} = conn, %{"method" => "totp"} = _params) do
with {:ok, user} <- MFA.setup_totp(user),
%{secret: secret} = _ <- user.multi_factor_authentication_settings.totp do
provisioning_uri = TOTP.provisioning_uri(secret, "#{user.email}")
json(conn, %{provisioning_uri: provisioning_uri, key: secret})
else
{:error, message} ->
json_response(conn, :unprocessable_entity, %{error: message})
end
end
def setup(conn, _params) do
json_response(conn, :bad_request, %{error: "undefined method"})
end
@doc """
Confirms setup and enable mfa method
## Endpoint
POST /api/pleroma/accounts/mfa/confirm/:method
- params:
`code` - confirmation code
`password` - current password
"""
def confirm(
%{assigns: %{user: user}} = conn,
%{"method" => "totp", "password" => _, "code" => _} = params
) do
with {:ok, _user} <- Utils.confirm_current_password(user, params["password"]),
{:ok, _user} <- MFA.confirm_totp(user, params) do
json(conn, %{})
else
{:error, message} ->
json_response(conn, :unprocessable_entity, %{error: message})
end
end
def confirm(conn, _) do
json_response(conn, :bad_request, %{error: "undefined mfa method"})
end
@doc """
Disable mfa method and disable mfa if need.
"""
def disable(%{assigns: %{user: user}} = conn, %{"method" => "totp"} = params) do
with {:ok, user} <- Utils.confirm_current_password(user, params["password"]),
{:ok, _user} <- MFA.disable_totp(user) do
json(conn, %{})
else
{:error, message} ->
json_response(conn, :unprocessable_entity, %{error: message})
end
end
def disable(%{assigns: %{user: user}} = conn, %{"method" => "mfa"} = params) do
with {:ok, user} <- Utils.confirm_current_password(user, params["password"]),
{:ok, _user} <- MFA.disable(user) do
json(conn, %{})
else
{:error, message} ->
json_response(conn, :unprocessable_entity, %{error: message})
end
end
def disable(conn, _) do
json_response(conn, :bad_request, %{error: "undefined mfa method"})
end
@doc """
Generates backup codes.
## Endpoint
GET /api/pleroma/accounts/mfa/backup_codes
## Response
### Success
`{codes: [codes]}`
### Error
`{error: [error_message]}`
"""
def backup_codes(%{assigns: %{user: user}} = conn, _params) do
with {:ok, codes} <- MFA.generate_backup_codes(user) do
json(conn, %{codes: codes})
else
{:error, message} ->
json_response(conn, :unprocessable_entity, %{error: message})
end
end
end

View file

@ -132,6 +132,7 @@ defmodule Pleroma.Web.Router do
post("/users/follow", AdminAPIController, :user_follow)
post("/users/unfollow", AdminAPIController, :user_unfollow)
put("/users/disable_mfa", AdminAPIController, :disable_mfa)
delete("/users", AdminAPIController, :user_delete)
post("/users", AdminAPIController, :users_create)
patch("/users/:nickname/toggle_activation", AdminAPIController, :user_toggle_activation)
@ -258,6 +259,16 @@ defmodule Pleroma.Web.Router do
post("/follow_import", UtilController, :follow_import)
end
scope "/api/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:authenticated_api)
get("/accounts/mfa", TwoFactorAuthenticationController, :settings)
get("/accounts/mfa/backup_codes", TwoFactorAuthenticationController, :backup_codes)
get("/accounts/mfa/setup/:method", TwoFactorAuthenticationController, :setup)
post("/accounts/mfa/confirm/:method", TwoFactorAuthenticationController, :confirm)
delete("/accounts/mfa/:method", TwoFactorAuthenticationController, :disable)
end
scope "/oauth", Pleroma.Web.OAuth do
scope [] do
pipe_through(:oauth)
@ -269,6 +280,10 @@ defmodule Pleroma.Web.Router do
post("/revoke", OAuthController, :token_revoke)
get("/registration_details", OAuthController, :registration_details)
post("/mfa/challenge", MFAController, :challenge)
post("/mfa/verify", MFAController, :verify, as: :mfa_verify)
get("/mfa", MFAController, :show)
scope [] do
pipe_through(:browser)

View file

@ -1,37 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Streamer.Ping do
use GenServer
require Logger
alias Pleroma.Web.Streamer.State
alias Pleroma.Web.Streamer.StreamerSocket
@keepalive_interval :timer.seconds(30)
def start_link(opts) do
ping_interval = Keyword.get(opts, :ping_interval, @keepalive_interval)
GenServer.start_link(__MODULE__, %{ping_interval: ping_interval}, name: __MODULE__)
end
def init(%{ping_interval: ping_interval} = args) do
Process.send_after(self(), :ping, ping_interval)
{:ok, args}
end
def handle_info(:ping, %{ping_interval: ping_interval} = state) do
State.get_sockets()
|> Map.values()
|> List.flatten()
|> Enum.each(fn %StreamerSocket{transport_pid: transport_pid} ->
Logger.debug("Sending keepalive ping")
send(transport_pid, {:text, ""})
end)
Process.send_after(self(), :ping, ping_interval)
{:noreply, state}
end
end

View file

@ -1,82 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Streamer.State do
use GenServer
require Logger
alias Pleroma.Web.Streamer.StreamerSocket
@env Mix.env()
def start_link(_) do
GenServer.start_link(__MODULE__, %{sockets: %{}}, name: __MODULE__)
end
def add_socket(topic, socket) do
GenServer.call(__MODULE__, {:add, topic, socket})
end
def remove_socket(topic, socket) do
do_remove_socket(@env, topic, socket)
end
def get_sockets do
%{sockets: stream_sockets} = GenServer.call(__MODULE__, :get_state)
stream_sockets
end
def init(init_arg) do
{:ok, init_arg}
end
def handle_call(:get_state, _from, state) do
{:reply, state, state}
end
def handle_call({:add, topic, socket}, _from, %{sockets: sockets} = state) do
internal_topic = internal_topic(topic, socket)
stream_socket = StreamerSocket.from_socket(socket)
sockets_for_topic =
sockets
|> Map.get(internal_topic, [])
|> List.insert_at(0, stream_socket)
|> Enum.uniq()
state = put_in(state, [:sockets, internal_topic], sockets_for_topic)
Logger.debug("Got new conn for #{topic}")
{:reply, state, state}
end
def handle_call({:remove, topic, socket}, _from, %{sockets: sockets} = state) do
internal_topic = internal_topic(topic, socket)
stream_socket = StreamerSocket.from_socket(socket)
sockets_for_topic =
sockets
|> Map.get(internal_topic, [])
|> List.delete(stream_socket)
state = Kernel.put_in(state, [:sockets, internal_topic], sockets_for_topic)
{:reply, state, state}
end
defp do_remove_socket(:test, _, _) do
:ok
end
defp do_remove_socket(_env, topic, socket) do
GenServer.call(__MODULE__, {:remove, topic, socket})
end
defp internal_topic(topic, socket)
when topic in ~w[user user:notification direct] do
"#{topic}:#{socket.assigns[:user].id}"
end
defp internal_topic(topic, _) do
topic
end
end

View file

@ -3,53 +3,241 @@
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Streamer do
alias Pleroma.Web.Streamer.State
alias Pleroma.Web.Streamer.Worker
require Logger
alias Pleroma.Activity
alias Pleroma.Config
alias Pleroma.Conversation.Participation
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.StreamerView
@timeout 60_000
@mix_env Mix.env()
@registry Pleroma.Web.StreamerRegistry
def add_socket(topic, socket) do
State.add_socket(topic, socket)
def registry, do: @registry
def add_socket(topic, %User{} = user) do
if should_env_send?(), do: Registry.register(@registry, user_topic(topic, user), true)
end
def remove_socket(topic, socket) do
State.remove_socket(topic, socket)
def add_socket(topic, _) do
if should_env_send?(), do: Registry.register(@registry, topic, false)
end
def get_sockets do
State.get_sockets()
def remove_socket(topic) do
if should_env_send?(), do: Registry.unregister(@registry, topic)
end
def stream(topics, items) do
if should_send?() do
Task.async(fn ->
:poolboy.transaction(
:streamer_worker,
&Worker.stream(&1, topics, items),
@timeout
)
def stream(topics, item) when is_list(topics) do
if should_env_send?() do
Enum.each(topics, fn t ->
spawn(fn -> do_stream(t, item) end)
end)
end
:ok
end
def supervisor, do: Pleroma.Web.Streamer.Supervisor
def stream(topic, items) when is_list(items) do
if should_env_send?() do
Enum.each(items, fn i ->
spawn(fn -> do_stream(topic, i) end)
end)
defp should_send? do
handle_should_send(@mix_env)
end
defp handle_should_send(:test) do
case Process.whereis(:streamer_worker) do
nil ->
false
pid ->
Process.alive?(pid)
:ok
end
end
defp handle_should_send(:benchmark), do: false
def stream(topic, item) do
if should_env_send?() do
spawn(fn -> do_stream(topic, item) end)
end
defp handle_should_send(_), do: true
:ok
end
def filtered_by_user?(%User{} = user, %Activity{} = item) do
%{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} =
User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute])
recipient_blocks = MapSet.new(blocked_ap_ids ++ muted_ap_ids)
recipients = MapSet.new(item.recipients)
domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks)
with parent <- Object.normalize(item) || item,
true <-
Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)),
true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids,
true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)),
true <- MapSet.disjoint?(recipients, recipient_blocks),
%{host: item_host} <- URI.parse(item.actor),
%{host: parent_host} <- URI.parse(parent.data["actor"]),
false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host),
false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host),
true <- thread_containment(item, user),
false <- CommonAPI.thread_muted?(user, item) do
false
else
_ -> true
end
end
def filtered_by_user?(%User{} = user, %Notification{activity: activity}) do
filtered_by_user?(user, activity)
end
defp do_stream("direct", item) do
recipient_topics =
User.get_recipients_from_activity(item)
|> Enum.map(fn %{id: id} -> "direct:#{id}" end)
Enum.each(recipient_topics, fn user_topic ->
Logger.debug("Trying to push direct message to #{user_topic}\n\n")
push_to_socket(user_topic, item)
end)
end
defp do_stream("participation", participation) do
user_topic = "direct:#{participation.user_id}"
Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n")
push_to_socket(user_topic, participation)
end
defp do_stream("list", item) do
# filter the recipient list if the activity is not public, see #270.
recipient_lists =
case Visibility.is_public?(item) do
true ->
Pleroma.List.get_lists_from_activity(item)
_ ->
Pleroma.List.get_lists_from_activity(item)
|> Enum.filter(fn list ->
owner = User.get_cached_by_id(list.user_id)
Visibility.visible_for_user?(item, owner)
end)
end
recipient_topics =
recipient_lists
|> Enum.map(fn %{id: id} -> "list:#{id}" end)
Enum.each(recipient_topics, fn list_topic ->
Logger.debug("Trying to push message to #{list_topic}\n\n")
push_to_socket(list_topic, item)
end)
end
defp do_stream(topic, %Notification{} = item)
when topic in ["user", "user:notification"] do
Registry.dispatch(@registry, "#{topic}:#{item.user_id}", fn list ->
Enum.each(list, fn {pid, _auth} ->
send(pid, {:render_with_user, StreamerView, "notification.json", item})
end)
end)
end
defp do_stream("user", item) do
Logger.debug("Trying to push to users")
recipient_topics =
User.get_recipients_from_activity(item)
|> Enum.map(fn %{id: id} -> "user:#{id}" end)
Enum.each(recipient_topics, fn topic ->
push_to_socket(topic, item)
end)
end
defp do_stream(topic, item) do
Logger.debug("Trying to push to #{topic}")
Logger.debug("Pushing item to #{topic}")
push_to_socket(topic, item)
end
defp push_to_socket(topic, %Participation{} = participation) do
rendered = StreamerView.render("conversation.json", participation)
Registry.dispatch(@registry, topic, fn list ->
Enum.each(list, fn {pid, _} ->
send(pid, {:text, rendered})
end)
end)
end
defp push_to_socket(topic, %Activity{
data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id}
}) do
rendered = Jason.encode!(%{event: "delete", payload: to_string(deleted_activity_id)})
Registry.dispatch(@registry, topic, fn list ->
Enum.each(list, fn {pid, _} ->
send(pid, {:text, rendered})
end)
end)
end
defp push_to_socket(_topic, %Activity{data: %{"type" => "Delete"}}), do: :noop
defp push_to_socket(topic, item) do
anon_render = StreamerView.render("update.json", item)
Registry.dispatch(@registry, topic, fn list ->
Enum.each(list, fn {pid, auth?} ->
if auth? do
send(pid, {:render_with_user, StreamerView, "update.json", item})
else
send(pid, {:text, anon_render})
end
end)
end)
end
defp thread_containment(_activity, %User{skip_thread_containment: true}), do: true
defp thread_containment(activity, user) do
if Config.get([:instance, :skip_thread_containment]) do
true
else
ActivityPub.contain_activity(activity, user)
end
end
# In test environement, only return true if the registry is started.
# In benchmark environment, returns false.
# In any other environment, always returns true.
cond do
@mix_env == :test ->
def should_env_send? do
case Process.whereis(@registry) do
nil ->
false
pid ->
Process.alive?(pid)
end
end
@mix_env == :benchmark ->
def should_env_send?, do: false
true ->
def should_env_send?, do: true
end
defp user_topic(topic, user)
when topic in ~w[user user:notification direct] do
"#{topic}:#{user.id}"
end
defp user_topic(topic, _) do
topic
end
end

View file

@ -1,35 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Streamer.StreamerSocket do
defstruct transport_pid: nil, user: nil
alias Pleroma.User
alias Pleroma.Web.Streamer.StreamerSocket
def from_socket(%{
transport_pid: transport_pid,
assigns: %{user: nil}
}) do
%StreamerSocket{
transport_pid: transport_pid
}
end
def from_socket(%{
transport_pid: transport_pid,
assigns: %{user: %User{} = user}
}) do
%StreamerSocket{
transport_pid: transport_pid,
user: user
}
end
def from_socket(%{transport_pid: transport_pid}) do
%StreamerSocket{
transport_pid: transport_pid
}
end
end

View file

@ -1,37 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Streamer.Supervisor do
use Supervisor
def start_link(opts) do
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
end
def init(args) do
children = [
{Pleroma.Web.Streamer.State, args},
{Pleroma.Web.Streamer.Ping, args},
:poolboy.child_spec(:streamer_worker, poolboy_config())
]
opts = [strategy: :one_for_one, name: Pleroma.Web.Streamer.Supervisor]
Supervisor.init(children, opts)
end
defp poolboy_config do
opts =
Pleroma.Config.get(:streamer,
workers: 3,
overflow_workers: 2
)
[
{:name, {:local, :streamer_worker}},
{:worker_module, Pleroma.Web.Streamer.Worker},
{:size, opts[:workers]},
{:max_overflow, opts[:overflow_workers]}
]
end
end

View file

@ -1,208 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Streamer.Worker do
use GenServer
require Logger
alias Pleroma.Activity
alias Pleroma.Config
alias Pleroma.Conversation.Participation
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Streamer.State
alias Pleroma.Web.Streamer.StreamerSocket
alias Pleroma.Web.StreamerView
def start_link(_) do
GenServer.start_link(__MODULE__, %{}, [])
end
def init(init_arg) do
{:ok, init_arg}
end
def stream(pid, topics, items) do
GenServer.call(pid, {:stream, topics, items})
end
def handle_call({:stream, topics, item}, _from, state) when is_list(topics) do
Enum.each(topics, fn t ->
do_stream(%{topic: t, item: item})
end)
{:reply, state, state}
end
def handle_call({:stream, topic, items}, _from, state) when is_list(items) do
Enum.each(items, fn i ->
do_stream(%{topic: topic, item: i})
end)
{:reply, state, state}
end
def handle_call({:stream, topic, item}, _from, state) do
do_stream(%{topic: topic, item: item})
{:reply, state, state}
end
defp do_stream(%{topic: "direct", item: item}) do
recipient_topics =
User.get_recipients_from_activity(item)
|> Enum.map(fn %{id: id} -> "direct:#{id}" end)
Enum.each(recipient_topics, fn user_topic ->
Logger.debug("Trying to push direct message to #{user_topic}\n\n")
push_to_socket(State.get_sockets(), user_topic, item)
end)
end
defp do_stream(%{topic: "participation", item: participation}) do
user_topic = "direct:#{participation.user_id}"
Logger.debug("Trying to push a conversation participation to #{user_topic}\n\n")
push_to_socket(State.get_sockets(), user_topic, participation)
end
defp do_stream(%{topic: "list", item: item}) do
# filter the recipient list if the activity is not public, see #270.
recipient_lists =
case Visibility.is_public?(item) do
true ->
Pleroma.List.get_lists_from_activity(item)
_ ->
Pleroma.List.get_lists_from_activity(item)
|> Enum.filter(fn list ->
owner = User.get_cached_by_id(list.user_id)
Visibility.visible_for_user?(item, owner)
end)
end
recipient_topics =
recipient_lists
|> Enum.map(fn %{id: id} -> "list:#{id}" end)
Enum.each(recipient_topics, fn list_topic ->
Logger.debug("Trying to push message to #{list_topic}\n\n")
push_to_socket(State.get_sockets(), list_topic, item)
end)
end
defp do_stream(%{topic: topic, item: %Notification{} = item})
when topic in ["user", "user:notification"] do
State.get_sockets()
|> Map.get("#{topic}:#{item.user_id}", [])
|> Enum.each(fn %StreamerSocket{transport_pid: transport_pid, user: socket_user} ->
with %User{} = user <- User.get_cached_by_ap_id(socket_user.ap_id),
true <- should_send?(user, item) do
send(transport_pid, {:text, StreamerView.render("notification.json", socket_user, item)})
end
end)
end
defp do_stream(%{topic: "user", item: item}) do
Logger.debug("Trying to push to users")
recipient_topics =
User.get_recipients_from_activity(item)
|> Enum.map(fn %{id: id} -> "user:#{id}" end)
Enum.each(recipient_topics, fn topic ->
push_to_socket(State.get_sockets(), topic, item)
end)
end
defp do_stream(%{topic: topic, item: item}) do
Logger.debug("Trying to push to #{topic}")
Logger.debug("Pushing item to #{topic}")
push_to_socket(State.get_sockets(), topic, item)
end
defp should_send?(%User{} = user, %Activity{} = item) do
%{block: blocked_ap_ids, mute: muted_ap_ids, reblog_mute: reblog_muted_ap_ids} =
User.outgoing_relationships_ap_ids(user, [:block, :mute, :reblog_mute])
recipient_blocks = MapSet.new(blocked_ap_ids ++ muted_ap_ids)
recipients = MapSet.new(item.recipients)
domain_blocks = Pleroma.Web.ActivityPub.MRF.subdomains_regex(user.domain_blocks)
with parent <- Object.normalize(item) || item,
true <-
Enum.all?([blocked_ap_ids, muted_ap_ids], &(item.actor not in &1)),
true <- item.data["type"] != "Announce" || item.actor not in reblog_muted_ap_ids,
true <- Enum.all?([blocked_ap_ids, muted_ap_ids], &(parent.data["actor"] not in &1)),
true <- MapSet.disjoint?(recipients, recipient_blocks),
%{host: item_host} <- URI.parse(item.actor),
%{host: parent_host} <- URI.parse(parent.data["actor"]),
false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, item_host),
false <- Pleroma.Web.ActivityPub.MRF.subdomain_match?(domain_blocks, parent_host),
true <- thread_containment(item, user),
false <- CommonAPI.thread_muted?(user, item) do
true
else
_ -> false
end
end
defp should_send?(%User{} = user, %Notification{activity: activity}) do
should_send?(user, activity)
end
def push_to_socket(topics, topic, %Participation{} = participation) do
Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} ->
send(transport_pid, {:text, StreamerView.render("conversation.json", participation)})
end)
end
def push_to_socket(topics, topic, %Activity{
data: %{"type" => "Delete", "deleted_activity_id" => deleted_activity_id}
}) do
Enum.each(topics[topic] || [], fn %StreamerSocket{transport_pid: transport_pid} ->
send(
transport_pid,
{:text, %{event: "delete", payload: to_string(deleted_activity_id)} |> Jason.encode!()}
)
end)
end
def push_to_socket(_topics, _topic, %Activity{data: %{"type" => "Delete"}}), do: :noop
def push_to_socket(topics, topic, item) do
Enum.each(topics[topic] || [], fn %StreamerSocket{
transport_pid: transport_pid,
user: socket_user
} ->
# Get the current user so we have up-to-date blocks etc.
if socket_user do
user = User.get_cached_by_ap_id(socket_user.ap_id)
if should_send?(user, item) do
send(transport_pid, {:text, StreamerView.render("update.json", item, user)})
end
else
send(transport_pid, {:text, StreamerView.render("update.json", item)})
end
end)
end
@spec thread_containment(Activity.t(), User.t()) :: boolean()
defp thread_containment(_activity, %User{skip_thread_containment: true}), do: true
defp thread_containment(activity, user) do
if Config.get([:instance, :skip_thread_containment]) do
true
else
ActivityPub.contain_activity(activity, user)
end
end
end

View file

@ -0,0 +1,24 @@
<%= if get_flash(@conn, :info) do %>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<% end %>
<%= if get_flash(@conn, :error) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<% end %>
<h2>Two-factor recovery</h2>
<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
<div class="input">
<%= label f, :code, "Recovery code" %>
<%= text_input f, :code %>
<%= hidden_input f, :mfa_token, value: @mfa_token %>
<%= hidden_input f, :state, value: @state %>
<%= hidden_input f, :redirect_uri, value: @redirect_uri %>
<%= hidden_input f, :challenge_type, value: "recovery" %>
</div>
<%= submit "Verify" %>
<% end %>
<a href="<%= mfa_path(@conn, :show, %{challenge_type: "totp", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
Enter a two-factor code
</a>

View file

@ -0,0 +1,24 @@
<%= if get_flash(@conn, :info) do %>
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<% end %>
<%= if get_flash(@conn, :error) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<% end %>
<h2>Two-factor authentication</h2>
<%= form_for @conn, mfa_verify_path(@conn, :verify), [as: "mfa"], fn f -> %>
<div class="input">
<%= label f, :code, "Authentication code" %>
<%= text_input f, :code %>
<%= hidden_input f, :mfa_token, value: @mfa_token %>
<%= hidden_input f, :state, value: @state %>
<%= hidden_input f, :redirect_uri, value: @redirect_uri %>
<%= hidden_input f, :challenge_type, value: "totp" %>
</div>
<%= submit "Verify" %>
<% end %>
<a href="<%= mfa_path(@conn, :show, %{challenge_type: "recovery", mfa_token: @mfa_token, state: @state, redirect_uri: @redirect_uri}) %>">
Enter a two-factor recovery code
</a>

View file

@ -0,0 +1,13 @@
<%= if @error do %>
<h2><%= @error %></h2>
<% end %>
<h2>Two-factor authentication</h2>
<p><%= @followee.nickname %></p>
<img height="128" width="128" src="<%= avatar_url(@followee) %>">
<%= form_for @conn, remote_follow_path(@conn, :do_follow), [as: "mfa"], fn f -> %>
<%= text_input f, :code, placeholder: "Authentication code", required: true %>
<br>
<%= hidden_input f, :id, value: @followee.id %>
<%= hidden_input f, :token, value: @mfa_token %>
<%= submit "Authorize" %>
<% end %>

View file

@ -8,10 +8,12 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do
require Logger
alias Pleroma.Activity
alias Pleroma.MFA
alias Pleroma.Object.Fetcher
alias Pleroma.Plugs.OAuthScopesPlug
alias Pleroma.User
alias Pleroma.Web.Auth.Authenticator
alias Pleroma.Web.Auth.TOTPAuthenticator
alias Pleroma.Web.CommonAPI
@status_types ["Article", "Event", "Note", "Video", "Page", "Question"]
@ -68,6 +70,8 @@ defp is_status?(acct) do
# POST /ostatus_subscribe
#
# adds a remote account in followers if user already is signed in.
#
def do_follow(%{assigns: %{user: %User{} = user}} = conn, %{"user" => %{"id" => id}}) do
with {:fetch_user, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
{:ok, _, _, _} <- CommonAPI.follow(user, followee) do
@ -78,9 +82,33 @@ def do_follow(%{assigns: %{user: %User{} = user}} = conn, %{"user" => %{"id" =>
end
end
# POST /ostatus_subscribe
#
# step 1.
# checks login\password and displays step 2 form of MFA if need.
#
def do_follow(conn, %{"authorization" => %{"name" => _, "password" => _, "id" => id}}) do
with {:fetch_user, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
with {_, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
{_, {:ok, user}, _} <- {:auth, Authenticator.get_user(conn), followee},
{_, _, _, false} <- {:mfa_required, followee, user, MFA.require?(user)},
{:ok, _, _, _} <- CommonAPI.follow(user, followee) do
redirect(conn, to: "/users/#{followee.id}")
else
error ->
handle_follow_error(conn, error)
end
end
# POST /ostatus_subscribe
#
# step 2
# checks TOTP code. otherwise displays form with errors
#
def do_follow(conn, %{"mfa" => %{"code" => code, "token" => token, "id" => id}}) do
with {_, %User{} = followee} <- {:fetch_user, User.get_cached_by_id(id)},
{_, _, {:ok, %{user: user}}} <- {:mfa_token, followee, MFA.Token.validate(token)},
{_, _, _, {:ok, _}} <-
{:verify_mfa_code, followee, token, TOTPAuthenticator.verify(code, user)},
{:ok, _, _, _} <- CommonAPI.follow(user, followee) do
redirect(conn, to: "/users/#{followee.id}")
else
@ -94,6 +122,23 @@ def do_follow(%{assigns: %{user: nil}} = conn, _) do
render(conn, "followed.html", %{error: "Insufficient permissions: follow | write:follows."})
end
defp handle_follow_error(conn, {:mfa_token, followee, _} = _) do
render(conn, "follow_login.html", %{error: "Wrong username or password", followee: followee})
end
defp handle_follow_error(conn, {:verify_mfa_code, followee, token, _} = _) do
render(conn, "follow_mfa.html", %{
error: "Wrong authentication code",
followee: followee,
mfa_token: token
})
end
defp handle_follow_error(conn, {:mfa_required, followee, user, _} = _) do
{:ok, %{token: token}} = MFA.Token.create_token(user)
render(conn, "follow_mfa.html", %{followee: followee, mfa_token: token, error: false})
end
defp handle_follow_error(conn, {:auth, _, followee} = _) do
render(conn, "follow_login.html", %{error: "Wrong username or password", followee: followee})
end

View file

@ -176,6 +176,7 @@ defp deps do
{:quack, "~> 0.1.1"},
{:joken, "~> 2.0"},
{:benchee, "~> 1.0"},
{:pot, "~> 0.10.2"},
{:esshd, "~> 0.1.0", runtime: Application.get_env(:esshd, :enabled, false)},
{:ex_const, "~> 0.2"},
{:plug_static_index_html, "~> 1.0.0"},

View file

@ -37,7 +37,7 @@
"ex_const": {:hex, :ex_const, "0.2.4", "d06e540c9d834865b012a17407761455efa71d0ce91e5831e86881b9c9d82448", [:mix], [], "hexpm", "96fd346610cc992b8f896ed26a98be82ac4efb065a0578f334a32d60a3ba9767"},
"ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"},
"ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "b84f6af156264530b312a8ab98ac6088f6b77ae5fe2058305c81434aa01fbaf9"},
"ex_syslogger": {:hex, :ex_syslogger, "1.5.0", "bc936ee3fd13d9e592cb4c3a1e8a55fccd33b05e3aa7b185f211f3ed263ff8f0", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.0.5", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "f3b4b184dcdd5f356b7c26c6cd72ab0918ba9dfb4061ccfaf519e562942af87b"},
"ex_syslogger": {:hex, :ex_syslogger, "1.5.2", "72b6aa2d47a236e999171f2e1ec18698740f40af0bd02c8c650bf5f1fd1bac79", [:mix], [{:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}, {:syslog, "~> 1.1.0", [hex: :syslog, repo: "hexpm", optional: false]}], "hexpm", "ab9fab4136dbc62651ec6f16fa4842f10cf02ab4433fa3d0976c01be99398399"},
"excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "151c476331d49b45601ffc45f43cb3a8beb396b02a34e3777fea0ad34ae57d89"},
"fast_html": {:hex, :fast_html, "1.0.3", "2cc0d4b68496266a1530e0c852cafeaede0bd10cfdee26fda50dc696c203162f", [:make, :mix], [], "hexpm", "ab3d782b639d3c4655fbaec0f9d032c91f8cab8dd791ac7469c2381bc7c32f85"},
"fast_sanitize": {:hex, :fast_sanitize, "0.1.7", "2a7cd8734c88a2de6de55022104f8a3b87f1fdbe8bbf131d9049764b53d50d0d", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f39fe8ea08fbac17487c30bf09b7d9f3e12472e51fb07a88ffeb8fd17da8ab67"},
@ -89,6 +89,7 @@
"poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"},
"pot": {:hex, :pot, "0.10.2", "9895c83bcff8cd22d9f5bc79dfc88a188176b261b618ad70d93faf5c5ca36e67", [:rebar3], [], "hexpm", "ac589a8e296b7802681e93cd0a436faec117ea63e9916709c628df31e17e91e2"},
"prometheus": {:hex, :prometheus, "4.5.0", "8f4a2246fe0beb50af0f77c5e0a5bb78fe575c34a9655d7f8bc743aad1c6bf76", [:mix, :rebar3], [], "hexpm", "679b5215480fff612b8351f45c839d995a07ce403e42ff02f1c6b20960d41a4e"},
"prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm", "8d66289f77f913b37eda81fd287340c17e61a447549deb28efc254532b2bed82"},
"prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm", "9fd13404a48437e044b288b41f76e64acd9735fb8b0e3809f494811dfa66d0fb"},
@ -102,7 +103,7 @@
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"},
"sweet_xml": {:hex, :sweet_xml, "0.6.6", "fc3e91ec5dd7c787b6195757fbcf0abc670cee1e4172687b45183032221b66b8", [:mix], [], "hexpm", "2e1ec458f892ffa81f9f8386e3f35a1af6db7a7a37748a64478f13163a1f3573"},
"swoosh": {:hex, :swoosh, "0.23.5", "bfd9404bbf5069b1be2ffd317923ce57e58b332e25dbca2a35dedd7820dfee5a", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "e3928e1d2889a308aaf3e42755809ac21cffd77cb58eef01cbfdab4ce2fd1e21"},
"syslog": {:hex, :syslog, "1.0.6", "995970c9aa7feb380ac493302138e308d6e04fd57da95b439a6df5bb3bf75076", [:rebar3], [], "hexpm", "769ddfabd0d2a16f3f9c17eb7509951e0ca4f68363fb26f2ee51a8ec4a49881a"},
"syslog": {:hex, :syslog, "1.1.0", "6419a232bea84f07b56dc575225007ffe34d9fdc91abe6f1b2f254fd71d8efc2", [:rebar3], [], "hexpm", "4c6a41373c7e20587be33ef841d3de6f3beba08519809329ecc4d27b15b659e1"},
"telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"},
"tesla": {:git, "https://git.pleroma.social/pleroma/elixir-libraries/tesla.git", "61b7503cef33f00834f78ddfafe0d5d9dec2270b", [ref: "61b7503cef33f00834f78ddfafe0d5d9dec2270b"]},
"timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "f354efb2400dd7a80fd9eb6c8419068c4f632da4ac47f3d8822d6e33f08bc852"},

View file

@ -0,0 +1,9 @@
defmodule Pleroma.Repo.Migrations.AddMultiFactorAuthenticationSettingsToUser do
use Ecto.Migration
def change do
alter table(:users) do
add(:multi_factor_authentication_settings, :map, default: %{})
end
end
end

View file

@ -0,0 +1,16 @@
defmodule Pleroma.Repo.Migrations.CreateMfaTokens do
use Ecto.Migration
def change do
create table(:mfa_tokens) do
add(:user_id, references(:users, type: :uuid, on_delete: :delete_all))
add(:authorization_id, references(:oauth_authorizations, on_delete: :delete_all))
add(:token, :string)
add(:valid_until, :naive_datetime_usec)
timestamps()
end
create(unique_index(:mfa_tokens, :token))
end
end

View file

@ -12,17 +12,14 @@ defmodule Pleroma.Integration.MastodonWebsocketTest do
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.OAuth
@moduletag needs_streamer: true, capture_log: true
@path Pleroma.Web.Endpoint.url()
|> URI.parse()
|> Map.put(:scheme, "ws")
|> Map.put(:path, "/api/v1/streaming")
|> URI.to_string()
setup_all do
start_supervised(Pleroma.Web.Streamer.supervisor())
:ok
end
def start_socket(qs \\ nil, headers \\ []) do
path =
case qs do

View file

@ -0,0 +1,11 @@
defmodule Pleroma.MFA.BackupCodesTest do
use Pleroma.DataCase
alias Pleroma.MFA.BackupCodes
test "generate backup codes" do
codes = BackupCodes.generate(number_of_codes: 2, length: 4)
assert [<<_::bytes-size(4)>>, <<_::bytes-size(4)>>] = codes
end
end

17
test/mfa/totp_test.exs Normal file
View file

@ -0,0 +1,17 @@
defmodule Pleroma.MFA.TOTPTest do
use Pleroma.DataCase
alias Pleroma.MFA.TOTP
test "create provisioning_uri to generate qrcode" do
uri =
TOTP.provisioning_uri("test-secrcet", "test@example.com",
issuer: "Plerome-42",
digits: 8,
period: 60
)
assert uri ==
"otpauth://totp/test@example.com?digits=8&issuer=Plerome-42&period=60&secret=test-secrcet"
end
end

53
test/mfa_test.exs Normal file
View file

@ -0,0 +1,53 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.MFATest do
use Pleroma.DataCase
import Pleroma.Factory
alias Comeonin.Pbkdf2
alias Pleroma.MFA
describe "mfa_settings" do
test "returns settings user's" do
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
totp: %MFA.Settings.TOTP{secret: "xx", confirmed: true}
}
)
settings = MFA.mfa_settings(user)
assert match?(^settings, %{enabled: true, totp: true})
end
end
describe "generate backup codes" do
test "returns backup codes" do
user = insert(:user)
{:ok, [code1, code2]} = MFA.generate_backup_codes(user)
updated_user = refresh_record(user)
[hash1, hash2] = updated_user.multi_factor_authentication_settings.backup_codes
assert Pbkdf2.checkpw(code1, hash1)
assert Pbkdf2.checkpw(code2, hash2)
end
end
describe "invalidate_backup_code" do
test "invalid used code" do
user = insert(:user)
{:ok, _} = MFA.generate_backup_codes(user)
user = refresh_record(user)
assert length(user.multi_factor_authentication_settings.backup_codes) == 2
[hash_code | _] = user.multi_factor_authentication_settings.backup_codes
{:ok, user} = MFA.invalidate_backup_code(user, hash_code)
assert length(user.multi_factor_authentication_settings.backup_codes) == 1
end
end
end

View file

@ -162,14 +162,18 @@ test "does not create a notification for subscribed users if status is a reply"
@tag needs_streamer: true
test "it creates a notification for user and send to the 'user' and the 'user:notification' stream" do
user = insert(:user)
task = Task.async(fn -> assert_receive {:text, _}, 4_000 end)
task_user_notification = Task.async(fn -> assert_receive {:text, _}, 4_000 end)
Streamer.add_socket("user", %{transport_pid: task.pid, assigns: %{user: user}})
Streamer.add_socket(
"user:notification",
%{transport_pid: task_user_notification.pid, assigns: %{user: user}}
)
task =
Task.async(fn ->
Streamer.add_socket("user", user)
assert_receive {:render_with_user, _, _, _}, 4_000
end)
task_user_notification =
Task.async(fn ->
Streamer.add_socket("user:notification", user)
assert_receive {:render_with_user, _, _, _}, 4_000
end)
activity = insert(:note_activity)

View file

@ -24,6 +24,31 @@ test "it continues if a user is assigned", %{conn: conn} do
end
end
test "it halts if user is assigned and MFA enabled", %{conn: conn} do
conn =
conn
|> assign(:user, %User{multi_factor_authentication_settings: %{enabled: true}})
|> assign(:auth_credentials, %{password: "xd-42"})
|> EnsureAuthenticatedPlug.call(%{})
assert conn.status == 403
assert conn.halted == true
assert conn.resp_body ==
"{\"error\":\"Two-factor authentication enabled, you must use a access token.\"}"
end
test "it continues if user is assigned and MFA disabled", %{conn: conn} do
conn =
conn
|> assign(:user, %User{multi_factor_authentication_settings: %{enabled: false}})
|> assign(:auth_credentials, %{password: "xd-42"})
|> EnsureAuthenticatedPlug.call(%{})
refute conn.status == 403
refute conn.halted
end
describe "with :if_func / :unless_func options" do
setup do
%{

View file

@ -21,7 +21,15 @@ def build(data \\ %{}, opts \\ %{}) do
def insert(data \\ %{}, opts \\ %{}) do
activity = build(data, opts)
ActivityPub.insert(activity)
case ActivityPub.insert(activity) do
ok = {:ok, activity} ->
ActivityPub.notify_and_stream(activity)
ok
error ->
error
end
end
def insert_list(times, data \\ %{}, opts \\ %{}) do

View file

@ -11,6 +11,7 @@ def build(data \\ %{}) do
bio: "A tester.",
ap_id: "some id",
last_digest_emailed_at: NaiveDateTime.truncate(NaiveDateTime.utc_now(), :second),
multi_factor_authentication_settings: %Pleroma.MFA.Settings{},
notification_settings: %Pleroma.User.NotificationSetting{}
}

View file

@ -139,7 +139,11 @@ defp ensure_federating_or_authenticated(conn, url, user) do
end
if tags[:needs_streamer] do
start_supervised(Pleroma.Web.Streamer.supervisor())
start_supervised(%{
id: Pleroma.Web.Streamer.registry(),
start:
{Registry, :start_link, [[keys: :duplicate, name: Pleroma.Web.Streamer.registry()]]}
})
end
{:ok, conn: Phoenix.ConnTest.build_conn()}

View file

@ -40,7 +40,11 @@ defmodule Pleroma.DataCase do
end
if tags[:needs_streamer] do
start_supervised(Pleroma.Web.Streamer.supervisor())
start_supervised(%{
id: Pleroma.Web.Streamer.registry(),
start:
{Registry, :start_link, [[keys: :duplicate, name: Pleroma.Web.Streamer.registry()]]}
})
end
:ok

View file

@ -33,7 +33,8 @@ def user_factory do
bio: sequence(:bio, &"Tester Number #{&1}"),
last_digest_emailed_at: NaiveDateTime.utc_now(),
last_refreshed_at: NaiveDateTime.utc_now(),
notification_settings: %Pleroma.User.NotificationSetting{}
notification_settings: %Pleroma.User.NotificationSetting{},
multi_factor_authentication_settings: %Pleroma.MFA.Settings{}
}
%{
@ -422,4 +423,13 @@ def marker_factory do
last_read_id: "1"
}
end
def mfa_token_factory do
%Pleroma.MFA.Token{
token: :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false),
authorization: build(:oauth_authorization),
valid_until: NaiveDateTime.add(NaiveDateTime.utc_now(), 60 * 10),
user: build(:user)
}
end
end

View file

@ -172,6 +172,7 @@ test "works with URIs" do
|> Map.put(:search_rank, nil)
|> Map.put(:search_type, nil)
|> Map.put(:last_digest_emailed_at, nil)
|> Map.put(:multi_factor_authentication_settings, nil)
|> Map.put(:notification_settings, nil)
assert user == expected

View file

@ -14,6 +14,7 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIControllerTest do
alias Pleroma.Config
alias Pleroma.ConfigDB
alias Pleroma.HTML
alias Pleroma.MFA
alias Pleroma.ModerationLog
alias Pleroma.Repo
alias Pleroma.ReportNote
@ -1278,6 +1279,38 @@ test "PATCH /api/pleroma/admin/users/:nickname/toggle_activation", %{admin: admi
"@#{admin.nickname} deactivated users: @#{user.nickname}"
end
describe "PUT disable_mfa" do
test "returns 200 and disable 2fa", %{conn: conn} do
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
totp: %MFA.Settings.TOTP{secret: "otp_secret", confirmed: true}
}
)
response =
conn
|> put("/api/pleroma/admin/users/disable_mfa", %{nickname: user.nickname})
|> json_response(200)
assert response == user.nickname
mfa_settings = refresh_record(user).multi_factor_authentication_settings
refute mfa_settings.enabled
refute mfa_settings.totp.confirmed
end
test "returns 404 if user not found", %{conn: conn} do
response =
conn
|> put("/api/pleroma/admin/users/disable_mfa", %{nickname: "nickname"})
|> json_response(404)
assert response == "Not found"
end
end
describe "POST /api/pleroma/admin/users/invite_token" do
test "without options", %{conn: conn} do
conn = post(conn, "/api/pleroma/admin/users/invite_token")

View file

@ -0,0 +1,43 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Auth.PleromaAuthenticatorTest do
use Pleroma.Web.ConnCase
alias Pleroma.Web.Auth.PleromaAuthenticator
import Pleroma.Factory
setup do
password = "testpassword"
name = "AgentSmith"
user = insert(:user, nickname: name, password_hash: Comeonin.Pbkdf2.hashpwsalt(password))
{:ok, [user: user, name: name, password: password]}
end
test "get_user/authorization", %{user: user, name: name, password: password} do
params = %{"authorization" => %{"name" => name, "password" => password}}
res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
assert {:ok, user} == res
end
test "get_user/authorization with invalid password", %{name: name} do
params = %{"authorization" => %{"name" => name, "password" => "password"}}
res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
assert {:error, {:checkpw, false}} == res
end
test "get_user/grant_type_password", %{user: user, name: name, password: password} do
params = %{"grant_type" => "password", "username" => name, "password" => password}
res = PleromaAuthenticator.get_user(%Plug.Conn{params: params})
assert {:ok, user} == res
end
test "error credintails" do
res = PleromaAuthenticator.get_user(%Plug.Conn{params: %{}})
assert {:error, :invalid_credentials} == res
end
end

View file

@ -0,0 +1,51 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Auth.TOTPAuthenticatorTest do
use Pleroma.Web.ConnCase
alias Pleroma.MFA
alias Pleroma.MFA.BackupCodes
alias Pleroma.MFA.TOTP
alias Pleroma.Web.Auth.TOTPAuthenticator
import Pleroma.Factory
test "verify token" do
otp_secret = TOTP.generate_secret()
otp_token = TOTP.generate_token(otp_secret)
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
}
)
assert TOTPAuthenticator.verify(otp_token, user) == {:ok, :pass}
assert TOTPAuthenticator.verify(nil, user) == {:error, :invalid_token}
assert TOTPAuthenticator.verify("", user) == {:error, :invalid_token}
end
test "checks backup codes" do
[code | _] = backup_codes = BackupCodes.generate()
hashed_codes =
backup_codes
|> Enum.map(&Comeonin.Pbkdf2.hashpwsalt(&1))
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
backup_codes: hashed_codes,
totp: %MFA.Settings.TOTP{secret: "otp_secret", confirmed: true}
}
)
assert TOTPAuthenticator.verify_recovery_code(user, code) == {:ok, :pass}
refute TOTPAuthenticator.verify_recovery_code(code, refresh_record(user)) == {:ok, :pass}
end
end

View file

@ -24,7 +24,7 @@ test "returns poll entity for object id", %{user: user, conn: conn} do
conn = get(conn, "/api/v1/polls/#{object.id}")
response = json_response(conn, 200)
response = json_response_and_validate_schema(conn, 200)
id = to_string(object.id)
assert %{"id" => ^id, "expired" => false, "multiple" => false} = response
end
@ -43,7 +43,7 @@ test "does not expose polls for private statuses", %{conn: conn} do
conn = get(conn, "/api/v1/polls/#{object.id}")
assert json_response(conn, 404)
assert json_response_and_validate_schema(conn, 404)
end
end
@ -65,9 +65,12 @@ test "votes are added to the poll", %{conn: conn} do
object = Object.normalize(activity)
conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]})
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1, 2]})
assert json_response(conn, 200)
assert json_response_and_validate_schema(conn, 200)
object = Object.get_by_id(object.id)
assert Enum.all?(object.data["anyOf"], fn %{"replies" => %{"totalItems" => total_items}} ->
@ -85,8 +88,9 @@ test "author can't vote", %{user: user, conn: conn} do
object = Object.normalize(activity)
assert conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [1]})
|> json_response(422) == %{"error" => "Poll's author can't vote"}
|> json_response_and_validate_schema(422) == %{"error" => "Poll's author can't vote"}
object = Object.get_by_id(object.id)
@ -105,8 +109,9 @@ test "does not allow multiple choices on a single-choice question", %{conn: conn
object = Object.normalize(activity)
assert conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0, 1]})
|> json_response(422) == %{"error" => "Too many choices"}
|> json_response_and_validate_schema(422) == %{"error" => "Too many choices"}
object = Object.get_by_id(object.id)
@ -126,15 +131,21 @@ test "does not allow choice index to be greater than options count", %{conn: con
object = Object.normalize(activity)
conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [2]})
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [2]})
assert json_response(conn, 422) == %{"error" => "Invalid indices"}
assert json_response_and_validate_schema(conn, 422) == %{"error" => "Invalid indices"}
end
test "returns 404 error when object is not exist", %{conn: conn} do
conn = post(conn, "/api/v1/polls/1/votes", %{"choices" => [0]})
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/1/votes", %{"choices" => [0]})
assert json_response(conn, 404) == %{"error" => "Record not found"}
assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
end
test "returns 404 when poll is private and not available for user", %{conn: conn} do
@ -149,9 +160,12 @@ test "returns 404 when poll is private and not available for user", %{conn: conn
object = Object.normalize(activity)
conn = post(conn, "/api/v1/polls/#{object.id}/votes", %{"choices" => [0]})
conn =
conn
|> put_req_header("content-type", "application/json")
|> post("/api/v1/polls/#{object.id}/votes", %{"choices" => [0]})
assert json_response(conn, 404) == %{"error" => "Record not found"}
assert json_response_and_validate_schema(conn, 404) == %{"error" => "Record not found"}
end
end
end

View file

@ -0,0 +1,306 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2018 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.OAuth.MFAControllerTest do
use Pleroma.Web.ConnCase
import Pleroma.Factory
alias Pleroma.MFA
alias Pleroma.MFA.BackupCodes
alias Pleroma.MFA.TOTP
alias Pleroma.Repo
alias Pleroma.Web.OAuth.Authorization
alias Pleroma.Web.OAuth.OAuthController
setup %{conn: conn} do
otp_secret = TOTP.generate_secret()
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
backup_codes: [Comeonin.Pbkdf2.hashpwsalt("test-code")],
totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
}
)
app = insert(:oauth_app)
{:ok, conn: conn, user: user, app: app}
end
describe "show" do
setup %{conn: conn, user: user, app: app} do
mfa_token =
insert(:mfa_token,
user: user,
authorization: build(:oauth_authorization, app: app, scopes: ["write"])
)
{:ok, conn: conn, mfa_token: mfa_token}
end
test "GET /oauth/mfa renders mfa forms", %{conn: conn, mfa_token: mfa_token} do
conn =
get(
conn,
"/oauth/mfa",
%{
"mfa_token" => mfa_token.token,
"state" => "a_state",
"redirect_uri" => "http://localhost:8080/callback"
}
)
assert response = html_response(conn, 200)
assert response =~ "Two-factor authentication"
assert response =~ mfa_token.token
assert response =~ "http://localhost:8080/callback"
end
test "GET /oauth/mfa renders mfa recovery forms", %{conn: conn, mfa_token: mfa_token} do
conn =
get(
conn,
"/oauth/mfa",
%{
"mfa_token" => mfa_token.token,
"state" => "a_state",
"redirect_uri" => "http://localhost:8080/callback",
"challenge_type" => "recovery"
}
)
assert response = html_response(conn, 200)
assert response =~ "Two-factor recovery"
assert response =~ mfa_token.token
assert response =~ "http://localhost:8080/callback"
end
end
describe "verify" do
setup %{conn: conn, user: user, app: app} do
mfa_token =
insert(:mfa_token,
user: user,
authorization: build(:oauth_authorization, app: app, scopes: ["write"])
)
{:ok, conn: conn, user: user, mfa_token: mfa_token, app: app}
end
test "POST /oauth/mfa/verify, verify totp code", %{
conn: conn,
user: user,
mfa_token: mfa_token,
app: app
} do
otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
conn =
conn
|> post("/oauth/mfa/verify", %{
"mfa" => %{
"mfa_token" => mfa_token.token,
"challenge_type" => "totp",
"code" => otp_token,
"state" => "a_state",
"redirect_uri" => OAuthController.default_redirect_uri(app)
}
})
target = redirected_to(conn)
target_url = %URI{URI.parse(target) | query: nil} |> URI.to_string()
query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
assert %{"state" => "a_state", "code" => code} = query
assert target_url == OAuthController.default_redirect_uri(app)
auth = Repo.get_by(Authorization, token: code)
assert auth.scopes == ["write"]
end
test "POST /oauth/mfa/verify, verify recovery code", %{
conn: conn,
mfa_token: mfa_token,
app: app
} do
conn =
conn
|> post("/oauth/mfa/verify", %{
"mfa" => %{
"mfa_token" => mfa_token.token,
"challenge_type" => "recovery",
"code" => "test-code",
"state" => "a_state",
"redirect_uri" => OAuthController.default_redirect_uri(app)
}
})
target = redirected_to(conn)
target_url = %URI{URI.parse(target) | query: nil} |> URI.to_string()
query = URI.parse(target).query |> URI.query_decoder() |> Map.new()
assert %{"state" => "a_state", "code" => code} = query
assert target_url == OAuthController.default_redirect_uri(app)
auth = Repo.get_by(Authorization, token: code)
assert auth.scopes == ["write"]
end
end
describe "challenge/totp" do
test "returns access token with valid code", %{conn: conn, user: user, app: app} do
otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
mfa_token =
insert(:mfa_token,
user: user,
authorization: build(:oauth_authorization, app: app, scopes: ["write"])
)
response =
conn
|> post("/oauth/mfa/challenge", %{
"mfa_token" => mfa_token.token,
"challenge_type" => "totp",
"code" => otp_token,
"client_id" => app.client_id,
"client_secret" => app.client_secret
})
|> json_response(:ok)
ap_id = user.ap_id
assert match?(
%{
"access_token" => _,
"expires_in" => 600,
"me" => ^ap_id,
"refresh_token" => _,
"scope" => "write",
"token_type" => "Bearer"
},
response
)
end
test "returns errors when mfa token invalid", %{conn: conn, user: user, app: app} do
otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
response =
conn
|> post("/oauth/mfa/challenge", %{
"mfa_token" => "XXX",
"challenge_type" => "totp",
"code" => otp_token,
"client_id" => app.client_id,
"client_secret" => app.client_secret
})
|> json_response(400)
assert response == %{"error" => "Invalid code"}
end
test "returns error when otp code is invalid", %{conn: conn, user: user, app: app} do
mfa_token = insert(:mfa_token, user: user)
response =
conn
|> post("/oauth/mfa/challenge", %{
"mfa_token" => mfa_token.token,
"challenge_type" => "totp",
"code" => "XXX",
"client_id" => app.client_id,
"client_secret" => app.client_secret
})
|> json_response(400)
assert response == %{"error" => "Invalid code"}
end
test "returns error when client credentails is wrong ", %{conn: conn, user: user} do
otp_token = TOTP.generate_token(user.multi_factor_authentication_settings.totp.secret)
mfa_token = insert(:mfa_token, user: user)
response =
conn
|> post("/oauth/mfa/challenge", %{
"mfa_token" => mfa_token.token,
"challenge_type" => "totp",
"code" => otp_token,
"client_id" => "xxx",
"client_secret" => "xxx"
})
|> json_response(400)
assert response == %{"error" => "Invalid code"}
end
end
describe "challenge/recovery" do
setup %{conn: conn} do
app = insert(:oauth_app)
{:ok, conn: conn, app: app}
end
test "returns access token with valid code", %{conn: conn, app: app} do
otp_secret = TOTP.generate_secret()
[code | _] = backup_codes = BackupCodes.generate()
hashed_codes =
backup_codes
|> Enum.map(&Comeonin.Pbkdf2.hashpwsalt(&1))
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
backup_codes: hashed_codes,
totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
}
)
mfa_token =
insert(:mfa_token,
user: user,
authorization: build(:oauth_authorization, app: app, scopes: ["write"])
)
response =
conn
|> post("/oauth/mfa/challenge", %{
"mfa_token" => mfa_token.token,
"challenge_type" => "recovery",
"code" => code,
"client_id" => app.client_id,
"client_secret" => app.client_secret
})
|> json_response(:ok)
ap_id = user.ap_id
assert match?(
%{
"access_token" => _,
"expires_in" => 600,
"me" => ^ap_id,
"refresh_token" => _,
"scope" => "write",
"token_type" => "Bearer"
},
response
)
error_response =
conn
|> post("/oauth/mfa/challenge", %{
"mfa_token" => mfa_token.token,
"challenge_type" => "recovery",
"code" => code,
"client_id" => app.client_id,
"client_secret" => app.client_secret
})
|> json_response(400)
assert error_response == %{"error" => "Invalid code"}
end
end
end

View file

@ -6,6 +6,8 @@ defmodule Pleroma.Web.OAuth.OAuthControllerTest do
use Pleroma.Web.ConnCase
import Pleroma.Factory
alias Pleroma.MFA
alias Pleroma.MFA.TOTP
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.OAuth.Authorization
@ -604,6 +606,41 @@ test "redirects with oauth authorization, " <>
end
end
test "redirect to on two-factor auth page" do
otp_secret = TOTP.generate_secret()
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
}
)
app = insert(:oauth_app, scopes: ["read", "write", "follow"])
conn =
build_conn()
|> post("/oauth/authorize", %{
"authorization" => %{
"name" => user.nickname,
"password" => "test",
"client_id" => app.client_id,
"redirect_uri" => app.redirect_uris,
"scope" => "read write",
"state" => "statepassed"
}
})
result = html_response(conn, 200)
mfa_token = Repo.get_by(MFA.Token, user_id: user.id)
assert result =~ app.redirect_uris
assert result =~ "statepassed"
assert result =~ mfa_token.token
assert result =~ "Two-factor authentication"
end
test "returns 401 for wrong credentials", %{conn: conn} do
user = insert(:user)
app = insert(:oauth_app)
@ -735,6 +772,46 @@ test "issues a token for `password` grant_type with valid credentials, with full
assert token.scopes == app.scopes
end
test "issues a mfa token for `password` grant_type, when MFA enabled" do
password = "testpassword"
otp_secret = TOTP.generate_secret()
user =
insert(:user,
password_hash: Comeonin.Pbkdf2.hashpwsalt(password),
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
}
)
app = insert(:oauth_app, scopes: ["read", "write"])
response =
build_conn()
|> post("/oauth/token", %{
"grant_type" => "password",
"username" => user.nickname,
"password" => password,
"client_id" => app.client_id,
"client_secret" => app.client_secret
})
|> json_response(403)
assert match?(
%{
"supported_challenge_types" => "totp",
"mfa_token" => _,
"error" => "mfa_required"
},
response
)
token = Repo.get_by(MFA.Token, token: response["mfa_token"])
assert token.user_id == user.id
assert token.authorization_id
end
test "issues a token for request with HTTP basic auth client credentials" do
user = insert(:user)
app = insert(:oauth_app, scopes: ["scope1", "scope2", "scope3"])

View file

@ -0,0 +1,260 @@
defmodule Pleroma.Web.PleromaAPI.TwoFactorAuthenticationControllerTest do
use Pleroma.Web.ConnCase
import Pleroma.Factory
alias Pleroma.MFA.Settings
alias Pleroma.MFA.TOTP
describe "GET /api/pleroma/accounts/mfa/settings" do
test "returns user mfa settings for new user", %{conn: conn} do
token = insert(:oauth_token, scopes: ["read", "follow"])
token2 = insert(:oauth_token, scopes: ["write"])
assert conn
|> put_req_header("authorization", "Bearer #{token.token}")
|> get("/api/pleroma/accounts/mfa")
|> json_response(:ok) == %{
"settings" => %{"enabled" => false, "totp" => false}
}
assert conn
|> put_req_header("authorization", "Bearer #{token2.token}")
|> get("/api/pleroma/accounts/mfa")
|> json_response(403) == %{
"error" => "Insufficient permissions: read:security."
}
end
test "returns user mfa settings with enabled totp", %{conn: conn} do
user =
insert(:user,
multi_factor_authentication_settings: %Settings{
enabled: true,
totp: %Settings.TOTP{secret: "XXX", delivery_type: "app", confirmed: true}
}
)
token = insert(:oauth_token, scopes: ["read", "follow"], user: user)
assert conn
|> put_req_header("authorization", "Bearer #{token.token}")
|> get("/api/pleroma/accounts/mfa")
|> json_response(:ok) == %{
"settings" => %{"enabled" => true, "totp" => true}
}
end
end
describe "GET /api/pleroma/accounts/mfa/backup_codes" do
test "returns backup codes", %{conn: conn} do
user =
insert(:user,
multi_factor_authentication_settings: %Settings{
backup_codes: ["1", "2", "3"],
totp: %Settings.TOTP{secret: "secret"}
}
)
token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
token2 = insert(:oauth_token, scopes: ["read"])
response =
conn
|> put_req_header("authorization", "Bearer #{token.token}")
|> get("/api/pleroma/accounts/mfa/backup_codes")
|> json_response(:ok)
assert [<<_::bytes-size(6)>>, <<_::bytes-size(6)>>] = response["codes"]
user = refresh_record(user)
mfa_settings = user.multi_factor_authentication_settings
assert mfa_settings.totp.secret == "secret"
refute mfa_settings.backup_codes == ["1", "2", "3"]
refute mfa_settings.backup_codes == []
assert conn
|> put_req_header("authorization", "Bearer #{token2.token}")
|> get("/api/pleroma/accounts/mfa/backup_codes")
|> json_response(403) == %{
"error" => "Insufficient permissions: write:security."
}
end
end
describe "GET /api/pleroma/accounts/mfa/setup/totp" do
test "return errors when method is invalid", %{conn: conn} do
user = insert(:user)
token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
response =
conn
|> put_req_header("authorization", "Bearer #{token.token}")
|> get("/api/pleroma/accounts/mfa/setup/torf")
|> json_response(400)
assert response == %{"error" => "undefined method"}
end
test "returns key and provisioning_uri", %{conn: conn} do
user =
insert(:user,
multi_factor_authentication_settings: %Settings{backup_codes: ["1", "2", "3"]}
)
token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
token2 = insert(:oauth_token, scopes: ["read"])
response =
conn
|> put_req_header("authorization", "Bearer #{token.token}")
|> get("/api/pleroma/accounts/mfa/setup/totp")
|> json_response(:ok)
user = refresh_record(user)
mfa_settings = user.multi_factor_authentication_settings
secret = mfa_settings.totp.secret
refute mfa_settings.enabled
assert mfa_settings.backup_codes == ["1", "2", "3"]
assert response == %{
"key" => secret,
"provisioning_uri" => TOTP.provisioning_uri(secret, "#{user.email}")
}
assert conn
|> put_req_header("authorization", "Bearer #{token2.token}")
|> get("/api/pleroma/accounts/mfa/setup/totp")
|> json_response(403) == %{
"error" => "Insufficient permissions: write:security."
}
end
end
describe "GET /api/pleroma/accounts/mfa/confirm/totp" do
test "returns success result", %{conn: conn} do
secret = TOTP.generate_secret()
code = TOTP.generate_token(secret)
user =
insert(:user,
multi_factor_authentication_settings: %Settings{
backup_codes: ["1", "2", "3"],
totp: %Settings.TOTP{secret: secret}
}
)
token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
token2 = insert(:oauth_token, scopes: ["read"])
assert conn
|> put_req_header("authorization", "Bearer #{token.token}")
|> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: code})
|> json_response(:ok)
settings = refresh_record(user).multi_factor_authentication_settings
assert settings.enabled
assert settings.totp.secret == secret
assert settings.totp.confirmed
assert settings.backup_codes == ["1", "2", "3"]
assert conn
|> put_req_header("authorization", "Bearer #{token2.token}")
|> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: code})
|> json_response(403) == %{
"error" => "Insufficient permissions: write:security."
}
end
test "returns error if password incorrect", %{conn: conn} do
secret = TOTP.generate_secret()
code = TOTP.generate_token(secret)
user =
insert(:user,
multi_factor_authentication_settings: %Settings{
backup_codes: ["1", "2", "3"],
totp: %Settings.TOTP{secret: secret}
}
)
token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
response =
conn
|> put_req_header("authorization", "Bearer #{token.token}")
|> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "xxx", code: code})
|> json_response(422)
settings = refresh_record(user).multi_factor_authentication_settings
refute settings.enabled
refute settings.totp.confirmed
assert settings.backup_codes == ["1", "2", "3"]
assert response == %{"error" => "Invalid password."}
end
test "returns error if code incorrect", %{conn: conn} do
secret = TOTP.generate_secret()
user =
insert(:user,
multi_factor_authentication_settings: %Settings{
backup_codes: ["1", "2", "3"],
totp: %Settings.TOTP{secret: secret}
}
)
token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
token2 = insert(:oauth_token, scopes: ["read"])
response =
conn
|> put_req_header("authorization", "Bearer #{token.token}")
|> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: "code"})
|> json_response(422)
settings = refresh_record(user).multi_factor_authentication_settings
refute settings.enabled
refute settings.totp.confirmed
assert settings.backup_codes == ["1", "2", "3"]
assert response == %{"error" => "invalid_token"}
assert conn
|> put_req_header("authorization", "Bearer #{token2.token}")
|> post("/api/pleroma/accounts/mfa/confirm/totp", %{password: "test", code: "code"})
|> json_response(403) == %{
"error" => "Insufficient permissions: write:security."
}
end
end
describe "DELETE /api/pleroma/accounts/mfa/totp" do
test "returns success result", %{conn: conn} do
user =
insert(:user,
multi_factor_authentication_settings: %Settings{
backup_codes: ["1", "2", "3"],
totp: %Settings.TOTP{secret: "secret"}
}
)
token = insert(:oauth_token, scopes: ["write", "follow"], user: user)
token2 = insert(:oauth_token, scopes: ["read"])
assert conn
|> put_req_header("authorization", "Bearer #{token.token}")
|> delete("/api/pleroma/accounts/mfa/totp", %{password: "test"})
|> json_response(:ok)
settings = refresh_record(user).multi_factor_authentication_settings
refute settings.enabled
assert settings.totp.secret == nil
refute settings.totp.confirmed
assert conn
|> put_req_header("authorization", "Bearer #{token2.token}")
|> delete("/api/pleroma/accounts/mfa/totp", %{password: "test"})
|> json_response(403) == %{
"error" => "Insufficient permissions: write:security."
}
end
end
end

View file

@ -1,36 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PingTest do
use Pleroma.DataCase
import Pleroma.Factory
alias Pleroma.Web.Streamer
setup do
start_supervised({Streamer.supervisor(), [ping_interval: 30]})
:ok
end
describe "sockets" do
setup do
user = insert(:user)
{:ok, %{user: user}}
end
test "it sends pings", %{user: user} do
task =
Task.async(fn ->
assert_receive {:text, received_event}, 40
assert_receive {:text, received_event}, 40
assert_receive {:text, received_event}, 40
end)
Streamer.add_socket("public", %{transport_pid: task.pid, assigns: %{user: user}})
Task.await(task)
end
end
end

View file

@ -1,54 +0,0 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.StateTest do
use Pleroma.DataCase
import Pleroma.Factory
alias Pleroma.Web.Streamer
alias Pleroma.Web.Streamer.StreamerSocket
@moduletag needs_streamer: true
describe "sockets" do
setup do
user = insert(:user)
user2 = insert(:user)
{:ok, %{user: user, user2: user2}}
end
test "it can add a socket", %{user: user} do
Streamer.add_socket("public", %{transport_pid: 1, assigns: %{user: user}})
assert(%{"public" => [%StreamerSocket{transport_pid: 1}]} = Streamer.get_sockets())
end
test "it can add multiple sockets per user", %{user: user} do
Streamer.add_socket("public", %{transport_pid: 1, assigns: %{user: user}})
Streamer.add_socket("public", %{transport_pid: 2, assigns: %{user: user}})
assert(
%{
"public" => [
%StreamerSocket{transport_pid: 2},
%StreamerSocket{transport_pid: 1}
]
} = Streamer.get_sockets()
)
end
test "it will not add a duplicate socket", %{user: user} do
Streamer.add_socket("activity", %{transport_pid: 1, assigns: %{user: user}})
Streamer.add_socket("activity", %{transport_pid: 1, assigns: %{user: user}})
assert(
%{
"activity" => [
%StreamerSocket{transport_pid: 1}
]
} = Streamer.get_sockets()
)
end
end
end

View file

@ -12,13 +12,9 @@ defmodule Pleroma.Web.StreamerTest do
alias Pleroma.User
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.Streamer
alias Pleroma.Web.Streamer.StreamerSocket
alias Pleroma.Web.Streamer.Worker
@moduletag needs_streamer: true, capture_log: true
@streamer_timeout 150
@streamer_start_wait 10
setup do: clear_config([:instance, :skip_thread_containment])
describe "user streams" do
@ -29,69 +25,35 @@ defmodule Pleroma.Web.StreamerTest do
end
test "it streams the user's post in the 'user' stream", %{user: user} do
task =
Task.async(fn ->
assert_receive {:text, _}, @streamer_timeout
end)
Streamer.add_socket(
"user",
%{transport_pid: task.pid, assigns: %{user: user}}
)
Streamer.add_socket("user", user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "hey"})
Streamer.stream("user", activity)
Task.await(task)
assert_receive {:render_with_user, _, _, ^activity}
refute Streamer.filtered_by_user?(user, activity)
end
test "it streams boosts of the user in the 'user' stream", %{user: user} do
task =
Task.async(fn ->
assert_receive {:text, _}, @streamer_timeout
end)
Streamer.add_socket(
"user",
%{transport_pid: task.pid, assigns: %{user: user}}
)
Streamer.add_socket("user", user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{"status" => "hey"})
{:ok, announce, _} = CommonAPI.repeat(activity.id, user)
Streamer.stream("user", announce)
Task.await(task)
assert_receive {:render_with_user, Pleroma.Web.StreamerView, "update.json", ^announce}
refute Streamer.filtered_by_user?(user, announce)
end
test "it sends notify to in the 'user' stream", %{user: user, notify: notify} do
task =
Task.async(fn ->
assert_receive {:text, _}, @streamer_timeout
end)
Streamer.add_socket(
"user",
%{transport_pid: task.pid, assigns: %{user: user}}
)
Streamer.add_socket("user", user)
Streamer.stream("user", notify)
Task.await(task)
assert_receive {:render_with_user, _, _, ^notify}
refute Streamer.filtered_by_user?(user, notify)
end
test "it sends notify to in the 'user:notification' stream", %{user: user, notify: notify} do
task =
Task.async(fn ->
assert_receive {:text, _}, @streamer_timeout
end)
Streamer.add_socket(
"user:notification",
%{transport_pid: task.pid, assigns: %{user: user}}
)
Streamer.add_socket("user:notification", user)
Streamer.stream("user:notification", notify)
Task.await(task)
assert_receive {:render_with_user, _, _, ^notify}
refute Streamer.filtered_by_user?(user, notify)
end
test "it doesn't send notify to the 'user:notification' stream when a user is blocked", %{
@ -100,18 +62,12 @@ test "it doesn't send notify to the 'user:notification' stream when a user is bl
blocked = insert(:user)
{:ok, _user_relationship} = User.block(user, blocked)
task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end)
Streamer.add_socket(
"user:notification",
%{transport_pid: task.pid, assigns: %{user: user}}
)
Streamer.add_socket("user:notification", user)
{:ok, activity} = CommonAPI.post(user, %{"status" => ":("})
{:ok, notif} = CommonAPI.favorite(blocked, activity.id)
{:ok, _} = CommonAPI.favorite(blocked, activity.id)
Streamer.stream("user:notification", notif)
Task.await(task)
refute_receive _
end
test "it doesn't send notify to the 'user:notification' stream when a thread is muted", %{
@ -119,45 +75,50 @@ test "it doesn't send notify to the 'user:notification' stream when a thread is
} do
user2 = insert(:user)
task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end)
Streamer.add_socket(
"user:notification",
%{transport_pid: task.pid, assigns: %{user: user}}
)
{:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
{:ok, activity} = CommonAPI.add_mute(user, activity)
{:ok, notif} = CommonAPI.favorite(user2, activity.id)
{:ok, _} = CommonAPI.add_mute(user, activity)
Streamer.stream("user:notification", notif)
Task.await(task)
Streamer.add_socket("user:notification", user)
{:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id)
refute_receive _
assert Streamer.filtered_by_user?(user, favorite_activity)
end
test "it doesn't send notify to the 'user:notification' stream' when a domain is blocked", %{
test "it sends favorite to 'user:notification' stream'", %{
user: user
} do
user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"})
task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end)
{:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
Streamer.add_socket("user:notification", user)
{:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id)
Streamer.add_socket(
"user:notification",
%{transport_pid: task.pid, assigns: %{user: user}}
)
assert_receive {:render_with_user, _, "notification.json", notif}
assert notif.activity.id == favorite_activity.id
refute Streamer.filtered_by_user?(user, notif)
end
test "it doesn't send the 'user:notification' stream' when a domain is blocked", %{
user: user
} do
user2 = insert(:user, %{ap_id: "https://hecking-lewd-place.com/user/meanie"})
{:ok, user} = User.block_domain(user, "hecking-lewd-place.com")
{:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
{:ok, notif} = CommonAPI.favorite(user2, activity.id)
Streamer.add_socket("user:notification", user)
{:ok, favorite_activity} = CommonAPI.favorite(user2, activity.id)
Streamer.stream("user:notification", notif)
Task.await(task)
refute_receive _
assert Streamer.filtered_by_user?(user, favorite_activity)
end
test "it sends follow activities to the 'user:notification' stream", %{
user: user
} do
user_url = user.ap_id
user2 = insert(:user)
body =
File.read!("test/fixtures/users_mock/localhost.json")
@ -169,47 +130,24 @@ test "it sends follow activities to the 'user:notification' stream", %{
%Tesla.Env{status: 200, body: body}
end)
user2 = insert(:user)
task = Task.async(fn -> assert_receive {:text, _}, @streamer_timeout end)
Streamer.add_socket("user:notification", user)
{:ok, _follower, _followed, follow_activity} = CommonAPI.follow(user2, user)
Process.sleep(@streamer_start_wait)
Streamer.add_socket(
"user:notification",
%{transport_pid: task.pid, assigns: %{user: user}}
)
{:ok, _follower, _followed, _activity} = CommonAPI.follow(user2, user)
# We don't directly pipe the notification to the streamer as it's already
# generated as a side effect of CommonAPI.follow().
Task.await(task)
assert_receive {:render_with_user, _, "notification.json", notif}
assert notif.activity.id == follow_activity.id
refute Streamer.filtered_by_user?(user, notif)
end
end
test "it sends to public" do
test "it sends to public authenticated" do
user = insert(:user)
other_user = insert(:user)
task =
Task.async(fn ->
assert_receive {:text, _}, @streamer_timeout
end)
Streamer.add_socket("public", other_user)
fake_socket = %StreamerSocket{
transport_pid: task.pid,
user: user
}
{:ok, activity} = CommonAPI.post(other_user, %{"status" => "Test"})
topics = %{
"public" => [fake_socket]
}
Worker.push_to_socket(topics, "public", activity)
Task.await(task)
{:ok, activity} = CommonAPI.post(user, %{"status" => "Test"})
assert_receive {:render_with_user, _, _, ^activity}
refute Streamer.filtered_by_user?(user, activity)
end
test "works for deletions" do
@ -217,37 +155,32 @@ test "works for deletions" do
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{"status" => "Test"})
task =
Task.async(fn ->
expected_event =
%{
"event" => "delete",
"payload" => activity.id
}
|> Jason.encode!()
Streamer.add_socket("public", user)
assert_receive {:text, received_event}, @streamer_timeout
assert received_event == expected_event
end)
{:ok, _} = CommonAPI.delete(activity.id, other_user)
activity_id = activity.id
assert_receive {:text, event}
assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
end
fake_socket = %StreamerSocket{
transport_pid: task.pid,
user: user
}
test "it sends to public unauthenticated" do
user = insert(:user)
{:ok, activity} = CommonAPI.delete(activity.id, other_user)
Streamer.add_socket("public", nil)
topics = %{
"public" => [fake_socket]
}
{:ok, activity} = CommonAPI.post(user, %{"status" => "Test"})
activity_id = activity.id
assert_receive {:text, event}
assert %{"event" => "update", "payload" => payload} = Jason.decode!(event)
assert %{"id" => ^activity_id} = Jason.decode!(payload)
Worker.push_to_socket(topics, "public", activity)
Task.await(task)
{:ok, _} = CommonAPI.delete(activity.id, user)
assert_receive {:text, event}
assert %{"event" => "delete", "payload" => ^activity_id} = Jason.decode!(event)
end
describe "thread_containment" do
test "it doesn't send to user if recipients invalid and thread containment is enabled" do
test "it filters to user if recipients invalid and thread containment is enabled" do
Pleroma.Config.put([:instance, :skip_thread_containment], false)
author = insert(:user)
user = insert(:user)
@ -262,12 +195,10 @@ test "it doesn't send to user if recipients invalid and thread containment is en
)
)
task = Task.async(fn -> refute_receive {:text, _}, 1_000 end)
fake_socket = %StreamerSocket{transport_pid: task.pid, user: user}
topics = %{"public" => [fake_socket]}
Worker.push_to_socket(topics, "public", activity)
Task.await(task)
Streamer.add_socket("public", user)
Streamer.stream("public", activity)
assert_receive {:render_with_user, _, _, ^activity}
assert Streamer.filtered_by_user?(user, activity)
end
test "it sends message if recipients invalid and thread containment is disabled" do
@ -285,12 +216,11 @@ test "it sends message if recipients invalid and thread containment is disabled"
)
)
task = Task.async(fn -> assert_receive {:text, _}, 1_000 end)
fake_socket = %StreamerSocket{transport_pid: task.pid, user: user}
topics = %{"public" => [fake_socket]}
Worker.push_to_socket(topics, "public", activity)
Streamer.add_socket("public", user)
Streamer.stream("public", activity)
Task.await(task)
assert_receive {:render_with_user, _, _, ^activity}
refute Streamer.filtered_by_user?(user, activity)
end
test "it sends message if recipients invalid and thread containment is enabled but user's thread containment is disabled" do
@ -308,255 +238,168 @@ test "it sends message if recipients invalid and thread containment is enabled b
)
)
task = Task.async(fn -> assert_receive {:text, _}, 1_000 end)
fake_socket = %StreamerSocket{transport_pid: task.pid, user: user}
topics = %{"public" => [fake_socket]}
Worker.push_to_socket(topics, "public", activity)
Streamer.add_socket("public", user)
Streamer.stream("public", activity)
Task.await(task)
assert_receive {:render_with_user, _, _, ^activity}
refute Streamer.filtered_by_user?(user, activity)
end
end
describe "blocks" do
test "it doesn't send messages involving blocked users" do
test "it filters messages involving blocked users" do
user = insert(:user)
blocked_user = insert(:user)
{:ok, _user_relationship} = User.block(user, blocked_user)
Streamer.add_socket("public", user)
{:ok, activity} = CommonAPI.post(blocked_user, %{"status" => "Test"})
task =
Task.async(fn ->
refute_receive {:text, _}, 1_000
end)
fake_socket = %StreamerSocket{
transport_pid: task.pid,
user: user
}
topics = %{
"public" => [fake_socket]
}
Worker.push_to_socket(topics, "public", activity)
Task.await(task)
assert_receive {:render_with_user, _, _, ^activity}
assert Streamer.filtered_by_user?(user, activity)
end
test "it doesn't send messages transitively involving blocked users" do
test "it filters messages transitively involving blocked users" do
blocker = insert(:user)
blockee = insert(:user)
friend = insert(:user)
task =
Task.async(fn ->
refute_receive {:text, _}, 1_000
end)
fake_socket = %StreamerSocket{
transport_pid: task.pid,
user: blocker
}
topics = %{
"public" => [fake_socket]
}
Streamer.add_socket("public", blocker)
{:ok, _user_relationship} = User.block(blocker, blockee)
{:ok, activity_one} = CommonAPI.post(friend, %{"status" => "hey! @#{blockee.nickname}"})
Worker.push_to_socket(topics, "public", activity_one)
assert_receive {:render_with_user, _, _, ^activity_one}
assert Streamer.filtered_by_user?(blocker, activity_one)
{:ok, activity_two} = CommonAPI.post(blockee, %{"status" => "hey! @#{friend.nickname}"})
Worker.push_to_socket(topics, "public", activity_two)
assert_receive {:render_with_user, _, _, ^activity_two}
assert Streamer.filtered_by_user?(blocker, activity_two)
{:ok, activity_three} = CommonAPI.post(blockee, %{"status" => "hey! @#{blocker.nickname}"})
Worker.push_to_socket(topics, "public", activity_three)
Task.await(task)
assert_receive {:render_with_user, _, _, ^activity_three}
assert Streamer.filtered_by_user?(blocker, activity_three)
end
end
test "it doesn't send unwanted DMs to list" do
user_a = insert(:user)
user_b = insert(:user)
user_c = insert(:user)
describe "lists" do
test "it doesn't send unwanted DMs to list" do
user_a = insert(:user)
user_b = insert(:user)
user_c = insert(:user)
{:ok, user_a} = User.follow(user_a, user_b)
{:ok, user_a} = User.follow(user_a, user_b)
{:ok, list} = List.create("Test", user_a)
{:ok, list} = List.follow(list, user_b)
{:ok, list} = List.create("Test", user_a)
{:ok, list} = List.follow(list, user_b)
{:ok, activity} =
CommonAPI.post(user_b, %{
"status" => "@#{user_c.nickname} Test",
"visibility" => "direct"
})
Streamer.add_socket("list:#{list.id}", user_a)
task =
Task.async(fn ->
refute_receive {:text, _}, 1_000
end)
{:ok, _activity} =
CommonAPI.post(user_b, %{
"status" => "@#{user_c.nickname} Test",
"visibility" => "direct"
})
fake_socket = %StreamerSocket{
transport_pid: task.pid,
user: user_a
}
refute_receive _
end
topics = %{
"list:#{list.id}" => [fake_socket]
}
test "it doesn't send unwanted private posts to list" do
user_a = insert(:user)
user_b = insert(:user)
Worker.handle_call({:stream, "list", activity}, self(), topics)
{:ok, list} = List.create("Test", user_a)
{:ok, list} = List.follow(list, user_b)
Task.await(task)
Streamer.add_socket("list:#{list.id}", user_a)
{:ok, _activity} =
CommonAPI.post(user_b, %{
"status" => "Test",
"visibility" => "private"
})
refute_receive _
end
test "it sends wanted private posts to list" do
user_a = insert(:user)
user_b = insert(:user)
{:ok, user_a} = User.follow(user_a, user_b)
{:ok, list} = List.create("Test", user_a)
{:ok, list} = List.follow(list, user_b)
Streamer.add_socket("list:#{list.id}", user_a)
{:ok, activity} =
CommonAPI.post(user_b, %{
"status" => "Test",
"visibility" => "private"
})
assert_receive {:render_with_user, _, _, ^activity}
refute Streamer.filtered_by_user?(user_a, activity)
end
end
test "it doesn't send unwanted private posts to list" do
user_a = insert(:user)
user_b = insert(:user)
describe "muted reblogs" do
test "it filters muted reblogs" do
user1 = insert(:user)
user2 = insert(:user)
user3 = insert(:user)
CommonAPI.follow(user1, user2)
CommonAPI.hide_reblogs(user1, user2)
{:ok, list} = List.create("Test", user_a)
{:ok, list} = List.follow(list, user_b)
{:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"})
{:ok, activity} =
CommonAPI.post(user_b, %{
"status" => "Test",
"visibility" => "private"
})
Streamer.add_socket("user", user1)
{:ok, announce_activity, _} = CommonAPI.repeat(create_activity.id, user2)
assert_receive {:render_with_user, _, _, ^announce_activity}
assert Streamer.filtered_by_user?(user1, announce_activity)
end
task =
Task.async(fn ->
refute_receive {:text, _}, 1_000
end)
test "it filters reblog notification for reblog-muted actors" do
user1 = insert(:user)
user2 = insert(:user)
CommonAPI.follow(user1, user2)
CommonAPI.hide_reblogs(user1, user2)
fake_socket = %StreamerSocket{
transport_pid: task.pid,
user: user_a
}
{:ok, create_activity} = CommonAPI.post(user1, %{"status" => "I'm kawen"})
Streamer.add_socket("user", user1)
{:ok, _favorite_activity, _} = CommonAPI.repeat(create_activity.id, user2)
topics = %{
"list:#{list.id}" => [fake_socket]
}
assert_receive {:render_with_user, _, "notification.json", notif}
assert Streamer.filtered_by_user?(user1, notif)
end
Worker.handle_call({:stream, "list", activity}, self(), topics)
test "it send non-reblog notification for reblog-muted actors" do
user1 = insert(:user)
user2 = insert(:user)
CommonAPI.follow(user1, user2)
CommonAPI.hide_reblogs(user1, user2)
Task.await(task)
{:ok, create_activity} = CommonAPI.post(user1, %{"status" => "I'm kawen"})
Streamer.add_socket("user", user1)
{:ok, _favorite_activity} = CommonAPI.favorite(user2, create_activity.id)
assert_receive {:render_with_user, _, "notification.json", notif}
refute Streamer.filtered_by_user?(user1, notif)
end
end
test "it sends wanted private posts to list" do
user_a = insert(:user)
user_b = insert(:user)
{:ok, user_a} = User.follow(user_a, user_b)
{:ok, list} = List.create("Test", user_a)
{:ok, list} = List.follow(list, user_b)
{:ok, activity} =
CommonAPI.post(user_b, %{
"status" => "Test",
"visibility" => "private"
})
task =
Task.async(fn ->
assert_receive {:text, _}, 1_000
end)
fake_socket = %StreamerSocket{
transport_pid: task.pid,
user: user_a
}
Streamer.add_socket(
"list:#{list.id}",
fake_socket
)
Worker.handle_call({:stream, "list", activity}, self(), %{})
Task.await(task)
end
test "it doesn't send muted reblogs" do
user1 = insert(:user)
user2 = insert(:user)
user3 = insert(:user)
CommonAPI.hide_reblogs(user1, user2)
{:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"})
{:ok, announce_activity, _} = CommonAPI.repeat(create_activity.id, user2)
task =
Task.async(fn ->
refute_receive {:text, _}, 1_000
end)
fake_socket = %StreamerSocket{
transport_pid: task.pid,
user: user1
}
topics = %{
"public" => [fake_socket]
}
Worker.push_to_socket(topics, "public", announce_activity)
Task.await(task)
end
test "it does send non-reblog notification for reblog-muted actors" do
user1 = insert(:user)
user2 = insert(:user)
user3 = insert(:user)
CommonAPI.hide_reblogs(user1, user2)
{:ok, create_activity} = CommonAPI.post(user3, %{"status" => "I'm kawen"})
{:ok, favorite_activity} = CommonAPI.favorite(user2, create_activity.id)
task =
Task.async(fn ->
assert_receive {:text, _}, 1_000
end)
fake_socket = %StreamerSocket{
transport_pid: task.pid,
user: user1
}
topics = %{
"public" => [fake_socket]
}
Worker.push_to_socket(topics, "public", favorite_activity)
Task.await(task)
end
test "it doesn't send posts from muted threads" do
test "it filters posts from muted threads" do
user = insert(:user)
user2 = insert(:user)
Streamer.add_socket("user", user2)
{:ok, user2, user, _activity} = CommonAPI.follow(user2, user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "super hot take"})
{:ok, activity} = CommonAPI.add_mute(user2, activity)
task = Task.async(fn -> refute_receive {:text, _}, @streamer_timeout end)
Streamer.add_socket(
"user",
%{transport_pid: task.pid, assigns: %{user: user2}}
)
Streamer.stream("user", activity)
Task.await(task)
{:ok, _} = CommonAPI.add_mute(user2, activity)
assert_receive {:render_with_user, _, _, ^activity}
assert Streamer.filtered_by_user?(user2, activity)
end
describe "direct streams" do
@ -568,22 +411,7 @@ test "it sends conversation update to the 'direct' stream", %{} do
user = insert(:user)
another_user = insert(:user)
task =
Task.async(fn ->
assert_receive {:text, received_event}, @streamer_timeout
assert %{"event" => "conversation", "payload" => received_payload} =
Jason.decode!(received_event)
assert %{"last_status" => last_status} = Jason.decode!(received_payload)
[participation] = Participation.for_user(user)
assert last_status["pleroma"]["direct_conversation_id"] == participation.id
end)
Streamer.add_socket(
"direct",
%{transport_pid: task.pid, assigns: %{user: user}}
)
Streamer.add_socket("direct", user)
{:ok, _create_activity} =
CommonAPI.post(another_user, %{
@ -591,42 +419,47 @@ test "it sends conversation update to the 'direct' stream", %{} do
"visibility" => "direct"
})
Task.await(task)
assert_receive {:text, received_event}
assert %{"event" => "conversation", "payload" => received_payload} =
Jason.decode!(received_event)
assert %{"last_status" => last_status} = Jason.decode!(received_payload)
[participation] = Participation.for_user(user)
assert last_status["pleroma"]["direct_conversation_id"] == participation.id
end
test "it doesn't send conversation update to the 'direct' stream when the last message in the conversation is deleted" do
user = insert(:user)
another_user = insert(:user)
Streamer.add_socket("direct", user)
{:ok, create_activity} =
CommonAPI.post(another_user, %{
"status" => "hi @#{user.nickname}",
"visibility" => "direct"
})
task =
Task.async(fn ->
assert_receive {:text, received_event}, @streamer_timeout
assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event)
create_activity_id = create_activity.id
assert_receive {:render_with_user, _, _, ^create_activity}
assert_receive {:text, received_conversation1}
assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
refute_receive {:text, _}, @streamer_timeout
end)
{:ok, _} = CommonAPI.delete(create_activity_id, another_user)
Process.sleep(@streamer_start_wait)
assert_receive {:text, received_event}
Streamer.add_socket(
"direct",
%{transport_pid: task.pid, assigns: %{user: user}}
)
assert %{"event" => "delete", "payload" => ^create_activity_id} =
Jason.decode!(received_event)
{:ok, _} = CommonAPI.delete(create_activity.id, another_user)
Task.await(task)
refute_receive _
end
test "it sends conversation update to the 'direct' stream when a message is deleted" do
user = insert(:user)
another_user = insert(:user)
Streamer.add_socket("direct", user)
{:ok, create_activity} =
CommonAPI.post(another_user, %{
@ -636,35 +469,30 @@ test "it sends conversation update to the 'direct' stream when a message is dele
{:ok, create_activity2} =
CommonAPI.post(another_user, %{
"status" => "hi @#{user.nickname}",
"status" => "hi @#{user.nickname} 2",
"in_reply_to_status_id" => create_activity.id,
"visibility" => "direct"
})
task =
Task.async(fn ->
assert_receive {:text, received_event}, @streamer_timeout
assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event)
assert_receive {:text, received_event}, @streamer_timeout
assert %{"event" => "conversation", "payload" => received_payload} =
Jason.decode!(received_event)
assert %{"last_status" => last_status} = Jason.decode!(received_payload)
assert last_status["id"] == to_string(create_activity.id)
end)
Process.sleep(@streamer_start_wait)
Streamer.add_socket(
"direct",
%{transport_pid: task.pid, assigns: %{user: user}}
)
assert_receive {:render_with_user, _, _, ^create_activity}
assert_receive {:render_with_user, _, _, ^create_activity2}
assert_receive {:text, received_conversation1}
assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
assert_receive {:text, received_conversation1}
assert %{"event" => "conversation", "payload" => _} = Jason.decode!(received_conversation1)
{:ok, _} = CommonAPI.delete(create_activity2.id, another_user)
Task.await(task)
assert_receive {:text, received_event}
assert %{"event" => "delete", "payload" => _} = Jason.decode!(received_event)
assert_receive {:text, received_event}
assert %{"event" => "conversation", "payload" => received_payload} =
Jason.decode!(received_event)
assert %{"last_status" => last_status} = Jason.decode!(received_payload)
assert last_status["id"] == to_string(create_activity.id)
end
end
end

View file

@ -6,11 +6,14 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowControllerTest do
use Pleroma.Web.ConnCase
alias Pleroma.Config
alias Pleroma.MFA
alias Pleroma.MFA.TOTP
alias Pleroma.User
alias Pleroma.Web.CommonAPI
import ExUnit.CaptureLog
import Pleroma.Factory
import Ecto.Query
setup do
Tesla.Mock.mock(fn env -> apply(HttpRequestMock, :request, [env]) end)
@ -160,6 +163,119 @@ test "returns success result when user already in followers", %{conn: conn} do
end
end
describe "POST /ostatus_subscribe - follow/2 with enabled Two-Factor Auth " do
test "render the MFA login form", %{conn: conn} do
otp_secret = TOTP.generate_secret()
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
}
)
user2 = insert(:user)
response =
conn
|> post(remote_follow_path(conn, :do_follow), %{
"authorization" => %{"name" => user.nickname, "password" => "test", "id" => user2.id}
})
|> response(200)
mfa_token = Pleroma.Repo.one(from(q in Pleroma.MFA.Token, where: q.user_id == ^user.id))
assert response =~ "Two-factor authentication"
assert response =~ "Authentication code"
assert response =~ mfa_token.token
refute user2.follower_address in User.following(user)
end
test "returns error when password is incorrect", %{conn: conn} do
otp_secret = TOTP.generate_secret()
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
}
)
user2 = insert(:user)
response =
conn
|> post(remote_follow_path(conn, :do_follow), %{
"authorization" => %{"name" => user.nickname, "password" => "test1", "id" => user2.id}
})
|> response(200)
assert response =~ "Wrong username or password"
refute user2.follower_address in User.following(user)
end
test "follows", %{conn: conn} do
otp_secret = TOTP.generate_secret()
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
}
)
{:ok, %{token: token}} = MFA.Token.create_token(user)
user2 = insert(:user)
otp_token = TOTP.generate_token(otp_secret)
conn =
conn
|> post(
remote_follow_path(conn, :do_follow),
%{
"mfa" => %{"code" => otp_token, "token" => token, "id" => user2.id}
}
)
assert redirected_to(conn) == "/users/#{user2.id}"
assert user2.follower_address in User.following(user)
end
test "returns error when auth code is incorrect", %{conn: conn} do
otp_secret = TOTP.generate_secret()
user =
insert(:user,
multi_factor_authentication_settings: %MFA.Settings{
enabled: true,
totp: %MFA.Settings.TOTP{secret: otp_secret, confirmed: true}
}
)
{:ok, %{token: token}} = MFA.Token.create_token(user)
user2 = insert(:user)
otp_token = TOTP.generate_token(TOTP.generate_secret())
response =
conn
|> post(
remote_follow_path(conn, :do_follow),
%{
"mfa" => %{"code" => otp_token, "token" => token, "id" => user2.id}
}
)
|> response(200)
assert response =~ "Wrong authentication code"
refute user2.follower_address in User.following(user)
end
end
describe "POST /ostatus_subscribe - follow/2 without assigned user " do
test "follows", %{conn: conn} do
user = insert(:user)