neiam
/oh-my-ex-oh-ircd
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
IRC auth: log why a SASL or PASS login failed
ad82ccd · Gabriel Morell · 2026-10-27 16:44
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