Implementing basic authentication

Published November 18, 2018 by Toran Billups

In part 7 of the url shortener adventure we restrict access to the url routes by requiring an authenticated session.

Part 7: Authentication

Today anyone can create a shortened url by doing an http post but ideally we'd like to limit this feature to only those with a valid account. To support this we need to implement a login flow that confirms the user's credentials and creates a session to identify them as authenticated.

We begin by adding GET and POST routes for the new SessionController.

    defmodule ExampleWeb.Router do
      use ExampleWeb, :router
    
      pipeline :browser do
        plug :accepts, ["html"]
      end
    
      scope "/", ExampleWeb do
        pipe_through(:browser)
    
        get "/login", SessionController, :new
        post "/login", SessionController, :create
      end
    end
  

The controller will take the username/password form data and lookup the associated user. If the user is found we push that user's `id` into the session and redirect them. If no user is found we render the login form and no session is generated meaning the user remains unauthenticated.

    defmodule ExampleWeb.SessionController do
      use ExampleWeb, :controller
    
      import Plug.Conn, only: [put_session: 3]
    
      def new(conn, _) do
        render(conn, "new.html")
      end
    
      def create(conn, %{"username" => username, "password" => password}) do
        case Example.User.get_by_username_and_password(:user, username, password) do
          nil ->
            render(conn, "new.html")
          id ->
            conn
              |> put_session(:user_id, id)
              |> redirect(to: user_path(conn, :show, "#{id}"))
        end
      end
    end
  

To drive the controller from the browser we need a login form that will allow any registered user to authenticate. We add both a view and the html template to complete the login requirement.

    <h1>Login</h1>
    <form action="/login" method="POST">
      <input name="username" type="text" value="" />
      <input name="password" type="password" value="" />
      <input type="submit" value="Submit" />
    </form>
  

To see this in action we next write an integration test to exercise the router and controller. You can run this from the command line using `mix test test/session_controller_test.exs`.

    defmodule ExampleWeb.SessionControllerTest do
      use ExampleWeb.ConnCase
    
      import Plug.Conn, only: [get_session: 2]
    
      @id "01D3CC"
      @username "toranb"
      @toran %{"id" => @id, "username" => @username}
      @data %{username: @username, password: "abc123"}
    
      test "create will put user id into session and redirect", %{conn: conn} do
        result = post(conn, user_path(conn, :create, %{user: @data}))
        assert json_response(result, 200) == @toran
    
        login = post(conn, session_path(conn, :create, @data))
        assert html_response(login, 302) =~ "redirected"
    
        session_id = get_session(login, :user_id)
        assert session_id == @id
      end
    
    end
  

When you run this test you get an `ArgumentError` that states `session not fetched, call fetch_session/2`. To solve this, simply alter the `browser` pipeline in the router to call `fetch_session`

    defmodule ExampleWeb.Router do
      use ExampleWeb, :router
    
      pipeline :browser do
        plug :accepts, ["html"]
        plug :fetch_session
      end
    
    end
  

After adding `fetch_session` you will see a different `ArgumentError` that states `cannot fetch session without a configured session plug`. To solve this, just include `Plug.Session` in the endpoint file.

    defmodule ExampleWeb.Endpoint do
      use Phoenix.Endpoint, otp_app: :example
    
      plug Plug.Session,
        store: :cookie,
        key: "_example_key",
        signing_salt: "8ixXSdpw"
    
    end
  

Now that we have a passing test it's time to take this login functionality for a test drive with the browser to confirm our view and template are working as designed. Start up Phoenix with IEx using the command `iex -S mix phx.server` and visit `localhost:4000/login` to submit the login form. When successful, the network tab should reveal a session cookie coming back in the http response headers.

Next we can use this session to prevent unauthenticated users from viewing or creating shortened urls. Open the UrlController and add a function to redirect users who don't have a valid session. In this case we are using a special map found on Plug.Conn called `assigns` to extract the `current_user`.

    defmodule ExampleWeb.UrlController do
      use ExampleWeb, :controller
    
      plug :redirect_unauthorized
    
      def redirect_unauthorized(conn, _opts) do
        current_user = Map.get(conn.assigns, :current_user)
        if current_user != nil and current_user.username != nil do
          conn
        else
          conn
            |> redirect(to: session_path(conn, :new))
            |> halt()
        end
      end
    end
  

To populate `conn.assigns` we need a custom authentication plug that will inspect the session and assign `current_user` when that session is properly authenticated.

    defmodule ExampleWeb.Authenticator do
      import Plug.Conn, only: [get_session: 2, assign: 3]
    
      def init(opts), do: opts
    
      def call(conn, _opts) do
        case get_session(conn, :user_id) do
          nil ->
            conn
          id ->
            username = Example.User.get(:user, "#{id}")
            assign(conn, :current_user, %{id: id, username: username})
        end
      end
    end
  

To incorporate this new authentication plug update the `browser` and `api` pipelines in the router.

    defmodule ExampleWeb.Router do
      use ExampleWeb, :router
    
      pipeline :browser do
        plug :accepts, ["html"]
        plug :fetch_session
        plug ExampleWeb.Authenticator
      end
    
      pipeline :api do
        plug :accepts, ["json"]
        plug :fetch_session
        plug ExampleWeb.Authenticator
      end
    end
  

To see the authentication logic in action we next write an integration test to exercise the router, authenticator and controller. You can run this from the command line using `mix test test/authenticator_test.exs`.

    defmodule ExampleWeb.AuthenticatorTest do
      use ExampleWeb.ConnCase
    
      test "login will authenticate the user and redirect unauthenticated requests", %{conn: conn} do
        id = "055577"
        url = "google.com"
        data = %{username: "toranb", password: "abc123"}
        toran = %{"id" => "01D3CC", "username" => "toranb"}
        google = %{"id" => id, "url" => url}
    
        post_result = post(conn, url_path(conn, :create, %{link: %{url: url}}))
        assert html_response(post_result, 302) =~ "redirected"
    
        get_result = get(conn, url_path(conn, :show, id))
        assert html_response(get_result, 302) =~ "redirected"
    
        result = post(conn, user_path(conn, :create, %{user: data}))
        assert json_response(result, 200) == toran
        authenticated = post(result, session_path(conn, :create, data))
        assert html_response(authenticated, 302) =~ "redirected"
    
        post_result = post(authenticated, url_path(conn, :create, %{link: %{url: url}}))
        assert json_response(post_result, 200) == google
    
        get_result = get(authenticated, url_path(conn, :show, id))
        assert json_response(get_result, 200) == google
      end
    end
  

You can also verify the application works end to end with curl. Start up the Phoenix app from the command line with IEx.

    iex -S mix phx.server
  

First make a POST request with curl to create a user you will login with.

    curl --request POST \
    --url http://localhost:4000/api/users \
    --header 'content-type: application/json' \
    --data '{"user": {"username": "toranb", "password": "abc123"}}'
  

Next make a POST request with curl to login with the username and password. Note: I'm using `--verbose` to see the `set-cookie` value returned in the response as we need it for the url controller POST request.

    curl --request POST \
    --url http://localhost:4000/login \
    --data 'username=toranb&password=abc123' \
    --verbose
  

Finally, take the `set-cookie` value from the previous request and make another POST request with curl to insert the url you'd like to shorten with the api.

    curl --request POST \
    --url http://localhost:4000/api/urls \
    --header 'content-type: application/json' \
    --data '{"link": {"url": "google.com"}}' \
    --cookie '_example_key=FOO;'
  

Notice that without the cookie you get redirected.

    curl --request POST \
    --url http://localhost:4000/api/urls \
    --header 'content-type: application/json' \
    --data '{"link": {"url": "google.com"}}'
  

You can track my progress on github commit by commit. If you just want the code for this post checkout this commit.


Buy Me a Coffee

Twitter / Github / Email