76 lines
2 KiB
Elixir
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
|