separate account settings from shop settings
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:
jamey
2026-03-08 18:42:29 +00:00
parent 0c2d4ac406
commit 32cc425458
21 changed files with 1396 additions and 308 deletions

View File

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

View File

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