Add NickServ LOGIN to identify and reclaim your nick in one command
All checks were successful
CI / check (push) Successful in 5m24s

This commit is contained in:
Jean Chevronnet 2026-07-20 20:59:47 +00:00
parent aa270e061e
commit c2a7dfa8f7
No known key found for this signature in database
12 changed files with 158 additions and 62 deletions

View file

@ -243,6 +243,9 @@ pub enum AuthThen {
// NickServ IDENTIFY: `uid` logs in, `agent` (NickServ uid) sends the notices, // NickServ IDENTIFY: `uid` logs in, `agent` (NickServ uid) sends the notices,
// `name` is what the user typed (for lockout/note_auth), `account` is canonical. // `name` is what the user typed (for lockout/note_auth), `account` is canonical.
Identify { uid: String, agent: String, name: String, account: String }, Identify { uid: String, agent: String, name: String, account: String },
// NickServ LOGIN: like Identify, but on success also reclaims `nick` — freeing
// any ghost holding it and moving the caller onto it.
Login { uid: String, agent: String, name: String, account: String, nick: String },
// SASL: finish the exchange for `client`, sourced from `agent`. `password` is // SASL: finish the exchange for `client`, sourced from `agent`. `password` is
// whether this was a password verify (feeds the brute-force throttle) vs a // whether this was a password verify (feeds the brute-force throttle) vs a
// one-time keycard redemption (which must not touch the password lockout). // one-time keycard redemption (which must not touch the password lockout).

View file

@ -1366,5 +1366,7 @@
"Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Unbekannter AJOIN-Befehl \u0002{other}\u0002. Nutze \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 oder \u0002LIST\u0002.", "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Unbekannter AJOIN-Befehl \u0002{other}\u0002. Nutze \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 oder \u0002LIST\u0002.",
"Or confirm in one click: {link}": "Oder bestätige mit einem Klick: {link}", "Or confirm in one click: {link}": "Oder bestätige mit einem Klick: {link}",
"Confirm now": "Jetzt bestätigen", "Confirm now": "Jetzt bestätigen",
"… and \u0002{more}\u0002 more; showing the first {cap}.": "… und \u0002{more}\u0002 weitere; die ersten {cap} werden angezeigt." "… and \u0002{more}\u0002 more; showing the first {cap}.": "… und \u0002{more}\u0002 weitere; die ersten {cap} werden angezeigt.",
"log in and reclaim your nick": "anmelden und deinen Nick zurückholen",
"Syntax: \u0002LOGIN <nick> <password>\u0002\nLogs you in to <nick>'s account and moves you onto that nick, freeing any session already holding it.": "Syntax: \u0002LOGIN <Nick> <Passwort>\u0002\nMeldet dich beim Konto von <Nick> an und setzt dich auf diesen Nick, wobei jede Sitzung, die ihn belegt, freigegeben wird."
} }

View file

@ -1366,5 +1366,7 @@
"Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconocido \u0002{other}\u0002. Usá \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 o \u0002LIST\u0002.", "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconocido \u0002{other}\u0002. Usá \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 o \u0002LIST\u0002.",
"Or confirm in one click: {link}": "O confirmá con un clic: {link}", "Or confirm in one click: {link}": "O confirmá con un clic: {link}",
"Confirm now": "Confirmar ahora", "Confirm now": "Confirmar ahora",
"… and \u0002{more}\u0002 more; showing the first {cap}.": "… y \u0002{more}\u0002 más; mostrando los primeros {cap}." "… and \u0002{more}\u0002 more; showing the first {cap}.": "… y \u0002{more}\u0002 más; mostrando los primeros {cap}.",
"log in and reclaim your nick": "iniciar sesión y recuperar tu apodo",
"Syntax: \u0002LOGIN <nick> <password>\u0002\nLogs you in to <nick>'s account and moves you onto that nick, freeing any session already holding it.": "Sintaxis: \u0002LOGIN <apodo> <contraseña>\u0002\nInicia sesión en la cuenta de <apodo> y te mueve a ese apodo, liberando cualquier sesión que ya lo esté usando."
} }

View file

@ -1366,5 +1366,7 @@
"Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconocido \u0002{other}\u0002. Usa \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 o \u0002LIST\u0002.", "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconocido \u0002{other}\u0002. Usa \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 o \u0002LIST\u0002.",
"Or confirm in one click: {link}": "O confirma con un clic: {link}", "Or confirm in one click: {link}": "O confirma con un clic: {link}",
"Confirm now": "Confirmar ahora", "Confirm now": "Confirmar ahora",
"… and \u0002{more}\u0002 more; showing the first {cap}.": "… y \u0002{more}\u0002 más; mostrando los primeros {cap}." "… and \u0002{more}\u0002 more; showing the first {cap}.": "… y \u0002{more}\u0002 más; mostrando los primeros {cap}.",
"log in and reclaim your nick": "iniciar sesión y recuperar tu apodo",
"Syntax: \u0002LOGIN <nick> <password>\u0002\nLogs you in to <nick>'s account and moves you onto that nick, freeing any session already holding it.": "Sintaxis: \u0002LOGIN <apodo> <contraseña>\u0002\nInicia sesión en la cuenta de <apodo> y te mueve a ese apodo, liberando cualquier sesión que ya lo ocupe."
} }

View file

@ -1366,5 +1366,7 @@
"Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Commande AJOIN inconnue \u0002{other}\u0002. Utilisez \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002.", "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Commande AJOIN inconnue \u0002{other}\u0002. Utilisez \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002.",
"Or confirm in one click: {link}": "Ou confirmez en un clic : {link}", "Or confirm in one click: {link}": "Ou confirmez en un clic : {link}",
"Confirm now": "Confirmer maintenant", "Confirm now": "Confirmer maintenant",
"… and \u0002{more}\u0002 more; showing the first {cap}.": "… et \u0002{more}\u0002 de plus ; affichage des {cap} premiers." "… and \u0002{more}\u0002 more; showing the first {cap}.": "… et \u0002{more}\u0002 de plus ; affichage des {cap} premiers.",
"log in and reclaim your nick": "se connecter et récupérer son pseudo",
"Syntax: \u0002LOGIN <nick> <password>\u0002\nLogs you in to <nick>'s account and moves you onto that nick, freeing any session already holding it.": "Syntaxe : \u0002LOGIN <pseudo> <mot de passe>\u0002\nVous connecte au compte de <pseudo> et vous place sur ce pseudo, en libérant toute session qui l'occupe déjà."
} }

View file

@ -1366,5 +1366,7 @@
"Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconhecido \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002.", "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconhecido \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002.",
"Or confirm in one click: {link}": "Ou confirme com um clique: {link}", "Or confirm in one click: {link}": "Ou confirme com um clique: {link}",
"Confirm now": "Confirmar agora", "Confirm now": "Confirmar agora",
"… and \u0002{more}\u0002 more; showing the first {cap}.": "… e mais \u0002{more}\u0002; mostrando os primeiros {cap}." "… and \u0002{more}\u0002 more; showing the first {cap}.": "… e mais \u0002{more}\u0002; mostrando os primeiros {cap}.",
"log in and reclaim your nick": "entrar e recuperar seu apelido",
"Syntax: \u0002LOGIN <nick> <password>\u0002\nLogs you in to <nick>'s account and moves you onto that nick, freeing any session already holding it.": "Sintaxe: \u0002LOGIN <apelido> <senha>\u0002\nEntra na conta de <apelido> e move você para esse apelido, liberando qualquer sessão que já o ocupe."
} }

View file

@ -1366,5 +1366,7 @@
"Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconhecido \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002.", "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconhecido \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002.",
"Or confirm in one click: {link}": "Ou confirme com um clique: {link}", "Or confirm in one click: {link}": "Ou confirme com um clique: {link}",
"Confirm now": "Confirmar agora", "Confirm now": "Confirmar agora",
"… and \u0002{more}\u0002 more; showing the first {cap}.": "… e mais \u0002{more}\u0002; a mostrar os primeiros {cap}." "… and \u0002{more}\u0002 more; showing the first {cap}.": "… e mais \u0002{more}\u0002; a mostrar os primeiros {cap}.",
"log in and reclaim your nick": "iniciar sessão e recuperar a sua alcunha",
"Syntax: \u0002LOGIN <nick> <password>\u0002\nLogs you in to <nick>'s account and moves you onto that nick, freeing any session already holding it.": "Sintaxe: \u0002LOGIN <alcunha> <palavra-passe>\u0002\nInicia sessão na conta de <alcunha> e move-o para essa alcunha, libertando qualquer sessão que já a ocupe."
} }

View file

@ -13,10 +13,30 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
return; return;
} }
}; };
let Some((account, verifier)) = precheck(me, from, "IDENTIFY", account_name, ctx, db) else {
return;
};
// Already identified to this account: skip the (wasted) verify.
if from.account == Some(account.as_str()) {
ctx.notice(me, from.uid, t!(ctx, "You're already identified as \x02{account}\x02.", account = account));
return;
}
ctx.defer_authenticate(
verifier,
password,
AuthThen::Identify { uid: from.uid.to_string(), agent: me.to_string(), name: account_name.to_string(), account },
);
}
// Shared validation for the password-login commands (IDENTIFY, LOGIN): the account
// must exist, not be suspended, not be throttled, and have a password verifier.
// Returns the (canonical account, verifier) for the deferred verify, or None after
// emitting the failure to `from`. `cmd` names the command in the FAIL/audit lines.
pub(crate) fn precheck(me: &str, from: &Sender, cmd: &str, account_name: &str, ctx: &mut ServiceCtx, db: &mut dyn Store) -> Option<(String, String)> {
// Distinguish an unregistered account from a wrong password. // Distinguish an unregistered account from a wrong password.
if !db.exists(account_name) { if !db.exists(account_name) {
ctx.fail(me, from.uid, "IDENTIFY", "ACCOUNT_NOT_REGISTERED", t!(ctx, "\x02{account_name}\x02 isn't registered.", account_name = account_name)); ctx.fail(me, from.uid, cmd, "ACCOUNT_NOT_REGISTERED", t!(ctx, "\x02{account_name}\x02 isn't registered.", account_name = account_name));
return; return None;
} }
// A suspended account can't be logged into (checked before the password so it // A suspended account can't be logged into (checked before the password so it
// doesn't reveal whether the password was right). Tell them who, when and why, // doesn't reveal whether the password was right). Tell them who, when and why,
@ -34,15 +54,15 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
msg.push_str(&t!(ctx, " The suspension is due to lift on {when}.", when = human_time(exp))); msg.push_str(&t!(ctx, " The suspension is due to lift on {when}.", when = human_time(exp)));
} }
msg.push_str(&t!(ctx, " If you think this is a mistake, please contact the network staff.")); msg.push_str(&t!(ctx, " If you think this is a mistake, please contact the network staff."));
ctx.fail(me, from.uid, "IDENTIFY", "ACCOUNT_SUSPENDED", msg); ctx.fail(me, from.uid, cmd, "ACCOUNT_SUSPENDED", msg);
} }
return; return None;
} }
} }
// Refuse while throttled, so a password can't be brute-forced. // Refuse while throttled, so a password can't be brute-forced.
if let Some(secs) = db.auth_lockout(account_name) { if let Some(secs) = db.auth_lockout(account_name) {
ctx.fail(me, from.uid, "IDENTIFY", "RATE_LIMITED", t!(ctx, "Too many failed attempts. Please wait {secs}s and try again.", secs = secs)); ctx.fail(me, from.uid, cmd, "RATE_LIMITED", t!(ctx, "Too many failed attempts. Please wait {secs}s and try again.", secs = secs));
return; return None;
} }
// Fetch the verifier cheaply and hand the (~1s) PBKDF2 verify to the engine to // Fetch the verifier cheaply and hand the (~1s) PBKDF2 verify to the engine to
// run off the reactor — never verify under the engine lock. The login finish // run off the reactor — never verify under the engine lock. The login finish
@ -54,20 +74,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
// blind spot the deferred wrong-password path doesn't have. // blind spot the deferred wrong-password path doesn't have.
db.note_auth(account_name, false); db.note_auth(account_name, false);
ctx.count("nickserv.identify_fail"); ctx.count("nickserv.identify_fail");
ctx.auth_report(false, Some(account_name), "NickServ IDENTIFY", from.uid, Some("no password set")); ctx.auth_report(false, Some(account_name), &format!("NickServ {cmd}"), from.uid, Some("no password set"));
ctx.fail(me, from.uid, "IDENTIFY", "INVALID_CREDENTIALS", "Invalid password. Please try again."); ctx.fail(me, from.uid, cmd, "INVALID_CREDENTIALS", "Invalid password. Please try again.");
} None
Some((account, verifier)) => {
// Already identified to this account: skip the (wasted) verify.
if from.account == Some(account.as_str()) {
ctx.notice(me, from.uid, t!(ctx, "You're already identified as \x02{account}\x02.", account = account));
return;
}
ctx.defer_authenticate(
verifier,
password,
AuthThen::Identify { uid: from.uid.to_string(), agent: me.to_string(), name: account_name.to_string(), account },
);
} }
Some((account, verifier)) => Some((account, verifier)),
} }
} }

