Sentry

"Sentry provides a set of helpers and conventions that will guide you in leveraging Elixir modules to build a simple, robust authorization system." - Inspired by elabs/pundit

TODOs

Installation

Add sentry to your list of dependencies in mix.exs:

def deps do
  [{:sentry, "~> 0.2"}]
end

Ensure sentry is started before your application:

def application do
  [applications: [:sentry]]
end

Ensure your User model and users table has the following fields:

# web/models/user.ex

defmodule MyApp.User do
  use MyApp.Web, :model

  schema "users" do
    field :email, :string
    field :encrypted_password, :string
    field :password, :string, virtual: true
    field :password_confirmation, :string, virtual: true
    ...
    timestamps
  end
end

Configure Ueberauth and Sentry in config/config.exs

# config/config.exs

# Ueberauth
config :ueberauth, Ueberauth,
  providers: [
    identity: {Ueberauth.Strategy.Identity, [
      callback_methods: ["POST"]
    ]}
  ]

# Sentry
config :sentry, Sentry,
  repo: MyApp.Repo,
  model: MyApp.User # you may use a different model as you like
  # uid_field: :some_id_field \\ defaults to :email
  # password_field: :some_pw_field \\ defaults to :password

Authentication

Sentry leverages ueberauth for the authentication layer with an addition of bcrypt encryption when storing and authenticating user.

Sentry provides useful helpers for working with users on your system

here's an example use case

# web/controllers/auth_controller.ex

defmodule MyApp.AuthController do
  use MyApp.Web, :controller
  use Sentry, :authenticator

  alias MyApp.User

  def request(conn, %{"provider" => "identity"} = _params) do
    render(conn, callback_url: callback_url(conn),
      changeset: User.changeset(%User{}))
  end

  def request(conn, _params) do
    conn
    |> put_status(:not_found)
    |> render(MyApp.ErrorView, "404.html")
  end

  def callback(%{ assigns: %{ ueberauth_failure: fails } } = conn, _) do
    conn
    |> put_flash(:error, "Failed to authenticate.")
    |> redirect(to: "/")
  end

  def callback(conn, %{"provider" => "identity"}) do
    # attempts to authorize user
    # once authorized you can then access the user info with
    # `Sentry.Authenticator.current_user/1`
    # or check if logged in with `Sentry.Authenticator.logged_in?/1`
    case attempt(conn) do
      {:ok, conn} ->
        conn
        |> put_flash(:info, "You've successfully logged in")
        |> redirect(to: "/")
      {:error, reason} ->
        conn
        |> put_flash(:error, reason)
        |> redirect(to: request_path(conn))
    end
  end

  def delete(conn, _params) do
    conn
    |> put_flash(:info, "You've been logged out!")
    |> logout()
    |> redirect(to: "/")
  end
end
# web/router.ex

defmodule MyApp.Router do
  ...
  pipeline :auth do
    plug Ueberauth
  end

  scope "/auth", MyApp do
    pipe_through [:browser, :auth]

    get "/logout", AuthController, :delete
    get "/:provider", AuthController, :request
    get "/:provider/callback", AuthController, :callback
    post "/:provider/callback", AuthController, :callback
  end
end
# web/view/layout_view.ex
defmodule MyApp.LayoutView do
  use MyApp.Web, :view
  # this adds the logged_in?/1 and current_user/1 helper to the view
  use Sentry, :view
end
# web/templates/auth/request.html.eex

<%= form_for @changeset, @callback_url, fn f -> %>
  <%= if f.errors != [] do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below:</p>
      <ul>
        <%= for {attr, message} <- f.errors do %>
          <li><%= humanize(attr) %> <%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="form-group">
    <label>Email</label>
    <%= text_input f, :email, class: "form-control" %>
  </div>

  <div class="form-group">
    <label>Password</label>
    <%= password_input f, :password, class: "form-control" %>
  </div>

  <div class="form-group">
    <%= submit "Login", class: "btn btn-primary" %>
  </div>
