AE
Ash Elixir•3y ago
Blibs

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
Blibs
BlibsOP•3y ago
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
Now, in my 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
Then I created the action to send the confirmation code to the user phone number:
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
Then I created the action to verify the received code:
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
Finally, just for completeness, I added these GraphQL queries/mutations:
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
So, did I miss something important or is this a reasonable implementation of the feature?
ZachDaniel
ZachDaniel•3y ago
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 🙂
Blibs
BlibsOP•3y ago
Thanks Zach! Cool! It should also work as a reference if someone else wants to do something similar 🙂

Did you find this page helpful?