generated from nimblehq/git-template
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Showing
33 changed files
with
1,584 additions
and
43 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,163 @@ | ||
defmodule ElixirSearchExtractor.Accounts do | ||
@moduledoc """ | ||
The Accounts context. | ||
""" | ||
|
||
import Ecto.Query, warn: false | ||
alias ElixirSearchExtractor.Repo | ||
alias ElixirSearchExtractor.Accounts.{User, UserToken} | ||
|
||
## Database getters | ||
|
||
@doc """ | ||
Gets a user by email. | ||
## Examples | ||
iex> get_user_by_email("[email protected]") | ||
%User{} | ||
iex> get_user_by_email("[email protected]") | ||
nil | ||
""" | ||
def get_user_by_email(email) when is_binary(email) do | ||
Repo.get_by(User, email: email) | ||
end | ||
|
||
@doc """ | ||
Gets a user by email and password. | ||
## Examples | ||
iex> get_user_by_email_and_password("[email protected]", "correct_password") | ||
%User{} | ||
iex> get_user_by_email_and_password("[email protected]", "invalid_password") | ||
nil | ||
""" | ||
def get_user_by_email_and_password(email, password) | ||
when is_binary(email) and is_binary(password) do | ||
user = Repo.get_by(User, email: email) | ||
if User.valid_password?(user, password), do: user | ||
end | ||
|
||
@doc """ | ||
Gets a single user. | ||
Raises `Ecto.NoResultsError` if the User does not exist. | ||
## Examples | ||
iex> get_user!(123) | ||
%User{} | ||
iex> get_user!(456) | ||
** (Ecto.NoResultsError) | ||
""" | ||
def get_user!(id), do: Repo.get!(User, id) | ||
|
||
## User registration | ||
|
||
@doc """ | ||
Registers a user. | ||
## Examples | ||
iex> register_user(%{field: value}) | ||
{:ok, %User{}} | ||
iex> register_user(%{field: bad_value}) | ||
{:error, %Ecto.Changeset{}} | ||
""" | ||
def register_user(attrs) do | ||
%User{} | ||
|> User.registration_changeset(attrs) | ||
|> Repo.insert() | ||
end | ||
|
||
@doc """ | ||
Returns an `%Ecto.Changeset{}` for tracking user changes. | ||
## Examples | ||
iex> change_user_registration(user) | ||
%Ecto.Changeset{data: %User{}} | ||
""" | ||
def change_user_registration(%User{} = user, attrs \\ %{}) do | ||
User.registration_changeset(user, attrs, hash_password: false) | ||
end | ||
|
||
## Settings | ||
@doc """ | ||
Returns an `%Ecto.Changeset{}` for changing the user password. | ||
## Examples | ||
iex> change_user_password(user) | ||
%Ecto.Changeset{data: %User{}} | ||
""" | ||
def change_user_password(user, attrs \\ %{}) do | ||
User.password_changeset(user, attrs, hash_password: false) | ||
end | ||
|
||
@doc """ | ||
Updates the user password. | ||
## Examples | ||
iex> update_user_password(user, "valid password", %{password: ...}) | ||
{:ok, %User{}} | ||
iex> update_user_password(user, "invalid password", %{password: ...}) | ||
{:error, %Ecto.Changeset{}} | ||
""" | ||
def update_user_password(user, password, attrs) do | ||
changeset = | ||
user | ||
|> User.password_changeset(attrs) | ||
|> User.validate_current_password(password) | ||
|
||
Ecto.Multi.new() | ||
|> Ecto.Multi.update(:user, changeset) | ||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all)) | ||
|> Repo.transaction() | ||
|> case do | ||
{:ok, %{user: user}} -> {:ok, user} | ||
{:error, :user, changeset, _} -> {:error, changeset} | ||
end | ||
end | ||
|
||
## Session | ||
|
||
@doc """ | ||
Generates a session token. | ||
""" | ||
def generate_user_session_token(user) do | ||
{token, user_token} = UserToken.build_session_token(user) | ||
Repo.insert!(user_token) | ||
token | ||
end | ||
|
||
@doc """ | ||
Gets the user with the given signed token. | ||
""" | ||
def get_user_by_session_token(token) do | ||
{:ok, query} = UserToken.verify_session_token_query(token) | ||
Repo.one(query) | ||
end | ||
|
||
@doc """ | ||
Deletes the signed token with the given context. | ||
""" | ||
def delete_session_token(token) do | ||
Repo.delete_all(UserToken.token_and_context_query(token, "session")) | ||
:ok | ||
end | ||
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
defmodule ElixirSearchExtractor.Accounts.User do | ||
use Ecto.Schema | ||
import Ecto.Changeset | ||
|
||
@derive {Inspect, except: [:password]} | ||
schema "users" do | ||
field :name, :string | ||
field :email, :string | ||
field :password, :string, virtual: true | ||
field :hashed_password, :string | ||
field :confirmed_at, :naive_datetime | ||
|
||
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, [:email, :name, :password]) | ||
|> validate_confirmation(:password, message: "does not match password") | ||
|> validate_email() | ||
|> validate_name() | ||
|> validate_password(opts) | ||
end | ||
|
||
defp validate_email(changeset) do | ||
changeset | ||
|> validate_required([:email]) | ||
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") | ||
|> validate_length(:email, max: 80) | ||
|> unsafe_validate_unique(:email, ElixirSearchExtractor.Repo) | ||
|> unique_constraint(:email) | ||
end | ||
|
||
defp validate_name(changeset) do | ||
changeset | ||
|> validate_required([:name]) | ||
|> validate_length(:name, min: 5, max: 80) | ||
end | ||
|
||
defp validate_password(changeset, opts) do | ||
changeset | ||
|> validate_required([:password]) | ||
|> validate_length(:password, min: 6, max: 30) | ||
|> 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 | ||
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) | ||
|> delete_change(:password) | ||
else | ||
changeset | ||
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 """ | ||
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?( | ||
%ElixirSearchExtractor.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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
defmodule ElixirSearchExtractor.Accounts.UserToken do | ||
use Ecto.Schema | ||
import Ecto.Query | ||
|
||
@rand_size 32 | ||
|
||
@session_validity_in_days 60 | ||
|
||
schema "users_tokens" do | ||
field :token, :binary | ||
field :context, :string | ||
field :sent_to, :string | ||
belongs_to :user, ElixirSearchExtractor.Accounts.User | ||
|
||
timestamps(updated_at: false) | ||
end | ||
|
||
@doc """ | ||
Generates a token that will be stored in a signed place, | ||
such as session or cookie. As they are signed, those | ||
tokens do not need to be hashed. | ||
""" | ||
def build_session_token(user) do | ||
token = :crypto.strong_rand_bytes(@rand_size) | ||
|
||
{token, | ||
%ElixirSearchExtractor.Accounts.UserToken{token: token, context: "session", user_id: user.id}} | ||
end | ||
|
||
@doc """ | ||
Checks if the token is valid and returns its underlying lookup query. | ||
The query returns the user found by the token. | ||
""" | ||
def verify_session_token_query(token) do | ||
query = | ||
from token in token_and_context_query(token, "session"), | ||
join: user in assoc(token, :user), | ||
where: token.inserted_at > ago(@session_validity_in_days, "day"), | ||
select: user | ||
|
||
{:ok, query} | ||
end | ||
|
||
@doc """ | ||
Returns the given token with the given context. | ||
""" | ||
def token_and_context_query(token, context) do | ||
from ElixirSearchExtractor.Accounts.UserToken, where: [token: ^token, context: ^context] | ||
end | ||
|
||
@doc """ | ||
Gets all tokens for the given user for the given contexts. | ||
""" | ||
def user_and_contexts_query(user, :all) do | ||
from t in ElixirSearchExtractor.Accounts.UserToken, where: t.user_id == ^user.id | ||
end | ||
end |
Oops, something went wrong.