defmodule Bones73k.Accounts.UserToken do use Ecto.Schema import Ecto.Query @hash_algorithm :sha256 @rand_size 32 # It is very important to keep the reset password token expiry short, # since someone with access to the email may take over the account. @reset_password_validity_in_days 1 @confirm_validity_in_days 7 @change_email_validity_in_days 7 @session_validity_in_days 60 @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "users_tokens" do field(:token, :binary) field(:context, :string) field(:sent_to, :string) belongs_to :user, Bones73k.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, %Bones73k.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 """ Builds a token with a hashed counter part. The non-hashed token is sent to the user email 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_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(@rand_size) hashed_token = :crypto.hash(@hash_algorithm, token) {Base.url_encode64(token, padding: false), %Bones73k.Accounts.UserToken{ 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_email_token_query(token, context) do case Base.url_decode64(token, padding: false) do {:ok, decoded_token} -> hashed_token = :crypto.hash(@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: @confirm_validity_in_days defp days_for_context("reset_password"), do: @reset_password_validity_in_days @doc """ Checks if the token is valid and returns its underlying lookup query. The query returns the user token record. """ def verify_change_email_token_query(token, context) do case Base.url_decode64(token, padding: false) do {:ok, decoded_token} -> hashed_token = :crypto.hash(@hash_algorithm, decoded_token) query = from(token in token_and_context_query(hashed_token, context), where: token.inserted_at > ago(@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(Bones73k.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 Bones73k.Accounts.UserToken, where: t.user_id == ^user.id) end def user_and_contexts_query(user, [_ | _] = contexts) do from(t in Bones73k.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts) end end