287 lines
8.2 KiB
Elixir
287 lines
8.2 KiB
Elixir
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
|