Created
September 2, 2022 00:12
-
-
Save superhawk610/c79dcad020fc9e4f5c5bfa62ef4dd166 to your computer and use it in GitHub Desktop.
Revisions
-
superhawk610 created this gist
Sep 2, 2022 .There are no files selected for viewing
This file contains hidden or 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 charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,308 @@ <!-- 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) ```