lib/ex_otp.ex

defmodule ExOtp do
  @moduledoc """
  ExOtp allows you to create One-Time-Passwords, which can be either Time-based One-Time Passwords(TOTP) and Counter based OTPs(via HMAC-based One Time Passwords(HOTP)).

  The library is primarily based on the [PyOTP](https://github.com/pyauth/pyotp), and almost exactly mirrors their API as well. It follows the MFA standards defined in [RFC4226](https://datatracker.ietf.org/doc/html/rfc4226)(HOTP: an Hmac-Based One-Time Password Algorithm) and [RFC6238](https://datatracker.ietf.org/doc/html/rfc6238)(TOTP: Time-Based One-Time Password Algorithm).

  The library provides the following features:

  - Generate TOTP or Counter based HOTP
  - Generate QR codes for OTPs
  - Generate random secret values(if required)

  ## Usage

  ### TOTP

  - Create a TOTP object using either a random secret or  a user-provided secret as follows:

  Example:

      secret = ExOtp.random_secret()
      #=> "uapgaiacdaptafbu"
      totp = ExOtp.create_totp(secret, 30) # Specify interval for which the OTP will be valid.
      #=> %ExOtp.Totp{
      #  base: %ExOtp.Base{
      #    digest: :sha,
      #    digits: 6,
      #    secret: "OVQXAZ3BNFQWGZDBOB2GCZTCOU======"
      #  },
      #  interval: 30
      #}

  - Generate an otp using the `totp` object, for a given datetime value:

  Example:

      otp = ExOtp.generate_totp(totp, DateTime.utc_now())
      #=> "967372"

  - Finally, you can check if the otp is valid using the `otp` and `totp` objects:

  Example:

      ExOtp.valid_totp?(totp, otp, DateTime.utc_now())
      #=> true

  ### HOTP

  - Create a HOTP object using either a random secret or  a user-provided secret as follows:

  Example:

      secret = ExOtp.random_secret()
      #=> "uapgaiacdaptafbu"
      hotp = ExOtp.create_hotp(secret, 30) # Specify initial count
      #=> %ExOtp.Hotp{
      #  base: %ExOtp.Base{
      #    digest: :sha,
      #    digits: 6,
      #    secret: "OVQXAZ3BNFQWGZDBOB2GCZTCOU======"
      #  },
      #  initial_count: 0
      #}

  - Generate an otp using the `hotp` object, for a given datetime value:

  Example:

      otp = ExOtp.generate_hotp(hotp, DateTime.utc_now())
      #=> "268374"

  - Finally, you can check if the otp is valid using the `otp` and `hotp` objects:

  Example:

      ExOtp.valid_hotp?(hotp, otp, 0) # Specify counter value
      #=> true

  ### QR Code(Optional)

  - Generate the provision URI for use with a QR code scanner built into MFA apps such as Google Authenticator.
  - Generate the QR code using the `EQRCode` libraby, which is an optional dependency.
  - Given a `totp` object and an `otp`, you can generate the QR code using:

  Example:

      totp
      |> ExOtp.provision_uri_totp(otp, "test:shubham@google.com",issuer_name: "test")
      |> ExOtp.generate_qr_code("code.svg")
      #=> 09:51:17.102 [info]  QR code written to file: code.svg

  """
  alias ExOtp.{Errors, Hotp, Totp}

  require Logger

  @spec random_secret(integer()) :: no_return() | String.t()
  def random_secret(length \\ 16) do
    if length < 16 do
      raise Errors.InvalidParam, "secret length should be atleast 16 characters"
    end

    for _ <- 1..length, into: "", do: <<Enum.random('abcdefghijklmnopqrstuvwxyz')>>
  end

  @spec create_totp(String.t(), integer()) :: Totp.t()
  def create_totp(secret, interval \\ 30) do
    Totp.new(interval, secret) |> Totp.validate()
  end

  @spec create_hotp(String.t(), integer()) :: Hotp.t()
  def create_hotp(secret, initial_count \\ 0) do
    Hotp.new(initial_count, secret) |> Hotp.validate()
  end

  @spec generate_totp(Totp.t(), DateTime.t(), integer) :: String.t()
  def generate_totp(%Totp{} = totp, for_time, counter \\ 0) do
    Totp.at(totp, for_time, counter)
  end

  @spec generate_hotp(Hotp.t(), integer) :: String.t()
  def generate_hotp(%Hotp{} = hotp, count) do
    Hotp.at(hotp, count)
  end

  @spec valid_totp?(Totp.t(), String.t(), DateTime.t(), integer) :: boolean
  def valid_totp?(%Totp{} = totp, otp, for_time \\ DateTime.utc_now(), valid_window \\ 0) do
    Totp.valid?(totp, otp, for_time, valid_window)
  end

  @spec valid_hotp?(ExOtp.Hotp.t(), String.t(), integer) :: boolean
  def valid_hotp?(%Hotp{} = hotp, otp, counter) do
    Hotp.valid?(hotp, otp, counter)
  end

  @spec provision_uri_totp(Totp.t(), String.t(), keyword) :: String.t()
  def provision_uri_totp(totp, label, opts \\ []) do
    Totp.provision_uri(totp, label, opts)
  end

  @spec provision_uri_hotp(Hotp.t(), String.t(), keyword) :: String.t()
  def provision_uri_hotp(hotp, label, opts \\ []) do
    Hotp.provision_uri(hotp, label, opts)
  end

  def generate_qr_code(input, filename \\ "code.svg") do
    unless Code.ensure_compiled(EQRCode) do
      raise Errors.MissingDependency,
            "Please install the optional depndency EQRCode to generate the QR code"
    end

    input |> EQRCode.encode() |> EQRCode.svg() |> (&File.write!(filename, &1)).()
    Logger.info("QR code written to file: #{filename}")
  end
end