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

@ -371,43 +371,13 @@ impl Engine {
/// AJOIN, vhost, memo notice) stay identical.
pub fn complete_authenticate(&mut self, ok: bool, then: AuthThen) -> Vec<NetAction> {
let actions = match then {
AuthThen::Identify { uid, agent, name, account } => {
self.db.note_auth(&name, ok);
let lang = self.lang_for_account(&account);
let mut ctx = ServiceCtx { lang: lang.clone(), ..Default::default() };
if !ok {
ctx.count("nickserv.identify_fail");
ctx.fail(&agent, &uid, "IDENTIFY", "INVALID_CREDENTIALS", "Invalid password. Please try again.");
} else {
ctx.login(&uid, &account);
ctx.count("nickserv.identify");
ctx.notice(&agent, &uid, echo_api::render(&lang, "You're now identified as \x02{account}\x02. Welcome back!", &[("account", account.clone())]));
for entry in self.db.ajoin_list(&account) {
ctx.force_join(&uid, &entry.channel, &entry.key);
}
let now = self.now_secs();
let vhost = self.db.account(&account).and_then(|a| {
a.vhost.as_ref().filter(|v| v.expires.is_none_or(|e| e > now)).map(|v| v.host.clone())
});
if let Some(host) = vhost {
ctx.apply_vhost(&uid, &host);
}
let unread = self.db.unread_memos(&account);
if unread > 0 && self.db.memo_notify_on(&account) {
ctx.notice(&agent, &uid, echo_api::render_plural(&lang, unread as u64, "You have \x02{unread}\x02 new memo. Read it with \x02/msg MemoServ READ NEW\x02.", "You have \x02{unread}\x02 new memos. Read them with \x02/msg MemoServ READ NEW\x02.", &[("unread", unread.to_string())]));
}
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));
}
for key in std::mem::take(&mut ctx.stats) {
self.bump(&key);
}
let feed = if ok {
self.auth_report(true, Some(&account), "NickServ IDENTIFY", &uid, None)
} else {
self.auth_report(false, Some(&name), "NickServ IDENTIFY", &uid, Some("bad password"))
};
let mut actions = ctx.actions;
actions.extend(feed);
actions
out
}
AuthThen::Sasl { agent, client, account, password } => {
// Feed the same brute-force throttle IDENTIFY uses (success clears it,
@ -432,4 +402,61 @@ impl Engine {
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);
let lang = self.lang_for_account(&account);
let mut ctx = ServiceCtx { lang: lang.clone(), ..Default::default() };
if !ok {
ctx.count("nickserv.identify_fail");
ctx.fail(&agent, &uid, "IDENTIFY", "INVALID_CREDENTIALS", "Invalid password. Please try again.");
} else {
ctx.login(&uid, &account);
ctx.count("nickserv.identify");
ctx.notice(&agent, &uid, echo_api::render(&lang, "You're now identified as \x02{account}\x02. Welcome back!", &[("account", account.clone())]));
for entry in self.db.ajoin_list(&account) {
ctx.force_join(&uid, &entry.channel, &entry.key);
}
let now = self.now_secs();
let vhost = self.db.account(&account).and_then(|a| {
a.vhost.as_ref().filter(|v| v.expires.is_none_or(|e| e > now)).map(|v| v.host.clone())
});
if let Some(host) = vhost {
ctx.apply_vhost(&uid, &host);
}
let unread = self.db.unread_memos(&account);
if unread > 0 && self.db.memo_notify_on(&account) {
ctx.notice(&agent, &uid, echo_api::render_plural(&lang, unread as u64, "You have \x02{unread}\x02 new memo. Read it with \x02/msg MemoServ READ NEW\x02.", "You have \x02{unread}\x02 new memos. Read them with \x02/msg MemoServ READ NEW\x02.", &[("unread", unread.to_string())]));
}
}
for key in std::mem::take(&mut ctx.stats) {
self.bump(&key);
}
let feed = if ok {
self.auth_report(true, Some(&account), "NickServ IDENTIFY", &uid, None)
} else {
self.auth_report(false, Some(&name), "NickServ IDENTIFY", &uid, Some("bad password"))
};
let mut actions = ctx.actions;
actions.extend(feed);
actions
}
// Reclaim `nick` for `uid` after a LOGIN: rename any other session off it (to a
// guest nick), then move the caller onto it.
fn recover_nick(&mut self, uid: &str, nick: &str) -> Vec<NetAction> {
let mut out = Vec::new();
if let Some(ghost) = self.network.uid_by_nick(nick).map(str::to_string) {
if ghost != uid {
let guest = echo_api::next_guest_nick(&self.guest_nick, &mut self.enforce_seq, &self.network, &self.db);
out.push(NetAction::ForceNick { uid: ghost, nick: guest });
}
}
if self.network.nick_of(uid) != Some(nick) {
out.push(NetAction::ForceNick { uid: uid.to_string(), nick: nick.to_string() });
}
out
}
}

View file

@ -854,6 +854,20 @@
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,
// just as a local SUSPEND does — the account and its channels stay put.
#[test]