separate account settings from shop settings
All checks were successful
deploy / deploy (push) Successful in 3m28s
All checks were successful
deploy / deploy (push) Successful in 3m28s
- Create dedicated /admin/account page for user account management - Move email, password, and 2FA settings from /admin/settings - Add Account link to top of admin sidebar navigation - Add TOTP-based two-factor authentication with NimbleTOTP - Add TOTP verification LiveView for login flow - Add AccountController for TOTP session management - Remove Advanced section from settings (duplicated in dev tools) - Remove user email from sidebar footer (replaced by Account link) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,9 +5,13 @@ defmodule Berrypod.Accounts do
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Berrypod.Repo
|
||||
alias Berrypod.Vault
|
||||
|
||||
alias Berrypod.Accounts.{User, UserToken, UserNotifier}
|
||||
|
||||
@totp_issuer "Berrypod"
|
||||
@backup_code_count 8
|
||||
|
||||
## Database getters
|
||||
|
||||
@doc """
|
||||
@@ -342,4 +346,147 @@ defmodule Berrypod.Accounts do
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
## TOTP 2FA
|
||||
|
||||
@doc """
|
||||
Generates a new TOTP secret for setup.
|
||||
|
||||
Returns `{secret, otpauth_uri}` where secret is the raw binary
|
||||
and otpauth_uri can be encoded as a QR code.
|
||||
"""
|
||||
def generate_totp_secret(user) do
|
||||
secret = NimbleTOTP.secret()
|
||||
uri = totp_uri(user, secret)
|
||||
{secret, uri}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates the otpauth URI for a given user and secret.
|
||||
|
||||
Used to regenerate the URI when restoring TOTP setup state from session.
|
||||
"""
|
||||
def totp_uri(user, secret) do
|
||||
NimbleTOTP.otpauth_uri("#{@totp_issuer}:#{user.email}", secret, issuer: @totp_issuer)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Enables TOTP for a user after verifying the code.
|
||||
|
||||
The secret should be the raw binary from `generate_totp_secret/1`.
|
||||
Returns `{:ok, user, backup_codes}` or `{:error, reason}`.
|
||||
"""
|
||||
def enable_totp(user, secret, code) do
|
||||
if valid_totp?(secret, code) do
|
||||
backup_codes = generate_backup_codes()
|
||||
hashed_codes = Enum.map(backup_codes, &Bcrypt.hash_pwd_salt/1)
|
||||
|
||||
{:ok, encrypted_secret} = Vault.encrypt(Base.encode32(secret))
|
||||
{:ok, encrypted_codes} = Vault.encrypt(Jason.encode!(hashed_codes))
|
||||
|
||||
changeset =
|
||||
user
|
||||
|> Ecto.Changeset.change(%{
|
||||
totp_secret_encrypted: encrypted_secret,
|
||||
totp_backup_codes_encrypted: encrypted_codes,
|
||||
totp_enabled_at: DateTime.utc_now(:second)
|
||||
})
|
||||
|
||||
case Repo.update(changeset) do
|
||||
{:ok, user} -> {:ok, user, backup_codes}
|
||||
{:error, changeset} -> {:error, changeset}
|
||||
end
|
||||
else
|
||||
{:error, :invalid_code}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Disables TOTP for a user.
|
||||
"""
|
||||
def disable_totp(user) do
|
||||
changeset =
|
||||
user
|
||||
|> Ecto.Changeset.change(%{
|
||||
totp_secret_encrypted: nil,
|
||||
totp_backup_codes_encrypted: nil,
|
||||
totp_enabled_at: nil
|
||||
})
|
||||
|
||||
Repo.update(changeset)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verifies a TOTP code for a user.
|
||||
|
||||
Also accepts backup codes, which are single-use.
|
||||
"""
|
||||
def verify_totp(user, code) do
|
||||
cond do
|
||||
valid_user_totp?(user, code) ->
|
||||
:ok
|
||||
|
||||
valid_backup_code?(user, code) ->
|
||||
consume_backup_code(user, code)
|
||||
:ok
|
||||
|
||||
true ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if the user has TOTP enabled.
|
||||
"""
|
||||
def totp_enabled?(user), do: User.totp_enabled?(user)
|
||||
|
||||
defp valid_totp?(secret, code) when is_binary(secret) and is_binary(code) do
|
||||
NimbleTOTP.valid?(secret, code)
|
||||
end
|
||||
|
||||
defp valid_totp?(_, _), do: false
|
||||
|
||||
defp valid_user_totp?(user, code) do
|
||||
with encrypted when not is_nil(encrypted) <- user.totp_secret_encrypted,
|
||||
{:ok, encoded} <- Vault.decrypt(encrypted),
|
||||
{:ok, secret} <- Base.decode32(encoded) do
|
||||
valid_totp?(secret, code)
|
||||
else
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp valid_backup_code?(user, code) do
|
||||
hashed_codes = get_hashed_backup_codes(user)
|
||||
Enum.any?(hashed_codes, &Bcrypt.verify_pass(code, &1))
|
||||
end
|
||||
|
||||
defp consume_backup_code(user, code) do
|
||||
hashed_codes = get_hashed_backup_codes(user)
|
||||
remaining = Enum.reject(hashed_codes, &Bcrypt.verify_pass(code, &1))
|
||||
|
||||
{:ok, encrypted} = Vault.encrypt(Jason.encode!(remaining))
|
||||
|
||||
user
|
||||
|> Ecto.Changeset.change(%{totp_backup_codes_encrypted: encrypted})
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
defp get_hashed_backup_codes(user) do
|
||||
with encrypted when not is_nil(encrypted) <- user.totp_backup_codes_encrypted,
|
||||
{:ok, json} <- Vault.decrypt(encrypted),
|
||||
{:ok, codes} <- Jason.decode(json) do
|
||||
codes
|
||||
else
|
||||
_ -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_backup_codes do
|
||||
for _ <- 1..@backup_code_count do
|
||||
:crypto.strong_rand_bytes(5)
|
||||
|> Base.encode32(padding: false)
|
||||
|> String.downcase()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,9 +11,20 @@ defmodule Berrypod.Accounts.User do
|
||||
field :confirmed_at, :utc_datetime
|
||||
field :authenticated_at, :utc_datetime, virtual: true
|
||||
|
||||
# TOTP 2FA fields (encrypted at rest)
|
||||
field :totp_secret_encrypted, :binary, redact: true
|
||||
field :totp_secret, :string, virtual: true, redact: true
|
||||
field :totp_enabled_at, :utc_datetime
|
||||
field :totp_backup_codes_encrypted, :binary, redact: true
|
||||
field :totp_backup_codes, {:array, :string}, virtual: true, redact: true
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc "Returns true if the user has 2FA enabled."
|
||||
def totp_enabled?(%__MODULE__{totp_enabled_at: nil}), do: false
|
||||
def totp_enabled?(%__MODULE__{totp_enabled_at: _}), do: true
|
||||
|
||||
@doc """
|
||||
A user changeset for registering or changing the email.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user