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'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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
7.3 KiB · text
History
b868907