Persistent logins in Elixir with Expected
This article was originally published on Medium.
TD;DR I’ve written an Elixir package to enable persistent logins through an authentication cookie, following Barry Jaspan’s Improved Persistent Login Cookie Best Practice. It is available on hex.pm and GitHub.
After writing my server-side session store using Mnesia, I found a new problem to solve: how should I manage persistent logins? One solution could be setting the session to exist forever, but this is a bad idea. If someone gets my session cookie, he can access to my account and that’s it. I have no way to discard the stolen session. I should have one. More: if someone steals my cookies, I should be informed of that.
I have looked for an authentication solution for browser sessions in Elixir. If there are many JWT-based ones for REST-like APIs, the lone I am aware of for browser sessions is Coherence. It seems really great if you want a framework that generates all the user management for you, but this is not what I was looking for. I feel this approach too monolithic. I want to build and use little tools that does one simple thing and compose them, like in the UNIX philosophy. So I would write mine for persistent logins.
The “best practice”
Before to write anything, I needed to figure how I would handle persistent logins. My initial idea was to have a cookie containing an authentication token, renewed on each successful authentication. But yet, I had to search the Internet for a possible better practice.
My research took me fairly quickly to Barry Jaspan’s Improved Persistent Login Cookie Best Practice. The principle is to store not only a token, but also a username and a serial in the authentication cookie. On a standard login through a user interaction, a new serial and token is randomly generated. Then, when the user’s browser presents the cookie in order to authenticate, the server looks for a username–serial pair in its database. If there is one, it then checks wether the token matches:
- if it is the case, the user is successfully authenticated; a new token is generated while the serial remains the same for this login instance;
- if it is not the case despite the serial beeing correct, it means the token has been re-used. In this event, all the user’s persistent sessions are discarded and he gets notified of a possible attack.
In addition to that, Barry Jaspan advises to use a short-lived standard session cookie, enforced server-side. When the session cookie expires, the user authenticates again thanks to his authentication cookie.
I would add two things: a user should be able to list his current logins and discard them. When a login is discarded, the current session associated with it should also be immediately discarded.
Expected
Lexical note: when I write “session”, I mean the standard session, managed
through Plug.Session
. When I write “login”, I mean a persistent login managed
through Expected.
So here we are: I’ve written an Elixir package and I named it Expected. It is made of three parts:
- a login store, where logins are registered,
- plugs to register logins, authenticate and logout,
- an API to list logins and discard them.
Let’s now explore each of these parts.
The login store(s)
When a user logs in through the login form, an
Expected.Login
is created.
It contains the user’s username, a newly generated serial and token, the session
ID and other metadata such as timestamps and peer information.
Logins must be stored somewhere by Expected. I’ve written a login store using Mnesia, which comes with a mix task to help you create the table:
$ mix expected.mnesia.setup
This is currently the only real store, but that’s up to you to choose wherever
you want to put your logins:
Expected.Store
is a
behaviour with a few callbacks to implement. I’ve even written a module that
automatically generates
tests for your callbacks’
implementation when using it. I also plan to eventually write an Ecto store
when I will feel the need. If you want to use Expected with Ecto and are willing
to help, you can open an issue on GitHub
so we can discuss about a good way to implement it.
The plugs
Expected comes with three
plugs so that login management
is easy on the connection side. Registering a login is simple as plugging
register_login/2
in your login pipeline:
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
It fetches all the information it needs from the session as long as it contains
a current_user
with a username
. Naturally, these fields can be
configured.
Authentication from a cookie is managed by
authenticate/2
,
that you can plug in your browser pipeline:
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
It follows Barry Jaspan’s best practice for you, checking and renewing the cookie if the session is not currently authenticated.
There are yet two actions left to your care because Expected can’t know how to do them:
- load the user from the database after a successful authentication,
- render an error message in case of an unexpected token.
You can achieve this by writing plugs and calling them after the
authenticate/2
plug.
If your users want to logout, which is somewhat fair, you can call
logout/2
on a
connection:
conn
|> logout()
|> redirect(to: "/")
It unregisters the login from the store, deletes the associated session and cookies.
The login management API
As I said earlier, a user should be able to list his logins and discard them. To help you provide your users such a feature, Expected exposes a few functions. The most useful are the two following:
list_user_logins(username :: String.t) :: [Expected.Login.t]
delete_login(username :: String.t, serial :: String.t) :: :ok
The first one returns the list of registered logins for a given user. You can use it to create a login list and show some information about them, like their creation date, their last activity and the IP and user agent of the last successful authentication.
With the second one, you can delete a login. Not only it is deleted from the store, but so is its associated session. The distant logout is thus instantly effective.
Some more technical details
TL;DR In this section, I discuss about some implementation details and difficulties I have encountered. If you do not have much time to spend here, you can jump to the conclusion below.
Old logins cleaning
To avoid old inactive logins to stay forever in the store, Expected is shipped
with a login cleaner. That’s a simple GenServer which routinely calls
Expected.clean_old_logins/1
with the cookie max age as an argument. This way, logins associated with cookies
that should not be valid anymore are effictively deleted server-side.
This function is store-agnostic, but thanks to the
clean_old_logins/2
callback in the store specifications, it can leverage implementation-specific
optimisations.
Session management
For the logout to be guaranteed when a login is discarded, Expected also needs to delete the current session if it exists. To do so, it must know the session ID and the session store to call. Unfortunately these two pieces of information belong to Plug.Session.
The session store configuration is not available application-wide: it is
evaluated at compile time by Plug.Session.init/1
to be passed to
Plug.Session.call/2
then. The session ID exists only for server-side session
stores, and it is stored in a cookie fairly lately in the connection lifetime.
In fact, it is put by a before_send
function registered by Plug.Session
.
Gathering all this information seemed to be a challenge. I even asked myself if
it was a good idea to pursue in this way, as it needed to access some
Plug.Session
internals. Maybe should I wrote a session store into Expected? I
had just written
plug_session_mnesia
and kept
in mind my idea of “one package, one task”, so I was not in peace with this
option. It was kind of a brain teaser and I had many other concerns at the time,
so I made a break.
Eventually, I started again to work on Expected and said: “Hey, let’s just say
that the application developer will use Expected for login and session
management, and let Expected use Plug.Session
itself, internally”.
The Plug.Session
configuration is generally done this way:
plug Plug.Session,
store: :ets,
table: :session,
key: "_my_app_key"
In fact, plug
calls Plug.Session.init/1
during the compilation:
Plug.Session.init(store: :ets, table: :session, key: "_my_app_key")
The result of Plug.Session.init/1
is then passed to Plug.Session.call/2
each
time it is called in the pipeline. Internally, Plug.Session.init/1
also
initialises the session store configuration.
Expected would be a wrapper for Plug.Session
, so I decided to put the session
configuration in the Expected one:
config :expected,
# Login store configuration
store: :mnesia,
table: :logins,
auth_cookie: "_my_app_auth", # Session store configuration
session_store: :ets,
session_opts: [table: :mnesia],
session_cookie: "_my_app_key" # Indeed, this is mapped to :key
As I prefer to load the configuration on the application startup and not at
compile time, I had yet to find a way to store the return value of
Plug.Session.init/1
and some other configuration options. I managed that by
compiling at the application start a configuration module containing one
constant function:
defp compile_config_module do
# Calls Plug.Session.init/1 and other init functions
expected = init_config()
config_module =
quote do
defmodule Expected.Config do
def get, do: unquote(Macro.escape(expected))
end
end
_ = Code.compile_quoted(config_module)
:ok
end
Expected.Config.get/0
returns a configuration map, accessible from anywhere in
the application at the cost of a single function call. Thanks to this, I can
call the session store functions where I need them.
For the session to be availabe on the connection, Plug.Session.call/2
must be
called in your pipeline. As Expected is a wrapper around it, it is simply called
by Expected.call/2
. You just have to plug Expected in your endoint, where you
would otherwise plug Plug.Session
.
The only remaining piece of information to get is the session ID. As I said
earlier in this article, it does exist only for server-side session stores.
That’s a requirement for Expected to work. Plug.Session
puts the session
ID in a cookie thanks to a before_send
function. Reading the documentation, I
found these functions are called in the reverse order they are registered. If I
register a before_send
function before the Plug.Session
one, it will be
called after. Thus, I can fetch the session ID from the response cookie:
def call(conn, _opts) do
expected = config_module().get()
conn
|> put_private(:expected, expected)
|> register_before_send(&before_send(&1))
|> Session.call(expected.session_opts)
end
Conclusion
Writing Expected has been quite a good experience. I have learned about
different subjects, from Plug internals to a bit of meta-programming. It has
also been the opportunity to write another tiny package:
export_private
, which allows to
export private functions witout compiler warnings when you build in test
environment.
Now, Expected is available for you to try on hex.pm and GitHub. It may evolve with a few more security options in the next few months, but overall I’ll try to keep it not too big. This is the start of a journey, so every review or comment is welcomed. Don’t hesitate to reply to this story on Medium or open issues on GitHub if you feel there is something to say. In the end, I hope it can be helpful to some of you. Meanwhile, the next step for me now is to build a Phoenix generator to kickstart my future web applications projects.