defmodule PlausibleWeb.AuthControllerTest do use PlausibleWeb.ConnCase, async: true use Bamboo.Test use Plausible.Repo import Mox require Logger require Plausible.Billing.Subscription.Status alias Plausible.Auth alias Plausible.Auth.User alias Plausible.Billing.Subscription setup {PlausibleWeb.FirstLaunchPlug.Test, :skip} setup [:verify_on_exit!] describe "GET /register" do test "shows the register form", %{conn: conn} do conn = get(conn, "/register") html = html_response(conn, 200) if ee?() do assert html =~ "Start your 30-day free trial" else assert html =~ "Create your #{Plausible.product_name()} account" end end end describe "POST /login (register_action = register_form)" do test "registering sends an activation link", %{conn: conn} do Repo.insert!( User.new(%{ name: "Jane Doe", email: "user@example.com", password: "very-secret-and-very-long-123", password_confirmation: "very-secret-and-very-long-123" }) ) post(conn, "/login", user: %{ email: "user@example.com", password: "very-secret-and-very-long-123", register_action: "register_form" } ) assert_delivered_email_matches(%{to: [{_, user_email}], subject: subject}) assert user_email == "user@example.com" assert subject =~ "is your Plausible email verification code" end test "user is redirected to activate page after registration", %{conn: conn} do Repo.insert!( User.new(%{ name: "Jane Doe", email: "user@example.com", password: "very-secret-and-very-long-123", password_confirmation: "very-secret-and-very-long-123" }) ) conn = post(conn, "/login", user: %{ email: "user@example.com", password: "very-secret-and-very-long-123", register_action: "register_form" } ) assert redirected_to(conn, 302) == "/activate?flow=register&team_identifier=" end test "logs the user in", %{conn: conn} do user = Repo.insert!( User.new(%{ name: "Jane Doe", email: "user@example.com", password: "very-secret-and-very-long-123", password_confirmation: "very-secret-and-very-long-123" }) ) conn = post(conn, "/login", user: %{ email: "user@example.com", password: "very-secret-and-very-long-123", register_action: "register_form" } ) assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn, :user_token) == token end end describe "GET /register/invitations/:invitation_id" do test "shows the register form", %{conn: conn} do inviter = new_user() site = new_site(owner: inviter) invitation = invite_guest(site, "user@email.co", role: :editor, inviter: inviter) conn = get(conn, "/register/invitation/#{invitation.invitation_id}") assert html_response(conn, 200) =~ "Accept your invitation to join your team." end end describe "POST /login (register_action = register_from_invitation_form)" do setup do inviter = new_user() site = new_site(owner: inviter) invitation = invite_guest(site, "user@email.co", role: :editor, inviter: inviter) user = Repo.insert!( User.new(%{ name: "Jane Doe", email: "user@example.com", password: "very-secret-and-very-long-123", password_confirmation: "very-secret-and-very-long-123" }) ) {:ok, %{site: site, invitation: invitation, user: user}} end test "registering sends an activation link", %{conn: conn} do post(conn, "/login", user: %{ name: "Jane Doe", email: "user@example.com", password: "very-secret-and-very-long-123", password_confirmation: "very-secret-and-very-long-123", register_action: "register_from_invitation_form", team_identifier: "" } ) assert_delivered_email_matches(%{to: [{_, user_email}], subject: subject}) assert user_email == "user@example.com" assert subject =~ "is your Plausible email verification code" end test "user is redirected to activate page after registration", %{conn: conn} do conn = post(conn, "/login", user: %{ name: "Jane Doe", email: "user@example.com", password: "very-secret-and-very-long-123", password_confirmation: "very-secret-and-very-long-123", register_action: "register_from_invitation_form", team_identifier: "" } ) assert redirected_to(conn, 302) == "/activate?flow=invitation&team_identifier=" end test "user with team invite is redirected to activate page after registration", %{conn: conn} do team_identifier = Ecto.UUID.generate() conn = post(conn, "/login", user: %{ name: "Jane Doe", email: "user@example.com", password: "very-secret-and-very-long-123", password_confirmation: "very-secret-and-very-long-123", register_action: "register_from_invitation_form", team_identifier: team_identifier } ) assert redirected_to(conn, 302) == "/activate?flow=invitation&team_identifier=#{team_identifier}" end test "logs the user in", %{conn: conn, user: user} do conn = post(conn, "/login", user: %{ name: "Jane Doe", email: "user@example.com", password: "very-secret-and-very-long-123", password_confirmation: "very-secret-and-very-long-123", register_action: "register_from_invitation_form", team_identifier: "" } ) assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn, :user_token) == token end test "logs the user in and redirects to sites index when email is already verified", %{ conn: conn, user: user } do user |> Ecto.Changeset.change(email_verified: true) |> Repo.update!() conn = post(conn, "/login", user: %{ name: "Jane Doe", email: "user@example.com", password: "very-secret-and-very-long-123", password_confirmation: "very-secret-and-very-long-123", register_action: "register_from_invitation_form", team_identifier: "" } ) assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn, :user_token) == token assert redirected_to(conn, 302) == Routes.site_path(conn, :index) end test "logs the user in, accepts team invite and redirects to team sites index", %{ conn: conn, user: user } do owner = new_user() _site = new_site(owner: owner) team = team_of(owner) invite_member(team, user, role: :viewer, inviter: owner) user |> Ecto.Changeset.change(email_verified: true) |> Repo.update!() conn = post(conn, "/login", user: %{ name: "Jane Doe", email: "user@example.com", password: "very-secret-and-very-long-123", password_confirmation: "very-secret-and-very-long-123", register_action: "register_from_invitation_form", team_identifier: team.identifier } ) assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn, :user_token) == token assert redirected_to(conn, 302) == Routes.site_path(conn, :index, __team: team.identifier) assert_team_membership(user, team, :viewer) end test "logs the user in and redirects to team sites index even when there's no matching team invite", %{ conn: conn, user: user } do owner = new_user() _site = new_site(owner: owner) team = team_of(owner) add_member(team, user: user, role: :viewer) user |> Ecto.Changeset.change(email_verified: true) |> Repo.update!() conn = post(conn, "/login", user: %{ name: "Jane Doe", email: "user@example.com", password: "very-secret-and-very-long-123", password_confirmation: "very-secret-and-very-long-123", register_action: "register_from_invitation_form", team_identifier: team.identifier } ) assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn, :user_token) == token assert redirected_to(conn, 302) == Routes.site_path(conn, :index, __team: team.identifier) end end describe "GET /activate" do setup [:create_user, :log_in] test "if user does not have a code: prompts user to request activation code", %{conn: conn} do conn = get(conn, "/activate") assert html_response(conn, 200) =~ "Send activation code" end test "if user does have a code: prompts user to enter the activation code from their email", %{conn: conn} do conn = post(conn, "/activate/request-code") |> get("/activate") assert html_response(conn, 200) =~ "We've sent an email with your code to:" end test "passes team identifier in form data", %{conn: conn, user: user} do Auth.EmailVerification.issue_code(user) team_identifier = Ecto.UUID.generate() conn = get(conn, "/activate?team_identifier=#{team_identifier}") assert html = html_response(conn, 200) assert text_of_attr(html, "input[name=team_identifier]", "value") == team_identifier end end describe "POST /activate/request-code" do setup [:create_user, :log_in] test "generates an activation pin for user account", %{conn: conn, user: user} do post(conn, "/activate/request-code") assert code = Repo.get_by(Auth.EmailActivationCode, user_id: user.id) assert code.user_id == user.id refute Plausible.Auth.EmailVerification.expired?(code) end test "regenerates an activation pin even if there's one already", %{conn: conn, user: user} do five_minutes_ago = NaiveDateTime.utc_now() |> NaiveDateTime.shift(minute: -5) |> NaiveDateTime.truncate(:second) {:ok, verification} = Auth.EmailVerification.issue_code(user, five_minutes_ago) post(conn, "/activate/request-code") assert new_verification = Repo.get_by(Auth.EmailActivationCode, user_id: user.id) assert verification.id == new_verification.id assert verification.user_id == new_verification.user_id # this actually has a chance to fail 1 in 8999 runs # but at the same time it's good to have a confirmation # that it indeed generates a new code if verification.code == new_verification.code do Logger.warning( "Congratulations! You you have hit 1 in 8999 chance of the same " <> "email verification code repeating twice in a row!" ) end assert NaiveDateTime.compare(verification.issued_at, new_verification.issued_at) == :lt end test "sends activation email to user", %{conn: conn, user: user} do post(conn, "/activate/request-code") assert_delivered_email_matches(%{to: [{_, user_email}], subject: subject}) assert user_email == user.email assert subject =~ "is your Plausible email verification code" end test "redirects user to /activate", %{conn: conn} do conn = post(conn, "/activate/request-code") assert redirected_to(conn, 302) == "/activate" end end describe "POST /activate" do setup [:create_user, :log_in] test "with wrong pin - reloads the form with error", %{conn: conn} do conn = post(conn, "/activate", %{code: "1234"}) assert html_response(conn, 200) =~ htmlize_quotes("That code didn't work. Please try again.") end test "with expired pin - reloads the form with error", %{conn: conn, user: user} do one_day_ago = NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -1) |> NaiveDateTime.truncate(:second) {:ok, verification} = Auth.EmailVerification.issue_code(user, one_day_ago) conn = post(conn, "/activate", %{code: verification.code}) assert html_response(conn, 200) =~ "The code has expired. Please request another one." end test "marks the user account as active", %{conn: conn, user: user} do Repo.update!(Plausible.Auth.User.changeset(user, %{email_verified: false})) post(conn, "/activate/request-code") verification = Repo.get_by!(Auth.EmailActivationCode, user_id: user.id) conn = post(conn, "/activate", %{code: verification.code}) user = Repo.get_by(Plausible.Auth.User, id: user.id) assert user.email_verified assert redirected_to(conn) == "/sites/new?flow=" end test "redirects to /sites if user has invitation", %{conn: conn, user: user} do owner = new_user() site = new_site(owner: owner) invite_guest(site, user, role: :viewer, inviter: owner) Repo.update!(Plausible.Auth.User.changeset(user, %{email_verified: false})) post(conn, "/activate/request-code") verification = Repo.get_by!(Auth.EmailActivationCode, user_id: user.id) conn = post(conn, "/activate", %{code: verification.code, team_identifier: ""}) assert redirected_to(conn) == "/sites?flow=" end test "accepts team invite and redirects to team sites, if team provided", %{ conn: conn, user: user } do owner = new_user() _site = new_site(owner: owner) team = team_of(owner) invite_member(team, user, role: :viewer, inviter: owner) Repo.update!(Auth.User.changeset(user, %{email_verified: false})) {:ok, %{code: code}} = Auth.EmailVerification.issue_code(user) conn = post(conn, "/activate", %{code: code, team_identifier: team.identifier}) assert redirected_to(conn) == Routes.site_path(conn, :index, __team: team.identifier, flow: "") assert_team_membership(user, team, :viewer) end test "redirects to team sites if team provided, even if there's no invite", %{ conn: conn, user: user } do owner = new_user() _site = new_site(owner: owner) team = team_of(owner) add_member(team, user: user, role: :viewer) Repo.update!(Auth.User.changeset(user, %{email_verified: false})) {:ok, %{code: code}} = Auth.EmailVerification.issue_code(user) conn = post(conn, "/activate", %{code: code, team_identifier: team.identifier}) assert redirected_to(conn) == Routes.site_path(conn, :index, __team: team.identifier, flow: "") end test "removes used up verification code", %{conn: conn, user: user} do Repo.update!(Plausible.Auth.User.changeset(user, %{email_verified: false})) post(conn, "/activate/request-code") verification = Repo.get_by!(Auth.EmailActivationCode, user_id: user.id) post(conn, "/activate", %{code: verification.code}) refute Repo.get_by(Auth.EmailActivationCode, user_id: user.id) end test "limits activation attempts to 10 per 5 minutes", %{conn: conn} do conn = put_req_header(conn, "x-forwarded-for", "10.9.8.7") response = eventually( fn -> Enum.each(1..10, fn _ -> post(conn, "/activate", %{code: "1111"}) end) conn = post(conn, "/activate", %{code: "1111"}) body = html_response(conn, 200) {body =~ "Too many attempts", conn} end, 500 ) assert html_response(response, 200) =~ "Too many attempts. Please wait a few minutes before trying again." end end describe "GET /login_form" do test "shows the login form", %{conn: conn} do conn = get(conn, "/login") assert html_response(conn, 200) =~ "Sign in to your account" end test "renders `return_to` query param as hidden input", %{conn: conn} do conn = get(conn, "/login?return_to=/dummy.site") input_value = conn |> html_response(200) |> text_of_attr("input[name=return_to]", "value") assert input_value == "/dummy.site" end @tag :ee_only test "redirects to sso login if preferred", %{conn: conn} do conn = PlausibleWeb.LoginPreference.set_sso(conn) conn = get(conn, "/login?return_to=foo") assert redirected_to(conn, 302) == "/sso/login?return_to=foo" end @tag :ee_only test "keeps standard login form if preference manually overridden", %{conn: conn} do conn = PlausibleWeb.LoginPreference.set_sso(conn) conn = get(conn, "/login?prefer=manual") assert html_response(conn, 200) =~ "Sign in to your account" end end describe "POST /login" do test "valid email and password - logs the user in", %{conn: conn} do user = insert(:user, password: "password") conn = post(conn, "/login", email: user.email, password: "password") assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn, :user_token) == token assert redirected_to(conn) == "/sites" end test "valid email and password, user on multiple teams - logs the user in", %{conn: conn} do user = insert(:user, password: "password") # first team new_site(owner: user) # another team another_team = new_site().team |> Plausible.Teams.complete_setup() add_member(another_team, user: user, role: :owner) conn = post(conn, "/login", email: user.email, password: "password") assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn, :user_token) == token assert redirected_to(conn) == "/sites" end test "valid email and password with return_to set - redirects properly", %{conn: conn} do user = insert(:user, password: "password") conn = post(conn, "/login", email: user.email, password: "password", return_to: Routes.settings_path(conn, :index) ) assert redirected_to(conn, 302) == Routes.settings_path(conn, :index) end test "valid email and password with 2FA enabled - sets 2FA session and redirects", %{ conn: conn } do user = insert(:user, password: "password") # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) conn = post(conn, "/login", email: user.email, password: "password") assert redirected_to(conn, 302) == Routes.auth_path(conn, :verify_2fa_form) assert fetch_cookies(conn).cookies["session_2fa"].current_2fa_user_id == user.id refute get_session(conn)["user_token"] end test "valid email and password with 2FA enabled and remember 2FA cookie set - logs the user in", %{conn: conn} do user = insert(:user, password: "password") # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) conn = set_remember_2fa_cookie(conn, user) conn = post(conn, "/login", email: user.email, password: "password") assert redirected_to(conn, 302) == Routes.site_path(conn, :index) assert conn.resp_cookies["session_2fa"].max_age == 0 assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn, :user_token) == token end test "valid email and password with 2FA enabled and rogue remember 2FA cookie set - logs the user in", %{conn: conn} do user = insert(:user, password: "password") # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) another_user = insert(:user) conn = set_remember_2fa_cookie(conn, another_user) conn = post(conn, "/login", email: user.email, password: "password") assert redirected_to(conn, 302) == Routes.auth_path(conn, :verify_2fa_form) assert fetch_cookies(conn).cookies["session_2fa"].current_2fa_user_id == user.id refute get_session(conn, :user_token) end on_ee do test "SSO owner user - logs in", %{conn: conn} do owner = new_user(name: "Jane Shelley", email: "jane@example.com", password: "password") team = new_site(owner: owner).team team = Plausible.Teams.complete_setup(team) # Setup SSO integration = Auth.SSO.initiate_saml_integration(team) {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com") _sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true) identity = new_identity(owner.name, owner.email, integration) {:ok, _, _, _sso_user} = Auth.SSO.provision_user(identity) conn = post(conn, "/login", email: owner.email, password: "password") assert redirected_to(conn, 302) == Routes.site_path(conn, :index) assert conn.resp_cookies["session_2fa"].max_age == 0 assert %{sessions: [%{token: token}]} = owner |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn, :user_token) == token end test "SSO user other than owner - renders login form again", %{conn: conn} do owner = new_user() team = new_site(owner: owner).team member = new_user(name: "Jane Shelley", email: "jane@example.com", password: "password") add_member(team, user: member, role: :viewer) # Setup SSO integration = Auth.SSO.initiate_saml_integration(team) {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com") _sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true) identity = new_identity(member.name, member.email, integration) {:ok, _, _, _sso_user} = Auth.SSO.provision_user(identity) conn = post(conn, "/login", email: member.email, password: "password") assert get_session(conn, :user_token) == nil assert html_response(conn, 200) =~ "Sign in to your account" end test "SSO user other than owner with personal team - renders login form again", %{ conn: conn } do owner = new_user() team = new_site(owner: owner).team member = new_user(name: "Jane Shelley", email: "jane@example.com", password: "password") {:ok, _} = Plausible.Teams.get_or_create(member) add_member(team, user: member, role: :viewer) # Setup SSO integration = Auth.SSO.initiate_saml_integration(team) {:ok, sso_domain} = Auth.SSO.Domains.add(integration, "example.com") _sso_domain = Auth.SSO.Domains.verify(sso_domain, skip_checks?: true) identity = new_identity(member.name, member.email, integration) {:ok, _, _, _sso_user} = Auth.SSO.provision_user(identity) conn = post(conn, "/login", email: member.email, password: "password") assert get_session(conn, :user_token) == nil assert html_response(conn, 200) =~ "Sign in to your account" end end test "email does not exist - renders login form again", %{conn: conn} do conn = post(conn, "/login", email: "user@example.com", password: "password") assert get_session(conn, :user_token) == nil assert html_response(conn, 200) =~ "Sign in to your account" end test "bad password - renders login form again", %{conn: conn} do user = insert(:user, password: "password") conn = post(conn, "/login", email: user.email, password: "wrong") assert get_session(conn, :user_token) == nil assert html_response(conn, 200) =~ "Sign in to your account" end test "limits login attempts to 5 per minute" do user = insert(:user, password: "password") conn = put_req_header(build_conn(), "x-forwarded-for", "1.2.3.5") response = eventually( fn -> Enum.each(1..5, fn _ -> post(conn, "/login", email: user.email, password: "wrong") end) conn = post(conn, "/login", email: user.email, password: "wrong") {conn.status == 429, conn} end, 500 ) assert html_response(response, 429) =~ "Too many login attempts" end test "malicious automated logins", %{conn: conn1} do conn = post(conn1, "/login", %{ "g-recaptcha-response" => "NwBMkEWbh", "user[email]" => "some@example.com", "user[name]" => "CJCtGVXVgfORdNN", "user[password]" => "[Filtered]", "user[password_confirmation]" => "[Filtered]", "user[register_action]" => "register_form" }) assert response(conn, 403) conn = conn1 |> put_req_header("content-type", "application/json") |> post("/login", """ { "g-recaptcha-response": "NwBMkEWbh", "user[email]": "some@example.com", "user[name]": "CJCtGVXVgfORdNN", "user[password]": "[Filtered]", "user[password_confirmation]": "[Filtered]", "user[register_action]": "register_form" } """) assert response(conn, 403) end end describe "GET /password/request-reset" do test "renders the form", %{conn: conn} do conn = get(conn, "/password/request-reset") assert html_response(conn, 200) =~ "Enter your email to receive a password reset link." end end describe "POST /password/request-reset" do test "email is empty - renders form with error", %{conn: conn} do conn = post(conn, "/password/request-reset", %{email: ""}) assert html_response(conn, 200) =~ "Enter your email to receive a password reset link." end test "email is present and exists - sends password reset email", %{conn: conn} do mock_captcha_success() user = insert(:user) conn = post(conn, "/password/request-reset", %{email: user.email}) assert html_response(conn, 200) =~ "Check your email" assert_email_delivered_with(subject: "Plausible password reset") end test "renders captcha errors in case of captcha input verification failure", %{conn: conn} do mock_captcha_failure() user = insert(:user) conn = post(conn, "/password/request-reset", %{email: user.email}) assert html_response(conn, 200) =~ "Please complete the captcha" end end on_ee do describe "POST /password/request-reset - SSO user" do setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user] test "initiates reset for owner SSO user email", %{conn: conn, user: user} do mock_captcha_success() conn = post(conn, "/password/request-reset", %{email: user.email}) assert html_response(conn, 200) assert_email_delivered_with( subject: "Plausible password reset", to: [nil: user.email] ) end test "does not initiate reset for non-owner SSO user", %{conn: conn, user: user, team: team} do add_member(team, role: :owner) assert {:ok, _} = Plausible.Teams.Memberships.UpdateRole.update(team, user.id, "editor", user) assert Plausible.Teams.Memberships.team_role(team, user) == {:ok, :editor} mock_captcha_success() conn = post(conn, "/password/request-reset", %{email: user.email}) assert html_response(conn, 200) refute_email_delivered_with( subject: "Plausible password reset", to: [nil: user.email] ) end end end describe "GET /password/reset" do test "with valid token - shows form", %{conn: conn} do user = insert(:user) token = Plausible.Auth.Token.sign_password_reset(user.email) conn = get(conn, "/password/reset", %{token: token}) assert html_response(conn, 200) =~ "Reset your password" end test "with invalid token - shows error page", %{conn: conn} do conn = get(conn, "/password/reset", %{token: "blabla"}) assert html_response(conn, 401) =~ "Password reset link invalid" end test "without token - shows error page", %{conn: conn} do conn = get(conn, "/password/reset", %{}) assert html_response(conn, 401) =~ "Password reset link invalid" end end describe "POST /password/reset" do test "redirects the user to login and shows success message", %{conn: conn} do conn = post(conn, "/password/reset", %{}) assert location = "/login" = redirected_to(conn, 302) # cookie state is as expected for logged out user assert conn.private[:plug_session_info] == :renew assert conn.resp_cookies["logged_in"].max_age == 0 assert get_session(conn, :user_token) == nil {:ok, %{conn: conn}} = PlausibleWeb.FirstLaunchPlug.Test.skip(%{conn: recycle(conn)}) conn = get(conn, location) assert html_response(conn, 200) =~ "Password updated successfully" end end describe "GET /logout" do setup [:create_user, :log_in] test "redirects the user to root", %{conn: conn} do conn = get(conn, "/logout") assert location = "/" = redirected_to(conn, 302) # cookie state is as expected for logged out user assert conn.private[:plug_session_info] == :renew assert conn.resp_cookies["logged_in"].max_age == 0 assert get_session(conn, :user_token) == nil {:ok, %{conn: conn}} = PlausibleWeb.FirstLaunchPlug.Test.skip(%{conn: recycle(conn)}) conn = get(conn, location) assert html_response(conn, 200) =~ "Welcome to Plausible!" end test "redirects user to `redirect` param when provided", %{conn: conn} do conn = get(conn, "/logout", %{redirect: "/docs"}) assert redirected_to(conn, 302) == "/docs" end end on_ee do describe "DELETE /me - SSO user" do setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in] test "refuses to delete SSO user", %{conn: conn, user: user} do conn = delete(conn, "/me") assert redirected_to(conn, 302) == Routes.site_path(conn, :index) assert Repo.reload(user) end end end describe "DELETE /me" do setup [:create_user, :log_in, :create_site] use Plausible.Repo test "deletes the user", %{conn: conn, user: user, site: site} do Repo.insert_all("intro_emails", [ %{ user_id: user.id, timestamp: NaiveDateTime.utc_now() } ]) Repo.insert_all("feedback_emails", [ %{ user_id: user.id, timestamp: NaiveDateTime.utc_now() } ]) Repo.insert_all("create_site_emails", [ %{ user_id: user.id, timestamp: NaiveDateTime.utc_now() } ]) Repo.insert_all("check_stats_emails", [ %{ user_id: user.id, timestamp: NaiveDateTime.utc_now() } ]) Repo.insert_all("sent_renewal_notifications", [ %{ user_id: user.id, timestamp: NaiveDateTime.utc_now() } ]) insert(:google_auth, site: site, user: user) subscribe_to_growth_plan(user, status: Subscription.Status.deleted()) subscribe_to_enterprise_plan(user, site_limit: 1, subscription?: false) {:ok, team} = Plausible.Teams.get_or_create(user) conn = delete(conn, "/me") assert redirected_to(conn) == "/" assert Repo.reload(site) == nil assert Repo.reload(user) == nil assert Repo.all(Plausible.Billing.Subscription) == [] assert Repo.all(Plausible.Billing.EnterprisePlan) == [] refute Repo.get(Plausible.Teams.Team, team.id) end test "refuses to delete when a personal team has an active subscription", %{ conn: conn, user: user } do subscribe_to_growth_plan(user, status: Subscription.Status.active()) conn = delete(conn, "/me") assert redirected_to(conn, 302) == Routes.settings_path(conn, :danger_zone) assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You have an active subscription which must be canceled first" assert Repo.reload(user) end test "deletes sites that the user owns", %{conn: conn, user: user, site: owner_site} do viewer_site = new_site() add_guest(viewer_site, user: user, role: :viewer) delete(conn, "/me") assert Repo.get(Plausible.Site, viewer_site.id) refute Repo.get(Plausible.Site, owner_site.id) end test "refuses to delete user when an only owner of a setup team", %{ conn: conn, user: user, site: site } do Plausible.Teams.complete_setup(site.team) conn = delete(conn, "/me") assert redirected_to(conn, 302) == Routes.settings_path(conn, :danger_zone) assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't delete your account when you are the only owner on a team" assert Repo.reload(user) end test "refuses to delete user when an only owner of multiple setup teams", %{ conn: conn, user: user, site: site } do Plausible.Teams.complete_setup(site.team) another_owner = new_user() another_site = new_site(owner: another_owner) add_member(another_site.team, user: user, role: :owner) Repo.delete!(another_owner) conn = delete(conn, "/me") assert redirected_to(conn, 302) == Routes.settings_path(conn, :danger_zone) assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't delete your account when you are the only owner on a team" assert Repo.reload(user) end test "context > team is autodeleted - personal segment is also deleted", %{ conn: conn, user: user, site: owner_site } do segment = insert(:segment, type: :personal, owner: user, site: owner_site, name: "personal segment" ) delete(conn, "/me") refute Repo.reload(segment) end test "context > team is autodeleted - site segment is also deleted", %{ conn: conn, user: user, site: owner_site } do segment = insert(:segment, type: :site, owner: user, site: owner_site, name: "site segment" ) delete(conn, "/me") refute Repo.reload(segment) end test "context > team is not autodeleted - personal segment is deleted", %{ conn: conn, user: user } do another_owner = new_user() another_site = new_site(owner: another_owner) another_team = another_owner |> team_of() |> Plausible.Teams.complete_setup() add_member(another_team, user: user, role: :admin) segment = insert(:segment, type: :personal, owner: user, site: another_site, name: "personal segment" ) delete(conn, "/me") refute Repo.reload(segment) end test "context > team is not autodeleted - site segment is kept with owner=null", %{ conn: conn, user: user } do another_owner = new_user() another_site = new_site(owner: another_owner) another_team = another_owner |> team_of() |> Plausible.Teams.complete_setup() add_member(another_team, user: user, role: :admin) segment = insert(:segment, type: :site, owner: user, site: another_site, name: "site segment" ) delete(conn, "/me") assert Repo.reload(segment).owner_id == nil end test "allows to delete user when not the only owner of a setup team", %{ conn: conn, user: user } do another_owner = new_user() another_site = new_site(owner: another_owner) team = another_owner |> team_of() |> Plausible.Teams.complete_setup() add_member(another_site.team, user: user, role: :owner) delete(conn, "/me") refute Repo.reload(user) assert Repo.reload(team) end test "deletes personal team in multiple teams case as well", %{ conn: conn, user: user } do new_site(owner: user) personal_team = team_of(user) another_owner = new_user() _another_site = new_site(owner: another_owner) another_team = another_owner |> team_of() |> Plausible.Teams.complete_setup() add_member(another_team, user: user, role: :owner) delete(conn, "/me") refute Repo.reload(user) assert Repo.reload(another_team) refute Repo.reload(personal_team) end end describe "GET /auth/google/callback" do setup [:create_user, :log_in] test "shows access denied error and redirects back to settings when authentication fails for import", %{ conn: conn } do site = new_site() state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) callback_params = %{"error" => "access_denied", "state" => state} conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(conn, 302) == Routes.site_path(conn, :settings_imports_exports, site.domain) assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "unable to authenticate your Google Analytics" end test "shows access denied error and redirects back to settings when authentication fails for search console", %{conn: conn} do site = new_site() state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [ site.id, "search-console" ]) callback_params = %{"error" => "access_denied", "state" => state} conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(conn, 302) == Routes.site_path(conn, :settings_integrations, site.domain) assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "unable to authenticate your Google Analytics" end test "shows server error and redirects back to settings when request fails on Google's end for import", %{ conn: conn } do site = new_site() state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) callback_params = %{"error" => "server_error", "state" => state} conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(conn, 302) == Routes.site_path(conn, :settings_imports_exports, site.domain) assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Google's authentication service is temporarily unavailable" end test "shows server error and redirects back to settings when request fails on Google's end for search console", %{ conn: conn } do site = new_site() state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [ site.id, "search-console" ]) callback_params = %{"error" => "temporarily_unavailable", "state" => state} conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(conn, 302) == Routes.site_path(conn, :settings_integrations, site.domain) assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Google's authentication service is temporarily unavailable" end test "shows generic error and redirects back to settings on unknown Google failure for import", %{ conn: conn } do site = new_site() state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) callback_params = %{"error" => "unknown", "state" => state} conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(conn, 302) == Routes.site_path(conn, :settings_imports_exports, site.domain) assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "unable to authenticate your Google Analytics" end test "shows generic error and redirects back to settings when request fails on Google's end for search console", %{ conn: conn } do site = new_site() state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [ site.id, "search-console" ]) callback_params = %{"error" => "unknown", "state" => state} conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(conn, 302) == Routes.site_path(conn, :settings_integrations, site.domain) assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "unable to authenticate your Google Analytics" end test "rejects error callback with tampered state", %{conn: conn} do site = new_site() state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) callback_params = %{"error" => "access_denied", "state" => state <> "gibberish"} conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) end test "rejects error callback with signed state containing non-existent site", %{conn: conn} do site = new_site() Repo.delete!(site) state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) callback_params = %{"error" => "access_denied", "state" => state} conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) end test "rejects success callback with tampered state", %{conn: conn} do site = new_site() state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) callback_params = %{"code" => "CodeForToken", "state" => state <> "gibberish"} conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) end test "rejects success callback with signed state containing non-existent site", %{conn: conn} do site = new_site() Repo.delete!(site) state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) callback_params = %{"code" => "CodeForToken", "state" => state} conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) end test "success callback rejects state pointing to a site the current user does not own", %{conn: conn} do victim_site = new_site() attacker = new_user() {:ok, conn: attacker_conn} = log_in(%{user: attacker, conn: conn}) # craft a validly signed state pointing at the victim's site_id state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [ victim_site.id, "search-console" ]) callback_params = %{"code" => "CodeForToken", "state" => state} attacker_conn = get(attacker_conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(attacker_conn, 302) == Routes.auth_path(conn, :login_form) # confirm no GoogleAuth record was created for the victim's site refute Repo.exists?( from ga in Plausible.Site.GoogleAuth, where: ga.site_id == ^victim_site.id ) end test "success callback for import redirects to property selection", %{conn: conn, user: user} do mock_google_access_token(user.email) site = new_site(owner: user) state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [site.id, "import"]) callback_params = %{"code" => "CodeForToken", "state" => state} conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirect_url = redirected_to(conn, 302) assert redirect_url =~ "/import/google-analytics/property" assert redirect_url =~ "access_token=SomeAccessToken" assert redirect_url =~ "refresh_token=SomeRefreshToken" assert redirect_url =~ "expires_at=" end test "success callback for search console creates a google auth entry and redirects to integrations form", %{conn: conn, user: user} do site = new_site(owner: user) mock_google_access_token(user.email) state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [ site.id, "search-console" ]) callback_params = %{"code" => "CodeForToken", "state" => state} conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(conn, 302) == Routes.site_path(conn, :settings_integrations, site.domain) assert_matches %{ access_token: "SomeAccessToken", refresh_token: "SomeRefreshToken", user_id: ^user.id, email: ^user.email, expires: %NaiveDateTime{} } = Repo.one( from ga in Plausible.Site.GoogleAuth, where: ga.site_id == ^site.id ) end test "success callback for search console creates a google auth entry and redirects to integrations form for editor", %{conn: conn, user: user} do site = new_site() add_guest(site, user: user, role: :editor) mock_google_access_token(user.email) state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [ site.id, "search-console" ]) callback_params = %{"code" => "CodeForToken", "state" => state} conn = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(conn, 302) == Routes.site_path(conn, :settings_integrations, site.domain) assert_matches %{ access_token: "SomeAccessToken", refresh_token: "SomeRefreshToken", user_id: ^user.id, email: ^user.email, expires: %NaiveDateTime{} } = Repo.one( from ga in Plausible.Site.GoogleAuth, where: ga.site_id == ^site.id ) end test "success callback for search console handles repeat callback for the same site gracefully", %{conn: conn, user: user} do site = new_site(owner: user) another_email = new_user().email mock_google_access_token(user.email) mock_google_access_token(another_email) state = Phoenix.Token.sign(PlausibleWeb.Endpoint, "google-oauth-state", [ site.id, "search-console" ]) callback_params = %{"code" => "CodeForToken", "state" => state} conn_first = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(conn_first, 302) == Routes.site_path(conn, :settings_integrations, site.domain) assert_matches %{ access_token: "SomeAccessToken", refresh_token: "SomeRefreshToken", user_id: ^user.id, email: ^user.email, expires: %NaiveDateTime{} } = Repo.one( from ga in Plausible.Site.GoogleAuth, where: ga.site_id == ^site.id ) conn_second = get(conn, Routes.auth_path(conn, :google_auth_callback), callback_params) assert redirected_to(conn_second, 302) == Routes.site_path(conn, :settings_integrations, site.domain) assert_matches %{ access_token: "SomeAccessToken", refresh_token: "SomeRefreshToken", user_id: ^user.id, email: ^another_email, expires: %NaiveDateTime{} } = Repo.one( from ga in Plausible.Site.GoogleAuth, where: ga.site_id == ^site.id ) end end describe "POST /2fa/setup/initiate" do setup [:create_user, :log_in] test "initiates setup rendering QR and human friendly versions of secret", %{ conn: conn, user: user } do conn = post(conn, Routes.auth_path(conn, :initiate_2fa_setup)) secret = Base.encode32(Repo.reload!(user).totp_secret) assert html = html_response(conn, 200) assert element_exists?(html, "svg") assert html =~ secret refute html =~ "You've been redirected here because your team enforces 2FA." end test "shows additional notice when `force` parameter set", %{conn: conn} do conn = post(conn, Routes.auth_path(conn, :initiate_2fa_setup, force: "true")) assert html = html_response(conn, 200) assert html =~ "You've been redirected here because your team enforces 2FA." end test "redirects back to settings if 2FA is already setup", %{conn: conn, user: user} do {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) conn = post(conn, Routes.auth_path(conn, :initiate_2fa_setup)) assert redirected_to(conn, 302) == Routes.settings_path(conn, :security) <> "#update-2fa" assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Two-Factor Authentication is already setup" end end describe "GET /2fa/setup/verify" do setup [:create_user, :log_in] test "renders form when 2FA setup is initiated", %{conn: conn, user: user} do {:ok, _, _} = Auth.TOTP.initiate(user) conn = get(conn, Routes.auth_path(conn, :verify_2fa_setup)) assert html = html_response(conn, 200) assert text_of_attr(html, "form#verify-2fa-form", "action") == Routes.auth_path(conn, :verify_2fa_setup) assert element_exists?(html, "input[name=code]") assert element_exists?( html, ~s|a[data-method="post"][data-to="#{Routes.auth_path(conn, :initiate_2fa_setup)}"]| ) end test "redirects back to settings if 2FA not initiated", %{conn: conn} do conn = get(conn, Routes.auth_path(conn, :verify_2fa_setup)) assert redirected_to(conn, 302) == Routes.settings_path(conn, :security) <> "#update-2fa" end end describe "POST /2fa/setup/verify" do setup [:create_user, :log_in] test "enables 2FA and renders recovery codes when valid code provided", %{ conn: conn, user: user } do {:ok, user, _} = Auth.TOTP.initiate(user) code = NimbleTOTP.verification_code(user.totp_secret) conn = post(conn, Routes.auth_path(conn, :verify_2fa_setup), %{code: code}) assert html = html_response(conn, 200) assert list = [_ | _] = find(html, "#recovery-codes-list > *") |> LazyHTML.to_tree() assert length(list) == 10 assert user |> Repo.reload!() |> Auth.TOTP.enabled?() end test "renders error on invalid code provided", %{conn: conn, user: user} do {:ok, _, _} = Auth.TOTP.initiate(user) conn = post(conn, Routes.auth_path(conn, :verify_2fa_setup), %{code: "invalid"}) assert html_response(conn, 200) assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "The provided code is invalid." end test "redirects to settings when 2FA is not initiated", %{conn: conn} do conn = post(conn, Routes.auth_path(conn, :verify_2fa_setup), %{code: "123123"}) assert redirected_to(conn, 302) == Routes.settings_path(conn, :security) <> "#update-2fa" assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Please enable Two-Factor Authentication" end end describe "POST /2fa/disable" do setup [:create_user, :log_in] test "disables 2FA when valid password provided", %{conn: conn, user: user} do {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) conn = post(conn, Routes.auth_path(conn, :disable_2fa), %{password: "password"}) assert redirected_to(conn, 302) == Routes.settings_path(conn, :security) <> "#update-2fa" assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "Two-Factor Authentication is disabled" refute user |> Repo.reload!() |> Auth.TOTP.enabled?() end test "renders error when invalid password provided", %{conn: conn, user: user} do {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) conn = post(conn, Routes.auth_path(conn, :disable_2fa), %{password: "invalid"}) assert redirected_to(conn, 302) == Routes.settings_path(conn, :security) <> "#update-2fa" assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Incorrect password provided" end end on_ee do describe "POST /2fa/disable - SSO user" do setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in] test "refuses to disable for SSO user", %{conn: conn, user: user} do {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) conn = post(conn, Routes.auth_path(conn, :disable_2fa), %{password: "password"}) assert redirected_to(conn, 302) == Routes.site_path(conn, :index) assert user |> Repo.reload!() |> Auth.TOTP.enabled?() end end end describe "POST /2fa/recovery_codes" do setup [:create_user, :log_in] test "generates new recovery codes when valid password provided", %{conn: conn, user: user} do {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) conn = post(conn, Routes.auth_path(conn, :generate_2fa_recovery_codes), %{password: "password"}) assert html = html_response(conn, 200) assert list = [_ | _] = find(html, "#recovery-codes-list > *") |> LazyHTML.to_tree() assert length(list) == 10 end test "renders error when invalid password provided", %{conn: conn, user: user} do {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) conn = post(conn, Routes.auth_path(conn, :generate_2fa_recovery_codes), %{password: "invalid"}) assert redirected_to(conn, 302) == Routes.settings_path(conn, :security) <> "#update-2fa" assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Incorrect password provided" end test "renders error when 2FA is not enabled", %{conn: conn} do conn = post(conn, Routes.auth_path(conn, :generate_2fa_recovery_codes), %{password: "password"}) assert redirected_to(conn, 302) == Routes.settings_path(conn, :security) <> "#update-2fa" assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Please enable Two-Factor Authentication" end end describe "GET /2fa/verify" do test "renders verification form when 2FA session present", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) conn = login_with_cookie(conn, user.email, "password") conn = get( conn, Routes.auth_path(conn, :verify_2fa_form, return_to: Routes.settings_path(conn, :index)) ) assert html = html_response(conn, 200) assert text_of_attr(html, "form", "action") == Routes.auth_path(conn, :verify_2fa, return_to: Routes.settings_path(conn, :index)) assert element_exists?(html, "input[name=code]") assert element_exists?(html, "input[name=remember_2fa]") assert text_of_attr(html, "input[name=return_to]", "value") == Routes.settings_path(conn, :index) assert element_exists?( html, "a[href='#{Routes.auth_path(conn, :verify_2fa_recovery_code_form)}']" ) end test "redirects to login when cookie not found", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) conn = get(conn, Routes.auth_path(conn, :verify_2fa_form)) assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) end test "redirects to login when 2FA not enabled", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) conn = login_with_cookie(conn, user.email, "password") {:ok, _} = Auth.TOTP.disable(user, "password") conn = get(conn, Routes.auth_path(conn, :verify_2fa_form)) assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) end end describe "POST /2fa/verify" do test "redirects to sites when code verification succeeds", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) conn = login_with_cookie(conn, user.email, "password") code = NimbleTOTP.verification_code(user.totp_secret) conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code}) assert redirected_to(conn, 302) == Routes.site_path(conn, :index) assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn)["user_token"] == token # 2FA session terminated assert conn.resp_cookies["session_2fa"].max_age == 0 # Remember cookie unset assert conn.resp_cookies["remember_2fa"].max_age == 0 end test "redirects to return_to when set", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) conn = login_with_cookie(conn, user.email, "password") code = NimbleTOTP.verification_code(user.totp_secret) conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code, return_to: "/dummy.site"}) assert redirected_to(conn, 302) == "/dummy.site" end test "sets remember cookie when device trusted", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) conn = login_with_cookie(conn, user.email, "password") code = NimbleTOTP.verification_code(user.totp_secret) conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code, remember_2fa: "true"}) assert redirected_to(conn, 302) == Routes.site_path(conn, :index) assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn)["user_token"] == token # 2FA session terminated assert conn.resp_cookies["session_2fa"].max_age == 0 # Remember cookie set assert conn.resp_cookies["remember_2fa"].max_age > 0 end test "overwrites rogue remember cookie when device trusted", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) conn = login_with_cookie(conn, user.email, "password") another_user = insert(:user, totp_token: "different_token") conn = set_remember_2fa_cookie(conn, another_user) code = NimbleTOTP.verification_code(user.totp_secret) conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code, remember_2fa: "true"}) assert redirected_to(conn, 302) == Routes.site_path(conn, :index) assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn)["user_token"] == token # 2FA session terminated assert conn.resp_cookies["session_2fa"].max_age == 0 # Remember cookie set assert conn.resp_cookies["remember_2fa"].max_age > 0 assert fetch_cookies(conn).cookies["remember_2fa"] == user.totp_token end test "clears rogue remember cookie when device _not_ trusted", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) conn = login_with_cookie(conn, user.email, "password") another_user = insert(:user, totp_token: "different_token") conn = set_remember_2fa_cookie(conn, another_user) code = NimbleTOTP.verification_code(user.totp_secret) conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code}) assert redirected_to(conn, 302) == Routes.site_path(conn, :index) assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn, :user_token) == token # 2FA session terminated assert conn.resp_cookies["session_2fa"].max_age == 0 # Remember cookie cleared assert conn.resp_cookies["remember_2fa"].max_age == 0 end test "returns error on invalid code", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) conn = login_with_cookie(conn, user.email, "password") conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: "invalid"}) assert html_response(conn, 200) =~ "The provided code is invalid" end test "redirects to login when cookie not found", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) code = NimbleTOTP.verification_code(user.totp_secret) conn = post(conn, Routes.auth_path(conn, :verify_2fa, %{code: code})) assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) end test "passes through when 2FA is disabled", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) conn = login_with_cookie(conn, user.email, "password") code = NimbleTOTP.verification_code(user.totp_secret) {:ok, _} = Auth.TOTP.disable(user, "password") conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: code}) assert redirected_to(conn, 302) == Routes.site_path(conn, :index) assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn)["user_token"] == token # 2FA session terminated assert conn.resp_cookies["session_2fa"].max_age == 0 end test "limits verification attempts to 5 per minute", %{conn: conn} do user = insert(:user, email: "ratio#{Ecto.UUID.generate()}@example.com") # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) conn = conn |> login_with_cookie(user.email, "password") |> put_req_header("x-forwarded-for", "1.1.1.1") response = eventually( fn -> Enum.each(1..5, fn _ -> post(conn, Routes.auth_path(conn, :verify_2fa), %{code: "invalid"}) end) conn = post(conn, Routes.auth_path(conn, :verify_2fa), %{code: "invalid"}) {conn.status == 429, conn} end, 500 ) assert get_session(response, :user_token) == nil # 2FA session terminated assert response.resp_cookies["session_2fa"].max_age == 0 assert html_response(response, 429) =~ "Too many login attempts" end end describe "GET /2fa/use_recovery_code" do test "renders recovery verification form when 2FA session present", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) conn = login_with_cookie(conn, user.email, "password") conn = get(conn, Routes.auth_path(conn, :verify_2fa_recovery_code_form)) assert html = html_response(conn, 200) assert text_of_attr(html, "form", "action") == Routes.auth_path(conn, :verify_2fa_recovery_code) assert element_exists?(html, "input[name=recovery_code]") assert element_exists?(html, "a[href='#{Routes.auth_path(conn, :verify_2fa_form)}']") end test "redirects to login when cookie not found", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) conn = get(conn, Routes.auth_path(conn, :verify_2fa_recovery_code_form)) assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) end test "redirects to login when 2FA not enabled", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) conn = login_with_cookie(conn, user.email, "password") {:ok, _} = Auth.TOTP.disable(user, "password") conn = get(conn, Routes.auth_path(conn, :verify_2fa_recovery_code_form)) assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) end end describe "POST /2fa/use_recovery_code" do test "redirects to sites when recovery code verification succeeds", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, user, %{recovery_codes: [recovery_code | _]}} = Auth.TOTP.enable(user, :skip_verify) conn = login_with_cookie(conn, user.email, "password") conn = post(conn, Routes.auth_path(conn, :verify_2fa_recovery_code), %{ recovery_code: recovery_code }) assert redirected_to(conn, 302) == Routes.site_path(conn, :index) assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn)["user_token"] == token # 2FA session terminated assert conn.resp_cookies["session_2fa"].max_age == 0 end test "returns error on invalid recovery code", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) conn = login_with_cookie(conn, user.email, "password") conn = post(conn, Routes.auth_path(conn, :verify_2fa_recovery_code), %{recovery_code: "invalid"}) assert html_response(conn, 200) =~ "The provided recovery code is invalid" end test "redirects to login when cookie not found", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, _, %{recovery_codes: [recovery_code | _]}} = Auth.TOTP.enable(user, :skip_verify) conn = post( conn, Routes.auth_path(conn, :verify_2fa_recovery_code, %{recovery_code: recovery_code}) ) assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) end test "passes through when 2FA is disabled", %{conn: conn} do user = insert(:user) # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, user, %{recovery_codes: [recovery_code | _]}} = Auth.TOTP.enable(user, :skip_verify) conn = login_with_cookie(conn, user.email, "password") {:ok, _} = Auth.TOTP.disable(user, "password") conn = post(conn, Routes.auth_path(conn, :verify_2fa_recovery_code), %{ recovery_code: recovery_code }) assert redirected_to(conn, 302) == Routes.site_path(conn, :index) assert %{sessions: [%{token: token}]} = user |> Repo.reload!() |> Repo.preload(:sessions) assert get_session(conn)["user_token"] == token # 2FA session terminated assert conn.resp_cookies["session_2fa"].max_age == 0 end test "limits verification attempts to 5 per minute", %{conn: conn} do user = insert(:user, email: "ratio#{Ecto.UUID.generate()}@example.com") # enable 2FA {:ok, user, _} = Auth.TOTP.initiate(user) {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) conn = conn |> login_with_cookie(user.email, "password") |> put_req_header("x-forwarded-for", "1.2.3.4") response = eventually( fn -> Enum.each(1..5, fn _ -> post(conn, Routes.auth_path(conn, :verify_2fa_recovery_code), %{ recovery_code: "invalid" }) end) conn = post(conn, Routes.auth_path(conn, :verify_2fa_recovery_code), %{ recovery_code: "invalid" }) {conn.status == 429, conn} end, 500 ) assert get_session(response, :user_token) == nil # 2FA session terminated assert response.resp_cookies["session_2fa"].max_age == 0 assert html_response(response, 429) =~ "Too many login attempts" end end defp login_with_cookie(conn, email, password) do conn |> post(Routes.auth_path(conn, :login), %{ email: email, password: password }) |> recycle() |> prepare_conn() end defp set_remember_2fa_cookie(conn, user) do conn |> PlausibleWeb.TwoFactor.Session.maybe_set_remember_2fa(user, "true") |> recycle() |> prepare_conn() end defp mock_captcha_success() do mock_captcha(true) end defp mock_captcha_failure() do mock_captcha(false) end defp mock_captcha(success) do expect( Plausible.HTTPClient.Mock, :post, fn _, _, _ -> {:ok, %Finch.Response{ status: 200, headers: [{"content-type", "application/json"}], body: %{"success" => success} }} end ) end defp mock_google_access_token(email) do body = %{"email" => email} |> Jason.encode!() |> Base.encode64(padding: false) expect( Plausible.HTTPClient.Mock, :post, fn _, _, _ -> {:ok, %Finch.Response{ status: 200, headers: [{"content-type", "application/json"}], body: %{ "expires_in" => 3600, "access_token" => "SomeAccessToken", "refresh_token" => "SomeRefreshToken", "id_token" => ".#{body}." } }} end ) end end