Current section
Files
Jump to
Current section
Files
lib/ecto/user_token.ex
defmodule AuthN.Ecto.UserToken do
defmacro __using__(opts) do
user_schema = Keyword.fetch!(opts, :user_schema) |> Macro.expand(__ENV__)
ecto_schema = Keyword.get(opts, :ecto_schema, Ecto.Schema) |> Macro.expand(__ENV__)
schema_name = Keyword.get(opts, :schema_name, "users_tokens")
hash_algorithm = Keyword.get(opts, :hash_algorithm, :sha256)
rand_size = Keyword.get(opts, :rand_size, 32)
reset_password_validity_in_days = Keyword.get(opts, :reset_password_validity_in_days, 1)
confirm_validity_in_days = Keyword.get(opts, :confirm_validity_in_days, 7)
change_email_validity_in_days = Keyword.get(opts, :change_email_validity_in_days, 7)
session_validity_in_days = Keyword.get(opts, :session_validity_in_days, 60)
quote do
@doc """
Verifies the password.
Returns the given user if valid,
If there is no user or the user doesn't have a password, we call
`Argon2.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Argon2.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Argon2.no_user_verify()
false
end
defmodule unquote(Module.concat([user_schema, Token])) do
use unquote(ecto_schema)
import Ecto.Query
schema unquote(schema_name) do
field :token, :binary
field :context, :string
field :sent_to, :string
belongs_to :user, unquote(user_schema)
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(unquote(rand_size))
{token, %__MODULE__{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(unquote(session_validity_in_days), "day"),
select: user
{:ok, query}
end
@doc """
Builds a token with a hashed counter part.
The non-hashed token is sent to the user e-mail while the
hashed part is stored in the database, to avoid reconstruction.
The token is valid for a week as long as users don't change
their email.
"""
def build_user_email_token(user, context) do
build_hashed_token(user, context, user.email)
end
defp build_hashed_token(user, context, sent_to) do
token = :crypto.strong_rand_bytes(unquote(rand_size))
hashed_token = :crypto.hash(unquote(hash_algorithm), token)
{Base.url_encode64(token, padding: false),
%__MODULE__{
token: hashed_token,
context: context,
sent_to: sent_to,
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_user_email_token_query(token, context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(unquote(hash_algorithm), decoded_token)
days = days_for_context(context)
query =
from token in token_and_context_query(hashed_token, context),
join: user in assoc(token, :user),
where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
select: user
{:ok, query}
:error ->
:error
end
end
defp days_for_context("confirm"), do: unquote(confirm_validity_in_days)
defp days_for_context("reset_password"), do: unquote(reset_password_validity_in_days)
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token.
"""
def verify_user_change_email_token_query(token, context) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(unquote(hash_algorithm), decoded_token)
query =
from token in token_and_context_query(hashed_token, context),
where: token.inserted_at > ago(unquote(change_email_validity_in_days), "day")
{:ok, query}
:error ->
:error
end
end
@doc """
Returns the given token with the given context.
"""
def token_and_context_query(token, context) do
from __MODULE__, 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 __MODULE__, where: t.user_id == ^user.id
end
def user_and_contexts_query(user, [_ | _] = contexts) do
from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts
end
end
end
end
end