Phone number confirmation with Twilio
Hey guys, I wanted to replace the current
AshAuthentication
confirmation by email add-on with one that will use Twilio Verify API so I can verify the user's phone number.
I just finished it, so I was wondering if you guys could give your suggestions about the implementation and if you see any apparent problems with it:
So, the first step is the Twilio Verify API implementation, I used `Finch`` for that:3 Replies
defmodule Marketplace.Accounts.User.Helpers.Twilio do
@moduledoc """
Contains helper functions to access Twilio's Verify API
"""
@base_header [
{"accept", "application/json"},
{"content-type", "application/x-www-form-urlencoded"}
]
@spec send_verification!(String.t(), String.t()) :: :ok | {:error, :unknown_error}
def send_verification!(phone_number, channel \\ "sms") do
url = send_url()
headers = create_headers()
payload = URI.encode_query(%{"To" => phone_number, "Channel" => channel})
{:ok, %{body: body}} =
Finch.build(:post, url, headers, payload) |> Finch.request(Marketplace.Finch)
case Jason.decode!(body) do
%{"status" => "pending"} -> :ok
_ -> {:error, :unknown_error}
end
end
@spec verify_code!(String.t(), String.t()) :: :ok | {:error, :incorrect_code | :unknown_error}
def verify_code!(code, phone_number) do
url = verify_url()
headers = create_headers()
payload = URI.encode_query(%{"To" => phone_number, "Code" => code})
{:ok, %{body: body}} =
Finch.build(:post, url, headers, payload) |> Finch.request(Marketplace.Finch)
case Jason.decode!(body) do
%{"status" => "approved"} -> :ok
%{"status" => "pending"} -> {:error, :incorrect_code}
%{"status" => _} -> {:error, :unknown_error}
end
end
defp create_headers do
username = "..."
password = "..."
basic_auth = "Basic #{Base.encode64(username <> ":" <> password)}"
@base_header ++ [{"Authorization", basic_auth}]
end
defp send_url, do: "#{base_url()}/Verifications"
defp verify_url, do: "#{base_url()}/VerificationCheck"
defp base_url do
service_name = "..."
"https://verify.twilio.com/v2/Services/#{service_name}"
end
end
defmodule Marketplace.Accounts.User.Helpers.Twilio do
@moduledoc """
Contains helper functions to access Twilio's Verify API
"""
@base_header [
{"accept", "application/json"},
{"content-type", "application/x-www-form-urlencoded"}
]
@spec send_verification!(String.t(), String.t()) :: :ok | {:error, :unknown_error}
def send_verification!(phone_number, channel \\ "sms") do
url = send_url()
headers = create_headers()
payload = URI.encode_query(%{"To" => phone_number, "Channel" => channel})
{:ok, %{body: body}} =
Finch.build(:post, url, headers, payload) |> Finch.request(Marketplace.Finch)
case Jason.decode!(body) do
%{"status" => "pending"} -> :ok
_ -> {:error, :unknown_error}
end
end
@spec verify_code!(String.t(), String.t()) :: :ok | {:error, :incorrect_code | :unknown_error}
def verify_code!(code, phone_number) do
url = verify_url()
headers = create_headers()
payload = URI.encode_query(%{"To" => phone_number, "Code" => code})
{:ok, %{body: body}} =
Finch.build(:post, url, headers, payload) |> Finch.request(Marketplace.Finch)
case Jason.decode!(body) do
%{"status" => "approved"} -> :ok
%{"status" => "pending"} -> {:error, :incorrect_code}
%{"status" => _} -> {:error, :unknown_error}
end
end
defp create_headers do
username = "..."
password = "..."
basic_auth = "Basic #{Base.encode64(username <> ":" <> password)}"
@base_header ++ [{"Authorization", basic_auth}]
end
defp send_url, do: "#{base_url()}/Verifications"
defp verify_url, do: "#{base_url()}/VerificationCheck"
defp base_url do
service_name = "..."
"https://verify.twilio.com/v2/Services/#{service_name}"
end
end
User
resource, I first added the confirmed_at
attribute:
defmodule Marketplace.Accounts.User do
...
attributes do
...
attribute :confirmed_at, :utc_datetime_usec
end
end
defmodule Marketplace.Accounts.User do
...
attributes do
...
attribute :confirmed_at, :utc_datetime_usec
end
end
defmodule Marketplace.Accounts.User do
...
actions do
...
update :send_phone_number_confirmation do
accept []
change fn changeset, _ ->
Ash.Changeset.before_action(changeset, fn changeset ->
phone_number = Ash.Changeset.get_data(changeset, :phone_number)
case Helpers.Twilio.send_verification!(phone_number) do
:ok -> changeset
{:error, error} -> Ash.Changeset.add_error(changeset, error)
end
end)
end
end
end
end
defmodule Marketplace.Accounts.User do
...
actions do
...
update :send_phone_number_confirmation do
accept []
change fn changeset, _ ->
Ash.Changeset.before_action(changeset, fn changeset ->
phone_number = Ash.Changeset.get_data(changeset, :phone_number)
case Helpers.Twilio.send_verification!(phone_number) do
:ok -> changeset
{:error, error} -> Ash.Changeset.add_error(changeset, error)
end
end)
end
end
end
end
defmodule Marketplace.Accounts.User do
...
actions do
...
update :confirm_phone_number do
accept []
argument :code, :string, allow_nil?: false
change fn changeset, _ ->
Ash.Changeset.before_action(changeset, fn changeset ->
phone_number = Ash.Changeset.get_data(changeset, :phone_number)
code = Ash.Changeset.get_argument(changeset, :code)
case Helpers.Twilio.verify_code!(code, phone_number) do
:ok ->
changeset
{:error, :incorrect_code} ->
error = InvalidAttribute.exception(message: "Code is incorrect")
Ash.Changeset.add_error(changeset, error)
{:error, :unknown_error} ->
error = InvalidAttribute.exception(message: "Unknown error, try again later")
Ash.Changeset.add_error(changeset, error)
end
end)
end
change set_attribute(:confirmed_at, DateTime.utc_now())
end
end
end
defmodule Marketplace.Accounts.User do
...
actions do
...
update :confirm_phone_number do
accept []
argument :code, :string, allow_nil?: false
change fn changeset, _ ->
Ash.Changeset.before_action(changeset, fn changeset ->
phone_number = Ash.Changeset.get_data(changeset, :phone_number)
code = Ash.Changeset.get_argument(changeset, :code)
case Helpers.Twilio.verify_code!(code, phone_number) do
:ok ->
changeset
{:error, :incorrect_code} ->
error = InvalidAttribute.exception(message: "Code is incorrect")
Ash.Changeset.add_error(changeset, error)
{:error, :unknown_error} ->
error = InvalidAttribute.exception(message: "Unknown error, try again later")
Ash.Changeset.add_error(changeset, error)
end
end)
end
change set_attribute(:confirmed_at, DateTime.utc_now())
end
end
end
defmodule Marketplace.Accounts.User.GraphQL do
...
object :user_output do
field :user, :user
field :errors, list_of(:mutation_error)
end
input_object :confirm_phone_number_input do
field :code, non_null(:string)
end
enum :send_phone_number_confirmation_output,
values: [:ok, :forbidden, :unknown_error]
object :accounts_user_queries do
...
field :send_phone_number_confirmation, type: :send_phone_number_confirmation_output do
resolve fn _, _, %{context: %{actor: actor}} ->
with {:ok, _} <- User.send_phone_number_confirmation(actor, actor: actor) do
{:ok, :ok}
else
{:error, %Ash.Error.Forbidden{}} -> {:ok, :forbidden}
{:error, %{errors: [:unknown_error]}} -> {:ok, :unknown_error}
end
end
end
end
object :accounts_user_mutations do
field :confirm_phone_number, type: :user_output do
arg :input, non_null(:confirm_phone_number_input)
resolve fn _, %{input: args}, %{context: %{actor: actor}} ->
try do
with {:ok, user} <- User.confirm_phone_number(actor, args, actor: actor) do
{:ok, %{user: user}}
else
{:error, %{errors: errors}} ->
errors = Enum.map(errors, &AshGraphql.Error.to_error/1)
{:ok, %{errors: errors}}
end
rescue
error ->
error = AshGraphql.Error.to_error(error)
{:ok, %{errors: [error]}}
end
end
end
end
end
defmodule Marketplace.Accounts.User.GraphQL do
...
object :user_output do
field :user, :user
field :errors, list_of(:mutation_error)
end
input_object :confirm_phone_number_input do
field :code, non_null(:string)
end
enum :send_phone_number_confirmation_output,
values: [:ok, :forbidden, :unknown_error]
object :accounts_user_queries do
...
field :send_phone_number_confirmation, type: :send_phone_number_confirmation_output do
resolve fn _, _, %{context: %{actor: actor}} ->
with {:ok, _} <- User.send_phone_number_confirmation(actor, actor: actor) do
{:ok, :ok}
else
{:error, %Ash.Error.Forbidden{}} -> {:ok, :forbidden}
{:error, %{errors: [:unknown_error]}} -> {:ok, :unknown_error}
end
end
end
end
object :accounts_user_mutations do
field :confirm_phone_number, type: :user_output do
arg :input, non_null(:confirm_phone_number_input)
resolve fn _, %{input: args}, %{context: %{actor: actor}} ->
try do
with {:ok, user} <- User.confirm_phone_number(actor, args, actor: actor) do
{:ok, %{user: user}}
else
{:error, %{errors: errors}} ->
errors = Enum.map(errors, &AshGraphql.Error.to_error/1)
{:ok, %{errors: errors}}
end
rescue
error ->
error = AshGraphql.Error.to_error(error)
{:ok, %{errors: [error]}}
end
end
end
end
end
Sorry, didn't see this before for some reason. Don't really have time to comb through it all, but it seems like a reasonable implementation 🙂
Thanks Zach! Cool! It should also work as a reference if someone else wants to do something similar 🙂