Expected is an Elixir application for login and session management. It enables persistent logins through a cookie, following Barry Jaspan’s Improved Persistent Login Cookie Best Practice. It also provides an API to list and discard sessions.
To use Expected in your app, add this to your dependencies:
{:expected, "~> 0.1.1"}
You must configure Expected in your config.exs
, for instance:
config :expected,
store: :mnesia,
table: :logins,
auth_cookie: "_my_app_auth",
session_store: PlugSessionMnesia.Store,
session_cookie: "_my_app_key"
The mandatory fields are the following:
store
- the login storeauth_cookie
- the persistent authentication cookie namesession_store
- the session store passed toPlug.Session
session_cookie
- thekey
option forPlug.Session
There are also some optional fields:
cookie_max_age
- the authentication cookie max age is seconds (default: 90 days)cleaner_period
- the login cleaner period in seconds (default: 1 day)session_opts
- options passed toPlug.Session
plug_config
- options passed to the plugs inExpected.Plugs
Currently, the built-in stores are :mnesia
and :memory
. :memory
is for
testing purpose only, so please use :mnesia
. An official :ecto
one could
come some day; feel free to ask me if you need it or plan to implement it. You
can also implement another store using the
Expected.Store
specifications.
For the :mnesia
store, you need to add a :table
option to set the Mnesia
table where to store logins. Then, ask mix to create the table for you:
$ mix expected.mnesia.setup
If you want to use a node name or a custom directory for the Mnesia database,
you can take a look at
Mix.Tasks.Expected.Mnesia.Setup
.
You can also create it directly from Elixir using
Expected.MnesiaStore.Helpers.setup!/0
.
This can be useful to include in a setup task to be run in a release
environment.
The authentication cookie contains the username, a serial and a token seperated by dots. The token is usable only once, and is renewed on each successful authentication.
By default, the authentication cookie is valid for 90 days after the last
successful authentication. This can be configured using the cookie_max_age
option in the configuration.
To avoid old logins to accumulate in the store, inactive logins older than the
cookie_max_age
are automatically cleaned by
Expected.Cleaner
once a
day. You can change this period by setting cleaner_period
(in seconds) in the
application configuration.
Expected calls Plug.Session
itself. Therefore, you need to set the session
store in the Expected configuration. For the session management to work, it
must be a server-side session store that stores the session ID in the session
cookie. I’ve written
plug_session_mnesia
, but you
can use whatever server-side session store you want.
The session_cookie
field is passed as key
to Plug.Session
. Every other
specific options you would need to pass to Plug.Session
must be written in
a session_opts
list. For example, to set the table for the :ets
session
store:
config :expected,
...
session_store: :ets,
session_opts: [table: :session],
session_cookie: "_my_app_auth"
You must plug Expected
in you endpoint, and do not plug Plug.Session
yourself:
# Plug Expected
plug Expected
# Do NOT plug Plug.Session
# plug Plug.Session,
# store: PlugSessionMnesia.Store,
# key: "_my_app_key"
Then, in your pipeline, plug Expected.Plugs.authenticate/2
:
defmodule MyAppWeb.Router do
use MyAppWeb, :router
# Import the authenticate/2 plug
import Expected.Plugs, only: [authenticate: 2]
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :authenticate # Plug it after fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
end
...
end
To register a login, use Expected.Plugs.register_login/2
in your login
pipeline, for instance:
case Auth.authenticate(username, password) do
{:ok, user} ->
conn
|> put_session(:authenticated, true)
|> put_session(:current_user, user)
|> register_login() # Call register_login here
|> redirect(to: page)
:error ->
...
end
To associate the login with a user, register_login/2
expects a
:current_user
key featuring a :username
field in the session. For more
information and configuration options, please look at
Expected.Plugs.register_login/2
in the documentation.
When Expected.Plugs.authenticate/2
is called in the pipeline, it does the
following things:
-
It checks wether the session is authenticated, i.e. the
:authenticated
key is set totrue
. If it is the case, it assigns:authenticated
and:current_user
in the connection according to their values in the session, and does nothing more. -
If the session is not authenticated, it checks for an authentication cookie. If there is no authentication cookie, it does nothing more.
-
If there is an authentication cookie, it checks for a login matching with the username and serial in the store. If the cookie is invalid or there is no login in the store, it just deletes the cookie and does nothing more.
-
If there is a matching login, it checks wether the token matches. If it is the case, it sets
:authenticated
totrue
and:current_user
to anExpected.NotLoadedUser
in both the session and the connection assigns. If the token does not match, it deletes all the users’s logins and sessions, puts a flag in the connection assigns, deletes the cookie and does not authenticate.
Expected does not makes any assumptions regarding your user module or how to
load it from the database. When authenticating from a cookie, it puts an
Expected.NotLoadedUser
with the username matching the one in the cookie in
both the session and the connection assings.
If you want the user to be loaded in the session and/or the connection assigns, you should write a plug to do so:
@spec load_user(Plug.Conn.t(), keyword()) :: Plug.Conn.t()
def load_user(conn, _opts \\\\ []) do
case conn.assigns[:current_user] do
# If there has been a successful authentication, there is a
# NotLoadedUser in the connection assigns.
%Expected.NotLoadedUser{username: username} ->
# Get the user from the database.
user = Accounts.get_user!(username)
# Replace the NotLoadedUser with the loaded one in both the session
# and the connection assigns.
conn
|> put_session(:current_user, user)
|> assign(:current_user, user)
# If there is no user to load, do nothing.
_ ->
conn
end
end
Then, plug it in your pipeline:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :authenticate
plug :load_user # Plug it after authenticate
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
end
A token mismatch generally means an old token has been reused. It basically can arrive in two situations:
- the user has rolled back a backup or has cloned his browser profile,
- the user tries to authenticate after some malicious people have stolen the cookie and already authenticated with it.
In the assumption we are in the case (2), all the user’s sessions are deleted
and a flag is put in the connection assigns. You can check after an
authentication attempt if there has been an unexpected token using
Expected.unexpected_token?/1
. It’s up to you to choose wether you should
show an alert to the user. Do not forget to state it can be due to case (1).
To log a user out, simply call Expected.Plugs.logout/2
on the connection. It
will delete the login and its associated session from the stores and their
cookies. For instance, you can imagine the following action for your session
controller:
import Expected.Plugs, only: [logout: 1]
@spec delete(Plug.Conn.t, map) :: Plug.Conn.t
def delete(conn, _params) do
conn
|> logout()
|> redirect(to: "/")
end
Before to contribute on this project, please read the CONTRIBUTING.md.
Copyright © 2018 Jean-Philippe Cugnet
This project is licensed under the MIT license.