diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 47f589d..b789088 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -86,7 +86,7 @@ lib/ │ ├── membership.ex # Domain definition │ ├── member.ex # Member resource │ ├── join_request.ex # JoinRequest (public join form, double opt-in) -│ ├── join_request/ # JoinRequest changes (SetConfirmationToken, ConfirmRequest) +│ ├── join_request/ # JoinRequest changes (SetConfirmationToken, FilterFormDataByAllowlist, ConfirmRequest) │ ├── custom_field.ex # Custom field (definition) resource │ ├── custom_field_value.ex # Custom field value resource │ ├── setting.ex # Global settings (singleton resource; incl. join form config) diff --git a/assets/css/app.css b/assets/css/app.css index 28ea24b..e3c6e83 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -99,6 +99,19 @@ /* Make LiveView wrapper divs transparent for layout */ [data-phx-session] { display: contents } +/* Honeypot: off-screen and minimal size so bots fill it, humans never see it (best practice) */ +.join-form-helper { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} +.join-form-helper .join-form-helper-input { + position: absolute; + left: -9999px; +} + /* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers. Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline spacing; use inherited values so custom stylesheets can override. */ diff --git a/config/config.exs b/config/config.exs index 323f5cd..ab55f2a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -93,6 +93,9 @@ config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local # user confirmation, password reset). Override in config/runtime.exs from ENV. config :mv, :mail_from, {"Mila", "noreply@example.com"} +# Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP. +config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10 + # Configure esbuild (the version is required) config :esbuild, version: "0.17.11", diff --git a/config/test.exs b/config/test.exs index 864222f..84ccd70 100644 --- a/config/test.exs +++ b/config/test.exs @@ -55,3 +55,6 @@ config :mv, :default_locale, "en" # Enable SQL Sandbox for async LiveView tests # This flag controls sync vs async behavior in CycleGenerator after_action hooks config :mv, :sql_sandbox, true + +# Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute) +config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2 diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index 01c7ea4..680799d 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -42,7 +42,7 @@ ### 2.3 Data Flow -- **Input:** Only data explicitly allowed for the public form; field set is admin-configured (see §2.6). No internal or sensitive fields. **Server-side allowlist:** The set of accepted fields must be enforced on the server from the join-form settings (allowlist), not only in the UI, to prevent field injection or extra attributes from being stored. +- **Input:** Only data explicitly allowed for the public form; field set is admin-configured (see §2.6). No internal or sensitive fields. **Server-side allowlist:** The set of accepted fields is enforced in the LiveView (`build_submit_attrs`) and in the resource via **`JoinRequest.Changes.FilterFormDataByAllowlist`** so that even direct API/submit_join_request calls only persist allowlisted form_data keys. - **On form submit:** **Create** a JoinRequest with status `pending_confirmation`, store confirmation token **hash** in the DB (raw token only in the email link), set `confirmation_token_expires_at` (e.g. 24h), store all allowlisted form data (see §2.3.2), then send confirmation email. - **On confirmation link click:** **Update** the JoinRequest (find by token hash): set status to `submitted`, set `submitted_at`, clear/invalidate token fields. If the record is already `submitted`, return success without changing it (idempotent). - **No creation** of Member or User in Prio 1; promotion to Member/User happens in a later step (e.g. after approval). @@ -73,7 +73,7 @@ - **Public paths:** `/join` and the confirmation route must be public (unauthenticated access returns 200). - **Explicit public path for `/join`:** Add **`/join`** (and if needed `/join/*`) to the page-permission plug’s **`public_path?/1`** so that the join page is reachable without login. Do not rely on the confirm path alone. - **Confirmation route:** Use **`/confirm_join/:token`** so that the existing whitelist (e.g. `String.starts_with?(path, "/confirm")`) already covers it; no extra plug change for confirm. -- **Abuse:** **Honeypot** (MVP) plus **rate limiting** (MVP). Use Phoenix/Elixir standard options (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Verify library version and multi-node behaviour before or during implementation. +- **Abuse:** **Honeypot** (MVP) plus **rate limiting** (MVP). Use Phoenix/Elixir standard options (e.g. **Hammer** with **Hammer.Plug**, ETS backend), scoped to the join flow (e.g. by IP). Client IP for rate limiting: prefer **X-Forwarded-For** / **X-Real-IP** when behind a reverse proxy (see Endpoint `connect_info: [:x_headers]` and `JoinLive.client_ip_from_socket/1`); fallback to peer_data with correct IPv4/IPv6 string via `:inet.ntoa/1`. Verify library version and multi-node behaviour before or during implementation. - **Data:** Minimal PII; no sensitive data on the public form; consider DSGVO when extending. If abuse signals are stored: only hashed or aggregated values; document classification and retention. - **Approval-only:** No automatic User/Member creation from the join form; approval (Step 2) or other trusted path creates identity. - **Ash policies and actor:** JoinRequest has **explicit public actions** allowed with `actor: nil` (e.g. `submit` for create, `confirm` for update). Model via **policies** that permit these actions when actor is nil; do **not** use `authorize?: false` unless documented and clearly not a privilege-escalation path. diff --git a/docs/page-permission-route-coverage.md b/docs/page-permission-route-coverage.md index f91ee0c..38625e6 100644 --- a/docs/page-permission-route-coverage.md +++ b/docs/page-permission-route-coverage.md @@ -36,7 +36,9 @@ This document lists all protected routes, which permission set may access them, ## Public Paths (no permission check) -- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale` +- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`, **`/join`** + +The public join page `GET /join` is explicitly public (Subtask 4); unauthenticated access returns 200 when join form is enabled, 404 when disabled. Unit test: `test/mv_web/plugs/check_page_permission_test.exs` (plug allows /join); integration: `test/mv_web/live/join_live_test.exs`. The join confirmation route `GET /confirm_join/:token` is public (matched by `/confirm*`). Unit tests: `test/mv_web/controllers/join_confirm_controller_test.exs` (stubbed callback, no integration). diff --git a/lib/membership/join_request.ex b/lib/membership/join_request.ex index 2519089..cf220a0 100644 --- a/lib/membership/join_request.ex +++ b/lib/membership/join_request.ex @@ -37,6 +37,7 @@ defmodule Mv.Membership.JoinRequest do accept [:email, :first_name, :last_name, :form_data, :schema_version] change Mv.Membership.JoinRequest.Changes.SetConfirmationToken + change Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist end read :get_by_confirmation_token_hash do @@ -77,6 +78,8 @@ defmodule Mv.Membership.JoinRequest do end validations do + # Format/formatting of email is not validated here; invalid addresses may fail at send time + # or can be enforced via an Ash change if needed. validate present(:email), on: [:create] end diff --git a/lib/membership/join_request/changes/filter_form_data_by_allowlist.ex b/lib/membership/join_request/changes/filter_form_data_by_allowlist.ex new file mode 100644 index 0000000..5de15c8 --- /dev/null +++ b/lib/membership/join_request/changes/filter_form_data_by_allowlist.ex @@ -0,0 +1,38 @@ +defmodule Mv.Membership.JoinRequest.Changes.FilterFormDataByAllowlist do + @moduledoc """ + Filters form_data to only keys that are in the join form allowlist (server-side). + + Ensures that even when submit_join_request/2 is called directly (e.g. from tests or API), + only allowlisted custom fields are persisted. Typed fields (email, first_name, last_name) + are not part of form_data; allowlist is join_form_field_ids minus those. + """ + use Ash.Resource.Change + + alias Mv.Membership + + @typed_fields ["email", "first_name", "last_name"] + + @spec change(Ash.Changeset.t(), keyword(), Ash.Resource.Change.context()) :: Ash.Changeset.t() + def change(changeset, _opts, _context) do + form_data = Ash.Changeset.get_attribute(changeset, :form_data) || %{} + + allowlist_ids = + case Membership.get_join_form_allowlist() do + list when is_list(list) -> + list + |> Enum.map(fn item -> item.id end) + |> MapSet.new() + |> MapSet.difference(MapSet.new(@typed_fields)) + + _ -> + MapSet.new() + end + + filtered = + form_data + |> Enum.filter(fn {key, _} -> MapSet.member?(allowlist_ids, to_string(key)) end) + |> Map.new() + + Ash.Changeset.force_change_attribute(changeset, :form_data, filtered) + end +end diff --git a/lib/mv/application.ex b/lib/mv/application.ex index 835652f..6b4a10b 100644 --- a/lib/mv/application.ex +++ b/lib/mv/application.ex @@ -9,6 +9,7 @@ defmodule Mv.Application do alias Mv.Repo alias Mv.Vereinfacht.SyncFlash alias MvWeb.Endpoint + alias MvWeb.JoinRateLimit alias MvWeb.Telemetry @impl true @@ -18,6 +19,7 @@ defmodule Mv.Application do children = [ Telemetry, Repo, + {JoinRateLimit, [clean_period: :timer.minutes(1)]}, {Task.Supervisor, name: Mv.TaskSupervisor}, {DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: Mv.PubSub}, diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index 79983c5..17fca11 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -82,7 +82,25 @@ defmodule MvWeb.Layouts do <% else %> - + +
+ Mila Logo + + {@club_name} + +
+ + +
+
{render_slot(@inner_block)} diff --git a/lib/mv_web/endpoint.ex b/lib/mv_web/endpoint.ex index d1b4247..a591fb9 100644 --- a/lib/mv_web/endpoint.ex +++ b/lib/mv_web/endpoint.ex @@ -12,8 +12,8 @@ defmodule MvWeb.Endpoint do ] socket "/live", Phoenix.LiveView.Socket, - websocket: [connect_info: [session: @session_options]], - longpoll: [connect_info: [session: @session_options]] + websocket: [connect_info: [:peer_data, :x_headers, session: @session_options]], + longpoll: [connect_info: [:peer_data, :x_headers, session: @session_options]] # Serve at "/" the static files from "priv/static" directory. # diff --git a/lib/mv_web/join_rate_limit.ex b/lib/mv_web/join_rate_limit.ex new file mode 100644 index 0000000..878e1ee --- /dev/null +++ b/lib/mv_web/join_rate_limit.ex @@ -0,0 +1,28 @@ +defmodule MvWeb.JoinRateLimit do + @moduledoc """ + Rate limiting for the public join form (submit action). + + Uses Hammer with ETS backend. Key is derived from client IP so each IP + is limited independently. Config from :mv :join_rate_limit (scale_ms, limit). + """ + use Hammer, backend: :ets + + @doc """ + Checks if the given key (e.g. client IP) is within rate limit for join form submit. + + Returns: + - `:allow` - submission allowed + - `{:deny, _retry_after_ms}` - rate limit exceeded + """ + def check(key) when is_binary(key) do + # Read at runtime so config can be changed without restart (e.g. in tests). + config = Application.get_env(:mv, :join_rate_limit, []) + scale_ms = Keyword.get(config, :scale_ms, 60_000) + limit = Keyword.get(config, :limit, 10) + + case hit(key, scale_ms, limit) do + {:allow, _count} -> :allow + {:deny, retry_after} -> {:deny, retry_after} + end + end +end diff --git a/lib/mv_web/live/join_live.ex b/lib/mv_web/live/join_live.ex new file mode 100644 index 0000000..99a7df9 --- /dev/null +++ b/lib/mv_web/live/join_live.ex @@ -0,0 +1,261 @@ +defmodule MvWeb.JoinLive do + @moduledoc """ + Public join page (unauthenticated). Renders form from allowlist, handles submit + with honeypot and rate limiting; shows success copy after submit. + """ + use MvWeb, :live_view + + alias Mv.Membership + alias MvWeb.JoinRateLimit + alias MvWeb.Translations.MemberFields + + # Honeypot field name (legitimate-sounding to avoid bot detection) + @honeypot_field "website" + + @impl true + def mount(_params, _session, socket) do + allowlist = Membership.get_join_form_allowlist() + join_fields = build_join_fields_with_labels(allowlist) + client_ip = client_ip_from_socket(socket) + + socket = + socket + |> assign(:join_fields, join_fields) + |> assign(:submitted, false) + |> assign(:rate_limit_error, nil) + |> assign(:client_ip, client_ip) + |> assign(:honeypot_field, @honeypot_field) + |> assign(:form, to_form(initial_form_params(join_fields))) + + {:ok, socket} + end + + @impl true + def render(assigns) do + ~H""" + +
+ <.header> + {gettext("Become a member")} + + +

