| 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 |
| |
| |
| |
| 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") |
|
|
| |
| new_site(owner: user) |
|
|
| |
| 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") |
|
|
| |
| {: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") |
|
|
| |
| {: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") |
|
|
| |
| {: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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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}) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| {: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) |
|
|
| |
| {: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) |
|
|
| |
| {: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) |
|
|
| |
| {: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 |
| |
| assert conn.resp_cookies["session_2fa"].max_age == 0 |
| |
| assert conn.resp_cookies["remember_2fa"].max_age == 0 |
| end |
|
|
| test "redirects to return_to when set", %{conn: conn} do |
| user = insert(:user) |
|
|
| |
| {: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) |
|
|
| |
| {: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 |
| |
| assert conn.resp_cookies["session_2fa"].max_age == 0 |
| |
| assert conn.resp_cookies["remember_2fa"].max_age > 0 |
| end |
|
|
| test "overwrites rogue remember cookie when device trusted", %{conn: conn} do |
| user = insert(:user) |
|
|
| |
| {: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 |
| |
| assert conn.resp_cookies["session_2fa"].max_age == 0 |
| |
| 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) |
|
|
| |
| {: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 |
| |
| assert conn.resp_cookies["session_2fa"].max_age == 0 |
| |
| assert conn.resp_cookies["remember_2fa"].max_age == 0 |
| end |
|
|
| test "returns error on invalid code", %{conn: conn} do |
| user = insert(:user) |
|
|
| |
| {: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) |
|
|
| |
| {: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) |
|
|
| |
| {: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 |
| |
| 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") |
|
|
| |
| {: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 |
| |
| 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) |
|
|
| |
| {: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) |
|
|
| |
| {: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) |
|
|
| |
| {: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) |
|
|
| |
| {: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 |
| |
| assert conn.resp_cookies["session_2fa"].max_age == 0 |
| end |
|
|
| test "returns error on invalid recovery code", %{conn: conn} do |
| user = insert(:user) |
|
|
| |
| {: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) |
|
|
| |
| {: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) |
|
|
| |
| {: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 |
| |
| 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") |
|
|
| |
| {: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 |
| |
| 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 |
|
|