Redeem website login keycards over SASL
All checks were successful
CI / check (push) Successful in 3m52s
All checks were successful
CI / check (push) Successful in 3m52s
A member already authenticated on tchatou.fr connects with a single-use kc_… keycard instead of their password (the m_apiauth module that used to validate it was retired in the Anope->echo cutover, so these logins had started failing as "wrong password"). The SASL PLAIN handler now recognises a kc_-prefixed credential and emits NetAction::DeferKeycard; the link layer redeems it off the engine lock — a localhost round-trip to Django's login-token endpoint via keycard::redeem — and completes the login exactly like a password auth. Config: [keycard] url + api_key; absent, kc_ credentials are simply not honoured.
This commit is contained in:
parent
bad17a184e
commit
f2fd80694d
9 changed files with 359 additions and 4 deletions
|
|
@ -41,6 +41,20 @@ pub struct Config {
|
|||
// and pushes accounts in; IRC can only authenticate.
|
||||
#[serde(default)]
|
||||
pub auth: Option<Auth>,
|
||||
// Website single-use keycards (passwordless web login). Absent = keycard
|
||||
// credentials (`kc_…` over SASL) are not honoured.
|
||||
#[serde(default)]
|
||||
pub keycard: Option<Keycard>,
|
||||
}
|
||||
|
||||
// Redeeming a website login keycard: a member already authenticated on the
|
||||
// website connects with a one-time `kc_…` token instead of their password. We
|
||||
// hand it to Django's localhost login-token endpoint, which redeems it and
|
||||
// confirms the account. `url` is that endpoint; `api_key` matches its X-API-Key.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Keycard {
|
||||
pub url: String,
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
// Account-authority configuration.
|
||||
|
|
|
|||
|
|
@ -59,13 +59,30 @@ impl Engine {
|
|||
// Decode, then defer the verify off the lock (the login
|
||||
// finish lands in Engine::complete_authenticate).
|
||||
match decode_plain(&response) {
|
||||
// A website login keycard (`kc_…`), not a password: it can't be
|
||||
// SCRAM-verified, so redeem it off-lock (link.rs) against Django.
|
||||
Some((authcid, passwd)) if passwd.starts_with("kc_") => {
|
||||
let account = match self.db.resolve_account(&authcid) {
|
||||
Some(a) => a.to_string(),
|
||||
None => authcid,
|
||||
};
|
||||
vec![NetAction::DeferKeycard {
|
||||
token: passwd,
|
||||
account: account.clone(),
|
||||
then: AuthThen::Sasl { agent: agent.clone(), client: client.clone(), account },
|
||||
}]
|
||||
}
|
||||
Some((authcid, passwd)) => match self.scram_verifier(&authcid) {
|
||||
Some((account, verifier)) => vec![NetAction::DeferAuthenticate {
|
||||
verifier,
|
||||
password: passwd,
|
||||
then: AuthThen::Sasl { agent: agent.clone(), client: client.clone(), account },
|
||||
}],
|
||||
None => mk("D", vec!["F".to_string()]),
|
||||
None => {
|
||||
let mut out = mk("D", vec!["F".to_string()]);
|
||||
out.extend(self.feed("AUTH", format!("\x0304✗\x03 {} — SASL PLAIN failed for \x02{authcid}\x02 (unknown account)", self.who(&client))));
|
||||
out
|
||||
}
|
||||
},
|
||||
None => mk("D", vec!["F".to_string()]),
|
||||
}
|
||||
|
|
|
|||
27
src/keycard.rs
Normal file
27
src/keycard.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
//! Redeeming website login keycards. A member already authenticated on the
|
||||
//! website (a Django session) connects with a single-use `kc_…` token instead of
|
||||
//! replaying their password. It isn't the account password, so it can't be
|
||||
//! checked against a SCRAM verifier — we hand it to Django's localhost
|
||||
//! login-token endpoint, which redeems the keycard (exactly once) and confirms
|
||||
//! the bound account.
|
||||
//!
|
||||
//! Runs on a blocking thread (see link.rs): a synchronous localhost round-trip,
|
||||
//! never on the reactor or under the engine lock.
|
||||
use crate::config::Keycard;
|
||||
|
||||
/// Redeem `token` for `account` against the configured endpoint. Returns true
|
||||
/// only on a confirmed success (HTTP 2xx). A missing config, transport error, or
|
||||
/// any non-2xx (bad / expired / already-used / account-mismatched keycard) is a
|
||||
/// clean `false` — the caller then fails the SASL exchange.
|
||||
pub fn redeem(cfg: Option<&Keycard>, account: &str, token: &str) -> bool {
|
||||
let Some(cfg) = cfg else { return false };
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(std::time::Duration::from_secs(3))
|
||||
.timeout_read(std::time::Duration::from_secs(4))
|
||||
.build();
|
||||
agent
|
||||
.post(&cfg.url)
|
||||
.set("X-API-Key", &cfg.api_key)
|
||||
.send_form(&[("username", account), ("password", token)])
|
||||
.is_ok()
|
||||
}
|
||||
14
src/link.rs
14
src/link.rs
|
|
@ -71,7 +71,7 @@ fn redact(line: &str) -> Cow<'_, str> {
|
|||
// One uplink session: connect, handshake + burst, then translate lines forever.
|
||||
// The engine is shared with the gossip layer, so it is locked per operation and
|
||||
// never held across the registration key-stretching await.
|
||||
pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver<NetAction>, email: Option<crate::config::Email>) -> Result<()> {
|
||||
pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr: &str, mut irc_rx: mpsc::UnboundedReceiver<NetAction>, email: Option<crate::config::Email>, keycard: Option<crate::config::Keycard>) -> Result<()> {
|
||||
let stream = TcpStream::connect(addr).await?;
|
||||
let (read, mut write) = stream.into_split();
|
||||
let mut lines = BufReader::new(read).lines();
|
||||
|
|
@ -131,6 +131,18 @@ pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr:
|
|||
.await?;
|
||||
engine.lock().await.complete_authenticate(ok, then)
|
||||
}
|
||||
NetAction::DeferKeycard { token, account, then } => {
|
||||
// Redeem a website login keycard off the reactor (a localhost
|
||||
// HTTP round-trip), then finish the login under the lock — the
|
||||
// completion is identical to a password auth once we know the
|
||||
// outcome.
|
||||
let kc = keycard.clone();
|
||||
let ok = tokio::task::spawn_blocking(move || {
|
||||
crate::keycard::redeem(kc.as_ref(), &account, &token)
|
||||
})
|
||||
.await?;
|
||||
engine.lock().await.complete_authenticate(ok, then)
|
||||
}
|
||||
action => vec![action],
|
||||
};
|
||||
for act in outs {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ mod engine;
|
|||
mod gossip;
|
||||
mod grpc;
|
||||
mod jsonrpc;
|
||||
mod keycard;
|
||||
mod link;
|
||||
mod migrate;
|
||||
mod proto;
|
||||
|
|
@ -262,7 +263,7 @@ async fn main() -> Result<()> {
|
|||
// just lets systemd stop us without waiting out the kill timeout.
|
||||
let shutdown_engine = engine.clone();
|
||||
tokio::select! {
|
||||
res = link::run(proto, engine, &addr, irc_rx, cfg.email.clone()) => res,
|
||||
res = link::run(proto, engine, &addr, irc_rx, cfg.email.clone(), cfg.keycard.clone()) => res,
|
||||
_ = shutdown_signal() => {
|
||||
// Flush stat counters so a clean stop/restart keeps StatServ history.
|
||||
shutdown_engine.lock().await.persist_stats();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue