Created
September 2, 2022 00:12
-
-
Save superhawk610/c79dcad020fc9e4f5c5bfa62ef4dd166 to your computer and use it in GitHub Desktop.
teller-challenge.livemd
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!-- livebook:{"autosave_interval_s":null} --> | |
# Teller Bank Challenge | |
```elixir | |
Mix.install([:req, :jason, :kino]) | |
``` | |
## Your Solution | |
```elixir | |
username = Kino.Input.text("Username") |> Kino.render() | |
password = Kino.Input.text("Password") | |
``` | |
```elixir | |
defmodule TellerBank do | |
defmodule OTPCode do | |
@moduledoc """ | |
You can use this util module to generate your OTP | |
code dynamically. | |
""" | |
@type username() :: String.t() | |
@spec generate(username) :: String.t() | |
def generate(username) do | |
username | |
|> String.to_charlist() | |
|> Enum.take(6) | |
|> Enum.map(&char_to_keypad_number/1) | |
|> List.to_string() | |
|> String.pad_leading(6, "0") | |
end | |
defp char_to_keypad_number(c) when c in ~c(a b c), do: '2' | |
defp char_to_keypad_number(c) when c in ~c(d e f), do: '3' | |
defp char_to_keypad_number(c) when c in ~c(g h i), do: '4' | |
defp char_to_keypad_number(c) when c in ~c(j k l), do: '5' | |
defp char_to_keypad_number(c) when c in ~c(m n o), do: '6' | |
defp char_to_keypad_number(c) when c in ~c(p q r s), do: '7' | |
defp char_to_keypad_number(c) when c in ~c(t u v), do: '8' | |
defp char_to_keypad_number(c) when c in ~c(w x y z), do: '9' | |
defp char_to_keypad_number(_), do: '0' | |
end | |
defmodule ChallengeResult do | |
@type t :: %__MODULE__{ | |
account_number: String.t(), | |
balance_in_cents: integer | |
} | |
defstruct [:account_number, :balance_in_cents] | |
end | |
defmodule Client do | |
@type t :: %__MODULE__{ | |
username: String.t() | nil, | |
last_request: {id :: String.t(), spec :: map()} | nil, | |
request_token: String.t() | nil, | |
teller_is_hiring: boolean() | |
} | |
defstruct username: nil, | |
last_request: nil, | |
request_token: nil, | |
teller_is_hiring: false | |
@type username() :: String.t() | |
@type password() :: String.t() | |
# set to `true` to log outgoing requests/responses | |
@debug false | |
@base_url "https://challenge.teller.engineering/" | |
@user_agent "Teller Bank iOS 1.0" | |
@device_identifier "7YTQNN7YPJZ72VOE" | |
@api_key "good-luck-at-the-teller-quiz!" | |
@base_headers [ | |
user_agent: @user_agent, | |
device_id: @device_identifier, | |
api_key: @api_key, | |
accept: "application/json" | |
] | |
@doc """ | |
Login and fetch the account number and available balance for the given | |
username and password. | |
""" | |
@spec fetch(username, password) :: ChallengeResult.t() | |
def fetch(username, password) do | |
# in a real application, this would be started under the app's supervision tree | |
Agent.start_link(fn -> %__MODULE__{} end, name: __MODULE__) | |
with {:ok, device_id} <- login(username, password), | |
:ok <- request_mfa(device_id), | |
otp_code <- OTPCode.generate(username), | |
{:ok, account_id} <- submit_mfa(otp_code), | |
# make sure to fetch account details _before_ balances, otherwise | |
# the API server will return a 400 | |
{:ok, %{number: account_number}} <- get_account_details(account_id), | |
{:ok, %{available: balance}} <- get_account_balances(account_id) do | |
%ChallengeResult{account_number: account_number, balance_in_cents: balance} | |
end | |
after | |
# make sure cell re-evaluation gets a fresh agent each time | |
Agent.stop(__MODULE__) | |
end | |
defp req_base do | |
state = Agent.get(__MODULE__, & &1) | |
headers = @base_headers | |
headers = | |
if state.teller_is_hiring do | |
Keyword.put(headers, :teller_is_hiring, "I know!") | |
else | |
headers | |
end | |
headers = | |
if token = state.request_token do | |
Keyword.put(headers, :request_token, token) | |
else | |
headers | |
end | |
headers = | |
case state.last_request do | |
{request_id, spec} -> | |
f_token = calculate_f_token(request_id, state.username, spec) | |
Keyword.put(headers, :f_token, f_token) | |
_ -> | |
headers | |
end | |
Req.new(base_url: @base_url, headers: headers) | |
|> Req.Request.append_request_steps(debug_request: &debug_request/1) | |
|> Req.Request.append_response_steps(update_state: &update_state/1) | |
end | |
defp calculate_f_token(request_id, username, %{ | |
"method" => "sha256-base64-no-padding", | |
"separator" => separator, | |
"values" => values | |
}) do | |
state = %{ | |
"device-id" => @device_identifier, | |
"api-key" => @api_key, | |
"username" => username, | |
"last-request-id" => request_id | |
} | |
input = values |> Enum.map(&Map.fetch!(state, &1)) |> Enum.join(separator) | |
sha256 = :crypto.hash(:sha256, input) | |
Base.encode64(sha256, padding: false) | |
end | |
defp calculate_f_token(_, _, %{} = spec) do | |
raise "unsupported f-token method #{spec["method"]}" | |
end | |
defp get(path) do | |
Req.request(req_base(), method: :get, url: path) | |
end | |
defp post(path, %{} = body) do | |
Req.request( | |
req_base(), | |
method: :post, | |
url: path, | |
json: body, | |
headers: [content_type: "application/json"] | |
) | |
end | |
defp debug_request(%Req.Request{} = req) do | |
if @debug do | |
IO.puts("----------") | |
method = req.method |> to_string() |> String.upcase() | |
IO.puts("#{method} #{req.url} HTTP/1.1") | |
for {header_key, header_val} <- req.headers do | |
IO.puts("#{header_key}: #{header_val}") | |
end | |
IO.puts(req.body) | |
IO.puts("----------") | |
end | |
req | |
end | |
defp update_state({req, %Req.Response{} = resp}) do | |
if @debug do | |
IO.puts("#{resp.status} #{Jason.encode!(resp.body, pretty: true)}") | |
end | |
Agent.update(__MODULE__, fn state -> | |
state = | |
case Req.Response.get_header(resp, "teller-is-hiring") do | |
[_] -> Map.put(state, :teller_is_hiring, true) | |
_ -> Map.put(state, :teller_is_hiring, false) | |
end | |
state = | |
case {Req.Response.get_header(resp, "f-token-spec"), | |
Req.Response.get_header(resp, "f-request-id")} do | |
{[spec], [request_id]} when not is_nil(spec) and not is_nil(request_id) -> | |
spec = spec |> Base.decode64!() |> Jason.decode!() | |
Map.put(state, :last_request, {request_id, spec}) | |
_ -> | |
state | |
end | |
state = | |
case Req.Response.get_header(resp, "request-token") do | |
[token] -> Map.put(state, :request_token, token) | |
_ -> state | |
end | |
state | |
end) | |
{req, resp} | |
end | |
defp login(username, password) do | |
with {:ok, resp} <- post("login", %{username: username, password: password}), | |
%{"mfa_required" => true, "mfa_devices" => devices} <- resp.body, | |
%{"id" => device_id} <- Enum.find(devices, &(&1["type"] == "SMS")) do | |
Agent.update(__MODULE__, &Map.put(&1, :username, username)) | |
{:ok, device_id} | |
else | |
{:error, reason} -> | |
{:error, reason} | |
%{"mfa_required" => false} -> | |
{:error, "only devices with MFA enabled are supported"} | |
nil -> | |
{:error, "no devices available"} | |
end | |
end | |
defp request_mfa(device_id) do | |
case post("login/mfa/request", %{device_id: device_id}) do | |
{:ok, %{status: 200}} -> | |
:ok | |
{:ok, %{status: status, body: body}} -> | |
{:error, "failed to request MFA: (#{status}) #{inspect(body)}"} | |
{:error, reason} -> | |
{:error, "failed to request MFA: #{inspect(reason)}"} | |
end | |
end | |
defp submit_mfa(otp_code) do | |
with {:ok, resp} <- post("login/mfa", %{code: otp_code}), | |
%{"accounts" => %{"checking" => [%{"id" => account_id}]}} <- resp.body do | |
{:ok, account_id} | |
else | |
{:error, reason} -> | |
{:error, reason} | |
invalid_resp -> | |
{:error, "only devices with a single account are supported: #{inspect(invalid_resp)}"} | |
end | |
end | |
defp get_account_details(account_id) do | |
with {:ok, resp} <- get("accounts/#{account_id}/details"), | |
%{"ach" => ach, "name" => name, "number" => number, "product" => product} <- resp.body do | |
{:ok, %{ach: ach, name: name, number: number, product: product}} | |
else | |
{:error, reason} -> | |
{:error, reason} | |
invalid_resp -> | |
{:error, | |
"malformed response when requesting account balances: #{inspect(invalid_resp)}"} | |
end | |
end | |
defp get_account_balances(account_id) do | |
with {:ok, resp} <- get("accounts/#{account_id}/balances"), | |
%{"available" => available, "ledger" => ledger} <- resp.body do | |
{:ok, %{available: available, ledger: ledger}} | |
else | |
{:error, reason} -> | |
{:error, reason} | |
invalid_resp -> | |
{:error, | |
"malformed response when requesting account balances: #{inspect(invalid_resp)}"} | |
end | |
end | |
end | |
end | |
username = Kino.Input.read(username) | |
password = Kino.Input.read(password) | |
TellerBank.Client.fetch(username, password) | |
``` |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment