diff options
Diffstat (limited to 'lib')
23 files changed, 1000 insertions, 9 deletions
diff --git a/lib/pleroma/mfa.ex b/lib/pleroma/mfa.ex new file mode 100644 index 000000000..d353a4dad --- /dev/null +++ b/lib/pleroma/mfa.ex @@ -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 diff --git a/lib/pleroma/mfa/backup_codes.ex b/lib/pleroma/mfa/backup_codes.ex new file mode 100644 index 000000000..2b5ec34f8 --- /dev/null +++ b/lib/pleroma/mfa/backup_codes.ex @@ -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 diff --git a/lib/pleroma/mfa/changeset.ex b/lib/pleroma/mfa/changeset.ex new file mode 100644 index 000000000..9b020aa8e --- /dev/null +++ b/lib/pleroma/mfa/changeset.ex @@ -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 diff --git a/lib/pleroma/mfa/settings.ex b/lib/pleroma/mfa/settings.ex new file mode 100644 index 000000000..2764b889c --- /dev/null +++ b/lib/pleroma/mfa/settings.ex @@ -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 diff --git a/lib/pleroma/mfa/token.ex b/lib/pleroma/mfa/token.ex new file mode 100644 index 000000000..25ff7fb29 --- /dev/null +++ b/lib/pleroma/mfa/token.ex @@ -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 diff --git a/lib/pleroma/mfa/totp.ex b/lib/pleroma/mfa/totp.ex new file mode 100644 index 000000000..1407afc57 --- /dev/null +++ b/lib/pleroma/mfa/totp.ex @@ -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 diff --git a/lib/pleroma/plugs/ensure_authenticated_plug.ex b/lib/pleroma/plugs/ensure_authenticated_plug.ex index 9d5176e2b..3fe550806 100644 --- a/lib/pleroma/plugs/ensure_authenticated_plug.ex +++ b/lib/pleroma/plugs/ensure_authenticated_plug.ex @@ -15,6 +15,20 @@ defmodule Pleroma.Plugs.EnsureAuthenticatedPlug 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 diff --git a/lib/pleroma/user.ex b/lib/pleroma/user.ex index 323eb2a41..a6f51f0be 100644 --- a/lib/pleroma/user.ex +++ b/lib/pleroma/user.ex @@ -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 @@ defmodule Pleroma.User 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 diff --git a/lib/pleroma/web/admin_api/admin_api_controller.ex b/lib/pleroma/web/admin_api/admin_api_controller.ex index 80a4ebaac..9f1fd3aeb 100644 --- a/lib/pleroma/web/admin_api/admin_api_controller.ex +++ b/lib/pleroma/web/admin_api/admin_api_controller.ex @@ -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 @@ defmodule Pleroma.Web.AdminAPI.AdminAPIController do      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 diff --git a/lib/pleroma/web/auth/pleroma_authenticator.ex b/lib/pleroma/web/auth/pleroma_authenticator.ex index cb09664ce..a8f554aa3 100644 --- a/lib/pleroma/web/auth/pleroma_authenticator.ex +++ b/lib/pleroma/web/auth/pleroma_authenticator.ex @@ -19,8 +19,8 @@ defmodule Pleroma.Web.Auth.PleromaAuthenticator 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 diff --git a/lib/pleroma/web/auth/totp_authenticator.ex b/lib/pleroma/web/auth/totp_authenticator.ex new file mode 100644 index 000000000..98aca9a51 --- /dev/null +++ b/lib/pleroma/web/auth/totp_authenticator.ex @@ -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 diff --git a/lib/pleroma/web/common_api/utils.ex b/lib/pleroma/web/common_api/utils.ex index 6540fa5d1..793f2e7f8 100644 --- a/lib/pleroma/web/common_api/utils.ex +++ b/lib/pleroma/web/common_api/utils.ex @@ -402,6 +402,7 @@ defmodule Pleroma.Web.CommonAPI.Utils 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 diff --git a/lib/pleroma/web/oauth/mfa_controller.ex b/lib/pleroma/web/oauth/mfa_controller.ex new file mode 100644 index 000000000..e52cccd85 --- /dev/null +++ b/lib/pleroma/web/oauth/mfa_controller.ex @@ -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 diff --git a/lib/pleroma/web/oauth/mfa_view.ex b/lib/pleroma/web/oauth/mfa_view.ex new file mode 100644 index 000000000..e88e7066b --- /dev/null +++ b/lib/pleroma/web/oauth/mfa_view.ex @@ -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 diff --git a/lib/pleroma/web/oauth/oauth_controller.ex b/lib/pleroma/web/oauth/oauth_controller.ex index 685269877..7c804233c 100644 --- a/lib/pleroma/web/oauth/oauth_controller.ex +++ b/lib/pleroma/web/oauth/oauth_controller.ex @@ -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 @@ defmodule Pleroma.Web.OAuth.OAuthController do          %{"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 -> @@ -181,6 +184,22 @@ defmodule Pleroma.Web.OAuth.OAuthController do    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},           %{"authorization" => _} = params         ) do @@ -231,7 +250,8 @@ defmodule Pleroma.Web.OAuth.OAuthController do        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 @@ defmodule Pleroma.Web.OAuth.OAuthController do           {: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 @@ defmodule Pleroma.Web.OAuth.OAuthController do           {: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 @@ defmodule Pleroma.Web.OAuth.OAuthController do    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 @@ defmodule Pleroma.Web.OAuth.OAuthController do           %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 @@ defmodule Pleroma.Web.OAuth.OAuthController do    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 diff --git a/lib/pleroma/web/oauth/token/clean_worker.ex b/lib/pleroma/web/oauth/token/clean_worker.ex new file mode 100644 index 000000000..2c3bb9ded --- /dev/null +++ b/lib/pleroma/web/oauth/token/clean_worker.ex @@ -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 diff --git a/lib/pleroma/web/oauth/token/response.ex b/lib/pleroma/web/oauth/token/response.ex index 6f4713dee..0e72c31e9 100644 --- a/lib/pleroma/web/oauth/token/response.ex +++ b/lib/pleroma/web/oauth/token/response.ex @@ -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 @@ defmodule Pleroma.Web.OAuth.Token.Response 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 diff --git a/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex b/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex new file mode 100644 index 000000000..eb9989cdf --- /dev/null +++ b/lib/pleroma/web/pleroma_api/controllers/two_factor_authentication_controller.ex @@ -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 diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex index 281516bb8..7a171f9fb 100644 --- a/lib/pleroma/web/router.ex +++ b/lib/pleroma/web/router.ex @@ -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) diff --git a/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex new file mode 100644 index 000000000..750f65386 --- /dev/null +++ b/lib/pleroma/web/templates/o_auth/mfa/recovery.html.eex @@ -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> diff --git a/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex new file mode 100644 index 000000000..af6e546b0 --- /dev/null +++ b/lib/pleroma/web/templates/o_auth/mfa/totp.html.eex @@ -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> diff --git a/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex new file mode 100644 index 000000000..adc3a3e3d --- /dev/null +++ b/lib/pleroma/web/templates/twitter_api/remote_follow/follow_mfa.html.eex @@ -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 %> diff --git a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex index 89da760da..521dc9322 100644 --- a/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex +++ b/lib/pleroma/web/twitter_api/controllers/remote_follow_controller.ex @@ -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 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController 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 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController do      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 @@ defmodule Pleroma.Web.TwitterAPI.RemoteFollowController 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  | 
