All checks were successful
CI / check (push) Successful in 3m54s
Rework the AUTH feed for an admin log channel. Drop the check/cross icons; each line now reads "account login ok|FAILED - mechanism - from source [- reason]". The important fix is the source: mid-SASL the client isn't a connected user yet, so the old lines showed "?" and a cloaked host — capture the real host+IP the ircd hands us in the SASL H message instead, and fall back to the live user's nick@host for a NickServ IDENTIFY. Failures now always name the account (which is attacker-supplied on a bad attempt, so it's stripped of control codes and capped before it goes in the channel message). Also emit an echo::auth info log so auth events land on disk, not only in the channel. No secret is ever included.
325 lines
17 KiB
Rust
325 lines
17 KiB
Rust
use super::*;
|
|
|
|
impl Engine {
|
|
// ── Account authority (see grpc.rs) ─────────────────────────────────────
|
|
// A trusted caller (e.g. a website backend that already did its own login
|
|
// check) managing accounts the same way an IRC user does through NickServ,
|
|
// minus the command syntax. The bearer token on the gRPC side IS the
|
|
// authorization: unlike the mirrored NickServ commands, these do NOT
|
|
// re-check the account's own password — Register and Authenticate are the
|
|
// two exceptions, since the password is the actual input there.
|
|
|
|
pub fn authority_pre_check(&mut self, name: &str) -> Result<(), AuthorityStatus> {
|
|
if self.db.exists(name) {
|
|
return Err(AuthorityStatus::AlreadyExists);
|
|
}
|
|
if !self.reg_limiter.allow() {
|
|
return Err(AuthorityStatus::RateLimited);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// Provision an account from pre-derived SCRAM verifiers (bulk backfill from
|
|
// the authority). No email confirmation — the authority already vouches for it.
|
|
pub fn authority_provision(&mut self, name: &str, scram256: &str, scram512: &str, email: Option<String>) -> AuthorityStatus {
|
|
match self.db.provision_account(name, scram256, scram512, email) {
|
|
Ok(()) => AuthorityStatus::Ok,
|
|
Err(RegError::Exists) => AuthorityStatus::AlreadyExists,
|
|
Err(_) => AuthorityStatus::Internal,
|
|
}
|
|
}
|
|
|
|
pub fn authority_register(&mut self, name: &str, creds: Option<db::Credentials>, email: Option<String>) -> AuthorityStatus {
|
|
let Some(creds) = creds else { return AuthorityStatus::Internal };
|
|
let addr = email.clone();
|
|
let status = match self.db.register_prepared(name, creds, email) {
|
|
Ok(()) => AuthorityStatus::Ok,
|
|
Err(RegError::Exists) => AuthorityStatus::AlreadyExists,
|
|
Err(RegError::Internal) => AuthorityStatus::Internal,
|
|
};
|
|
if status == AuthorityStatus::Ok && !self.db.is_verified(name) {
|
|
if let Some(addr) = addr {
|
|
let code = self.db.issue_code(name, db::CodeKind::Confirm);
|
|
let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), name, &code);
|
|
self.emit_irc(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) });
|
|
}
|
|
}
|
|
status
|
|
}
|
|
|
|
/// The account's canonical name and its SHA-256 verifier (owned), so a caller
|
|
/// can run the PBKDF2 verify OFF the engine lock. At production iteration
|
|
/// counts that derivation is ~1s of CPU — never run it while holding the lock
|
|
/// (it would freeze the whole daemon); fetch here, then `scram::verify_plain`
|
|
/// on a blocking thread.
|
|
pub fn scram_verifier(&self, name: &str) -> Option<(String, String)> {
|
|
self.db.scram_lookup(name, "SCRAM-SHA-256").map(|(a, v)| (a.to_string(), v.to_string()))
|
|
}
|
|
|
|
pub fn authority_set_password(&mut self, account: &str, creds: Option<db::Credentials>) -> AuthorityStatus {
|
|
let Some(creds) = creds else { return AuthorityStatus::Internal };
|
|
// set_credentials's only Err is Internal, which here always means "no such account".
|
|
match self.db.set_credentials(account, creds) {
|
|
Ok(()) => AuthorityStatus::Ok,
|
|
Err(_) => AuthorityStatus::NotFound,
|
|
}
|
|
}
|
|
|
|
pub fn authority_set_email(&mut self, account: &str, email: Option<String>) -> AuthorityStatus {
|
|
match self.db.set_email(account, email) {
|
|
Ok(()) => AuthorityStatus::Ok,
|
|
Err(_) => AuthorityStatus::NotFound,
|
|
}
|
|
}
|
|
|
|
pub fn authority_confirm(&mut self, account: &str, code: &str) -> AuthorityStatus {
|
|
if !self.db.exists(account) {
|
|
return AuthorityStatus::NotFound;
|
|
}
|
|
if self.db.is_verified(account) {
|
|
return AuthorityStatus::Ok; // already confirmed — idempotent, not an error
|
|
}
|
|
if !self.db.take_code(account, db::CodeKind::Confirm, code) {
|
|
return AuthorityStatus::Invalid;
|
|
}
|
|
match self.db.verify_account(account) {
|
|
Ok(()) => AuthorityStatus::Ok,
|
|
Err(_) => AuthorityStatus::Internal,
|
|
}
|
|
}
|
|
|
|
// Drop reuses the exact cleanup a peer's gossiped drop already triggers
|
|
// locally (channel release + session logout) — see `handle_account_gone`.
|
|
pub fn authority_drop(&mut self, account: &str) -> AuthorityStatus {
|
|
match self.db.drop_account(account) {
|
|
Ok(true) => {
|
|
self.handle_account_gone(account, "was dropped");
|
|
AuthorityStatus::Ok
|
|
}
|
|
Ok(false) => AuthorityStatus::NotFound,
|
|
Err(_) => AuthorityStatus::Internal,
|
|
}
|
|
}
|
|
|
|
// Unlike Drop, the account itself is untouched — only its active IRC
|
|
// sessions are logged out (no channel cleanup, this isn't "account gone").
|
|
pub fn authority_force_logout(&mut self, account: &str) -> u32 {
|
|
let victims = self.network.uids_logged_into(account);
|
|
let n = victims.len() as u32;
|
|
let ns = self.nick_service.clone();
|
|
for uid in victims {
|
|
self.network.clear_account(&uid);
|
|
self.emit_irc(NetAction::Metadata { target: uid.clone(), key: "accountname".to_string(), value: String::new() });
|
|
if let Some(ns) = &ns {
|
|
self.emit_irc(NetAction::Notice { from: ns.clone(), to: uid, text: format!("You have been logged out of \x02{account}\x02.") });
|
|
}
|
|
}
|
|
n
|
|
}
|
|
|
|
pub fn authority_group_nick(&mut self, nick: &str, account: &str) -> AuthorityStatus {
|
|
if self.db.account(nick).is_some() {
|
|
return AuthorityStatus::Invalid; // nick is itself a registered account
|
|
}
|
|
match self.db.group_nick(nick, account) {
|
|
Ok(()) => AuthorityStatus::Ok,
|
|
Err(_) => AuthorityStatus::NotFound, // group_nick's only Err means the account doesn't exist
|
|
}
|
|
}
|
|
|
|
pub fn authority_ungroup_nick(&mut self, nick: &str) -> AuthorityStatus {
|
|
match self.db.ungroup_nick(nick) {
|
|
Ok(true) => AuthorityStatus::Ok,
|
|
Ok(false) => AuthorityStatus::NotFound,
|
|
Err(_) => AuthorityStatus::Internal,
|
|
}
|
|
}
|
|
|
|
// Authority side of the IRCv3 account-registration relay (draft/account-
|
|
// registration). REGISTER hands off to the link layer (which derives the
|
|
// password off-thread) via DeferRegister; VERIFY/RESEND/STATUS are answered
|
|
// here directly against the emailed-code flow.
|
|
pub(crate) fn account_request(&mut self, reqid: String, origin: String, kind: String, account: String, p2: String, p3: String) -> Vec<NetAction> {
|
|
if kind.eq_ignore_ascii_case("REGISTER") {
|
|
let email = if p2.is_empty() || p2 == "*" { None } else { Some(p2) };
|
|
return vec![NetAction::DeferRegister { account, password: p3, email, reply: RegReply::Relay { reqid, kind, origin } }];
|
|
}
|
|
|
|
// Relay-only replies, built directly (REGISTER goes through complete_register).
|
|
let resp = |status: &str, code: &str, message: &str| {
|
|
vec![NetAction::AccountResponse {
|
|
reqid: reqid.clone(),
|
|
origin: origin.clone(),
|
|
kind: kind.clone(),
|
|
account: account.clone(),
|
|
status: status.to_string(),
|
|
code: code.to_string(),
|
|
message: message.to_string(),
|
|
}]
|
|
};
|
|
// Identity is the website's in external mode; the relay shouldn't manage it.
|
|
if self.db.external_accounts() {
|
|
return resp("error", "ACCOUNT_REGISTRATION_DISABLED", "Accounts are managed on the website.");
|
|
}
|
|
|
|
match kind.to_ascii_uppercase().as_str() {
|
|
// VERIFY <account> <code> — confirm the emailed code.
|
|
"VERIFY" => {
|
|
let code = if !p2.is_empty() { p2 } else { p3 };
|
|
if self.db.account(&account).is_none() {
|
|
resp("error", "ACCOUNT_UNKNOWN", "No such account.")
|
|
} else if self.db.take_code(&account, db::CodeKind::Confirm, &code) {
|
|
let _ = self.db.verify_account(&account);
|
|
resp("success", "*", "Account verified.")
|
|
} else {
|
|
resp("error", "INVALID_CODE", "That verification code is wrong or has expired.")
|
|
}
|
|
}
|
|
// RESEND <account> — issue and email a fresh confirmation code.
|
|
"RESEND" => match self.db.account(&account).map(|a| (a.verified, a.email.clone())) {
|
|
None => resp("error", "ACCOUNT_UNKNOWN", "No such account."),
|
|
Some((true, _)) => resp("error", "ALREADY_VERIFIED", "That account is already verified."),
|
|
Some((false, None)) => resp("error", "NO_EMAIL", "No email address is on file for that account."),
|
|
Some((false, Some(_))) if self.db.code_issue_wait(&account) > 0 => {
|
|
resp("error", "TEMPORARILY_UNAVAILABLE", "A code was just sent — please wait a moment before requesting another.")
|
|
}
|
|
Some((false, Some(addr))) => {
|
|
let code = self.db.issue_code(&account, db::CodeKind::Confirm);
|
|
let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), &account, &code);
|
|
let mut out = resp("verification_required", "VERIFICATION_REQUIRED", "A new confirmation code has been emailed.");
|
|
out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) });
|
|
out
|
|
}
|
|
},
|
|
// STATUS <account> — report registration/verification state.
|
|
"STATUS" => match self.db.account(&account).map(|a| a.verified) {
|
|
None => resp("error", "ACCOUNT_UNKNOWN", "No such account."),
|
|
Some(true) => resp("success", "VERIFIED", "Account is registered and verified."),
|
|
Some(false) => resp("verification_required", "VERIFICATION_REQUIRED", "Account is registered but not yet verified."),
|
|
},
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
// Cheap gate run before the expensive derivation: reject an already-taken name
|
|
// outright, and rate-limit the rest so a REGISTER flood can't pin CPU. Returns
|
|
// the rejection response if refused, or None to proceed (spending a token).
|
|
pub fn pre_register_check(&mut self, account: &str, reply: &RegReply) -> Option<Vec<NetAction>> {
|
|
if self.db.external_accounts() {
|
|
return Some(reg_reply(reply, RegOutcome::External, account));
|
|
}
|
|
if self.db.registrations_frozen() || self.db.readonly() {
|
|
return Some(reg_reply(reply, RegOutcome::Frozen, account));
|
|
}
|
|
if self.db.exists(account) {
|
|
return Some(reg_reply(reply, RegOutcome::Exists, account));
|
|
}
|
|
if self.db.is_forbidden("NICK", account).is_some() {
|
|
return Some(reg_reply(reply, RegOutcome::Forbidden, account));
|
|
}
|
|
if !self.reg_limiter.allow() {
|
|
return Some(reg_reply(reply, RegOutcome::RateLimited, account));
|
|
}
|
|
None
|
|
}
|
|
|
|
// Commit credentials the link layer derived off-thread, then answer `reply`.
|
|
pub fn complete_register(&mut self, account: &str, creds: Option<db::Credentials>, email: Option<String>, reply: RegReply) -> Vec<NetAction> {
|
|
let Some(creds) = creds else {
|
|
return reg_reply(&reply, RegOutcome::Internal, account);
|
|
};
|
|
// A forbidden email pattern (OperServ FORBID EMAIL) blocks registration.
|
|
if let Some(addr) = &email {
|
|
if self.db.is_forbidden("EMAIL", addr).is_some() {
|
|
return reg_reply(&reply, RegOutcome::ForbiddenEmail, account);
|
|
}
|
|
}
|
|
let addr = email.clone();
|
|
let outcome = match self.db.register_prepared(account, creds, email) {
|
|
Ok(()) if self.db.is_verified(account) => RegOutcome::Ok,
|
|
Ok(()) => RegOutcome::VerifyRequired,
|
|
Err(RegError::Exists) => RegOutcome::Exists,
|
|
Err(RegError::Internal) => RegOutcome::Internal,
|
|
};
|
|
let needs_verify = matches!(outcome, RegOutcome::VerifyRequired);
|
|
let mut out = reg_reply(&reply, outcome, account);
|
|
self.track_accounts(&out);
|
|
// Unverified (email confirmation applies): email a code, on either the
|
|
// NickServ or the account-registration relay path. The relay reply
|
|
// already carries verification_required; NickServ users get a notice.
|
|
if needs_verify {
|
|
if let Some(addr) = addr {
|
|
let code = self.db.issue_code(account, db::CodeKind::Confirm);
|
|
let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), account, &code);
|
|
out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) });
|
|
if let RegReply::NickServ { agent, uid, .. } = &reply {
|
|
out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: "A confirmation code has been emailed to you. Confirm with \x02CONFIRM <code>\x02.".to_string() });
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
// Commit a password change the link layer derived off-thread, then notice the user.
|
|
pub fn complete_password_change(&mut self, account: &str, creds: Option<db::Credentials>, agent: &str, uid: &str) -> Vec<NetAction> {
|
|
let text = match creds.and_then(|c| self.db.set_credentials(account, c).ok()) {
|
|
Some(()) => format!("Your password for \x02{account}\x02 has been changed."),
|
|
None => "Sorry, that didn't work. Please try again in a moment.".to_string(),
|
|
};
|
|
vec![NetAction::Notice { from: agent.to_string(), to: uid.to_string(), text }]
|
|
}
|
|
|
|
/// Finish a deferred password verify once the off-thread `verify_plain` gave
|
|
/// `ok`. The cheap pre-checks (exists/suspended/lockout) already ran in the
|
|
/// caller; this is only the success/failure finish. IDENTIFY reuses the same
|
|
/// ctx helpers the inline path did, so its login side-effects (login, notice,
|
|
/// AJOIN, vhost, memo notice) stay identical.
|
|
pub fn complete_authenticate(&mut self, ok: bool, then: AuthThen) -> Vec<NetAction> {
|
|
match then {
|
|
AuthThen::Identify { uid, agent, name, account } => {
|
|
self.db.note_auth(&name, ok);
|
|
let mut ctx = ServiceCtx::default();
|
|
if !ok {
|
|
ctx.count("nickserv.identify_fail");
|
|
ctx.notice(&agent, &uid, "Invalid password. Please try again.");
|
|
} else {
|
|
ctx.login(&uid, &account);
|
|
ctx.count("nickserv.identify");
|
|
ctx.notice(&agent, &uid, format!("You're now identified as \x02{account}\x02. Welcome back!"));
|
|
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, format!("You have \x02{unread}\x02 new memo(s). Read them with \x02/msg MemoServ READ NEW\x02."));
|
|
}
|
|
}
|
|
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
|
|
}
|
|
AuthThen::Sasl { agent, client, account } => {
|
|
if ok {
|
|
self.sasl_login("SASL PLAIN", &agent, &client, account)
|
|
} else {
|
|
self.sasl_deny("SASL PLAIN", &agent, &client, Some(&account), "bad password")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|