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).
(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.0"}]
end
- Run the install command
mix curator.install
This 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)
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 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)
The generators aren't perfect (TODO), so finish the installation
Update your router (<my_app_web>/lib/<my_app_web>/router.ex)
```elixir
require Curator.Router
pipeline :browser do
...
plug <MyWebApp>.Auth.Curator.UnauthenticatedPipeline
end
pipeline :authenticated_browser do
... (copy the code from browser)
plug <MyWebApp>.Auth.Curator.AuthenticatedPipeline
end
scope "/", <MyWebApp> do
pipe_through :browser
...
Curator.Router.mount_unauthenticated_routes(<MyWebApp>.Auth.Curator)
end
scope "/", <MyWebApp> do
pipe_through :authenticated_browser
...
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)
```elixir
def view do
quote do
...
import <MyAppWeb>.Auth.CuratorHelper
end
end
```
This allows you to call `current_user(conn)` in your templates
- Configure Guardian in
config.exs
```elixir
config :<my_app_web>, <MyAppWeb>.Auth.Guardian,
issuer: "<my_app_web>",
secret_key: "Secret key. You can use `mix guardian.gen.secret` to get one"
```
- 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] do
Ecto.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}
end
Note: This uses conn as an authenticated connection, so existing tests won't need to be updated.
To test, I created some special routes:
scope "/", <MyAppWeb> do
pipe_through :browser
if Mix.env == :test do
get "/insecure", PageController, :insecure
end
Curator.Router.mount_unauthenticated_routes(<MyAppWeb>.Auth.Curator)
end
scope "/", <MyAppWeb> do
pipe_through :authenticated_browser
if Mix.env == :test do
get "/secure", PageController, :secure
end
Curator.Router.mount_authenticated_routes(<MyAppWeb>.Auth.Curator)
end
Update the PageController:
defmodule <MyAppWeb>.PageController do
use <MyAppWeb>, :controller
def index(conn, _params) do
render conn, "index.html"
end
def secure(conn, _params) do
text conn, "!!!SECURE!!!"
end
def insecure(conn, _params) do
text conn, "INSECURE"
end
end
And wrote tests:
defmodule <MyAppWeb>.PageControllerTest do
use <MyAppWeb>.ConnCase
test "GET /secure (Unauthenticated)", %{unauth_conn: conn} do
conn = get conn, "/secure"
assert redirected_to(conn) == session_path(conn, :new)
end
test "GET /secure (Authenticated)", %{conn: conn} do
conn = get conn, "/secure"
assert text_response(conn, 200) == "!!!SECURE!!!"
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
end
Other scenarios can also be tested here with difference setups (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.
You probably want a session page, so try out Database Authenticatable. Without being able to sign up it won't be too helpful though... Maybe Registerable?
Module Documentation
Ueberauth
Description
Ueberauth Integration
Installation
- Run the install command
mix curator.ueberauth.install
- Add to 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.exs
```elixir
defp deps do
[
{:ueberauth, "~> 0.4"},
{:ueberauth_google, "~> 0.7"},
]
end
```
NOTE: If you're using an umbrella app you'll also need to add ueberauth to your ecto application.
- 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")
```
- Put some links to the providers (<my_app_web>/lib/<my_app_web>/templates/auth/session/new.html.eex)
```elixir
<%= 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 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.Timeoutable
end
defmodule <MyAppWeb>.Auth.Curator.AuthenticatedPipeline do
...
plug Curator.Timeoutable.Plug, timeoutable_module: <MyAppWeb>.Auth.Timeoutable
end
- (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 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
Database Authenticatable
Confirmable
Recoverable
Lockable
Approvable
Extending Curator
Design Pattern
Curator Module
- Want your_verification to run on every request? Check out the pattern in Confirmable. It requires an update to your curator_hooks module:
def before_sign_in(user) do
with :ok <- your_verification(user) do
:ok
end
end
where your_verification returns :ok or {:error, 'message'}
And add a new Plug (in between your LoadResource and EnsureResource Plugs)
def call(conn, opts) do
key = Map.get(opts, :key, Curator.default_key)
case Curator.PlugHelper.current_resource(conn, key) do
nil -> conn
{:error, _error} -> conn
current_resource ->
case your_verification(current_resource) do
:ok -> conn
{:error, error} -> Curator.PlugHelper.clear_current_resource_with_error(conn, error, key)
end
end
end
An example of utilizing Curator.Hooks.after_sign_in can be seen in the Timeoutable Module.
An example of utilizing Curator.Hooks.after_failed_sign_in can be seen in the Lockable Module.
Need More? There's a Curator.Hooks.after_extension callback which can be pattern matched for additional functionality, as seen in the Approvable Module.
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.