//! Gate registration behind a human-verification step. An unverified user is handed //! a one-time, IP-bound HS256 JWT and a URL to solve a reCAPTCHA at; once solved they //! present the signed token back with `CAPTCHA ` and the connection is allowed. //! //! Modes: //! * JWT-only (default): a validly-signed, unexpired, IP-matching token is proof //! enough — no backend call, so it works standalone. //! * backend (`recaptcha_checkurl` set): additionally POST the token to a backend //! that confirms the captcha was actually solved (async, via `Server::spawn_http`). //! //! The token binds to the client IP (not the per-connection id) so a token earned //! in a browser survives the reconnect. Off unless `recaptcha = yes` and both //! `recaptcha_secret` and `recaptcha_url` are set. All config-driven; the only //! state (who has passed) lives in `Server.ext`. use std::collections::HashSet; use crate::command::{CmdResult, Command}; use crate::http::json_str; use crate::module::{ModResult, Module}; use crate::modules::jwt; use crate::server::{now, Server}; use crate::Uid; /// The set of uids that have passed verification this connection. In `Server.ext`. #[derive(Default)] struct Verified(HashSet); fn enabled(s: &Server) -> bool { s.conf_bool("recaptcha", false) && s.conf("recaptcha_secret").is_some_and(|v| !v.is_empty()) && s.conf("recaptcha_url").is_some_and(|v| !v.is_empty()) } fn is_verified(s: &Server, uid: Uid) -> bool { s.ext .get::() .map(|v| v.0.contains(&uid)) .unwrap_or(false) } /// Whether the user's source port is in `recaptcha_whitelistports` (skip captcha). fn port_whitelisted(s: &Server, uid: Uid) -> bool { let Some(port) = s.users.get(&uid).map(|u| u.addr.port()) else { return false; }; s.conf_all("recaptcha_whitelistports").iter().any(|line| { line.split([',', ' ']) .filter(|x| !x.is_empty()) .any(|p| p.parse::() == Ok(port)) }) } /// Build the IP-bound challenge token for `uid`. fn make_token(s: &Server, uid: Uid) -> Option { let secret = s.conf("recaptcha_secret")?; let issuer = s.conf("recaptcha_issuer").unwrap_or("echoIRCd"); let ttl = s.conf_num("recaptcha_ttl", 1800i64); let ip = s.users.get(&uid)?.addr.ip().to_string(); let n = now() as i64; let claims = format!( r#"{{"iss":"{issuer}","ip":"{ip}","iat":{n},"exp":{}}}"#, n + ttl ); jwt::sign_hs256(&claims, secret) } pub struct ReCaptcha; impl Module for ReCaptcha { fn name(&self) -> &'static str { "recaptcha" } fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult { if !enabled(srv) || srv.is_oper(uid) { return ModResult::Passthru; } if is_verified(srv, uid) || port_whitelisted(srv, uid) { return ModResult::Passthru; } // hand out a challenge and refuse the link until they verify let (nick, token) = ( srv.users .get(&uid) .map(|u| u.nick.clone()) .unwrap_or_default(), make_token(srv, uid), ); if let Some(token) = token { let base = srv.conf("recaptcha_url").unwrap_or(""); let sep = if base.contains('?') { '&' } else { '?' }; let link = format!("{base}{sep}token={token}"); let template = srv .conf("recaptcha_message") .unwrap_or("*** reCAPTCHA: verify your connection at {url}") .to_string(); let msg = template.replace("{url}", &link); srv.send(uid, format!(":{} NOTICE {nick} :{msg}", srv.name)); } ModResult::Deny } } pub fn commands() -> Vec> { vec![Box::new(Captcha)] } /// Mark `uid` verified (used by the sync path and the async backend callback). fn mark_verified(s: &mut Server, uid: Uid) { s.ext .get_or_insert_with::(Verified::default) .0 .insert(uid); let nick = s .users .get(&uid) .map(|u| u.nick.clone()) .unwrap_or_default(); s.send( uid, format!( ":{} NOTICE {nick} :*** reCAPTCHA: verification successful — you may continue.", s.name ), ); } /// CAPTCHA `` — present the signed verification token. Usable during the /// handshake (before registration completes). struct Captcha; impl Command for Captcha { fn name(&self) -> &'static str { "CAPTCHA" } fn min_params(&self) -> usize { 1 } fn before_reg(&self) -> bool { true } fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { if !enabled(s) { return CmdResult::Ok; } let token = params[0].clone(); let secret = s.conf("recaptcha_secret").unwrap_or("").to_string(); let issuer = s.conf("recaptcha_issuer").unwrap_or("echoIRCd").to_string(); let nick = s .users .get(&uid) .map(|u| u.nick.clone()) .unwrap_or_default(); let ip = s .users .get(&uid) .map(|u| u.addr.ip().to_string()) .unwrap_or_default(); let fail = |s: &mut Server, why: &str| { s.send( uid, format!(":{} NOTICE {nick} :*** reCAPTCHA: {why}", s.name), ); CmdResult::Fail }; let Some(claims) = jwt::verify_hs256(&token, &secret) else { return fail( s, "invalid or tampered token. Please reconnect and verify again.", ); }; if json_str(&claims, "iss").as_deref() != Some(issuer.as_str()) { return fail(s, "token issuer mismatch."); } if json_str(&claims, "ip").as_deref() != Some(ip.as_str()) { return fail(s, "token IP does not match. Reconnect and verify again."); } if jwt::claim_num(&claims, "exp").unwrap_or(0) <= now() as i64 { return fail(s, "token has expired. Please verify again."); } // JWT-only mode: a validly-signed, unexpired, IP-matching token is proof. mark_verified(s, uid); CmdResult::Ok } }