Passwordless Authentication and Magic Links

01/08/2019


Foundations
MessageBus / EventBus

Overview

The new trend in authentication, in addition to 2FA, is passwordless login. This method involves the user submitting their email and getting a “magic link” emailed to them which would authenticate them when they visited it.

The biggest argument against this method is “what if your email is compromised?”

The common sense response is, well, you’re screwed. Think about it, traditional authentication systems require some sort of username/email and a password to login. But, there’s also a way to recover that password, typically via… email.

The benefits far outweigh the negatives with passwordless authentication, the biggest is eliminating the need for account recovery mechanisms like “forgot password”. This also simplifies the registration process, you no longer need to verify if an email has been used before (which also removes a vector of compromising an account). Instead, we can silently fail and only start registration if the user clicks the magic link. This will also cut down on spam registrations in the database.

One thing we do have to consider is spamming an email address, to prevent this we just limit the number of calls that can be made to the endpoint per minute from a particular device or email.

The Plan

The user will submit their email address and receive an email with a magic link containing a JWT token (ie example.com/auth/{token}).

POST /api/sessions

REQUEST

{
  "data": {
    "attributes": {
      "email": "[email protected]"
    }
  }
}

RESPONSE: 201 created

The client would then perform another POST /api/session, this time with the token from the link.

{
  "data": {
    "attributes": {
      "token": "token from email"
    }
  }
}

The server will validate the token and send back the users actual auth token to be used in subsequent requests.

At this point, we’ve verified that the email is valid and the token sent to the email is valid, so now we can return the user, or create one.

Public API Interface

To get started, let’s write a controller test and define what the public interface will look like. Make sure tests are running so we can keep track of our progress by executing docker-compose run --rm server mix test.watch.

NOTE: If you don’t have a mix test.watch command, be sure to checkout the testing before continuing as we will be using it as our foundation here.

For our first test we want request a “magic link” by providing an email address. Because we don’t want to reveal too much information to an intruder, the api response should be a 201 created with an empty body.

# server/src/test/get_social_web/controllers/session_controller_test.exs
defmodule GetSocialWeb.SessionControllerTest do
  use GetSocialWeb.ApiCase, resource_name: :session

  describe "request a magic link" do
    test "create", %{ conn: conn } do
      response = conn
        |> request_create(%{ email: "[email protected]" })
        |> json_response(201)

      assert response == nil
    end
  end
end

If we run our tests, we will get an (UndefinedFunctionError) function GetSocialWeb.Router.Helpers.session_path/2 is undefined or private. We can fix that by adding our /sessions route.

# server/src/lib/get_social_web/router.ex
defmodule GetSocialWeb.Router do
  # ...

  scope "/api", GetSocialWeb do
    # ...

    post "/sessions", SessionController, :create
  end
end

With the route added, we should now get a (UndefinedFunctionError) function GetSocialWeb.SessionController.init/1 is undefined (module GetSocialWeb.SessionController is not available). Let’s create our GetSocialWeb.SessionController and add the create\2 method that will just set the status to 201 and return an empty response.

# server/src/lib/get_social_web/controllers/session_controller.ex
defmodule GetSocialWeb.SessionController do
  use GetSocialWeb, :controller

  def create(conn, _) do
    conn
    |> put_status(:created)
    |> json(nil)
  end
end

Now our tests should pass. Let’s next test creating a session from the magic link’s token. When we create a session with a token, we expect to get back the details that our application can use to authenticate requests to the api. Bare minimum we will need an “authentication token” (auth_token), for this example we will also expect an “email address” (email).

For now we will return a fake token until we implement the token generation.

# server/src/test/get_social_web/controllers/session_controller_test.exs
defmodule GetSocialWeb.SessionControllerTest do
  # ...

  describe "start session from token" do
    setup %{ conn: conn } do
      %{ token: "12345" }
    end

    test "when valid", %{ conn: conn, token: token } do
      response = conn
        |> request_create(%{ token: token })
        |> jsonapi_response(201)
        |> Kernel.get_in(["data", "attributes"])

      assert response["auth-token"]
      assert response["email"] == "[email protected]"
    end
  end
end

With that, our tests should fail because our endpoint is returning nil. To fix this, we will add pattern matching to our SessionController#create\2 method and look for a token attribute.

# lib/get_social_web/controllers/session_controller.ex
defmodule GetSocialWeb.SessionController do
  # ...

  def create(conn, %{ "data" => %{ "token" => _token }}) do
    conn
    |> put_status(:created)
    |> json(%{
      data: %{
        id: "",
        attributes: %{
          "email" => "[email protected]",
          "auth-token" => "some-auth-token"
        }
      },
      jsonapi: %{
        version: "1.0"
      }
    })
  end

  def create(conn, _) do
    # ...
  end
end

Looking good, but, what happens if we don’t send an email address when requesting a passwordless link?

# server/src/test/get_social_web/controllers/session_controller_test.exs
defmodule GetSocialWeb.SessionControllerTest do
  # ...

  describe "request a magic link" do
    # ...

    test "validates input", %{ conn: conn } do
      response = conn
        |> request_create(%{})
        |> jsonapi_response(422)

      assert response == %{
        "errors" => [
          %{
            "detail" => "Email can't be blank",
            "source" => %{
              "pointer" => "/data/attributes/email"
            },
            "title" => "can't be blank"
          }
        ],
        "jsonapi" => %{
          "version" => "1.0"
        }
      }
    end
  end
end

Hmm, still getting a 201. We’d expect to get a 422 with some validation errors. Let’s fix it. We need to create a validation using an ecto changeset, but, we are not persisting the session to the database. To handle for this, we will use ecto’s schemaless changesets.

To get it working, let’s add the logic directly to our create(conn, _) method in our controller for now.

# server/src/lib/get_social_web/controllers/session_controller.ex
defmodule GetSocialWeb.SessionController do
  # ...

  def create(conn, %{ "data" => %{ "token" => _token }}) do
    # ...
  end

  def create(conn, %{ "data" => params }) do
    with {:ok, _} <- conn |> build_passwordless_token(params) do
      conn
      |> put_status(:created)
      |> json(nil)
    else
      {:error, %Ecto.Changeset{} = changeset} ->
        conn
        |> put_status(:unprocessable_entity)
        |> json(JaSerializer.EctoErrorSerializer.format(changeset))
    end
  end

  def build_passwordless_token(_conn, params) do
    params |> create_changeset()
  end

  def create_changeset(%{} = params) do
    data  = %{}
    types = %{email: :string}

    {data, types}
      |> Ecto.Changeset.cast(params, Map.keys(types))
      |> Ecto.Changeset.validate_required([:email])
      |> Ecto.Changeset.apply_action(:insert)
  end
end

If you’ve already followed along with the testing article, then you’ve already ran a generator which would have created a server/src/lib/get_social_web/controllers/fallback_controller.ex and server/src/lib/get_social_web/views/changeset_view.ex. If you have these files, we can clean up our SessionController even more by using the action_fallback method.

Instead of calling the action_fallback macro in each of our controllers, let’s add it to the GetSocialWeb#controller method that is called when we do use GetSocialWeb, :controller.

# server/src/lib/get_social_web.ex
defmodule GetSocialWeb do
  # ...

  def controller do
    quote do
      # ...

      action_fallback GetSocialWeb.FallbackController
    end
  end

  # ...
end

The GetSocialWeb.FallbackController will catch the {:error, changeset} we are handling in the else block, meaning, we can remove it from our create(conn, %{ "data" => params }) method all together.

# server/src/lib/get_social_web/controllers/session_controller.ex
defmodule GetSocialWeb.SessionController do
  # ...

  def create(conn, %{ "data" => params }) do
    with {:ok, _} <- conn |> build_passwordless_token(params) do
      conn
      |> put_status(:created)
      |> json(nil)
    end
  end

  # ...
end

We will need to remove the action_fallback from all of our controllers or we will get a compilation error (RuntimeError) action_fallback can only be called a single time per controller..

We’ll also take this opportunity to clean up our create(conn, %{ "data" => %{ "token" => token }}) method as well. Instead of manually rendering our jsonapi, let’s refactor to use a view:

# server/src/lib/get_social_web/views/session_view.ex
defmodule GetSocialWeb.SessionView do
  use GetSocialWeb, :view

  attributes [:auth_token]
end

With our view created, we can refactor our controller action to render("show.json-api", data: session), we’ll also create a method that will eventually handle restoring a magic link token. For now, we’ll just hard code the response.

# server/src/lib/get_social_web/controllers/session_controller.ex
defmodule GetSocialWeb.SessionController do
  # ...

  def create(conn, %{ "data" => %{ "token" => token }}) do
    with {:ok, session} <- conn |> restore_passwordless_token(token) do
      conn
      |> put_status(:created)
      |> render("show.json-api", data: session)
    end
  end

  # ...

  def restore_passwordless_token(_conn, _token) do
    {:ok, %{ id: Ecto.UUID.generate, email: "[email protected]", auth_token: Ecto.UUID.generate }}
  end
end

Now we’ve got the building blocks for our passwordless authentication, next steps are generating tokens, sending the email, and verifying the tokens.

Before moving on, let’s refactor our extra methods out of our controller and into a couple context modules.

We will start by refactoring our create_changeset method into a Session context. This will help us differentiate our User from an auth_token and the passwordless authentication Session. We will also refactor to use an embedded_schema instead of a schemaless changeset.

# server/src/lib/get_social_web/authentication/session.ex
defmodule GetSocialWeb.Authentication.Session do
  use Ecto.Schema

  import Ecto.Changeset

  alias GetSocialWeb.Authentication.Session

  @primary_key false

  embedded_schema do
    field :email
  end

  def create_changeset(params) do
    %Session{}
      |> cast(params, [:email])
      |> validate_required([:email])
      |> apply_action(:insert)
  end
end

We will move our build_passwordless_token, restore_passwordless_token, and build_passwordless_token_claims methods into a new Authentication module. We also need to update our build_passwordless_token to call create_changeset on GetSocialWeb.Authentication.Session.

# server/src/lib/get_social_web/authentication/authentication.ex
defmodule GetSocialWeb.Authentication do
  alias GetSocial.Guardian
  alias GetSocialWeb.Authentication.Session

  def build_passwordless_token(_conn, params) do
    params |> Session.create_changeset()
  end

  def restore_passwordless_token(conn, token) do
    {:ok, %{ id: Ecto.UUID.generate, email: "[email protected]", auth_token: Ecto.UUID.generate }}
  end
end

Now that we’ve refactored our code into its own module, let’s add some tests. We’ll start with our build_passwordless_token.

# server/src/test/get_social_web/authentication_test.exs
defmodule GetSocialWeb.AuthenticationTest do
  use GetSocialWeb.ConnCase

  alias GetSocialWeb.Authentication

  describe "build_passwordless_token" do

    test "when invalid", %{ conn: conn } do
      assert {:error, %Ecto.Changeset{}} = conn |> Authentication.build_passwordless_token(%{})
    end

    test "when valid", %{ conn: conn } do
      {:ok, result} = conn |> Authentication.build_passwordless_token(%{ email: "[email protected]" })

      # ensure our result is not a struct
      refute result |> Map.has_key?(:__struct__)

      # ensure we get the expected keys
      assert %{ email: "[email protected]", token: token } = result

      # ensure we get a token value
      assert token
    end

  end
end

Our tests are failing because it appears we are not actually generating a token for our passwordless link. Let’s update our implementation…

# server/src/lib/get_social_web/authentication/authentication.ex
defmodule GetSocialWeb.Authentication do
  # ...

  def build_passwordless_token(_conn, params) do
    with {:ok, result} <- params |> Session.create_changeset() do
      result = result
      |> Map.from_struct()
      |> Map.put(:token, Ecto.UUID.generate)

      {:ok, result}
    end
  end

  # ...
end

Fixed! Let’s now add some tests around our restore_passwordless_token method.

# server/src/test/get_social_web/authentication_test.exs
defmodule GetSocialWeb.AuthenticationTest do
  # ...

  describe "restore_passwordless_token" do

    setup %{ conn: conn } do
      conn |> Authentication.build_passwordless_token(%{ email: "[email protected]" })
    end

    test "when invalid", %{ conn: conn } do
      assert {:error, :invalid_token} == conn |> Authentication.restore_passwordless_token("12345")
    end

    test "when valid", %{ conn: conn, token: token } do
      {:ok, result} = conn |> Authentication.restore_passwordless_token(token)

      assert %{ email: "[email protected]", auth_token: auth_token } = result
      assert auth_token
    end

  end
end

We need to fix our implementation when we get an invalid token, for now we will just consider “12345” an invalid token.

# server/src/lib/get_social_web/authentication/authentication.ex
defmodule GetSocialWeb.Authentication do
  # ...

  def restore_passwordless_token(_conn, "12345"), do: {:error, :invalid_token}
  def restore_passwordless_token(_conn, _token) do
    {:ok, %{ id: Ecto.UUID.generate, email: "[email protected]", auth_token: Ecto.UUID.generate }}
  end
end

Oops. Looks like our GetSocialWeb.SessionControllerTest tests are now failing. This is because we are hardcoding “12345” as our valid token. Let’s go update our controller tests to use the new Authentication module, and also add a test to handle invalid tokens.

defmodule GetSocialWeb.SessionControllerTest do
  # ...

  describe "start session from token" do
    setup %{ conn: conn } do
      conn |> GetSocialWeb.Authentication.build_passwordless_token(%{ email: "[email protected]" })
    end

    test "when valid", %{ conn: conn, token: token } do
      response = conn
        |> request_create(%{ token: token })
        |> jsonapi_response(201)
        |> Kernel.get_in(["data", "attributes"])

      assert response["auth-token"]
      assert response["email"] == "[email protected]"
    end

    test "when invalid", %{ conn: conn } do
      response = conn
        |> request_create(%{ token: "12345" })
        |> jsonapi_response(401)

      assert response == %{
        "errors" => [
          %{
            "status" => 401,
            "detail" => "Unauthorized",
            "title" => "Unauthorized"
          }
        ],
        "jsonapi" => %{
          "version" => "1.0"
        }
      }
    end
  end
end

Our tests fail, look’s like we need to update our GetSocialWeb.FallbackController to handle for the {:error, :invalid_token} returned by our GetSocialWeb.Authentication.restore_passwordless_token\2 method.

# server/src/lib/get_social_web/controllers/fallback_controller.ex
defmodule GetSocialWeb.FallbackController do
  # ...

  def call(conn, {:error, :invalid_token}), do: call(conn, {:error, :unauthorized})
  def call(conn, {:error, :unauthorized}) do
    conn
    |> put_status(:unauthorized)
    |> put_view(GetSocialWeb.ErrorView)
    |> render(:"401")
  end
end

Because :unauthorized is a pretty common error case, we will add a method that handles for :unauthorized explicitly. Then, for our :invalid_token, we will just call the :unauthorized error handler.

Now, in our SessionController, we can delete the build_passwordless_token, restore_passwordless_token, and create_changeset methods and instead import them from our new GetSocial.Authentication module.

defmodule GetSocialWeb.SessionController do
  # ...

  import GetSocialWeb.Authentication, only: [
    build_passwordless_token: 2,
    restore_passwordless_token: 2
  ]

  # ...
end

NOTE: Instead of importing the methods, we could add an alias to GetSocialWeb.Authentication and call the methods directly like Authentication.build_passwordless_token and Authentication.restore_passwordless_token. I’ve chose to import them to reduce the number of changes required in the controller to hopefully make this article a little easier to follow.

Our magic link tokens will be JWT’s that will contain the user’s email address and a signature that we can use to verify the tokens authenticity. We will use guardian for generating our JWTs and authentication.

First things first, let’s install guardian:

# server/src/mix.exs
defmodule GetSocial.MixProject do
  # ...

  defp deps do
    [
      # ...
      {:guardian, "~> 1.0"}
    ]
  end

  # ...
end

Then we’ll create our Guardian module:

# server/src/lib/get_social/guardian.ex
defmodule GetSocial.Guardian do
  use Guardian, otp_app: :get_social

  def subject_for_token(_, _claims), do: {:error, :invalid_subject}
  def resource_from_claims(_claims), do: {:error, :invalid_claim}
end

and add guardian to our configuration:

# server/src/config/config.exs

# ...

config :get_social, GetSocial.Guardian,
       issuer: "get_social",
       secret_key: "Secret key. You can use `mix guardian.gen.secret` to get one"

# ...

With Guardian setup, we can start to implement our SessionController#build_passwordless_token\2 method. Let’s write some tests first. We need to expand our “when valid” tests to decode the token returned. We expect to get a JWT with the email address and a typ claim of otp and our sub value to equal the email address.

# server/src/test/get_social_web/authentication_test.exs
defmodule GetSocialWeb.AuthenticationTest do
  # ...

  describe "build_passwordless_token" do
    # ...

    test "when valid", %{ conn: conn } do
      email = "[email protected]"
      {:ok, result} = conn |> Authentication.build_passwordless_token(%{ email: email })

      refute result |> Map.has_key?(:__struct__)
      assert %{ email: ^email, token: token } = result

      assert {:ok, session, claims} = GetSocial.Guardian.resource_from_token(token)
      assert %{ id: _id, email: ^email } = session

      assert claims["typ"] == "otp"
      assert claims["sub"] == email
    end

  end

  # ...
end

We get an argument error because the token we are currently sending isn’t a valid JWT token. Let’s update our implementation to return a JWT.

# server/src/lib/get_social_web/authentication/authentication.ex
defmodule GetSocialWeb.Authentication do
  # ...

  alias GetSocial.Guardian

  # ...

  def build_passwordless_token(conn, params) do
    with {:ok, claims} <- conn |> build_passwordless_token_claims(),
         {:ok, changeset} <- params |> Session.create_changeset(),
         {:ok, token, _claims} <- Guardian.encode_and_sign(changeset, claims, ttl: { 5, :minute }) do

      detail = changeset
      |> Map.from_struct()
      |> Map.put(:token, token)

      {:ok, detail}
    end
  end

  def build_passwordless_token_claims(_conn) do
    {:ok, %{ typ: "otp" }}
  end

  # ...
end

Our tests are now failing because of an {:error, :invalid_subject}, this is because we need to tell guardian how to generate the sub for our Session.

# server/src/lib/get_social/guardian.ex
defmodule GetSocial.Guardian do
  # ...

  alias GetSocialWeb.Authentication.Session

  def subject_for_token(%Session{ email: email }, _claims), do: {:ok, email}

  # ...
end

Our tests are now failing with {:error, :invalid_claim}, this is because guardian doesn’t know how to retrieve the resource from the JWT claims. Let’s update our guardian config.

# server/src/lib/get_social/guardian.ex
defmodule GetSocial.Guardian do
  # ...

  def resource_from_claims(%{ "typ" => "otp", "sub" => email }) do
    {:ok, %{ id: Ecto.UUID.generate, email: email }}
  end
  def resource_from_claims(_claims), do: {:error, :invalid_claim}
end

For now we will just fake a user, later we will add the logic for retrieving or creating a user to this method. With that, we should be able to work on implementing our restore_passwordless_token method. First step is to add some tests that decode the auth_token.

defmodule GetSocialWeb.AuthenticationTest do
  # ...

  describe "restore_passwordless_token" do
    # ...

    test "when valid", %{ conn: conn, token: token } do
      email = "[email protected]"

      {:ok, result} = conn |> Authentication.restore_passwordless_token(token)

      assert %{ email: ^email, auth_token: auth_token } = result
      assert {:ok, user, claims} = GetSocial.Guardian.resource_from_token(auth_token)
      assert %{ id: id } = user

      assert claims["typ"] == "access"
      assert claims["sub"] == id
    end

  end
