Files
RustProxy/src/auth/store.rs
T
2026-10-05 09:30:21 +02:00

258 lines
11 KiB
Rust

//! Transactional administrator operations. Login/reset races are guarded by generation CAS.
use super::{now, random_secret, secret_hash, Session, SessionToken, SESSION_SECONDS};
use crate::db::{audit_tx, Store};
use anyhow::{ensure, Result};
use sqlx::Row;
#[derive(Debug, Clone)]
pub struct AdminRecord {
pub username: String,
pub password_hash: String,
pub generation: i64,
}
impl Store {
pub async fn admin_record(&self) -> Result<Option<AdminRecord>> {
sqlx::query("SELECT username, password_hash, generation FROM administrator WHERE id = 1")
.fetch_optional(&self.pool)
.await?
.map(|row| {
Ok(AdminRecord {
username: row.try_get("username")?,
password_hash: row.try_get("password_hash")?,
generation: row.try_get("generation")?,
})
})
.transpose()
}
pub async fn initialize_admin(&self, username: &str, hash: &str, source: &str) -> Result<bool> {
let mut tx = self.pool.begin().await?;
let inserted = sqlx::query("INSERT OR IGNORE INTO administrator(id, username, password_hash, generation, created_at) VALUES(1, ?, ?, 1, ?)")
.bind(username).bind(hash).bind(now()).execute(&mut *tx).await?;
if inserted.rows_affected() != 1 {
return Ok(false);
}
audit_tx(&mut tx, username, "admin.initialize", source).await?;
tx.commit().await?;
Ok(true)
}
/// One persisted global bucket for the single administration listener.
/// Fixed upper bound: 10 attempts per 60-second window, including successful attempts.
/// Unknown usernames use the same bucket and password work as known usernames.
pub async fn consume_auth_attempt(&self, time: i64) -> Result<bool> {
let changed = sqlx::query("UPDATE auth_throttle SET attempts = CASE WHEN window_start <= ? THEN 1 ELSE attempts + 1 END, window_start = CASE WHEN window_start <= ? THEN ? ELSE window_start END WHERE id = 1 AND (window_start <= ? OR attempts < 10)")
.bind(time - 60).bind(time - 60).bind(time).bind(time - 60)
.execute(&self.pool).await?;
Ok(changed.rows_affected() == 1)
}
/// The first UPDATE obtains SQLite's write lock and validates the credential generation.
/// A reset committed during password verification prevents insertion of a stale session.
pub async fn create_session(
&self,
generation: i64,
kind: &str,
previous: Option<&str>,
) -> Result<Option<SessionToken>> {
self.create_session_with_lifetime(generation, kind, previous, SESSION_SECONDS, 60)
.await
}
pub async fn create_browser_session(
&self,
generation: i64,
previous: Option<&str>,
session_seconds: i64,
) -> Result<Option<SessionToken>> {
ensure!(
(7 * 24 * 60 * 60..=30 * 24 * 60 * 60).contains(&session_seconds),
"browser session lifetime must be 7..30 days"
);
self.create_session_with_lifetime(generation, "browser", previous, session_seconds, 60)
.await
}
pub async fn create_live_log_session(&self, generation: i64) -> Result<Option<SessionToken>> {
self.create_session_with_lifetime(generation, "cli", None, SESSION_SECONDS, 8 * 60 * 60)
.await
}
async fn create_session_with_lifetime(
&self,
generation: i64,
kind: &str,
previous: Option<&str>,
browser_lifetime: i64,
cli_lifetime: i64,
) -> Result<Option<SessionToken>> {
let time = now();
let secret = random_secret();
let hash = secret_hash(&secret);
let csrf = random_secret();
let lifetime = if kind == "cli" {
cli_lifetime
} else {
browser_lifetime
};
let mut tx = self.pool.begin().await?;
let valid = sqlx::query(
"UPDATE administrator SET generation = generation WHERE id = 1 AND generation = ?",
)
.bind(generation)
.execute(&mut *tx)
.await?;
if valid.rows_affected() == 0 {
return Ok(None);
}
let username: String =
sqlx::query_scalar("SELECT username FROM administrator WHERE id = 1")
.fetch_one(&mut *tx)
.await?;
sqlx::query("DELETE FROM admin_sessions WHERE expires_at <= ? OR (kind = 'browser' AND created_at + ? <= ?)")
.bind(time)
.bind(browser_lifetime)
.bind(time)
.execute(&mut *tx)
.await?;
if let Some(previous) = previous {
sqlx::query("DELETE FROM admin_sessions WHERE token_hash = ?")
.bind(secret_hash(previous))
.execute(&mut *tx)
.await?;
}
// Independent caps avoid a local CLI burst evicting all browser sessions.
let cap = if kind == "cli" { 8_i64 } else { 16_i64 };
sqlx::query("DELETE FROM admin_sessions WHERE token_hash IN (SELECT token_hash FROM admin_sessions WHERE kind = ? ORDER BY created_at DESC, rowid DESC LIMIT -1 OFFSET ?)")
.bind(kind).bind(cap - 1).execute(&mut *tx).await?;
sqlx::query("INSERT INTO admin_sessions(token_hash, csrf_token, generation, created_at, last_seen, expires_at, kind) VALUES(?, ?, ?, ?, ?, ?, ?)")
.bind(&hash).bind(&csrf).bind(generation).bind(time).bind(time).bind(time + lifetime)
.bind(kind).execute(&mut *tx).await?;
audit_tx(&mut tx, &username, "session.create", kind).await?;
tx.commit().await?;
Ok(Some(SessionToken {
secret,
session: Session {
token_hash: hash,
csrf,
username,
expires_at: time + lifetime,
generation,
},
}))
}
pub async fn session_at(&self, secret: &str, time: i64) -> Result<Option<Session>> {
self.session_at_with_browser_lifetime(secret, time, SESSION_SECONDS)
.await
}
pub async fn session_at_with_browser_lifetime(
&self,
secret: &str,
time: i64,
browser_lifetime: i64,
) -> Result<Option<Session>> {
ensure!(
(7 * 24 * 60 * 60..=30 * 24 * 60 * 60).contains(&browser_lifetime),
"browser session lifetime must be 7..30 days"
);
let hash = secret_hash(secret);
// A browser session is bounded by the currently configured lifetime. If the
// setting was increased while an older still-valid session exists, extend its
// absolute expiry from its original creation time without turning it into a
// sliding/immortal session. CLI capabilities keep the fixed expiry chosen
// when they are created (60 seconds for one-shot admin commands, longer
// for the bounded local live-log viewer).
let row = sqlx::query("UPDATE admin_sessions SET last_seen = ?, expires_at = CASE WHEN kind = 'browser' THEN created_at + ? ELSE expires_at END WHERE token_hash = ? AND expires_at > ? AND (kind <> 'browser' OR created_at + ? > ?) AND generation = (SELECT generation FROM administrator WHERE id = 1) RETURNING csrf_token, expires_at, generation, (SELECT username FROM administrator WHERE id = 1) AS username")
.bind(time)
.bind(browser_lifetime)
.bind(&hash)
.bind(time)
.bind(browser_lifetime)
.bind(time)
.fetch_optional(&self.pool).await?;
row.map(|row| {
Ok(Session {
token_hash: hash,
csrf: row.try_get("csrf_token")?,
username: row.try_get("username")?,
expires_at: row.try_get("expires_at")?,
generation: row.try_get("generation")?,
})
})
.transpose()
}
pub async fn admin_preferences(&self, username: &str) -> Result<Option<String>> {
sqlx::query_scalar(
"SELECT preferences.document FROM admin_preferences AS preferences JOIN administrator AS admin ON admin.id = preferences.administrator_id WHERE admin.id = 1 AND admin.username = ?",
)
.bind(username)
.fetch_optional(&self.pool)
.await
.map_err(Into::into)
}
pub async fn save_admin_preferences(&self, username: &str, document: &str) -> Result<bool> {
ensure!(
(2..=4096).contains(&document.len()),
"administrator preferences document is too large"
);
let result = sqlx::query(
"INSERT INTO admin_preferences(administrator_id, document) SELECT id, ? FROM administrator WHERE id = 1 AND username = ? ON CONFLICT(administrator_id) DO UPDATE SET document = excluded.document",
)
.bind(document)
.bind(username)
.execute(&self.pool)
.await?;
Ok(result.rows_affected() == 1)
}
pub async fn end_session(&self, session: &Session) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("DELETE FROM admin_sessions WHERE token_hash = ?")
.bind(&session.token_hash)
.execute(&mut *tx)
.await?;
audit_tx(
&mut tx,
&session.username,
"session.logout",
"current session",
)
.await?;
tx.commit().await?;
Ok(())
}
/// expected_generation protects browser password changes against simultaneous resets.
/// CLI passes the generation it read, so even two offline recoveries cannot silently win.
pub async fn reset_admin_password(
&self,
hash: &str,
expected_generation: i64,
source: &str,
) -> Result<bool> {
let mut tx = self.pool.begin().await?;
let result = sqlx::query("UPDATE administrator SET password_hash = ?, generation = generation + 1 WHERE id = 1 AND generation = ? AND generation < 9223372036854775807")
.bind(hash).bind(expected_generation).execute(&mut *tx).await?;
if result.rows_affected() == 0 {
return Ok(false);
}
let username: String =
sqlx::query_scalar("SELECT username FROM administrator WHERE id = 1")
.fetch_one(&mut *tx)
.await?;
sqlx::query("DELETE FROM admin_sessions")
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE auth_throttle SET window_start = 0, attempts = 0 WHERE id = 1")
.execute(&mut *tx)
.await?;
audit_tx(&mut tx, &username, "admin.password_reset", source).await?;
tx.commit().await?;
Ok(true)
}
pub async fn revoke_all_sessions(&self, actor: &str) -> Result<()> {
let mut tx = self.pool.begin().await?;
sqlx::query("UPDATE administrator SET generation = generation + 1 WHERE id = 1 AND generation < 9223372036854775807")
.execute(&mut *tx).await?;
sqlx::query("DELETE FROM admin_sessions")
.execute(&mut *tx)
.await?;
audit_tx(&mut tx, actor, "session.revoke_all", "all sessions").await?;
tx.commit().await?;
Ok(())
}
}