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

@ -13,10 +13,30 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
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.
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));
return;
ctx.fail(me, from.uid, cmd, "ACCOUNT_NOT_REGISTERED", t!(ctx, "\x02{account_name}\x02 isn't registered.", account_name = account_name));
return None;
}
// 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,
@ -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, " 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.
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));
return;
ctx.fail(me, from.uid, cmd, "RATE_LIMITED", t!(ctx, "Too many failed attempts. Please wait {secs}s and try again.", secs = secs));
return None;
}
// 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
@ -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.
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)) => {
// 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 },
);
ctx.auth_report(false, Some(account_name), &format!("NickServ {cmd}"), from.uid, Some("no password set"));
ctx.fail(me, from.uid, cmd, "INVALID_CREDENTIALS", "Invalid password. Please try again.");
None
}
Some((account, verifier)) => Some((account, verifier)),
}
}

View file

@ -6,6 +6,7 @@ use echo_api::NetView;
mod register;
#[path = "identify.rs"]
mod identify;
mod login;
#[path = "logout.rs"]
mod logout;
#[path = "cert.rs"]
@ -51,6 +52,7 @@ const BLURB: &str = "NickServ looks after your nickname and account: register it
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: "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: "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." },
@ -112,6 +114,7 @@ impl Service for NickServ {
match cmd.as_deref() {
Some("REGISTER") => register::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("CERT") => cert::handle(me, from, args, ctx, 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(),
},
);
}