Add persisted account store with NickServ REGISTER/IDENTIFY
Accounts are an append-only JSONL event log (state is the fold of the log) with argon2id-hashed passwords. NickServ gains REGISTER <password> [email] and IDENTIFY <password>; the engine tracks UID->nick from the burst so a command resolves to the sender's current nick. Classic and ircd-agnostic — the cap-based account-registration forward is the next step.
This commit is contained in:
parent
7929f5f9f4
commit
9285105afd
11 changed files with 348 additions and 41 deletions
101
src/engine/db.rs
Normal file
101
src/engine/db.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use argon2::password_hash::rand_core::OsRng;
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Account {
|
||||
pub name: String,
|
||||
pub password_hash: String,
|
||||
pub email: Option<String>,
|
||||
pub ts: u64,
|
||||
}
|
||||
|
||||
// Event-sourced persistence: every change is an Event appended to a JSONL log,
|
||||
// and account state is the fold of that log. Replicating this log across nodes
|
||||
// is what turns the store federated later, without changing the services.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "event")]
|
||||
pub enum Event {
|
||||
AccountRegistered(Account),
|
||||
}
|
||||
|
||||
pub enum RegError {
|
||||
Exists,
|
||||
Internal,
|
||||
}
|
||||
|
||||
pub struct Db {
|
||||
accounts: HashMap<String, Account>, // keyed by casefolded name
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
fn key(name: &str) -> String {
|
||||
name.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn open(path: impl Into<PathBuf>) -> Self {
|
||||
let path = path.into();
|
||||
let mut accounts = HashMap::new();
|
||||
if let Ok(data) = std::fs::read_to_string(&path) {
|
||||
for line in data.lines().filter(|l| !l.trim().is_empty()) {
|
||||
match serde_json::from_str::<Event>(line) {
|
||||
Ok(Event::AccountRegistered(a)) => {
|
||||
accounts.insert(key(&a.name), a);
|
||||
}
|
||||
Err(e) => tracing::warn!(%e, "skipping malformed event log line"),
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(accounts = accounts.len(), ?path, "account store loaded");
|
||||
Self { accounts, path }
|
||||
}
|
||||
|
||||
pub fn exists(&self, name: &str) -> bool {
|
||||
self.accounts.contains_key(&key(name))
|
||||
}
|
||||
|
||||
pub fn register(&mut self, name: &str, password: &str, email: Option<String>) -> Result<(), RegError> {
|
||||
if self.exists(name) {
|
||||
return Err(RegError::Exists);
|
||||
}
|
||||
let password_hash = hash_password(password).ok_or(RegError::Internal)?;
|
||||
let account = Account { name: name.to_string(), password_hash, email, ts: now() };
|
||||
self.append(&Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?;
|
||||
self.accounts.insert(key(name), account);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn verify(&self, name: &str, password: &str) -> bool {
|
||||
match self.accounts.get(&key(name)) {
|
||||
Some(a) => verify_password(password, &a.password_hash),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn append(&self, event: &Event) -> std::io::Result<()> {
|
||||
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?;
|
||||
writeln!(f, "{}", serde_json::to_string(event).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
fn verify_password(password: &str, hash: &str) -> bool {
|
||||
PasswordHash::new(hash)
|
||||
.map(|parsed| Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue