From 1ecb76affb6dc0ee63f9a1870552b8ebbf142200 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 19 Jul 2026 05:00:12 +0000 Subject: [PATCH] Report failed IDENTIFY against a password-less account to the auth feed --- api/src/lib.rs | 28 ++++++++++++++++++++++++++++ modules/nickserv/src/identify.rs | 3 +++ src/engine/dispatch.rs | 7 +++++++ 3 files changed, 38 insertions(+) diff --git a/api/src/lib.rs b/api/src/lib.rs index 5b5c8af..3c6ae17 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -485,6 +485,21 @@ pub struct ServiceCtx { // engine drains these to the log channel, prefixing the sender's nick/host — // e.g. a blocked look-alike registration attempt. pub alerts: Vec<(String, String)>, + // Login-attempt outcomes for the auth audit feed. The engine drains these + // through its own auth_report (which resolves the client's source), so a + // module can report a reject the engine's verify path never sees — e.g. a + // failed IDENTIFY against a cert-only account with no password verifier. + pub auth_reports: Vec, +} + +// A login-attempt outcome a module hands back for the auth audit feed. +#[derive(Default)] +pub struct AuthReport { + pub ok: bool, + pub account: Option, + pub mech: String, + pub client: String, + pub reason: Option, } impl ServiceCtx { @@ -536,6 +551,19 @@ impl ServiceCtx { self.alerts.push((category.into(), text.into())); } + // Report a login-attempt outcome to the auth audit feed. For a reject the + // engine's own verify path won't produce (a failed IDENTIFY against an account + // with no password verifier), so operators see it like any other failed login. + pub fn auth_report(&mut self, ok: bool, account: Option<&str>, mech: &str, client: &str, reason: Option<&str>) { + self.auth_reports.push(AuthReport { + ok, + account: account.map(String::from), + mech: mech.to_string(), + client: client.to_string(), + reason: reason.map(String::from), + }); + } + // A channel/target message sourced from one of our pseudo-clients (e.g. a // bot answering a fantasy command, or BotServ SAY). pub fn privmsg(&mut self, from: &str, to: &str, text: impl Into) { diff --git a/modules/nickserv/src/identify.rs b/modules/nickserv/src/identify.rs index 311aa5e..3e7c0ab 100644 --- a/modules/nickserv/src/identify.rs +++ b/modules/nickserv/src/identify.rs @@ -49,8 +49,11 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: match db.scram_verifier(account_name) { None => { // Exists but has no verifier (e.g. cert-only) — a password can't match. + // Report it to the auth feed too, or probing a cert-only account is a + // blind spot the deferred wrong-password path doesn't have. db.note_auth(account_name, false); ctx.count("nickserv.identify_fail"); + ctx.auth_report(false, Some(account_name), "NickServ IDENTIFY", from.uid, Some("no password set")); ctx.fail(me, from.uid, "IDENTIFY", "INVALID_CREDENTIALS", "Invalid password. Please try again."); } Some((account, verifier)) => { diff --git a/src/engine/dispatch.rs b/src/engine/dispatch.rs index 3ad9f71..0d81e37 100644 --- a/src/engine/dispatch.rs +++ b/src/engine/dispatch.rs @@ -113,6 +113,13 @@ impl Engine { out.push(line); } } + // Login-attempt outcomes a command reported (e.g. a failed IDENTIFY against + // a cert-only account), routed through the same auth feed as the verify path. + for r in std::mem::take(&mut ctx.auth_reports) { + if let Some(line) = self.auth_report(r.ok, r.account.as_deref(), &r.mech, &r.client, r.reason.as_deref()) { + out.push(line); + } + } self.apply_dict_limit(&mut out); self.apply_msg_style(&mut out); out