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

Adds a new One time token type. #421

Closed
wants to merge 3 commits into from
Closed
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
1 change: 0 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ language: elixir
elixir:
- 1.5
- 1.4
- 1.3

otp_release:
- 20.0
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
* Permissions as an optional add-in
* Deprecates Hooks in favour of callbacks on particular implementations
* Removes Phoenix macros in favour of plain functions
* Drops support for Elixir 1.3
* Adds a OneTime use token `Guardian.Token.OneTime`

# v 0.14.5

Expand Down
11 changes: 11 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,14 @@ config :guardian, Guardian.Phoenix.ControllerTest.Endpoint,
config :guardian, Guardian.Phoenix.SocketTest.Impl, []

config :guardian, Guardian.Phoenix.Permissions.BitwiseTest.Impl, []


config :guardian, ecto_repos: [Guardian.Token.OneTime.Repo]

config :guardian, Guardian.Token.OneTime.Repo,
adapter: Ecto.Adapters.Postgres,
username: "postgres",
database: "guardian_one_time_test",
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox,
loggers: []
225 changes: 225 additions & 0 deletions lib/guardian/token/one_time.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
if Code.ensure_loaded?(Ecto) do
defmodule Guardian.Token.OneTime do
@moduledoc """
A one time token implementation for Guardian.
This can be used like any other Guardian token, either in a header, or a query string.
Once decoded once the token is removed and can no longer be used.

The resource and other data may be encoded into it.

### Setup

```elixir
defmodule MyApp.OneTimeToken do
use Guardian.Token.OneTime, otp_app: :my_app,
repo: MyApp.Repo,
token_table: "one_time_tokens"

def subject_for_token(%{id: id}, _), do: {:ok, to_string(id)}
def resource_from_claims(%{"sub" => id}), do: {:ok, %{id: id}}
end
```

Configuration can be given via options to use or in the configuration.

#### Required configuration

* `repo` - the repository to use for the one time token storage


#### Optional configuration

* `token_table` - the table name for where to find tokens. The required fields are `id:string`, `claims:map`, `expiry:utc_datetime`
* `ttl` - a default ttl for all tokens. If left nil tokens generated will never expire unless explicitly told to

### Usage

```elixir
# Create a token
{:ok, token, _claims} = MyApp.OneTimeToken(my_resource)

# Create a token with custom data alongside the resource
{:ok, token, _claims} = MyApp.OneTimeToken(my_resource, %{some: "data"})

# Create a token with an explicit ttl
{:ok, token, _claims} = MyApp.OneTimeToken(my_resource, %{some: "data"}, ttl: {2, :hours})
{:ok, token, _claims} = MyApp.OneTimeToken(my_resource, %{some: "data"}, ttl: {2, :days})
{:ok, token, _claims} = MyApp.OneTimeToken(my_resource, %{some: "data"}, ttl: {2, :weeks})

# Create a token with an explicit expiry
{:ok, token, _claims} = MyApp.OneTimeToken(my_resource, %{some: "data"}, expiry: some_datetime_in_utc)

# Consume a token
{:ok, claims} = MyApp.OneTimeToken.decode_and_verify(token)

# Consume a token and load the resource
{:ok, resource, claims} = MyApp.OneTimeToken.resource_from_token(token)

# Revoke a token
MyApp.OneTimeToken.revoke(token)
```
"""
@behaviour Guardian.Token

import Ecto.Query, only: [from: 2]

defmodule Token do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset

@primary_key false
schema "abstract_table: tokens" do
field(:id, :string)
field(:claims, :map, default: %{})
field(:expiry, :utc_datetime)
end

def changeset(params) do
%__MODULE__{}
|> cast(params, [:id, :claims, :expiry])
|> validate_required([:id])
end
end

defmacro __using__(opts \\ []) do
opts = [token_module: Guardian.Token.OneTime] ++ opts

quote do
use Guardian, unquote(opts)

def repo, do: Keyword.get(unquote(opts), :repo, config(:repo))
def token_table, do: config(:token_table, "one_time_tokens")

