Hey! I recorded two video courses!
If you like this article, you will also like the courses! Check them out here!
If you want to secure your users’ accounts beyond simple username and password authentication, U2F is arguably the most secure multi-factor authentication method you can offer. In order to log in or register, your user needs to authenticate using a physical device like a YubiKey. After entering the username and password, the user needs to authorize the physical device to accept and solve a challenge given to it from your application. If the device answers correctly to the challenge, the user is logged in successfully. If the user does not have the correct physical devise, she won’t be able to log in.
By having a physical device as a multi-factor, you can prevent unauthorized access to a user’s account even if an attacker gained access to the user’s username and password. Without the physical device, the attacker won’t be able to log in, end of story. The U2F-method secures accounts with such ease and speediness that Google rolled out U2P-devices to all their employees.
So, all this sounds cool, but how can you add a U2F authentication method to your Phoenix LiveView project? Here is how:
🔗 The Setup
Let’s first create a new Phoenix LiveView project. You can skip this step if you have an existing project already. Create a new project with:
mix phx.new app --live
This command will create a new Phoenix LiveView project. Next, let us include the u2f_ex library, which handles registering the U2F-devices and creation of challenges when a user wants to log in. In your mix.exs
, add the following dependency:
def deps do
[
...
{:u2f_ex, "~> 0.4.2"}
]
end
Fetch the dependency with mix deps.get
. Now, you have a project and the necessary dependency, so let’s create the database schemas to register the U2F-devices.
🔗 Enabling SSL
U2F requires an SSL connection and does not allow communication over an unprotected HTTP-connection. Offering a HTTPS-connection is typically not problem in production since we would enable SSL encryption anyway. However, we want to test the U2F authentication locally, which means we have set up a HTTPS-connection to our server locally. We can do that by running the following command to create a new self-signed SSL certificate:
mix phx.gen.cert
This will create a self-signed SSL certificate and store it at priv/cert/selfsigned_key.pem
. Now, enable the SSL endpoint in dev.exs
like this:
# config/dev.exs
config :app, AppWeb.Endpoint,
...
http: [port: 4000],
# Add the following lines
https: [
port: 4001,
cipher_suite: :strong,
keyfile: "priv/cert/selfsigned_key.pem",
certfile: "priv/cert/selfsigned.pem"
]
You can now access your server through https://localhost:4001. If your browser warns you about an insecure or invalid certificate, consider allowing insecure certificates for localhost.
🔗 The Schema for the U2F-Keys
We want to save the U2f-keys given to us by the users for creating challenges for them whenever they try to log in. In your /lib/app
folder, create a new folder called u2f
and add the following schema for the U2F-keys:
# /lib/u2f/u2f_key.ex
defmodule App.U2FKey do
use Ecto.Schema
import Ecto.Changeset
schema "u2f_keys" do
field(:public_key, :string)
field(:key_handle, :string)
field(:version, :string, size: 10, default: "U2F_V2")
field(:app_id, :string)
# Change the following field if you want to connect the U2F-key
# to a user in a different way (e.g. through a 'user_id' instead)
field(:username, :string)
timestamps()
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:public_key, :key_handle, :version, :app_id, :username])
|> validate_required([
:public_key,
:key_handle,
:version,
:app_id,
:username
])
end
end
This schema will save all necessary information about the U2F-key. I added a username
field to fetch the correct U2F-key for the user who wants to log in. If you want to establish a relationship between a user and a U2F-key in a different way, feel free to change this field to e.g. user_id
instead .
Next, let’s create a migration for this schema with:
mix ecto.gen.migration create_u2f_keys
Open the newly create migration and add the following code to it:
defmodule App.Repo.Migrations.CreateU2fKey do
use Ecto.Migration
def change do
create table(:u2f_keys) do
add(:public_key, :string)
add(:key_handle, :string)
add(:version, :string, size: 10, default: "U2F_V2")
add(:app_id, :string)
# Again, change this field to e.g. user_id if you want to
add(:username, :string)
timestamps()
end
end
end
Now, execute the migration with mix ecto.migrate
. This command will create the u2f_keys
table in your database.
🔗 The Context for the U2F-keys
For fetching and creating U2F-keys, the u2f_ex
-library requires us to create a context that implements the U2FEx.PKIStorageBehaviour
behaviour, so let’s create a pki_storage.ex
file and add the following code to it:
# /lib/app/u2f/pki_storage.ex
defmodule App.PKIStorage do
import Ecto.Query
alias App.Repo
alias App.U2FKey
alias U2FEx.PKIStorageBehaviour
@behaviour U2FEx.PKIStorageBehaviour
@impl PKIStorageBehaviour
def list_key_handles_for_user(username) do
keys =
from(u in U2FKey,
where: u.username == ^username,
select: map(u, [:version, :key_handle, :app_id])
)
|> Repo.all()
{:ok, keys}
end
@impl PKIStorageBehaviour
def get_public_key_for_user(username, key_handle) do
from(u in U2FKey,
where: u.username == ^username and u.key_handle == ^key_handle
)
|> Repo.one()
|> case do
nil -> {:error, :public_key_not_found}
%U2FKey{public_key: public_key} -> {:ok, public_key}
end
end
def create_u2f_key(username, %U2FEx.KeyMetadata{} = key_metadata) do
attrs = Map.merge(Map.from_struct(key_metadata), %{username: username})
%U2FKey{}
|> U2FKey.changeset(attrs)
|> Repo.insert()
end
end
The PKIStorage
-module allows us to fetch the key_handles
and the public_key
for a user. It also offers a create_u2f_key/2
-method to register a new U2F-key for a user. Once again, please change the username
parameter to e.g. user_id
, if you establish the connection between u2f_key
and user
differently.
🔗 The Templates for Authentication
Let’s add a simple template, which offers two forms: One form for registering a U2F-Key and one form for logging in with the U2F-key. For brevity, I put both forms into a single template. Feel free to separate them in your project.
# /lib/app_web/live/page_live.html.leex
# Register a U2F-key using a Phoenix LiveView
<section class="phx-hero">
<%= f = form_for :registration, "#", [id: "registration-form", phx_submit: "start_registration", phx_hook: "Registration"] %>
<%= text_input f, :username, placeholder: "Choose a username..." %>
<%= submit "Register" %>
</form>
</section>
# Login with a U2F-Key using a Phoenix Controller
<section class="phx-hero">
<%= f = form_for :login, "#", [id: "login-form", phx_submit: "start_login", phx_hook: "Login"] %>
<%= text_input f, :username, placeholder: "Enter your username..." %>
<%= submit "Login" %>
</form>
</section>
The forms above allow us to register and log in. Let’s create two more templates that show the result of the login attempt.
# /lib/app_web/templates/login/success.html.eex
<section>
<h1>Success!</h1>
<h3>You're logged in as: <%= @username %></h3>
</section>
# /lib/app_web/templates/login/failure.html.eex
<section>
<h1>Error!</h1>
<h3>Could not log you in:</h3>
<h2><%= @error %></h2>
</section>
🔗 The LiveView for Authentication
Now, let’s get to the interesting parts. First, we need to write a LiveView that handles registering a U2F-device and subsequently logging in with it. I will paste the entire module here first and then discuss its functions:
# /lib/app_web/live/page_live.ex
defmodule AppWeb.PageLive do
use AppWeb, :live_view
require Logger
alias App.{PKIStorage, U2FKey}
alias U2FEx.KeyMetadata
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_event(
"start_registration",
%{
"registration" => %{"username" => username}
},
socket
) do
{:ok, %{registerRequests: register_requests}} =
U2FEx.start_registration(username)
socket =
socket
|> assign(:username, username)
|> push_event("register", %{registerRequests: register_requests})
{:noreply, socket}
end
@impl true
def handle_event(
"finish_registration",
%{"response" => device_response},
%{assigns: %{username: username}} = socket
) do
{:ok, %KeyMetadata{} = key_metadata} =
U2FEx.finish_registration(username, device_response)
{:ok, %U2FKey{} = key} =
PKIStorage.create_u2f_key(username, key_metadata)
{:noreply, put_flash(socket, :success, "Registration successful.")}
end
@impl true
def handle_event(
"start_login",
%{
"login" => %{"username" => username}
},
socket
) do
{:ok, %{challenge: challenge, registeredKeys: registered_keys}} =
U2FEx.start_authentication(username)
sign_requests = Enum.map(
registered_keys,
&Map.merge(&1, %{challenge: challenge})
)
socket =
socket
|> assign(:username, username)
|> push_event("login", %{signRequests: sign_requests})
{:noreply, socket}
end
@impl true
def handle_event(
"finish_login",
%{"response" => device_response},
%{assigns: %{username: username}} = socket
) do
encoded_response = Jason.encode!(device_response)
U2FEx.finish_authentication(username, encoded_response)
|> case do
:ok ->
token = Phoenix.Token.sign(AppWeb.Endpoint, "username", username)
callback_path = Routes.login_path(socket, :login, token)
{:noreply, redirect(socket, to: callback_path)}
error ->
Logger.error(inspect(error))
{:noreply, put_flash(socket, :error, "Login failed.")}
end
end
end
The first functions of the LiveView handle the registration of a new U2F-key.
🔗 Registering a U2F Device
User can register their U2F-device with a username and call the start_registration
-handler. The handler simply passes on the username
to the U2FEx
-library, which creates a challenge for the U2F-device and returns registration_requests
. We assign the currently registering username
to the socket and push the registration_requests
back to the JavaScript-Hooks, which I will explain later.
Once a user has authorized the U2F-device to answer the registration_requests
by pressing a button on the device or touching it, the JavaScript-Hooks will send a finish_registration
-event back to the LiveView. The LiveView receives the device response for the presented challenges and passes on the username
and device_response
to the U2FEx
-library. The library verifies the response against the challenge and returns metadata for the U2F-key. We store the metadata in the database by calling the PKIStorage.create_u2f_key/2
-function.
The user has now successfully registered a new U2F-device for the given username
.
🔗 Log in with a U2F Device
The login flow using a U2F-device is similar to the registration flow. A user first sends a login request to the LiveView and provides a username
for which a U2F-device was registered previously. We pass the username
into the U2FEx.start_authentication/1
function, which fetches the registered U2F-key metadata from the database and creates a login-challenge
. We merge every registered_key
with the challenge
since this is what the JavaScript-library handles the frontend communication requires. Eventually, we push the sign_requests
to the JavaScript-Hooks.
Next, the JavaScript-Hooks present the challenge to the U2F-device, the user authorizes the U2F-device to answer the challenge, and eventually the LiveView receives the finish_login
-event, which includes the device response. We present the response to the U2FEx
-library as encoded json
and let the library verify the challenge. If the library returns a success, we know that the user owns the previously registered U2F-device and we can log her in. Unfortunately, we cannot edit the user’s session from inside the LiveView. We need to redirect to a Phoenix.Controller
instead, which then uses put_session/3
, to authenticate the user for subsequent requests. More on this later.
🔗 The JavaScript-Hooks for Authentication
Before we write our Hooks, please download the u2f-api.js-library, which handles the communication with the U2F-device in the frontend. Save its code into /assets/js/u2f-api.js
.
Now, let’s create our LiveView-Hooks. Create the file /assets/js/u2f.js
and paste the following code into it:
// /assets/js/u2f.js
import u2fApi from "u2f-api";
const Registration = {
mounted() {
this.handleEvent("register", ({ registerRequests }) => {
u2fApi.register(registerRequests).then((deviceResponse) => {
this.pushEvent("finish_registration", { response: deviceResponse });
});
});
},
};
const Login = {
mounted() {
this.handleEvent("login", ({ signRequests }) => {
u2fApi.sign(signRequests).then((deviceResponse) => {
this.pushEvent("finish_login", { response: deviceResponse });
});
});
},
};
export { Registration, Login };
As you can see, the Hooks are minimal. They receive the register
and login
-events from the LiveView, pass on the challenges to the u2fApi
, and push the device response back to the LiveView. For brevity, I left out most of the error handling in the Hooks. Thus, in your project, you probably want to handle any errors in the .then()
-callbacks.
Don’t forget to add these Hooks to your LiveSocket in app.js
:
// /assets/js/app.js
import { Registration, Login } from "./u2f";
var Hooks = {};
Hooks.Registration = Registration;
Hooks.Login = Login;
...
let liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks,
});
🔗 Authenticating the User in the Phoenix.Session
After user log in successfully, we redirect them from our LiveView to a LoginController
in order to modify the users’ Plug.Session. Let’s recall the code:
U2FEx.finish_authentication(username, encoded_response)
|> case do
:ok ->
token = Phoenix.Token.sign(AppWeb.Endpoint, "username", username)
callback_path = Routes.login_path(socket, :login, token: token)
{:noreply, redirect(socket, to: callback_path)}
error ->
...
We want to tell the LoginController
that the user logged in successfully and use a Phoenix.Token for this purpose. We create a token after the successful login by signing the username with the secret of our AppWeb.Endpoint
. We then redirect to the login-callback
-route and add the token
as a query parameter.
Now, let’s have a look at the LoginController
, which receives the request:
# /lib/app_web/controller/login_controller.ex
defmodule AppWeb.LoginController do
use AppWeb, :controller
def login(conn, %{"token" => token}) do
Phoenix.Token.verify(AppWeb.Endpoint, "username", token, max_age: 60)
|> case do
{:ok, username} ->
conn
|> put_session(:username, username)
|> render( "success.html", username: username)
{:error, error} ->
render(conn, "failure.html", error: error)
end
end
end
Also, don’t forget to add the LoginController
to your router.ex
:
# /lib/app_web/router.ex
scope "/", AppWeb do
pipe_through :browser
live "/", PageLive, :index
get "/login-callback", LoginController, :login
end
The LoginController
receives the callback with the token
as a parameter. It then verifies the token with the secret from the AppWeb.Endpoint
, a signing salt (username
), and a max_age
. In our case, the creation and verification of the token should not take longer than the redirect, so we set the max_age
to 60 seconds. If you set a higher max_age
you risk that an attacker can reuse the token to sign in at a later point in time. Thus, keep the max_age
small.
After the controller successfully verifies the token, it puts the username into the user’s session and renders the success.html
template. On further requests, the user will now provide her username
in the session
and is therefore fully authenticated. Hurray!
🔗 Caveats
-
The
u2f-api.js
isarchived
by now, but I could not find a better library for communicating with my YubiKey. Google recommends using the WebAuthn-API in the future. However, neither Google nor Yubico gives examples of how to integrate with said API. Yubico’s tutorials use the u2f-api.js library, which is why I decided to use it for this project also. If you know more about which JavaScript-library to use, please contact me on Twitter. -
In your project, don’t use the
username
for registering a U2F-device, since others can register a U2F-device for usernames of others as well. Use auser_id
instead. -
Be aware that you leak the
user_id
to the user’s browser if you usePhoenix.Token
since the tokens are not encrypted. If this is a security issue for you, consider encrypting theuser_id
before redirecting the user to thelogin-callback
.