modules: port account_registration (REGISTER/VERIFY over async HTTP API) + async http infra

This commit is contained in:
Jean Chevronnet 2026-08-09 11:48:17 +00:00
parent c71c28457f
commit b0d767a809
6 changed files with 399 additions and 7 deletions

View file

@ -0,0 +1,326 @@
//! account_registration — IRCv3 `draft/account-registration` (the `REGISTER` /
//! `VERIFY` commands and the cap that advertises them), bridged to a configurable
//! HTTP accounts API. reverse's own module: the old Swaygo Django backend is gone,
//! so this talks to whatever `acctregister_registerurl` / `_verifyurl` you point it
//! at, POSTing 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. Everything is config-driven; the only per-IP state (rate limit)
//! lives in `Server.ext`.
//!
//! Config (all under flat keys):
//! account_registration = yes enable
//! acctregister_registerurl = <url> POST username,email,password,client_ip,port
//! acctregister_verifyurl = <url> POST username,code
//! acctregister_apikey = <key> 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<String, Vec<u64>>);
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>(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<Box<dyn Command>> {
vec![Box::new(Register), Box::new(Verify)]
}
/// REGISTER `<account> <email> <password>` — `<account>` 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 <account|*> <email> <password>",
);
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(&params[2]),
urlencode(&ip),
port
);
s.spawn_http(
uid,
format!("acctreg:register:{account}"),
url,
body,
apikey_headers(s),
);
CmdResult::Ok
}
}
/// VERIFY `<account> <code>` — 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 <account> <code>",
);
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(&params[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:<account>"` or `"verify:<account>"`.
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"));
}
}

View file

@ -3,6 +3,7 @@
//! is the exception — it's driven straight from the connection lifecycle rather
//! than the hook bus, but lives here as its own self-contained unit.
pub mod account_registration;
pub mod antimixedutf8;
pub mod antirandom;
pub mod blockamsg;
@ -71,5 +72,6 @@ pub fn module_commands() -> Vec<Box<dyn Command>> {
.chain(reputation::commands())
.chain(securitygroups::commands())
.chain(password_hash::commands())
.chain(account_registration::commands())
.collect()
}