email: outbound mail subsystem and RESETPASS
Adds a config-driven mailer: a SendEmail action the link layer pipes to a configured command (sendmail -t, msmtp, ...) off-thread. NickServ RESETPASS <account> emails a short-lived code to the address on file; RESETPASS <account> <code> <newpassword> completes the reset. Codes are node-local; the password change itself federates.
This commit is contained in:
parent
020e9bd576
commit
a2957ffe02
12 changed files with 205 additions and 10 deletions
|
|
@ -1,9 +1,9 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use argon2::password_hash::rand_core::OsRng;
|
||||
use argon2::password_hash::rand_core::{OsRng, RngCore};
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -443,6 +443,10 @@ pub struct Db {
|
|||
log: EventLog,
|
||||
// PBKDF2 cost baked into new SCRAM verifiers; lowered by tests.
|
||||
pub(crate) scram_iterations: u32,
|
||||
// Whether outbound email is configured, so email features can gate themselves.
|
||||
email_enabled: bool,
|
||||
// Node-local, non-persisted password-reset codes: account -> (code, expiry).
|
||||
reset_codes: HashMap<String, (String, Instant)>,
|
||||
}
|
||||
|
||||
fn key(name: &str) -> String {
|
||||
|
|
@ -482,7 +486,7 @@ impl Db {
|
|||
apply(&mut accounts, &mut channels, &mut grouped, event);
|
||||
}
|
||||
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
|
||||
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS }
|
||||
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, reset_codes: HashMap::new() }
|
||||
}
|
||||
|
||||
/// Fold an entry authored by another node into the store — the services-side
|
||||
|
|
@ -680,6 +684,34 @@ impl Db {
|
|||
self.accounts.get(&key(account)).map_or(&[], |a| a.certfps.as_slice())
|
||||
}
|
||||
|
||||
/// Whether outbound email is configured.
|
||||
pub fn email_enabled(&self) -> bool {
|
||||
self.email_enabled
|
||||
}
|
||||
|
||||
pub fn set_email_enabled(&mut self, on: bool) {
|
||||
self.email_enabled = on;
|
||||
}
|
||||
|
||||
/// Issue a fresh password-reset code for `account`, valid for 15 minutes.
|
||||
pub fn issue_reset_code(&mut self, account: &str) -> String {
|
||||
let code = gen_code();
|
||||
self.reset_codes.insert(key(account), (code.clone(), Instant::now() + Duration::from_secs(900)));
|
||||
code
|
||||
}
|
||||
|
||||
/// Consume a reset code for `account`: true if it matches and hasn't expired.
|
||||
pub fn take_reset_code(&mut self, account: &str, code: &str) -> bool {
|
||||
let k = key(account);
|
||||
match self.reset_codes.get(&k) {
|
||||
Some((c, deadline)) if c == code && *deadline > Instant::now() => {
|
||||
self.reset_codes.remove(&k);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set (or clear) `account`'s email.
|
||||
pub fn set_email(&mut self, account: &str, email: Option<String>) -> Result<(), RegError> {
|
||||
let k = key(account);
|
||||
|
|
@ -1062,6 +1094,13 @@ fn glob_match(pattern: &str, text: &str) -> bool {
|
|||
pi == p.len()
|
||||
}
|
||||
|
||||
// A random 6-digit code for email verification / password reset.
|
||||
fn gen_code() -> String {
|
||||
let mut b = [0u8; 4];
|
||||
OsRng.fill_bytes(&mut b);
|
||||
format!("{:06}", u32::from_le_bytes(b) % 1_000_000)
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> Option<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default().hash_password(password.as_bytes(), &salt).ok().map(|h| h.to_string())
|
||||
|
|
|
|||
|
|
@ -1210,6 +1210,40 @@ mod tests {
|
|||
assert!(notice(&to_ns(&mut e, "000AAAAAC", "IDENTIFY sesame"), "isn't registered"));
|
||||
}
|
||||
|
||||
// RESETPASS emails a code, and that code + a new password completes the reset;
|
||||
// the new password then authenticates and the old one no longer does.
|
||||
#[test]
|
||||
fn nickserv_resetpass() {
|
||||
let mut e = engine_with("nsreset", "alice", "sesame");
|
||||
e.db.set_email_enabled(true);
|
||||
e.db.set_email("alice", Some("alice@example.org".into())).unwrap();
|
||||
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "someone".into(), host: "h".into() });
|
||||
let to_ns = |e: &mut Engine, text: &str| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: text.into() });
|
||||
|
||||
// Request: a code is emailed to the address on file.
|
||||
let out = to_ns(&mut e, "RESETPASS alice");
|
||||
let (to, body) = out.iter().find_map(|a| match a {
|
||||
NetAction::SendEmail { to, body, .. } => Some((to.clone(), body.clone())),
|
||||
_ => None,
|
||||
}).expect("a reset code is emailed");
|
||||
assert_eq!(to, "alice@example.org");
|
||||
let code = body.split("is: ").nth(1).unwrap().split_whitespace().next().unwrap().to_string();
|
||||
|
||||
// Complete: the code + a new password defers the change.
|
||||
let out = to_ns(&mut e, &format!("RESETPASS alice {code} brandnew"));
|
||||
let (account, password, agent, uid) = out.iter().find_map(|a| match a {
|
||||
NetAction::DeferPassword { account, password, agent, uid } => Some((account.clone(), password.clone(), agent.clone(), uid.clone())),
|
||||
_ => None,
|
||||
}).expect("a valid code defers the new password");
|
||||
assert_eq!(password, "brandnew");
|
||||
e.complete_password_change(&account, Db::derive_credentials(&password, 4096), &agent, &uid);
|
||||
assert!(e.db.authenticate("alice", "brandnew").is_some(), "new password works");
|
||||
assert!(e.db.authenticate("alice", "sesame").is_none(), "old password rejected");
|
||||
|
||||
// A used/wrong code is refused.
|
||||
assert!(to_ns(&mut e, "RESETPASS alice 000000 x").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("Invalid or expired"))));
|
||||
}
|
||||
|
||||
// GHOST renames off a session using a nick the caller owns.
|
||||
#[test]
|
||||
fn nickserv_ghost() {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ impl ServiceCtx {
|
|||
});
|
||||
}
|
||||
|
||||
// Send an email (the link layer pipes it to the configured mail command).
|
||||
pub fn send_email(&mut self, to: impl Into<String>, subject: impl Into<String>, body: impl Into<String>) {
|
||||
self.actions.push(NetAction::SendEmail { to: to.into(), subject: subject.into(), body: body.into() });
|
||||
}
|
||||
|
||||
// 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>) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue