Current section

Files

Jump to
charon lib charon session_store redis_store.ex
Raw

lib/charon/session_store/redis_store.ex

defmodule Charon.SessionStore.RedisStore do
@moduledoc """
A persistent session store based on Redis, which implements behaviour `Charon.SessionStore`.
In addition to the required callbacks, this store also provides `get_all/3` and `delete_all/3` (for a user) functions.
## Redis requirements
This module needs a Redis >= 6.2.0 instance.
## Config
Additional config is required for this module (see `Charon.SessionStore.RedisStore.Config`):
Charon.Config.from_enum(
...,
optional_modules: %{
Charon.SessionStore.RedisStore => %{
redix_module: MyApp.Redix,
key_prefix: "charon_"
}
}
)
The following options are supported:
- `:redix_module` (required). A module that implements a `command/1` and a `pipeline/1` function for Redis commands like Redix.
- `:key_prefix` (optional). A string prefix for the Redis keys that are sessions.
## Redix
This module depends on a correctly configured `Redix` module with `command/1` and `pipeline/1` functions. See https://hexdocs.pm/redix for instructions.
"""
@behaviour Charon.SessionStore.Behaviour
alias Charon.Config
alias Charon.Internal
alias Charon.Models.Session
import Charon.SessionStore.RedisStore.Config, only: [get_mod_config: 1]
require Logger
@multi ~W(MULTI)
@exec ~W(EXEC)
@impl true
def get(session_id, user_id, type, config) do
mod_conf = get_mod_config(config)
session_key = session_key(session_id, user_id, type, mod_conf)
["GET", session_key]
|> mod_conf.redix_module.command()
|> case do
{:ok, nil} -> nil
{:ok, serialized} -> Session.deserialize(serialized, config)
error -> error
end
end
@impl true
def upsert(
session = %{
id: sid,
user_id: uid,
type: type,
refreshed_at: now,
expires_at: s_exp,
refresh_expires_at: exp
},
config
) do
mod_conf = get_mod_config(config)
session_key = session_key(sid, uid, type, mod_conf)
set_key = set_key(uid, type, mod_conf)
exp_str = Integer.to_string(exp)
now = Integer.to_string(now)
# upsert the actual session as a separate key-value pair that expires when the refresh token expires
upsert_session_c = ["SET", session_key, Session.serialize(session), "EXAT", exp_str]
# add session key to user's session set, with exp timestamp as score (or update score)
upsert_set_c = ["ZADD", set_key, exp_str, session_key]
max_exp_c = get_max_exp_session_cmd(set_key)
prune_set_c = prune_session_set_cmd(set_key, now)
if s_exp == exp do
# s_exp == exp, we can't assume that this session has the highest refresh exp
# because this session's refresh_expires_at is reduced so that it is <= expires_at
[@multi, max_exp_c, upsert_session_c, upsert_set_c, prune_set_c, @exec]
|> mod_conf.redix_module.pipeline()
|> case do
{:ok, [_, _, _, _, _, [prev_max_exp_session, "OK", r3, r4]]}
when is_integer(r3) and is_integer(r4) ->
# prev_max_exp_session had the highest exp *before* this session was upserted
# update user session set ttl if there is no other session OR the new session's exp is the highest
{prev_exists?, prev_max_exp_str} = parse_zrange_withscores(prev_max_exp_session)
prev_max_exp = String.to_integer(prev_max_exp_str)
if not prev_exists? or exp > prev_max_exp, do: put_s_exp(set_key, exp_str, mod_conf)
:ok
error ->
redis_result_to_error(error)
end
else
# s_exp != exp; we can assume that this session has the highest refresh exp of all of the user's sessions
set_exp_c = put_s_exp_cmd(set_key, exp_str)
[@multi, upsert_session_c, upsert_set_c, set_exp_c, prune_set_c, @exec]
|> mod_conf.redix_module.pipeline()
|> case do
{:ok, [_, _, _, _, _, ["OK", r2, 1, r4]]} when is_integer(r2) and is_integer(r4) -> :ok
error -> redis_result_to_error(error)
end
end
end
@impl true
def delete(session_id, user_id, type, config) do
mod_conf = get_mod_config(config)
session_key = session_key(session_id, user_id, type, mod_conf)
set_key = set_key(user_id, type, mod_conf)
now = Internal.now() |> Integer.to_string()
delete_c = ["DEL", session_key]
delete_key_c = ["ZREM", set_key, session_key]
max_exp_c = get_max_exp_session_cmd(set_key)
prune_set_c = prune_session_set_cmd(set_key, now)
[@multi, max_exp_c, delete_c, delete_key_c, prune_set_c, max_exp_c, @exec]
|> mod_conf.redix_module.pipeline()
|> case do
{:ok, [_, _, _, _, _, _, [pre_max_exp_session, r2, r3, _, post_max_exp_session]]}
when is_integer(r2) and is_integer(r3) ->
# pre_max_exp_session had the highest exp *before* this session was deleted
{pre_exists?, pre_max_exp_str} = parse_zrange_withscores(pre_max_exp_session)
{post_exists?, post_max_exp_str} = parse_zrange_withscores(post_max_exp_session)
if pre_exists? and post_exists? and post_max_exp_str != pre_max_exp_str do
put_s_exp(set_key, post_max_exp_str, mod_conf)
end
:ok
error ->
redis_result_to_error(error)
end
end
@impl true
def get_all(user_id, type, config) do
mod_conf = get_mod_config(config)
with {:ok, keys = [_ | _]} <- get_valid_session_keys(user_id, type, mod_conf),
{:ok, values} <- mod_conf.redix_module.command(["MGET" | keys]) do
values |> Stream.reject(&is_nil/1) |> Enum.map(&Session.deserialize(&1, config))
else
{:ok, []} -> []
other -> other
end
end
@impl true
def delete_all(user_id, type, config) do
mod_conf = get_mod_config(config)
with {:ok, keys} <- get_session_keys(user_id, type, mod_conf),
to_delete = [set_key(user_id, type, mod_conf) | keys],
{:ok, n} when is_integer(n) <- ["DEL" | to_delete] |> mod_conf.redix_module.command() do
:ok
else
error -> redis_result_to_error(error)
end
end
@doc """
This should run periodically, for example once per day at a quiet moment.
Deprecated; periodic cleanup is no longer required.
"""
@spec cleanup(Config.t()) :: :ok | {:error, binary()}
@deprecated "Periodic cleanup is no longer required."
def cleanup(_config), do: :ok
@doc false
def init_config(enum), do: __MODULE__.Config.from_enum(enum)
###########
# Private #
###########
# key for a single session
# using the "old" format for :full sessions prevents old sessions from suddenly being logged-out
# so this code is "backwards compatible" with respect to old sessions being retrievable
@doc false
def session_key(session_id, user_id, :full, config) do
key = [config.key_prefix, ".s.", to_string(user_id), ?., session_id]
:crypto.hash(:blake2s, key)
end
def session_key(session_id, user_id, type, config) do
key = [config.key_prefix, ".s.", to_string(user_id), ?., Atom.to_string(type), ?., session_id]
:crypto.hash(:blake2s, key)
end
# key for the sorted-by-expiration-timestamp set of the user's session keys
@doc false
def set_key(user_id, :full, config), do: [config.key_prefix, ".u.", to_string(user_id)]
def set_key(user_id, type, config),
do: [config.key_prefix, ".u.", to_string(user_id), ?., Atom.to_string(type)]
# get all keys, including expired ones, for a user
defp get_session_keys(user_id, type, config) do
# get all of the user's session keys (index 0 = first, -1 = last)
["ZRANGE", set_key(user_id, type, config), "0", "-1"]
|> config.redix_module.command()
end
# get all valid keys for a user
defp get_valid_session_keys(user_id, type, config) do
now = Internal.now() |> Integer.to_string()
# get all of the user's valid session keys (with score/timestamp >= now)
["ZRANGE", set_key(user_id, type, config), now, "+inf", "BYSCORE"]
|> config.redix_module.command()
end
# returns {session_exists?, exp_str}
defp parse_zrange_withscores(single_zrange_withscores_result)
defp parse_zrange_withscores([_session_key, exp_str]), do: {true, exp_str}
defp parse_zrange_withscores(_), do: {false, "0"}
# clean up the user's old sessions
defp prune_session_set_cmd(set_key, now_str), do: ["ZREMRANGEBYSCORE", set_key, "-inf", now_str]
# grab the session key and score (= exp timestamp) of the highest-exp session of the user
defp get_max_exp_session_cmd(set_key) do
["ZRANGE", set_key, "+inf", "-inf", "REV", "BYSCORE", "LIMIT", "0", "1", "WITHSCORES"]
end
# set the expiration time of the user's session set
defp put_s_exp_cmd(set_key, exp_str), do: ["EXPIREAT", set_key, exp_str]
defp put_s_exp(set_key, exp_str, mod_conf) do
put_s_exp_cmd(set_key, exp_str)
|> mod_conf.redix_module.command()
|> case do
{:ok, n} when is_integer(n) -> :ok
error -> Logger.error("Error during user session set maintenance: #{inspect(error)}")
end
end
defp redis_result_to_error({:ok, error}), do: {:error, inspect(error)}
defp redis_result_to_error(error), do: error
end