Add opt-in external-account mode (delegate identity to the website)
By default fedserv owns accounts itself — nothing changes, no external
service required. Set [auth] external = true and an outside authority (the
website) owns identity instead: NickServ REGISTER / DROP / SET
PASSWORD|EMAIL / RESETPASS / CONFIRM / CERT / GROUP and the IRCv3
registration relay are all refused ("managed on the website"), so IRC
can't mint or change a second identity. Login is unchanged — fedserv still
authenticates locally against the accounts the authority pushes in via the
existing gRPC Accounts API, so lookups stay in-memory fast.
fedserv keeps owning all IRC-domain data (channels, access, vhosts, bans,
memos) keyed by the account name in both modes. One config bool, off by
default, so standalone deployments are unaffected.
This commit is contained in:
parent
4c899d80b0
commit
fba91b4d62
8 changed files with 108 additions and 3 deletions
|
|
@ -776,6 +776,9 @@ pub trait Store {
|
||||||
fn set_defcon(&mut self, level: u8);
|
fn set_defcon(&mut self, level: u8);
|
||||||
fn registrations_frozen(&self) -> bool;
|
fn registrations_frozen(&self) -> bool;
|
||||||
fn channel_regs_frozen(&self) -> bool;
|
fn channel_regs_frozen(&self) -> bool;
|
||||||
|
// Whether account identity is owned externally (website); when true, IRC
|
||||||
|
// can't register or change credentials — it only authenticates.
|
||||||
|
fn external_accounts(&self) -> bool;
|
||||||
// Staff notes on accounts/channels (oper-only), shown in INFO to operators.
|
// Staff notes on accounts/channels (oper-only), shown in INFO to operators.
|
||||||
fn set_account_note(&mut self, account: &str, note: Option<String>) -> bool;
|
fn set_account_note(&mut self, account: &str, note: Option<String>) -> bool;
|
||||||
fn account_note(&self, account: &str) -> Option<String>;
|
fn account_note(&self, account: &str) -> Option<String>;
|
||||||
|
|
|
||||||
|
|
@ -74,3 +74,13 @@ protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205
|
||||||
# per IP-mask (an exception limit of 0 means unlimited). 0 or omitted = off.
|
# per IP-mask (an exception limit of 0 means unlimited). 0 or omitted = off.
|
||||||
# [session]
|
# [session]
|
||||||
# default_limit = 3
|
# default_limit = 3
|
||||||
|
|
||||||
|
# Account authority. Omit this section (the default) and fedserv owns accounts
|
||||||
|
# itself: NickServ REGISTER / IDENTIFY / SET PASSWORD all work standalone, no
|
||||||
|
# external service needed. Set external = true to hand identity to an outside
|
||||||
|
# authority (e.g. your website): IRC can then only IDENTIFY — REGISTER, DROP,
|
||||||
|
# SET PASSWORD/EMAIL, RESETPASS, CONFIRM, CERT and GROUP are refused, and the
|
||||||
|
# authority pushes accounts in via the gRPC Accounts API (see [grpc]). fedserv
|
||||||
|
# still owns all channel/vhost/ban data, keyed by the account name.
|
||||||
|
# [auth]
|
||||||
|
# external = true
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,14 @@ impl Service for NickServ {
|
||||||
|
|
||||||
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
|
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, db: &mut dyn Store) {
|
||||||
let me = self.uid.as_str();
|
let me = self.uid.as_str();
|
||||||
match args.first().map(|s| s.to_ascii_uppercase()).as_deref() {
|
let cmd = args.first().map(|s| s.to_ascii_uppercase());
|
||||||
|
// When identity is owned externally (the website), IRC can log in but not
|
||||||
|
// create or change an account — those commands are refused.
|
||||||
|
if db.external_accounts() && matches!(cmd.as_deref(), Some("REGISTER" | "DROP" | "RESETPASS" | "CONFIRM" | "CERT" | "GROUP" | "UNGROUP")) {
|
||||||
|
ctx.notice(me, from.uid, "Your account is managed on the website — register or change it there. From IRC you can only \x02IDENTIFY\x02.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match cmd.as_deref() {
|
||||||
Some("REGISTER") => register::handle(me, from, args, ctx),
|
Some("REGISTER") => register::handle(me, from, args, ctx),
|
||||||
Some("IDENTIFY") | Some("ID") => identify::handle(me, from, args, ctx, db),
|
Some("IDENTIFY") | Some("ID") => identify::handle(me, from, args, ctx, db),
|
||||||
Some("LOGOUT") | Some("LOGOFF") => logout::handle(me, &self.guest_nick, &mut self.guest_seq, from, ctx),
|
Some("LOGOUT") | Some("LOGOFF") => logout::handle(me, &self.guest_nick, &mut self.guest_seq, from, ctx),
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,13 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
|
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
|
let sub = args.get(1).map(|s| s.to_ascii_uppercase());
|
||||||
|
// Credential/identity fields are owned by the website in external mode.
|
||||||
|
if db.external_accounts() && matches!(sub.as_deref(), Some("PASSWORD" | "PASS" | "EMAIL")) {
|
||||||
|
ctx.notice(me, from.uid, "That's managed on the website — change it there.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match sub.as_deref() {
|
||||||
Some("PASSWORD") | Some("PASS") => {
|
Some("PASSWORD") | Some("PASS") => {
|
||||||
let Some(&password) = args.get(2) else {
|
let Some(&password) = args.get(2) else {
|
||||||
ctx.notice(me, from.uid, "Syntax: SET PASSWORD <newpassword>");
|
ctx.notice(me, from.uid, "Syntax: SET PASSWORD <newpassword>");
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,18 @@ pub struct Config {
|
||||||
// Per-IP session limiting. Absent = unlimited.
|
// Per-IP session limiting. Absent = unlimited.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub session: Option<Session>,
|
pub session: Option<Session>,
|
||||||
|
// Account authority. Absent = built-in (fedserv owns accounts). With
|
||||||
|
// `external = true`, an outside authority (e.g. the website) owns identity
|
||||||
|
// and pushes accounts in; IRC can only authenticate.
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth: Option<Auth>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account-authority configuration.
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct Auth {
|
||||||
|
#[serde(default)]
|
||||||
|
pub external: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session limiting: the default connections allowed per IP (0/absent = off),
|
// Session limiting: the default connections allowed per IP (0/absent = off),
|
||||||
|
|
|
||||||
|
|
@ -985,6 +985,10 @@ pub struct Db {
|
||||||
// Network defence level (OperServ DEFCON), 5 = normal down to 1 = lockdown.
|
// Network defence level (OperServ DEFCON), 5 = normal down to 1 = lockdown.
|
||||||
// Node-local; the derived restrictions are read by the engine and services.
|
// Node-local; the derived restrictions are read by the engine and services.
|
||||||
defcon: u8,
|
defcon: u8,
|
||||||
|
// When set, account identity is owned by an external authority (e.g. the
|
||||||
|
// website): IRC can't register or change credentials, only authenticate
|
||||||
|
// against accounts pushed in (via the gRPC Accounts API). Node-local config.
|
||||||
|
external_accounts: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A juped server: the held name, the fake server id we allocated for it, and why.
|
// A juped server: the held name, the fake server id we allocated for it, and why.
|
||||||
|
|
@ -1048,7 +1052,7 @@ impl Db {
|
||||||
apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event);
|
apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event);
|
||||||
}
|
}
|
||||||
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
|
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
|
||||||
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), jupes: Vec::new(), jupe_seq: 0, defcon: 5 }
|
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), jupes: Vec::new(), jupe_seq: 0, defcon: 5, external_accounts: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fold an entry authored by another node into the store — the services-side
|
/// Fold an entry authored by another node into the store — the services-side
|
||||||
|
|
@ -2053,6 +2057,16 @@ impl Db {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether account identity is owned by an external authority.
|
||||||
|
pub fn external_accounts(&self) -> bool {
|
||||||
|
self.external_accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set external-account mode (from config, at startup).
|
||||||
|
pub fn set_external_accounts(&mut self, on: bool) {
|
||||||
|
self.external_accounts = on;
|
||||||
|
}
|
||||||
|
|
||||||
/// The network defence level (5 = normal, 1 = full lockdown).
|
/// The network defence level (5 = normal, 1 = full lockdown).
|
||||||
pub fn defcon(&self) -> u8 {
|
pub fn defcon(&self) -> u8 {
|
||||||
self.defcon
|
self.defcon
|
||||||
|
|
@ -3359,6 +3373,9 @@ impl Store for Db {
|
||||||
fn channel_regs_frozen(&self) -> bool {
|
fn channel_regs_frozen(&self) -> bool {
|
||||||
Db::channel_regs_frozen(self)
|
Db::channel_regs_frozen(self)
|
||||||
}
|
}
|
||||||
|
fn external_accounts(&self) -> bool {
|
||||||
|
Db::external_accounts(self)
|
||||||
|
}
|
||||||
fn set_account_note(&mut self, account: &str, note: Option<String>) -> bool {
|
fn set_account_note(&mut self, account: &str, note: Option<String>) -> bool {
|
||||||
Db::set_account_note(self, account, note)
|
Db::set_account_note(self, account, note)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1147,6 +1147,9 @@ impl Engine {
|
||||||
// outright, and rate-limit the rest so a REGISTER flood can't pin CPU. Returns
|
// 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).
|
// 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>> {
|
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() {
|
if self.db.registrations_frozen() {
|
||||||
return Some(reg_reply(reply, RegOutcome::Frozen, account));
|
return Some(reg_reply(reply, RegOutcome::Frozen, account));
|
||||||
}
|
}
|
||||||
|
|
@ -1779,6 +1782,7 @@ enum RegOutcome {
|
||||||
Exists,
|
Exists,
|
||||||
RateLimited,
|
RateLimited,
|
||||||
Frozen,
|
Frozen,
|
||||||
|
External,
|
||||||
Internal,
|
Internal,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1792,6 +1796,7 @@ fn reg_reply(reply: &RegReply, outcome: RegOutcome, account: &str) -> Vec<NetAct
|
||||||
RegOutcome::Exists => ("error", "ACCOUNT_EXISTS", "That account name is already registered."),
|
RegOutcome::Exists => ("error", "ACCOUNT_EXISTS", "That account name is already registered."),
|
||||||
RegOutcome::RateLimited => ("error", "TEMPORARILY_UNAVAILABLE", "Too many registrations, please wait a moment."),
|
RegOutcome::RateLimited => ("error", "TEMPORARILY_UNAVAILABLE", "Too many registrations, please wait a moment."),
|
||||||
RegOutcome::Frozen => ("error", "TEMPORARILY_UNAVAILABLE", "Registrations are temporarily frozen by network staff."),
|
RegOutcome::Frozen => ("error", "TEMPORARILY_UNAVAILABLE", "Registrations are temporarily frozen by network staff."),
|
||||||
|
RegOutcome::External => ("error", "ACCOUNT_REGISTRATION_DISABLED", "Accounts are managed on the website; register there."),
|
||||||
RegOutcome::Internal => ("error", "TEMPORARILY_UNAVAILABLE", "Registration is unavailable, try again later."),
|
RegOutcome::Internal => ("error", "TEMPORARILY_UNAVAILABLE", "Registration is unavailable, try again later."),
|
||||||
};
|
};
|
||||||
vec![NetAction::AccountResponse {
|
vec![NetAction::AccountResponse {
|
||||||
|
|
@ -1814,6 +1819,7 @@ fn reg_reply(reply: &RegReply, outcome: RegOutcome, account: &str) -> Vec<NetAct
|
||||||
RegOutcome::Exists => vec![notice(format!("\x02{nick}\x02 is already registered. If it's yours, use \x02IDENTIFY <password>\x02."))],
|
RegOutcome::Exists => vec![notice(format!("\x02{nick}\x02 is already registered. If it's yours, use \x02IDENTIFY <password>\x02."))],
|
||||||
RegOutcome::RateLimited => vec![notice("Registrations are busy right now. Please try again in a moment.".to_string())],
|
RegOutcome::RateLimited => vec![notice("Registrations are busy right now. Please try again in a moment.".to_string())],
|
||||||
RegOutcome::Frozen => vec![notice("Registrations are temporarily frozen by network staff. Please try again later.".to_string())],
|
RegOutcome::Frozen => vec![notice("Registrations are temporarily frozen by network staff. Please try again later.".to_string())],
|
||||||
|
RegOutcome::External => vec![notice("Accounts are managed on the website — register there.".to_string())],
|
||||||
RegOutcome::Internal => vec![notice("Sorry, that didn't work. Please try again in a moment.".to_string())],
|
RegOutcome::Internal => vec![notice("Sorry, that didn't work. Please try again in a moment.".to_string())],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4313,6 +4319,49 @@ mod tests {
|
||||||
assert!(!e.startup_actions().iter().any(|a| matches!(a, NetAction::JupeServer { .. })), "gone after DEL");
|
assert!(!e.startup_actions().iter().any(|a| matches!(a, NetAction::JupeServer { .. })), "gone after DEL");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// External-account mode: IRC can log in against pushed-in accounts but can't
|
||||||
|
// register or change identity; channels (fedserv's own domain) still work.
|
||||||
|
#[test]
|
||||||
|
fn external_accounts_delegate_identity() {
|
||||||
|
use fedserv_chanserv::ChanServ;
|
||||||
|
let path = std::env::temp_dir().join("fedserv-external.jsonl");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut db = Db::open(&path, "42S");
|
||||||
|
db.scram_iterations = 4096;
|
||||||
|
// Simulates an account pushed in by the external authority (the website).
|
||||||
|
db.register("alice", "password1", None).unwrap();
|
||||||
|
db.set_external_accounts(true);
|
||||||
|
let mut e = Engine::new(
|
||||||
|
vec![
|
||||||
|
Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }),
|
||||||
|
Box::new(ChanServ { uid: "42SAAAAAB".into() }),
|
||||||
|
],
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
e.set_sid("42S".into());
|
||||||
|
let ns = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: t.into() });
|
||||||
|
let cs = |e: &mut Engine, uid: &str, t: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAB".into(), text: t.into() });
|
||||||
|
let has = |out: &[NetAction], n: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(n)));
|
||||||
|
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() });
|
||||||
|
|
||||||
|
// Login against the pushed-in account still works.
|
||||||
|
let out = ns(&mut e, "000AAAAAB", "IDENTIFY password1");
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "alice")), "identify works: {out:?}");
|
||||||
|
|
||||||
|
// But creating or changing identity from IRC is refused.
|
||||||
|
assert!(has(&ns(&mut e, "000AAAAAB", "REGISTER newpass a@b.c"), "website"), "register refused");
|
||||||
|
assert!(has(&ns(&mut e, "000AAAAAB", "SET PASSWORD hunter2"), "website"), "set password refused");
|
||||||
|
assert!(has(&ns(&mut e, "000AAAAAB", "DROP password1"), "website"), "drop refused");
|
||||||
|
// The IRCv3 registration relay is refused too.
|
||||||
|
let reply = crate::proto::RegReply::NickServ { agent: "42SAAAAAA".into(), uid: "000AAAAAB".into(), nick: "bob".into() };
|
||||||
|
assert!(e.pre_register_check("bob", &reply).is_some_and(|r| r.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("website")))), "relay refused");
|
||||||
|
|
||||||
|
// Channels are fedserv's own domain — still registerable.
|
||||||
|
e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#room".into(), op: true });
|
||||||
|
assert!(has(&cs(&mut e, "000AAAAAB", "REGISTER #room"), "now registered"), "channels still work");
|
||||||
|
}
|
||||||
|
|
||||||
// OperServ DEFCON: each level tightens the network — announce on change, freeze
|
// OperServ DEFCON: each level tightens the network — announce on change, freeze
|
||||||
// channel then all registrations, cap sessions, and lock out new connections.
|
// channel then all registrations, cap sessions, and lock out new connections.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ async fn main() -> Result<()> {
|
||||||
db.scram_iterations = cfg.server.scram_iterations;
|
db.scram_iterations = cfg.server.scram_iterations;
|
||||||
db.set_outbound(gossip_tx.clone());
|
db.set_outbound(gossip_tx.clone());
|
||||||
db.set_email_enabled(cfg.email.is_some());
|
db.set_email_enabled(cfg.email.is_some());
|
||||||
|
db.set_external_accounts(cfg.auth.as_ref().is_some_and(|a| a.external));
|
||||||
if let Some(email) = &cfg.email {
|
if let Some(email) = &cfg.email {
|
||||||
db.set_email_brand(&email.brand);
|
db.set_email_brand(&email.brand);
|
||||||
db.set_email_accent(&email.accent);
|
db.set_email_accent(&email.accent);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue