defmodule OneTimePassEcto do
  @moduledoc """
  Module to handle one-time passwords, usually for use in two factor
  authentication.

  ## One-time password options

  There are the following options for the one-time passwords:

    * HMAC-based one-time passwords
      * `:token_length` - the length of the one-time password
        * the default is 6
      * `:last` - the count when the one-time password was last used
        * this count needs to be stored server-side
      * `:window` - the number of future attempts allowed
        * the default is 3
    * Time-based one-time passwords
      * `:token_length` - the length of the one-time password
        * the default is 6
      * `:interval_length` - the length of each timed interval
        * the default is 30 (seconds)
      * `:window` - the number of attempts, before and after the current one, allowed
        * the default is 1 (1 interval before and 1 interval after)
    * Both HOTP and TOTP
      * `:otp_secret` - name of the Ecto field holding the secret (default :otp_secret)
      * `:otp_last` - name of the Ecto field holding the last value (default :otp_last)

  See the documentation for the OneTimePassEcto.Base module for more details
  about generating and verifying one-time passwords.

  ## Implementation details

  The following notes provide details about how this module implements
  the verification of one-time passwords.

  It is important not to allow the one-time password to be reused within
  the timeframe that it is valid.

  For TOTPs, one method of preventing reuse is to compare the output of
  check_totp (the `last` value) with the previous output. The output
  should be greater than the previous `last` value.

  In the case of HOTPs, it is important that the database is locked
  from the time the `last` value is checked until the `last` value is
  updated.
  """

  import Ecto.{Changeset, Query}
  alias OneTimePassEcto.Base

  @doc """
  Check the one-time password, and return {:ok, user} if the one-time
  password is correct or {:error, message} if there is an error.

  After this function has been called, you need to either add the user
  to the session, by running `put_session(conn, :user_id, id)`, or send
  an API token to the user.

  See the `One-time password options` in this module's documentation
  for available options to be used as the second argument to this
  function.
  """
  def verify(params, repo, user_schema, opts \\ [])

  def verify(%{"id" => id, "hotp" => hotp}, repo, user_schema, opts) do
    {:ok, result} =
      repo.transaction(fn ->
        get_user_with_lock(repo, user_schema, id)
        |> check_hotp(hotp, opts)
        |> update_otp(repo, opts)
      end)

    result
  end

  def verify(%{"id" => id, "totp" => totp}, repo, user_schema, opts) do
    repo.get(user_schema, id)
    |> check_totp(totp, opts)
    |> update_otp(repo, opts)
  end

  defp check_hotp(user, hotp, opts) do
    otp_secret = Map.get(user, opts[:otp_secret] || :otp_secret)
    otp_last = Map.get(user, opts[:otp_last] || :otp_last)
    
    {user, Base.check_hotp(hotp, otp_secret, [last: otp_last] ++ opts)}
  end

  defp check_totp(user, totp, opts) do
    otp_secret = Map.get(user, opts[:otp_secret] || :otp_secret)
    
    {user, Base.check_totp(totp, otp_secret, opts)}
  end

  defp get_user_with_lock(repo, user_schema, id) do
    from(u in user_schema, where: u.id == ^id, lock: "FOR UPDATE")
    |> repo.one!
  end

  defp update_otp({_, false}, _, _), do: {:error, "invalid one-time password"}

  defp update_otp({user, last}, repo, opts) do
    otp_last_name = opts[:otp_last] || :otp_last
    otp_last = Map.get(user, otp_last_name)
    
    if last > otp_last do
      change(user, %{otp_last_name => last}) |> repo.update
    else
      {:error, "invalid user-identifier"}
    end
  end

end