+ {gettext("Please enter your details for the membership application here.")} +

+ + <%= if @submitted do %> +
+

+ {gettext( + "We have saved your details. To complete your request, please click the link we sent to your email." + )} +

+
+ <% else %> + <.form + for={@form} + id="join-form" + phx-submit="submit" + class="space-y-4" + > + <%= if @rate_limit_error do %> +
+ {@rate_limit_error} +
+ <% end %> + + <%= for field <- @join_fields do %> +
+ + +
+ <% end %> + + <%!-- + Honeypot (best practice): legit field name "website", type="text", no inline CSS, + hidden via class in app.css (off-screen + 1px). tabindex=-1, autocomplete=off, + aria-hidden so screen readers skip. If filled → silent failure (same success UI). + --%> + + +

+ {gettext( + "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed." + )} +

+ +

+ {gettext( + "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary." + )} +

+ +
+ +
+ + <% end %> +
+
+ """ + end + + @impl true + def handle_event("submit", params, socket) do + # Honeypot: if filled, treat as bot – show same success UI, do not create (silent failure) + honeypot_value = String.trim(params[@honeypot_field] || "") + + if honeypot_value != "" do + {:noreply, assign(socket, :submitted, true)} + else + key = "join:#{socket.assigns.client_ip}" + + case JoinRateLimit.check(key) do + :allow -> do_submit(socket, params) + {:deny, _retry_after} -> rate_limited_reply(socket, params) + end + end + end + + defp do_submit(socket, params) do + case build_submit_attrs(params, socket.assigns.join_fields) do + {:ok, attrs} -> + case Membership.submit_join_request(attrs, actor: nil) do + {:ok, _} -> {:noreply, assign(socket, :submitted, true)} + {:error, _} -> validation_error_reply(socket, params) + end + + {:error, message} -> + {:noreply, + socket + |> put_flash(:error, message) + |> assign(:form, to_form(params, as: "join"))} + end + end + + defp validation_error_reply(socket, params) do + {:noreply, + socket + |> put_flash(:error, gettext("Please check your entries. Email is required.")) + |> assign(:form, to_form(params, as: "join"))} + end + + defp rate_limited_reply(socket, params) do + {:noreply, + socket + |> assign(:rate_limit_error, gettext("Too many requests. Please try again later.")) + |> assign(:form, to_form(params, as: "join"))} + end + + defp build_join_fields_with_labels(allowlist) do + member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) + + Enum.map(allowlist, fn %{id: id, required: required} -> + label = + if id in member_field_strings do + MemberFields.label(String.to_existing_atom(id)) + else + gettext("Field") + end + + %{id: id, label: label, required: required} + end) + end + + defp initial_form_params(join_fields) do + join_fields + |> Enum.map(fn f -> {f.id, ""} end) + |> Map.new() + |> Map.put(@honeypot_field, "") + end + + defp input_type("email"), do: "email" + defp input_type(_), do: "text" + + defp build_submit_attrs(params, join_fields) do + allowlist_ids = MapSet.new(Enum.map(join_fields, & &1.id)) + typed = ["email", "first_name", "last_name"] + + email = String.trim(params["email"] || "") + + if email == "" do + {:error, gettext("Email is required.")} + else + attrs = %{ + email: email, + first_name: optional_param(params, "first_name"), + last_name: optional_param(params, "last_name"), + form_data: %{}, + schema_version: 1 + } + + form_data = + params + |> Enum.filter(fn {key, _} -> key in allowlist_ids and key not in typed end) + |> Map.new(fn {k, v} -> {k, String.trim(to_string(v))} end) + + attrs = %{attrs | form_data: form_data} + {:ok, attrs} + end + end + + defp optional_param(params, key) do + v = params[key] + if is_binary(v), do: String.trim(v), else: nil + end + + # Prefer X-Forwarded-For / X-Real-IP when behind a reverse proxy; fall back to peer_data. + # Uses :inet.ntoa/1 for correct IPv4 and IPv6 string representation. + defp client_ip_from_socket(socket) do + with nil <- client_ip_from_headers(socket), + %{address: address} when is_tuple(address) <- get_connect_info(socket, :peer_data) do + address |> :inet.ntoa() |> to_string() + else + ip when is_binary(ip) -> ip + _ -> "unknown" + end + end + + defp client_ip_from_headers(socket) do + headers = get_connect_info(socket, :x_headers) || [] + real_ip = header_value(headers, "x-real-ip") + forwarded = header_value(headers, "x-forwarded-for") + + cond do + real_ip != nil -> real_ip + forwarded != nil -> String.split(forwarded, ~r/,\s*/) |> List.first() |> String.trim() + true -> nil + end + end + + defp header_value(headers, name) do + name_lower = String.downcase(name) + + headers + |> Enum.find_value(fn + {h, v} when is_binary(h) -> if String.downcase(h) == name_lower, do: String.trim(v) + _ -> nil + end) + end +end diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index e2e037d..c8f32a0 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -107,6 +107,9 @@ defmodule MvWeb.MemberLive.Index do {:error, _} -> %{member_field_visibility: %{}} end + # Ensure nested module is loaded (can be missing after code reload in dev if load order changes) + Code.ensure_loaded!(FieldSelection) + # Load user field selection from session session_selection = FieldSelection.get_from_session(session) diff --git a/lib/mv_web/plugs/check_page_permission.ex b/lib/mv_web/plugs/check_page_permission.ex index 3aa6561..ff6d47d 100644 --- a/lib/mv_web/plugs/check_page_permission.ex +++ b/lib/mv_web/plugs/check_page_permission.ex @@ -80,7 +80,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do Used by LiveView hook to skip redirect on sign-in etc. """ def public_path?(path) when is_binary(path) do - path in ["/register", "/reset", "/set_locale", "/sign-in", "/sign-out"] or + path in ["/register", "/reset", "/set_locale", "/sign-in", "/sign-out", "/join"] or String.starts_with?(path, "/auth") or String.starts_with?(path, "/confirm") or String.starts_with?(path, "/password-reset") diff --git a/lib/mv_web/plugs/join_form_enabled.ex b/lib/mv_web/plugs/join_form_enabled.ex new file mode 100644 index 0000000..854a7cf --- /dev/null +++ b/lib/mv_web/plugs/join_form_enabled.ex @@ -0,0 +1,34 @@ +defmodule MvWeb.Plugs.JoinFormEnabled do + @moduledoc """ + For GET /join: returns 404 when the join form is disabled in settings. + No-op for other paths. + """ + import Plug.Conn + + alias Mv.Membership + + def init(opts), do: opts + + def call(conn, _opts) do + if join_path?(conn), do: maybe_404(conn), else: conn + end + + defp join_path?(conn) do + conn.request_path == "/join" and conn.method == "GET" + end + + defp maybe_404(conn) do + case Membership.get_settings() do + {:ok, %{join_form_enabled: true}} -> conn + _ -> send_404(conn) + end + end + + # Same body as default ErrorHTML 404 (no custom error templates in this app). + defp send_404(conn) do + conn + |> put_resp_content_type("text/html") + |> send_resp(404, "Not Found") + |> halt() + end +end diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex index 3ab264f..74fcd22 100644 --- a/lib/mv_web/router.ex +++ b/lib/mv_web/router.ex @@ -15,6 +15,7 @@ defmodule MvWeb.Router do plug :load_from_session plug :set_locale plug MvWeb.Plugs.CheckPagePermission + plug MvWeb.Plugs.JoinFormEnabled end pipeline :api do @@ -126,6 +127,12 @@ defmodule MvWeb.Router do overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI], gettext_backend: {MvWeb.Gettext, "auth"} + # Public join page (no auth required) + live_session :public_join, + on_mount: [{MvWeb.LiveUserAuth, :live_user_optional}] do + live "/join", JoinLive, :index + end + # Public join confirmation (double opt-in); /confirm* is already public in CheckPagePermission get "/confirm_join/:token", JoinConfirmController, :confirm diff --git a/mix.exs b/mix.exs index 0d1d4f1..56e7dde 100644 --- a/mix.exs +++ b/mix.exs @@ -82,7 +82,8 @@ defmodule Mv.MixProject do {:ecto_commons, "~> 0.3"}, {:slugify, "~> 1.3"}, {:nimble_csv, "~> 1.0"}, - {:imprintor, "~> 0.5.0"} + {:imprintor, "~> 0.5.0"}, + {:hammer, "~> 7.0"} ] end diff --git a/mix.lock b/mix.lock index 849dfd5..8ac995a 100644 --- a/mix.lock +++ b/mix.lock @@ -37,6 +37,7 @@ "fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"}, "gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"}, "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, + "hammer": {:hex, :hammer, "7.2.0", "73113eca87f0fd20a6d3679c1182e8c4c1778266f61de4e9dc8c589dee156c30", [:mix], [], "hexpm", "c50fa865ddfe7b3d4f8a6941f56940679e02a9a1465b00668a95d140b101d828"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 90bedc5..96b8c07 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -513,6 +513,7 @@ msgstr "Zurück zur Mitgliederliste" msgid "Back to users list" msgstr "Zurück zur Benutzer*innen-Liste" +#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Select language" @@ -1792,6 +1793,7 @@ msgid "Email is invalid." msgstr "E-Mail ist ungültig." #: lib/mv/membership/import/member_csv.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Email is required." msgstr "E-Mail ist erforderlich." @@ -3348,6 +3350,7 @@ msgid "Could not save join form settings." msgstr "Beitrittsformular-Einstellungen konnten nicht gespeichert werden." #: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Field" msgstr "Feld" @@ -3401,3 +3404,48 @@ msgstr "Umordnen" #, elixir-autogen, elixir-format msgid "The order of rows determines the field order in the join form." msgstr "Die Reihenfolge der Zeilen bestimmt die Reihenfolge der Felder im Beitrittsformular." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Become a member" +msgstr "Mitglied werden" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please check your entries. Email is required." +msgstr "Bitte prüfe deine Angaben. E-Mail ist erforderlich." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Submit request" +msgstr "Antrag absenden" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Too many requests. Please try again later." +msgstr "Zu viele Anfragen. Bitte versuche es später erneut." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "We have saved your details. To complete your request, please click the link we sent to your email." +msgstr "Wir haben deine Angaben gespeichert. Um deinen Antrag abzuschließen, klicke bitte auf den Link in der E-Mail, die wir dir geschickt haben." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed." +msgstr "Mit Absenden deines Antrags erhältst du eine Mail mit einem Bestätigungslink. Sobald du deine Mail-Adresse bestätigt hast, wird dein Antrag geprüft." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please enter your details for the membership application here." +msgstr "Bitte gib hier die Daten für deinen Mitgliedsantrag an." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary." +msgstr "Deine Angaben werden nur zur Bearbeitung deines Mitgliedsantrags und zur Kontaktaufnahme genutzt. Zur Absicherung gegen Missbrauch verarbeiten wir zusätzlich technische Daten (z. B. IP-Adresse) nur im dafür nötigen Umfang." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Website" +msgstr "Webseite" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 70bf233..65197e1 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -514,6 +514,7 @@ msgstr "" msgid "Back to users list" msgstr "" +#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format msgid "Select language" @@ -1793,6 +1794,7 @@ msgid "Email is invalid." msgstr "" #: lib/mv/membership/import/member_csv.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Email is required." msgstr "" @@ -3348,6 +3350,7 @@ msgid "Could not save join form settings." msgstr "" #: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Field" msgstr "" @@ -3401,3 +3404,48 @@ msgstr "" #, elixir-autogen, elixir-format msgid "The order of rows determines the field order in the join form." msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Become a member" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please check your entries. Email is required." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Submit request" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Too many requests. Please try again later." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "We have saved your details. To complete your request, please click the link we sent to your email." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please enter your details for the membership application here." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Website" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 02160f9..4ebce69 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -514,6 +514,7 @@ msgstr "" msgid "Back to users list" msgstr "" +#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts/sidebar.ex #, elixir-autogen, elixir-format, fuzzy msgid "Select language" @@ -1793,6 +1794,7 @@ msgid "Email is invalid." msgstr "" #: lib/mv/membership/import/member_csv.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Email is required." msgstr "" @@ -3348,6 +3350,7 @@ msgid "Could not save join form settings." msgstr "" #: lib/mv_web/live/global_settings_live.ex +#: lib/mv_web/live/join_live.ex #, elixir-autogen, elixir-format msgid "Field" msgstr "" @@ -3401,3 +3404,48 @@ msgstr "Reorder" #, elixir-autogen, elixir-format msgid "The order of rows determines the field order in the join form." msgstr "The order of rows determines the field order in the join form." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Become a member" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please check your entries. Email is required." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Submit request" +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Too many requests. Please try again later." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "We have saved your details. To complete your request, please click the link we sent to your email." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Please enter your details for the membership application here." +msgstr "" + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary." +msgstr "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary." + +#: lib/mv_web/live/join_live.ex +#, elixir-autogen, elixir-format +msgid "Website" +msgstr "" diff --git a/test/membership/join_request_test.exs b/test/membership/join_request_test.exs index f40c9ec..1992993 100644 --- a/test/membership/join_request_test.exs +++ b/test/membership/join_request_test.exs @@ -38,6 +38,21 @@ defmodule Mv.Membership.JoinRequestTest do end test "persists first_name, last_name and form_data when provided" do + # Allowlist must include custom fields so FilterFormDataByAllowlist persists them + {:ok, settings} = Membership.get_settings() + + Mv.Membership.update_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "first_name", "last_name", "city", "notes"], + join_form_field_required: %{ + "email" => true, + "first_name" => false, + "last_name" => false, + "city" => false, + "notes" => false + } + }) + attrs = @valid_submit_attrs |> Map.put(:confirmation_token, "token-#{System.unique_integer([:positive])}") @@ -121,6 +136,33 @@ defmodule Mv.Membership.JoinRequestTest do end end + describe "allowlist (server-side field filter)" do + test "submit with non-allowlisted form_data keys does not persist those keys" do + # Allowlist restricts which fields are accepted; extra keys must not be stored. + {:ok, settings} = Membership.get_settings() + + Mv.Membership.update_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "first_name", "city"], + join_form_field_required: %{"email" => true, "first_name" => false, "city" => false} + }) + + attrs = %{ + email: "allowlist#{System.unique_integer([:positive])}@example.com", + first_name: "Allowed", + confirmation_token: "tok-#{System.unique_integer([:positive])}", + form_data: %{"city" => "Berlin", "internal_or_secret" => "must not persist"}, + schema_version: 1 + } + + assert {:ok, request} = Membership.submit_join_request(attrs, actor: nil) + assert request.email == attrs.email + assert request.first_name == attrs.first_name + refute Map.has_key?(request.form_data || %{}, "internal_or_secret") + assert (request.form_data || %{})["city"] == "Berlin" + end + end + defp error_message(errors, field) do errors |> Enum.filter(fn err -> Map.get(err, :field) == field end) diff --git a/test/mv_web/controllers/join_confirm_controller_test.exs b/test/mv_web/controllers/join_confirm_controller_test.exs index d1e9117..a85cde5 100644 --- a/test/mv_web/controllers/join_confirm_controller_test.exs +++ b/test/mv_web/controllers/join_confirm_controller_test.exs @@ -60,13 +60,18 @@ defmodule MvWeb.JoinConfirmControllerTest do end @tag role: :unauthenticated - test "expired token returns 200 with expired message", %{conn: conn} do + test "expired token returns 200 with expired message and instructs to submit form again", %{ + conn: conn + } do Application.put_env(:mv, :join_confirm_callback, JoinConfirmExpiredStub) conn = get(conn, "/confirm_join/expired-token") + body = response(conn, 200) - assert response(conn, 200) =~ "expired" - assert response(conn, 200) =~ "submit" + assert body =~ "expired" + assert body =~ "submit" + # Concept §2.5: clear message + "submit form again" + assert body =~ "form" or body =~ "again" end @tag role: :unauthenticated diff --git a/test/mv_web/live/join_live_test.exs b/test/mv_web/live/join_live_test.exs new file mode 100644 index 0000000..bd133cd --- /dev/null +++ b/test/mv_web/live/join_live_test.exs @@ -0,0 +1,169 @@ +defmodule MvWeb.JoinLiveTest do + @moduledoc """ + Tests for the public join page (Subtask 4: Public join page and anti-abuse). + + Covers: public path /join (unauthenticated 200), 404 when join disabled, + submit creates JoinRequest and shows success copy, honeypot prevents create, + rate limiting rejects excess submits. Uses unauthenticated conn; no User/Member. + + Honeypot: form param `"website"` (legit-sounding name per best practice; not "honeypot"). + Field is hidden via CSS class in app.css (off-screen, no inline styles), type="text". + """ + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + import Ecto.Query + + alias Mv.Membership + alias Mv.Repo + + describe "GET /join" do + @tag role: :unauthenticated + test "unauthenticated GET /join returns 200 when join form is enabled", %{conn: conn} do + enable_join_form(true) + conn = get(conn, "/join") + assert conn.status == 200 + end + + @tag role: :unauthenticated + test "unauthenticated GET /join returns 404 when join form is disabled", %{conn: conn} do + enable_join_form(false) + conn = get(conn, "/join") + assert conn.status == 404 + end + end + + describe "submit join form" do + setup :enable_join_form_for_test + + @tag role: :unauthenticated + test "submit with valid allowlist data creates one JoinRequest and shows success copy", %{ + conn: conn + } do + count_before = count_join_requests() + {:ok, view, _html} = live(conn, "/join") + + view + |> form("#join-form", %{ + "email" => "newuser#{System.unique_integer([:positive])}@example.com", + "first_name" => "Jane", + "last_name" => "Doe", + "website" => "" + }) + |> render_submit() + + assert count_join_requests() == count_before + 1 + assert view |> element("[data-testid='join-success-message']") |> has_element?() + assert render(view) =~ "saved your details" + assert render(view) =~ "click the link" + end + + @tag role: :unauthenticated + test "submit with honeypot filled does not create JoinRequest but shows same success copy", %{ + conn: conn + } do + count_before = count_join_requests() + {:ok, view, _html} = live(conn, "/join") + + view + |> form("#join-form", %{ + "email" => "bot#{System.unique_integer([:positive])}@example.com", + "first_name" => "Bot", + "last_name" => "User", + "website" => "filled-by-bot" + }) + |> render_submit() + + assert count_join_requests() == count_before + assert view |> element("[data-testid='join-success-message']") |> has_element?() + end + + @tag role: :unauthenticated + @tag :slow + test "after rate limit exceeded submit returns 429 or error and no new JoinRequest", %{ + conn: conn + } do + # Reset rate limit state so this test is independent of others (same key in test) + try do + :ets.delete_all_objects(MvWeb.JoinRateLimit) + rescue + ArgumentError -> :ok + end + + enable_join_form(true) + # Set allowlist so form has email, first_name, last_name + {:ok, settings} = Membership.get_settings() + + Membership.update_settings(settings, %{ + join_form_field_ids: ["email", "first_name", "last_name"], + join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false} + }) + + # Rely on test config: join rate limit low (e.g. 2 per window) + base_email = "ratelimit#{System.unique_integer([:positive])}@example.com" + count_before = count_join_requests() + sandbox = conn.private[:ecto_sandbox] + + # Exhaust limit with 2 valid submits (each needs a fresh session because form disappears after submit) + for i <- 0..1 do + c = + build_conn() + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_private(:ecto_sandbox, sandbox) + + {:ok, view, _} = live(c, "/join") + + view + |> form("#join-form", %{ + "email" => "#{i}-#{base_email}", + "first_name" => "User", + "last_name" => "Test", + "website" => "" + }) + |> render_submit() + end + + # Next submit (new session) should be rate limited + c = + build_conn() + |> Phoenix.ConnTest.init_test_session(%{}) + |> Plug.Conn.put_private(:ecto_sandbox, sandbox) + + {:ok, view, _} = live(c, "/join") + + result = + view + |> form("#join-form", %{ + "email" => "third-#{base_email}", + "first_name" => "Third", + "last_name" => "User", + "website" => "" + }) + |> render_submit() + + assert count_join_requests() == count_before + 2 + assert result =~ "rate limit" or String.downcase(result) =~ "too many" or result =~ "429" + end + end + + defp enable_join_form(enabled) do + {:ok, settings} = Membership.get_settings() + {:ok, _} = Membership.update_settings(settings, %{join_form_enabled: enabled}) + end + + defp enable_join_form_for_test(_context) do + {:ok, settings} = Membership.get_settings() + + {:ok, _} = + Membership.update_settings(settings, %{ + join_form_enabled: true, + join_form_field_ids: ["email", "first_name", "last_name"], + join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false} + }) + + :ok + end + + defp count_join_requests do + Repo.one(from j in "join_requests", select: count(j.id)) || 0 + end +end diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs index 1b3f827..80aa95e 100644 --- a/test/mv_web/plugs/check_page_permission_test.exs +++ b/test/mv_web/plugs/check_page_permission_test.exs @@ -204,6 +204,12 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do refute conn.halted end + + test "unauthenticated user can access /join (public join page, no redirect)" do + conn = conn_without_user("/join") |> CheckPagePermission.call([]) + + refute conn.halted + end end describe "error handling" do