end

We should get the familiar argument error returned from GetSocial.Guardian.resource_from_token. This is because our Authentication.restore_passwordless_token is returning a GUID instead of a JWT. Let’s also handle the ArgumenrError and roll it into an :invalid_token.

Let’s fix our implementation now.

# server/src/lib/get_social_web/authentication/authentication.ex
defmodule GetSocialWeb.Authentication do
  # ...

  def restore_passwordless_token(conn, token) do
    with {:ok, claims} <- conn |> build_passwordless_token_claims(),
      {:ok, %{ "sub" => email }} <- token |> Guardian.decode_and_verify(claims),
      {:ok, resource, _claims} = token |> Guardian.resource_from_token(claims),
      {:ok, auth_token, _claims} <- resource |> Guardian.encode_and_sign() do

      {:ok, %{ id: resource.id, email: email, auth_token: auth_token }}
    else
      {:error, %ArgumentError{}} -> {:error, :invalid_token}
    end
  end

  # ...
end

We can now remove our hardcoded restore_passwordless_token for “12345”.

The restore_passwordless_token method does a few things:

1. Validates the magic link token: This is handled by guardian automatically with Guardian.resource_from_token\2.

2. Retrieves the user from the database, or creates one: This is handled by GetSocial.Guardian.resource_from_claims\1

3. Returns a new JWT to be used in our authentication header: This is generated by Guardian.encode_and_sign\1.

Now we are getting an {:error, :invalid_subject}. This is because guardian doesn’t know how to convert our psuedo “user” into a JWT subject. We can add another subject_for_token to our guardian module:

defmodule GetSocial.Guardian do
  # ...

  def subject_for_token(%Session{ email: email }, _claims), do: {:ok, email}
  def subject_for_token(%{ id: id }, _claims), do: {:ok, id}
  def subject_for_token(_, _claims), do: {:error, :invalid_subject}

  # ...
end

Our tests are now failing due to a ** (MatchError) no match of right hand side value: {:error, :invalid_claim}. This is because we need to tell guardian how to get the user from the auth_token claims. To do this we need to add a new GetSocial.Guardian#resource_from_claims that expects our typ and sub from the passwordless link token:

defmodule GetSocial.Guardian do
  # ...

  def resource_from_claims(%{ "typ" => "access", "sub" => id }) do
    user = %{ id: id }
    {:ok, user}
  end
  def resource_from_claims(%{ "typ" => "otp", "sub" => email }) do
    # ...
  end

  # ...
end

Testing For Invalid Tokens

Let’s write some tests for invalid tokens, first tests handles invalid JWT token, and the second attempts an “alg: none” attack to verify the contents of a valid JWT.

# server/src/test/get_social_web/authentication_test.exs
defmodule GetSocialWeb.AuthenticationTest do

  # ...

  describe "restore_passwordless_token" do
    # ...

    test "when invalid", %{ conn: conn } do
      assert {:error, :invalid_token} = conn |> Authentication.restore_passwordless_token("12345")
    end

    test "`alg: none` attack", %{ conn: conn } do
      header = %{
          alg: "none",
          typ: "JWT"
        }
        |> Jason.encode!
        |> Base.encode64

      payload = %{
          sub: "[email protected]"
        }
        |> Jason.encode!
        |> Base.encode64

      invalid_token = [header, payload, nil]
        |> Enum.join(".")

      assert {:error, :invalid_token} = conn
        |> Authentication.restore_passwordless_token(invalid_token)
    end
  end
end

Looks like guardian returns a different error from Guardian.resource_from_token\2 for our different inputs. Let’s update restore_passwordless_token so it returns a consistent error tuple. We dont really care “why” it failed at this point, just that the token is invalid.

# server/src/lib/get_social_web/authentication/authentication.ex
defmodule GetSocialWeb.Authentication do
  # ...

  def restore_passwordless_token(conn, token) do
    with {:ok, claims} <- conn |> build_passwordless_token_claims(),
      # ...
    else
      {:error, _} -> {:error, :invalid_token}
    end
  end

  # ...
end

At this point, we’ve generated a passwordless authentication token and generated an authorization token we can use to identify our user.

User Authorization

Now that we have our passwordless authentication flow sorted, let’s work on the authorization flow. To do this, we will need to create a User.

$ docker-compose run --rm server \
    mix phx.gen.schema Accounts.User users \
      email:string:unique
$ docker-compose run --rm server \
    mix ecto.migrate

Let’s first update our tests to expect a User when we restore our auth_token. We’ll also update our test to assert that the token id is equal to the user id.

