nickserv: add SET (password/email) and DROP
SET PASSWORD changes your password (derived off-thread like register) and SET EMAIL sets an address. DROP deletes your account, releases and drops the channels you founded, and logs you out. A dropped account federates, and each node logs out any local session that was relying on it.
This commit is contained in:
parent
0427819f86
commit
ef89f97158
9 changed files with 249 additions and 23 deletions
33
modules/nickserv/drop.rs
Normal file
33
modules/nickserv/drop.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
use crate::engine::db::Db;
|
||||||
|
use crate::engine::service::{Sender, ServiceCtx};
|
||||||
|
use crate::engine::state::Network;
|
||||||
|
|
||||||
|
// DROP <password>: delete your account. Re-authenticates as confirmation, releases
|
||||||
|
// and drops the channels you found, and logs you out.
|
||||||
|
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &mut Db) {
|
||||||
|
let Some(account) = from.account else {
|
||||||
|
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(&password) = args.get(1) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: DROP <password>");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if db.authenticate(account, password).is_none() {
|
||||||
|
ctx.notice(me, from.uid, "Invalid password.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let channels = db.channels_owned_by(account);
|
||||||
|
for chan in &channels {
|
||||||
|
let _ = db.drop_channel(chan);
|
||||||
|
ctx.channel_mode("", chan, "-r"); // server-sourced: release the registered mode
|
||||||
|
}
|
||||||
|
let _ = db.drop_account(account);
|
||||||
|
for uid in net.uids_logged_into(account) {
|
||||||
|
ctx.logout(&uid);
|
||||||
|
}
|
||||||
|
ctx.notice(me, from.uid, format!("Your account \x02{account}\x02 has been dropped."));
|
||||||
|
if !channels.is_empty() {
|
||||||
|
ctx.notice(me, from.uid, format!("Channels released: {}.", channels.join(", ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,10 @@ mod cert;
|
||||||
mod info;
|
mod info;
|
||||||
#[path = "alist.rs"]
|
#[path = "alist.rs"]
|
||||||
mod alist;
|
mod alist;
|
||||||
|
#[path = "set.rs"]
|
||||||
|
mod set;
|
||||||
|
#[path = "drop.rs"]
|
||||||
|
mod drop;
|
||||||
|
|
||||||
pub struct NickServ {
|
pub struct NickServ {
|
||||||
pub uid: String,
|
pub uid: String,
|
||||||
|
|
@ -37,7 +41,7 @@ impl Service for NickServ {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &Network, db: &mut Db) {
|
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &mut Db) {
|
||||||
let me = self.uid.as_str();
|
let me = self.uid.as_str();
|
||||||
match args.first().map(|s| s.to_ascii_uppercase()).as_deref() {
|
match args.first().map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||||
Some("REGISTER") => register::handle(me, from, args, ctx),
|
Some("REGISTER") => register::handle(me, from, args, ctx),
|
||||||
|
|
@ -46,7 +50,9 @@ impl Service for NickServ {
|
||||||
Some("CERT") => cert::handle(me, from, args, ctx, db),
|
Some("CERT") => cert::handle(me, from, args, ctx, db),
|
||||||
Some("INFO") => info::handle(me, from, args, ctx, db),
|
Some("INFO") => info::handle(me, from, args, ctx, db),
|
||||||
Some("ALIST") => alist::handle(me, from, ctx, db),
|
Some("ALIST") => alist::handle(me, from, ctx, db),
|
||||||
Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 <password> [email], \x02IDENTIFY\x02 [account] <password>, \x02INFO\x02 [account], \x02ALIST\x02, \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST <password> [fingerprint]."),
|
Some("SET") => set::handle(me, from, args, ctx, db),
|
||||||
|
Some("DROP") => drop::handle(me, from, args, ctx, net, db),
|
||||||
|
Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 <password> [email], \x02IDENTIFY\x02 [account] <password>, \x02INFO\x02 [account], \x02ALIST\x02, \x02SET\x02 PASSWORD|EMAIL, \x02DROP\x02 <password>, \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST <password> [fingerprint]."),
|
||||||
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
|
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
modules/nickserv/set.rs
Normal file
30
modules/nickserv/set.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
use crate::engine::db::Db;
|
||||||
|
use crate::engine::service::{Sender, ServiceCtx};
|
||||||
|
|
||||||
|
// SET PASSWORD <newpassword> | SET EMAIL [address]: change your account settings.
|
||||||
|
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) {
|
||||||
|
let Some(account) = from.account else {
|
||||||
|
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match args.get(1).map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||||
|
Some("PASSWORD") | Some("PASS") => {
|
||||||
|
let Some(&password) = args.get(2) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: SET PASSWORD <newpassword>");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// The link layer derives the new password off-thread, then commits it.
|
||||||
|
ctx.defer_password(account, password, me, from.uid);
|
||||||
|
}
|
||||||
|
Some("EMAIL") => {
|
||||||
|
let email = args.get(2).map(|s| s.to_string());
|
||||||
|
let cleared = email.is_none();
|
||||||
|
match db.set_email(account, email) {
|
||||||
|
Ok(()) if cleared => ctx.notice(me, from.uid, format!("Email for \x02{account}\x02 cleared.")),
|
||||||
|
Ok(()) => ctx.notice(me, from.uid, format!("Email for \x02{account}\x02 updated.")),
|
||||||
|
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => ctx.notice(me, from.uid, "Syntax: SET PASSWORD <newpassword> | SET EMAIL [address]"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -280,8 +280,8 @@ impl Protocol for InspIrcd {
|
||||||
vec![format!(":{} INVITE {} {} 1 0", from, uid, channel)]
|
vec![format!(":{} INVITE {} {} 1 0", from, uid, channel)]
|
||||||
}
|
}
|
||||||
NetAction::Raw(s) => vec![s.clone()],
|
NetAction::Raw(s) => vec![s.clone()],
|
||||||
// Internal: the link layer handles this before serialization.
|
// Internal: the link layer handles these before serialization.
|
||||||
NetAction::DeferRegister { .. } => vec![],
|
NetAction::DeferRegister { .. } | NetAction::DeferPassword { .. } => vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,9 @@ pub enum NetAction {
|
||||||
// still needs its (expensive) key derivation. The link layer runs the
|
// still needs its (expensive) key derivation. The link layer runs the
|
||||||
// derivation off-thread, then calls Engine::complete_register.
|
// derivation off-thread, then calls Engine::complete_register.
|
||||||
DeferRegister { account: String, password: String, email: Option<String>, reply: RegReply },
|
DeferRegister { account: String, password: String, email: Option<String>, reply: RegReply },
|
||||||
|
// Internal only: a password change awaiting the same off-thread derivation.
|
||||||
|
// The link layer derives, then calls Engine::complete_password_change.
|
||||||
|
DeferPassword { account: String, password: String, agent: String, uid: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
// How to answer a registration once its credentials have been derived.
|
// How to answer a registration once its credentials have been derived.
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@ pub enum Event {
|
||||||
AccountRegistered(Account),
|
AccountRegistered(Account),
|
||||||
CertAdded { account: String, fp: String },
|
CertAdded { account: String, fp: String },
|
||||||
CertRemoved { account: String, fp: String },
|
CertRemoved { account: String, fp: String },
|
||||||
|
AccountEmailSet { account: String, email: Option<String> },
|
||||||
|
AccountPasswordSet { account: String, password_hash: String, scram256: String, scram512: String },
|
||||||
|
AccountDropped { account: String },
|
||||||
ChannelRegistered { name: String, founder: String, ts: u64 },
|
ChannelRegistered { name: String, founder: String, ts: u64 },
|
||||||
ChannelDropped { name: String },
|
ChannelDropped { name: String },
|
||||||
ChannelMlock { name: String, on: String, off: String },
|
ChannelMlock { name: String, on: String, off: String },
|
||||||
|
|
@ -70,7 +73,12 @@ enum Scope {
|
||||||
impl Event {
|
impl Event {
|
||||||
fn scope(&self) -> Scope {
|
fn scope(&self) -> Scope {
|
||||||
match self {
|
match self {
|
||||||
Event::AccountRegistered(_) | Event::CertAdded { .. } | Event::CertRemoved { .. } => Scope::Global,
|
Event::AccountRegistered(_)
|
||||||
|
| Event::CertAdded { .. }
|
||||||
|
| Event::CertRemoved { .. }
|
||||||
|
| Event::AccountEmailSet { .. }
|
||||||
|
| Event::AccountPasswordSet { .. }
|
||||||
|
| Event::AccountDropped { .. } => Scope::Global,
|
||||||
Event::ChannelRegistered { .. }
|
Event::ChannelRegistered { .. }
|
||||||
| Event::ChannelDropped { .. }
|
| Event::ChannelDropped { .. }
|
||||||
| Event::ChannelMlock { .. }
|
| Event::ChannelMlock { .. }
|
||||||
|
|
@ -386,6 +394,13 @@ pub enum RegError {
|
||||||
Internal,
|
Internal,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What an ingested entry did to a locally-known account, so the engine can log
|
||||||
|
// out sessions that were relying on it.
|
||||||
|
pub enum AccountChange {
|
||||||
|
TakenOver(String),
|
||||||
|
Dropped(String),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum CertError {
|
pub enum CertError {
|
||||||
Invalid, // not a plausible fingerprint
|
Invalid, // not a plausible fingerprint
|
||||||
|
|
@ -468,19 +483,22 @@ impl Db {
|
||||||
/// of the gossip seam. Idempotent (re-delivered entries are dropped). Returns
|
/// of the gossip seam. Idempotent (re-delivered entries are dropped). Returns
|
||||||
/// the account name if this ingest changed its owner (a registration conflict
|
/// the account name if this ingest changed its owner (a registration conflict
|
||||||
/// resolved against the local claim), so the caller can log out stale sessions.
|
/// resolved against the local claim), so the caller can log out stale sessions.
|
||||||
pub fn ingest(&mut self, entry: LogEntry) -> std::io::Result<Option<String>> {
|
pub fn ingest(&mut self, entry: LogEntry) -> std::io::Result<Option<AccountChange>> {
|
||||||
// Record the current owner of an incoming account before applying, to see
|
// Snapshot the incoming account's local owner before applying, to detect a
|
||||||
// if this ingest transfers ownership away from us.
|
// takeover (home changed) or a remote drop (it disappears).
|
||||||
let contested = match &entry.event {
|
let watched: Option<(String, Option<String>)> = match &entry.event {
|
||||||
Event::AccountRegistered(a) => self.accounts.get(&key(&a.name)).map(|cur| (a.name.clone(), cur.home.clone())),
|
Event::AccountRegistered(a) => Some((a.name.clone(), self.account(&a.name).map(|c| c.home.clone()))),
|
||||||
|
Event::AccountDropped { account } => Some((account.clone(), self.account(account).map(|c| c.home.clone()))),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let Some(event) = self.log.ingest(entry)? {
|
if let Some(event) = self.log.ingest(entry)? {
|
||||||
apply(&mut self.accounts, &mut self.channels, event);
|
apply(&mut self.accounts, &mut self.channels, event);
|
||||||
}
|
}
|
||||||
if let Some((name, prev_home)) = contested {
|
if let Some((name, prev_home)) = watched {
|
||||||
if self.accounts.get(&key(&name)).is_some_and(|cur| cur.home != prev_home) {
|
match (prev_home, self.account(&name).map(|c| c.home.clone())) {
|
||||||
return Ok(Some(name)); // ownership moved to another node
|
(Some(prev), Some(cur)) if cur != prev => return Ok(Some(AccountChange::TakenOver(name))),
|
||||||
|
(Some(_), None) => return Ok(Some(AccountChange::Dropped(name))),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
|
|
@ -611,6 +629,49 @@ impl Db {
|
||||||
self.accounts.get(&key(account)).map_or(&[], |a| a.certfps.as_slice())
|
self.accounts.get(&key(account)).map_or(&[], |a| a.certfps.as_slice())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set (or clear) `account`'s email.
|
||||||
|
pub fn set_email(&mut self, account: &str, email: Option<String>) -> Result<(), RegError> {
|
||||||
|
let k = key(account);
|
||||||
|
if !self.accounts.contains_key(&k) {
|
||||||
|
return Err(RegError::Internal);
|
||||||
|
}
|
||||||
|
self.log.append(Event::AccountEmailSet { account: account.to_string(), email: email.clone() }).map_err(|_| RegError::Internal)?;
|
||||||
|
self.accounts.get_mut(&k).unwrap().email = email;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace `account`'s password with freshly derived credentials.
|
||||||
|
pub fn set_credentials(&mut self, account: &str, creds: Credentials) -> Result<(), RegError> {
|
||||||
|
let k = key(account);
|
||||||
|
if !self.accounts.contains_key(&k) {
|
||||||
|
return Err(RegError::Internal);
|
||||||
|
}
|
||||||
|
self.log
|
||||||
|
.append(Event::AccountPasswordSet {
|
||||||
|
account: account.to_string(),
|
||||||
|
password_hash: creds.password_hash.clone(),
|
||||||
|
scram256: creds.scram256.clone(),
|
||||||
|
scram512: creds.scram512.clone(),
|
||||||
|
})
|
||||||
|
.map_err(|_| RegError::Internal)?;
|
||||||
|
let a = self.accounts.get_mut(&k).unwrap();
|
||||||
|
a.password_hash = creds.password_hash;
|
||||||
|
a.scram256 = Some(creds.scram256);
|
||||||
|
a.scram512 = Some(creds.scram512);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete `account`. Ok(false) if it wasn't registered.
|
||||||
|
pub fn drop_account(&mut self, account: &str) -> Result<bool, RegError> {
|
||||||
|
let k = key(account);
|
||||||
|
if !self.accounts.contains_key(&k) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.log.append(Event::AccountDropped { account: account.to_string() }).map_err(|_| RegError::Internal)?;
|
||||||
|
self.accounts.remove(&k);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Register `fp` to `account`. Fingerprints are one-to-one with accounts.
|
/// Register `fp` to `account`. Fingerprints are one-to-one with accounts.
|
||||||
pub fn certfp_add(&mut self, account: &str, fp: &str) -> Result<(), CertError> {
|
pub fn certfp_add(&mut self, account: &str, fp: &str) -> Result<(), CertError> {
|
||||||
let fp = fp.to_ascii_lowercase();
|
let fp = fp.to_ascii_lowercase();
|
||||||
|
|
@ -845,6 +906,21 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
|
||||||
a.certfps.retain(|c| *c != fp);
|
a.certfps.retain(|c| *c != fp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Event::AccountEmailSet { account, email } => {
|
||||||
|
if let Some(a) = accounts.get_mut(&key(&account)) {
|
||||||
|
a.email = email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::AccountPasswordSet { account, password_hash, scram256, scram512 } => {
|
||||||
|
if let Some(a) = accounts.get_mut(&key(&account)) {
|
||||||
|
a.password_hash = password_hash;
|
||||||
|
a.scram256 = Some(scram256);
|
||||||
|
a.scram512 = Some(scram512);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::AccountDropped { account } => {
|
||||||
|
accounts.remove(&key(&account));
|
||||||
|
}
|
||||||
Event::ChannelRegistered { name, founder, ts } => {
|
Event::ChannelRegistered { name, founder, ts } => {
|
||||||
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new() });
|
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new() });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,19 +123,20 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn gossip_ingest(&mut self, entry: LogEntry) -> std::io::Result<()> {
|
pub fn gossip_ingest(&mut self, entry: LogEntry) -> std::io::Result<()> {
|
||||||
// If ingesting a peer's registration took an account name away from a
|
// If ingesting a peer's entry removed an account a local session relied on
|
||||||
// local session (it lost the conflict), clean up after the loser.
|
// (lost a conflict, or dropped elsewhere), clean up after it.
|
||||||
if let Some(account) = self.db.ingest(entry)? {
|
match self.db.ingest(entry)? {
|
||||||
self.handle_account_takeover(&account);
|
Some(db::AccountChange::TakenOver(a)) => self.handle_account_gone(&a, "collided with another network and no longer belongs to you"),
|
||||||
|
Some(db::AccountChange::Dropped(a)) => self.handle_account_gone(&a, "was dropped"),
|
||||||
|
None => {}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// A registration conflict resolved against this node: the name `account` is now
|
// An account a local session was using is gone (lost a conflict, or dropped on
|
||||||
// owned by another node. Log out any local session on it (its credential is
|
// another node). Log out those sessions (their credential is gone) and drop the
|
||||||
// gone) and drop the channels it had registered locally — their founder
|
// channels it founded locally — their founder identity no longer exists here.
|
||||||
// identity now belongs to another node, so they can't silently transfer.
|
fn handle_account_gone(&mut self, account: &str, reason: &str) {
|
||||||
fn handle_account_takeover(&mut self, account: &str) {
|
|
||||||
let victims = self.network.uids_logged_into(account);
|
let victims = self.network.uids_logged_into(account);
|
||||||
let orphaned = self.db.channels_owned_by(account);
|
let orphaned = self.db.channels_owned_by(account);
|
||||||
for chan in &orphaned {
|
for chan in &orphaned {
|
||||||
|
|
@ -156,7 +157,7 @@ impl Engine {
|
||||||
self.emit_irc(NetAction::Notice {
|
self.emit_irc(NetAction::Notice {
|
||||||
from: ns.clone(),
|
from: ns.clone(),
|
||||||
to: uid.clone(),
|
to: uid.clone(),
|
||||||
text: format!("Your registration of \x02{account}\x02 collided with another network and it now belongs elsewhere. You have been logged out; please register a different name."),
|
text: format!("Your account \x02{account}\x02 {reason}. You have been logged out."),
|
||||||
});
|
});
|
||||||
if !orphaned.is_empty() {
|
if !orphaned.is_empty() {
|
||||||
self.emit_irc(NetAction::Notice {
|
self.emit_irc(NetAction::Notice {
|
||||||
|
|
@ -544,6 +545,15 @@ impl Engine {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Commit a password change the link layer derived off-thread, then notice the user.
|
||||||
|
pub fn complete_password_change(&mut self, account: &str, creds: Option<db::Credentials>, agent: &str, uid: &str) -> Vec<NetAction> {
|
||||||
|
let text = match creds.and_then(|c| self.db.set_credentials(account, c).ok()) {
|
||||||
|
Some(()) => format!("Your password for \x02{account}\x02 has been changed."),
|
||||||
|
None => "Sorry, that didn't work. Please try again in a moment.".to_string(),
|
||||||
|
};
|
||||||
|
vec![NetAction::Notice { from: agent.to_string(), to: uid.to_string(), text }]
|
||||||
|
}
|
||||||
|
|
||||||
// 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, login state, 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> {
|
||||||
|
|
@ -1132,6 +1142,54 @@ mod tests {
|
||||||
assert!(notice(&to_ns(&mut e, "ALIST"), "#a"));
|
assert!(notice(&to_ns(&mut e, "ALIST"), "#a"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SET EMAIL stores an email; SET PASSWORD defers derivation, and once
|
||||||
|
// completed the new password authenticates and the old one no longer does.
|
||||||
|
#[test]
|
||||||
|
fn nickserv_set_password_and_email() {
|
||||||
|
let mut e = engine_with("nsset", "alice", "sesame");
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() });
|
||||||
|
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||||||
|
let to_ns = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: text.into() });
|
||||||
|
let notice = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)));
|
||||||
|
|
||||||
|
assert!(notice(&to_ns(&mut e, "SET EMAIL alice@example.org"), "updated"));
|
||||||
|
assert!(notice(&to_ns(&mut e, "INFO"), "alice@example.org"), "owner INFO shows the email");
|
||||||
|
|
||||||
|
let out = to_ns(&mut e, "SET PASSWORD newsecret");
|
||||||
|
let deferred = out.iter().find_map(|a| match a {
|
||||||
|
NetAction::DeferPassword { account, password, agent, uid } => Some((account.clone(), password.clone(), agent.clone(), uid.clone())),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let (account, password, agent, uid) = deferred.expect("SET PASSWORD defers derivation");
|
||||||
|
assert_eq!(password, "newsecret");
|
||||||
|
let creds = Db::derive_credentials(&password, 4096);
|
||||||
|
e.complete_password_change(&account, creds, &agent, &uid);
|
||||||
|
assert!(e.db.authenticate("alice", "newsecret").is_some(), "new password works");
|
||||||
|
assert!(e.db.authenticate("alice", "sesame").is_none(), "old password rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// DROP deletes the account, releases and drops the channels it founded, and
|
||||||
|
// logs the session out; a wrong password is refused.
|
||||||
|
#[test]
|
||||||
|
fn nickserv_drop_account() {
|
||||||
|
let mut e = engine_with("nsdrop", "alice", "sesame");
|
||||||
|
e.db.register_channel("#a", "alice").unwrap();
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() });
|
||||||
|
e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() });
|
||||||
|
let to_ns = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: text.into() });
|
||||||
|
let notice = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)));
|
||||||
|
|
||||||
|
assert!(notice(&to_ns(&mut e, "DROP wrongpass"), "Invalid password"));
|
||||||
|
assert!(e.db.account("alice").is_some(), "wrong password keeps the account");
|
||||||
|
|
||||||
|
let out = to_ns(&mut e, "DROP sesame");
|
||||||
|
assert!(notice(&out, "has been dropped"));
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#a" && modes == "-r")), "channel released: {out:?}");
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { target, key, value } if target == "000AAAAAB" && key == "accountname" && value.is_empty())), "session logged out: {out:?}");
|
||||||
|
assert!(e.db.account("alice").is_none(), "account gone");
|
||||||
|
assert!(e.db.channel("#a").is_none(), "founded channel gone");
|
||||||
|
}
|
||||||
|
|
||||||
// IDENTIFY accepts the two-arg account+password form: log into a named
|
// IDENTIFY accepts the two-arg account+password form: log into a named
|
||||||
// account even when the current nick differs; a wrong password is refused.
|
// account even when the current nick differs; a wrong password is refused.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,17 @@ impl ServiceCtx {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hand a password change to the engine to finish: its derivation runs off the
|
||||||
|
// reactor, then the engine commits it and notices `uid`, sourced from `agent`.
|
||||||
|
pub fn defer_password(&mut self, account: impl Into<String>, password: impl Into<String>, agent: impl Into<String>, uid: impl Into<String>) {
|
||||||
|
self.actions.push(NetAction::DeferPassword {
|
||||||
|
account: account.into(),
|
||||||
|
password: password.into(),
|
||||||
|
agent: agent.into(),
|
||||||
|
uid: uid.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Log a user into an account: sets the accountname the ircd turns into
|
// Log a user into an account: sets the accountname the ircd turns into
|
||||||
// RPL_LOGGEDIN (900) and exposes to account-tag / WHOX, the same login the
|
// RPL_LOGGEDIN (900) and exposes to account-tag / WHOX, the same login the
|
||||||
// SASL agent applies. Used after a successful REGISTER / IDENTIFY.
|
// SASL agent applies. Used after a successful REGISTER / IDENTIFY.
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,15 @@ pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Password change: same off-thread derivation as register.
|
||||||
|
NetAction::DeferPassword { account, password, agent, uid } => {
|
||||||
|
let iterations = engine.lock().await.scram_iterations();
|
||||||
|
let creds = tokio::task::spawn_blocking(move || {
|
||||||
|
Db::derive_credentials(&password, iterations)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
engine.lock().await.complete_password_change(&account, creds, &agent, &uid)
|
||||||
|
}
|
||||||
action => vec![action],
|
action => vec![action],
|
||||||
};
|
};
|
||||||
for act in outs {
|
for act in outs {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue