lib/web/plug/basic_authentication.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

use Croma

defmodule Antikythera.Plug.BasicAuthentication do
  @moduledoc """
  Plug to restrict access to controller action by basic authentication.

  ## Usage

  ### Static username/password: using gear config

  The following line installs a plug that checks username/password in incoming requests against
  `"BASIC_AUTHENTICATION_ID"` and `"BASIC_AUTHENTICATION_PW"` in your gear's gear config.

      plug Antikythera.Plug.BasicAuthentication, :check_with_config, []

  ### Dynamic username/password: using module-function pair.

  In the following case you can check the username/password pair with arbitrary logic by the specified function.

      plug Antikythera.Plug.BasicAuthentication, :check_with_fun, [mod: YourGear.AuthModule, fun: :function_name]

  The given function (`YourGear.AuthModule.function_name/3` in this case) must have the following type signature:

  - receives (1) a `Antikythera.Conn.t`, (2) username, and (3) password
  - returns (1) `{:ok, Antikythera.Conn.t}` if successfully authenticated, (2) `:error` otherwise
  """

  alias Antikythera.{Conn, Crypto}
  alias AntikytheraCore.Ets.ConfigCache
  alias AntikytheraCore.Config.Gear, as: GearConfig

  defun check_with_config(conn :: v[Conn.t()], _opts :: any) :: Conn.t() do
    %GearConfig{kv: %{"BASIC_AUTHENTICATION_ID" => id, "BASIC_AUTHENTICATION_PW" => pw}} =
      ConfigCache.Gear.read(conn.context.gear_name)

    check_impl(conn, fn input_id, input_pw ->
      if Crypto.secure_compare(input_id, id) and Crypto.secure_compare(input_pw, pw) do
        {:ok, conn}
      else
        :error
      end
    end)
  end

  defun check_with_fun(conn :: v[Conn.t()], opts :: [{:mod | :fun, atom}]) :: Conn.t() do
    m = Keyword.fetch!(opts, :mod)
    f = Keyword.fetch!(opts, :fun)
    check_impl(conn, fn id, pw -> apply(m, f, [conn, id, pw]) end)
  end

  defp check_impl(conn1, f) do
    with {id, pw} <- Conn.get_req_header(conn1, "authorization") |> decode_auth_header(),
         {:ok, conn2} <- f.(id, pw) do
      conn2
    else
      _ -> halt_with_401(conn1)
    end
  end

  defp decode_auth_header("Basic " <> encoded_cred) do
    case Base.decode64(encoded_cred) do
      {:ok, name_and_pass} ->
        case :binary.split(name_and_pass, ":") do
          [n, p] -> {n, p}
          _ -> nil
        end

      :error ->
        nil
    end
  end

  defp decode_auth_header(_), do: nil

  defp halt_with_401(conn) do
    gear_name = conn.context.gear_name

    Conn.put_resp_header(conn, "www-authenticate", "Basic realm=\"#{gear_name}\"")
    |> Conn.put_status(401)
    |> Conn.put_resp_body("Access denied.")
  end
end