Skip to content

Commit

Permalink
Release - 0.2.0
Browse files Browse the repository at this point in the history
Merge pull request #32 from rafayet-monon/release/0.2.0
  • Loading branch information
rafayet-monon authored Jun 14, 2021
2 parents 30894c8 + 33599aa commit 79802bb
Show file tree
Hide file tree
Showing 33 changed files with 1,584 additions and 43 deletions.
2 changes: 1 addition & 1 deletion .credo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@
order:
~w/shortdoc moduledoc behaviour use import alias require module_attribute defstruct callback/a
]},
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
{Credo.Check.Consistency.MultiAliasImportRequireUse, false},
{Credo.Check.Consistency.UnusedVariableNames, false},
{Credo.Check.Design.DuplicatedCode, []},
{Credo.Check.Readability.AliasAs, false},
Expand Down
3 changes: 3 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use Mix.Config

# Only in tests, remove the complexity from the password hashing algorithm
config :bcrypt_elixir, :log_rounds, 1

# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
Expand Down
163 changes: 163 additions & 0 deletions lib/elixir_search_extractor/accounts.ex
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
125 changes: 125 additions & 0 deletions lib/elixir_search_extractor/accounts/user.ex
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
58 changes: 58 additions & 0 deletions lib/elixir_search_extractor/accounts/user_token.ex
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
Loading

0 comments on commit 79802bb

Please sign in to comment.