use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use sqlx::SqlitePool;
use tokio::sync::{Mutex, RwLock};
use crate::auth::oidc::Discovery;
use crate::config::{AuthMode, Config, ConfigError, OidcSettings};
use crate::db::crypto::Clave;
use crate::db::entities::{Ejecuciones, Repositorios, Restauraciones, Tareas, Targets};
use crate::engine::Motor;
use crate::secret::SecretString;
pub const STATE_TTL: Duration = Duration::from_secs(5 * 60);
#[derive(Debug, Clone)]
pub struct PendingLogin {
pub nonce: String,
pub issued_at: Instant,
}
impl PendingLogin {
pub fn expirado_en(&self, ahora: Instant) -> bool {
ahora.saturating_duration_since(self.issued_at) >= STATE_TTL
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RechazoState {
Desconocido,
Caducado,
Reutilizado,
}
#[derive(Debug, Clone)]
pub struct AuthState {
pub secret: SecretString,
pub(crate) settings: Arc<OidcSettings>,
pub(crate) discovery: Arc<RwLock<Option<Discovery>>>,
pub(crate) logins: Arc<Mutex<HashMap<String, PendingLogin>>>,
pub(crate) consumidos: Arc<Mutex<HashMap<String, Instant>>>,
}
impl AuthState {
pub(crate) fn new(settings: OidcSettings, secret: SecretString) -> Self {
Self {
secret,
settings: Arc::new(settings),
discovery: Arc::new(RwLock::new(None)),
logins: Arc::new(Mutex::new(HashMap::new())),
consumidos: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn registrar_login(&self, state: &str, nonce: &str) {
let ahora = Instant::now();
let mut logins = self.logins.lock().await;
logins.retain(|_, pendiente| !pendiente.expirado_en(ahora));
logins.insert(
state.to_owned(),
PendingLogin {
nonce: nonce.to_owned(),
issued_at: ahora,
},
);
}
pub(crate) async fn consumir_login(&self, state: &str) -> Result<PendingLogin, RechazoState> {
let ahora = Instant::now();
let mut logins = self.logins.lock().await;
let mut consumidos = self.consumidos.lock().await;
let entrada = logins.remove(state);
logins.retain(|_, pendiente| !pendiente.expirado_en(ahora));
consumidos.retain(|_, gastado_en| ahora.saturating_duration_since(*gastado_en) < STATE_TTL);
match entrada {
None => {
if consumidos.contains_key(state) {
Err(RechazoState::Reutilizado)
} else {
Err(RechazoState::Desconocido)
}
}
Some(pendiente) if pendiente.expirado_en(ahora) => Err(RechazoState::Caducado),
Some(pendiente) => {
consumidos.insert(state.to_owned(), ahora);
Ok(pendiente)
}
}
}
}
#[derive(Clone)]
pub struct EstadoDeBase {
pub pool: SqlitePool,
pub clave: Arc<Clave>,
pub repositorios: Arc<Repositorios>,
pub targets: Arc<Targets>,
pub tareas: Arc<Tareas>,
pub ejecuciones: Arc<Ejecuciones>,
pub restauraciones: Arc<Restauraciones>,
}
impl EstadoDeBase {
pub fn nuevo(pool: SqlitePool, clave: Arc<Clave>) -> Self {
Self {
repositorios: Arc::new(Repositorios::nuevo(pool.clone(), clave.clone())),
targets: Arc::new(Targets::nuevo(pool.clone(), clave.clone())),
tareas: Arc::new(Tareas::nuevo(pool.clone())),
ejecuciones: Arc::new(Ejecuciones::nuevo(pool.clone(), clave.clone())),
restauraciones: Arc::new(Restauraciones::nuevo(pool.clone())),
pool,
clave,
}
}
}
#[derive(Clone)]
pub struct AppState {
pub config: Arc<Config>,
pub auth: Option<AuthState>,
pub db: EstadoDeBase,
pub motor: Motor,
}
impl AppState {
pub fn new(config: Config, db: EstadoDeBase, motor: Motor) -> Result<Self, ConfigError> {
let auth = match &config.auth {
AuthMode::Oidc(settings) => match &config.secret {
Some(secret) => Some(AuthState::new(settings.clone(), secret.clone())),
None => {
return Err(ConfigError::MissingVariable {
name: "KESTO_SECRET",
});
}
},
AuthMode::Disabled => None,
};
Ok(Self {
config: Arc::new(config),
auth,
db,
motor,
})
}
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use time::OffsetDateTime;
use super::{EstadoDeBase, PendingLogin, STATE_TTL};
use crate::config::Config;
use crate::db::entities::{
AltaDeEjecucion, AltaDeRepositorio, AltaDeTarea, AltaDeTarget, Estado, TipoDeTarget,
};
use crate::secret::SecretString;
use crate::test_support::{
MASTER_KEY, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_ISSUER_URL, SESSION_SECRET, auth_oidc,
base_con_clave, config_con_dsn, estado_de_prueba, lookup_from,
};
#[tokio::test]
async fn la_purga_descarta_las_entradas_caducadas() {
let auth = auth_oidc().await;
let hace = Instant::now()
.checked_sub(STATE_TTL + Duration::from_secs(1))
.expect("la purga necesita un uptime mayor que STATE_TTL");
{
let mut logins = auth.logins.lock().await;
logins.insert(
"state-caducado".to_owned(),
PendingLogin {
nonce: "nonce-caducado".to_owned(),
issued_at: hace,
},
);
assert_eq!(logins.len(), 1, "el mapa arranca con la entrada caducada");
}
auth.registrar_login("state-nuevo", "nonce-nuevo").await;
let logins = auth.logins.lock().await;
assert_eq!(
logins.len(),
1,
"la entrada caducada debe purgarse al insertar: el mapa no puede crecer: {logins:?}"
);
assert!(
!logins.contains_key("state-caducado"),
"la entrada caducada sigue en el mapa: {logins:?}"
);
assert!(
logins.contains_key("state-nuevo"),
"la entrada nueva debe quedar registrada: {logins:?}"
);
}
#[test]
fn el_ttl_del_state_es_de_cinco_minutos() {
assert_eq!(
STATE_TTL,
Duration::from_secs(300),
"el TTL del state debe ser de 5 minutos"
);
}
#[tokio::test]
async fn en_desarrollo_no_hay_estado_de_autenticacion() {
let config = Config::from_lookup(&lookup_from(&[("KESTO_MASTER_KEY", MASTER_KEY)]))
.expect("la configuración de desarrollo es válida");
let (_directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(config, EstadoDeBase::nuevo(pool, clave));
assert!(
estado.auth.is_none(),
"sin proveedor no hay capa que montar ni secreto que guardar"
);
}
#[tokio::test]
async fn con_oidc_el_estado_lleva_la_clave_y_no_la_publica() {
let config = Config::from_lookup(&lookup_from(&[
("KESTO_MASTER_KEY", MASTER_KEY),
("KESTO_SECRET", SESSION_SECRET),
("OIDC_ISSUER_URL", OIDC_ISSUER_URL),
("OIDC_CLIENT_ID", OIDC_CLIENT_ID),
("OIDC_CLIENT_SECRET", OIDC_CLIENT_SECRET),
]))
.expect("la configuración OIDC es válida");
let (_directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(config, EstadoDeBase::nuevo(pool, clave));
let auth = estado
.auth
.as_ref()
.expect("con Oidc hay capa de autenticación");
assert_eq!(auth.secret.expose(), SESSION_SECRET);
let en_debug = format!("{auth:?}");
assert!(
!en_debug.contains(SESSION_SECRET),
"el Debug del estado publica la clave de firma: {en_debug}"
);
}
#[tokio::test]
async fn el_estado_lleva_el_pool_y_las_cuatro_capas() {
const SECRETO: &str = "AKIAIOSFODNN7EXAMPLE-estado-1-1";
const PASSPHRASE: &str = "passphrase-distinguible-000000000000000000000000001-1";
let (directorio, pool, clave) = base_con_clave().await;
let estado = estado_de_prueba(
config_con_dsn(&directorio.dsn()),
EstadoDeBase::nuevo(pool.clone(), clave),
);
let repositorio = estado
.db
.repositorios
.alta(AltaDeRepositorio {
name: "repo-1-1".to_owned(),
endpoint: "https://s3.example.test/repo-1-1".to_owned(),
bucket: "cubo-repo-1-1".to_owned(),
region: "eu-west-1".to_owned(),
access_key_id: "AKIAIOSFODNN7EXAMPLE".to_owned(),
secret_access_key: SecretString::new(SECRETO.to_owned()),
passphrase: SecretString::new(PASSPHRASE.to_owned()),
})
.await
.expect("el alta por la capa del estado no puede fallar");
let nombre: String = sqlx::query_scalar("SELECT name FROM repositories WHERE id = ?1")
.bind(repositorio.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("la fila debe estar en el fichero de la base del estado");
assert_eq!(
nombre, "repo-1-1",
"la capa de repositorios no escribió en el pool del estado"
);
let guardado: String =
sqlx::query_scalar("SELECT secret_access_key FROM repositories WHERE id = ?1")
.bind(repositorio.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("el sobre almacenado debe poder leerse");
assert!(
guardado.starts_with("v1:"),
"el secreto no quedó cifrado en la columna: {guardado}"
);
assert_eq!(
estado
.db
.clave
.descifrar(&guardado)
.expect("la clave del estado debe descifrar lo que escribió su propia capa")
.expose(),
SECRETO,
"la capa de repositorios no cifró con la clave del estado"
);
let target = estado
.db
.targets
.alta(AltaDeTarget {
name: "target-1-1".to_owned(),
target_type: TipoDeTarget::Directory,
container_name: None,
db_user: None,
db_password: None,
db_name: None,
host_path: Some("/srv/target-1-1".to_owned()),
db_path: None,
})
.await
.expect("el alta del target por la capa del estado no puede fallar");
let tarea = estado
.db
.tareas
.alta(AltaDeTarea {
target_id: target.id,
repository_id: repositorio.id,
cron_expression: "0 3 * * *".to_owned(),
keep_daily: 7,
keep_weekly: 4,
keep_monthly: 12,
enabled: true,
name: None,
})
.await
.expect("el alta de la tarea por la capa del estado no puede fallar");
let ejecucion = estado
.db
.ejecuciones
.alta(AltaDeEjecucion {
job_id: tarea.id,
status: Estado::Pending,
snapshot_id: None,
bytes_processed: 0,
bytes_uploaded: 0,
error_message: None,
started_at: OffsetDateTime::now_utc(),
finished_at: None,
})
.await
.expect("el alta de la ejecución por la capa del estado no puede fallar");
let targets_en_la_base: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM backup_targets WHERE id = ?1")
.bind(target.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("el recuento de targets no puede fallar");
assert_eq!(
targets_en_la_base, 1,
"la capa de targets no escribió en el pool del estado"
);
let tareas_en_la_base: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM backup_jobs WHERE id = ?1")
.bind(tarea.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("el recuento de tareas no puede fallar");
assert_eq!(
tareas_en_la_base, 1,
"la capa de tareas no escribió en el pool del estado"
);
let ejecuciones_en_la_base: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM job_executions WHERE id = ?1")
.bind(ejecucion.id.hyphenated().to_string())
.fetch_one(&pool)
.await
.expect("el recuento de ejecuciones no puede fallar");
assert_eq!(
ejecuciones_en_la_base, 1,
"la capa de ejecuciones no escribió en el pool del estado"
);
}
}