//! account_registration — IRCv3 `draft/account-registration` (`REGISTER` / `VERIFY` //! commands and the cap advertising them), bridged to a configurable HTTP accounts //! API. POSTs form-encoded fields with an `X-API-Key` header. //! //! The API call runs on a worker thread (`Server::spawn_http`) and its result comes //! back as `Event::HttpResult` → [`on_http_result`], so a slow endpoint never blocks //! the core. On success (and when `acctregister_autologin`) the user is logged into //! the new account. Per-IP rate-limit state lives in `Server.ext`. //! //! Config (all under flat keys): //! account_registration = yes enable //! acctregister_registerurl = POST username,email,password,client_ip,port //! acctregister_verifyurl = POST username,code //! acctregister_apikey = sent as X-API-Key //! acctregister_emailrequired = yes require a real email (advertise email-required) //! acctregister_beforeconnect = yes allow REGISTER before the handshake completes //! acctregister_autologin = yes log in on success //! acctregister_requiretls = yes refuse REGISTER on a plaintext link //! acctregister_ratecount = 3 max REGISTER attempts per IP … //! acctregister_ratetime = 3600 … per this many seconds use std::collections::HashMap; use crate::command::{CmdResult, Command}; use crate::http::{json_str, urlencode}; use crate::server::{now, Server}; use crate::Uid; /// Per-IP REGISTER attempt timestamps, for rate limiting. Stored in `Server.ext`. #[derive(Default)] struct RateState(HashMap>); fn enabled(s: &Server) -> bool { s.conf_bool("account_registration", false) && s.conf("acctregister_registerurl").is_some() } /// The value tokens advertised on the `draft/account-registration` cap (302). Empty /// when the module is disabled (so the cap is advertised bare / not at all). pub fn cap_tokens(s: &Server) -> String { if !enabled(s) { return String::new(); } let mut toks = vec!["custom-account-name"]; if s.conf_bool("acctregister_beforeconnect", true) { toks.push("before-connect"); } if s.conf_bool("acctregister_emailrequired", true) { toks.push("email-required"); } toks.join(",") } fn apikey_headers(s: &Server) -> Vec<(String, String)> { match s.conf("acctregister_apikey") { Some(k) if !k.is_empty() => vec![("X-API-Key".to_string(), k.to_string())], _ => Vec::new(), } } /// Rate-limit a REGISTER from `ip`; true when the attempt is within budget. fn rate_ok(s: &mut Server, ip: &str) -> bool { let count = s.conf_num("acctregister_ratecount", 3u32); let window = s.conf_num("acctregister_ratetime", 3600u64); if count == 0 { return true; } let n = now(); let st = s.ext.get_or_insert_with::(RateState::default); let hist = st.0.entry(ip.to_string()).or_default(); hist.retain(|&t| n.saturating_sub(t) < window); if hist.len() as u32 >= count { return false; } hist.push(n); true } pub fn commands() -> Vec> { vec![Box::new(Register), Box::new(Verify)] } /// REGISTER ` ` — `` may be `*` for the current /// nick. IRCv3 `draft/account-registration`. struct Register; impl Command for Register { fn name(&self) -> &'static str { "REGISTER" } fn min_params(&self) -> usize { 0 } fn before_reg(&self) -> bool { true // allow before-connect; the handler re-checks the config gate } fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { if !enabled(s) { s.fail( uid, "REGISTER", "TEMPORARILY_UNAVAILABLE", "Account registration is disabled.", ); return CmdResult::Fail; } if params.len() < 3 { s.fail( uid, "REGISTER", "INVALID_PARAMS", "Syntax: REGISTER ", ); return CmdResult::Fail; } let registered = s.users.get(&uid).map(|u| u.registered).unwrap_or(false); if !registered && !s.conf_bool("acctregister_beforeconnect", true) { s.fail( uid, "REGISTER", "COMPLETE_CONNECTION_REQUIRED", "Finish connecting before registering.", ); return CmdResult::Fail; } let (secure, nick, ip, port) = match s.users.get(&uid) { Some(u) => ( u.secure, u.nick.clone(), u.addr.ip().to_string(), u.addr.port(), ), None => return CmdResult::Fail, }; if s.conf_bool("acctregister_requiretls", true) && !secure { s.fail( uid, "REGISTER", "REG_UNAVAILABLE", "Registration requires a TLS connection.", ); return CmdResult::Fail; } let account = if params[0] == "*" { nick.clone() } else { params[0].clone() }; if account.is_empty() { s.fail( uid, "REGISTER", "ACCOUNT_NAME_MUST_BE_NICK", "Choose an account name (or set a nick first).", ); return CmdResult::Fail; } let email = params[1].clone(); if s.conf_bool("acctregister_emailrequired", true) && (email == "*" || email.is_empty()) { s.fail( uid, "REGISTER", "INVALID_EMAIL", "A valid email address is required.", ); return CmdResult::Fail; } if !rate_ok(s, &ip) { s.fail( uid, "REGISTER", "RATE_LIMITED", "Too many registration attempts. Please wait.", ); return CmdResult::Fail; } let url = s.conf("acctregister_registerurl").unwrap_or("").to_string(); let body = format!( "username={}&email={}&password={}&client_ip={}&port={}", urlencode(&account), urlencode(&email), urlencode(¶ms[2]), urlencode(&ip), port ); s.spawn_http( uid, format!("acctreg:register:{account}"), url, body, apikey_headers(s), ); CmdResult::Ok } } /// VERIFY ` ` — confirm a pending registration with an emailed code. struct Verify; impl Command for Verify { fn name(&self) -> &'static str { "VERIFY" } fn min_params(&self) -> usize { 0 } fn before_reg(&self) -> bool { true } fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { if !enabled(s) || s.conf("acctregister_verifyurl").is_none() { s.fail( uid, "VERIFY", "TEMPORARILY_UNAVAILABLE", "Account verification is disabled.", ); return CmdResult::Fail; } if params.len() < 2 { s.fail( uid, "VERIFY", "INVALID_PARAMS", "Syntax: VERIFY ", ); return CmdResult::Fail; } let account = params[0].clone(); let url = s.conf("acctregister_verifyurl").unwrap_or("").to_string(); let body = format!( "username={}&code={}", urlencode(&account), urlencode(¶ms[1]) ); s.spawn_http( uid, format!("acctreg:verify:{account}"), url, body, apikey_headers(s), ); CmdResult::Ok } } /// Truthy JSON field? Matches `"key":true` (whitespace-tolerant) in `body`. fn json_true(body: &str, key: &str) -> bool { let needle = format!("\"{key}\""); if let Some(pos) = body.find(&needle) { let after = &body[pos + needle.len()..]; if let Some(colon) = after.find(':') { return after[colon + 1..].trim_start().starts_with("true"); } } false } /// Called from the core when a REGISTER/VERIFY HTTP call finishes. `detail` is /// `"register:"` or `"verify:"`. pub fn on_http_result(s: &mut Server, uid: Uid, detail: &str, status: u16, body: &str) { let Some((kind, account)) = detail.split_once(':') else { return; }; if !s.users.contains_key(&uid) { return; // user vanished while the request was in flight } let verb = if kind == "verify" { "VERIFY" } else { "REGISTER" }; let msg = json_str(body, "message") .or_else(|| json_str(body, "error")) .unwrap_or_else(|| "Account service response.".to_string()); // transport failure if status == 0 { s.fail( uid, verb, "TEMPORARILY_UNAVAILABLE", "The account service is unreachable. Try again later.", ); return; } let ok = (200..300).contains(&status) && json_true(body, "success"); if !ok { let code = json_str(body, "code").unwrap_or_else(|| "REGISTRATION_FAILED".to_string()); s.send( uid, format!(":{} FAIL {verb} {code} {account} :{msg}", s.name), ); return; } let autologin = s.conf_bool("acctregister_autologin", true); if kind == "register" && json_true(body, "verification_required") { s.send( uid, format!( ":{} REGISTER VERIFICATION_REQUIRED {account} :{msg}", s.name ), ); return; } s.send(uid, format!(":{} {verb} SUCCESS {account} :{msg}", s.name)); if autologin { s.set_login(uid, account); } } #[cfg(test)] mod tests { use super::*; #[test] fn json_true_detects_boolean() { assert!(json_true(r#"{"success": true}"#, "success")); assert!(json_true(r#"{"success":true,"x":1}"#, "success")); assert!(!json_true(r#"{"success": false}"#, "success")); assert!(!json_true(r#"{"other": true}"#, "success")); } }