defoverridable repo: 0, token_table: 0
end
end

def peek(mod, token) do
case find_token(mod, token) do
nil -> nil
result -> %{claims: result.claims, expiry: result.expiry}
end
end

def token_id, do: UUID.uuid4() |> to_string()

@doc """
Build the default claims for the token
"""
def build_claims(mod, _resource, sub, claims, _opts) do
claims =
claims
|> Guardian.stringify_keys()
|> Map.put("sub", sub)
|> Map.put_new("typ", mod.default_token_type())

{:ok, claims}
end

def create_token(mod, claims, opts) do
data = %{id: token_id(), claims: claims, expiry: find_expiry(mod, claims, opts)}

result = mod.repo.insert_all({mod.token_table, Token}, [data])

case result do
{1, _} ->
{:ok, data.id}

_ ->
{:error, :could_not_create_token}
end
end

@doc """
Decode the token. Without verification of the claims within it.
"""
def decode_token(mod, token, _opts) do
result = find_token(mod, token, DateTime.utc_now())

if result do
delete_token(mod, token)
{:ok, result.claims || %{}}
else
{:error, :token_not_found_or_expired}
end
end

@doc """
Verify the claims of a token
"""
def verify_claims(_mod, claims, _opts) do
{:ok, claims}
end

@doc """
Revoke a token (if appropriate)
"""
def revoke(mod, claims, token, _opts) do
delete_token(mod, token)
{:ok, claims}
end

@doc """
Refresh a token
"""
def refresh(_mod, _old_token, _opts) do
{:error, :not_refreshable}
end

@doc """
Exchange a token from one type to another
"""
def exchange(_mod, _old_token, _from_type, _to_type, _opts) do
{:error, :not_exchangeable}
end

defp delete_token(mod, token) do
q = from(t in mod.token_table, where: t.id == ^token)
mod.repo.delete_all(q)
end

defp find_expiry(mod, claims, opts) when is_list(opts) do
opts_as_map = Enum.into(opts, %{})
find_expiry(mod, claims, opts_as_map)
end

defp find_expiry(_mod, _claims, %{expiry: exp}) when not is_nil(exp), do: exp

defp find_expiry(_mod, _claims, %{ttl: ttl}) when not is_nil(ttl), do: expiry_from_ttl(ttl)
defp find_expiry(mod, _claims, _opts), do: expiry_from_ttl(mod.config(:ttl))

defp expiry_from_ttl(nil), do: nil

defp expiry_from_ttl(ttl) do
ts = DateTime.utc_now() |> DateTime.to_unix()
sec = ttl_in_seconds(ttl)
DateTime.from_unix(ts + sec)
end

defp ttl_in_seconds({seconds, unit}) when unit in [:seconds, :seconds], do: seconds
defp ttl_in_seconds({minutes, unit}) when unit in [:minute, :minutes], do: minutes * 60
defp ttl_in_seconds({hours, unit}) when unit in [:hour, :hours], do: hours * 60 * 60
defp ttl_in_seconds({weeks, unit}) when unit in [:week, :weeks], do: weeks * 7 * 24 * 60 * 60
defp ttl_in_seconds({_, units}), do: raise("Unknown Units: #{units}")

defp find_token(mod, token) do
query = from(t in {mod.token_table, Token}, where: t.id == ^token)
mod.repo.one(query)
end

defp find_token(mod, token, nil) do
find_token(mod, token)
end

