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 <hq@mtod.org>

* Fix moduledoc alignment

* Use more compact conditional expression

Co-authored-by: hq1 <hq@mtod.org>

* Disambiguate `I` as `7` when generating recovery codes (h/t @hq1)

* Fix a typo in runtime config error message

---------

Co-authored-by: hq1 <hq@mtod.org>
This commit is contained in:
Adrian Gruntkowski 2023-11-20 14:04:48 +01:00 committed by GitHub
parent 819449f19a
commit 65cc8980e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 863 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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" => [

282
lib/plausible/auth/totp.ex Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"},

View File

@ -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"},

View File

@ -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

View File

@ -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

View File

@ -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