modules: port account_registration (REGISTER/VERIFY over async HTTP API) + async http infra
This commit is contained in:
parent
c71c28457f
commit
b0d767a809
6 changed files with 399 additions and 7 deletions
326
src/modules/account_registration.rs
Normal file
326
src/modules/account_registration.rs
Normal 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(¶ms[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(¶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:<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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue