From 3b5120b087182cb96a1a2082a5fb149276e45304 Mon Sep 17 00:00:00 2001 From: Sam Seay Date: Thu, 1 Sep 2016 09:43:23 +1200 Subject: Feature/v2 rewrite (#7) Rewrite of the API. * Change the copyright holder since its a rewrite. * Bump dependencies to their latest versions. * Revision of the README to document the new API * Break config up into keyword list (no longer a map) * A rewrite of the recaptcha verify API and move of templating into Recaptcha.Template * Add tests and credo for code style * Remove exception raising method calls (`HTTPoison.post!` and `Poison.decode!`) * Change Elixir version to 1.2 for `with` support. --- LICENSE | 2 +- README.md | 50 +++++++++++--------- config/config.exs | 12 +++-- config/dev.exs | 1 + config/test.exs | 6 +++ lib/recaptcha.ex | 86 +++++++++++++--------------------- lib/recaptcha/http.ex | 37 +++++++++++++++ lib/recaptcha/http/mock_http_client.ex | 18 +++++++ lib/recaptcha/response.ex | 8 ++++ lib/recaptcha/template.ex | 25 ++++++++++ lib/template.html.eex | 8 ++-- mix.exs | 16 ++++--- mix.lock | 14 ++++-- test/recaptcha/template_test.exs | 25 ++++++++++ test/recaptcha_test.exs | 49 +++++++++++++++++++ test/test_helper.exs | 1 + 16 files changed, 261 insertions(+), 97 deletions(-) create mode 100644 config/dev.exs create mode 100644 config/test.exs create mode 100644 lib/recaptcha/http.ex create mode 100644 lib/recaptcha/http/mock_http_client.ex create mode 100644 lib/recaptcha/response.ex create mode 100644 lib/recaptcha/template.ex create mode 100644 test/recaptcha/template_test.exs create mode 100644 test/recaptcha_test.exs create mode 100644 test/test_helper.exs diff --git a/LICENSE b/LICENSE index a492b9b..f19e5ef 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2015 Mikhail Alekseev +Copyright (c) 2016 Samuel Seay MIT License diff --git a/README.md b/README.md index 68392b5..eeb6bc7 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,19 @@ # Recaptcha -A simple Elixir package for implementing [reCAPTCHA] in [Phoenix] applications. +A simple Elixir package for implementing [reCAPTCHA] in [Elixir] applications. [reCAPTCHA]: http://www.google.com/recaptcha -[Phoenix]: http://www.phoenixframework.org/ + +## Migration from 1.x + +There are several breaking changes in recaptcha version 2. + +The 2 most obvious are that templating is now in a separate module: `Recaptcha.Template` the `display/1` API however, has not changed. In future templating may be moved to a Phoenix specific package. + +The `verify` API has changed now to only accept a recaptcha response string and a set of options. (see the verify documentation for supported options). The `remote_ip` that was once passed as the first argument is now an optional argument. + +Most other questions about 2.x should be answered by looking over the documentation and the code. Please raise an issue +if you have any problems with migrating. ## Installation @@ -12,7 +22,7 @@ A simple Elixir package for implementing [reCAPTCHA] in [Phoenix] applications. ```elixir defp deps do [ - {:recaptcha, "~> 1.1.0"}, + {:recaptcha, "~> 2.0"}, ] end ``` @@ -21,7 +31,7 @@ A simple Elixir package for implementing [reCAPTCHA] in [Phoenix] applications. ```elixir def application do - [ applications: [:phoenix, :recaptcha] ] + [ applications: [:recaptcha] ] end ``` @@ -29,20 +39,19 @@ A simple Elixir package for implementing [reCAPTCHA] in [Phoenix] applications. ## Config -Set default public and private keys in your application's config.exs +By default the public and private keys are loaded via the `RECAPTCHA_PUBLIC_KEY` and `RECAPTCHA_PRIVATE_KEY` environment variables. Part of the reason for doing this is to encourage best practice (specifically not committing your reCAPTCHA secret key to your code base). You can of course override them in your own config any way that you like. ```elixir config :recaptcha, - api_config: %{ verify_url: "https://www.google.com/recaptcha/api/siteverify", - public_key: "YOUR_PUBLIC_KEY", - private_key: "YOUR_PRIVATE_KEY" } + public_key: System.get_env("RECAPTCHA_PUBLIC_KEY"), + secret: System.get_env("RECAPTCHA_PRIVATE_KEY") ``` ## Usage -### View +### Render the Widget -Use `raw` and `Recaptcha.display` methods to render the captcha +Use `raw` (if you're using Phoenix.HTML) and `Recaptcha.Template.display/1` methods to render the captcha widget. ```html
@@ -60,36 +69,33 @@ Option | Action `public_key` | Sets key to the `data-sitekey` reCaptcha div attribute | Public key from the config file +### Verify API -### Controller - -Recaptcha provides `verify` method, that can be used like this: +Recaptcha provides the `verify/2` method, that can be used like this: ```elixir def create(conn, params) do # some code - case Recaptcha.verify(conn.remote_ip, params["g-recaptcha-response"]) do - :ok -> do_something - :error -> handle_error + case Recaptcha.verify(params["g-recaptcha-response"]) do + {:ok, response} -> do_something + {:error, errors} -> handle_error end end ``` `verify` method sends a `POST` request to the reCAPTCHA API and returns 2 possible values: -`:ok` -> The captcha is valid - -`:error` -> Server returned `missing-input-response` or `invalid-input-response` error codes - -If the server returns `missing-input-secret` or `invalid-input-secret`, `RuntimeError` is raised +`{:ok, %Recaptcha.Response{challenge_ts: timestamp, hostname: host}}` -> The captcha is valid, see the [documentation](https://developers.google.com/recaptcha/docs/verify#api-response) for more details. +`{:error, errors}` -> `errors` contains atomised versions of the errors returned by the API, See the [error documentation](https://developers.google.com/recaptcha/docs/verify#error-code-reference) for more details. Errors caused by timeouts in HTTPoison or Poison encoding are also returned as atoms. `verify` method also accepts a keyword list as the third parameter with the following options: Option | Action | Default :---------------------- | :----------------------------------------------------- | :------------------------ `timeout` | Time to wait before timeout | 5000 (ms) -`private_key` | Private key to send as a parameter of the API request | Private key from the config file +`secret` | Private key to send as a parameter of the API request | Private key from the config file +`remote_ip` | Optional. The user's IP address, used by reCaptcha | no default ## Contributing diff --git a/config/config.exs b/config/config.exs index a87116e..3c34501 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,7 +1,9 @@ use Mix.Config -config :recaptcha, :api_config, - %{ verify_url: "https://www.google.com/recaptcha/api/siteverify", - public_key: "YOUR_PUBLIC_KEY", - private_key: "YOUR_PRIVATE_KEY" - } +config :recaptcha, + verify_url: "https://www.google.com/recaptcha/api/siteverify", + timeout: 5000, + public_key: System.get_env("RECAPTCHA_PUBLIC_KEY"), + secret: System.get_env("RECAPTCHA_PRIVATE_KEY") + +import_config "#{Mix.env}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..d2d855e --- /dev/null +++ b/config/dev.exs @@ -0,0 +1 @@ +use Mix.Config diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..18aed6e --- /dev/null +++ b/config/test.exs @@ -0,0 +1,6 @@ +use Mix.Config + +config :recaptcha, + http_client: Recaptcha.Http.MockClient, + secret: "test_secret", + public_key: "test_public_key" diff --git a/lib/recaptcha.ex b/lib/recaptcha.ex index e0656c3..0037480 100644 --- a/lib/recaptcha.ex +++ b/lib/recaptcha.ex @@ -1,75 +1,53 @@ defmodule Recaptcha do - require Elixir.EEx + @moduledoc """ + A module for verifying reCAPTCHA version 2.0 response strings. - @secret_key_errors ~w(missing-input-secret invalid-input-secret) - - EEx.function_from_file :defp, :render_template, "lib/template.html.eex", [:assigns] - - @doc """ - Returns a string with reCAPTCHA code - - To convert the string to html code, use Phoenix.HTML.Raw/1 method + See the [documentation](https://developers.google.com/recaptcha/docs/verify) for more details. """ - def display(options \\ []) do - public_key = options[:public_key] || config.public_key - render_template(public_key: public_key, options: options) - end + @secret Application.get_env(:recaptcha, :secret) + @http_client Application.get_env(:recaptcha, :http_client, Recaptcha.Http) @doc """ - Verifies reCAPTCHA response string. + Verifies a reCAPTCHA response string. - The function returns :ok or :error, depending on the verification's result + ## Options - :ok is returned when the response code was successfully verified + * `:timeout` - the timeout for the request (defaults to 5000ms) + * `:secret` - the secret key used by recaptcha (defaults to the secret provided in application config) + * `:remote_ip` - the IP address of the user (optional and not set by default) - :error is returned when the response is nil or if the reCAPTCHA server returned - a missing-input-response or invalid-input-response code + ## Example - The function raises RuntimeError if the server returned a missing-input-secret or invalid-input-secret - code + {:ok, api_response} = Recaptcha.verify("response_string") """ - def verify(remote_ip, response, options \\ []) + @spec verify(String.t, [timeout: integer, secret: String.t, remote_ip: String.t]) :: {:ok, Recaptcha.Response.t} | {:error, [atom]} + def verify(response, options \\ []) - def verify(remote_ip, response, options) when is_tuple(remote_ip) do - verify(:inet_parse.ntoa(remote_ip), response, options) + def verify(nil = _response, _) do + {:error, [:invalid_input_response]} end - def verify(_remote_ip, nil = _response, _options), do: :error - - def verify(remote_ip, response, options) do - case api_response(remote_ip, response, options) do - %{"success" => true} -> - :ok - %{"success" => false, "error-codes" => error_codes} -> - handle_error_codes(error_codes) - %{"success" => false} -> - :error + def verify(response, options) do + case @http_client.request_verification(request_body(response, options), Keyword.take(options, [:timeout])) do + {:error, errors} -> {:error, errors} + {:ok, %{"success" => false, "error-codes" => errors}} -> {:error, Enum.map(errors, fn(error) -> atomise_api_error(error) end) } + {:ok, %{"success" => true, "challenge_ts" => timestamp, "hostname" => host}} -> {:ok, %Recaptcha.Response{challenge_ts: timestamp, hostname: host}} end end - defp api_response(remote_ip, response, options) do - private_key = options[:private_key] || config.private_key - timeout = options[:timeout] || 5000 - body_content = URI.encode_query(%{"remoteip" => to_string(remote_ip), - "response" => response, - "secret" => private_key}) - headers = [{"Content-type", "application/x-www-form-urlencoded"}, {"Accept", "application/json"}] + defp request_body(response, options) do + body_options = Keyword.take(options, [:remote_ip, :secret]) + application_options = [secret: @secret] - HTTPoison.post!(config.verify_url, body_content, headers, timeout: timeout).body - |> Poison.decode! + # override application secret with options secret if it exists + application_options + |> Keyword.merge(body_options) + |> Keyword.put(:response, response) + |> URI.encode_query end - defp config do - Application.get_env(:recaptcha, :api_config) + defp atomise_api_error(error) do + String.replace(error, "-", "_") + |> String.to_atom end - - defp handle_error_codes(error_codes) do - if Enum.any?(error_codes, fn(code) -> Enum.member?(@secret_key_errors, code) end) do - raise RuntimeError, - message: "reCaptcha API has declined the private key. Please make sure you've set the correct private key" - else - :error - end - end - end diff --git a/lib/recaptcha/http.ex b/lib/recaptcha/http.ex new file mode 100644 index 0000000..01c088a --- /dev/null +++ b/lib/recaptcha/http.ex @@ -0,0 +1,37 @@ +defmodule Recaptcha.Http do + @moduledoc """ + Responsible for managing HTTP requests to the reCAPTCHA API + """ + @headers [{"Content-type", "application/x-www-form-urlencoded"}, {"Accept", "application/json"}] + @url Application.get_env(:recaptcha, :verify_url) + @timeout Application.get_env(:recaptcha, :timeout, 5000) + + @doc """ + Sends an HTTP request to the reCAPTCHA version 2.0 API. + + See the [documentation](https://developers.google.com/recaptcha/docs/verify#api-response) for more details on the API response. + + ## Options + + * `:timeout` - the timeout for the request (defaults to 5000ms) + + ## Example + + {:ok, %{ "success" => success, "challenge_ts" => ts, "hostname" => host, "error-codes" => errors}} = Recaptcha.Http.request_verification(%{ secret: "secret", response: "response", remote_ip: "remote_ip"}) + """ + @spec request_verification(map, [timeout: integer]) :: {:ok, map} | {:error, [atom]} + def request_verification(body, options \\ []) do + result = + with {:ok, response} <- HTTPoison.post(@url, body, @headers, timeout: options[:timeout] || @timeout), + {:ok, data} <- Poison.decode(response.body) do + {:ok, data} + end + + case result do + {:ok, data} -> {:ok, data} + {:error, :invalid} -> {:error, [:invalid_api_response]} + {:error, {:invalid, _reason}} -> {:error, [:invalid_api_response]} + {:error, %{reason: reason}} -> {:error, [reason]} + end + end +end diff --git a/lib/recaptcha/http/mock_http_client.ex b/lib/recaptcha/http/mock_http_client.ex new file mode 100644 index 0000000..2355dd3 --- /dev/null +++ b/lib/recaptcha/http/mock_http_client.ex @@ -0,0 +1,18 @@ +defmodule Recaptcha.Http.MockClient do + @moduledoc """ + A mock HTTP client used for testing. + """ + + def request_verification(body, options \\ []) + + def request_verification("response=valid_response&secret=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" = body, options) do + send self(), {:request_verification, body, options} + {:ok, %{"success" => true, "challenge_ts" => "timestamp", "hostname" => "localhost"}} + end + + # every other match is a pass through to the real client + def request_verification(body, options) do + send self(), {:request_verification, body, options} + Recaptcha.Http.request_verification(body, options) + end +end diff --git a/lib/recaptcha/response.ex b/lib/recaptcha/response.ex new file mode 100644 index 0000000..f8adb18 --- /dev/null +++ b/lib/recaptcha/response.ex @@ -0,0 +1,8 @@ +defmodule Recaptcha.Response do + @moduledoc """ + A struct representing the successful recaptcha response from the reCAPTCHA API. + """ + defstruct challenge_ts: "", hostname: "" + + @type t :: %__MODULE__{challenge_ts: String.t, hostname: String.t} +end diff --git a/lib/recaptcha/template.ex b/lib/recaptcha/template.ex new file mode 100644 index 0000000..d644266 --- /dev/null +++ b/lib/recaptcha/template.ex @@ -0,0 +1,25 @@ +defmodule Recaptcha.Template do + @moduledoc """ + Responsible for rendering boilerplate recaptcha HTML code, supports noscript fallback. + + Currently the [explicit render](https://developers.google.com/recaptcha/docs/display#explicit_render) functionality + is not supported. + + In future this module may be separated out into a Phoenix specific library. + """ + require Elixir.EEx + + EEx.function_from_file :defp, :render_template, "lib/template.html.eex", [:assigns] + + @public_key Application.get_env(:recaptcha, :public_key) + + @doc """ + Returns a string with reCAPTCHA code + + To convert the string to html code, use Phoenix.HTML.Raw/1 method + """ + def display(options \\ []) do + public_key = options[:public_key] || @public_key + render_template(public_key: public_key, options: options) + end +end diff --git a/lib/template.html.eex b/lib/template.html.eex index c9c5385..5f9109c 100644 --- a/lib/template.html.eex +++ b/lib/template.html.eex @@ -2,10 +2,10 @@
+ data-theme="<%= @options[:theme] %>" + data-type="<%= @options[:type] %>" + data-tabindex="<%= @options[:tabindex] %>" + data-size="<%= @options[:size] %>">
<%= if @options[:noscript] do %> diff --git a/mix.exs b/mix.exs index bf4dc31..46c88c1 100644 --- a/mix.exs +++ b/mix.exs @@ -3,8 +3,8 @@ defmodule Recaptcha.Mixfile do def project do [app: :recaptcha, - version: "1.1.1", - elixir: "~> 1.0", + version: "2.0.0", + elixir: "~> 1.2", description: description, deps: deps, package: package] @@ -16,21 +16,23 @@ defmodule Recaptcha.Mixfile do defp description do """ - A simple reCaptcha package for Phoenix applications. + A simple reCaptcha package for Elixir applications, provides verification + and templates for rendering forms with the reCaptcha widget """ end defp deps do [ - {:httpoison, "~> 0.7"}, - {:poison, "~> 1.5"} + {:httpoison, "~> 0.9.0"}, + {:poison, "~> 2.0"}, + {:credo, "~> 0.4", only: [:dev, :test]} ] end defp package do [files: ["lib", "mix.exs", "README.md", "LICENSE"], - maintainers: ["Alekseev Mikhail"], + maintainers: ["Samuel Seay"], licenses: ["MIT"], - links: %{"GitHub" => "https://github.com/JustMikey/recaptcha"}] + links: %{"GitHub" => "https://github.com/samueljseay/recaptcha"}] end end diff --git a/mix.lock b/mix.lock index eac4b1b..668749b 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,13 @@ -%{"hackney": {:hex, :hackney, "1.3.2"}, - "httpoison": {:hex, :httpoison, "0.7.4"}, +%{"bunt": {:hex, :bunt, "0.1.6", "5d95a6882f73f3b9969fdfd1953798046664e6f77ec4e486e6fafc7caad97c6f", [:mix], []}, + "certifi": {:hex, :certifi, "0.4.0", "a7966efb868b179023618d29a407548f70c52466bf1849b9e8ebd0e34b7ea11f", [:rebar3], []}, + "credo": {:hex, :credo, "0.4.11", "03a64e9d53309b7132556284dda0be57ba1013885725124cfea7748d740c6170", [:mix], [{:bunt, "~> 0.1.6", [hex: :bunt, optional: false]}]}, + "hackney": {:hex, :hackney, "1.6.1", "ddd22d42db2b50e6a155439c8811b8f6df61a4395de10509714ad2751c6da817", [:rebar3], [{:certifi, "0.4.0", [hex: :certifi, optional: false]}, {:idna, "1.2.0", [hex: :idna, optional: false]}, {:metrics, "1.0.1", [hex: :metrics, optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, optional: false]}, {:ssl_verify_fun, "1.1.0", [hex: :ssl_verify_fun, optional: false]}]}, + "httpoison": {:hex, :httpoison, "0.9.1", "6c2b4eaf2588a6f3ef29663d28c992531ca3f0bc832a97e0359bc822978e1c5d", [:mix], [{:hackney, "~> 1.6.0", [hex: :hackney, optional: false]}]}, "httpotion": {:hex, :httpotion, "2.1.0"}, "ibrowse": {:git, "https://github.com/cmullaparthi/ibrowse.git", "ea3305d21f37eced4fac290f64b068e56df7de80", [tag: "v4.1.2"]}, - "idna": {:hex, :idna, "1.0.2"}, - "poison": {:hex, :poison, "1.5.0"}, + "idna": {:hex, :idna, "1.2.0", "ac62ee99da068f43c50dc69acf700e03a62a348360126260e87f2b54eced86b2", [:rebar3], []}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []}, + "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], []}, + "poison": {:hex, :poison, "2.2.0", "4763b69a8a77bd77d26f477d196428b741261a761257ff1cf92753a0d4d24a63", [:mix], []}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.0", "edee20847c42e379bf91261db474ffbe373f8acb56e9079acb6038d4e0bf414f", [:rebar, :make], []}, "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.5"}} diff --git a/test/recaptcha/template_test.exs b/test/recaptcha/template_test.exs new file mode 100644 index 0000000..335eb5f --- /dev/null +++ b/test/recaptcha/template_test.exs @@ -0,0 +1,25 @@ +defmodule RecaptchaTemplateTest do + use ExUnit.Case, async: true + + test "supplying options to display/1 renders them in the g-recaptcha div" do + template_string = Recaptcha.Template.display(theme: "dark", type: "audio", tabindex: 1, size: "compact") + + assert template_string =~ "data-theme=\"dark\"" + assert template_string =~ "data-type=\"audio\"" + assert template_string =~ "data-tabindex=\"1\"" + assert template_string =~ "data-size=\"compact\"" + end + + test "supplying a public key in options to display/1 overrides it in the g-recaptcha-div" do + template_string = Recaptcha.Template.display(public_key: "override_test_public_key") + + assert template_string =~ "data-sitekey=\"override_test_public_key\"" + end + + test "supplying noscript option displays the noscript fallback" do + template_string = Recaptcha.Template.display(noscript: true) + + assert template_string =~ "