nickserv: email confirmation (CONFIRM) on registration
When email is configured and a REGISTER includes an address, the account starts unverified and a confirmation code is emailed. CONFIRM <code> verifies it (a federated AccountVerified event); INFO shows an unconfirmed email until then. Emailed codes now carry a purpose (reset vs confirm).
This commit is contained in:
parent
a2957ffe02
commit
fbcf0eaac7
6 changed files with 140 additions and 20 deletions
26
modules/nickserv/confirm.rs
Normal file
26
modules/nickserv/confirm.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
use crate::engine::db::{CodeKind, Db};
|
||||||
|
use crate::engine::service::{Sender, ServiceCtx};
|
||||||
|
|
||||||
|
// CONFIRM <code>: confirm your account's email with the code you were emailed.
|
||||||
|
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) {
|
||||||
|
let Some(&code) = args.get(1) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: CONFIRM <code>");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(account) = from.account.map(str::to_string).or_else(|| db.resolve_account(from.nick).map(str::to_string)) else {
|
||||||
|
ctx.notice(me, from.uid, "You don't have an account to confirm.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if db.is_verified(&account) {
|
||||||
|
ctx.notice(me, from.uid, format!("\x02{account}\x02 is already confirmed."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !db.take_code(&account, CodeKind::Confirm, code) {
|
||||||
|
ctx.notice(me, from.uid, "Invalid or expired confirmation code.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match db.verify_account(&account) {
|
||||||
|
Ok(()) => ctx.notice(me, from.uid, format!("\x02{account}\x02 is now confirmed. Thanks!")),
|
||||||
|
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,8 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
ctx.notice(me, from.uid, format!(" Registered : {}", human_time(acct.ts)));
|
ctx.notice(me, from.uid, format!(" Registered : {}", human_time(acct.ts)));
|
||||||
if from.account == Some(acct.name.as_str()) {
|
if from.account == Some(acct.name.as_str()) {
|
||||||
match &acct.email {
|
match &acct.email {
|
||||||
Some(email) => ctx.notice(me, from.uid, format!(" Email : {email}")),
|
Some(email) if acct.verified => ctx.notice(me, from.uid, format!(" Email : {email}")),
|
||||||
|
Some(email) => ctx.notice(me, from.uid, format!(" Email : {email} (unconfirmed)")),
|
||||||
None => ctx.notice(me, from.uid, " Email : (none set)"),
|
None => ctx.notice(me, from.uid, " Email : (none set)"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ mod ungroup;
|
||||||
mod ghost;
|
mod ghost;
|
||||||
#[path = "resetpass.rs"]
|
#[path = "resetpass.rs"]
|
||||||
mod resetpass;
|
mod resetpass;
|
||||||
|
#[path = "confirm.rs"]
|
||||||
|
mod confirm;
|
||||||
|
|
||||||
pub struct NickServ {
|
pub struct NickServ {
|
||||||
pub uid: String,
|
pub uid: String,
|
||||||
|
|
@ -67,7 +69,8 @@ impl Service for NickServ {
|
||||||
Some("UNGROUP") => ungroup::handle(me, from, args, ctx, db),
|
Some("UNGROUP") => ungroup::handle(me, from, args, ctx, db),
|
||||||
Some("GHOST") | Some("RECOVER") => ghost::handle(me, &self.guest_nick, &mut self.guest_seq, from, args, ctx, net, db),
|
Some("GHOST") | Some("RECOVER") => ghost::handle(me, &self.guest_nick, &mut self.guest_seq, from, args, ctx, net, db),
|
||||||
Some("RESETPASS") => resetpass::handle(me, from, args, ctx, db),
|
Some("RESETPASS") => resetpass::handle(me, from, args, 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, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 <nick> [password], \x02SET\x02 PASSWORD|EMAIL, \x02RESETPASS\x02 <account>, \x02DROP\x02 <password>, \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST <password> [fingerprint]."),
|
Some("CONFIRM") => confirm::handle(me, from, args, 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, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 <nick> [password], \x02SET\x02 PASSWORD|EMAIL, \x02RESETPASS\x02 <account>, \x02CONFIRM\x02 <code>, \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 => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::engine::db::Db;
|
use crate::engine::db::{CodeKind, Db};
|
||||||
use crate::engine::service::{Sender, ServiceCtx};
|
use crate::engine::service::{Sender, ServiceCtx};
|
||||||
|
|
||||||
// RESETPASS <account>: email a reset code to the address on file.
|
// RESETPASS <account>: email a reset code to the address on file.
|
||||||
|
|
@ -18,7 +18,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
ctx.notice(me, from.uid, "That account has no email on file, so it can't be reset.");
|
ctx.notice(me, from.uid, "That account has no email on file, so it can't be reset.");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let code = db.issue_reset_code(&canonical);
|
let code = db.issue_code(&canonical, CodeKind::Reset);
|
||||||
ctx.send_email(
|
ctx.send_email(
|
||||||
email,
|
email,
|
||||||
format!("Password reset for {canonical}"),
|
format!("Password reset for {canonical}"),
|
||||||
|
|
@ -31,7 +31,7 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
|
||||||
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
|
ctx.notice(me, from.uid, format!("\x02{name}\x02 isn't registered."));
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if !db.take_reset_code(&canonical, code) {
|
if !db.take_code(&canonical, CodeKind::Reset, code) {
|
||||||
ctx.notice(me, from.uid, "Invalid or expired reset code.");
|
ctx.notice(me, from.uid, "Invalid or expired reset code.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,15 @@ pub struct Account {
|
||||||
// this account via SASL EXTERNAL. Each fingerprint maps to one account.
|
// this account via SASL EXTERNAL. Each fingerprint maps to one account.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub certfps: Vec<String>,
|
pub certfps: Vec<String>,
|
||||||
|
// Whether the email on file has been confirmed. Defaults true so accounts
|
||||||
|
// predating email confirmation (and those registered without email) count
|
||||||
|
// as verified.
|
||||||
|
#[serde(default = "verified_default")]
|
||||||
|
pub verified: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verified_default() -> bool {
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event-sourced persistence: every change is an Event appended to a JSONL log,
|
// Event-sourced persistence: every change is an Event appended to a JSONL log,
|
||||||
|
|
@ -48,6 +57,7 @@ pub enum Event {
|
||||||
AccountEmailSet { account: String, email: Option<String> },
|
AccountEmailSet { account: String, email: Option<String> },
|
||||||
AccountPasswordSet { account: String, password_hash: String, scram256: String, scram512: String },
|
AccountPasswordSet { account: String, password_hash: String, scram256: String, scram512: String },
|
||||||
AccountDropped { account: String },
|
AccountDropped { account: String },
|
||||||
|
AccountVerified { account: String },
|
||||||
NickGrouped { nick: String, account: String },
|
NickGrouped { nick: String, account: String },
|
||||||
NickUngrouped { nick: String },
|
NickUngrouped { nick: String },
|
||||||
ChannelRegistered { name: String, founder: String, ts: u64 },
|
ChannelRegistered { name: String, founder: String, ts: u64 },
|
||||||
|
|
@ -81,6 +91,7 @@ impl Event {
|
||||||
| Event::AccountEmailSet { .. }
|
| Event::AccountEmailSet { .. }
|
||||||
| Event::AccountPasswordSet { .. }
|
| Event::AccountPasswordSet { .. }
|
||||||
| Event::AccountDropped { .. }
|
| Event::AccountDropped { .. }
|
||||||
|
| Event::AccountVerified { .. }
|
||||||
| Event::NickGrouped { .. }
|
| Event::NickGrouped { .. }
|
||||||
| Event::NickUngrouped { .. } => Scope::Global,
|
| Event::NickUngrouped { .. } => Scope::Global,
|
||||||
Event::ChannelRegistered { .. }
|
Event::ChannelRegistered { .. }
|
||||||
|
|
@ -445,8 +456,15 @@ pub struct Db {
|
||||||
pub(crate) scram_iterations: u32,
|
pub(crate) scram_iterations: u32,
|
||||||
// Whether outbound email is configured, so email features can gate themselves.
|
// Whether outbound email is configured, so email features can gate themselves.
|
||||||
email_enabled: bool,
|
email_enabled: bool,
|
||||||
// Node-local, non-persisted password-reset codes: account -> (code, expiry).
|
// Node-local, non-persisted email codes: account -> (purpose, code, expiry).
|
||||||
reset_codes: HashMap<String, (String, Instant)>,
|
codes: HashMap<String, (CodeKind, String, Instant)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// What an emailed code authorises.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CodeKind {
|
||||||
|
Reset,
|
||||||
|
Confirm,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn key(name: &str) -> String {
|
fn key(name: &str) -> String {
|
||||||
|
|
@ -486,7 +504,7 @@ impl Db {
|
||||||
apply(&mut accounts, &mut channels, &mut grouped, event);
|
apply(&mut accounts, &mut channels, &mut grouped, 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, reset_codes: HashMap::new() }
|
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, codes: HashMap::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
||||||
|
|
@ -626,6 +644,9 @@ impl Db {
|
||||||
if self.exists(name) {
|
if self.exists(name) {
|
||||||
return Err(RegError::Exists);
|
return Err(RegError::Exists);
|
||||||
}
|
}
|
||||||
|
// Unverified only when email confirmation actually applies (email is
|
||||||
|
// configured and an address was given); otherwise verified immediately.
|
||||||
|
let verified = !(self.email_enabled && email.is_some());
|
||||||
let account = Account {
|
let account = Account {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
password_hash: creds.password_hash,
|
password_hash: creds.password_hash,
|
||||||
|
|
@ -635,6 +656,7 @@ impl Db {
|
||||||
scram256: Some(creds.scram256),
|
scram256: Some(creds.scram256),
|
||||||
scram512: Some(creds.scram512),
|
scram512: Some(creds.scram512),
|
||||||
certfps: Vec::new(),
|
certfps: Vec::new(),
|
||||||
|
verified,
|
||||||
};
|
};
|
||||||
self.log.append(Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?;
|
self.log.append(Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?;
|
||||||
self.accounts.insert(key(name), account);
|
self.accounts.insert(key(name), account);
|
||||||
|
|
@ -689,23 +711,40 @@ impl Db {
|
||||||
self.email_enabled
|
self.email_enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether `account`'s email is confirmed (true for unknown/legacy accounts).
|
||||||
|
pub fn is_verified(&self, account: &str) -> bool {
|
||||||
|
self.account(account).map_or(true, |a| a.verified)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark `account`'s email confirmed.
|
||||||
|
pub fn verify_account(&mut self, account: &str) -> Result<(), RegError> {
|
||||||
|
let k = key(account);
|
||||||
|
if !self.accounts.contains_key(&k) {
|
||||||
|
return Err(RegError::Internal);
|
||||||
|
}
|
||||||
|
self.log.append(Event::AccountVerified { account: account.to_string() }).map_err(|_| RegError::Internal)?;
|
||||||
|
self.accounts.get_mut(&k).unwrap().verified = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_email_enabled(&mut self, on: bool) {
|
pub fn set_email_enabled(&mut self, on: bool) {
|
||||||
self.email_enabled = on;
|
self.email_enabled = on;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Issue a fresh password-reset code for `account`, valid for 15 minutes.
|
/// Issue a fresh emailed code for `account` and purpose, valid for 15 minutes.
|
||||||
pub fn issue_reset_code(&mut self, account: &str) -> String {
|
pub fn issue_code(&mut self, account: &str, kind: CodeKind) -> String {
|
||||||
let code = gen_code();
|
let code = gen_code();
|
||||||
self.reset_codes.insert(key(account), (code.clone(), Instant::now() + Duration::from_secs(900)));
|
self.codes.insert(key(account), (kind, code.clone(), Instant::now() + Duration::from_secs(900)));
|
||||||
code
|
code
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Consume a reset code for `account`: true if it matches and hasn't expired.
|
/// Consume a code for `account`: true if the purpose and code match and it
|
||||||
pub fn take_reset_code(&mut self, account: &str, code: &str) -> bool {
|
/// hasn't expired.
|
||||||
|
pub fn take_code(&mut self, account: &str, kind: CodeKind, code: &str) -> bool {
|
||||||
let k = key(account);
|
let k = key(account);
|
||||||
match self.reset_codes.get(&k) {
|
match self.codes.get(&k) {
|
||||||
Some((c, deadline)) if c == code && *deadline > Instant::now() => {
|
Some((ki, c, deadline)) if *ki == kind && c == code && *deadline > Instant::now() => {
|
||||||
self.reset_codes.remove(&k);
|
self.codes.remove(&k);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
|
|
@ -1005,6 +1044,11 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
|
||||||
accounts.remove(&key(&account));
|
accounts.remove(&key(&account));
|
||||||
grouped.retain(|_, a| !a.eq_ignore_ascii_case(&account)); // its aliases go too
|
grouped.retain(|_, a| !a.eq_ignore_ascii_case(&account)); // its aliases go too
|
||||||
}
|
}
|
||||||
|
Event::AccountVerified { account } => {
|
||||||
|
if let Some(a) = accounts.get_mut(&key(&account)) {
|
||||||
|
a.verified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Event::NickGrouped { nick, account } => {
|
Event::NickGrouped { nick, account } => {
|
||||||
grouped.insert(key(&nick), account);
|
grouped.insert(key(&nick), account);
|
||||||
}
|
}
|
||||||
|
|
@ -1146,7 +1190,7 @@ mod tests {
|
||||||
fn account_conflict_resolves_deterministically() {
|
fn account_conflict_resolves_deterministically() {
|
||||||
let alice = |hash: &str, ts: u64, home: &str| Account {
|
let alice = |hash: &str, ts: u64, home: &str| Account {
|
||||||
name: "alice".into(), password_hash: hash.into(), email: None,
|
name: "alice".into(), password_hash: hash.into(), email: None,
|
||||||
ts, home: home.into(), scram256: None, scram512: None, certfps: vec![],
|
ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true,
|
||||||
};
|
};
|
||||||
let converge = |first: &Account, second: &Account| {
|
let converge = |first: &Account, second: &Account| {
|
||||||
let (mut acc, mut ch, mut gr) = (HashMap::new(), HashMap::new(), HashMap::new());
|
let (mut acc, mut ch, mut gr) = (HashMap::new(), HashMap::new(), HashMap::new());
|
||||||
|
|
@ -1251,7 +1295,7 @@ mod tests {
|
||||||
db.register("alice", "pw", None).unwrap();
|
db.register("alice", "pw", None).unwrap();
|
||||||
let bob = Account {
|
let bob = Account {
|
||||||
name: "bob".into(), password_hash: "x".into(), email: None,
|
name: "bob".into(), password_hash: "x".into(), email: None,
|
||||||
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![],
|
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true,
|
||||||
};
|
};
|
||||||
let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, event: Event::AccountRegistered(bob) };
|
let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, event: Event::AccountRegistered(bob) };
|
||||||
db.ingest(entry).unwrap();
|
db.ingest(entry).unwrap();
|
||||||
|
|
|
||||||
|
|
@ -535,13 +535,27 @@ impl Engine {
|
||||||
let Some(creds) = creds else {
|
let Some(creds) = creds else {
|
||||||
return reg_reply(&reply, RegOutcome::Internal, account);
|
return reg_reply(&reply, RegOutcome::Internal, account);
|
||||||
};
|
};
|
||||||
|
let addr = email.clone();
|
||||||
let outcome = match self.db.register_prepared(account, creds, email) {
|
let outcome = match self.db.register_prepared(account, creds, email) {
|
||||||
Ok(()) => RegOutcome::Ok,
|
Ok(()) => RegOutcome::Ok,
|
||||||
Err(RegError::Exists) => RegOutcome::Exists,
|
Err(RegError::Exists) => RegOutcome::Exists,
|
||||||
Err(RegError::Internal) => RegOutcome::Internal,
|
Err(RegError::Internal) => RegOutcome::Internal,
|
||||||
};
|
};
|
||||||
let out = reg_reply(&reply, outcome, account);
|
let ok = matches!(outcome, RegOutcome::Ok);
|
||||||
|
let mut out = reg_reply(&reply, outcome, account);
|
||||||
self.track_accounts(&out);
|
self.track_accounts(&out);
|
||||||
|
// If this created an unverified account (email confirmation applies), email a code.
|
||||||
|
if ok && !self.db.is_verified(account) {
|
||||||
|
if let (Some(addr), RegReply::NickServ { agent, uid, .. }) = (addr, &reply) {
|
||||||
|
let code = self.db.issue_code(account, db::CodeKind::Confirm);
|
||||||
|
out.push(NetAction::SendEmail {
|
||||||
|
to: addr,
|
||||||
|
subject: format!("Confirm your {account} registration"),
|
||||||
|
body: format!("Welcome! Confirm your account {account} with:\n /msg NickServ CONFIRM {code}\nThe code expires in 15 minutes."),
|
||||||
|
});
|
||||||
|
out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: "A confirmation code has been emailed to you. Confirm with \x02CONFIRM <code>\x02.".to_string() });
|
||||||
|
}
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1088,7 +1102,7 @@ mod tests {
|
||||||
// An earlier claim from another node wins and takes the name over.
|
// An earlier claim from another node wins and takes the name over.
|
||||||
let winner = db::Account {
|
let winner = db::Account {
|
||||||
name: "alice".into(), password_hash: "OTHER".into(), email: None,
|
name: "alice".into(), password_hash: "OTHER".into(), email: None,
|
||||||
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![],
|
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true,
|
||||||
};
|
};
|
||||||
let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(winner));
|
let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(winner));
|
||||||
e.gossip_ingest(entry).unwrap();
|
e.gossip_ingest(entry).unwrap();
|
||||||
|
|
@ -1244,6 +1258,38 @@ mod tests {
|
||||||
assert!(to_ns(&mut e, "RESETPASS alice 000000 x").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Invalid or expired"))));
|
assert!(to_ns(&mut e, "RESETPASS alice 000000 x").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Invalid or expired"))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// With email configured, registering with an address creates an unverified
|
||||||
|
// account and emails a confirmation code; CONFIRM <code> verifies it.
|
||||||
|
#[test]
|
||||||
|
fn nickserv_confirm() {
|
||||||
|
let path = std::env::temp_dir().join("fedserv-nsconfirm.jsonl");
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let mut db = Db::open(&path, "test");
|
||||||
|
db.scram_iterations = 4096;
|
||||||
|
db.set_email_enabled(true);
|
||||||
|
let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db);
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "newbie".into(), host: "h".into() });
|
||||||
|
|
||||||
|
// REGISTER with an email defers; complete it as the link layer would.
|
||||||
|
let reg = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "REGISTER pw newbie@example.org".into() });
|
||||||
|
let (account, password, email, reply) = reg.iter().find_map(|a| match a {
|
||||||
|
NetAction::DeferRegister { account, password, email, reply } => Some((account.clone(), password.clone(), email.clone(), reply.clone())),
|
||||||
|
_ => None,
|
||||||
|
}).expect("register defers");
|
||||||
|
let out = e.complete_register(&account, Db::derive_credentials(&password, 4096), email, reply);
|
||||||
|
assert!(!e.db.is_verified("newbie"), "registering with email starts unverified");
|
||||||
|
let body = out.iter().find_map(|a| match a {
|
||||||
|
NetAction::SendEmail { body, .. } => Some(body.clone()),
|
||||||
|
_ => None,
|
||||||
|
}).expect("a confirm code is emailed");
|
||||||
|
let code = body.split("CONFIRM ").nth(1).unwrap().split_whitespace().next().unwrap().to_string();
|
||||||
|
|
||||||
|
// CONFIRM verifies (the user was auto-logged-in by register).
|
||||||
|
let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: format!("CONFIRM {code}") });
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("now confirmed"))), "{out:?}");
|
||||||
|
assert!(e.db.is_verified("newbie"), "confirmed after CONFIRM");
|
||||||
|
}
|
||||||
|
|
||||||
// GHOST renames off a session using a nick the caller owns.
|
// GHOST renames off a session using a nick the caller owns.
|
||||||
#[test]
|
#[test]
|
||||||
fn nickserv_ghost() {
|
fn nickserv_ghost() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue