Skip to content

Commit

Permalink
v1 release
Browse files Browse the repository at this point in the history
  • Loading branch information
jsonmaur committed Apr 25, 2023
1 parent 6b65e10 commit 7177b0b
Show file tree
Hide file tree
Showing 15 changed files with 617 additions and 8 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ jobs:
mix-build-${{ runner.os }}-
- run: mix deps.get
- run: mix test
- run: mix test --include external
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## v1.0.0

- Initial release 🎉
204 changes: 204 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<a href="https://github.com/jsonmaur/phoenix-turnstile/actions/workflows/test.yml"><img alt="Test Status" src="https://img.shields.io/github/actions/workflow/status/jsonmaur/phoenix-turnstile/test.yml?label=&style=for-the-badge&logo=github"></a> <a href="https://hexdocs.pm/phoenix_turnstile/"><img alt="Hex Version" src="https://img.shields.io/hexpm/v/phoenix_turnstile?style=for-the-badge&label=&logo=elixir" /></a>

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
Expand All @@ -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
<head>
<!-- ... -->
<Turnstile.script />
</head>
```

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">
<Turnstile.widget theme="light" />
<button type="submit">Submit</button>
</.form>
```

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
<form action="/create" method="POST">
<!-- ... -->

<div class="cf-turnstile" data-sitekey="<%= @site_key %>"></div>
<button type="submit">Submit</button>
</form>
```

## 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
<head>
<!-- ... -->
<Turnstile.script nonce={@script_src_nonce} />
</head>
```

## 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
```
3 changes: 0 additions & 3 deletions lib/phoenix_turnstile.ex

This file was deleted.

165 changes: 165 additions & 0 deletions lib/turnstile.ex
Original file line number Diff line number Diff line change
@@ -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"""
<script defer src={"#{@url}?render=explicit"} {@rest} />
"""
end

@doc """
Renders the Turnstile widget.
## Attributes
* `:id` - The ID of the element. Defaults to `"cf-turnstile"`.
* `:class` - The class name passed to the element. Defaults to `nil`.
* `:hook` - The phx-hook used. Defaults to `"Turnstile"`.
* `:sitekey` - The Turnstile site key. Defaults to the `:site_key` config value.
All other attributes will be passed through to the element as `data-*` attributes so the widget
can be customized. See the [Turnstile docs](https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#configurations)
for a list of available attributes.
"""
def widget(assigns) do
rest =
assigns
|> assigns_to_attributes([:id, :class, :hook, :sitekey])
|> Enum.map(fn {k, v} -> {"data-#{k}", v} end)
|> Keyword.put(:class, assigns[:class])

assigns =
assigns
|> assign_new(:id, fn -> "cf-turnstile" end)
|> assign_new(:hook, fn -> "Turnstile" end)
|> assign_new(:sitekey, &site_key/0)
|> assign(:rest, rest)

~H"""
<div
id={@id}
phx-hook={@hook}
phx-update="ignore"
data-sitekey={@sitekey}
{@rest}
/>
"""
end

@impl true
@doc """
Refreshes the Turnstile widget in a LiveView.
Since the widget uses `phx-update="ignore"`, this function can be used if the widget needs to be
re-mounted in the DOM, such as when the verification fails. If there are multiple Turnstile
widgets on the page and you only want to refresh one of them, pass a DOM ID as the second
argument. Otherwise they will all be refreshed.
"""
def refresh(%LiveView.Socket{} = socket, id \\ nil) do
LiveView.push_event(socket, "turnstile:refresh", %{id: id})
end

@impl true
@doc """
Removes the Turnstile widget from a LiveView.
Since the widget uses `phx-update="ignore"`, this function can be used if the widget needs to be
removed from the DOM. If there are multiple Turnstile widgets on the page and you only want to
refresh one of them, pass a DOM ID as the second argument. Otherwise they will all be removed.
"""
def remove(%LiveView.Socket{} = socket, id \\ nil) do
LiveView.push_event(socket, "turnstile:remove", %{id: id})
end

@impl true
@doc """
Calls the Turnstile verify endpoint with a response token.
Expects a map with string keys that contains a value for `"cf-response-token"` (see
[verification](readme.html#verification) for more info). The remote IP can be passed for extra
security when running the verification, but is optional. Returns `{:ok, response}` if the
verification succeeded, or `{:error, reason}` if the verification failed.
"""
def verify(%{"cf-turnstile-response" => turnstile_response}, remoteip \\ nil) do
body = encode_body!(turnstile_response, remoteip)
headers = [{to_charlist("accept"), to_charlist("application/json")}]
request = {to_charlist(@verify_url), headers, to_charlist("application/json"), body}

case :httpc.request(:post, request, [ssl: ssl_opts()], []) do
{:ok, {{_, 200, _}, _, body}} ->
body = Jason.decode!(body)

if body["success"] do
{:ok, body}
else
{:error, body}
end

{:ok, {_, _, body}} ->
{:error, body}

{:error, error} ->
{:error, error}
end
end

defp encode_body!(response, remoteip) when is_tuple(remoteip) do
encode_body!(response, :inet_parse.ntoa(remoteip) |> to_string())
end

defp encode_body!(response, remoteip) when is_list(remoteip) do
encode_body!(response, to_string(remoteip))
end

defp encode_body!(response, remoteip) do
%{response: response, remoteip: remoteip, secret: secret_key()}
|> Enum.reject(fn {_, v} -> is_nil(v) end)
|> Enum.into(%{})
|> Jason.encode!()
|> to_charlist()
end

defp ssl_opts do
[
depth: 99,
verify: :verify_peer,
cacerts: :certifi.cacerts(),
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
]
]
end
end
Loading

0 comments on commit 7177b0b

Please sign in to comment.