From 2055742911201258e6f755b3eb4031a1b09407f1 Mon Sep 17 00:00:00 2001 From: Logan Hunt Date: Wed, 6 Apr 2022 12:13:54 -0600 Subject: Initial commit; generate auth code with phx.gen.auth; added room model and association; generate room model on domain of user emails; allow users to change their email --- lib/aggiedit/accounts/user.ex | 157 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 lib/aggiedit/accounts/user.ex (limited to 'lib/aggiedit/accounts/user.ex') diff --git a/lib/aggiedit/accounts/user.ex b/lib/aggiedit/accounts/user.ex new file mode 100644 index 0000000..aeb37ed --- /dev/null +++ b/lib/aggiedit/accounts/user.ex @@ -0,0 +1,157 @@ +defmodule Aggiedit.Accounts.User do + use Ecto.Schema + import Ecto.Changeset + + alias Aggiedit.Rooms.Room + + schema "users" do + field :email, :string + field :username, :string + field :password, :string, virtual: true, redact: true + field :hashed_password, :string, redact: true + field :confirmed_at, :naive_datetime + field :role, Ecto.Enum, values: [:user, :admin], default: :user + + belongs_to :room, Room, on_replace: :update + + timestamps() + end + + @doc """ + A user changeset for registration. + + It is important to validate the length of both email and password. + Otherwise databases may truncate the email without warnings, which + could lead to unpredictable or insecure behaviour. Long passwords may + also be very expensive to hash for certain algorithms. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def registration_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:username, :email, :password]) + |> validate_username() + |> validate_email() + |> validate_password(opts) + end + + defp validate_username(changeset) do + changeset + |> validate_required([:username]) + |> validate_format(:username, ~r/^[a-z0-9_\-]*$/, message: "only lowercase letters, numbers, underscores, and hyphens allowed") + |> unique_constraint(:username) + end + + defp validate_email(changeset) do + changeset + |> validate_required([:email]) + |> validate_format(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/, message: "must have the @ sign, no spaces, and a domain") + |> validate_length(:email, max: 160) + |> unsafe_validate_unique(:email, Aggiedit.Repo) + |> unique_constraint(:email) + end + + defp validate_password(changeset, opts) do + changeset + |> validate_required([:password]) + |> validate_length(:password, min: 8, max: 72) + # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") + # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") + # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") + |> maybe_hash_password(opts) + end + + defp maybe_hash_password(changeset, opts) do + hash_password? = Keyword.get(opts, :hash_password, true) + password = get_change(changeset, :password) + + if hash_password? && password && changeset.valid? do + changeset + # If using Bcrypt, then further validate it is at most 72 bytes long + |> validate_length(:password, max: 72, count: :bytes) + |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) + |> delete_change(:password) + else + changeset + end + end + + @doc """ + A user changeset for changing the email. It requires the email to change otherwise an error is added. + """ + def email_changeset(user, attrs) do + user + |> cast(attrs, [:email]) + |> validate_email() + |> case do + %{changes: %{email: _}} = changeset -> changeset + %{} = changeset -> add_error(changeset, :email, "did not change") + end + end + + @doc """ + A user changeset for changing the password. + + ## Options + + * `:hash_password` - Hashes the password so it can be stored securely + in the database and ensures the password field is cleared to prevent + leaks in the logs. If password hashing is not needed and clearing the + password field is not desired (like when using this changeset for + validations on a LiveView form), this option can be set to `false`. + Defaults to `true`. + """ + def password_changeset(user, attrs, opts \\ []) do + user + |> cast(attrs, [:password]) + |> validate_confirmation(:password, message: "does not match password") + |> validate_password(opts) + end + + @doc """ + Confirms the account by setting `confirmed_at`. + """ + def confirm_changeset(user) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + change(user, confirmed_at: now) + end + + def room_changeset(user, room) do + change(user) + |> cast(%{:room_id => room.id}, [:room_id]) + end + + @doc """ + Verifies the password. + + If there is no user or the user doesn't have a password, we call + `Bcrypt.no_user_verify/0` to avoid timing attacks. + """ + def valid_password?(%Aggiedit.Accounts.User{hashed_password: hashed_password}, password) + when is_binary(hashed_password) and byte_size(password) > 0 do + Bcrypt.verify_pass(password, hashed_password) + end + + def valid_password?(_, _) do + Bcrypt.no_user_verify() + false + end + + @doc """ + Validates the current password otherwise adds an error to the changeset. + """ + def validate_current_password(changeset, password) do + if valid_password?(changeset.data, password) do + changeset + else + add_error(changeset, :current_password, "is not valid") + end + end +end -- cgit v1.2.3-70-g09d2