7.3 KiB · text History b868907
defmodule GitGudWeb.TwoFactorLoginTest do
  @moduledoc """
  The second-factor half of a password login.

  The completion endpoint used to log in anyone whose session held a
  pending `:two_factor_user_id` — which the password alone puts there —
  so a correct password was enough. These pin down that it now needs
  proof of a factor, bound to the user and the pending login.
  """

  use GitGudWeb.ConnCase, async: true

  import Phoenix.LiveViewTest
  import GitGud.AccountsFixtures

  alias GitGud.Accounts
  alias GitGud.Accounts.TwoFactor
  alias GitGud.Accounts.TwoFactorAttempts
  alias GitGudWeb.UserAuth

  setup do
    user = user_fixture() |> set_password()
    secret = TwoFactor.new_secret()
    {:ok, codes} = TwoFactor.enable(user, secret, NimbleTOTP.verification_code(secret))

    %{user: Accounts.get_user!(user.id), secret: secret, codes: codes}
  end

  # Right password, no second factor yet.
  defp password_stage(conn, user) do
    conn =
      post(conn, ~p"/users/log-in", %{
        "user" => %{"email" => user.email, "password" => valid_user_password()}
      })

    assert redirected_to(conn) == ~p"/users/log-in/two-factor"
    refute get_session(conn, :user_token)
    conn
  end

  defp submit_code(lv, code) do
    lv |> element("#two-factor-form") |> render_submit(%{"code" => code})
  end

  defp logged_in?(conn), do: is_binary(get_session(conn, :user_token))

  describe "completion without a verified factor" do
    test "the password stage alone does not log in", %{conn: conn, user: user} do
      conn = password_stage(conn, user)

      conn = get(conn, ~p"/users/log-in/two-factor/complete?#{[u: user.id]}")

      refute logged_in?(conn)
      assert redirected_to(conn) == ~p"/users/log-in"
    end

    test "a proof for another user is refused", %{conn: conn, user: user} do
      conn = password_stage(conn, user)
      nonce = get_session(conn, :two_factor_nonce)
      other = user_fixture()

      token = UserAuth.sign_two_factor_proof(other, nonce)
      conn = get(conn, ~p"/users/log-in/two-factor/complete?#{[t: token]}")

      refute logged_in?(conn)
    end

    test "an expired proof is refused", %{conn: conn, user: user} do
      conn = password_stage(conn, user)
      nonce = get_session(conn, :two_factor_nonce)

      token =
        Phoenix.Token.sign(
          GitGudWeb.Endpoint,
          "two-factor-verified",
          %{
            "u" => user.id,
            "n" => nonce
          },
          signed_at: System.system_time(:second) - 600
        )

      conn = get(conn, ~p"/users/log-in/two-factor/complete?#{[t: token]}")

      refute logged_in?(conn)
    end

    test "a proof from one pending login doesn't carry over to another",
         %{conn: conn, user: user} do
      first = password_stage(conn, user)
      token = UserAuth.sign_two_factor_proof(user, get_session(first, :two_factor_nonce))

      second = password_stage(build_conn(), user)
      second = get(second, ~p"/users/log-in/two-factor/complete?#{[t: token]}")

      refute logged_in?(second)
    end

    test "a WebAuthn flag for another user is refused", %{conn: conn, user: user} do
      other = user_fixture()

      conn =
        conn
        |> init_test_session(%{two_factor_user_id: user.id, webauthn_verified_user_id: other.id})
        |> get(~p"/users/log-in/two-factor/complete")

      refute logged_in?(conn)
    end
  end

  describe "completion with a verified factor" do
    test "a TOTP code that finished one login can't finish another",
         %{conn: conn, user: user, secret: secret} do
      code = NimbleTOTP.verification_code(secret)

      {:ok, lv, _html} = live(password_stage(conn, user), ~p"/users/log-in/two-factor")
      assert {:error, {:redirect, _}} = submit_code(lv, code)

      {:ok, lv, _html} = live(password_stage(build_conn(), user), ~p"/users/log-in/two-factor")
      html = submit_code(lv, code)
      assert html =~ "didn't match"
    end

    test "a TOTP code logs in", %{conn: conn, user: user, secret: secret} do
      conn = password_stage(conn, user)
      {:ok, lv, _html} = live(conn, ~p"/users/log-in/two-factor")

      assert {:error, {:redirect, %{to: to}}} =
               submit_code(lv, NimbleTOTP.verification_code(secret))

      conn = get(conn, to)
      assert logged_in?(conn)
      assert redirected_to(conn) == ~p"/"
      refute get_session(conn, :two_factor_user_id)
      refute get_session(conn, :two_factor_nonce)
    end

    test "a used completion link can't be replayed into a later login",
         %{conn: conn, user: user, secret: secret} do
      conn = password_stage(conn, user)
      {:ok, lv, _html} = live(conn, ~p"/users/log-in/two-factor")

      {:error, {:redirect, %{to: to}}} = submit_code(lv, NimbleTOTP.verification_code(secret))
      assert logged_in?(get(conn, to))

      # Someone holding the password and the old link, in a new login.
      replay = get(password_stage(build_conn(), user), to)
      refute logged_in?(replay)
    end

    test "a recovery code logs in, once", %{conn: conn, user: user, codes: [code | _]} do
      conn = password_stage(conn, user)
      {:ok, lv, _html} = live(conn, ~p"/users/log-in/two-factor")
      render_click(lv, "switch_mode", %{"mode" => "recovery"})

      {:error, {:redirect, %{to: to}}} = submit_code(lv, code)
      assert logged_in?(get(conn, to))

      conn = password_stage(build_conn(), user)
      {:ok, lv, _html} = live(conn, ~p"/users/log-in/two-factor")
      render_click(lv, "switch_mode", %{"mode" => "recovery"})

      assert submit_code(lv, code) =~ "didn't match"
    end

    test "a WebAuthn assertion logs in", %{conn: conn, user: user} do
      # What WebauthnController.auth_finish/2 leaves behind on success.
      conn =
        conn
        |> init_test_session(%{
          two_factor_user_id: user.id,
          two_factor_nonce: "n",
          webauthn_verified_user_id: user.id
        })
        |> get(~p"/users/log-in/two-factor/complete")

      assert logged_in?(conn)
      refute get_session(conn, :webauthn_verified_user_id)
    end
  end

  describe "attempt limit" do
    test "too many wrong codes abandon the pending login",
         %{conn: conn, user: user, secret: secret} do
      conn = password_stage(conn, user)
      {:ok, lv, _html} = live(conn, ~p"/users/log-in/two-factor")

      for _ <- 1..(TwoFactorAttempts.per_login_limit() - 1) do
        assert submit_code(lv, "nope00") =~ "didn&#39;t match"
      end

      assert {:error, {:redirect, %{to: "/users/log-in"}}} = submit_code(lv, "nope00")

      # The same pending login can't come back for more guesses, even
      # with the right code.
      assert {:error, {:redirect, %{to: "/users/log-in"}}} =
               live(conn, ~p"/users/log-in/two-factor")

      # Signing in with the password again starts a fresh budget.
      conn = password_stage(build_conn(), user)
      {:ok, lv, _html} = live(conn, ~p"/users/log-in/two-factor")

      assert {:error, {:redirect, %{to: to}}} =
               submit_code(lv, NimbleTOTP.verification_code(secret))

      assert logged_in?(get(conn, to))
    end

    test "failures across logins are capped per user", %{user: user} do
      for i <- 1..TwoFactorAttempts.per_user_limit() do
        TwoFactorAttempts.record_failure(user.id, "login-#{i}")
      end

      assert TwoFactorAttempts.locked?(user.id, "a-fresh-login")
      refute TwoFactorAttempts.locked?(user_fixture().id, "a-fresh-login")
    end
  end
end