View file

@ -6,6 +6,7 @@ use echo_api::NetView;
mod register; mod register;
#[path = "identify.rs"] #[path = "identify.rs"]
mod identify; mod identify;
mod login;
#[path = "logout.rs"] #[path = "logout.rs"]
mod logout; mod logout;
#[path = "cert.rs"] #[path = "cert.rs"]
@ -51,6 +52,7 @@ const BLURB: &str = "NickServ looks after your nickname and account: register it
const TOPICS: &[HelpEntry] = &[ const TOPICS: &[HelpEntry] = &[
HelpEntry { cmd: "REGISTER", summary: "register your nick as an account", detail: "Syntax: \x02REGISTER <password> [email]\x02\nRegisters your current nick as an account. If an email is given and confirmation is on, you get a code to \x02CONFIRM\x02." }, HelpEntry { cmd: "REGISTER", summary: "register your nick as an account", detail: "Syntax: \x02REGISTER <password> [email]\x02\nRegisters your current nick as an account. If an email is given and confirmation is on, you get a code to \x02CONFIRM\x02." },
HelpEntry { cmd: "IDENTIFY", summary: "log in to your account", detail: "Syntax: \x02IDENTIFY [account] <password>\x02\nLogs you in. Also \x02ID\x02." }, HelpEntry { cmd: "IDENTIFY", summary: "log in to your account", detail: "Syntax: \x02IDENTIFY [account] <password>\x02\nLogs you in. Also \x02ID\x02." },
HelpEntry { cmd: "LOGIN", summary: "log in and reclaim your nick", detail: "Syntax: \x02LOGIN <nick> <password>\x02\nLogs you in to <nick>'s account and moves you onto that nick, freeing any session already holding it." },
HelpEntry { cmd: "LOGOUT", summary: "log out to a guest nick", detail: "Syntax: \x02LOGOUT\x02\nLogs you out and moves you to a guest nick. Also \x02LOGOFF\x02." }, HelpEntry { cmd: "LOGOUT", summary: "log out to a guest nick", detail: "Syntax: \x02LOGOUT\x02\nLogs you out and moves you to a guest nick. Also \x02LOGOFF\x02." },
HelpEntry { cmd: "INFO", summary: "show account information", detail: "Syntax: \x02INFO [account]\x02\nShows account information. The email is shown only to the owner." }, HelpEntry { cmd: "INFO", summary: "show account information", detail: "Syntax: \x02INFO [account]\x02\nShows account information. The email is shown only to the owner." },
HelpEntry { cmd: "ALIST", summary: "list channels you have access on", detail: "Syntax: \x02ALIST\x02\nLists the channels you hold access on." }, HelpEntry { cmd: "ALIST", summary: "list channels you have access on", detail: "Syntax: \x02ALIST\x02\nLists the channels you hold access on." },
@ -112,6 +114,7 @@ impl Service for NickServ {
match cmd.as_deref() { match cmd.as_deref() {
Some("REGISTER") => register::handle(me, from, args, ctx, db), Some("REGISTER") => register::handle(me, from, args, ctx, db),
Some("IDENTIFY") | Some("ID") => identify::handle(me, from, args, ctx, db), Some("IDENTIFY") | Some("ID") => identify::handle(me, from, args, ctx, db),
Some("LOGIN") => login::handle(me, from, args, ctx, db),
Some("LOGOUT") | Some("LOGOFF") => logout::handle(me, &self.guest_nick, &mut self.guest_seq, from, ctx, net, db), Some("LOGOUT") | Some("LOGOFF") => logout::handle(me, &self.guest_nick, &mut self.guest_seq, from, ctx, net, db),
Some("CERT") => cert::handle(me, from, args, ctx, db), Some("CERT") => cert::handle(me, from, args, ctx, db),
Some("INFO") => info::handle(me, from, args, ctx, net, db), Some("INFO") => info::handle(me, from, args, ctx, net, db),

View file

@ -0,0 +1,27 @@
use echo_api::{AuthThen, Sender, ServiceCtx, Store};
use super::identify;
// LOGIN <nick> <password>: identify to the account owning <nick> and, on success,
// reclaim the nick — freeing any ghost holding it and moving you onto it. A
// one-shot IDENTIFY + RECOVER for someone who connected under a guest nick.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let (Some(&nick), Some(&password)) = (args.get(1), args.get(2)) else {
ctx.notice(me, from.uid, "Syntax: LOGIN <nick> <password>");
return;
};
let Some((account, verifier)) = identify::precheck(me, from, "LOGIN", nick, ctx, db) else {
return;
};
ctx.defer_authenticate(
verifier,
password,
AuthThen::Login {
uid: from.uid.to_string(),
agent: me.to_string(),
name: nick.to_string(),
account,
nick: nick.to_string(),
},
);
}

View file

@ -371,7 +371,41 @@ impl Engine {
/// AJOIN, vhost, memo notice) stay identical. /// AJOIN, vhost, memo notice) stay identical.
pub fn complete_authenticate(&mut self, ok: bool, then: AuthThen) -> Vec<NetAction> { pub fn complete_authenticate(&mut self, ok: bool, then: AuthThen) -> Vec<NetAction> {
let actions = match then { let actions = match then {
AuthThen::Identify { uid, agent, name, account } => { AuthThen::Identify { uid, agent, name, account } => self.finish_identify(ok, uid, agent, name, account),
AuthThen::Login { uid, agent, name, account, nick } => {
let mut out = self.finish_identify(ok, uid.clone(), agent, name, account);
if ok {
out.extend(self.recover_nick(&uid, &nick));
}
out
}
AuthThen::Sasl { agent, client, account, password } => {
// Feed the same brute-force throttle IDENTIFY uses (success clears it,
// failure grows the backoff) — but ONLY for a password verify; a failed
// one-time keycard redemption must not lock out the account's password.
if password {
self.db.note_auth(&account, ok);
}
if ok {
self.sasl_login("SASL PLAIN", &agent, &client, account)
} else {
self.sasl_deny("SASL PLAIN", &agent, &client, Some(&account), "bad password")
}
}
};
// This runs in the link layer, off the handle() path, so the login's
// accountname metadata never reaches track_accounts in handle(). Apply it
// here so echo's own account map is authoritative and doesn't depend on the
// ircd reflecting the metadata back — which is a no-op when re-authenticating
// an already-logged-in user after a services relink, leaving the user unable
// to use their access despite a successful login.
self.track_accounts(&actions);
actions
}
// The login side-effects shared by IDENTIFY and LOGIN: throttle bookkeeping,
// the welcome + auto-join + vhost + waiting-memo notice, and the auth-feed line.
fn finish_identify(&mut self, ok: bool, uid: String, agent: String, name: String, account: String) -> Vec<NetAction> {
self.db.note_auth(&name, ok); self.db.note_auth(&name, ok);
let lang = self.lang_for_account(&account); let lang = self.lang_for_account(&account);
let mut ctx = ServiceCtx { lang: lang.clone(), ..Default::default() }; let mut ctx = ServiceCtx { lang: lang.clone(), ..Default::default() };
@ -409,27 +443,20 @@ impl Engine {
actions.extend(feed); actions.extend(feed);
actions actions
} }
AuthThen::Sasl { agent, client, account, password } => {
// Feed the same brute-force throttle IDENTIFY uses (success clears it, // Reclaim `nick` for `uid` after a LOGIN: rename any other session off it (to a
// failure grows the backoff) — but ONLY for a password verify; a failed // guest nick), then move the caller onto it.
// one-time keycard redemption must not lock out the account's password. fn recover_nick(&mut self, uid: &str, nick: &str) -> Vec<NetAction> {
if password { let mut out = Vec::new();
self.db.note_auth(&account, ok); if let Some(ghost) = self.network.uid_by_nick(nick).map(str::to_string) {
} if ghost != uid {
if ok { let guest = echo_api::next_guest_nick(&self.guest_nick, &mut self.enforce_seq, &self.network, &self.db);
self.sasl_login("SASL PLAIN", &agent, &client, account) out.push(NetAction::ForceNick { uid: ghost, nick: guest });
} else {
self.sasl_deny("SASL PLAIN", &agent, &client, Some(&account), "bad password")
} }
} }
}; if self.network.nick_of(uid) != Some(nick) {
// This runs in the link layer, off the handle() path, so the login's out.push(NetAction::ForceNick { uid: uid.to_string(), nick: nick.to_string() });
// accountname metadata never reaches track_accounts in handle(). Apply it }
// here so echo's own account map is authoritative and doesn't depend on the out
// ircd reflecting the metadata back — which is a no-op when re-authenticating
// an already-logged-in user after a services relink, leaving the user unable
// to use their access despite a successful login.
self.track_accounts(&actions);
actions
} }
} }

View file

@ -854,6 +854,20 @@
assert_eq!(e.network.account_of("000AAAAAB"), Some("foo"), "and is authoritative in echo's own map"); assert_eq!(e.network.account_of("000AAAAAB"), Some("foo"), "and is authoritative in echo's own map");
} }
// NS LOGIN identifies AND reclaims the nick: after the deferred verify succeeds,
// any ghost on the target nick is renamed off and the caller is moved onto it.
#[test]
fn login_recovers_the_nick_after_the_deferred_verify() {
let mut e = engine_with("login", "alice", "sesame");
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "Guest7".into(), host: "h".into(), ip: "0.0.0.0".into() });
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
let then = echo_api::AuthThen::Login { uid: "000AAAAAB".into(), agent: "42SAAAAAA".into(), name: "alice".into(), account: "alice".into(), nick: "alice".into() };
let out = e.complete_authenticate(true, then);
assert_eq!(e.network.account_of("000AAAAAB"), Some("alice"), "caller is identified");
assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, nick } if uid == "000AAAAAC" && nick != "alice")), "ghost renamed off the nick: {out:?}");
assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, nick } if uid == "000AAAAAB" && nick == "alice")), "caller takes the nick: {out:?}");
}
// A suspension that arrives by gossip must end local sessions on that account, // A suspension that arrives by gossip must end local sessions on that account,
// just as a local SUSPEND does — the account and its channels stay put. // just as a local SUSPEND does — the account and its channels stay put.
#[test] #[test]