Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add SlidingCookie plug #616

Merged
merged 4 commits into from
Oct 31, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion lib/guardian.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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

Expand Down Expand Up @@ -767,6 +782,25 @@ defmodule Guardian do
apply(mod, :config, [:token_module, @default_token_module])
end

@doc false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might make sense to move this to https://github.com/ueberauth/guardian/blob/master/lib/guardian/token/jwt.ex and do a small refactor to avoid a bit of code dublication

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well.. it's funny you should say this, as a version of this logic did previously exist in jwt.ex, I necessarily decoupled the Map.put stuff in assign_exp_from_ttl from the actual ttl parsing to avoid the duplication, and then moved it out of jwt.ex to guardian.ex as I viewed it as a general utility function; it didn't seem to have anything really to do with the JWT token implementation at all as such at that point... and calling code from jwt.ex to parse a config option just didn't seem quite right to me... I'll move it to jwt.ex if you like though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you are right, that it should actually live in guardian, and we should just refactor jwt to use the code from guardian and not the other way around

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])

Expand Down
11 changes: 11 additions & 0 deletions lib/guardian/plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
107 changes: 107 additions & 0 deletions lib/guardian/plug/sliding_cookie.ex
Original file line number Diff line number Diff line change
@@ -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)}
Hanspagh marked this conversation as resolved.
Show resolved Hide resolved
end
end
end
end
7 changes: 1 addition & 6 deletions lib/guardian/plug/verify_cookie.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
20 changes: 3 additions & 17 deletions lib/guardian/token/jwt.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ defmodule Guardian.Mixfile do
Guardian.Plug.VerifySession,
Guardian.Plug.VerifyHeader,
Guardian.Plug.VerifyCookie,
Guardian.Plug.SlidingCookie,
Guardian.Plug.Keys
],
Permissions: [
Expand Down
Loading