Adds authentication and authorization to a Phoenix project. It allows a user to login with a username and password held in the DB or alternatively authenticate against an LDAP server.
The user can have one or more roles associated with them which are loaded from the DB and can be checked within a controller using a plug or within a template.
Add simple_auth to your list of dependencies in mix.exs:
def deps do
[{:simple_auth, "~> 1.8.0"}]
endconfig :simple_auth,
error_view: MyApp.ErrorView,
repo: MyApp.Repo,
user_model: MyApp.User,
username_field: :email, # field in User model and login form that user uses to login (default is :email)
user_session_api: SimpleAuth.UserSession.HTTPSession # See Advanced section for more optionsIn this example we are using an Accounts context
defmodule MyProject.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
alias __MODULE__
schema "users" do
field :email, :string # Must match the field name specified in :username_field config setting
field :crypted_password, :string
field :password, :string, virtual: true
field :roles, {:array, :string}
field :attempts, :integer, default: 0
field :attempted_at, :naive_datetime
timestamps
end
def changeset(%User{} = user, attrs) do
user
|> cast(attrs, [:email, :crypted_password, :attempts, :attempted_at])
|> validate_required([:email, :crypted_password, :attempts])
|> unique_constraint(:email)
|> validate_format(:email, ~r/@/)
|> validate_length(:password, min: 5)
end
endmix ecto.gen.migration create_user
and then set the change function to:
def change do
create table(:users) do
add :email, :string
add :crypted_password, :string
add :roles, {:array, :string}
add :attempts, :integer
add :attempted_at, :naive_datetime, null: true
timestamps
end
create unique_index(:users, [:email])
enddefmodule MyProjectWeb.LoginController do
use MyProjectWeb, :controller
# Import login methods
use SimpleAuth.LoginController
# optional callback
def on_login_success(conn, user, password) do
# additional login logic here
end
# optional callback
def on_logout(conn, user) do
# additional logout logic here
end
# optional callback
def transform_user(conn, user) do
# transform the user that is retrieved from the repo before storing in the session
user
end
endThe callbacks on_login_success/3, on_logout/2 and transform_user/2 can be optionally
implemented if additional logic is required - e.g. logging the user's login/logout times to a DB
or, in the case of transform_user/2, changing the user struct/map type that is stored in the session.
get "/login", LoginController, :show
post "/login", LoginController, :login
delete "/logout", LoginController, :logoutdefmodule MyProject.LoginView do
use MyProjectWeb, :view
endIn login/login.html.eex
<%= form_for @conn, Routes.login_path(@conn, :login), [as: :credentials], fn f -> %>
<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 %>To protect an action in a controller from unauthorised access add the plug authorize for the required actions.
If the user is not logged in they will be redirected to the login page. If they are are logged in but not authorized
for this role, they will be shown an unauthorized page.
import SimpleAuth.AccessControl
plug :authorize, ["ROLE_ADMIN"] when action in [:action_1, :action_2]For protecting API actions invoked with an AJAX request a redirection is not desirable. Therefore for any API pipeline in
you app add the no_redirect_on_unauthorized plug. e.g.
pipeline :api do
plug :accepts, ["json"]
plug :fetch_session # Needed to send the session cookie from the browser
plug :no_redirect_on_unauthorized
endThis will return just a status code of 401 in the case the user is not logged in or not authorized.
In my_project_web.ex add this in the view macro:
import SimpleAuth.AccessControl, only: [current_user: 1, logged_in?: 1, any_granted?: 2]<%= if any_granted?(@conn, ["ROLE_ADMIN"]) do %>
<li class="<%=menu_class @conn, :admin %>"><a href="/admin/students">Admin</a></li>
<% end %><%= if logged_in?(@conn) do %>
<p>Signed in as <%= current_user(@conn).email %></p>
<%= link "Logout", to: "/logout", method: :delete %>
<% else %>
<%= link "Login", to: "/login" %>
<% end %>In a controller any_granted?(conn, ["ROLE_ADMIN"]) can be used as the conn struct is available - this can
be used for finer grained control if plug :authorize is not sufficient.
Elsewhere, for example in a context, any_granted?/2 can also be used passing the user struct.
This can be done from iex
%MyProject.User{email: "joe@bloggs.com",
crypted_password: Comeonin.Bcrypt.hashpwsalt("password"),
roles: ["ROLE_ADMIN"]} |> MyProject.Repo.insertThe User Session API SimpleAuth.UserSession.Assigns can be used in controller tests.
Set it in config/test.exs
config :simple_auth,
user_session_api: SimpleAuth.UserSession.AssignsA user can be set in the connection, rather than in the session, as is the default, for example in the setup:
setup do
{:ok, conn: SimpleAuth.UserSession.put(build_conn(), %User{email: "joe.bloggs@gmail.com", roles:["ROLE_ADMIN"]}}
endNot setting a user simulates no user being logged in.
The following additional config options are available:
login_url- path to redirect to when a user is not logged and tries to access a protected resource. Defaults to "/login".post_login_path- Path to redirect to after a successful login. Defaults to "/".post_logout_path- path to redirect to after logout. Defaults to "/".
The simplest storage for the User Session is
config :simple_auth, user_session_api: SimpleAuth.UserSession.HTTPSessionwhich stores the session in Plug.Conn session. However the following other implementations
are available:
SimpleAuth.UserSession.Memory- The details are stored in a GenServer with just the user_id stored in thePlug.Connsession. Logging out for a given user will kill all that user's sessions and provides a callback that can be invoked on session expiry.SimpleAuth.UserSession.Assigns- A version that can be used in tests which puts the user inconn.assigns(See above).
SimpleAuth.UserSession.Memory supports these additional endpoints.
pipe_through :api
put "/login/refresh", LoginController, :refresh
get "/login/info", LoginController, :infoThese can be used from the browser to refresh the session and also get information about the session, for example to display the remaining session time in the menu bar, and a button to refresh it.
These will both return:
{"status": "ok", "remainingSeconds": 125, "canRefresh": true}or if the session has expired
{"status": "expired"}These imports can also be added to the view: remaining_seconds/1, can_refresh?/1.
These allow checking the remaining seconds of the session and if the user can refresh the session.
For SimpleAuth.UserSession.Memory the following additional configuration options are available:
config :simple_auth, :expiry_callback, {MyApp.LoginController, :session_expired }This is an optional callback to invoke when the session expires or is deleted. It takes 1 parameter which is the user_id whose session has expired. Sessions are checked periodically (every minute) to ensure they are not expired
config :simple_auth, :session_expiry_seconds, 600config :simple_auth, :session_refresh_limit, 5The number of times the user can refresh the session (setting the expiry back to the maximum) 0 = never, nil = infinitely.
Instead of using passwords stored in the DB, an LDAP server can be used to authenticate users. This uses the exldap package.
A User DB table is still used, but rows are automatically inserted for any new users logging in (although this can be disabled - see below).
To use LDAP do the same as the basic configuration (apart from the user model and migrations - see below) and also do the following:
Add exldap as an additional dependency in your mix.exs
def deps do
...
{:exldap, "~> 0.6"},
...
endTo use LDAP add the following additional entry to the config:
config :simple_auth, :authenticate_api, SimpleAuth.Authenticate.LdapAlso add the server, port and ssl LDAP configuration for exldap. For example:
config :exldap, :settings,
server: "my.ldap.server",
port: 389,
ssl: falseCreate a user schema and migrations (as above) but only include the username, roles and timestamp columns.
Passwords and blocking of users should be handled by the LDAP server.
Typically the user will login using a username, e.g. john.smith, however the LDAP server
will probably expect usernames in a different format e.g. mycorp\john.smith or CN=john.smith.
Therefore a module must be provided with a build_ldap_user/1 function to translate the username as entered by the user
to the user field expected by LDAP.
For example for the second example, create a module as follows:
defmodule MyApp.LdapHelper do
@behaviour SimpleAuth.LdapHelperAPI
def build_ldap_user(username), do: "CN=#{username}"
def enhance_user(user, _connection, _opts), do: user
endThe enhance_user/2 function allows enhancing the user structure before it is added to the database. The
function receives the user struct and a connection to LDAP allowing querying of other fields which can then
be populated in the struct. For example to get the display name the following can be used (this example
uses an MS ActiveDirectory server):
def enhance_user(%User{username: username}=user, connection) do
{:ok, search_results} = Exldap.search_field(connection, "dc=mycorp,dc=com", "sAMAccountName", username)
{:ok, first_result} = search_results |> Enum.fetch(0)
display_name = Exldap.search_attributes(first_result, "displayName")
%User{user | display_name: display_name}
endThe enhance_user/3 function allows applying a different logic depending on the received opts. Currently only :new_user is sent back,
to allow distinguishing newly created users from already existent.
def enhance_user(%User{username: username}=user, connection, opts) do
new_user = Keyword.get(opts, :new_user, false)
{:ok, search_results} = Exldap.search_field(connection, "dc=mycorp,dc=com", "sAMAccountName", username)
{:ok, first_result} = search_results |> Enum.fetch(0)
display_name = Exldap.search_attributes(first_result, "displayName")
%User{user | display_name: display_name}
if new_user do
# Something specific for new users
end
endPoint to this module in the config:
config :simple_auth, :ldap_helper_module, MyApp.LdapHelperBy default the Exldap client is used, but you can use your own to provide an implementation for testing.
config :simple_auth, :ldap_client, TestLdapClient