Persistent logins in Elixir with Expected
This blog has moved. Please now refer to this article by using this link.
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.