defmodule Tesla.Middleware.DigestAuth do @moduledoc """ Digest access authentication middleware. [Wiki on the topic](https://en.wikipedia.org/wiki/Digest_access_authentication) **NOTE**: Currently the implementation is incomplete and works only for MD5 algorithm and auth "quality of protection" (qop). ## Examples ``` defmodule MyClient do def client(username, password, opts \\ %{}) do Tesla.client([ {Tesla.Middleware.DigestAuth, Map.merge(%{username: username, password: password}, opts)} ]) end end ``` ## Options - `:username` - username (defaults to `""`) - `:password` - password (defaults to `""`) - `:cnonce_fn` - custom function generating client nonce (defaults to `&Tesla.Middleware.DigestAuth.cnonce/0`) - `:nc` - nonce counter (defaults to `"00000000"`) """ @behaviour Tesla.Middleware @impl Tesla.Middleware def call(env, next, opts) do if env.opts && Keyword.get(env.opts, :digest_auth_handshake) do Tesla.run(env, next) else opts = opts || %{} with {:ok, headers} <- authorization_header(env, opts) do env |> Tesla.put_headers(headers) |> Tesla.run(next) end end end defp authorization_header(env, opts) do with {:ok, vars} <- authorization_vars(env, opts) do {:ok, vars |> calculated_authorization_values |> create_header} end end defp authorization_vars(env, opts) do with {:ok, unauthorized_response} <- env.__module__.request( env.__client__, method: env.opts[:pre_auth_method] || env.method, url: env.url, opts: Keyword.put(env.opts || [], :digest_auth_handshake, true) ) do {:ok, %{ username: opts[:username] || "", password: opts[:password] || "", path: URI.parse(env.url).path, auth: Tesla.get_header(unauthorized_response, "www-authenticate") |> parse_www_authenticate_header, method: env.method |> to_string |> String.upcase(), client_nonce: (opts[:cnonce_fn] || (&cnonce/0)).(), nc: opts[:nc] || "00000000" }} end end defp calculated_authorization_values(%{auth: auth}) when auth == %{}, do: [] defp calculated_authorization_values(auth_vars) do [ {"username", auth_vars.username}, {"realm", auth_vars.auth["realm"]}, {"uri", auth_vars[:path]}, {"nonce", auth_vars.auth["nonce"]}, {"nc", auth_vars.nc}, {"cnonce", auth_vars.client_nonce}, {"response", response(auth_vars)}, # hard-coded, will not work for MD5-sess {"algorithm", "MD5"}, # hard-coded, will not work for auth-int or unspecified {"qop", "auth"} ] end defp single_header_val({k, v}) when k in ~w(nc qop algorithm), do: "#{k}=#{v}" defp single_header_val({k, v}), do: "#{k}=\"#{v}\"" defp create_header([]), do: [] defp create_header(calculated_authorization_values) do vals = calculated_authorization_values |> Enum.reduce([], fn val, acc -> [single_header_val(val) | acc] end) |> Enum.join(", ") [{"authorization", "Digest #{vals}"}] end defp ha1(%{username: username, auth: %{"realm" => realm}, password: password}) do md5("#{username}:#{realm}:#{password}") end defp ha2(%{method: method, path: path}) do md5("#{method}:#{path}") end defp response(%{auth: %{"nonce" => nonce}, nc: nc, client_nonce: client_nonce} = auth_vars) do md5("#{ha1(auth_vars)}:#{nonce}:#{nc}:#{client_nonce}:auth:#{ha2(auth_vars)}") end defp parse_www_authenticate_header(nil), do: %{} defp parse_www_authenticate_header(header) do Regex.scan(~r/(\w+?)="(.+?)"/, header) |> Enum.reduce(%{}, fn [_, key, val], acc -> Map.merge(acc, %{key => val}) end) end defp md5(data), do: Base.encode16(:erlang.md5(data), case: :lower) defp cnonce, do: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower) end