# server/src/test/get_social_web/authentication_test.exs
defmodule GetSocialWeb.AuthenticationTest do
  # ...

  alias GetSocial.Accounts.User

  # ...

  describe "restore_passwordless_token" do
    # ...

    test "when valid", %{ conn: conn, token: token } do
      # ...

      assert %User{ id: id } = user

      # ...
    end

    # ...
  end
end

With our failing test, we can now update our GetSocial.Guardian#resource_from_claims to return an existing or new user.

# server/src/lib/get_social/guardian.ex
defmodule GetSocial.Guardian do
  # ...

  alias GetSocial.Accounts.User
  # ...

  def subject_for_token(%User{ id: id }, _claims), do: {:ok, id}
  # ...

  def resource_from_claims(%{ "typ" => "access", "sub" => id }) do
    with %User{} = user <- User |> Repo.get(id) do
      {:ok, user}
    else
      _ -> {:error, :invalid_subject}
    end
  end
  def resource_from_claims(%{ "typ" => "otp", "sub" => email }) do
    user = Repo.get_by(User, %{ email: email }) || %User{ email: email }

    user
      |> User.changeset(%{})
      |> Repo.insert_or_update
  end
  # ...
end

Current User

Now that we have our passwordless tokens and can convert those to an auth_token, but, we need to turn the auth_token into a current user. To do this, we will create a controller that will return the current users’ account details.

Let’s first write some tests.

# server/src/test/get_social_web/controllers/account_controller_test.exs
defmodule GetSocialWeb.AccountControllerTest do
  use GetSocialWeb.ApiCase, resource_name: :account

  alias GetSocial.Guardian
  alias GetSocialWeb.Authentication

  setup do
    %{ user: insert(:user) }
  end

  test "when empty", %{ conn: conn, user: user } do
    assert conn
      |> request_show(user)
      |> jsonapi_response(401)
  end

  test "when invalid", %{ conn: conn, user: user } do
    assert conn
      |> put_req_header("authorization", "Bearer INVALID")
      |> request_show(user)
      |> jsonapi_response(401)
  end

  test "when valid", %{ conn: conn, user: user } do
    {:ok, auth_token, _claims} = user |> Guardian.encode_and_sign()

    assert conn
      |> put_req_header("authorization", "Bearer #{auth_token}")
      |> request_show(user)
      |> jsonapi_response(200)
  end

  test "cannot access other user", %{ conn: conn, user: user } do
    %{ id: id } = insert(:user)
    {:ok, auth_token, _claims} = user |> Guardian.encode_and_sign()

    assert conn
      |> put_req_header("authorization", "Bearer #{auth_token}")
      |> request_show(id)
      |> jsonapi_response(401)
  end

  test "when using passwordless token", %{ conn: conn, user: user } do
    {:ok, %{ token: auth_token }} = Authentication.build_passwordless_token(conn, %{ email: "[email protected]" })

    assert conn
      |> put_req_header("authorization", "Bearer #{auth_token}")
      |> request_show(user)
      |> jsonapi_response(401)
  end
end

Our tests are complaining of an undefined or private account_path/2 method. Lets add our new account path to our router, this route will also be run through a new :authorize pipeline in addition to the :api pipeline.

# server/src/lib/get_social_web/router.ex
defmodule GetSocialWeb.Router do
  # ...

  pipeline :authorize do
    # coming soon
  end

  scope "/api", GetSocialWeb do
    pipe_through :api

    # ...
  end

  scope "/api", GetSocialWeb do
    pipe_through [:api, :authorize]

    get "/accounts", AccountController, :index
  end
end

Now we need to create our AccountController, by default we will just render an {:error, :unauthorized}

# server/src/lib/get_social_web/controllers/account_controller.ex
defmodule GetSocialWeb.AccountController do
  use GetSocialWeb, :controller

  def index(_conn, _params) do
    {:error, :unauthorized}
  end
end

With our controller stubbed out, we can create our user_factory.

# server/src/test/support/factory.ex
defmodule GetSocial.Factory do
  # ...

  def user_factory do
    %GetSocial.Accounts.User{
      email: sequence(:email, &"email-#{&1}@example.com")
    }
  end

  # ...
end

Now we can start working on our :authorize pipeline. We will create a new plug that handles all of the guardian setup and assigns a :current_user to our connection.

# server/src/lib/get_social_web/plugs/authorization.ex
defmodule GetSocialWeb.Plugs.Authorization do
  use Guardian.Plug.Pipeline, otp_app: :get_social,
                              module: GetSocial.Guardian,
                              error_handler: __MODULE__

  alias GetSocialWeb.FallbackController

  plug Guardian.Plug.VerifyHeader, realm: "Bearer", claims: %{typ: "access"}
  plug Guardian.Plug.LoadResource, allow_blank: false
  plug Guardian.Plug.EnsureAuthenticated
  plug :assign_current_user

  defp assign_current_user conn, _ do
    conn |> Plug.Conn.assign(:current_user, Guardian.Plug.current_resource(conn))
  end

  def auth_error(conn, _error, _opts), do: FallbackController.call(conn, { :error, :unauthorized })
end

We can now add our GetSocialWeb.Plugs.Authorization plug to the pipeline.