defp find_token(mod, token, expiring_after) do
query =
from(
t in {mod.token_table, Token},
where: is_nil(t.expiry) or t.expiry >= ^expiring_after,
where: t.id == ^token
)
mod.repo.one(query)
end
end
end
16 changes: 14 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ defmodule Guardian.Mixfile do
name: "Guardian",
app: :guardian,
version: @version,
elixir: "~> 1.3.2 or ~> 1.4 or ~> 1.5",
elixir: "~> 1.4 or ~> 1.5",
elixirc_paths: elixirc_paths(Mix.env),
package: package(),
source_url: @url,
Expand All @@ -24,6 +24,7 @@ defmodule Guardian.Mixfile do
maintainers: @maintainers,
description: "Elixir Authentication framework",
homepage_url: @url,
aliases: aliases(),
docs: docs(),
deps: deps(),
xref: [exclude: [:phoenix]],
Expand Down Expand Up @@ -61,7 +62,11 @@ defmodule Guardian.Mixfile do
# Dev and Test dependencies
{:credo, "~> 0.8.6", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 0.5.0", only: [:dev, :test], runtime: false},
{:ex_doc, "~> 0.16", only: :dev}
{:ex_doc, "~> 0.16", only: :dev},

# Used for the one time token
{:ecto, "~> 2.2.6", optional: true},
{:postgrex, "~> 0.13.3", optional: true},
]
end

Expand All @@ -73,4 +78,11 @@ defmodule Guardian.Mixfile do
files: ~w(lib) ++ ~w(CHANGELOG.md LICENSE mix.exs README.md)
]
end

defp aliases do
[
# Ensures database is reset before tests are run
"test": ["ecto.create --quiet", "ecto.migrate", "test"]
]
end
end
6 changes: 6 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
%{"base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [], [], "hexpm"},
"credo": {:hex, :credo, "0.8.6", "335f723772d35da499b5ebfdaf6b426bfb73590b6fcbc8908d476b75f8cbca3f", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"},
"db_connection": {:hex, :db_connection, "1.1.2", "2865c2a4bae0714e2213a0ce60a1b12d76a6efba0c51fbda59c9ab8d1accc7a8", [], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
"decimal": {:hex, :decimal, "1.4.1", "ad9e501edf7322f122f7fc151cce7c2a0c9ada96f2b0155b8a09a795c2029770", [], [], "hexpm"},
"dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"},
"earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [:mix], [], "hexpm"},
"ecto": {:hex, :ecto, "2.2.6", "3fd1067661d6d64851a0d4db9acd9e884c00d2d1aa41cc09da687226cf894661", [], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
"ex_doc": {:hex, :ex_doc, "0.16.3", "cd2a4cfe5d26e37502d3ec776702c72efa1adfa24ed9ce723bb565f4c30bd31a", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"},
"jose": {:hex, :jose, "1.8.4", "7946d1e5c03a76ac9ef42a6e6a20001d35987afd68c2107bcd8f01a84e75aa73", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"},
"mime": {:hex, :mime, "1.1.0", "01c1d6f4083d8aa5c7b8c246ade95139620ef8effb009edde934e0ec3b28090a", [:mix], [], "hexpm"},
"phoenix": {:hex, :phoenix, "1.3.0", "1c01124caa1b4a7af46f2050ff11b267baa3edb441b45dbf243e979cd4c5891b", [:mix], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.2", "bfa7fd52788b5eaa09cb51ff9fcad1d9edfeb68251add458523f839392f034c1", [:mix], [], "hexpm"},
"plug": {:hex, :plug, "1.4.3", "236d77ce7bf3e3a2668dc0d32a9b6f1f9b1f05361019946aae49874904be4aed", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"},
"poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"},
"poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [], [], "hexpm"},
"postgrex": {:hex, :postgrex, "0.13.3", "c277cfb2a9c5034d445a722494c13359e361d344ef6f25d604c2353185682bfc", [], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
"uuid": {:hex, :uuid, "1.1.7", "007afd58273bc0bc7f849c3bdc763e2f8124e83b957e515368c498b641f7ab69", [:mix], [], "hexpm"}}
11 changes: 11 additions & 0 deletions priv/repo/migrations/20171107074327_test_migration.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule Guardian.OneTime.Repo.Migrations.TestMigration do
use Ecto.Migration

def change do
create table(:one_time_tokens, primary_key: false) do
add :id, :string, priary_key: true
add :claims, :json
add :expiry, :utc_datetime
end
end
end
Loading