towerops/lib/towerops/accounts/user_recovery_code.ex

76 lines
2 KiB
Elixir

defmodule Towerops.Accounts.UserRecoveryCode do
@moduledoc """
Schema for user recovery codes (backup codes for account recovery).
Recovery codes are single-use backup codes that can be used to access
an account if all TOTP devices are lost. Codes are stored as SHA-256 hashes.
"""
use Ecto.Schema
import Ecto.Changeset
alias Towerops.Accounts.User
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "user_recovery_codes" do
field :code_hash, :string
field :used_at, :utc_datetime
field :created_at, :utc_datetime
belongs_to :user, User
timestamps(type: :utc_datetime, updated_at: false)
end
@doc """
Generates a cryptographically secure 8-character recovery code.
Format: XXXX-XXXX (uppercase letters and numbers, no ambiguous chars).
Uses base32 alphabet without 0, O, I, 1 to avoid confusion.
"""
def generate_code do
# Use base32 alphabet without ambiguous characters (0, O, I, 1)
alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
for_result =
for _ <- 1..8 do
Enum.random(String.graphemes(alphabet))
end
code = Enum.join(for_result)
# Format: XXXX-XXXX
String.slice(code, 0..3) <> "-" <> String.slice(code, 4..7)
end
@doc """
Hashes a recovery code using SHA-256.
## Examples
iex> hash_code("ABCD-EFGH")
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
"""
def hash_code(code) when is_binary(code) do
:sha256 |> :crypto.hash(code) |> Base.encode16(case: :lower)
end
@doc """
Changeset for creating a recovery code.
"""
def changeset(recovery_code, attrs) do
recovery_code
|> cast(attrs, [:code_hash, :user_id, :created_at])
|> validate_required([:code_hash, :user_id, :created_at])
|> unique_constraint(:code_hash)
|> foreign_key_constraint(:user_id)
end
@doc """
Changeset for marking a recovery code as used.
"""
def use_changeset(recovery_code) do
change(recovery_code, used_at: DateTime.utc_now(:second))
end
end