Packages
yipyip_ex_auth
0.1.0-alpha.2
YipyipExAuth has been retired and replaced by Charon. Check it out at https://github.com/weareyipyip/charon or https://hexdocs.pm/charon
Current section
Files
Jump to
Current section
Files
lib/plugs.ex
defmodule YipyipExAuth.Plugs do
@moduledoc """
Function plugs to create and delete sessions. `create_session/3` can be used in combination with
`YipyipExAuth.Plugs.ProcessRefreshToken` for token refreshing.
"""
alias Plug.Conn
alias Phoenix.Token
require Logger
use YipyipExAuth.Utils.Constants
alias YipyipExAuth.{Config, Utils}
alias YipyipExAuth.Models.{Session, Tokens}
@doc """
Create or update a session. If a session exists in the conn, the session is updated, otherwise a new one is created.
The session is put on the conn by `YipyipExAuth.Plugs.ProcessRefreshToken`.
In both cases, new access / refresh tokens are created and stored in the conn's private map.
The server-side session stored in the session store is created / updated as well.
The tokens' signatures are split off and sent as cookies if the session's token signature
transport mechanism is set to `:cookie`. By default, these are http-only strictly-same-site secure cookies.
Optionally, it is possible to store extra payload in the access- and refresh tokens, which can be used to
implement things like role-based authorization or forced logout after password change.
"""
@spec create_session(Conn.t(), pos_integer | binary, Config.t(), any, any) :: Conn.t()
def create_session(
conn,
user_id,
%Config{
refresh_token_ttl: max_refresh_ttl,
session_store_module: session_store,
session_ttl: session_ttl,
access_token_ttl: max_access_ttl,
access_token_salt: access_salt,
refresh_token_salt: refresh_salt,
access_token_key_digest: access_digest,
refresh_token_key_digest: refresh_digest,
access_cookie_name: access_cookie_name,
refresh_cookie_name: refresh_cookie_name,
access_cookie_opts: access_cookie_opts,
refresh_cookie_opts: refresh_cookie_opts
},
extra_access_payload \\ nil,
extra_refresh_payload \\ nil
) do
now = System.system_time(:second)
refresh_token_opts = [key_digest: refresh_digest, signed_at: now]
access_token_opts = [key_digest: access_digest, signed_at: now]
# the refresh token id is renewed every time so that refresh tokens are single-use only
rtid = random_id()
# update the existing session (as set by &refresh/2) or create a new one
session = %{
(Utils.get_session(conn) || new_session(conn, session_ttl, now, user_id))
| refresh_token_id: rtid,
refreshed_at: now,
last_known_ip: conn.remote_ip |> :inet.ntoa() |> to_string()
}
# create access and refresh tokens and put them on the conn
tst = session.token_signature_transport
a_payload = %{
uid: user_id,
sid: session.id,
tst: tst,
exp: session.expires_at,
epl: extra_access_payload
}
r_payload = %{
id: rtid,
uid: user_id,
sid: session.id,
tst: tst,
exp: session.expires_at,
epl: extra_refresh_payload
}
refresh_token = Token.sign(conn, refresh_salt, r_payload, refresh_token_opts)
refresh_ttl = calc_ttl(session, now, max_refresh_ttl)
access_token = Token.sign(conn, access_salt, a_payload, access_token_opts)
access_ttl = calc_ttl(session, now, max_access_ttl)
session_store.upsert(session, refresh_ttl)
tokens = %Tokens{
access_token: access_token,
access_token_exp: now + access_ttl,
refresh_token: refresh_token,
refresh_token_exp: now + refresh_ttl
}
Logger.debug(fn ->
operation = if session.created_at == now, do: "CREATED", else: "REFRESHED"
"#{operation} session #{session.id}: #{inspect(session)}"
end)
conn
|> transport_tokens(
tst,
tokens,
access_ttl,
refresh_ttl,
access_cookie_opts,
access_cookie_name,
refresh_cookie_opts,
refresh_cookie_name
)
|> Conn.put_private(@private_session_key, session)
|> Conn.put_private(@private_access_token_payload_key, a_payload)
|> Conn.put_private(@private_refresh_token_payload_key, r_payload)
end
@doc """
Delete the persistent session identified by the session_id in the access token payload.
Note that the access token remains valid until it expires, it is left up to the client to drop
the access token. It will no longer be possible to refresh the session, however.
"""
@spec delete_session(Conn.t(), Config.t()) :: Conn.t()
def delete_session(conn, %Config{
session_store_module: session_store,
access_cookie_name: access_cookie_name,
refresh_cookie_name: refresh_cookie_name,
access_cookie_opts: access_cookie_opts,
refresh_cookie_opts: refresh_cookie_opts
}) do
case conn.private[@private_access_token_payload_key] do
%{sid: session_id, uid: user_id} -> session_store.delete(session_id, user_id)
_ -> :ok
end
conn
|> Conn.delete_resp_cookie(refresh_cookie_name, refresh_cookie_opts)
|> Conn.delete_resp_cookie(access_cookie_name, access_cookie_opts)
end
############
# Privates #
############
defp calc_ttl(session, now, max_ttl)
defp calc_ttl(%{expires_at: nil}, _now, max_ttl), do: max_ttl
defp calc_ttl(%{expires_at: timestamp}, now, max_ttl), do: min(timestamp - now, max_ttl)
defp transport_tokens(conn, :bearer, tokens, _, _, _, _, _, _),
do: Conn.put_private(conn, @private_tokens_key, tokens)
defp transport_tokens(
conn,
:cookie,
tokens,
access_ttl,
refresh_ttl,
access_cookie_opts,
access_cookie_name,
refresh_cookie_opts,
refresh_cookie_name
) do
[at_header, at_payload, at_signature] = String.split(tokens.access_token, ".", parts: 3)
access_token = at_header <> "." <> at_payload
[rt_header, rt_payload, rt_signature] = String.split(tokens.refresh_token, ".", parts: 3)
refresh_token = rt_header <> "." <> rt_payload
tokens = %{tokens | access_token: access_token, refresh_token: refresh_token}
access_cookie_opts = Keyword.put(access_cookie_opts, :max_age, access_ttl)
refresh_cookie_opts = Keyword.put(refresh_cookie_opts, :max_age, refresh_ttl)
conn
|> Conn.put_private(@private_tokens_key, tokens)
|> Conn.put_resp_cookie(access_cookie_name, "." <> at_signature, access_cookie_opts)
|> Conn.put_resp_cookie(refresh_cookie_name, "." <> rt_signature, refresh_cookie_opts)
end
defp new_session(conn, session_ttl, timestamp, user_id) do
%Session{
created_at: timestamp,
id: random_id(),
user_id: user_id,
token_signature_transport: Map.fetch!(conn.private, @private_token_signature_transport_key),
expires_at:
case session_ttl do
ttl when is_integer(ttl) -> ttl + timestamp
_ -> nil
end
}
end
# generate random IDs of a specified bit length, default 128, as hex string
# 2^128 == 16^32 so 128 bits of randomness is equal to a UUID (actually slightly more)
defp random_id(bits \\ 128),
do: bits |> div(8) |> :crypto.strong_rand_bytes() |> Base.encode16()
end