daily-tracker

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

user_token.ex (5390B)


      1 defmodule DailyTracker.Accounts.UserToken do
      2   use Ecto.Schema
      3   import Ecto.Query
      4   alias DailyTracker.Accounts.UserToken
      5 
      6   @hash_algorithm :sha256
      7   @rand_size 32
      8 
      9   # It is very important to keep the magic link token expiry short,
     10   # since someone with access to the email may take over the account.
     11   @magic_link_validity_in_minutes 15
     12   @change_email_validity_in_days 7
     13   @session_validity_in_days 14
     14 
     15   schema "users_tokens" do
     16     field :token, :binary
     17     field :context, :string
     18     field :sent_to, :string
     19     field :authenticated_at, :utc_datetime
     20     belongs_to :user, DailyTracker.Accounts.User
     21 
     22     timestamps(type: :utc_datetime, updated_at: false)
     23   end
     24 
     25   @doc """
     26   Generates a token that will be stored in a signed place,
     27   such as session or cookie. As they are signed, those
     28   tokens do not need to be hashed.
     29 
     30   The reason why we store session tokens in the database, even
     31   though Phoenix already provides a session cookie, is because
     32   Phoenix's default session cookies are not persisted, they are
     33   simply signed and potentially encrypted. This means they are
     34   valid indefinitely, unless you change the signing/encryption
     35   salt.
     36 
     37   Therefore, storing them allows individual user
     38   sessions to be expired. The token system can also be extended
     39   to store additional data, such as the device used for logging in.
     40   You could then use this information to display all valid sessions
     41   and devices in the UI and allow users to explicitly expire any
     42   session they deem invalid.
     43   """
     44   def build_session_token(user) do
     45     token = :crypto.strong_rand_bytes(@rand_size)
     46     dt = user.authenticated_at || DateTime.utc_now(:second)
     47     {token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}}
     48   end
     49 
     50   @doc """
     51   Checks if the token is valid and returns its underlying lookup query.
     52 
     53   The query returns the user found by the token, if any, along with the token's creation time.
     54 
     55   The token is valid if it matches the value in the database and it has
     56   not expired (after @session_validity_in_days).
     57   """
     58   def verify_session_token_query(token) do
     59     query =
     60       from token in by_token_and_context_query(token, "session"),
     61         join: user in assoc(token, :user),
     62         where: token.inserted_at > ago(@session_validity_in_days, "day"),
     63         select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}
     64 
     65     {:ok, query}
     66   end
     67 
     68   @doc """
     69   Builds a token and its hash to be delivered to the user's email.
     70 
     71   The non-hashed token is sent to the user email while the
     72   hashed part is stored in the database. The original token cannot be reconstructed,
     73   which means anyone with read-only access to the database cannot directly use
     74   the token in the application to gain access. Furthermore, if the user changes
     75   their email in the system, the tokens sent to the previous email are no longer
     76   valid.
     77 
     78   Users can easily adapt the existing code to provide other types of delivery methods,
     79   for example, by phone numbers.
     80   """
     81   def build_email_token(user, context) do
     82     build_hashed_token(user, context, user.email)
     83   end
     84 
     85   defp build_hashed_token(user, context, sent_to) do
     86     token = :crypto.strong_rand_bytes(@rand_size)
     87     hashed_token = :crypto.hash(@hash_algorithm, token)
     88 
     89     {Base.url_encode64(token, padding: false),
     90      %UserToken{
     91        token: hashed_token,
     92        context: context,
     93        sent_to: sent_to,
     94        user_id: user.id
     95      }}
     96   end
     97 
     98   @doc """
     99   Checks if the token is valid and returns its underlying lookup query.
    100 
    101   If found, the query returns a tuple of the form `{user, token}`.
    102 
    103   The given token is valid if it matches its hashed counterpart in the
    104   database. This function also checks whether the token has expired. The context
    105   of a magic link token is always "login".
    106   """
    107   def verify_magic_link_token_query(token) do
    108     case Base.url_decode64(token, padding: false) do
    109       {:ok, decoded_token} ->
    110         hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
    111 
    112         query =
    113           from token in by_token_and_context_query(hashed_token, "login"),
    114             join: user in assoc(token, :user),
    115             where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
    116             where: token.sent_to == user.email,
    117             select: {user, token}
    118 
    119         {:ok, query}
    120 
    121       :error ->
    122         :error
    123     end
    124   end
    125 
    126   @doc """
    127   Checks if the token is valid and returns its underlying lookup query.
    128 
    129   The query returns the user_token found by the token, if any.
    130 
    131   This is used to validate requests to change the user
    132   email.
    133   The given token is valid if it matches its hashed counterpart in the
    134   database and if it has not expired (after @change_email_validity_in_days).
    135   The context must always start with "change:".
    136   """
    137   def verify_change_email_token_query(token, "change:" <> _ = context) do
    138     case Base.url_decode64(token, padding: false) do
    139       {:ok, decoded_token} ->
    140         hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
    141 
    142         query =
    143           from token in by_token_and_context_query(hashed_token, context),
    144             where: token.inserted_at > ago(@change_email_validity_in_days, "day")
    145 
    146         {:ok, query}
    147 
    148       :error ->
    149         :error
    150     end
    151   end
    152 
    153   defp by_token_and_context_query(token, context) do
    154     from UserToken, where: [token: ^token, context: ^context]
    155   end
    156 end