diff --git a/.github/workflows/config.yml b/.github/workflows/config.yml index 7e5d17bcdb..4401c8ec0b 100644 --- a/.github/workflows/config.yml +++ b/.github/workflows/config.yml @@ -4,6 +4,7 @@ on: push: branches: - account + - np-add-redis-storage-for-sessions env: MIX_ENV: test diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/account/auth_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/account/auth_controller.ex index e4ad0d07d2..2691ac643a 100644 --- a/apps/block_scout_web/lib/block_scout_web/controllers/account/auth_controller.ex +++ b/apps/block_scout_web/lib/block_scout_web/controllers/account/auth_controller.ex @@ -3,6 +3,7 @@ defmodule BlockScoutWeb.Account.AuthController do alias BlockScoutWeb.Models.UserFromAuth alias Explorer.Account + alias Plug.CSRFProtection plug(Ueberauth) @@ -34,6 +35,8 @@ defmodule BlockScoutWeb.Account.AuthController do def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do case UserFromAuth.find_or_create(auth) do {:ok, user} -> + CSRFProtection.get_csrf_token() + conn |> put_session(:current_user, user) |> redirect(to: root()) diff --git a/apps/block_scout_web/lib/block_scout_web/endpoint.ex b/apps/block_scout_web/lib/block_scout_web/endpoint.ex index 2d52b192d8..7b83088c18 100644 --- a/apps/block_scout_web/lib/block_scout_web/endpoint.ex +++ b/apps/block_scout_web/lib/block_scout_web/endpoint.ex @@ -59,9 +59,10 @@ defmodule BlockScoutWeb.Endpoint do # The session will be stored in the cookie and signed, # this means its contents can be read but not tampered with. # Set :encryption_salt if you would also like to encrypt it. + plug( Plug.Session, - store: :cookie, + store: BlockScoutWeb.Plug.RedisCookie, key: "_explorer_key", signing_salt: "iC2ksJHS", same_site: "Lax" diff --git a/apps/block_scout_web/lib/block_scout_web/plug/redis_cookie.ex b/apps/block_scout_web/lib/block_scout_web/plug/redis_cookie.ex new file mode 100644 index 0000000000..ff8d457f09 --- /dev/null +++ b/apps/block_scout_web/lib/block_scout_web/plug/redis_cookie.ex @@ -0,0 +1,223 @@ +defmodule BlockScoutWeb.Plug.RedisCookie do + @moduledoc """ + Extended version of Plug.Session.COOKIE from https://github.com/elixir-plug/plug/blob/main/lib/plug/session/cookie.ex + Added Redis to have a possibility to invalidate session + """ + + require Logger + @behaviour Plug.Session.Store + + alias Plug.Crypto + alias Plug.Crypto.{KeyGenerator, MessageEncryptor, MessageVerifier} + + @impl true + def init(opts) do + opts + |> build_opts() + |> build_rotating_opts(opts[:rotating_options]) + |> Map.delete(:secret_key_base) + end + + @impl true + def get(conn, raw_cookie, opts) do + opts = Map.put(opts, :secret_key_base, conn.secret_key_base) + + [opts | opts.rotating_options] + |> Enum.find_value(:error, &read_raw_cookie(raw_cookie, &1)) + |> decode(opts.serializer, opts.log) + |> check_in_redis(raw_cookie) + end + + @impl true + def put(conn, _sid, term, opts) do + %{serializer: serializer, key_opts: key_opts, signing_salt: signing_salt} = opts + binary = encode(term, serializer) + + opts + |> case do + %{encryption_salt: nil} -> + MessageVerifier.sign(binary, derive(conn.secret_key_base, signing_salt, key_opts)) + + %{encryption_salt: encryption_salt} -> + MessageEncryptor.encrypt( + binary, + derive(conn.secret_key_base, encryption_salt, key_opts), + derive(conn.secret_key_base, signing_salt, key_opts) + ) + end + |> store_to_redis() + end + + @impl true + def delete(_conn, sid, _opts) do + remove_from_redis(sid) + :ok + end + + defp encode(term, :external_term_format) do + :erlang.term_to_binary(term) + end + + defp encode(term, serializer) do + {:ok, binary} = serializer.encode(term) + binary + end + + defp decode({:ok, binary}, :external_term_format, log) do + {:term, + try do + Crypto.non_executable_binary_to_term(binary) + rescue + e -> + Logger.log( + log, + "Plug.Session could not decode incoming session cookie. Reason: " <> + Exception.message(e) + ) + + %{} + end} + end + + defp decode({:ok, binary}, serializer, _log) do + case serializer.decode(binary) do + {:ok, term} -> {:custom, term} + _ -> {:custom, %{}} + end + end + + defp decode(:error, _serializer, false) do + {nil, %{}} + end + + defp decode(:error, _serializer, log) do + Logger.log( + log, + "Plug.Session could not verify incoming session cookie. " <> + "This may happen when the session settings change or a stale cookie is sent." + ) + + {nil, %{}} + end + + defp prederive(secret_key_base, value, key_opts) + when is_binary(secret_key_base) and is_binary(value) do + {:prederived, derive(secret_key_base, value, Keyword.delete(key_opts, :cache))} + end + + defp prederive(_secret_key_base, value, _key_opts) do + value + end + + defp derive(_secret_key_base, {:prederived, value}, _key_opts) do + value + end + + defp derive(secret_key_base, {module, function, args}, key_opts) do + derive(secret_key_base, apply(module, function, args), key_opts) + end + + defp derive(secret_key_base, key, key_opts) do + secret_key_base + |> validate_secret_key_base() + |> KeyGenerator.generate(key, key_opts) + end + + defp validate_secret_key_base(nil), + do: raise(ArgumentError, "cookie store expects conn.secret_key_base to be set") + + defp validate_secret_key_base(secret_key_base) when byte_size(secret_key_base) < 64, + do: raise(ArgumentError, "cookie store expects conn.secret_key_base to be at least 64 bytes") + + defp validate_secret_key_base(secret_key_base), do: secret_key_base + + defp check_signing_salt(opts) do + case opts[:signing_salt] do + nil -> raise ArgumentError, "cookie store expects :signing_salt as option" + salt -> salt + end + end + + defp check_serializer(serializer) when is_atom(serializer), do: serializer + + defp check_serializer(_), + do: raise(ArgumentError, "cookie store expects :serializer option to be a module") + + defp read_raw_cookie(raw_cookie, opts) do + signing_salt = derive(opts.secret_key_base, opts.signing_salt, opts.key_opts) + + opts + |> case do + %{encryption_salt: nil} -> + MessageVerifier.verify(raw_cookie, signing_salt) + + %{encryption_salt: _} -> + encryption_salt = derive(opts.secret_key_base, opts.encryption_salt, opts.key_opts) + + MessageEncryptor.decrypt(raw_cookie, encryption_salt, signing_salt) + end + |> case do + :error -> nil + result -> result + end + end + + defp build_opts(opts) do + encryption_salt = opts[:encryption_salt] + signing_salt = check_signing_salt(opts) + + iterations = Keyword.get(opts, :key_iterations, 1000) + length = Keyword.get(opts, :key_length, 32) + digest = Keyword.get(opts, :key_digest, :sha256) + log = Keyword.get(opts, :log, :debug) + secret_key_base = Keyword.get(opts, :secret_key_base) + key_opts = [iterations: iterations, length: length, digest: digest, cache: Plug.Keys] + + serializer = check_serializer(opts[:serializer] || :external_term_format) + + %{ + secret_key_base: secret_key_base, + encryption_salt: prederive(secret_key_base, encryption_salt, key_opts), + signing_salt: prederive(secret_key_base, signing_salt, key_opts), + key_opts: key_opts, + serializer: serializer, + log: log + } + end + + defp build_rotating_opts(opts, rotating_opts) when is_list(rotating_opts) do + Map.put(opts, :rotating_options, Enum.map(rotating_opts, &build_opts/1)) + end + + defp build_rotating_opts(opts, _), do: Map.put(opts, :rotating_options, []) + + defp store_to_redis(cookie) do + Redix.command(:redix, ["SET", hash(cookie), 1]) + + cookie + end + + defp remove_from_redis(sid) do + Redix.command(:redix, ["DEL", sid]) + end + + defp check_in_redis({sid, map}, _cookie) when is_nil(sid) or map == %{}, do: {nil, %{}} + + defp check_in_redis({_sid, session}, cookie) do + hash = hash(cookie) + + case Redix.command(:redix, ["GET", hash]) do + {:ok, one} when one in [1, "1"] -> + {hash, session} + + _ -> + {nil, %{}} + end + end + + defp hash(cookie) do + :sha256 + |> :crypto.hash(cookie) + |> Base.encode16() + end +end diff --git a/apps/explorer/lib/explorer/application.ex b/apps/explorer/lib/explorer/application.ex index 8b9f60a966..2a646b0728 100644 --- a/apps/explorer/lib/explorer/application.ex +++ b/apps/explorer/lib/explorer/application.ex @@ -67,7 +67,8 @@ defmodule Explorer.Application do con_cache_child_spec(RSK.cache_name(), ttl_check_interval: :timer.minutes(1), global_ttl: :timer.minutes(30)), Transactions, Accounts, - Uncles + Uncles, + {Redix, redix_opts()} ] children = base_children ++ configurable_children() @@ -176,4 +177,10 @@ defmodule Explorer.Application do id: {ConCache, name} ) end + + defp redix_opts do + config = Application.get_env(:explorer, Redix) + + [name: :redix, host: config[:host], port: config[:port]] + end end diff --git a/apps/explorer/mix.exs b/apps/explorer/mix.exs index 38a98c5dc2..9a44b82d98 100644 --- a/apps/explorer/mix.exs +++ b/apps/explorer/mix.exs @@ -114,7 +114,8 @@ defmodule Explorer.Mixfile do {:con_cache, "~> 1.0"}, {:tesla, "~> 1.4.4"}, {:cbor, "~> 1.0"}, - {:cloak_ecto, "~> 1.2.0"} + {:cloak_ecto, "~> 1.2.0"}, + {:redix, "~> 1.1"} ] end diff --git a/config/runtime/dev.exs b/config/runtime/dev.exs index a4d0d5b6a7..491113adb1 100644 --- a/config/runtime/dev.exs +++ b/config/runtime/dev.exs @@ -103,6 +103,17 @@ variant = Code.require_file("#{variant}.exs", "apps/explorer/config/dev") +redis_port = + case System.get_env("ACCOUNT_REDIS_PORT") && Integer.parse(System.get_env("ACCOUNT_REDIS_PORT")) do + {port, _} -> port + :error -> nil + nil -> nil + end + +config :explorer, Redix, + host: System.get_env("ACCOUNT_REDIS_HOST_URL") || "127.0.0.1", + port: redis_port || 6379 + ############### ### Indexer ### ############### diff --git a/config/runtime/prod.exs b/config/runtime/prod.exs index 2b15151aab..0218804f4f 100644 --- a/config/runtime/prod.exs +++ b/config/runtime/prod.exs @@ -70,6 +70,17 @@ variant = Code.require_file("#{variant}.exs", "apps/explorer/config/prod") +redis_port = + case System.get_env("ACCOUNT_REDIS_PORT") && Integer.parse(System.get_env("ACCOUNT_REDIS_PORT")) do + {port, _} -> port + :error -> nil + nil -> nil + end + +config :explorer, Redix, + host: System.get_env("ACCOUNT_REDIS_HOST_URL"), + port: redis_port + ############### ### Indexer ### ############### diff --git a/config/runtime/test.exs b/config/runtime/test.exs index 6f604ba05e..d4a9dda8b5 100644 --- a/config/runtime/test.exs +++ b/config/runtime/test.exs @@ -22,6 +22,17 @@ variant = Code.require_file("#{variant}.exs", "apps/explorer/config/test") +redis_port = + case System.get_env("ACCOUNT_REDIS_PORT") && Integer.parse(System.get_env("ACCOUNT_REDIS_PORT")) do + {port, _} -> port + :error -> nil + nil -> nil + end + +config :explorer, Redix, + host: System.get_env("ACCOUNT_REDIS_HOST_URL") || "127.0.0.1", + port: redis_port || 6379 + ############### ### Indexer ### ############### diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 0a57ceb4ca..b971fcb22b 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -1,6 +1,12 @@ version: '3.8' services: + redis: + image: "redis:alpine" + command: redis-server + ports: + - 6379:6379 + db: image: postgres:14 restart: always diff --git a/docker-compose/envs/common-blockscout.env b/docker-compose/envs/common-blockscout.env index 4a1fdaa820..171d39e9e5 100644 --- a/docker-compose/envs/common-blockscout.env +++ b/docker-compose/envs/common-blockscout.env @@ -138,3 +138,5 @@ RUST_VERIFICATION_SERVICE_URL=http://host.docker.internal:8043/ # ACCOUNT_SENDGRID_SENDER= # ACCOUNT_SENDGRID_TEMPLATE= ACCOUNT_CLOAK_KEY= +ACCOUNT_REDIS_HOST_URL=http://host.docker.internal +ACCOUNT_REDIS_PORT=6379 \ No newline at end of file diff --git a/mix.lock b/mix.lock index 6dcd12035d..0a53157e37 100644 --- a/mix.lock +++ b/mix.lock @@ -16,9 +16,9 @@ "cbor": {:hex, :cbor, "1.0.0", "35d33a26f6420ce3d2d01c0b1463a748b34c537d5609fc40116daf3666700d36", [:mix], [], "hexpm", "cc5e21e0fa5a0330715a3806c67bc294f8b65d07160f751b5bd6058bed1962ac"}, "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, "cldr_utils": {:hex, :cldr_utils, "2.19.1", "5a7bcd2f2fd432c548e494e850bba8a9e838f1b10202f682ea1d9809d74eff31", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "fbd10f79363e70f3d893ab21e195f444ca87c2c80120b5911761491da4489620"}, - "coerce": {:hex, :coerce, "1.0.1", "211c27386315dc2894ac11bc1f413a0e38505d808153367bd5c6e75a4003d096", [:mix], [], "hexpm", "b44a691700f7a1a15b4b7e2ff1fa30bebd669929ac8aa43cffe9e2f8bf051cf1"}, "cloak": {:hex, :cloak, "1.1.2", "7e0006c2b0b98d976d4f559080fabefd81f0e0a50a3c4b621f85ceeb563e80bb", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "940d5ac4fcd51b252930fd112e319ea5ae6ab540b722f3ca60a85666759b9585"}, "cloak_ecto": {:hex, :cloak_ecto, "1.2.0", "e86a3df3bf0dc8980f70406bcb0af2858bac247d55494d40bc58a152590bd402", [:mix], [{:cloak, "~> 1.1.1", [hex: :cloak, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "8bcc677185c813fe64b786618bd6689b1707b35cd95acaae0834557b15a0c62f"}, + "coerce": {:hex, :coerce, "1.0.1", "211c27386315dc2894ac11bc1f413a0e38505d808153367bd5c6e75a4003d096", [:mix], [], "hexpm", "b44a691700f7a1a15b4b7e2ff1fa30bebd669929ac8aa43cffe9e2f8bf051cf1"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"}, "con_cache": {:hex, :con_cache, "1.0.0", "6405e2bd5d5005334af72939432783562a8c35a196c2e63108fe10bb97b366e6", [:mix], [], "hexpm", "4d1f5cb1a67f3c1a468243dc98d10ac83af7f3e33b7e7c15999dc2c9bc0a551e"}, @@ -120,6 +120,7 @@ "que": {:hex, :que, "0.10.1", "788ed0ec92ed69bdf9cfb29bf41a94ca6355b8d44959bd0669cf706e557ac891", [:mix], [{:ex_utils, "~> 0.1.6", [hex: :ex_utils, repo: "hexpm", optional: false]}, {:memento, "~> 0.3.0", [hex: :memento, repo: "hexpm", optional: false]}], "hexpm", "a737b365253e75dbd24b2d51acc1d851049e87baae08cd0c94e2bc5cd65088d5"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ratio": {:hex, :ratio, "2.4.2", "c8518f3536d49b1b00d88dd20d49f8b11abb7819638093314a6348139f14f9f9", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:numbers, "~> 5.2.0", [hex: :numbers, repo: "hexpm", optional: false]}], "hexpm", "441ef6f73172a3503de65ccf1769030997b0d533b1039422f1e5e0e0b4cbf89e"}, + "redix": {:hex, :redix, "1.1.5", "6fc460d66a5c2287e83e6d73dddc8d527ff59cb4d4f298b41e03a4db8c3b2bd5", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "679afdd4c14502fe9c11387ff1cdcb33065a1cf511097da1eee407f17c7a418b"}, "remote_ip": {:hex, :remote_ip, "1.0.0", "3d7fb45204a5704443f480cee9515e464997f52c35e0a60b6ece1f81484067ae", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "9e9fcad4e50c43b5234bb6a9629ed6ab223f3ed07147bd35470e4ee5c8caf907"}, "rustler": {:hex, :rustler, "0.24.0", "b8362a2fee1c9d2c7373b0bfdc98f75bbc02864efcec50df173fe6c4f72d4cc4", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "2773167fca68a6525822ad977b41368ea3c2af876c42ebaa7c9d6bb69b67f1ce"}, "sobelow": {:hex, :sobelow, "0.11.1", "23438964486f8112b41e743bbfd402da3e5b296fdc9eacab29914b79c48916dd", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "9897363a7eff96f4809304a90aad819e2ad5e5d24db547af502885146746a53c"},