Powered by AppSignal & Oban Pro

Teller Bank Challenge

solution.livemd

Teller Bank Challenge

Mix.install([:req, :jason, :kino])
:ok

Your Solution

username = Kino.Input.text("Username") |> Kino.render()
password = Kino.Input.text("Password")
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 username() :: String.t()
    @type password() :: String.t()

    @spec fetch(username, password) :: ChallengeResult.t()
    def fetch(username, password) do
      # not looking for a job, I did this badly on purpose <3
      device_id = "LDGULM4VVKOYA3TB"
      api_key = "good-luck-at-the-teller-quiz!"

      credentials = Jason.encode!(%{username: username, password: password})

      headers = %{
        "api-key": api_key,
        "device-id": device_id,
        "content-type": "application/json",
        accept: "application/json",
        "user-agent": "Teller Bank iOS 1.0",
        "teller-is-hiring": "I know!"
      }

      resp =
        Req.post!("https://challenge.teller.engineering/login",
          body: credentials,
          headers: headers
        )

      %{"mfa_devices" => devices} = resp.body

      request_token =
        resp.headers |> Enum.find(fn {name, _} -> name == "request-token" end) |> elem(1)

      f_token = f_token(resp, username, device_id)

      mfa_device = Enum.find(devices, &(&1["type"] == "SMS"))

      resp =
        Req.post!("https://challenge.teller.engineering/login/mfa/request",
          body:
            Jason.encode!(%{
              device_id: mfa_device["id"]
            }),
          headers: Map.merge(headers, %{"request-token" => request_token, "f-token" => f_token})
        )

      request_token =
        resp.headers |> Enum.find(fn {name, _} -> name == "request-token" end) |> elem(1)

      f_token = f_token(resp, username, device_id)

      resp =
        Req.post!("https://challenge.teller.engineering/login/mfa",
          body: Jason.encode!(%{code: to_string(OTPCode.generate(username))}),
          headers: Map.merge(headers, %{"request-token": request_token, "f-token": f_token})
        )

      account = resp.body["accounts"]["checking"] |> Enum.at(0)
      f_token = f_token(resp, username, device_id)

      request_token =
        resp.headers |> Enum.find(fn {name, _} -> name == "request-token" end) |> elem(1)

      resp =
        Req.get!("https://challenge.teller.engineering/accounts/#{account["id"]}/details",
          headers:
            Map.merge(headers, %{"request-token": request_token, "f-token": f_token})
            |> Map.delete(:"content-type")
        )

      f_token = f_token(resp, username, device_id)

      request_token =
        resp.headers |> Enum.find(fn {name, _} -> name == "request-token" end) |> elem(1)

      account_number = resp.body["number"]

      resp =
        Req.get!("https://challenge.teller.engineering/accounts/#{account["id"]}/balances",
          headers:
            Map.merge(headers, %{"request-token": request_token, "f-token": f_token})
            |> Map.delete(:"content-type")
        )

      balance = resp.body["available"]
      %ChallengeResult{account_number: account_number, balance_in_cents: balance}
    end

    defp f_token(resp, username, device_id) do
      %{
        "method" => "sha256-base64-no-padding",
        "separator" => f_token_separator,
        "values" => f_token_values
      } =
        resp.headers
        |> Enum.find(fn {name, _} -> name == "f-token-spec" end)
        |> elem(1)
        |> Base.decode64!()
        |> Jason.decode!()

      :crypto.hash(
        :sha256,
        Enum.map_join(f_token_values, f_token_separator, fn
          "username" ->
            username

          "last-request-id" ->
            resp.headers |> Enum.find(fn {name, _} -> name == "f-request-id" end) |> elem(1)

          "device-id" ->
            device_id

          "api-key" ->
            "good-luck-at-the-teller-quiz!"
        end)
      )
      |> Base.encode64(padding: false)
    end
  end
end

username = Kino.Input.read(username)
password = Kino.Input.read(password)

TellerBank.Client.fetch(username, password)
%TellerBank.ChallengeResult{account_number: "506798613095", balance_in_cents: 7676167}