summaryrefslogtreecommitdiff
path: root/lib/pleroma/mfa
diff options
context:
space:
mode:
Diffstat (limited to 'lib/pleroma/mfa')
-rw-r--r--lib/pleroma/mfa/backup_codes.ex31
-rw-r--r--lib/pleroma/mfa/changeset.ex64
-rw-r--r--lib/pleroma/mfa/settings.ex24
-rw-r--r--lib/pleroma/mfa/token.ex106
-rw-r--r--lib/pleroma/mfa/totp.ex86
5 files changed, 311 insertions, 0 deletions
diff --git a/lib/pleroma/mfa/backup_codes.ex b/lib/pleroma/mfa/backup_codes.ex
new file mode 100644
index 000000000..9875310ff
--- /dev/null
+++ b/lib/pleroma/mfa/backup_codes.ex
@@ -0,0 +1,31 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 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..77c4fa202
--- /dev/null
+++ b/lib/pleroma/mfa/changeset.ex
@@ -0,0 +1,64 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 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..de6e2228f
--- /dev/null
+++ b/lib/pleroma/mfa/settings.ex
@@ -0,0 +1,24 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 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..0b2449971
--- /dev/null
+++ b/lib/pleroma/mfa/token.ex
@@ -0,0 +1,106 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 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..d2ea2b3aa
--- /dev/null
+++ b/lib/pleroma/mfa/totp.ex
@@ -0,0 +1,86 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2020 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