<% end %>

Authorization

For authorization, we have the following functions for dealing with it

The bang functions follows Elixir's convention of throwing exceptions and returning values instead of returning tuples like {:ok, result} or {:error, reason}

# web/controllers/auth_controller.ex

defmodule MyApp.AuthController do
  use MyApp.Web, :controller
  use Sentry, :authenticator

  alias MyApp.User

  def request(conn, %{"provider" => "identity"} = _params) do
    # you may pass an optional second parameter as
    # a data that is then accessable on the `AuthPolicy.request/2` action
    case authorize(conn) do
      {:ok, conn} ->
        render(conn, callback_url: callback_url(conn),
               changeset: User.changeset(%User{}))
      {:error, reason} ->
        conn
        |> put_flash(:error, reason)
        |> redirect(to: "/")
    end
  end

  def request(conn, _params) do
    conn
    |> put_status(:not_found)
    |> render(MyApp.ErrorView, "404.html")
  end

  def callback(%{ assigns: %{ ueberauth_failure: fails } } = conn, _) do
    conn
    |> put_flash(:error, "Failed to authenticate.")
    |> redirect(to: "/")
  end

  def callback(conn, %{"provider" => "identity"}) do
    # attempts to authorize user
    # once authorized you can then access the user info with
    # `Sentry.Authenticator.current_user/1`
    # or check if logged in with `Sentry.Authenticator.logged_in?/1`
    case attempt(conn) do
      {:ok, conn} ->
        conn
        |> put_flash(:info, "You&#39;ve successfully logged in")
        |> redirect(to: "/")
      {:error, reason} ->
        conn
        |> put_flash(:error, reason)
        |> redirect(to: request_path(conn))
    end
  end

  def delete(conn, _params) do
    conn
    |> put_flash(:info, "You&#39;ve been logged out!")
    |> logout()
    |> redirect(to: "/")
  end
end

or you can use it as a plug function or even cleaner module

defmod MyApp.AuthController do
  ...

  plug :authorize_action when action in [:request]

  def request(conn, _params) do
    ...
  end

  def authorize_action (conn, _options) do
    case authorize(conn) do
      {:ok, conn} ->
        conn
      {:error, reason} ->
        conn
        |> put_flash(:error, reason)
        |> redirect(to: "/")
    end
  end
end
# web/policies/auth_policy.ex

defmodule MyApp.AuthPolicy do
  use Sentry, :authenticator

  def request(conn, options) do
    # `logged_in?/1` is a helper function from
    # `Sentry.Authenticator` that checks if a user is logged in
    if logged_in?(conn) do
      {:ok, conn}
    else
      {:error, "You&#39;re already logged in"}
    end
  end
end

Authorizing resource/changeset

If you are working on resource/changeset, you might want to use the Sentry.Authorizer.authorize_changeset/2 function as it will use a policy based on the model name you can also use authorize_changeset/2 with plugs.

# web/controllers/user_controller.ex

defmodule PostController do
  use Sentry, :authorizer

  alias MyApp.Post

  def update(conn, %{"id" => id, "post" => post_params}) do
    changeset = Post.changeset(post, post_params)
    # you can pass an optional third argument as an
    # atom to override the function
    # to be executed on the policy for example:
    # authorize_changeset(conn, changeset, :belongs_to_current_user)
    # this will instead run the
    # `PostPolicy.belongs_to_current_user/2` action
    authorize_changeset(conn, changeset)
  end
end
# web/policies/post_policy.ex

defmodule PostPolicy do
  use Sentry, :authenticator

  def update(conn, changeset) do
    ...
  end

  def belongs_to_current_user(conn,
                          %Ecto.Changeset{params: %{user_id: user_id}}) do
    if current_user(conn).id === user_id do
      {:ok, conn}
    else
      {:error, "You&#39;re not authorized to edit this post}
    end
  end
end

License

Sentry is open-sourced software licensed under the MIT license