From 7177b0b0a4af86fe8a49f82c00f9186bb4d87e6e Mon Sep 17 00:00:00 2001 From: Jason Maurer Date: Mon, 24 Apr 2023 20:50:30 -0600 Subject: [PATCH] v1 release --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 3 + README.md | 204 ++++++++++++++++++ lib/phoenix_turnstile.ex | 3 - lib/turnstile.ex | 165 ++++++++++++++ lib/turnstile/behaviour.ex | 13 ++ mix.exs | 10 +- mix.lock | 19 ++ package.json | 3 + priv/static/phoenix_turnstile.js | 25 +++ .../custom_cassettes/turnstile_error.json | 15 ++ .../custom_cassettes/turnstile_failure.json | 15 ++ .../custom_cassettes/turnstile_success.json | 15 ++ test/test_helper.exs | 2 +- test/turnstile_test.exs | 131 +++++++++++ 15 files changed, 617 insertions(+), 8 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 lib/phoenix_turnstile.ex create mode 100644 lib/turnstile.ex create mode 100644 lib/turnstile/behaviour.ex create mode 100644 package.json create mode 100644 priv/static/phoenix_turnstile.js create mode 100644 test/fixtures/custom_cassettes/turnstile_error.json create mode 100644 test/fixtures/custom_cassettes/turnstile_failure.json create mode 100644 test/fixtures/custom_cassettes/turnstile_success.json create mode 100644 test/turnstile_test.exs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d1222b7..d1f5a75 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,4 +39,4 @@ jobs: mix-build-${{ runner.os }}- - run: mix deps.get - - run: mix test + - run: mix test --include external diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..acd2eef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## v1.0.0 + +- Initial release 🎉 diff --git a/README.md b/README.md index fb4e692..6a4c0b2 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ Test Status Hex Version +Components and helpers for using [Cloudflare Turnstile CAPTCHAs](https://www.cloudflare.com/products/turnstile/) in Phoenix apps. To get started, log into the Cloudflare dashboard and visit the Turnstile tab. Add a new site with your domain name (no need to add `localhost` if using the default test keys), and take note of your site key and secret key. You'll need these values later. + ## Getting Started ```elixir @@ -9,3 +11,205 @@ def deps do ] end ``` + +Now add the site key and secret key to your environment variables, and configure them in `config/runtime.exs`: + +```elixir +config :phoenix_turnstile, + site_key: System.fetch_env!("TURNSTILE_SITE_KEY"), + secret_key: System.fetch_env!("TURNSTILE_SECRET_KEY") +``` + +You don't need to add a site key or secret key for dev/test environments. This library will use the Turnstile test keys by default. + +## With Live View + +To use CAPTCHAs in a Live View app, start out by adding the script component in your root layout: + +```heex + + + + + +``` + +Next, install the hook in `app.js` or wherever your live socket is being defined (make sure you're setting `NODE_PATH` in your [esbuild config](https://github.com/phoenixframework/esbuild#adding-to-phoenix) and including the `deps` folder): + +```javascript +import { TurnstileHook } from "phoenix_turnstile" + +const liveSocket = new LiveSocket("/live", Socket, { + /* ... */ + hooks: { + Turnstile: TurnstileHook + } +}) +``` + +Now you can use the Turnstile widget component in any of your forms. For example: + +```heex +<.form for={@form} phx-submit="submit"> + + + + +``` + +To customize the widget, pass any of the render parameters [specificed here](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations) (without the `data-` prefix). + +### Verification + +The widget by itself won't actually complete the verification. It works by generating a token which gets injected into your form as a hidden input named `cf-turnstile-response`. The token needs to be sent to the Cloudflare API for final verification before continuing with the form submission. This should be done in your submit event using `Turnstile.verify/2`: + +```elixir +def handle_event("submit", values, socket) do + case Turnstile.verify(values) do + {:ok, _} -> + # Verification passed! + + {:noreply, socket} + + {:error, _} -> + socket = + socket + |> put_flash(:error, "Please try submitting again") + |> Turnstile.refresh() + + {:noreply, socket} + end +end +``` + +To be extra sure the user is not a robot, you also have the option of passing their IP address to the verification API. **This step is optional.** To get the user's IP address in Live View, add `:peer_data` to the connect info for your socket in `endpoint.ex`: + +```elixir +socket "/live", Phoenix.LiveView.Socket, + websocket: [ + connect_info: [:peer_data, ...] + ] +``` + +and pass it as the second argument to `Turnstile.verify/2`: + +```elixir +def mount(_params, session, socket) do + remote_ip = get_connect_info(socket, :peer_data).address + + {:ok, assign(socket, :remote_ip, remote_ip)} +end + +def handle_event("submit", values, socket) do + case Turnstile.verify(values, socket.assigns.remote_ip) do + # ... + end +end +``` + +### Multiple Widgets + +If you want to have multiple widgets on the same page, pass a unique ID to `Turnstile.widget/1`, `Turnstile.refresh/1`, and `Turnstile.remove/1`. + +## Without Live View + +`Turnstile.script/1` and `Turnstile.widget/1` both rely on [client hooks](https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks-via-phx-hook), and should work in non-Live View pages as long as `app.js` is opening a live socket (which it should by default). Simply call `Turnstile.verify/2` in the controller: + +```elixir +def create(conn, params) do + case Turnstile.verify(params, conn.remote_ip) do + {:ok, _} -> + # Verification passed! + + redirect(conn, to: ~p"/success") + + {:error, _} -> + conn + |> put_flash(:error, "Please try submitting again") + |> redirect(to: ~p"/new") + end +end +``` + +If your page doesn't open a live socket or your're not using HEEx, you can still run Turnstile verifications by building your own client-side widget following the [documentation](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/) and using `Turnstile.site_key/0` to get your site key in the template: + +```elixir +def new(conn, _params) do + conn + |> assign(:site_key, Turnstile.site_key()) + |> render("new.html") +end +``` + +```html +
+ + +
+ +
+``` + +## Content Security Policies + +If your site uses a content security policy, you'll need to add `https://challenges.cloudflare.com` to your `script-src` and `frame-src` directives. You can also add attributes to the script component such as `nonce`, and they will be passed through to the script tag: + +```heex + + + + + +``` + +## Writing Tests + +When testing forms that use Turnstile verification, you may or may not want to call the live API. + +Although we use the test keys by default, you should consider using mocks during testing. An excellent library to consider is [mox](https://github.com/dashbitco/mox). Phoenix Turnstile exposes a behaviour that you can use to make writing your tests much easier. + +To start using Mox with Phoenix Turnstile, add this to your `test/test_helper.ex`: + +```elixir +Mox.defmock(TurnstileMock, for: Turnstile.Behaviour) +``` + +Then in your `config/test.exs`: + +```elixir +config :phoenix_turnstile, adapter: TurnstileMock +``` + +To make sure you're using `TurnstileMock` during testing, use the adapter from the config rather than using `Turnstile` directly: + +```elixir +@turnstile Application.compile_env(:phoenix_turnstile, :adapter, Turnstile) + +def handle_event("submit", values, socket) do + case @turnstile.verify(values) do + {:ok, _} -> + # Verification passed! + + {:noreply, socket} + + {:error, _} -> + socket = + socket + |> put_flash(:error, "Please try submitting again") + |> @turnstile.refresh() + + {:noreply, socket} + end +end +``` + +Now you can easily mock or stub any Turnstile function in your tests and they won't make any real API calls: + +```elixir +import Mox + +setup do + stub(TurnstileMock, :refresh, fn socket -> socket end) + stub(TurnstileMock, :verify, fn _values, _remoteip -> {:ok, %{}} end) +end +``` diff --git a/lib/phoenix_turnstile.ex b/lib/phoenix_turnstile.ex deleted file mode 100644 index afd2769..0000000 --- a/lib/phoenix_turnstile.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule PhoenixTurnstile do - @moduledoc false -end diff --git a/lib/turnstile.ex b/lib/turnstile.ex new file mode 100644 index 0000000..9ef7511 --- /dev/null +++ b/lib/turnstile.ex @@ -0,0 +1,165 @@ +defmodule Turnstile do + @behaviour Turnstile.Behaviour + @moduledoc """ + Use Cloudflare Turnstile in Phoenix apps + """ + + import Phoenix.Component + + alias Phoenix.LiveView + + @script_url "https://challenges.cloudflare.com/turnstile/v0/api.js" + @verify_url "https://challenges.cloudflare.com/turnstile/v0/siteverify" + + @impl true + @doc """ + Returns the configured site key. + """ + def site_key, do: Application.get_env(:phoenix_turnstile, :site_key, "1x00000000000000000000AA") + + @impl true + @doc """ + Returns the configured secret key. + """ + def secret_key, do: Application.get_env(:phoenix_turnstile, :secret_key, "1x0000000000000000000000000000000AA") + + @doc """ + Renders the Turnstile script tag. + + Uses explicit rendering so it works with hooks. Additional attributes will be passed through to + the script tag. + """ + def script(assigns) do + assigns = + assigns + |> assign(:url, @script_url) + |> assign(:rest, assigns_to_attributes(assigns, [:noHook])) + + ~H""" + " + end + + test "should render component with custom attributes" do + assert render_component(&Turnstile.script/1, foo: "bar") == + "" + end + end + + describe "widget/1" do + test "should render component with defaults" do + assert render_component(&Turnstile.widget/1) == + "
" + end + + test "should render component with a class" do + assert render_component(&Turnstile.widget/1, class: "foo") == + "
" + end + + test "should render component with a custom id and hook" do + assert render_component(&Turnstile.widget/1, id: "1", hook: "Foo") == + "
" + end + + test "should render component with a custom site key" do + assert render_component(&Turnstile.widget/1, sitekey: "123") == + "
" + end + + test "should render component with custom data attribute" do + assert render_component(&Turnstile.widget/1, theme: "dark") == + "
" + end + end + + test "refresh/2" do + assert %LiveView.Socket{} = Turnstile.refresh(%LiveView.Socket{}) + end + + test "remove/2" do + assert %LiveView.Socket{} = Turnstile.remove(%LiveView.Socket{}) + end + + describe "verify/2" do + test "should return successful status" do + use_cassette "turnstile_success", custom: true do + assert Turnstile.verify(%{"cf-turnstile-response" => "foo"}) == {:ok, %{"success" => true}} + end + end + + test "should return successful status with ip" do + use_cassette "turnstile_success", custom: true do + assert Turnstile.verify(%{"cf-turnstile-response" => "foo"}, "127.0.0.1") == {:ok, %{"success" => true}} + end + end + + test "should return successful status with charlist ip" do + use_cassette "turnstile_success", custom: true do + assert Turnstile.verify(%{"cf-turnstile-response" => "foo"}, '127.0.0.1') == {:ok, %{"success" => true}} + end + end + + test "should return successful status with tuple ip" do + use_cassette "turnstile_success", custom: true do + assert Turnstile.verify(%{"cf-turnstile-response" => "foo"}, {127, 0, 0, 1}) == {:ok, %{"success" => true}} + end + end + + test "should return unsuccessful status" do + use_cassette "turnstile_failure", custom: true do + assert Turnstile.verify(%{"cf-turnstile-response" => "foo"}) == {:error, %{"success" => false}} + end + end + + test "should return any other errors" do + use_cassette "turnstile_error", custom: true do + assert Turnstile.verify(%{"cf-turnstile-response" => "foo"}) == {:error, "everything broke"} + end + end + + @tag :external + test "should return a successful response" do + assert {:ok, res} = Turnstile.verify(%{"cf-turnstile-response" => "abc123"}) + assert res["success"] == true + assert res["error-codes"] == [] + end + + @tag :external + test "should return a successful response with ip address" do + assert {:ok, res} = Turnstile.verify(%{"cf-turnstile-response" => "abc123"}, "127.0.0.1") + assert res["success"] == true + assert res["error-codes"] == [] + end + + @tag :external + test "should return an error response" do + Application.put_env(:phoenix_turnstile, :secret_key, "2x0000000000000000000000000000000AA") + on_exit(fn -> Application.put_env(:phoenix_turnstile, :secret_key, "1x0000000000000000000000000000000AA") end) + + assert {:error, res} = Turnstile.verify(%{"cf-turnstile-response" => "abc123"}, "127.0.0.1") + assert res["success"] == false + assert res["error-codes"] == ["invalid-input-response"] + end + end +end