lib/charon/auth_challenge/totp_challenge.ex

defmodule Charon.AuthChallenge.TotpChallenge do
  @moduledoc """
  TOTP-challenge.
  The otp codes may be generated by the user's device,
  or can be sent in advance by SMS/email.

  ## Config

  Additional config is required for this module under `optional.charon_totp_challenge`:

      Charon.Config.from_enum(
        ...,
        optional_modules: %{
          charon_totp_challenge: %{
            ...
          }
        }
      )

  The following configuration options are supported:
    - `:totp_label` (required). Ends up as a TOTP label in apps like Google Authenticator, for example "Gmail".
    - `:totp_issuer` (required). Similar to `:totp_label`, for example "Google".
    - `:totp_seed_field` (optional, default `:totp_seed`). The binary field of the user struct that is used to store the underlying secret of the TOTP challenges.
    - `:param` (optional, default: "otp"). The name of the param that contains an OTP code.
    - `:period` (optional, default 30). The duration in seconds in which a single OTP code is valid.
  """
  @challenge_name "totp"
  use Charon.AuthChallenge

  if Code.ensure_loaded?(NimbleTOTP) do
    alias Charon.Internal
    @optional_config_field :charon_totp_challenge
    @defaults %{
      totp_seed_field: :totp_seed,
      param: "otp",
      period: 30
    }
    @required [:totp_label, :totp_issuer]

    @impl true
    def challenge_complete(conn, params, user, config) do
      with :ok <- AuthChallenge.verify_enabled(user, @challenge_name, config) do
        %{totp_seed_field: field, param: param, period: period} = process_config(config)
        seed = Map.fetch!(user, field)
        now = Internal.now()

        with <<otp::binary>> <- Map.get(params, param, {:error, "#{param} not found"}),
             true <-
               NimbleTOTP.valid?(seed, otp, time: now, period: period) or
                 NimbleTOTP.valid?(seed, otp, time: now - period, period: period) do
          {:ok, conn, nil}
        else
          false -> {:error, "#{param} invalid"}
          error -> error
        end
      end
    end

    @impl true
    def setup_init(conn, params, user, config) do
      with :ok <- AuthChallenge.check_current_password(user, params, config) do
        %{totp_label: label, totp_issuer: issuer} = process_config(config)
        seed = :crypto.strong_rand_bytes(32)
        secret = Base.encode32(seed, padding: false, case: :upper)
        uri = NimbleTOTP.otpauth_uri(label, seed, issuer: issuer)
        token = AuthChallenge.gen_setup_token(@challenge_name, config, %{"secret" => secret})
        {:ok, conn, %{config.auth_challenge_setup_token_param => token, secret: secret, uri: uri}}
      end
    end

    @impl true
    def setup_complete(conn, params, user, config) do
      %{totp_seed_field: field} = process_config(config)

      with {:ok, payload} <- AuthChallenge.validate_setup_token(@challenge_name, params, config),
           %{"secret" => secret} = payload,
           seed = Base.decode32!(secret, padding: false, case: :upper),
           user_overrides = %{
             field => seed,
             config.enabled_auth_challenges_field => [@challenge_name]
           },
           {:ok, _, _} <-
             challenge_complete(conn, params, Map.merge(user, user_overrides), config),
           enabled = AuthChallenge.put_enabled(user, @challenge_name, config),
           params = %{field => seed, config.enabled_auth_challenges_field => enabled},
           {:ok, _} <- AuthChallenge.update_user(user, params, config) do
        {:ok, conn, nil}
      end
    end

    @doc false
    def generate_code(user, config) do
      %{totp_seed_field: field, period: period} = process_config(config)
      seed = Map.fetch!(user, field)
      now = Internal.now()
      NimbleTOTP.verification_code(seed, time: now, period: period)
    end

    ###########
    # Private #
    ###########

    defp process_config(config) do
      Internal.process_optional_config(config, @optional_config_field, @defaults, @required)
    end
  else
    @impl true
    def challenge_init(_conn, _params, _user, _config), do: throw_error()
    @impl true
    def challenge_complete(_conn, _params, _user, _config), do: throw_error()
    @impl true
    def setup_init(_conn, _params, _user, _config), do: throw_error()
    @impl true
    def setup_complete(_conn, _params, _user, _config), do: throw_error()
    def generate_code(_user, _config), do: throw_error()

    defp throw_error(), do: raise("optional dependency NimbleTOTP not found")
  end
end