Skip to content

Instantly share code, notes, and snippets.

@superhawk610
Created September 2, 2022 00:12
Show Gist options
  • Select an option

  • Save superhawk610/c79dcad020fc9e4f5c5bfa62ef4dd166 to your computer and use it in GitHub Desktop.

Select an option

Save superhawk610/c79dcad020fc9e4f5c5bfa62ef4dd166 to your computer and use it in GitHub Desktop.

Revisions

  1. superhawk610 created this gist Sep 2, 2022.
    308 changes: 308 additions & 0 deletions gistfile1.txt
    Original 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)
    ```