Curator
An Authentication Framework for Phoenix.
Curator is meant to mimic Devise, as such it provides several modules to accomplish authentication and various aspects of user lifecycle mangement. It's build with a modular architecture that differs from existing Elixir Authentication solutions. Each curator module can be combined to handle various authentication scenarios, passing coordination through a curator module](#curator-module). Under the hood, this uses Guardian for session management.
For an example, see the PhoenixCurator Application
Curator Modules
- Ueberauth: Ueberauth Integration.
- Timeoutable: Session Timeout (after configurable inactivity).
- API: API login (with an opaque token).
(TODO)
- Registerable: A Generator to support user registration.
- Database Authenticatable: Compare a password to a hashed password to support password based sign-in. Also provide a generator for creating a session page.
- Confirmable: Account email verification.
- Recoverable: Reset the User Password.
- Lockable: Lock Account after configurbale count of invalid sign-ins.
- Approvable: Require an approval step before user sign-in.
Installation
-
Add
curatorto your list of dependencies inmix.exs:def deps do[{:curator, "~> 0.2.4"}]end -
Run the install command
mix curator.installThis will generate:
-
A User context, migration, and schema (in the Ecto application if an umbrella)
- A user migration (
priv/repo/migrations/<timestamp>_create_users.exs) - A user schema (
<my_app>/lib/<my_app>/auth/user.ex) - A user context (
<my_app>/lib/<my_app>/auth/auth.ex)
- A user migration (
-
An empty Curator module (
<my_app_web>/lib/<my_app_web>/auth/curator.ex) -
A Guardian Configuration
- A Guardian module (
<my_app_web>/lib/<my_app_web>/auth/guardian.ex) - An error handler (
<my_app_web>/lib/<my_app_web>/controllers/auth/error_handler.ex)
- A Guardian module (
-
A view helper (
<my_app_web>/lib/<my_app_web>/views/auth/curator_helper.ex) -
A Session Controller
- controller (
<my_app_web>/lib/<my_app_web>/controllers/auth/session_controller.ex) - view helper (
<my_app_web>/lib/<my_app_web>/views/auth/curator_helper.ex) - new template (
<my_app_web>/lib/<my_app_web>/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.
- controller (
-
-
The generators aren't perfect (TODO), so finish the installation
-
Update your router (
<my_app_web>/lib/<my_app_web>/router.ex)require Curator.Routerpipeline :browser do...plug <MyWebApp>.Auth.Curator.UnauthenticatedPipelineendpipeline :authenticated_browser do... (copy the code from browser)plug <MyWebApp>.Auth.Curator.AuthenticatedPipelineendscope "/", <MyWebApp> dopipe_through :browser...Insert your unprotected routes here...Curator.Router.mount_unauthenticated_routes(<MyWebApp>.Auth.Curator)endscope "/", <MyWebApp> dopipe_through :authenticated_browser...Insert your unprotected routes here...Curator.Router.mount_authenticated_routes(<MyWebApp>.Auth.Curator)end -
Add the view_helper to your web module (
<my_app_web>/lib/<my_app_web>.ex)def view doquote do...import <MyAppWeb>.Auth.CuratorHelperendendThis allows you to call
current_user(@conn)in your templates -
Configure Guardian in
config.exsconfig :<my_app_web>, <MyAppWeb>.Auth.Guardian,issuer: "<my_app_web>",secret_key: "Secret key. You can use `mix guardian.gen.secret` to get one"and
prod.exsconfig :<my_app_web>, <MyAppWeb>.Auth.Guardian,issuer: "<my_app_web>",allowed_algos: ["HS512"],ttl: { 1, :days },verify_issuer: true,secret_key: {<MyAppWeb>.Auth.Guardian, :fetch_secret_key, []}(NOTE: the sameple prod.exs is one way to keep the
secret_keyout of source code. If you use an alternative technique thefetch_secret_keymethod can be removed from<MyAppWeb>.Auth.Guardian) -
Add to your Auth Context (
<my_app>/lib/<my_app>/auth/auth.ex)def get_user(id) docase Repo.get(User, id) donil -> {:error, :no_resource_found}record -> {:ok, record}endend
-
-
Add a signout link to your layout
<%= if current_user(@conn) do %><%= link "Sign Out", to: session_path(@conn, :delete), method: :delete %><% end %> -
Testing
Update
conn_case.ex:setup tags do:ok = Ecto.Adapters.SQL.Sandbox.checkout(<MyApp>.Repo)unless tags[:async] doEcto.Adapters.SQL.Sandbox.mode(<MyApp>.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 = <MyApp>.Factory.insert(:auth_user){:ok, token, claims} = <MyAppWeb>.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}endNote: This uses
connas an authenticated connection, so existing tests won't need to be updated.To test, I created some special routes:
scope "/", <MyAppWeb> dopipe_through :browserif Mix.env == :test doget "/insecure", PageController, :insecureendCurator.Router.mount_unauthenticated_routes(<MyAppWeb>.Auth.Curator)endscope "/", <MyAppWeb> dopipe_through :authenticated_browserif Mix.env == :test doget "/secure", PageController, :secureendCurator.Router.mount_authenticated_routes(<MyAppWeb>.Auth.Curator)endUpdate the
page_controller.ex:defmodule <MyAppWeb>.PageController douse <MyAppWeb>, :controllerdef secure(conn, _params) dotext conn, "!!!SECURE!!!"enddef insecure(conn, _params) dotext conn, "INSECURE"endendAnd wrote tests in
page_controller_test.exs:defmodule <MyAppWeb>.PageControllerTest douse <MyAppWeb>.ConnCasetest "GET /secure (Unauthenticated)", %{unauth_conn: conn} doconn = get conn, "/secure"assert redirected_to(conn) == session_path(conn, :new)assert get_flash(conn, :error) == "Please Sign In"endtest "GET /secure (Authenticated)", %{conn: conn} doconn = get conn, "/secure"assert text_response(conn, 200) == "!!!SECURE!!!"endtest "GET /secure (Authenticated - User Delete)", %{conn: conn, auth_user: user} do<MyApp>.Auth.delete_user(user)conn = get conn, "/secure"assert redirected_to(conn) == session_path(conn, :new)assert get_flash(conn, :error) == "Please Sign In"endtest "GET /insecure (Unauthenticated)", %{unauth_conn: conn} doconn = get conn, "/insecure"assert text_response(conn, 200) == "INSECURE"endtest "GET /insecure (Authenticated)", %{conn: conn} doconn = get conn, "/insecure"assert text_response(conn, 200) == "INSECURE"endtest "GET /insecure (Authenticated - User Delete)", %{conn: conn, auth_user: user} do<MyApp>.Auth.delete_user(user)conn = get conn, "/insecure"assert redirected_to(conn) == session_path(conn, :new)assert get_flash(conn, :error) == "Please Sign In"endendThese examples can be extended as additional modules are integrated (ex. using Confirmable with a user that hasn't been confirmed).
-
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
Module Documentation
Ueberauth
Description
Ueberauth Integration
Installation
-
Run the install command
mix curator.ueberauth.install -
Add to the curator modules (
<my_app_web>/lib/<my_app_web>/auth/curator.ex)use Curator, otp_app: :my_app_web,modules: [<MyAppWeb>.Auth.Ueberauth,] -
Install Ueberauth and the desired strategies. For example, to add google oauth:
-
Update
mix.exsdefp deps do[{:ueberauth, "~> 0.4"},{:ueberauth_google, "~> 0.7"},]endNOTE: If you're using an umbrella app you'll also need to add ueberauth to your ecto applications
mix.exs. -
Update
config.exsconfig :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") -
Put some links to the providers (
<my_app_web>/lib/<my_app_web>/templates/auth/session/new.html.eex)<%= link "Google", to: ueberauth_path(@conn, :request, "google"), class: "btn btn-default" %>
-
Timeoutable
Description
Session Timeout (after configurable inactivity)
Installation
-
Run the install command
mix curator.timeoutable.install -
Add to the curator modules (
<my_app_web>/lib/<my_app_web>/auth/curator.ex)use Curator, otp_app: :<my_app_web>,modules: [<MyAppWeb>.Auth.Timeoutable,] -
Add to the curator plugs
defmodule <MyAppWeb>.Auth.Curator.UnauthenticatedPipeline do...plug Curator.Timeoutable.Plug, timeoutable_module: <MyAppWeb>.Auth.Timeoutableenddefmodule <MyAppWeb>.Auth.Curator.AuthenticatedPipeline do...plug Curator.Timeoutable.Plug, timeoutable_module: <MyAppWeb>.Auth.Timeoutableend -
(optional) Configure Timeoutable (
<my_app_web>/lib/<my_app_web>/auth/timeoutable.ex)use Curator.Timeoutable, otp_app: :<my_app_web>,timeout_in: 1800 -
Update tests (
<my_app_web>/test/support/conn_case.ex)auth_conn = conn|> Plug.Test.init_test_session(%{guardian_default_token: token,guardian_default_timeoutable: Curator.Time.timestamp(),})This session key usually is set as part of the after_sign_in extension.
-
(optional) Update the ErrorHandler (
<my_app_web>/lib/<my_app_web>/controllers/auth/error_handler.ex)defp translate_error({:timeoutable, :timeout}), do: "You have been signed out due to inactivity"
Registerable (TODO)
Database Authenticatable (TODO)
Confirmable (TODO)
Recoverable (TODO)
Lockable (TODO)
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
-
Run the install command
mix curator.api.install -
Update your router (
<my_app_web>/lib/<my_app_web>/router.ex)require Curator.Routerpipeline :api doplug :accepts, ["json"]plug <MyWebApp>.Auth.Curator.ApiPipelineendscope "/api", <MyWebApp> dopipe_through :api...end -
Testing
Update
conn_case.ex:setup tags do...api_unauth_conn = Phoenix.ConnTest.build_conn() |> Plug.Conn.put_req_header("accept", "application/json"){:ok, token, _claims} = <MyWebApp>.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,}endTo test, I created some special routes:
scope "/api", <MyAppWeb> dopipe_through :apiif Mix.env == :test doget "/secure", PageController, :json_secureendUpdate the
page_controller.ex:defmodule <MyAppWeb>.PageController douse <MyAppWeb>, :controllerdef json_secure(conn, _params) dojson conn, %{data: "SECURE"}endendAnd wrote tests in
page_controller_test.exs:defmodule <MyAppWeb>.PageControllerTest douse <MyAppWeb>.ConnCasedescribe "API" dotest "GET /secure (Unauthenticated)", %{api_unauth_conn: conn} doconn = get conn, "/api/secure"assert json_response(conn, 403) == %{"error" => "No API Token"}endtest "GET /secure (Authenticated)", %{api_auth_conn: conn} doconn = get conn, "/api/secure"assert json_response(conn, 200) == %{"data" => "SECURE"}endtest "GET /secure (Bad Token)", %{api_invalid_conn: conn} doconn = get conn, "/api/secure"assert json_response(conn, 403) == %{"error" => "Invalid API Token"}endendend
Extending Curator (TODO)
Debt
Thanks go out to the Phoenix Team, the original rails gem Devise, Guardian, and the other elixir authentication solutions:
Any decent ideas I credit to them, I was just acting as the curator.