echo/modules/nickserv/src/identify.rs
Jean 1ecb76affb
All checks were successful
CI / check (push) Successful in 4m10s
Report failed IDENTIFY against a password-less account to the auth feed
2026-07-19 05:00:12 +00:00

72 lines
3.7 KiB
Rust

use echo_api::{human_time, AuthThen, Store};
use echo_api::{Sender, ServiceCtx};
// IDENTIFY [account] <password>: log in. The account defaults to the current
// nick, so both the bare-password and account+password forms work.
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut dyn Store) {
let (account_name, password) = match (args.get(1), args.get(2)) {
(Some(account), Some(password)) => (*account, *password),
(Some(password), None) => (from.nick, *password),
_ => {
ctx.notice(me, from.uid, "Syntax: IDENTIFY [account] <password>");
return;
}
};
// Distinguish an unregistered account from a wrong password.
if !db.exists(account_name) {
ctx.fail(me, from.uid, "IDENTIFY", "ACCOUNT_NOT_REGISTERED", format!("\x02{account_name}\x02 isn't registered."));
return;
}
// 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,
// and when it lifts, so they know what happened and who to reach.
if let Some(acc) = db.resolve_account(account_name).map(str::to_string) {
if db.is_suspended(&acc) {
if let Some(s) = db.suspension(&acc) {
let mut msg = format!("\x02{acc}\x02 is suspended, so you can't log in to it right now. It was suspended by \x02{}\x02 on {}", s.by, human_time(s.ts));
if s.reason.trim().is_empty() {
msg.push('.');
} else {
msg.push_str(&format!(" — reason: {}", s.reason));
}
if let Some(exp) = s.expires {
msg.push_str(&format!(" The suspension is due to lift on {}.", human_time(exp)));
}
msg.push_str(" If you think this is a mistake, please contact the network staff.");
ctx.fail(me, from.uid, "IDENTIFY", "ACCOUNT_SUSPENDED", msg);
}
return;
}
}
// 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", format!("Too many failed attempts. Please wait {secs}s and try again."));
return;
}
// 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
// happens in Engine::complete_authenticate.
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)) => {
// Already identified to this account: skip the (wasted) verify.
if from.account == Some(account.as_str()) {
ctx.notice(me, from.uid, format!("You're already identified as \x02{}\x02.", account));
return;
}
ctx.defer_authenticate(
verifier,
password,
AuthThen::Identify { uid: from.uid.to_string(), agent: me.to_string(), name: account_name.to_string(), account },
);
}
}
}