diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/recaptcha.ex | 86 | ||||
-rw-r--r-- | lib/recaptcha/http.ex | 37 | ||||
-rw-r--r-- | lib/recaptcha/http/mock_http_client.ex | 18 | ||||
-rw-r--r-- | lib/recaptcha/response.ex | 8 | ||||
-rw-r--r-- | lib/recaptcha/template.ex | 25 | ||||
-rw-r--r-- | lib/template.html.eex | 8 |
6 files changed, 124 insertions, 58 deletions
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 @@ <div class="g-recaptcha" data-sitekey="<%= @public_key %>" - data-theme="<%= @options[:theme]%>" - data-type="<%= @options[:type]%>" - data-tabindex="<%= @options[:tabindex]%>" - data-size="<%= @options[:size]%>"> + data-theme="<%= @options[:theme] %>" + data-type="<%= @options[:type] %>" + data-tabindex="<%= @options[:tabindex] %>" + data-size="<%= @options[:size] %>"> </div> <%= if @options[:noscript] do %> |