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