defmodule GetSocialWeb.Router do
  # ...

  pipeline :authorize do
    plug GetSocialWeb.Plugs.Authorization
  end

  # ...
end

Now that our :authorize pipeline is wired up, we can work on returning the current user’s details from our controller.

# server/src/lib/get_social_web/controllers/account_controller.ex
defmodule GetSocialWeb.AccountController do
  use GetSocialWeb, :controller

  def index(conn, _params) do
    render(conn, "index.json-api", data: conn.assigns[:current_user])
  end
end

Our tests are now failing because we need to add the AccountView, for now we will just return the user’s email address.

# server/src/lib/get_social_web/views/account_view.ex
defmodule GetSocialWeb.AccountView do
  use GetSocialWeb, :view

  attributes [:email]
end

I’m not a fan of using conn.assigns[:current_user] to get the user, instead I’d like to have the current user passed into the action like we get with the Conn and params:

# server/src/lib/get_social_web/controllers/account_controller.ex
defmodule GetSocialWeb.AccountController do
  # ...

  def index(conn, _params, user) do
    render(conn, "index.json-api", data: user)
  end
end

To do this we can use some good ol metaprogramming. First thing we need to do is create a new module that we can use in our controllers that require authentication.

# server/src/lib/get_social_web/controllers/authenticated_controller.ex
defmodule GetSocialWeb.AuthenticatedController do

  alias GetSocialWeb.AuthenticatedController

  import Phoenix.Controller, only: [
    action_name: 1
  ]

  defmacro __using__(_) do
    quote do
      def action(conn, _), do: AuthenticatedController.__action__(__MODULE__, conn)
      defoverridable action: 2
    end
  end

  def __action__(controller, conn) do
    action = action_name(conn)

    params = cond do
      function_exported?(controller, action, 3) ->
        [conn, conn.params, conn.assigns[:current_user]]
      true ->
        [conn, conn.params]
    end

    controller
    |> apply(action, params)
  end
end

Then, we can add use GetSocialWeb.AuthenticatedController to our AccountController and add the user as the third argument.

# server/src/lib/get_social_web/controllers/account_controller.ex
defmodule GetSocialWeb.AccountController do
  use GetSocialWeb, :controller
  use GetSocialWeb.AuthenticatedController

  def index(conn, _params, user) do
    render(conn, "index.json-api", data: user)
  end
end

The last little bit to do is to actually send the email that contains the magic link. We will be leveraging the code build in the event bus and email so be sure to visit those articles first.

For our magic link email we still need to build the actual link, but, we still need the host for link.

One option would be to hardcode the host, but, our API will be consumed by a javascript client in the browser. We can actually leverage the origin header that will be sent by the browser.

The Origin request header indicates where a fetch originates from. It doesn’t include any path information, but only the server name. It is sent with CORS requests, as well as with POST requests. It is similar to the Referer header, but, unlike this header, it doesn’t disclose the whole path. - MDN

Using the origin we can link the user back to where they came from.

NOTE: You will want to validate and restrict origins in the application, but, that can be done at a higher level with a plug. Be sure to checkout the CORS article for more on restricting API access from browsers.

We need to update our build_passwordless_token method to pull the

# project/server/src/test/get_social_web/authentication_test.exs

defmodule GetSocialWeb.AuthenticationTest do
  # ...

  setup do
    conn = build_conn()
      |> put_req_header("origin", "http://localhost:4200")

    %{ conn: conn }
  end

  describe "build_passwordless_token" do

    # ...

    test "when valid", %{ conn: conn } do
      email = "[email protected]"
      # ...

      assert %{ email: ^email, token: token, origin: "http://localhost:4200" } = result

      # ...
    end

  end

  # ...
end

Now we can go update our build_passwordless_token to include the origin:

# project/server/src/lib/get_social_web/authentication/authentication.ex

defmodule GetSocialWeb.Authentication do
  # ...

  @spec build_passwordless_token(Plug.Conn.t, map) :: map
  def build_passwordless_token(conn, params) do
    with {:ok, claims} <- conn |> build_passwordless_token_claims(),
         {:ok, changeset} <- params |> Session.create_changeset(),
         {:ok, token, _claims} <- Guardian.encode_and_sign(changeset, claims, ttl: { 5, :minute }) do

      origin = conn
        |> Conn.get_req_header("origin")
        |> List.first

      detail = changeset
        |> Map.from_struct()
        |> Map.put(:token, token)
        |> Map.put(:origin, origin)

      {:ok, detail}
    end
  end

  # ...
end

Now our event payload will have enough information to generate a magic link like "#{origin}/auth/#{token}".

Wiring Up The Events

Let’s first wire up our event triggers for when the user requests a passwordless token and another for when the user authenticates with their token.

We need to register these events to our config.exs:

# project/server/src/config/config.exs

config :event_bus,
  topics: [
    :user_authenticated,
    :user_token_request
  ]

Now we can update our tests:

