defmodule Apache.Htpasswd do @sha "{SHA}" @atoz 'abcdefghijklmnopqrstuvwxyz' @moduledoc """ Provides basic htpasswd(1) functions as a library. The hashing methods available are :md5 (the default), :sha, :crypt, and :plaintext. # Examples iex> Apache.Htpasswd.check "user:pass", "test/htfile" false iex> Apache.Htpasswd.check "plaintext:plaintext", "test/htfile" true iex> Apache.Htpasswd.encode "user", "pass" "user:$apr1$O5f9TZT.$IBzxX8byvgfsLYp/dkIzC/" iex> Apache.Htpasswd.encode "user", "pass", :sha "user:{SHA}nU4eI71bcnBGqeO0t9tXvY1u5oQ=" iex> Apache.Htpasswd.check "user:pass", Apache.Htpasswd.encode("user", "pass") true iex> Apache.Htpasswd.add("user", "pass", "/tmp/htpasswd") {:ok, "user:$apr1$on6He14N$3AeecTsC32uTadbYK1Ij4/"} iex> Apache.Htpasswd.rm("user", "/tmp/htpasswd") :ok """ def check(slug, htfile_or_string) do [user, plaintext] = String.split slug, ":", parts: 2 case File.exists?(htfile_or_string) do true -> check_against_file(user, plaintext, htfile_or_string) false -> check_against_string(user, plaintext, htfile_or_string) end end def encode(user, plaintext, method \\ :md5) do Enum.join [user, hash(method, plaintext)], ":" end def add(user, plaintext, file, method \\ :md5) do if (File.exists?(file)), do: rm(user, file) string = encode(user, plaintext, method) case File.open(file, [:append]) do {:ok, device} -> IO.puts(device, string) File.close(device) {:ok, string} {:error, reason} -> {:error, reason} end end def rm(user, file) do File.write!(file, filtered_file_contents(user, file)) end def filtered_file_contents(user, file) do File.stream!(file) |> Enum.filter &(! String.starts_with?(&1, user <> ":")) end def check_against_file(user, plaintext, htfile) do case password_from_file(user, htfile) do nil -> false encrypted -> validate_password(encrypted, plaintext) end end def check_against_string(user, plaintext, string) do case String.starts_with?(string, user) do false -> false true -> case password_from_string(string) do nil -> false encrypted -> validate_password(encrypted, plaintext) end end end defp validate_password(encrypted, plaintext) do Enum.any?([:plaintext, :crypt, :sha, :md5], &(match &1, plaintext, encrypted)) end defp password_from_file(user, htfile) do case Enum.find File.stream!(htfile), &(String.starts_with?(&1, user <> ":")) do nil -> nil row -> password_from_string(row) end end defp password_from_string(string) do case Regex.match?(~r/:/, string) do true -> String.strip(string) |> String.split(":", parts: 2) |> List.last false -> nil end end defp match(:plaintext, plaintext, encrypted), do: plaintext == encrypted defp match(:crypt, plaintext, encrypted) do <> = encrypted :crypt.crypt(plaintext, salt) == encrypted end defp match(:md5, plaintext, encrypted) do {:ok, _, _, _, str} = Apache.PasswdMD5.crypt(plaintext, encrypted) str == encrypted end defp match(:sha, plaintext, encrypted) do encrypted == @sha <> Base.encode64(:crypto.hash :sha, plaintext) end defp hash(:md5, plaintext) do {:ok, _, _, _, str} = Apache.PasswdMD5.crypt(plaintext) str end defp hash(:sha, plaintext) do @sha <> Base.encode64(:crypto.hash :sha, plaintext) end defp hash(:plaintext, plaintext), do: plaintext defp hash(:crypt, plaintext), do: :crypt.crypt(plaintext, salt(2)) defp hash(method, _) do raise(RuntimeError, message: "invalid encoding method :#{ method }") end defp salt(length, seed \\ :os.timestamp) do :random.seed(seed) Enum.shuffle(@atoz) |> Enum.take(length) |> List.to_string end end