diff --git a/README.md b/README.md index f4ccd55c4..4c9936f16 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ conn = MyApp.Guardian.Plug.sign_in(conn, resource, %{some: "claim"}, ttl: {1, :m conn = MyApp.Guardian.Plug.sign_out(conn) # Set a "refresh" token directly on a cookie. -# Can be used in conjunction with `Guardian.Plug.VerifyCookie` +# Can be used in conjunction with `Guardian.Plug.VerifyCookie` and `Guardian.Plug.SlidingCookie` conn = MyApp.Guardian.Plug.remember_me(conn, resource) # Fetch the information from the current connection @@ -295,6 +295,11 @@ Look for a token in the session and verify it Look for a token in cookies and exchange it for an access token +#### `Guardian.Plug.SlidingCookie` + +Replace the token in cookies with a new one when a configured minimum TTL +is remaining. + #### `Guardian.Plug.EnsureAuthenticated` Make sure that a token was found and is valid diff --git a/lib/guardian.ex b/lib/guardian.ex index 2c0023c20..a0f224c73 100644 --- a/lib/guardian.ex +++ b/lib/guardian.ex @@ -473,6 +473,20 @@ defmodule Guardian do def exchange(token, from_type, to_type, opts \\ []), do: Guardian.exchange(__MODULE__, token, from_type, to_type, opts) + @doc """ + If Guardian.Plug.SlidingCookie is used, this callback will be invoked to + return the new claims, or an error (which will mean the cookie will not + be refreshed). + """ + + @spec sliding_cookie( + current_claims :: Guardian.Token.claims(), + current_resource :: Guardian.Token.resource(), + options :: Guardian.options() + ) :: {:ok, new_claims :: Guardian.Token.claims()} | {:error, any} + def sliding_cookie(_current_claims, _current_resource, opts \\ []), + do: {:error, :not_implemented} + def after_encode_and_sign(_r, _claims, token, _), do: {:ok, token} def after_sign_in(conn, _r, _t, _c, _o), do: {:ok, conn} def before_sign_out(conn, _location, _opts), do: {:ok, conn} @@ -494,7 +508,8 @@ defmodule Guardian do on_refresh: 3, on_verify: 3, peek: 1, - verify_claims: 2 + verify_claims: 2, + sliding_cookie: 3 end end @@ -767,6 +782,25 @@ defmodule Guardian do apply(mod, :config, [:token_module, @default_token_module]) end + @doc false + def ttl_to_seconds({seconds, unit}) when unit in [:second, :seconds], + do: seconds + + def ttl_to_seconds({minutes, unit}) when unit in [:minute, :minutes], + do: minutes * 60 + + def ttl_to_seconds({hours, unit}) when unit in [:hour, :hours], + do: hours * 60 * 60 + + def ttl_to_seconds({days, unit}) when unit in [:day, :days], + do: days * 24 * 60 * 60 + + def ttl_to_seconds({weeks, unit}) when unit in [:week, :weeks], + do: weeks * 7 * 24 * 60 * 60 + + def ttl_to_seconds({_, units}), + do: raise("Unknown Units: #{units}") + defp validate_exchange_type(claims, from_type) when is_binary(from_type), do: validate_exchange_type(claims, [from_type]) diff --git a/lib/guardian/plug.ex b/lib/guardian/plug.ex index 3978dc457..e9555fa0a 100644 --- a/lib/guardian/plug.ex +++ b/lib/guardian/plug.ex @@ -297,6 +297,17 @@ if Code.ensure_loaded?(Plug) do end end + @spec find_token_from_cookies(conn :: Plug.Conn.t(), Keyword.t()) :: {:ok, String.t()} | :no_token_found + def find_token_from_cookies(conn, opts) do + key = + conn + |> Pipeline.fetch_key(opts) + |> token_key() + + token = conn.req_cookies[key] || conn.req_cookies[to_string(key)] + if token, do: {:ok, token}, else: :no_token_found + end + defp fetch_token_key(conn, opts) do conn |> Pipeline.fetch_key(opts) diff --git a/lib/guardian/plug/sliding_cookie.ex b/lib/guardian/plug/sliding_cookie.ex new file mode 100644 index 000000000..ec5737588 --- /dev/null +++ b/lib/guardian/plug/sliding_cookie.ex @@ -0,0 +1,107 @@ +if Code.ensure_loaded?(Plug) do + defmodule Guardian.Plug.SlidingCookie do + @moduledoc """ + WARNING! Use of this plug MAY allow a session to be maintained + indefinitely without primary authentication by issuing new refresh + tokens off the back of previous (still valid) tokens. Especially if your + `resource_from_claims` implemention does not check resource validity (in + a user database or whatever), you SHOULD then at least make such checks + in the `sliding_cookie/3` implementation to make sure the resource still + exists, is valid and permitted. + + Looks for a valid token in the request cookies, and replaces it, if: + + a. A valid unexpired token is found in the request cookies. + b. There is a `:sliding_cookie` configuration (or plug option). + c. The token age (since issue) exceeds that configuration. + d. The implementation module `sliding_cookie/3` returns `{:ok, new_claims}`. + + Otherwise the plug does nothing. + + The implementation module MUST implement the `sliding_cookie/3` function + if this plug is used. The return value, if an updated cookie is approved + of, should be `{:ok, new_claims}`. The `sliding_cookie/3` function should + take any security action (such as checking a database to check a user has + not been disabled). Anything else returned will be taken as an indication + that the cookie should not refreshed. + + The only case whereby the error handler is employed is if the + `sliding_cookie/3` function is not provided, in which case it is called + with a type of `:implementation_fault` and reason `:no_sliding_cookie_fn`. + + This, like all other Guardian plugs, requires a Guardian pipeline to be setup. + It requires an implementation module, an error handler and a key. + + These can be set either: + + 1. Upstream on the connection with `plug Guardian.Pipeline` + 2. Upstream on the connection with `Guardian.Pipeline.{put_module, put_error_handler, put_key}` + 3. Inline with an option of `:module`, `:error_handler`, `:key` + + Nothing is done with the token, refreshed or not, no errors are handled as validity and expiry + can be checked by the VerifyCookie and EnsureAuthenticated plugs respectively. + + Options: + + * `:key` - The location of the token (default `:default`) + * `:sliding_cookie` - The minimum TTL remaining after which a replacement will be issued. Defaults to configured values. + * `:halt` - Whether to halt the connection in case of error. Defaults to `true`. + + The `:sliding_cookie` config (or plug option) should be the same format as `:ttl`, for example + `{1, :hour}`, and obviously it should be less than the prevailing `:ttl`. + """ + + import Plug.Conn + import Guardian.Plug, only: [find_token_from_cookies: 2, maybe_halt: 2] + + alias Guardian.Plug.Pipeline + + import Guardian, only: [ttl_to_seconds: 1, decode_and_verify: 4, timestamp: 0] + + @behaviour Plug + + @impl Plug + @spec init(opts :: Keyword.t()) :: Keyword.t() + def init(opts), do: opts + + @impl Plug + @spec call(conn :: Plug.Conn.t(), opts :: Keyword.t()) :: Plug.Conn.t() + def call(%{req_cookies: %Plug.Conn.Unfetched{}} = conn, opts) do + conn + |> fetch_cookies() + |> call(opts) + end + + def call(conn, opts) do + with {:ok, token} <- find_token_from_cookies(conn, opts), + module <- Pipeline.fetch_module!(conn, opts), + {:ok, ttl_softlimit} <- sliding_window(module, opts), + {:ok, %{"exp" => exp} = claims} <- decode_and_verify(module, token, %{}, opts), + {:ok, resource} <- module.resource_from_claims(claims), + true <- timestamp() >= exp - ttl_softlimit, + {:ok, new_c} <- module.sliding_cookie(claims, resource, opts) do + conn + |> Guardian.Plug.remember_me(module, resource, new_c) + else + {:error, :not_implemented} -> + conn + |> Pipeline.fetch_error_handler!(opts) + |> apply(:auth_error, [conn, {:implementation_fault, :no_sliding_cookie_fn}, opts]) + |> maybe_halt(opts) + + _ -> + conn + end + end + + defp sliding_window(module, opts) do + case Keyword.get(opts, :sliding_cookie, module.config(:sliding_cookie)) do + nil -> + :no_sliding_window + + ttl_descr -> + {:ok, ttl_to_seconds(ttl_descr)} + end + end + end +end diff --git a/lib/guardian/plug/verify_cookie.ex b/lib/guardian/plug/verify_cookie.ex index 8fb4a8390..14260d7f9 100644 --- a/lib/guardian/plug/verify_cookie.ex +++ b/lib/guardian/plug/verify_cookie.ex @@ -43,6 +43,7 @@ if Code.ensure_loaded?(Plug) do import Plug.Conn import Guardian.Plug.Keys + import Guardian.Plug, only: [find_token_from_cookies: 2] alias Guardian.Plug.Pipeline @@ -93,12 +94,6 @@ if Code.ensure_loaded?(Plug) do end end - defp find_token_from_cookies(conn, opts) do - key = conn |> storage_key(opts) |> token_key() - token = conn.req_cookies[key] || conn.req_cookies[to_string(key)] - if token, do: {:ok, token}, else: :no_token_found - end - defp maybe_put_in_session(conn, false, _, _), do: conn defp maybe_put_in_session(conn, true, token, opts) do diff --git a/lib/guardian/token/jwt.ex b/lib/guardian/token/jwt.ex index f8caebd32..6c198336a 100644 --- a/lib/guardian/token/jwt.ex +++ b/lib/guardian/token/jwt.ex @@ -147,7 +147,7 @@ defmodule Guardian.Token.Jwt do alias JOSE.JWS alias JOSE.JWT - import Guardian, only: [stringify_keys: 1] + import Guardian, only: [stringify_keys: 1, ttl_to_seconds: 1] @default_algos ["HS512"] @default_token_type "access" @@ -446,22 +446,8 @@ defmodule Guardian.Token.Jwt do # catch all for when the issued at iat is not yet set defp set_ttl(claims, requested_ttl), do: claims |> set_iat() |> set_ttl(requested_ttl) - defp assign_exp_from_ttl(the_claims, {iat_v, {seconds, unit}}) when unit in [:second, :seconds], - do: Map.put(the_claims, "exp", iat_v + seconds) - - defp assign_exp_from_ttl(the_claims, {iat_v, {minutes, unit}}) when unit in [:minute, :minutes], - do: Map.put(the_claims, "exp", iat_v + minutes * 60) - - defp assign_exp_from_ttl(the_claims, {iat_v, {hours, unit}}) when unit in [:hour, :hours], - do: Map.put(the_claims, "exp", iat_v + hours * 60 * 60) - - defp assign_exp_from_ttl(the_claims, {iat_v, {days, unit}}) when unit in [:day, :days], - do: Map.put(the_claims, "exp", iat_v + days * 24 * 60 * 60) - - defp assign_exp_from_ttl(the_claims, {iat_v, {weeks, unit}}) when unit in [:week, :weeks], - do: Map.put(the_claims, "exp", iat_v + weeks * 7 * 24 * 60 * 60) - - defp assign_exp_from_ttl(_, {_iat_v, {_, units}}), do: raise("Unknown Units: #{units}") + defp assign_exp_from_ttl(the_claims, {iat_v, ttl}), + do: Map.put(the_claims, "exp", iat_v + ttl_to_seconds(ttl)) defp set_iss(claims, mod, _opts) do issuer = mod |> apply(:config, [:issuer]) |> to_string() diff --git a/mix.exs b/mix.exs index 9596338ee..ddd4e7189 100644 --- a/mix.exs +++ b/mix.exs @@ -146,6 +146,7 @@ defmodule Guardian.Mixfile do Guardian.Plug.VerifySession, Guardian.Plug.VerifyHeader, Guardian.Plug.VerifyCookie, + Guardian.Plug.SlidingCookie, Guardian.Plug.Keys ], Permissions: [ diff --git a/test/guardian/plug/sliding_cookie_test.exs b/test/guardian/plug/sliding_cookie_test.exs new file mode 100644 index 000000000..58ea099e6 --- /dev/null +++ b/test/guardian/plug/sliding_cookie_test.exs @@ -0,0 +1,200 @@ +defmodule Guardian.Plug.SlidingCookieTest do + @moduledoc false + + use Plug.Test + + alias Guardian.Plug.Pipeline + alias Guardian.Plug.SlidingCookie + + use ExUnit.Case, async: true + + defmodule Handler do + @moduledoc false + + import Plug.Conn + + @behaviour Guardian.Plug.ErrorHandler + + @impl Guardian.Plug.ErrorHandler + def auth_error(conn, {type, reason}, _opts) do + body = inspect({type, reason}) + send_resp(conn, 401, body) + end + end + + defmodule Impl do + @moduledoc false + + use Guardian, + otp_app: :guardian, + token_module: Guardian.Support.TokenModule + + def subject_for_token(%{id: id}, _claims), do: {:ok, id} + def subject_for_token(%{"id" => id}, _claims), do: {:ok, id} + + def resource_from_claims(%{"sub" => id}), do: {:ok, %{id: id}} + + def sliding_cookie(_claims, _resource, opts) do + case Keyword.get(opts, :fail_sliding_cookie) do + nil -> {:ok, %{"new" => "claim"}} + reason -> {:error, reason} + end + end + end + + @resource %{id: "bobby"} + + setup do + impl = __MODULE__.Impl + handler = __MODULE__.Handler + + conn = + conn(:get, "/") + |> Pipeline.put_module(impl) + |> Pipeline.put_error_handler(handler) + + {:ok, token, claims} = + __MODULE__.Impl.encode_and_sign(@resource, %{"exp" => Guardian.timestamp() + 60}, token_type: "refresh") + + {:ok, %{claims: claims, conn: conn, token: token, impl: impl, handler: handler}} + end + + test "with no cookies fetched it does nothing", ctx do + conn = SlidingCookie.call(ctx.conn, []) + refute conn.halted + assert conn === fetch_cookies(ctx.conn) + end + + describe "with no sliding_window callback implementation" do + setup ctx do + conn = + ctx.conn + |> put_req_cookie("guardian_default_token", ctx.token) + |> fetch_cookies() + + {:ok, %{ctx | conn: conn}} + end + + test "conn is halted", ctx do + conn = + ctx.conn + |> SlidingCookie.call(sliding_cookie: {60, :seconds}, fail_sliding_cookie: :not_implemented) + + assert conn.halted + end + + test "conn is not halted when halt option is set to false", ctx do + conn = + ctx.conn + |> SlidingCookie.call(sliding_cookie: {30, :seconds}, fail_sliding_cookie: :not_implemented, halt: false) + + refute conn.halted + end + end + + describe "with fetched cookies" do + setup ctx do + conn = + ctx.conn + |> fetch_cookies() + + {:ok, %{ctx | conn: conn}} + end + + test "it does nothing", ctx do + conn = SlidingCookie.call(ctx.conn, []) + + refute conn.halted + assert conn === ctx.conn + end + end + + describe "with cookie and invalid token" do + setup ctx do + conn = + ctx.conn + |> put_req_cookie("guardian_default_token", "not a good one") + |> fetch_cookies() + + {:ok, %{ctx | conn: conn}} + end + + test "it does nothing", ctx do + conn = SlidingCookie.call(ctx.conn, []) + + refute conn.halted + assert conn === ctx.conn + end + end + + describe "with cookie and token after refresh threshold" do + setup ctx do + iat = Guardian.timestamp() - 110 + exp = Guardian.timestamp() + 10 + claims = %{"iat" => iat, "exp" => exp} + {:ok, token, claims} = __MODULE__.Impl.encode_and_sign(@resource, claims, token_type: "refresh") + + conn = + ctx.conn + |> put_req_cookie("guardian_default_token", token) + |> fetch_cookies() + + {:ok, %{ctx | conn: conn, claims: claims}} + end + + test "it does nothing when claims verify fails", ctx do + conn = + ctx.conn + |> SlidingCookie.call(sliding_cookie: {30, :seconds}, fail_verify_claims: true) + + assert conn === ctx.conn + end + + test "it replaces cookie when claims verify succeeds", ctx do + conn = + ctx.conn + |> SlidingCookie.call(sliding_cookie: {30, :seconds}) + + refute conn.halted + refute conn === ctx.conn + assert %Plug.Conn{conn | cookies: %{}, resp_cookies: %{}} === %Plug.Conn{ctx.conn | cookies: %{}} + end + + test "replacement cookie is valid and has specified new claims", ctx do + conn = + ctx.conn + |> SlidingCookie.call(sliding_cookie: {30, :seconds}) + + refute conn.halted + refute conn === ctx.conn + assert %Plug.Conn{conn | cookies: %{}, resp_cookies: %{}} === %Plug.Conn{ctx.conn | cookies: %{}} + + assert {:ok, %{"new" => "claim"}} = + __MODULE__.Impl.decode_and_verify(conn.resp_cookies["guardian_default_token"].value) + end + end + + describe "with cookie and token before refresh threshold" do + setup ctx do + iat = Guardian.timestamp() - 60 + exp = Guardian.timestamp() + 60 + claims = %{"iat" => iat, "exp" => exp} + {:ok, token, claims} = __MODULE__.Impl.encode_and_sign(@resource, claims, token_type: "refresh") + + conn = + ctx.conn + |> put_req_cookie("guardian_default_token", token) + |> fetch_cookies() + + {:ok, %{ctx | conn: conn, claims: claims}} + end + + test "it does nothing", ctx do + conn = + ctx.conn + |> SlidingCookie.call(sliding_cookie: {30, :seconds}) + + assert conn === ctx.conn + end + end +end