# project/server/src/test/get_social_web/controllers/session_controller_test.exs
defmodule GetSocialWeb.SessionControllerTest do
  # ...
  use GetSocial.EventCase, otp_app: :get_social

  describe "request a magic link" do
    test "create", %{ conn: conn } do
      # ...

      assert_receive {
        :notify,
        %{
          topic: :user_token_request,
          data: %{
            email: "[email protected]",
            origin: "http://localhost:4200",
            token: _token
          }
        }
      }
    end

    test "validates input", %{ conn: conn } do
      response = conn
        |> request_create(%{})
        |> jsonapi_response(422)

      refute_received {:notify, %{ topic: :user_token_request }}

      # ...
    end
  end

  describe "start session from token" do
    # ...

    test "when valid", %{ conn: conn, token: token } do
      response = conn
        |> request_create(%{ token: token })
        |> jsonapi_response(201)

      assert_receive {
        :notify,
        %{
          topic: :user_authenticated,
          data: %{
            id: id,
            email: email,
            auth_token: auth_token
          }
        }
      }

      assert response == %{
        "data" => %{
          "id" => id,
          "type" => "session",
          "attributes" => %{
            "auth-token" => auth_token
          }
        },
        "jsonapi" => %{
          "version" => "1.0"
        }
      }
    end

    test "when invalid", %{ conn: conn } do
      response = conn
        |> request_create(%{ token: "12345" })
        |> jsonapi_response(401)

      refute_received {:notify, %{ topic: :user_authenticated }}

      # ...
    end
  end
end

With our tests failing we can go add our triggers:

# project/server/src/lib/get_social_web/controllers/session_controller.ex

defmodule GetSocialWeb.SessionController do
  # ...

  alias GetSocial.Events

  # ...

  def create(conn, %{ "data" => %{ "token" => token }}) do
    with {:ok, session} <- conn |> restore_passwordless_token(token) do
      Events.publish(:user_authenticated, session)

      # ...
    end
  end

  def create(conn, %{ "data" => params }) do
    with {:ok, data} <- conn |> build_passwordless_token(params) do
      Events.publish(:user_token_request, data)

      # ...
    end
  end
end

Our tests are now failing because the origin in our event is nil. We need to go update our ApiCase so it will set the origin header.

# project/server/src/test/support/api_case.ex

defmodule GetSocialWeb.ApiCase do
  # ...

  setup tags do
    # ...

    conn = build_conn()
      # ...
      |> put_req_header("origin", "http://localhost:4200")

    # ...
  end

  # ...
end

We can now move onto setting up our email subscriber and sending the email. For that you’ll need to check out the email article.

Adding Auhentication Token Constraints

We want to put some limits on who can redeem the magic link token. To do that, we will add an additional claim to the JWT. This claim will be a “fingerprint” of the connection’s origin and the user-agent. This means, in order for the user to redeem the token they have to visit the link from the same site and with the same browser. You might be tempted to add IP address to the mix, but, that could change between requests.

Like always, we’ll start with the tests:

# project/server/src/test/get_social_web/authentication_test.exs

defmodule GetSocialWeb.AuthenticationTest do
  # ...

  describe "restore_passwordless_token" do

    # ...

    test "constraints", %{ conn: conn } do
      conn1 = conn
        |> put_req_header("origin", "http://localhost:4200")
        |> put_req_header("user-agent", "Chrome")

      {:ok, %{ token: token1 }} = conn1
        |> Authentication.build_passwordless_token(%{ email: "[email protected]" })

      conn2 = conn
        |> put_req_header("origin", "http://example.com")
        |> put_req_header("user-agent", "Chrome")

      {:ok, %{ token: token2 }} = conn2
        |> Authentication.build_passwordless_token(%{ email: "[email protected]" })

      assert {:ok, _} = conn1 |> Authentication.restore_passwordless_token(token1)
      assert {:ok, _} = conn2 |> Authentication.restore_passwordless_token(token2)
      assert {:error, :invalid_token} = conn2 |> Authentication.restore_passwordless_token(token1)
      assert {:error, :invalid_token} = conn1 |> Authentication.restore_passwordless_token(token2)
    end
  end
end

Our new tests will verify that tokens cannot be restored unless the connection’s origin and user-agent that is used to redeem the token is the same as the one that generated the token. With our failing tests we can now go update our implementation.

Luckily, that’s only in a single spot. Our build_passwordless_token_claims method.

defmodule GetSocialWeb.Authentication do
  # ...

  @spec build_passwordless_token_claims(Plug.Conn.t) :: {atom, map}
  def build_passwordless_token_claims(conn) do
    origin = conn |> Conn.get_req_header("origin")
    user_agent = conn |> Conn.get_req_header("user-agent")

    constraints = [origin, user_agent]

    fingerprint = :crypto.hash(:sha256, constraints)
      |> Base.encode16
      |> String.downcase

    {:ok, %{ typ: "otp", fp: fingerprint }}
  end
end
Next Level

© 2020 ThinkAddict.com. All rights reserved.