Track login state and nick changes in NickServ
This commit is contained in:
parent
60fc687162
commit
65729a62b8
6 changed files with 150 additions and 5 deletions
|
|
@ -127,12 +127,16 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle(&mut self, event: NetEvent) -> Vec<NetAction> {
|
pub fn handle(&mut self, event: NetEvent) -> Vec<NetAction> {
|
||||||
match event {
|
let out = match event {
|
||||||
NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }],
|
NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }],
|
||||||
NetEvent::UserConnect { uid, nick } => {
|
NetEvent::UserConnect { uid, nick } => {
|
||||||
self.network.user_connect(uid, nick);
|
self.network.user_connect(uid, nick);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
NetEvent::NickChange { uid, nick } => {
|
||||||
|
self.network.user_nick_change(&uid, nick);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
NetEvent::Quit { uid } => {
|
NetEvent::Quit { uid } => {
|
||||||
self.network.user_quit(&uid);
|
self.network.user_quit(&uid);
|
||||||
self.sasl_sessions.remove(&uid); // drop any half-finished exchange
|
self.sasl_sessions.remove(&uid); // drop any half-finished exchange
|
||||||
|
|
@ -144,6 +148,25 @@ impl Engine {
|
||||||
}
|
}
|
||||||
NetEvent::Sasl { client, mode, data, .. } => self.sasl(client, mode, data),
|
NetEvent::Sasl { client, mode, data, .. } => self.sasl(client, mode, data),
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
|
};
|
||||||
|
self.track_accounts(&out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep per-user login state in step with the accountname metadata we send:
|
||||||
|
// a non-empty value (SASL/IDENTIFY/REGISTER) logs the uid in, an empty one
|
||||||
|
// (LOGOUT) logs it out. Server-global metadata ("*") is not a login.
|
||||||
|
fn track_accounts(&mut self, actions: &[NetAction]) {
|
||||||
|
for action in actions {
|
||||||
|
if let NetAction::Metadata { target, key, value } = action {
|
||||||
|
if key == "accountname" && target != "*" {
|
||||||
|
if value.is_empty() {
|
||||||
|
self.network.clear_account(target);
|
||||||
|
} else {
|
||||||
|
self.network.set_account(target, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -326,15 +349,18 @@ impl Engine {
|
||||||
Err(RegError::Exists) => RegOutcome::Exists,
|
Err(RegError::Exists) => RegOutcome::Exists,
|
||||||
Err(RegError::Internal) => RegOutcome::Internal,
|
Err(RegError::Internal) => RegOutcome::Internal,
|
||||||
};
|
};
|
||||||
reg_reply(&reply, outcome, account)
|
let out = reg_reply(&reply, outcome, account);
|
||||||
|
self.track_accounts(&out);
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route a PRIVMSG addressed to a service (by uid or nick) into that service,
|
// Route a PRIVMSG addressed to a service (by uid or nick) into that service,
|
||||||
// handing it the sender's resolved nick and the account store.
|
// handing it the sender's resolved nick, login state, and the account store.
|
||||||
fn dispatch(&mut self, from: &str, to: &str, text: &str) -> Vec<NetAction> {
|
fn dispatch(&mut self, from: &str, to: &str, text: &str) -> Vec<NetAction> {
|
||||||
let nick = self.network.nick_of(from).unwrap_or(from).to_string();
|
let nick = self.network.nick_of(from).unwrap_or(from).to_string();
|
||||||
|
let account = self.network.account_of(from).map(str::to_string);
|
||||||
let mut ctx = ServiceCtx::default();
|
let mut ctx = ServiceCtx::default();
|
||||||
let sender = Sender { uid: from, nick: &nick };
|
let sender = Sender { uid: from, nick: &nick, account: account.as_deref() };
|
||||||
let Self { services, db, .. } = self;
|
let Self { services, db, .. } = self;
|
||||||
for svc in services.iter_mut() {
|
for svc in services.iter_mut() {
|
||||||
if to.eq_ignore_ascii_case(svc.uid()) || to.eq_ignore_ascii_case(svc.nick()) {
|
if to.eq_ignore_ascii_case(svc.uid()) || to.eq_ignore_ascii_case(svc.nick()) {
|
||||||
|
|
@ -744,4 +770,80 @@ mod tests {
|
||||||
assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, nick } if uid == "000AAAAAB" && nick == "Guest12345")), "logout renames to guest nick: {out:?}");
|
assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, nick } if uid == "000AAAAAB" && nick == "Guest12345")), "logout renames to guest nick: {out:?}");
|
||||||
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("logged out"))), "{out:?}");
|
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("logged out"))), "{out:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn logout(e: &mut Engine, uid: &str) -> Vec<NetAction> {
|
||||||
|
e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: "LOGOUT".into() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOGOUT while not identified must not rename you or clear anything.
|
||||||
|
#[test]
|
||||||
|
fn logout_without_login_is_noop() {
|
||||||
|
let mut e = engine_with("nologin", "foo", "sesame");
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "someone".into() });
|
||||||
|
let out = logout(&mut e, "000AAAAAB");
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("not logged in"))), "{out:?}");
|
||||||
|
assert!(!out.iter().any(|a| matches!(a, NetAction::ForceNick { .. })), "must not rename when not logged in: {out:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: a second LOGOUT is a no-op, not another guest rename (the churn
|
||||||
|
// where Guest33294 -> LOGOUT -> Guest33295).
|
||||||
|
#[test]
|
||||||
|
fn logout_twice_renames_only_once() {
|
||||||
|
let mut e = engine_with("twice", "foo", "sesame");
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() });
|
||||||
|
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||||||
|
|
||||||
|
let first = logout(&mut e, "000AAAAAB");
|
||||||
|
assert!(first.iter().any(|a| matches!(a, NetAction::ForceNick { .. })), "first logout renames: {first:?}");
|
||||||
|
|
||||||
|
let second = logout(&mut e, "000AAAAAB");
|
||||||
|
assert!(second.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("not logged in"))), "{second:?}");
|
||||||
|
assert!(!second.iter().any(|a| matches!(a, NetAction::ForceNick { .. })), "second logout must not rename again: {second:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A SASL login (before the user is even bursted) is remembered, so a later
|
||||||
|
// LOGOUT from that uid is recognised as logged in.
|
||||||
|
#[test]
|
||||||
|
fn sasl_login_is_tracked_for_logout() {
|
||||||
|
let mut e = engine_with("sasllogout", "foo", "sesame");
|
||||||
|
sasl(&mut e, "S", "PLAIN");
|
||||||
|
let ok = sasl(&mut e, "C", &plain(b"", b"foo", b"sesame"));
|
||||||
|
assert!(is_success(&ok), "{ok:?}");
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() });
|
||||||
|
|
||||||
|
let out = logout(&mut e, "000AAAAAB");
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { .. })), "sasl-authed user can log out: {out:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: repeated IDENTIFY while already identified must not re-fire the
|
||||||
|
// login (the 900 loop when the command is spammed).
|
||||||
|
#[test]
|
||||||
|
fn identify_twice_does_not_relogin() {
|
||||||
|
let mut e = engine_with("reident", "foo", "sesame");
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() });
|
||||||
|
let ident = |e: &mut Engine| e.handle(NetEvent::Privmsg {
|
||||||
|
from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let first = ident(&mut e);
|
||||||
|
assert!(first.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "foo")), "first identify logs in: {first:?}");
|
||||||
|
|
||||||
|
let second = ident(&mut e);
|
||||||
|
assert!(!second.iter().any(|a| matches!(a, NetAction::Metadata { key, .. } if key == "accountname")), "second identify must not re-login: {second:?}");
|
||||||
|
assert!(second.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("already identified"))), "{second:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: after a nick change (e.g. a guest rename, then back to your
|
||||||
|
// real nick), IDENTIFY must authenticate the CURRENT nick, not the one held
|
||||||
|
// at burst time — otherwise you log into the wrong account.
|
||||||
|
#[test]
|
||||||
|
fn identify_uses_current_nick_after_rename() {
|
||||||
|
let mut e = engine_with("renameident", "realnick", "sesame");
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "Guest99999".into() });
|
||||||
|
e.handle(NetEvent::NickChange { uid: "000AAAAAB".into(), nick: "realnick".into() });
|
||||||
|
let out = e.handle(NetEvent::Privmsg {
|
||||||
|
from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into(),
|
||||||
|
});
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "realnick")), "must log into the current nick's account: {out:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
use crate::engine::db::Db;
|
use crate::engine::db::Db;
|
||||||
use crate::proto::{NetAction, RegReply};
|
use crate::proto::{NetAction, RegReply};
|
||||||
|
|
||||||
// Who sent the command, resolved by the engine (UID + current nick).
|
// Who sent the command, resolved by the engine (UID + current nick + the
|
||||||
|
// account they are identified to, if any).
|
||||||
pub struct Sender<'a> {
|
pub struct Sender<'a> {
|
||||||
pub uid: &'a str,
|
pub uid: &'a str,
|
||||||
pub nick: &'a str,
|
pub nick: &'a str,
|
||||||
|
pub account: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// A pseudo-client (NickServ, ChanServ, ...). Introduced at burst, receives the
|
// A pseudo-client (NickServ, ChanServ, ...). Introduced at burst, receives the
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use std::collections::HashMap;
|
||||||
pub struct Network {
|
pub struct Network {
|
||||||
pub users: HashMap<String, User>, // keyed by UID
|
pub users: HashMap<String, User>, // keyed by UID
|
||||||
pub channels: HashMap<String, Channel>,
|
pub channels: HashMap<String, Channel>,
|
||||||
|
accounts: HashMap<String, String>, // UID -> logged-in account, until logout/quit
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct User {
|
pub struct User {
|
||||||
|
|
@ -24,11 +25,32 @@ impl Network {
|
||||||
self.users.insert(uid.clone(), User { uid, nick });
|
self.users.insert(uid.clone(), User { uid, nick });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn user_nick_change(&mut self, uid: &str, nick: String) {
|
||||||
|
if let Some(user) = self.users.get_mut(uid) {
|
||||||
|
user.nick = nick;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn user_quit(&mut self, uid: &str) {
|
pub fn user_quit(&mut self, uid: &str) {
|
||||||
self.users.remove(uid);
|
self.users.remove(uid);
|
||||||
|
self.accounts.remove(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn nick_of(&self, uid: &str) -> Option<&str> {
|
pub fn nick_of(&self, uid: &str) -> Option<&str> {
|
||||||
self.users.get(uid).map(|u| u.nick.as_str())
|
self.users.get(uid).map(|u| u.nick.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A user's currently identified account, if any. Kept in step with the
|
||||||
|
// accountname metadata the engine emits (login sets it, logout clears it).
|
||||||
|
pub fn account_of(&self, uid: &str) -> Option<&str> {
|
||||||
|
self.accounts.get(uid).map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_account(&mut self, uid: &str, account: &str) {
|
||||||
|
self.accounts.insert(uid.to_string(), account.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_account(&mut self, uid: &str) {
|
||||||
|
self.accounts.remove(uid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,15 @@ impl Protocol for InspIrcd {
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// :<uid> NICK <newnick> <newts> — keep the sender's current nick
|
||||||
|
// fresh, so nick-based commands (IDENTIFY/REGISTER) act on who they
|
||||||
|
// are now, not their nick at burst time (e.g. after a guest rename).
|
||||||
|
"NICK" => match (source, tokens.next()) {
|
||||||
|
(Some(uid), Some(nick)) if !nick.is_empty() => {
|
||||||
|
vec![NetEvent::NickChange { uid, nick: nick.to_string() }]
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
},
|
||||||
"QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }],
|
"QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }],
|
||||||
// account-registration relay from an ircd:
|
// account-registration relay from an ircd:
|
||||||
// ACCTREGISTER <reqid> <origin> <kind> <account> <p2> :<p3>
|
// ACCTREGISTER <reqid> <origin> <kind> <account> <p2> :<p3>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ pub enum NetEvent {
|
||||||
Ping { token: String, from: Option<String> },
|
Ping { token: String, from: Option<String> },
|
||||||
Privmsg { from: String, to: String, text: String },
|
Privmsg { from: String, to: String, text: String },
|
||||||
UserConnect { uid: String, nick: String },
|
UserConnect { uid: String, nick: String },
|
||||||
|
NickChange { uid: String, nick: String },
|
||||||
Quit { uid: String },
|
Quit { uid: String },
|
||||||
// An ircd relaying an IRCv3 account-registration request to us as the authority.
|
// An ircd relaying an IRCv3 account-registration request to us as the authority.
|
||||||
AccountRequest { reqid: String, origin: String, kind: String, account: String, p2: String, p3: String },
|
AccountRequest { reqid: String, origin: String, kind: String, account: String, p2: String, p3: String },
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,11 @@ impl Service for NickServ {
|
||||||
};
|
};
|
||||||
match db.authenticate(from.nick, password) {
|
match db.authenticate(from.nick, password) {
|
||||||
Some(account) => {
|
Some(account) => {
|
||||||
|
// Already identified to this account: don't re-fire the login.
|
||||||
|
if from.account == Some(account) {
|
||||||
|
ctx.notice(me, from.uid, format!("You are already identified for \x02{}\x02.", account));
|
||||||
|
return;
|
||||||
|
}
|
||||||
let account = account.to_string();
|
let account = account.to_string();
|
||||||
ctx.login(from.uid, &account);
|
ctx.login(from.uid, &account);
|
||||||
ctx.notice(me, from.uid, format!("You are now identified for \x02{}\x02.", account));
|
ctx.notice(me, from.uid, format!("You are now identified for \x02{}\x02.", account));
|
||||||
|
|
@ -53,6 +58,10 @@ impl Service for NickServ {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("LOGOUT") | Some("LOGOFF") => {
|
Some("LOGOUT") | Some("LOGOFF") => {
|
||||||
|
if from.account.is_none() {
|
||||||
|
ctx.notice(me, from.uid, "You are not logged in.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Guest nick = prefix + sequence (seeded from the link TS, bumped
|
// Guest nick = prefix + sequence (seeded from the link TS, bumped
|
||||||
// per logout). Inlined field access so it stays disjoint from `me`.
|
// per logout). Inlined field access so it stays disjoint from `me`.
|
||||||
let guest = format!("{}{}", self.guest_nick, self.guest_seq);
|
let guest = format!("{}{}", self.guest_nick, self.guest_seq);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue