diff --git a/CHANGELOG.md b/CHANGELOG.md index c8367e6..34b717c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,17 @@ -# v 0.2.0 +# v 0.2.3 (6/12/2018) +API Authentication + +* Generator for API Authentication +* Add 'opaque' Guardian token + +# v 0.2.2 (1/30/2018) +* Add Curator.Plug.LoadResource + +# v 0.2.1 (1/30/2018) +* README.md formatting +* Package priv (for generators) + +# v 0.2.0 (1/25/2018) Rewrite - currently only supports an Ueberauth workflow (and timeouts). v0.3.0 will add support back for database authentication. * Generators match Phoenix 1.3 structure diff --git a/README.md b/README.md index e62f9e3..746d09e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ For an example, see the [PhoenixCurator Application](https://github.com/curator- * [Ueberauth](#ueberauth): Ueberauth Integration. * [Timeoutable](#timeoutable): Session Timeout (after configurable inactivity). +* [API](#api): API login (with an opaque token). (TODO) @@ -26,7 +27,7 @@ For an example, see the [PhoenixCurator Application](https://github.com/curator- ```elixir def deps do - [{:curator, "~> 0.2.2"}] + [{:curator, "~> 0.2.3"}] end ``` @@ -60,67 +61,67 @@ For an example, see the [PhoenixCurator Application](https://github.com/curator- * new template (`/lib//templates/auth/session/new.html.eex`). Note: this is just a placeholder that you'll want to update when you decide on a sign-in strategy. 3. The generators aren't perfect (TODO), so finish the installation - + 1. Update your router (`/lib//router.ex`) - + ```elixir require Curator.Router - + pipeline :browser do ... plug .Auth.Curator.UnauthenticatedPipeline end - + pipeline :authenticated_browser do ... (copy the code from browser) plug .Auth.Curator.AuthenticatedPipeline end - + scope "/", do pipe_through :browser - + ... Insert your unprotected routes here ... - + Curator.Router.mount_unauthenticated_routes(.Auth.Curator) end - + scope "/", do pipe_through :authenticated_browser - + ... Insert your unprotected routes here ... - + Curator.Router.mount_authenticated_routes(.Auth.Curator) end ``` - + 2. Add the view_helper to your web module (`/lib/.ex`) - + ```elixir def view do quote do ... - + import .Auth.CuratorHelper end end ``` - + This allows you to call `current_user(@conn)` in your templates - + 3. [Configure Guardian](https://github.com/ueberauth/guardian#installation) in `config.exs` - + ```elixir config :, .Auth.Guardian, issuer: "", secret_key: "Secret key. You can use `mix guardian.gen.secret` to get one" ``` - + and `prod.exs` - + ```elixir config :, .Auth.Guardian, issuer: "", @@ -129,11 +130,11 @@ For an example, see the [PhoenixCurator Application](https://github.com/curator- verify_issuer: true, secret_key: {.Auth.Guardian, :fetch_secret_key, []} ``` - + (NOTE: the sameple prod.exs is one way to keep the `secret_key` out of source code. If you use an alternative technique the `fetch_secret_key` method can be removed from `.Auth.Guardian`) - + 4. Add to your Auth Context (`/lib//auth/auth.ex`) - + ```elixir def get_user(id) do case Repo.get(User, id) do @@ -142,15 +143,15 @@ For an example, see the [PhoenixCurator Application](https://github.com/curator- end end ``` - + 4. Add a signout link to your layout - + ```elixir <%= if current_user(@conn) do %> <%= link "Sign Out", to: session_path(@conn, :delete), method: :delete %> <% end %> ``` - + 5. Testing Update `conn_case.ex`: @@ -161,21 +162,21 @@ For an example, see the [PhoenixCurator Application](https://github.com/curator- unless tags[:async] do Ecto.Adapters.SQL.Sandbox.mode(.Repo, {:shared, self()}) end - + # Create w/ ExMachina (or your preferred method) # Note: As you add additional modules, make sure this user is valid for them too. auth_user = .Factory.insert(:auth_user) - + {:ok, token, claims} = .Auth.Guardian.encode_and_sign(auth_user) - + conn = Phoenix.ConnTest.build_conn() - + auth_conn = conn |> Plug.Test.init_test_session(%{ guardian_default_token: token, guardian_default_timeoutable: Curator.Time.timestamp(), }) - + {:ok, unauth_conn: conn, auth_user: auth_user, conn: auth_conn} end ``` @@ -187,21 +188,21 @@ For an example, see the [PhoenixCurator Application](https://github.com/curator- ```elixir scope "/", do pipe_through :browser - + if Mix.env == :test do get "/insecure", PageController, :insecure end - + Curator.Router.mount_unauthenticated_routes(.Auth.Curator) end - + scope "/", do pipe_through :authenticated_browser - + if Mix.env == :test do get "/secure", PageController, :secure end - + Curator.Router.mount_authenticated_routes(.Auth.Curator) end ``` @@ -211,11 +212,11 @@ For an example, see the [PhoenixCurator Application](https://github.com/curator- ```elixir defmodule .PageController do use , :controller - + def secure(conn, _params) do text conn, "!!!SECURE!!!" end - + def insecure(conn, _params) do text conn, "INSECURE" end @@ -227,35 +228,35 @@ For an example, see the [PhoenixCurator Application](https://github.com/curator- ``` defmodule .PageControllerTest do use .ConnCase - + test "GET /secure (Unauthenticated)", %{unauth_conn: conn} do conn = get conn, "/secure" assert redirected_to(conn) == session_path(conn, :new) assert get_flash(conn, :error) == "Please Sign In" end - + test "GET /secure (Authenticated)", %{conn: conn} do conn = get conn, "/secure" assert text_response(conn, 200) == "!!!SECURE!!!" end - + test "GET /secure (Authenticated - User Delete)", %{conn: conn, auth_user: user} do .Auth.delete_user(user) conn = get conn, "/secure" assert redirected_to(conn) == session_path(conn, :new) assert get_flash(conn, :error) == "Please Sign In" end - + test "GET /insecure (Unauthenticated)", %{unauth_conn: conn} do conn = get conn, "/insecure" assert text_response(conn, 200) == "INSECURE" end - + test "GET /insecure (Authenticated)", %{conn: conn} do conn = get conn, "/insecure" assert text_response(conn, 200) == "INSECURE" end - + test "GET /insecure (Authenticated - User Delete)", %{conn: conn, auth_user: user} do .Auth.delete_user(user) conn = get conn, "/insecure" @@ -270,7 +271,7 @@ For an example, see the [PhoenixCurator Application](https://github.com/curator- 6. Curate. Your authentication library is looking a bit spartan... Time to add to you collection. - + Currently only an oauth workflow is supported, so start with [Ueberauth](#ueberauth) ## Module Documentation @@ -298,9 +299,9 @@ Ueberauth Integration ``` 3. Install Ueberauth and the desired [strategies](https://github.com/ueberauth/ueberauth#configuring-providers). For example, to add google oauth: - + 1. Update `mix.exs` - + ```elixir defp deps do [ @@ -309,24 +310,24 @@ Ueberauth Integration ] end ``` - + NOTE: If you're using an umbrella app you'll also need to add ueberauth to your ecto applications `mix.exs`. - + 2. Update `config.exs` - + ```elixir config :ueberauth, Ueberauth, providers: [ google: {Ueberauth.Strategy.Google, []} ] - + config :ueberauth, Ueberauth.Strategy.Google.OAuth, client_id: System.get_env("GOOGLE_CLIENT_ID"), client_secret: System.get_env("GOOGLE_CLIENT_SECRET") ``` 3. Put some links to the providers (`/lib//templates/auth/session/new.html.eex`) - + ```elixir <%= link "Google", to: ueberauth_path(@conn, :request, "google"), class: "btn btn-default" %> ``` @@ -360,7 +361,7 @@ Session Timeout (after configurable inactivity) ... plug Curator.Timeoutable.Plug, timeoutable_module: .Auth.Timeoutable end - + defmodule .Auth.Curator.AuthenticatedPipeline do ... plug Curator.Timeoutable.Plug, timeoutable_module: .Auth.Timeoutable @@ -404,6 +405,111 @@ Session Timeout (after configurable inactivity) ### Approvable (TODO) +### API + +#### Description +API Login (with an opaque token) + +This generator uses the `Curator.Guardian.Token.Opaque` module in place of the guardian default `Guardian.Token.Jwt`. It also assumes you'll be storing them in an ecto database. Various backends could be used, as long as they implement the Curator.Guardian.Token.Opaque.Persistence behaviour. If you prefer JWT throughout, you can remove the schema / context and set the guardian_token to the default (TODO: accept a command line option to do this). + +#### Installation + +1. Run the install command + + ``` + mix curator.api.install + ``` + +2. Update your router (`/lib//router.ex`) + + ```elixir + require Curator.Router + + pipeline :api do + plug :accepts, ["json"] + + plug .Auth.Curator.ApiPipeline + end + + scope "/api", do + pipe_through :api + + ... + end + ``` + +3. Testing + + Update `conn_case.ex`: + + ```elixir + setup tags do + ... + + api_unauth_conn = Phoenix.ConnTest.build_conn() |> Plug.Conn.put_req_header("accept", "application/json") + + {:ok, token, _claims} = .Auth.ApiGuardian.encode_and_sign(auth_user, %{description: "TEST"}) + api_auth_conn = Plug.Conn.put_req_header(api_unauth_conn, "authorization", "Bearer: #{token.token}") + + api_invalid_conn = Plug.Conn.put_req_header(api_unauth_conn, "authorization", "Bearer: NOT_A_REAL_TOKEN") + + {:ok, + ... + api_unauth_conn: api_unauth_conn, + api_auth_conn: api_auth_conn, + api_invalid_conn: api_invalid_conn, + } + end + ``` + + To test, I created some special routes: + + ```elixir + scope "/api", do + pipe_through :api + + if Mix.env == :test do + get "/secure", PageController, :json_secure + end + ``` + + Update the `page_controller.ex`: + + ```elixir + defmodule .PageController do + use , :controller + + def json_secure(conn, _params) do + json conn, %{data: "SECURE"} + end + end + ``` + + And wrote tests in `page_controller_test.exs`: + + ``` + defmodule .PageControllerTest do + use .ConnCase + + describe "API" do + test "GET /secure (Unauthenticated)", %{api_unauth_conn: conn} do + conn = get conn, "/api/secure" + assert json_response(conn, 403) == %{"error" => "No API Token"} + end + + test "GET /secure (Authenticated)", %{api_auth_conn: conn} do + conn = get conn, "/api/secure" + assert json_response(conn, 200) == %{"data" => "SECURE"} + end + + test "GET /secure (Bad Token)", %{api_invalid_conn: conn} do + conn = get conn, "/api/secure" + assert json_response(conn, 403) == %{"error" => "Invalid API Token"} + end + end + end + ``` + # Extending Curator (TODO) # Debt diff --git a/lib/curator/guardian/token/opaque.ex b/lib/curator/guardian/token/opaque.ex new file mode 100644 index 0000000..fa479f7 --- /dev/null +++ b/lib/curator/guardian/token/opaque.ex @@ -0,0 +1,135 @@ +defmodule Curator.Guardian.Token.Opaque do + @moduledoc """ + Opaque token implementation for Guardian. + + Rather than the default JWT implementation, this module expect that a token + will be an opaque string, that can be looked up (in a persistance module) to + get the claims. It uses a subset of the standard JWT claims so it will function + as a drop-in replacement for the default Guardian implementation. + + NOTE: To use this module, the guardian implementation module must + implement get_token, create_token & delete_token (the + Curator.Guardian.Token.Opaque.Persistence behaviour). An example can be found in + the specs (it uses a context and an ecto repo). Redis, Genserver, or other + stateful implementations can also be used. + """ + + @behaviour Guardian.Token + + @default_token_type "access" + + import Guardian, only: [stringify_keys: 1] + + @doc """ + Inspect the token. + + Return a map with keys: `claims` + """ + def peek(_mod, nil), do: nil + + def peek(mod, token_id) do + case apply(mod, :get_token, [token_id]) do + {:ok, token} -> %{claims: token.claims} + _ -> nil + end + end + + @doc """ + Generate unique token id + """ + def token_id do + length = 64 + :crypto.strong_rand_bytes(length) |> Base.encode64 |> binary_part(0, length) + end + + @doc """ + Builds the default claims (a subset of the JWT claims). + + By default, only typ, and sub are used + + Options: + + Options may override the defaults found in the configuration. + + * `token_type` - Override the default token type + """ + def build_claims(mod, _resource, sub, claims \\ %{}, options \\ []) do + claims = + claims + |> stringify_keys() + |> set_type(mod, options) + |> set_sub(mod, sub, options) + + {:ok, claims} + end + + defp set_type(%{"typ" => typ} = claims, _mod, _opts) when not is_nil(typ), do: claims + + defp set_type(claims, mod, opts) do + defaults = apply(mod, :default_token_type, []) + typ = Keyword.get(opts, :token_type, defaults) + Map.put(claims, "typ", to_string(typ || @default_token_type)) + end + + defp set_sub(claims, _mod, subject, _opts), do: Map.put(claims, "sub", subject) + + @doc """ + Create a token. Uses the claims, and persists the token. + Returns the token + + """ + def create_token(mod, claims, _options \\ []) do + case apply(mod, :create_token, [claims]) do + {:ok, token} -> + {:ok, token} + result -> + result + end + end + + @doc """ + Find the token and return its claims (or return an error) + """ + def decode_token(mod, token_id, _options \\ []) do + case apply(mod, :get_token, [token_id]) do + {:ok, token} -> {:ok, token.claims} + result -> result + end + end + + @doc """ + Verifies the claims (not applicable but a required behaviour). + """ + def verify_claims(_mod, claims, _options) do + {:ok, claims} + end + + @doc """ + Delete the token + """ + def revoke(mod, claims, token_id, _options) do + case apply(mod, :delete_token, [token_id]) do + {:ok, _} -> + {:ok, claims} + result -> result + end + end + + @doc """ + Refresh the token (not applicable but a required behaviour) + + It will return an error if called + """ + def refresh(_mod, _old_token, _options) do + {:error, :not_applicable} + end + + @doc """ + Exchange a token of one type to another (not applicable but a required behaviour). + + It will return an error if called + """ + def exchange(_mod, _old_token, _from_type, _to_type, _options) do + {:error, :not_applicable} + end +end diff --git a/lib/curator/guardian/token/opaque/persistence.ex b/lib/curator/guardian/token/opaque/persistence.ex new file mode 100644 index 0000000..d7a07d1 --- /dev/null +++ b/lib/curator/guardian/token/opaque/persistence.ex @@ -0,0 +1,28 @@ +defmodule Curator.Guardian.Token.Opaque.Persistence do + @moduledoc """ + The opaque token can be stored in _many_ different backends. + This is the behaviour those backends must implement + """ + + @type token_id :: String.t() + @type token :: any + @type claims :: Map.t() + + @doc """ + Get a Token + """ + @callback get_token(token_id) :: + {:ok, token} | {:error, any} + + @doc """ + Create a token + """ + @callback create_token(claims) :: + {:ok, token} | {:error, any} + + @doc """ + Delete a token + """ + @callback delete_token(token_id) :: + {:ok, token} | {:error, any} +end diff --git a/lib/mix/tasks/curator.api.install.ex b/lib/mix/tasks/curator.api.install.ex new file mode 100644 index 0000000..5ecfee6 --- /dev/null +++ b/lib/mix/tasks/curator.api.install.ex @@ -0,0 +1,196 @@ +defmodule Mix.Tasks.Curator.Api.Install do + @shortdoc "Install Curator (for API)" + + @moduledoc """ + Generates required Curator API files. + + mix curator.api.install + + NOTE: This was copied and adapted from: mix phx.gen.context + """ + + use Mix.Task + + alias Mix.Phoenix.{Context, Schema} + alias Mix.Tasks.Phx.Gen + + @switches [binary_id: :boolean, table: :string, web: :string, + schema: :boolean, context: :boolean, context_app: :string] + + @default_opts [schema: true, context: true] + + @doc false + def run(args) do + args = ["Auth", "Token", "auth_tokens", "token:string:unique", "description:string", "claims:map", "user_id:references:users"] ++ args + + if Mix.Project.umbrella? do + Mix.raise "mix curator.api.install can only be run inside an application directory" + end + + Gen.Context.run(args) + + {context, schema} = Gen.Context.build(args) + + binding = [context: context, schema: schema] + paths = generator_paths() + + context + |> copy_new_files(paths, binding) + |> print_shell_instructions() + end + + def generator_paths do + [".", :curator] + end + + @doc false + def files_to_be_generated(%Context{schema: schema, context_app: context_app}) do + web_prefix = Mix.Phoenix.web_path(context_app) + web_path = to_string(schema.web_path) + + [ + # {:eex, "curator.ex", Path.join([web_prefix, web_path, "auth", "curator.ex"])}, + {:eex, "api_error_handler.ex", Path.join([web_prefix, "controllers", web_path, "auth", "api_error_handler.ex"])}, + # {:eex, "guardian.ex", Path.join([web_prefix, web_path, "auth", "guardian.ex"])}, + {:force_eex, "schema.ex", schema.file} + ] + end + + defp curator_file_path(%Context{schema: schema, context_app: context_app}) do + web_prefix = Mix.Phoenix.web_path(context_app) + web_path = to_string(schema.web_path) + + Path.join([web_prefix, web_path, "auth", "curator.ex"]) + end + + defp guardian_file_path(%Context{schema: schema, context_app: context_app}) do + web_prefix = Mix.Phoenix.web_path(context_app) + web_path = to_string(schema.web_path) + + Path.join([web_prefix, web_path, "auth", "guardian.ex"]) + end + + @doc false + def copy_new_files(%Context{} = context, paths, binding) do + files = files_to_be_generated(context) + copy_from paths, "priv/templates/curator.api.install", binding, files + inject_schema_access(context, paths, binding) + inject_curator_module(context, paths, binding) + inject_guardian_module(context, paths, binding) + + context + end + + defp inject_schema_access(%Context{file: file} = context, paths, binding) do + unless Context.pre_existing?(context) do + raise "No context to inject into" + end + + paths + |> Mix.Phoenix.eval_from("priv/templates/curator.api.install/schema_access.ex", binding) + |> inject_eex_before_final_end(file, binding) + end + + defp inject_curator_module(context, paths, binding) do + paths + |> Mix.Phoenix.eval_from("priv/templates/curator.api.install/api_pipeline.ex", binding) + |> inject_eex_after_final_end(curator_file_path(context), binding) + end + + defp inject_guardian_module(context, paths, binding) do + paths + |> Mix.Phoenix.eval_from("priv/templates/curator.api.install/api_guardian.ex", binding) + |> inject_eex_after_final_end(guardian_file_path(context), binding) + end + + defp write_file(content, file) do + File.write!(file, content) + end + + defp inject_eex_before_final_end(content_to_inject, file_path, binding) do + file = File.read!(file_path) + + if String.contains?(file, content_to_inject) do + :ok + else + Mix.shell.info([:green, "* injecting ", :reset, Path.relative_to_cwd(file_path)]) + + file + |> String.trim_trailing() + |> String.trim_trailing("end") + |> EEx.eval_string(binding) + |> Kernel.<>(content_to_inject) + |> Kernel.<>("end\n") + |> write_file(file_path) + end + end + + defp inject_eex_after_final_end(content_to_inject, file_path, binding) do + file = File.read!(file_path) + + if String.contains?(file, content_to_inject) do + :ok + else + Mix.shell.info([:green, "* injecting ", :reset, Path.relative_to_cwd(file_path)]) + + file + |> String.trim_trailing() + |> EEx.eval_string(binding) + |> Kernel.<>(content_to_inject) + |> write_file(file_path) + end + end + + def copy_from(apps, source_dir, binding, mapping) when is_list(mapping) do + roots = Enum.map(apps, &to_app_source(&1, source_dir)) + + for {format, source_file_path, target} <- mapping do + source = + Enum.find_value(roots, fn root -> + source = Path.join(root, source_file_path) + if File.exists?(source), do: source + end) || raise "could not find #{source_file_path} in any of the sources" + + case format do + :text -> Mix.Generator.create_file(target, File.read!(source)) + :eex -> Mix.Generator.create_file(target, EEx.eval_file(source, binding)) + :new_eex -> + if File.exists?(target) do + :ok + else + Mix.Generator.create_file(target, EEx.eval_file(source, binding)) + end + :force_eex -> Mix.Generator.create_file(target, EEx.eval_file(source, binding), force: true) + end + end + end + + defp to_app_source(path, source_dir) when is_binary(path), + do: Path.join(path, source_dir) + defp to_app_source(app, source_dir) when is_atom(app), + do: Application.app_dir(app, source_dir) + + @doc false + def print_shell_instructions(%Context{schema: schema, context_app: context_app} = context) do + Mix.shell.info """ + + Add curator to your router #{Mix.Phoenix.web_path(context_app)}/router.ex: + + require Curator.Router + + pipeline :api do + ... + plug #{inspect context.web_module}.Auth.Curator.ApiPipeline + end + + scope "/api", #{inspect context.web_module} do + pipe_through :api + + ... + end + + """ + + if context.generate?, do: Gen.Context.print_shell_instructions(context) + end +end diff --git a/lib/mix/tasks/curator.timeoutable.install.ex b/lib/mix/tasks/curator.timeoutable.install.ex index 0aebee4..22715bd 100644 --- a/lib/mix/tasks/curator.timeoutable.install.ex +++ b/lib/mix/tasks/curator.timeoutable.install.ex @@ -1,5 +1,5 @@ defmodule Mix.Tasks.Curator.Timeoutable.Install do - @shortdoc "Install Curator" + @shortdoc "Install Curator::Timeoutable" @moduledoc """ Generates required Curator files. diff --git a/lib/mix/tasks/curator.ueberauth.install.ex b/lib/mix/tasks/curator.ueberauth.install.ex index abbfc5a..2e88003 100644 --- a/lib/mix/tasks/curator.ueberauth.install.ex +++ b/lib/mix/tasks/curator.ueberauth.install.ex @@ -65,7 +65,7 @@ defmodule Mix.Tasks.Curator.Ueberauth.Install do defp inject_schema_access(%Context{file: file} = context, paths, binding) do unless Context.pre_existing?(context) do - Mix.Generator.create_file(file, Mix.Phoenix.eval_from(paths, "priv/templates/curator.ueberauth.install/context.ex", binding)) + raise "No context to inject into" end paths @@ -79,7 +79,7 @@ defmodule Mix.Tasks.Curator.Ueberauth.Install do defp inject_tests(%Context{test_file: test_file} = context, paths, binding) do unless Context.pre_existing_tests?(context) do - Mix.Generator.create_file(test_file, Mix.Phoenix.eval_from(paths, "priv/templates/curator.ueberauth.install/context_test.exs", binding)) + raise "No context tests to inject into" end paths diff --git a/mix.exs b/mix.exs index 7045390..ad56b47 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Curator.Mixfile do use Mix.Project - @version "0.2.2" + @version "0.2.3" @url "https://github.com/curator-ex/curator" @maintainers [ "Eric Sullivan", diff --git a/priv/templates/curator.api.install/api_error_handler.ex b/priv/templates/curator.api.install/api_error_handler.ex new file mode 100644 index 0000000..fabd019 --- /dev/null +++ b/priv/templates/curator.api.install/api_error_handler.ex @@ -0,0 +1,19 @@ +defmodule <%= inspect context.web_module %>.Auth.ApiErrorHandler do + use <%= inspect context.web_module %>, :controller + + def auth_error(conn, error, _opts) do + conn + |> put_status(403) + |> json(%{error: translate_auth_error(error)}) + end + + # From Guardian.Plug.VerifyHeader + defp translate_auth_error({:invalid_token, :not_found}), do: "Invalid API Token" + + # From Curator.Plug.LoadResource + defp translate_auth_error({:load_resource, :no_resource_found}), do: "Invalid API Token" + + defp translate_auth_error({:load_resource, :no_claims}), do: "No API Token" + + defp translate_auth_error({_type, reason}), do: reason +end diff --git a/priv/templates/curator.api.install/api_guardian.ex b/priv/templates/curator.api.install/api_guardian.ex new file mode 100644 index 0000000..126ba41 --- /dev/null +++ b/priv/templates/curator.api.install/api_guardian.ex @@ -0,0 +1,55 @@ + + +defmodule <%= inspect context.web_module %>.Auth.ApiGuardian do + use Guardian, otp_app: :<%= Mix.Phoenix.otp_app() %>, + token_module: Curator.Guardian.Token.Opaque + + def subject_for_token(resource, _claims) do + sub = to_string(resource.id) + {:ok, sub} + end + + def resource_from_claims(claims) do + claims["sub"] + |> <%= inspect context.module %>.get_user() + end + + @behaviour Curator.Guardian.Token.Opaque.Persistence + + # TODO: Should this be a hashed token_id? + # Any issue with timing attacks? + def get_token(token_id) do + <%= inspect context.module %>.get_token_by_id(token_id) + end + + # NOTE: We pull user_id & description our of claims + # We will also use the sub in place of user_id + # Finally, the token is set here (to a random string) + def create_token(claims) do + user_id = Map.get(claims, "user_id") || Map.get(claims, "sub") + description = Map.get(claims, "description") + + claims = claims + |> Map.drop(["user_id", "description"]) + + token = Curator.Guardian.Token.Opaque.token_id() + + attrs = %{ + "claims" => claims, + "user_id" => user_id, + "description" => description, + "token" => token, + } + + <%= inspect context.module %>.create_token(attrs) + end + + def delete_token(token_id) do + case get_token(token_id) do + {:ok, token} -> + <%= inspect context.module %>.delete_token(token) + result -> + result + end + end +end diff --git a/priv/templates/curator.api.install/api_pipeline.ex b/priv/templates/curator.api.install/api_pipeline.ex new file mode 100644 index 0000000..460a43e --- /dev/null +++ b/priv/templates/curator.api.install/api_pipeline.ex @@ -0,0 +1,11 @@ + + +defmodule <%= inspect context.web_module %>.Auth.Curator.ApiPipeline do + use Plug.Builder + + plug Guardian.Plug.Pipeline, module: <%= inspect context.web_module %>.Auth.ApiGuardian, + error_handler: <%= inspect context.web_module %>.Auth.ApiErrorHandler + + plug Guardian.Plug.VerifyHeader + plug Curator.Plug.LoadResource +end diff --git a/priv/templates/curator.api.install/schema.ex b/priv/templates/curator.api.install/schema.ex new file mode 100644 index 0000000..36c7cb6 --- /dev/null +++ b/priv/templates/curator.api.install/schema.ex @@ -0,0 +1,22 @@ +defmodule <%= inspect schema.module %> do + use Ecto.Schema + import Ecto.Changeset + + schema "auth_tokens" do + field :claims, :map + field :description, :string + field :token, :string + + belongs_to :user, <%= inspect context.module %>.User + + timestamps() + end + + @doc false + def changeset(token, attrs) do + token + |> cast(attrs, [:token, :description, :claims, :user_id]) + |> validate_required([:token, :claims, :user_id]) + |> unique_constraint(:token) + end +end diff --git a/priv/templates/curator.api.install/schema_access.ex b/priv/templates/curator.api.install/schema_access.ex new file mode 100644 index 0000000..b965aa1 --- /dev/null +++ b/priv/templates/curator.api.install/schema_access.ex @@ -0,0 +1,9 @@ + + def get_token_by_id(token_id) do + case Repo.get_by(Token, %{token: token_id}) do + nil -> + {:error, :not_found} + token -> + {:ok, token} + end + end diff --git a/test/curator/guardian/token/opaque_test.exs b/test/curator/guardian/token/opaque_test.exs new file mode 100644 index 0000000..cbc1cca --- /dev/null +++ b/test/curator/guardian/token/opaque_test.exs @@ -0,0 +1,236 @@ +defmodule Curator.Guardian.Token.OpaqueTest do + @moduledoc false + + use ExUnit.Case, async: true + + @token_module Curator.Guardian.Token.Opaque + + defmodule Token do + use Ecto.Schema + import Ecto.Changeset + + schema "auth_tokens" do + field :claims, :map + field :description, :string + field :token, :string + field :user_id, :integer + + timestamps() + end + + @doc false + def changeset(token, attrs) do + token + |> cast(attrs, [:token, :description, :claims, :user_id]) + |> validate_required([:token, :claims, :user_id]) + end + end + + # Not quite ecto... + defmodule Repo do + @token_id "TEST" + + @token %Token{ + token: @token_id, + user_id: 1, + claims: %{ + "typ" => "access", + "sub" => "1", + "something_else" => "foo" + }, + } + + def insert(changeset) do + token = changeset + |> Ecto.Changeset.apply_changes() + + if Map.get(token.claims, :error) do + {:error, :invalid} + else + {:ok, token} + end + end + + def get_by(Token, %{token: @token_id}) do + @token + end + + def get_by(_mod, _params) do + nil + end + end + + defmodule Auth do + alias Curator.Guardian.Token.OpaqueTest.Token + alias Curator.Guardian.Token.OpaqueTest.Repo + + def create_token(attrs \\ %{}) do + struct(Token, %{}) + |> Token.changeset(attrs) + |> Repo.insert() + end + + def delete_token(%Token{} = token) do + Repo.delete(token) + end + end + + defmodule Impl do + use Guardian, + otp_app: :curator, + token_module: Curator.Guardian.Token.Opaque + + alias Curator.Guardian.Token.OpaqueTest.Auth + alias Curator.Guardian.Token.OpaqueTest.Repo + + def subject_for_token(user, _claims) do + sub = to_string(user.id) + {:ok, sub} + end + + def resource_from_claims(claims) do + claims["sub"] + |> get_user() + end + + @behaviour Curator.Guardian.Token.Opaque.Persistence + + def get_token(token_id) do + case Repo.get_by(Token, %{token: token_id}) do + nil -> + {:error, :not_found} + token -> + {:ok, token} + end + end + + def create_token(claims) do + user_id = Map.get(claims, "user_id") || Map.get(claims, "sub") + description = Map.get(claims, "description") + + claims = claims + |> Map.drop(["user_id", "description"]) + + token = Curator.Guardian.Token.Opaque.token_id() + + attrs = %{ + "claims" => claims, + "user_id" => user_id, + "description" => description, + "token" => token, + } + + Auth.create_token(attrs) + end + + def delete_token(token_id) do + case Repo.get_by(Token, %{token: token_id}) do + nil -> + {:error, :not_found} + token -> + Auth.delete_token(token) + end + end + + def get_user(1) do + {:ok, %{id: 1}} + end + + def get_user(_) do + {:error, :not_found} + end + end + + @token_id "TEST" + @invalid_token_id "1234" + + @claims %{ + "typ" => "access", + "sub" => "1", + "something_else" => "foo" + } + + describe "peek" do + test "with a nil token" do + result = @token_module.peek(Impl, nil) + + assert result == nil + end + + test "with a valid token" do + result = @token_module.peek(Impl, @token_id) + + assert result == %{ + claims: @claims + } + end + + test "with an invalid token" do + result = @token_module.peek(Impl, @invalid_token_id) + + assert result == nil + end + end + + describe "create_token" do + test "(when valid) creates a token " do + {:ok, _token} = @token_module.create_token(Impl, @claims) + end + + test "(when invalid) returns an error" do + {:error, :invalid} = @token_module.create_token(Impl, %{error: "invalid"}) + end + end + + describe "decode_token" do + test "returns the claims when it exists" do + {:ok, @claims} = @token_module.decode_token(Impl, @token_id) + end + + test "returns an error when it doesn't exists" do + {:error, :not_found} = @token_module.decode_token(Impl, @invalid_token_id) + end + end + + describe "build_claims" do + @user %{id: "1"} + + test "it adds some fields" do + {:ok, result} = @token_module.build_claims(Impl, @user, "1") + + assert result["typ"] == "access" + assert result["sub"] == "1" + end + + test "it keeps other fields that have been added" do + assert {:ok, result} = @token_module.build_claims(Impl, @user, "1", %{my: "claim"}) + assert result["my"] == "claim" + end + + test "sets to the default for the token type" do + assert {:ok, result} = @token_module.build_claims(Impl, @user, "1", %{}) + assert result["typ"] == "access" + + assert {:ok, result} = @token_module.build_claims(Impl, @user, "1", %{}, token_type: "refresh") + assert result["typ"] == "refresh" + end + end + + describe "verify_claims" do + test "it returns the claims" do + assert {:ok, @claims} = @token_module.verify_claims(Impl, @claims, []) + end + end + + describe "refresh" do + test "returns an error" do + assert {:error, :not_applicable} = @token_module.refresh(Impl, @token_id, []) + end + end + + describe "exchange" do + test "returns an error" do + assert {:error, :not_applicable} = @token_module.exchange(Impl, @token_id, "access", "refresh", []) + end + end +end