3.3 KiB · text History 4b6fe3b
defmodule OhMyExOhIrcd.IRC.Auth do
  @moduledoc """
  Bridges IRC authentication into the Phoenix `phx.gen.auth` Accounts
  context.

  Two paths are supported:

    * **SASL PLAIN** (IRCv3) — the modern flow. Client sends
      `AUTHENTICATE PLAIN` then a base64 blob `\\0<email>\\0<password>`.
    * **PASS** (classic IRC) — sent before NICK/USER. We accept the
      forms `email:password` and `email password`. This is a fallback
      for clients that don't speak SASL.

  Both look the account up by email and check its bcrypt hash.
  """

  require Logger

  alias OhMyExOhIrcd.Accounts
  alias OhMyExOhIrcd.Accounts.User

  @doc """
  Decode a SASL PLAIN payload and verify it against Accounts.
  Returns `{:ok, %User{}}`, `{:error, :malformed}`, or
  `{:error, {reason, email}}` with `reason` one of `:no_such_account`,
  `:no_password` (the account only signs in by magic link) or
  `:wrong_password`.
  """
  def verify_sasl_plain(payload) when is_binary(payload) do
    with {:ok, decoded} <- Base.decode64(payload),
         [_authzid, authcid, password] <- :binary.split(decoded, <<0>>, [:global]) do
      verify(authcid, password)
    else
      _ -> {:error, :malformed}
    end
  end

  @doc """
  Parse a PASS argument and verify it. Supports `email:password` and
  `email password` forms.
  """
  def verify_pass(arg) when is_binary(arg) do
    case parse_pass(arg) do
      {:ok, email, password} -> verify(email, password)
      :error -> {:error, :malformed}
    end
  end

  defp parse_pass(arg) do
    cond do
      String.contains?(arg, ":") ->
        [email, password] = String.split(arg, ":", parts: 2)
        {:ok, email, password}

      String.contains?(arg, " ") ->
        [email, password] = String.split(arg, " ", parts: 2)
        {:ok, email, password}

      true ->
        :error
    end
  end

  # Which of the three failures it was, for the log: the client only ever
  # hears "authentication failed". The misses still run a dummy hash, so
  # response time doesn't tell an account that exists from one that doesn't.
  defp verify(email, password) do
    case Accounts.get_user_by_email(email) do
      %User{hashed_password: hash} = user when is_binary(hash) ->
        if User.valid_password?(user, password),
          do: {:ok, user},
          else: {:error, {:wrong_password, email}}

      %User{} ->
        User.valid_password?(nil, password)
        {:error, {:no_password, email}}

      nil ->
        User.valid_password?(nil, password)
        {:error, {:no_such_account, email}}
    end
  end

  @doc """
  Log a failed SASL or PASS attempt: the mechanism, the account with its
  local part masked, the client's host and why. Never the password.
  """
  def log_failure(mechanism, error, host) do
    {reason, who} =
      case error do
        {reason, email} when is_binary(email) -> {reason, mask(email)}
        reason -> {reason, "-"}
      end

    Logger.warning("#{mechanism} authentication failed for #{who} from #{host || "?"}: #{reason}")
  end

  # "gmp@gmp.io" -> "g**@gmp.io": enough to tell which account was tried
  # without writing addresses into the log.
  defp mask(email) do
    case String.split(email, "@", parts: 2) do
      [<<first::utf8, rest::binary>>, domain] ->
        <<first::utf8>> <> String.duplicate("*", String.length(rest)) <> "@" <> domain

      _ ->
        "(not an email)"
    end
  end
end