From 65cc8980e092e003ffc42e82219ccd4546d25e64 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 20 Nov 2023 14:04:48 +0100 Subject: [PATCH] Implement core logic for TOTP support (#3525) * Add `nimble_totp`, `cloak` and `cloak_ecto` to project dependencies * Setup Cloak-based secrets vault and create a dedicated Ecto type * Add `totp_enabled|secret|last_used_at` fields to `User` schema * Implement schema and stateless logic for TOTP recovery codes * Implement core logic of TOTP auth * Fix typos and improve style of doc comments Co-authored-by: hq1 * Fix moduledoc alignment * Use more compact conditional expression Co-authored-by: hq1 * Disambiguate `I` as `7` when generating recovery codes (h/t @hq1) * Fix a typo in runtime config error message --------- Co-authored-by: hq1 --- config/.env.dev | 1 + config/.env.test | 1 + config/runtime.exs | 16 + lib/plausible/application.ex | 8 + lib/plausible/auth/totp.ex | 282 +++++++++++++++ lib/plausible/auth/totp/encrypted_binary.ex | 7 + lib/plausible/auth/totp/recovery_code.ex | 79 +++++ lib/plausible/auth/totp/vault.ex | 19 ++ lib/plausible/auth/user.ex | 6 + mix.exs | 3 + mix.lock | 3 + .../auth/totp/recovery_code_test.exs | 87 +++++ test/plausible/auth/totp/vault_test.exs | 30 ++ test/plausible/auth/totp_test.exs | 321 ++++++++++++++++++ 14 files changed, 863 insertions(+) create mode 100644 lib/plausible/auth/totp.ex create mode 100644 lib/plausible/auth/totp/encrypted_binary.ex create mode 100644 lib/plausible/auth/totp/recovery_code.ex create mode 100644 lib/plausible/auth/totp/vault.ex create mode 100644 test/plausible/auth/totp/recovery_code_test.exs create mode 100644 test/plausible/auth/totp/vault_test.exs create mode 100644 test/plausible/auth/totp_test.exs diff --git a/config/.env.dev b/config/.env.dev index 575b699749..64e3239e25 100644 --- a/config/.env.dev +++ b/config/.env.dev @@ -3,6 +3,7 @@ SECURE_COOKIE=false DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/plausible_dev CLICKHOUSE_DATABASE_URL=http://127.0.0.1:8123/plausible_events_db SECRET_KEY_BASE=/njrhntbycvastyvtk1zycwfm981vpo/0xrvwjjvemdakc/vsvbrevlwsc6u8rcg +TOTP_VAULT_KEY=Q3BD4nddbkVJIPXgHuo5NthGKSIH0yesRfG05J88HIo= ENVIRONMENT=dev MAILER_ADAPTER=Bamboo.LocalAdapter LOG_LEVEL=debug diff --git a/config/.env.test b/config/.env.test index cf4f019e84..050d6f8799 100644 --- a/config/.env.test +++ b/config/.env.test @@ -1,6 +1,7 @@ DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/plausible_test?pool_size=40 CLICKHOUSE_DATABASE_URL=http://127.0.0.1:8123/plausible_test SECRET_KEY_BASE=/njrhntbycvastyvtk1zycwfm981vpo/0xrvwjjvemdakc/vsvbrevlwsc6u8rcg +TOTP_VAULT_KEY=1Jah1HEOnCEnmBE+4/OgbJRraJIppPmYCNbZoFJboZs= BASE_URL=http://localhost:8000 CRON_ENABLED=false LOG_LEVEL=warning diff --git a/config/runtime.exs b/config/runtime.exs index 3c10b42377..ef71eb492a 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -145,6 +145,20 @@ ch_db_url = |> get_var_from_path_or_env("CLICKHOUSE_MAX_BUFFER_SIZE", "10000") |> Integer.parse() +# Can be generated with `Base.encode64(:crypto.strong_rand_bytes(32))` from +# iex shell or `openssl rand -base64 32` from command line. +totp_vault_key = get_var_from_path_or_env(config_dir, "TOTP_VAULT_KEY", nil) + +case totp_vault_key do + nil -> + raise "TOTP_VAULT_KEY configuration option is required. See https://plausible.io/docs/self-hosting-configuration#server" + + key -> + if byte_size(Base.decode64!(key)) != 32 do + raise "TOTP_VAULT_KEY must exactly 32 bytes long. See https://plausible.io/docs/self-hosting-configuration#server" + end +end + ### Mandatory params End build_metadata_raw = get_var_from_path_or_env(config_dir, "BUILD_METADATA", "{}") @@ -177,6 +191,8 @@ runtime_metadata = [ config :plausible, :runtime_metadata, runtime_metadata +config :plausible, Plausible.Auth.TOTP, vault_key: totp_vault_key + sentry_dsn = get_var_from_path_or_env(config_dir, "SENTRY_DSN") honeycomb_api_key = get_var_from_path_or_env(config_dir, "HONEYCOMB_API_KEY") honeycomb_dataset = get_var_from_path_or_env(config_dir, "HONEYCOMB_DATASET") diff --git a/lib/plausible/application.ex b/lib/plausible/application.ex index 1ad972dd95..b5a91296fb 100644 --- a/lib/plausible/application.ex +++ b/lib/plausible/application.ex @@ -28,6 +28,7 @@ defmodule Plausible.Application do {Plausible.Site.Cache, []}, {Plausible.Site.Cache.Warmer.All, []}, {Plausible.Site.Cache.Warmer.RecentlyUpdated, []}, + {Plausible.Auth.TOTP.Vault, key: totp_vault_key()}, PlausibleWeb.Endpoint, {Oban, Application.get_env(:plausible, Oban)}, Plausible.PromEx @@ -50,6 +51,13 @@ defmodule Plausible.Application do :ok end + defp totp_vault_key() do + :plausible + |> Application.fetch_env!(Plausible.Auth.TOTP) + |> Keyword.fetch!(:vault_key) + |> Base.decode64!() + end + defp finch_pool_config() do base_config = %{ "https://icons.duckduckgo.com" => [ diff --git a/lib/plausible/auth/totp.ex b/lib/plausible/auth/totp.ex new file mode 100644 index 0000000000..a6a64b88d8 --- /dev/null +++ b/lib/plausible/auth/totp.ex @@ -0,0 +1,282 @@ +defmodule Plausible.Auth.TOTP do + @moduledoc """ + TOTP auth context + + Handles all the aspects of TOTP setup, management and validation for users. + + ## Setup + + TOTP setup is started with `initiate/1`. At this stage, a random secret + binary is generated for user and stored under `User.totp_secret`. The secret + is additionally encrypted while stored in the database using `Cloak`. The + vault for safe storage is configured in `Plausible.Auth.TOTP.Vault` via + a dedicated `Ecto` type defined in `Plausible.Auth.TOTP.EncryptedBinary`. + The function returns updated user along with TOTP URI and a readable form + of secret. Both - the URI and readable secret - are meant for exposure + in the user's setup screen. The URI should be encoded as a QR code. + + After initiation, user is expected to confirm valid setup with `enable/2`, + providing TOTP code from their authenticator app. After code validation + passes successfully, the `User.totp_enabled` flag is set to `true`. + + Finally, the user must be immediately presented with a list of recovery codes + generated with `generate_recovery_codes/1`. The codes should be presented + in copy/paste friendly form, ideally also with a print-friendly view option. + The function can be run more than once, giving the user ability to regenerate + codes from the final stage of setup if needed. + + The `initiate/1` and `enable/1` functions can be safely called multiple + times, allowing user to abort and restart setup up to these stages. + + ## Management + + State of TOTP for a particular user can be chcecked by calling `enabled?/1`. + + TOTP can be disabled with `disable/2`. User is expected to provide their + current password for safety. Once disabled, all TOTP user settings are + cleared and any remaining generated recovery codes are removed. The function + can be safely run more than once. + + If the user needs to regenerate the recovery codes outside of setup procedure, + they must do it via `generate_recovery_codes_protected/2`, providing + their current password for safety. They must be warned that any existing + recovery codes will be invalidated. + + ## Validation + + After logging in, user's TOTP state must be checked with `enabled?/1`. + + If enabled, user must be presented with TOTP code input form accepting + 6 digit characters. The code must be checked using `validate_code/2`. + + User must have an option to alternatively input one of their recovery + codes. Those codes must be checked with `use_recovery_code/2`. + + ## Code validity + + In case of TOTP codes, a grace period of 30 seconds is applied, which + allows user to use their current and previous TOTP code, assuming 30 + second validity window of each. This allows user to use code that was + about to expire before the submission. Regardless of that, each TOTP + code can be used only once. Validation procedure rejects repeat use + of the same code for safety. It's done by tracking last time a TOTP + code was used successfully, stored under `User.totp_last_used_at`. + + In case of recovery codes, each code is deleted immediately after use. + They are strictly one-time use only. + + """ + + import Ecto.Changeset, only: [change: 2] + import Ecto.Query, only: [from: 2] + + alias Plausible.Auth + alias Plausible.Auth.TOTP + alias Plausible.Repo + + @issuer_name "Plausible Analytics" + @recovery_codes_count 10 + + @spec enabled?(Auth.User.t()) :: boolean() + def enabled?(user) do + user.totp_enabled and not is_nil(user.totp_secret) + end + + @spec initiate(Auth.User.t()) :: + {:ok, Auth.User.t(), %{totp_uri: String.t(), secret: String.t()}} + | {:error, :not_verified | :already_setup} + def initiate(%{email_verified: false}) do + {:error, :not_verified} + end + + def initiate(%{totp_enabled: true}) do + {:error, :already_setup} + end + + def initiate(user) do + secret = NimbleTOTP.secret() + + user = + user + |> change( + totp_enabled: false, + totp_secret: secret + ) + |> Repo.update!() + + {:ok, user, %{totp_uri: totp_uri(user), secret: readable_secret(user)}} + end + + @spec enable(Auth.User.t(), String.t(), Keyword.t()) :: + {:ok, Auth.User.t()} | {:error, :invalid_code | :not_initiated} + def enable(user, code, opts \\ []) + + def enable(%{totp_secret: nil}, _, _) do + {:error, :not_initiated} + end + + def enable(user, code, opts) do + with {:ok, user} <- do_validate_code(user, code, opts) do + user = + user + |> change(totp_enabled: true) + |> Repo.update!() + + {:ok, user} + end + end + + @spec disable(Auth.User.t(), String.t()) :: {:ok, Auth.User.t()} | {:error, :invalid_password} + def disable(user, password) do + if Auth.Password.match?(password, user.password_hash) do + Repo.transaction(fn -> + {_, _} = + user + |> recovery_codes_query() + |> Repo.delete_all() + + user + |> change( + totp_enabled: false, + totp_secret: nil, + totp_last_used_at: nil + ) + |> Repo.update!() + end) + else + {:error, :invalid_password} + end + end + + @spec generate_recovery_codes_protected(Auth.User.t(), String.t()) :: + {:ok, [String.t()]} | {:error, :invalid_password | :not_enabled} + def generate_recovery_codes_protected(%{totp_enabled: false}) do + {:error, :not_enabled} + end + + def generate_recovery_codes_protected(user, password) do + if Auth.Password.match?(password, user.password_hash) do + generate_recovery_codes(user) + else + {:error, :invalid_password} + end + end + + @spec generate_recovery_codes(Auth.User.t()) :: {:ok, [String.t()]} | {:error, :not_enabled} + def generate_recovery_codes(%{totp_enabled: false}) do + {:error, :not_enabled} + end + + def generate_recovery_codes(user) do + Repo.transaction(fn -> + {_, _} = + user + |> recovery_codes_query() + |> Repo.delete_all() + + plain_codes = TOTP.RecoveryCode.generate_codes(@recovery_codes_count) + + now = + NaiveDateTime.utc_now() + |> NaiveDateTime.truncate(:second) + + codes = + plain_codes + |> Enum.map(fn plain_code -> + user + |> TOTP.RecoveryCode.changeset(plain_code) + |> TOTP.RecoveryCode.changeset_to_map(now) + end) + + {_, _} = Repo.insert_all(TOTP.RecoveryCode, codes) + + plain_codes + end) + end + + @spec validate_code(Auth.User.t(), String.t()) :: + {:ok, Auth.User.t(), Keyword.t()} | {:error, :invalid_code | :not_enabled} + def validate_code(user, code, opts \\ []) + + def validate_code(%{totp_enabled: false}, _, _) do + {:error, :not_enabled} + end + + def validate_code(user, code, opts) do + do_validate_code(user, code, opts) + end + + @spec use_recovery_code(Auth.User.t(), String.t()) :: + :ok | {:error, :invalid_code | :not_enabled} + def user_recovery_code(%{totp_enabled: false}, _) do + {:error, :not_enabled} + end + + def use_recovery_code(user, code) do + matching_code = + user + |> recovery_codes_query() + |> Repo.all() + |> Enum.find(&TOTP.RecoveryCode.match?(&1, code)) + + if matching_code do + Repo.delete!(matching_code) + :ok + else + {:error, :invalid_code} + end + end + + defp totp_uri(user) do + NimbleTOTP.otpauth_uri("#{@issuer_name}:#{user.email}", user.totp_secret, + issuer: @issuer_name + ) + end + + defp readable_secret(user) do + Base.encode32(user.totp_secret, padding: false) + end + + defp recovery_codes_query(user) do + from(rc in TOTP.RecoveryCode, where: rc.user_id == ^user.id) + end + + defp do_validate_code(user, code, opts) do + # Necessary because we must be sure the timestamp is current. + # User struct stored in liveview context on mount might be + # pretty out of date, for instance. + last_used = + if Keyword.get(opts, :allow_reuse?) do + nil + else + fetch_last_used(user) + end + + time = System.os_time(:second) + + if NimbleTOTP.valid?(user.totp_secret, code, since: last_used, time: time) or + NimbleTOTP.valid?(user.totp_secret, code, since: last_used, time: time - 30) do + {:ok, bump_last_used!(user)} + else + {:error, :invalid_code} + end + end + + defp fetch_last_used(user) do + datetime = + from(u in Plausible.Auth.User, where: u.id == ^user.id, select: u.totp_last_used_at) + |> Repo.one() + + if datetime do + Timex.to_unix(datetime) + end + end + + defp bump_last_used!(user) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + user + |> change(totp_last_used_at: now) + |> Repo.update!() + end +end diff --git a/lib/plausible/auth/totp/encrypted_binary.ex b/lib/plausible/auth/totp/encrypted_binary.ex new file mode 100644 index 0000000000..6683fab1e3 --- /dev/null +++ b/lib/plausible/auth/totp/encrypted_binary.ex @@ -0,0 +1,7 @@ +defmodule Plausible.Auth.TOTP.EncryptedBinary do + @moduledoc """ + Defines an Ecto type so Cloak.Ecto can encrypt/decrypt a binary field. + """ + + use Cloak.Ecto.Binary, vault: Plausible.Auth.TOTP.Vault +end diff --git a/lib/plausible/auth/totp/recovery_code.ex b/lib/plausible/auth/totp/recovery_code.ex new file mode 100644 index 0000000000..0065c6825d --- /dev/null +++ b/lib/plausible/auth/totp/recovery_code.ex @@ -0,0 +1,79 @@ +defmodule Plausible.Auth.TOTP.RecoveryCode do + @moduledoc """ + Schema for TOTP recovery codes. + """ + + use Ecto.Schema + + alias Plausible.Auth + + import Ecto.Changeset + + @type t :: %__MODULE__{} + + @code_length 10 + + schema "totp_recovery_codes" do + field :code_digest, :string + + belongs_to :user, Plausible.Auth.User + + timestamps(updated_at: false) + end + + @doc """ + Generates `count` unique recovery codes, each alphanumeric + and #{@code_length} characters long. + """ + @spec generate_codes(non_neg_integer()) :: [String.t()] + def generate_codes(count) do + Stream.repeatedly(&generate_code/0) + |> Stream.map(&disambiguate/1) + |> Stream.uniq() + |> Enum.take(count) + end + + @spec match?(t(), String.t()) :: boolean() + def match?(recovery_code, input_code) do + Bcrypt.verify_pass(input_code, recovery_code.code_digest) + end + + @spec changeset(Auth.User.t(), String.t()) :: Ecto.Changeset.t() + def changeset(user, code) do + %__MODULE__{} + |> change() + |> put_assoc(:user, user) + |> put_change(:code_digest, hash(code)) + end + + @spec changeset_to_map(Ecto.Changeset.t(), NaiveDateTime.t()) :: map() + def changeset_to_map(changeset, now) do + changeset + |> apply_changes() + |> Map.take([:user_id, :code_digest]) + |> Map.put(:inserted_at, now) + end + + @safe_disambiguations %{ + "O" => "8", + "I" => "7" + } + + @doc false + # Exposed for testing only + def disambiguate(code) do + String.replace( + code, + Map.keys(@safe_disambiguations), + &Map.fetch!(@safe_disambiguations, &1) + ) + end + + defp generate_code() do + Base.encode32(:crypto.strong_rand_bytes(6), padding: false) + end + + defp hash(code) when byte_size(code) == @code_length do + Bcrypt.hash_pwd_salt(code) + end +end diff --git a/lib/plausible/auth/totp/vault.ex b/lib/plausible/auth/totp/vault.ex new file mode 100644 index 0000000000..cb4862d7de --- /dev/null +++ b/lib/plausible/auth/totp/vault.ex @@ -0,0 +1,19 @@ +defmodule Plausible.Auth.TOTP.Vault do + @moduledoc """ + Provides a vault that will be used to encrypt/decrypt the TOTP secrets of users who enable it. + """ + + use Cloak.Vault, otp_app: :plausible + + @impl GenServer + def init(config) do + {key, config} = Keyword.pop!(config, :key) + + config = + Keyword.put(config, :ciphers, + default: {Cloak.Ciphers.AES.GCM, tag: "AES.GCM.V1", iv_length: 12, key: key} + ) + + {:ok, config} + end +end diff --git a/lib/plausible/auth/user.ex b/lib/plausible/auth/user.ex index 99a94e7c74..6980da2a0d 100644 --- a/lib/plausible/auth/user.ex +++ b/lib/plausible/auth/user.ex @@ -29,6 +29,12 @@ defmodule Plausible.Auth.User do field :theme, Ecto.Enum, values: [:system, :light, :dark] field :email_verified, :boolean field :previous_email, :string + + # Fields for TOTP authentication. See `Plausible.Auth.TOTP`. + field :totp_enabled, :boolean, default: false + field :totp_secret, Plausible.Auth.TOTP.EncryptedBinary + field :totp_last_used_at, :naive_datetime + embeds_one :grace_period, Plausible.Auth.GracePeriod, on_replace: :update has_many :site_memberships, Plausible.Site.Membership diff --git a/mix.exs b/mix.exs index 85e69019bb..c4b558a0b9 100644 --- a/mix.exs +++ b/mix.exs @@ -68,6 +68,8 @@ defmodule Plausible.MixProject do {:bypass, "~> 2.1", only: [:dev, :test, :small_test]}, {:cachex, "~> 3.4"}, {:ecto_ch, "~> 0.1.10"}, + {:cloak, "~> 1.1"}, + {:cloak_ecto, "~> 1.2"}, {:combination, "~> 0.0.3"}, {:connection, "~> 1.1", override: true}, {:cors_plug, "~> 3.0"}, @@ -94,6 +96,7 @@ defmodule Plausible.MixProject do {:location, git: "https://github.com/plausible/location.git"}, {:mox, "~> 1.0", only: [:test, :small_test]}, {:nanoid, "~> 2.0.2"}, + {:nimble_totp, "~> 1.0"}, {:oauther, "~> 1.3"}, {:oban, "~> 2.12.0"}, {:observer_cli, "~> 1.7"}, diff --git a/mix.lock b/mix.lock index 59363c97b1..d2621cecad 100644 --- a/mix.lock +++ b/mix.lock @@ -13,6 +13,8 @@ "ch": {:hex, :ch, "0.1.14", "c53489b66eeb83dca63e63155c3e0de74f99ba30a15e90d0cd6b38db86be5891", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "306357bd9a92662713b6b9b4244eb94e1ef93ececa3906dbbef7392c4001c8ef"}, "chatterbox": {:hex, :ts_chatterbox, "0.13.0", "6f059d97bcaa758b8ea6fffe2b3b81362bd06b639d3ea2bb088335511d691ebf", [:rebar3], [{:hpack, "~> 0.2.3", [hex: :hpack_erl, repo: "hexpm", optional: false]}], "hexpm", "b93d19104d86af0b3f2566c4cba2a57d2e06d103728246ba1ac6c3c0ff010aa7"}, "cldr_utils": {:hex, :cldr_utils, "2.24.1", "5ff8c8c55f96666228827bcf85a23d632022def200566346545d01d15e4c30dc", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "1820300531b5b849d0bc468e5a87cd64f8f2c5191916f548cbe69b2efc203780"}, + "cloak": {:hex, :cloak, "1.1.2", "7e0006c2b0b98d976d4f559080fabefd81f0e0a50a3c4b621f85ceeb563e80bb", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "940d5ac4fcd51b252930fd112e319ea5ae6ab540b722f3ca60a85666759b9585"}, + "cloak_ecto": {:hex, :cloak_ecto, "1.2.0", "e86a3df3bf0dc8980f70406bcb0af2858bac247d55494d40bc58a152590bd402", [:mix], [{:cloak, "~> 1.1.1", [hex: :cloak, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "8bcc677185c813fe64b786618bd6689b1707b35cd95acaae0834557b15a0c62f"}, "combination": {:hex, :combination, "0.0.3", "746aedca63d833293ec6e835aa1f34974868829b1486b1e1cb0685f0b2ae1f41", [:mix], [], "hexpm", "72b099f463df42ef7dc6371d250c7070b57b6c5902853f69deb894f79eda18ca"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"}, @@ -86,6 +88,7 @@ "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, + "nimble_totp": {:hex, :nimble_totp, "1.0.0", "79753bae6ce59fd7cacdb21501a1dbac249e53a51c4cd22b34fa8438ee067283", [:mix], [], "hexpm", "6ce5e4c068feecdb782e85b18237f86f66541523e6bad123e02ee1adbe48eda9"}, "oauther": {:hex, :oauther, "1.3.0", "82b399607f0ca9d01c640438b34d74ebd9e4acd716508f868e864537ecdb1f76", [:mix], [], "hexpm", "78eb888ea875c72ca27b0864a6f550bc6ee84f2eeca37b093d3d833fbcaec04e"}, "oban": {:hex, :oban, "2.12.1", "f604d7e6a8be9fda4a9b0f6cebbd633deba569f85dbff70c4d25d99a6f023177", [:mix], [{:ecto_sql, "~> 3.6", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "9b1844c2b74e0d788b73e5144b0c9d5674cb775eae29d88a36f3c3b48d42d058"}, "observer_cli": {:hex, :observer_cli, "1.7.3", "25d094d485f47239f218b53df0691a102fef13071dfd0d04922b5142297cfc93", [:mix, :rebar3], [{:recon, "~>2.5.1", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "a41b6d3e11a3444e063e09cc225f7f3e631ce14019e5fbcaebfda89b1bd788ea"}, diff --git a/test/plausible/auth/totp/recovery_code_test.exs b/test/plausible/auth/totp/recovery_code_test.exs new file mode 100644 index 0000000000..d3cbb4ee0f --- /dev/null +++ b/test/plausible/auth/totp/recovery_code_test.exs @@ -0,0 +1,87 @@ +defmodule Plausible.Auth.TOTP.RecoveryCodeTest do + use Plausible.DataCase, async: true + + alias Plausible.Auth.TOTP.RecoveryCode + + describe "generate_codes/1" do + test "generates random codes conforming agreed upon format" do + codes = RecoveryCode.generate_codes(3) + + Enum.each(codes, fn code -> + assert Regex.match?(~r/[A-Z0-9]{10}/, code) + end) + + assert codes == Enum.uniq(codes) + end + end + + describe "match?/1" do + test "verifies that provided code matches against a digest of stored recovery code" do + [plain_code] = RecoveryCode.generate_codes(1) + + recovery_code = + build(:user) + |> RecoveryCode.changeset(plain_code) + |> Ecto.Changeset.apply_changes() + + assert RecoveryCode.match?(recovery_code, plain_code) + refute RecoveryCode.match?(recovery_code, "INVALID") + end + end + + describe "changeset/2" do + test "builds a valid changeset when provided valid code format" do + [plain_code] = RecoveryCode.generate_codes(1) + + changeset = RecoveryCode.changeset(build(:user), plain_code) + + assert changeset.valid? + assert changeset.changes.user + assert changeset.changes.code_digest + + assert RecoveryCode.match?(Ecto.Changeset.apply_changes(changeset), plain_code) + end + + test "crashes when code in invalid format is passed" do + user = build(:user) + + assert_raise FunctionClauseError, fn -> + RecoveryCode.changeset(user, "INVALID") + end + + assert_raise FunctionClauseError, fn -> + RecoveryCode.changeset(user, 123) + end + end + end + + describe "changeset_to_map/2" do + test "converts changeset to a map suitable for Repo.insert_all/3" do + user = %{id: user_id} = insert(:user) + [plain_code] = RecoveryCode.generate_codes(1) + + changeset = + %{changes: %{code_digest: code_digest}} = RecoveryCode.changeset(user, plain_code) + + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + assert %{ + user_id: ^user_id, + code_digest: ^code_digest, + inserted_at: ^now + } = RecoveryCode.changeset_to_map(changeset, now) + end + end + + describe "disambiguate/1" do + test "disambiguates strings with hard to discern letters" do + assert RecoveryCode.disambiguate("ABDIZL12") == "ABD7ZL12" + assert RecoveryCode.disambiguate("ABDIZLO12") == "ABD7ZL812" + assert RecoveryCode.disambiguate("AOBDIZLO12I") == "A8BD7ZL8127" + end + + test "leaves strings that have no sunch letters intact" do + assert RecoveryCode.disambiguate("N0D0UBT") == "N0D0UBT" + end + end +end diff --git a/test/plausible/auth/totp/vault_test.exs b/test/plausible/auth/totp/vault_test.exs new file mode 100644 index 0000000000..4647ff21fc --- /dev/null +++ b/test/plausible/auth/totp/vault_test.exs @@ -0,0 +1,30 @@ +defmodule Plausible.Auth.TOTP.VaultTest do + use Plausible.DataCase, async: true + + alias Plausible.Auth.TOTP.Vault + + describe "encrypting secrets" do + test "encryption works" do + plain_secret = "super secret" + encrypted_secret = Vault.encrypt!(plain_secret) + decrypted_secret = Vault.decrypt!(encrypted_secret) + + assert encrypted_secret != plain_secret + assert decrypted_secret == plain_secret + end + + test "TOTP secret is stored encrypted and decrypted on read" do + secret = NimbleTOTP.secret() + + user = insert(:user, totp_secret: secret) + user = Repo.reload!(user) + + assert user.totp_secret == secret + + {:ok, %{rows: [[totp_secret_in_db]]}} = + Repo.query("SELECT totp_secret from users where id = $1", [user.id]) + + assert totp_secret_in_db != secret + end + end +end diff --git a/test/plausible/auth/totp_test.exs b/test/plausible/auth/totp_test.exs new file mode 100644 index 0000000000..64059418ef --- /dev/null +++ b/test/plausible/auth/totp_test.exs @@ -0,0 +1,321 @@ +defmodule Plausible.Auth.TOTPTest do + use Plausible.DataCase, async: true + + alias Plausible.Auth.TOTP + alias Plausible.Auth.TOTP.RecoveryCode + + alias Plausible.Repo + + describe "enabled?/1" do + test "Returns user's TOTP state" do + assert TOTP.enabled?(insert(:user, totp_enabled: true, totp_secret: "secret")) + refute TOTP.enabled?(insert(:user, totp_enabled: false, totp_secret: nil)) + # these shouldn't happen under normal circumstances but we do check + # totp_secret presence just to be safe and avoid undefined behavior + refute TOTP.enabled?(insert(:user, totp_enabled: false, totp_secret: "secret")) + refute TOTP.enabled?(insert(:user, totp_enabled: true, totp_secret: nil)) + end + end + + describe "initiate/1" do + test "initiates TOTP setup for user" do + user = insert(:user) + + assert {:ok, updated_user, params} = TOTP.initiate(user) + + assert updated_user.id == user.id + refute updated_user.totp_enabled + assert byte_size(updated_user.totp_secret) > 0 + + assert Regex.match?(~r/[0-9A-Z]+/, params.secret) + assert String.starts_with?(params.totp_uri, "otpauth://totp") + end + + test "reinitiates setup for user with unfinished TOTP setup" do + user = insert(:user) + {:ok, user, params} = TOTP.initiate(user) + + assert {:ok, updated_user, new_params} = TOTP.initiate(user) + + assert new_params.totp_uri != params.totp_uri + assert new_params.secret != params.secret + + refute updated_user.totp_enabled + assert byte_size(updated_user.totp_secret) > 0 + assert updated_user.totp_secret != user.totp_secret + end + + test "does not initiate setup for user with TOTP already enabled" do + user = insert(:user) + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + {:ok, user} = TOTP.enable(user, code) + + assert TOTP.initiate(user) == {:error, :already_setup} + end + + test "does not initiate setup for user with unverified email" do + user = insert(:user, email_verified: false) + + assert TOTP.initiate(user) == {:error, :not_verified} + end + end + + describe "enable/2" do + test "finishes setting up TOTP for user" do + user = insert(:user) + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + + assert {:ok, user} = TOTP.enable(user, code) + + assert user.totp_enabled + assert byte_size(user.totp_secret) > 0 + end + + test "succeeds for user who has TOTP enabled already" do + user = insert(:user) + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret, time: System.os_time(:second) - 30) + {:ok, user} = TOTP.enable(user, code) + + assert {:ok, updated_user} = TOTP.enable(user, code, allow_reuse?: true) + + assert updated_user.id == user.id + assert updated_user.totp_enabled + assert updated_user.totp_secret == user.totp_secret + end + + test "fails when TOTP setup is not initiated" do + user = insert(:user) + + assert {:error, :not_initiated} = TOTP.enable(user, "123456") + end + + test "fails when invalid code is provided" do + user = insert(:user) + {:ok, user, _} = TOTP.initiate(user) + + assert {:error, :invalid_code} = TOTP.enable(user, "1234") + end + end + + describe "disable/2" do + test "disables TOTP for user who has it enabled" do + user = insert(:user, password: "VeryStrongVerySecret") + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + {:ok, user} = TOTP.enable(user, code) + + assert {:ok, updated_user} = TOTP.disable(user, "VeryStrongVerySecret") + + assert updated_user.id == user.id + refute updated_user.totp_enabled + assert is_nil(updated_user.totp_secret) + + assert Repo.all(RecoveryCode) == [] + end + + test "succeeds for user who does not have TOTP enabled" do + user = insert(:user, password: "VeryStrongVerySecret") + + assert {:ok, updated_user} = TOTP.disable(user, "VeryStrongVerySecret") + + assert updated_user.id == user.id + refute updated_user.totp_enabled + assert is_nil(updated_user.totp_secret) + end + + test "fails when invalid password is provided" do + user = insert(:user, password: "VeryStrongVerySecret") + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + {:ok, user} = TOTP.enable(user, code) + + assert {:error, :invalid_password} = TOTP.disable(user, "invalid") + end + end + + describe "generate_recovery_codes/1" do + test "generates recovery codes for user with enabled TOTP" do + user = insert(:user) + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + {:ok, user} = TOTP.enable(user, code) + + assert {:ok, codes} = TOTP.generate_recovery_codes(user) + + persisted_codes = Repo.all(RecoveryCode) + + assert length(codes) == 10 + assert length(persisted_codes) == 10 + + Enum.each(persisted_codes, fn recovery_code -> + assert recovery_code.user_id == user.id + assert byte_size(recovery_code.code_digest) > 0 + end) + + Enum.each(codes, fn code -> + assert byte_size(code) > 0 + assert :ok = TOTP.use_recovery_code(user, code) + end) + end + + test "regenerates recovery codes when generated already" do + user = insert(:user) + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + {:ok, user} = TOTP.enable(user, code) + + assert {:ok, [code | codes]} = TOTP.generate_recovery_codes(user) + assert :ok = TOTP.use_recovery_code(user, code) + + assert {:ok, new_codes} = TOTP.generate_recovery_codes(user) + + assert Enum.uniq(codes ++ new_codes) == codes ++ new_codes + + assert length(new_codes) == 10 + + Enum.each(new_codes, fn code -> + assert byte_size(code) > 0 + assert :ok = TOTP.use_recovery_code(user, code) + end) + end + + test "fails when user has TOTP disabled" do + user = insert(:user) + + assert {:error, :not_enabled} = TOTP.generate_recovery_codes(user) + end + end + + describe "generate_recovery_codes_protected/1" do + test "generates recovery codes for user with enabled TOTP" do + user = insert(:user, password: "VeryStrongVerySecret") + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + {:ok, user} = TOTP.enable(user, code) + + assert {:ok, codes} = TOTP.generate_recovery_codes_protected(user, "VeryStrongVerySecret") + + persisted_codes = Repo.all(RecoveryCode) + + assert length(codes) == 10 + assert length(persisted_codes) == 10 + + Enum.each(persisted_codes, fn recovery_code -> + assert recovery_code.user_id == user.id + assert byte_size(recovery_code.code_digest) > 0 + end) + + Enum.each(codes, fn code -> + assert byte_size(code) > 0 + assert :ok = TOTP.use_recovery_code(user, code) + end) + end + + test "fails when user has TOTP disabled" do + user = insert(:user, password: "VeryStrongVerySecret") + + assert {:error, :not_enabled} = + TOTP.generate_recovery_codes_protected(user, "VeryStrongVerySecret") + end + + test "fails when invalid password provided" do + user = insert(:user, password: "VeryStrongVerySecret") + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + {:ok, user} = TOTP.enable(user, code) + + assert {:error, :invalid_password} = TOTP.generate_recovery_codes_protected(user, "invalid") + end + end + + describe "validate_code/2" do + test "succeeds when valid code provided and respects grace period" do + user = insert(:user) + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret, time: System.os_time(:second) - 30) + {:ok, user} = TOTP.enable(user, code) + new_code = NimbleTOTP.verification_code(user.totp_secret) + + # making sure that generated OTP codes are different + assert code != new_code + + assert {:ok, user} = TOTP.validate_code(user, code, allow_reuse?: true) + + assert_in_delta Timex.to_unix(user.totp_last_used_at), System.os_time(:second), 2 + end + + test "fails when trying to reuse the same code twice" do + user = insert(:user) + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret, time: System.os_time(:second) - 30) + {:ok, user} = TOTP.enable(user, code) + + assert {:error, :invalid_code} = TOTP.validate_code(user, code) + end + + test "fails when invalid code provided" do + user = insert(:user) + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + {:ok, user} = TOTP.enable(user, code) + + assert {:error, :invalid_code} = TOTP.validate_code(user, "1234") + end + + test "fails when user has TOTP initiated but not enabled" do + user = insert(:user) + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + + assert {:error, :not_enabled} = TOTP.validate_code(user, code) + end + end + + describe "use_recovery_code/2" do + test "succeeds when valid recovery code provided but fails when trying to reuse it" do + user = insert(:user) + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + {:ok, user} = TOTP.enable(user, code) + {:ok, [code | codes]} = TOTP.generate_recovery_codes(user) + + assert :ok = TOTP.use_recovery_code(user, code) + assert {:error, :invalid_code} = TOTP.use_recovery_code(user, code) + + assert length(Repo.all(RecoveryCode)) == length(codes) + end + + test "fails when provided code is invalid" do + user = insert(:user) + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + {:ok, user} = TOTP.enable(user, code) + {:ok, _} = TOTP.generate_recovery_codes(user) + + assert {:error, :invalid_code} = TOTP.use_recovery_code(user, "INVALID") + end + + test "fails when there are no recovery codes to check against" do + user = insert(:user) + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + {:ok, user} = TOTP.enable(user, code) + + assert {:error, :invalid_code} = TOTP.use_recovery_code(user, "INVALID") + end + + test "fails when user has TOTP disabled even though provided code is valid" do + user = insert(:user, password: "VeryStrongVerySecret") + {:ok, user, _} = TOTP.initiate(user) + code = NimbleTOTP.verification_code(user.totp_secret) + {:ok, user} = TOTP.enable(user, code) + {:ok, [code | _]} = TOTP.generate_recovery_codes(user) + {:ok, user} = TOTP.disable(user, "VeryStrongVerySecret") + + assert {:error, :not_enabled} = TOTP.user_recovery_code(user, code) + end + end +end