defmodule Plausible.Auth.User do use Plausible use Ecto.Schema import Ecto.Changeset @type t() :: %__MODULE__{} @required [:email, :name, :password] on_ee do @derive {Plausible.Audit.Encoder, only: [ :id, :email, :name, :email_verified, :previous_email, :totp_enabled, :last_team_identifier, :sso_integration, :sso_domain ]} end schema "users" do field :email, :string field :password_hash field :old_password, :string, virtual: true field :password, :string, virtual: true field :password_confirmation, :string, virtual: true field :name, :string field :last_seen, :naive_datetime field :theme, Ecto.Enum, values: [:system, :light, :dark] field :email_verified, :boolean field :previous_email, :string # Field for purely informational purposes in CRM context field :notes, :string # Fields for TOTP authentication. See `Plausible.Auth.TOTP`. field :totp_enabled, :boolean, default: false field :totp_secret, Plausible.Auth.TOTP.EncryptedBinary field :totp_token, :string field :totp_last_used_at, :naive_datetime # for context perseverance across sessions field :last_team_identifier, Ecto.UUID on_ee do # Fields for SSO field :type, Ecto.Enum, values: [:standard, :sso] field :sso_identity_id, :string field :last_sso_login, :naive_datetime belongs_to :sso_integration, Plausible.Auth.SSO.Integration, on_replace: :nilify belongs_to :sso_domain, Plausible.Auth.SSO.Domain, on_replace: :nilify end has_many :sessions, Plausible.Auth.UserSession has_many :team_memberships, Plausible.Teams.Membership has_many :api_keys, Plausible.Auth.ApiKey has_one :google_auth, Plausible.Site.GoogleAuth has_many :owner_memberships, Plausible.Teams.Membership, where: [role: :owner] has_many :owned_teams, through: [:owner_memberships, :team] on_ce do # we only need this for backfill teams migration to work; let's figure out how to safely remove later on field :trial_expiry_date, :date end timestamps() end def new(attrs \\ %{}) do %Plausible.Auth.User{} |> cast(attrs, @required) |> validate_required(@required) |> validate_password_length() |> validate_confirmation(:password, required: true) |> validate_password_strength() |> hash_password() |> set_email_verification_status() |> unique_constraint(:email) end def name_changeset(user, attrs \\ %{}) do user |> cast(attrs, [:name]) |> validate_required([:name]) end def theme_changeset(user, attrs \\ %{}) do user |> cast(attrs, [:theme]) |> validate_required([:theme]) end def settings_changeset(user, attrs \\ %{}) do user |> cast(attrs, [:email, :name, :theme]) |> validate_required([:email, :name, :theme]) |> unique_constraint(:email) end def email_changeset(user, attrs \\ %{}) do user |> cast(attrs, [:email, :password]) |> validate_required([:email, :password]) |> validate_email_changed() |> check_password() |> unique_constraint(:email) |> set_email_verification_status() |> put_change(:previous_email, user.email) end def cancel_email_changeset(user) do if user.previous_email do user |> change() |> unique_constraint(:email) |> put_change(:email_verified, true) |> put_change(:email, user.previous_email) |> put_change(:previous_email, nil) else # It shouldn't happen under normal circumstances raise "Previous email is empty for user #{user.id} (#{user.email}) when it shouldn't." end end def changeset(user, attrs \\ %{}) do user |> cast(attrs, [:email, :name, :email_verified, :theme, :notes]) |> validate_required([:email, :name, :email_verified]) |> unique_constraint(:email) end def set_password(user, password) do user |> cast(%{password: password}, [:password]) |> validate_required([:password]) |> validate_password_length() |> validate_password_strength() |> hash_password() end def password_changeset(user, params \\ %{}) do user |> cast(params, [:old_password, :password]) |> check_password(:old_password) |> validate_required([:old_password, :password]) |> validate_password_length() |> validate_confirmation(:password, required: true) |> validate_password_strength() |> validate_password_changed() |> hash_password() end def hash_password(%{errors: [], changes: changes} = changeset) do hash = Plausible.Auth.Password.hash(changes[:password]) change(changeset, password_hash: hash) end def hash_password(changeset), do: changeset def password_strength(changeset) do case get_field(changeset, :password) do nil -> %{suggestions: [], warning: "", score: 0} # Passwords past (approximately) 32 characters are treated # as strong, despite what they contain, to avoid unnecessarily # expensive computation. password when byte_size(password) > 32 -> %{suggestions: [], warning: "", score: 4} password -> existing_phrases = [] |> maybe_add_phrase(get_field(changeset, :name)) |> maybe_add_phrase(get_field(changeset, :email)) case ZXCVBN.zxcvbn(password, existing_phrases) do %{score: score, feedback: feedback} -> %{suggestions: feedback.suggestions, warning: feedback.warning, score: score} :error -> %{suggestions: [], warning: "", score: 3} end end catch _kind, _value -> %{suggestions: [], warning: "", score: 3} end def profile_img_url(%__MODULE__{email: email}) do hash = email |> String.trim() |> String.downcase() |> :erlang.md5() |> Base.encode16(case: :lower) Path.join(PlausibleWeb.Endpoint.url(), ["avatar/", hash]) end def profile_img_url(email) when is_binary(email) do profile_img_url(%__MODULE__{email: email}) end defp validate_email_changed(changeset) do if !get_change(changeset, :email) && !changeset.errors[:email] do add_error(changeset, :email, "can't be the same", validation: :different_email) else changeset end end defp validate_password_changed(changeset) do old_password = get_change(changeset, :old_password) new_password = get_change(changeset, :password) if old_password == new_password do add_error(changeset, :password, "is too weak", validation: :different_password) else changeset end end defp check_password(changeset, field \\ :password) do if password = get_change(changeset, field) do if Plausible.Auth.Password.match?(password, changeset.data.password_hash) do changeset else add_error(changeset, field, "is invalid", validation: :check_password) end else changeset end end defp validate_password_length(changeset) do changeset |> validate_length(:password, min: 12, message: "has to be at least 12 characters") |> validate_length(:password, max: 128, message: "cannot be longer than 128 characters") end defp validate_password_strength(changeset) do if get_change(changeset, :password) != nil and password_strength(changeset).score <= 2 do add_error(changeset, :password, "is too weak", validation: :strength) else changeset end end defp maybe_add_phrase(phrases, nil), do: phrases defp maybe_add_phrase(phrases, phrase) do parts = String.split(phrase) [phrase, parts] |> List.flatten(phrases) |> Enum.uniq() end defp set_email_verification_status(user) do on_ee do change(user, email_verified: false) else selfhosted_config = Application.get_env(:plausible, :selfhost) must_verify? = Keyword.fetch!(selfhosted_config, :enable_email_verification) change(user, email_verified: not must_verify?) end end end defimpl Bamboo.Formatter, for: Plausible.Auth.User do def format_email_address(user, _opts) do {user.name, user.email} end end defimpl FunWithFlags.Actor, for: Plausible.Auth.User do def id(%{id: id}) do "user:#{id}" end end