mirror of
https://github.com/plausible/analytics.git
synced 2024-11-22 10:43:38 +03:00
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:
parent
819449f19a
commit
65cc8980e0
@ -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
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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
282
lib/plausible/auth/totp.ex
Normal 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
|
7
lib/plausible/auth/totp/encrypted_binary.ex
Normal file
7
lib/plausible/auth/totp/encrypted_binary.ex
Normal 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
|
79
lib/plausible/auth/totp/recovery_code.ex
Normal file
79
lib/plausible/auth/totp/recovery_code.ex
Normal 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
|
19
lib/plausible/auth/totp/vault.ex
Normal file
19
lib/plausible/auth/totp/vault.ex
Normal 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
|
@ -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
|
||||
|
3
mix.exs
3
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"},
|
||||
|
3
mix.lock
3
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"},
|
||||
|
87
test/plausible/auth/totp/recovery_code_test.exs
Normal file
87
test/plausible/auth/totp/recovery_code_test.exs
Normal 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
|
30
test/plausible/auth/totp/vault_test.exs
Normal file
30
test/plausible/auth/totp/vault_test.exs
Normal 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
|
321
test/plausible/auth/totp_test.exs
Normal file
321
test/plausible/auth/totp_test.exs
Normal 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
|
Loading…
Reference in New Issue
Block a user