IRC auth: log why a SASL or PASS login failed

ad82ccd · Gabriel Morell · 2026-10-27 16:44

9 files +129 -8
Message
{commit_body(@commit)}

Files changed

modified lib/oh_my_ex_oh_ircd/irc/auth.ex
+49 −5
@@ -11,15 +11,20 @@ defmodule OhMyExOhIrcd.IRC.Auth do
11 11 forms `email:password` and `email password`. This is a fallback
12 12 for clients that don't speak SASL.
13 13
14 − Both end up calling `Accounts.get_user_by_email_and_password/2`.
14 + Both look the account up by email and check its bcrypt hash.
15 15 """
16 16
17 + require Logger
18 +
17 19 alias OhMyExOhIrcd.Accounts
18 20 alias OhMyExOhIrcd.Accounts.User
19 21
20 22 @doc """
21 23 Decode a SASL PLAIN payload and verify it against Accounts.
22 − Returns `{:ok, %User{}}` or `{:error, reason}`.
24 + Returns `{:ok, %User{}}`, `{:error, :malformed}`, or
25 + `{:error, {reason, email}}` with `reason` one of `:no_such_account`,
26 + `:no_password` (the account only signs in by magic link) or
27 + `:wrong_password`.
23 28 """
24 29 def verify_sasl_plain(payload) when is_binary(payload) do
25 30 with {:ok, decoded} <- Base.decode64(payload),
@@ -56,10 +61,49 @@ defmodule OhMyExOhIrcd.IRC.Auth do
56 61 end
57 62 end
58 63
64 + # Which of the three failures it was, for the log: the client only ever
65 + # hears "authentication failed". The misses still run a dummy hash, so
66 + # response time doesn't tell an account that exists from one that doesn't.
59 67 defp verify(email, password) do
60 − case Accounts.get_user_by_email_and_password(email, password) do
61 − %User{} = user -> {:ok, user}
62 − nil -> {:error, :invalid_credentials}
68 + case Accounts.get_user_by_email(email) do
69 + %User{hashed_password: hash} = user when is_binary(hash) ->
70 + if User.valid_password?(user, password),
71 + do: {:ok, user},
72 + else: {:error, {:wrong_password, email}}
73 +
74 + %User{} ->
75 + User.valid_password?(nil, password)
76 + {:error, {:no_password, email}}
77 +
78 + nil ->
79 + User.valid_password?(nil, password)
80 + {:error, {:no_such_account, email}}
81 + end
82 + end
83 +
84 + @doc """
85 + Log a failed SASL or PASS attempt: the mechanism, the account with its
86 + local part masked, the client's host and why. Never the password.
87 + """
88 + def log_failure(mechanism, error, host) do
89 + {reason, who} =
90 + case error do
91 + {reason, email} when is_binary(email) -> {reason, mask(email)}
92 + reason -> {reason, "-"}
93 + end
94 +
95 + Logger.warning("#{mechanism} authentication failed for #{who} from #{host || "?"}: #{reason}")
96 + end
97 +
98 + # "gmp@gmp.io" -> "g**@gmp.io": enough to tell which account was tried
99 + # without writing addresses into the log.
100 + defp mask(email) do
101 + case String.split(email, "@", parts: 2) do
102 + [<<first::utf8, rest::binary>>, domain] ->
103 + <<first::utf8>> <> String.duplicate("*", String.length(rest)) <> "@" <> domain
104 +
105 + _ ->
106 + "(not an email)"
63 107 end
64 108 end
65 109 end
modified lib/oh_my_ex_oh_ircd/irc/connection.ex
+20 −3
@@ -622,13 +622,29 @@ defmodule OhMyExOhIrcd.IRC.Connection do
622 622 send_numeric(socket, state, "903", [state.nick || "*", "SASL authentication successful"])
623 623 %{state | account: user, sasl: nil}
624 624
625 − {:error, _} ->
625 + {:error, error} ->
626 + Auth.log_failure("SASL PLAIN", error, state.real_host || state.host)
626 627 send_numeric(socket, state, "904", [state.nick || "*", "SASL authentication failed"])
627 628 %{state | sasl: nil}
628 629 end
629 630 end
630 631
631 − defp dispatch(%Message{command: "AUTHENTICATE"}, socket, state) do
632 + # An unsupported mechanism (AUTHENTICATE EXTERNAL, SCRAM-…), or a payload
633 + # with no AUTHENTICATE PLAIN before it. Only a mechanism-shaped first
634 + # parameter is logged; anything else could be a credential.
635 + defp dispatch(%Message{command: "AUTHENTICATE", params: params}, socket, state) do
636 + reason =
637 + case params do
638 + [mech | _] when byte_size(mech) <= 20 ->
639 + if mech =~ ~r/^[A-Z0-9_-]+$/,
640 + do: "unsupported mechanism #{mech}",
641 + else: "payload without AUTHENTICATE PLAIN"
642 +
643 + _ ->
644 + "payload without AUTHENTICATE PLAIN"
645 + end
646 +
647 + Auth.log_failure("SASL", reason, state.real_host || state.host)
632 648 send_numeric(socket, state, "904", [state.nick || "*", "SASL authentication failed"])
633 649 %{state | sasl: nil}
634 650 end
@@ -638,7 +654,8 @@ defmodule OhMyExOhIrcd.IRC.Connection do
638 654 {:ok, user} ->
639 655 %{state | account: user}
640 656
641 − {:error, _} ->
657 + {:error, error} ->
658 + Auth.log_failure("PASS", error, state.real_host || state.host)
642 659 send_numeric(socket, state, "464", [state.nick || "*", "Password incorrect"])
643 660 state
644 661 end
added test/oh_my_ex_oh_ircd/irc/auth_failure_test.exs
+60 −0
@@ -0,0 +1,60 @@
1 +defmodule OhMyExOhIrcd.IRC.AuthFailureTest do
2 + @moduledoc """
3 + Why a SASL or PASS login failed, for the server's log: the client only
4 + hears "authentication failed", whichever it was.
5 + """
6 + use OhMyExOhIrcd.DataCase, async: true
7 +
8 + import ExUnit.CaptureLog
9 + import OhMyExOhIrcd.AccountsFixtures
10 +
11 + alias OhMyExOhIrcd.IRC.Auth
12 +
13 + defp plain(email, password), do: Base.encode64(<<0>> <> email <> <<0>> <> password)
14 +
15 + test "an account and its password log in" do
16 + user = user_fixture() |> set_password()
17 +
18 + assert {:ok, %{id: id}} = Auth.verify_sasl_plain(plain(user.email, valid_user_password()))
19 + assert id == user.id
20 + end
21 +
22 + test "no account under that email" do
23 + assert {:error, {:no_such_account, "nobody@example.com"}} =
24 + Auth.verify_sasl_plain(plain("nobody@example.com", "whatever"))
25 + end
26 +
27 + test "an account with no password: it signs in by magic link only" do
28 + user = user_fixture()
29 +
30 + assert {:error, {:no_password, email}} = Auth.verify_sasl_plain(plain(user.email, "whatever"))
31 + assert email == user.email
32 + end
33 +
34 + test "the wrong password" do
35 + user = user_fixture() |> set_password()
36 +
37 + assert {:error, {:wrong_password, _}} = Auth.verify_sasl_plain(plain(user.email, "not it"))
38 + assert {:error, {:wrong_password, _}} = Auth.verify_pass(user.email <> ":not it")
39 + end
40 +
41 + test "the log names the reason and a masked account, never the password" do
42 + user = user_fixture() |> set_password()
43 + {:error, error} = Auth.verify_sasl_plain(plain(user.email, "hunter2-not-it"))
44 +
45 + log = capture_log(fn -> Auth.log_failure("SASL PLAIN", error, "198.51.100.7") end)
46 +
47 + [local, domain] = String.split(user.email, "@")
48 + masked = String.first(local) <> String.duplicate("*", String.length(local) - 1)
49 +
50 + assert log =~ "SASL PLAIN authentication failed for #{masked}@#{domain} from 198.51.100.7: wrong_password"
51 + refute log =~ user.email
52 + refute log =~ "hunter2"
53 + end
54 +
55 + test "a malformed payload is logged without an account" do
56 + log = capture_log(fn -> Auth.log_failure("SASL PLAIN", :malformed, nil) end)
57 +
58 + assert log =~ "SASL PLAIN authentication failed for - from ?: malformed"
59 + end
60 +end

Parents: 3d068a3