Serveur Rust : socle (config, base, auth, sessions, journaux, cache FF, SPA)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
This commit is contained in:
Claude committed 2026-10-12 18:56:23 +00:00
1 parent d2c3dc7f8e
commit 3f69b1904e
28 files changed
+5873

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
//! Authentification de l'API publique `/api/v1`.
//!
//! Deux voies : clé d'API (`X-API-Key` ou `Authorization: Bearer …`) pour les
//! appelants non-navigateur, ou session du navigateur (l'interface utilise les
//! mêmes routes). Échec → 401 JSON au format `/api/v1`, jamais de redirection.
//!
//! Seul le hachage SHA-256 des clés est stocké (table `api_keys`).
use crate::api_v1_response::{fail, ApiErrorCode};
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::HeaderMap;
use axum::response::Response;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use parking_lot::Mutex;
use rand::RngCore;
use sha2::{Digest, Sha256};
use sqlx::Row;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use subtle::ConstantTimeEq;
const KEY_PREFIX: &str = "cf_";
const TRACE_USAGE: Duration = Duration::from_secs(60);
static DERNIERE_TRACE: LazyLock<Mutex<HashMap<i32, Instant>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone)]
pub struct AuthContext {
/// "api_key" | "session"
pub via: &'static str,
pub role: String,
/// Nom de la clé, ou nom de l'utilisateur connecté.
pub subject: String,
}
impl AuthContext {
/// Restreint un endpoint aux appelants administrateurs.
pub fn require_admin(&self) -> Result<(), Response> {
if self.role != "admin" {
return Err(fail(ApiErrorCode::Forbidden, "Rôle administrateur requis pour cette opération.", None));
}
Ok(())
}
}
pub fn hash_api_key(key: &str) -> String {
hex::encode(Sha256::digest(key.as_bytes()))
}
pub struct GeneratedKey {
pub key: String,
pub key_hash: String,
pub key_prefix: String,
}
/// Génère une clé : en clair (affichée une seule fois), son hachage, et un préfixe lisible.
pub fn generate_api_key() -> GeneratedKey {
let mut buf = [0u8; 24];
rand::thread_rng().fill_bytes(&mut buf);
let key = format!("{KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(buf));
GeneratedKey { key_hash: hash_api_key(&key), key_prefix: key.chars().take(11).collect(), key }
}
fn hashes_equal(a: &str, b: &str) -> bool {
match (hex::decode(a), hex::decode(b)) {
(Ok(x), Ok(y)) => !x.is_empty() && x.len() == y.len() && bool::from(x.as_slice().ct_eq(y.as_slice())),
_ => false,
}
}
fn read_key(headers: &HeaderMap) -> Option<String> {
if let Some(v) = headers.get("x-api-key").and_then(|v| v.to_str().ok()) {
if !v.trim().is_empty() {
return Some(v.trim().to_string());
}
}
let authz = headers.get("authorization").and_then(|v| v.to_str().ok())?;
let re = regex::Regex::new(r"(?i)^Bearer\s+(.+)$").unwrap();
re.captures(authz).map(|c| c[1].trim().to_string())
}
/// Authentifie une requête `/api/v1`.
pub async fn require_api_auth(headers: &HeaderMap) -> Result<AuthContext, Response> {
if let Some(presented) = read_key(headers) {
let presented_hash = hash_api_key(&presented);
let pool = crate::db::pool().map_err(|e| fail(ApiErrorCode::InternalError, e.message, None))?;
let row = sqlx::query("SELECT id, name, role, key_hash, revoked_at FROM api_keys WHERE key_hash = $1 LIMIT 1")
.bind(&presented_hash)
.fetch_optional(&pool)
.await
.map_err(|e| fail(ApiErrorCode::InternalError, e.to_string(), None))?;
let Some(row) = row else {
return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API invalide.", None));
};
let key_hash: String = row.try_get("key_hash").unwrap_or_default();
if !hashes_equal(&key_hash, &presented_hash) {
return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API invalide.", None));
}
let revoked: Option<chrono::NaiveDateTime> = row.try_get("revoked_at").ok().flatten();
if revoked.is_some() {
return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API révoquée.", None));
}
let id: i32 = row.try_get("id").unwrap_or_default();
// Trace d'usage — jamais bloquante, au plus une écriture par minute et par clé.
let tracer = {
let mut m = DERNIERE_TRACE.lock();
let due = m.get(&id).map(|t| t.elapsed() >= TRACE_USAGE).unwrap_or(true);
if due {
m.insert(id, Instant::now());
}
due
};
if tracer {
tokio::spawn(async move {
if let Err(e) = sqlx::query("UPDATE api_keys SET last_used_at = now() WHERE id = $1").bind(id).execute(&pool).await {
tracing::error!("[api-auth] lastUsedAt KO: {}", crate::error::short(&e, 120));
}
});
}
return Ok(AuthContext {
via: "api_key",
role: row.try_get("role").unwrap_or_else(|_| "user".into()),
subject: row.try_get("name").unwrap_or_default(),
});
}
// Repli sur la session du navigateur.
if let Some(c) = super::session::from_headers(headers) {
return Ok(AuthContext { via: "session", role: c.role, subject: c.name });
}
Err(fail(
ApiErrorCode::Unauthorized,
"Authentification requise : fournissez un en-tête X-API-Key (ou Authorization: Bearer), ou connectez-vous.",
None,
))
}
/// Extracteur pour les routes `/api/v1`.
pub struct ApiAuth(pub AuthContext);
impl<S: Send + Sync> FromRequestParts<S> for ApiAuth {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Ok(ApiAuth(require_api_auth(&parts.headers).await?))
}
}
+155
View File
@@ -0,0 +1,155 @@
//! Contrôle d'accès commun aux actions, routes et pages.
//!
//! Le jeton de session seul ne suffit pas : il reste valide jusqu'à son
//! expiration, même si le compte a été supprimé ou rétrogradé entre-temps. On
//! relit donc l'utilisateur dans `users` (cache court), et le rôle en base
//! l'emporte sur celui du jeton.
//!
//! Base injoignable : on se contente du jeton (mode dégradé), sans quoi le
//! compte de secours `data/users.json` ne pourrait plus rien faire — y compris
//! configurer la base.
use super::session::{from_headers, Claims};
use crate::error::AppError;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use parking_lot::Mutex;
use serde::Serialize;
use sqlx::Row;
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
pub const MESSAGE_SESSION: &str = "Session expirée : reconnectez-vous.";
pub const MESSAGE_ADMIN: &str = "Action réservée aux administrateurs.";
const CACHE_TTL: Duration = Duration::from_secs(60);
const PAUSE_BASE_INJOIGNABLE: Duration = Duration::from_secs(15);
#[derive(Debug, Clone, Serialize)]
pub struct UtilisateurCourant {
pub id: String,
pub username: String,
pub role: String,
}
impl UtilisateurCourant {
pub fn is_admin(&self) -> bool {
self.role == "admin"
}
/// Identifiant numérique (`users.id`), `None` pour le compte de secours.
pub fn id_numerique(&self) -> Option<i32> {
self.id.parse::<i32>().ok().filter(|v| *v > 0)
}
}
#[derive(Clone)]
struct Fiche {
exists: bool,
role: String,
username: String,
at: Instant,
}
struct CacheState {
fiches: HashMap<String, Fiche>,
injoignable_depuis: Option<Instant>,
}
static CACHE: LazyLock<Mutex<CacheState>> =
LazyLock::new(|| Mutex::new(CacheState { fiches: HashMap::new(), injoignable_depuis: None }));
/// Oublie la fiche d'un utilisateur (après modification de son compte).
pub fn invalider(id: &str) {
CACHE.lock().fiches.remove(id);
}
/// Fiche de l'utilisateur en base, ou `None` si la base n'a pas pu répondre.
async fn lire_fiche(id: &str) -> Option<Fiche> {
{
let c = CACHE.lock();
if let Some(f) = c.fiches.get(id) {
if f.at.elapsed() < CACHE_TTL {
return Some(f.clone());
}
}
if c.injoignable_depuis.map(|t| t.elapsed() < PAUSE_BASE_INJOIGNABLE).unwrap_or(false) {
return None;
}
}
let id_num: i32 = if id.chars().all(|c| c.is_ascii_digit()) && !id.is_empty() {
id.parse::<i32>().unwrap_or(-1)
} else {
-1
};
let res = async {
let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?;
let row = sqlx::query("SELECT username, role FROM users WHERE id = $1 LIMIT 1")
.bind(id_num)
.fetch_optional(&pool)
.await?;
anyhow::Ok(row)
}
.await;
match res {
Ok(row) => {
let fiche = match row {
Some(r) => Fiche {
exists: true,
username: r.try_get("username").unwrap_or_default(),
role: r.try_get("role").unwrap_or_default(),
at: Instant::now(),
},
None => Fiche { exists: false, username: String::new(), role: String::new(), at: Instant::now() },
};
CACHE.lock().fiches.insert(id.to_string(), fiche.clone());
Some(fiche)
}
Err(e) => {
CACHE.lock().injoignable_depuis = Some(Instant::now());
tracing::warn!("[authz] Base injoignable, contrôle sur le seul jeton de session : {}", crate::error::short(&e, 200));
None
}
}
}
/// Utilisateur connecté, relu en base. 401 sinon.
pub async fn require_session(token: Option<&Claims>) -> Result<UtilisateurCourant, AppError> {
let jeton = token.ok_or_else(|| AppError::unauthorized(MESSAGE_SESSION))?;
match lire_fiche(&jeton.id).await {
None => Ok(UtilisateurCourant { id: jeton.id.clone(), username: jeton.name.clone(), role: jeton.role.clone() }),
Some(f) if !f.exists => Err(AppError::unauthorized(MESSAGE_SESSION)),
Some(f) => Ok(UtilisateurCourant { id: jeton.id.clone(), username: f.username, role: f.role }),
}
}
/// Administrateur connecté. 401 ou 403 sinon.
pub async fn require_admin(token: Option<&Claims>) -> Result<UtilisateurCourant, AppError> {
let u = require_session(token).await?;
if !u.is_admin() {
return Err(AppError::forbidden(MESSAGE_ADMIN));
}
Ok(u)
}
/// Extracteur : utilisateur connecté (sinon 401 JSON `{ error }`).
pub struct SessionUser(pub UtilisateurCourant);
impl<S: Send + Sync> FromRequestParts<S> for SessionUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let claims = from_headers(&parts.headers);
Ok(SessionUser(require_session(claims.as_ref()).await?))
}
}
/// Extracteur : administrateur connecté (sinon 401/403 JSON `{ error }`).
pub struct AdminUser(pub UtilisateurCourant);
impl<S: Send + Sync> FromRequestParts<S> for AdminUser {
type Rejection = AppError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let claims = from_headers(&parts.headers);
Ok(AdminUser(require_admin(claims.as_ref()).await?))
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Comptes de secours `data/users.json`, utilisés seulement quand la base est
//! injoignable (première installation, base pas encore configurée).
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UserJson {
pub id: String,
pub username: String,
pub password_hash: String,
pub role: String,
}
fn path() -> PathBuf {
crate::config::data_dir().join("users.json")
}
fn default_admin() -> UserJson {
UserJson {
id: "0".into(),
username: "admin".into(),
password_hash: super::password::hash_password("admin"),
role: "admin".into(),
}
}
/// Comptes de secours (créés avec admin/admin si le fichier n'existe pas).
pub fn get_fallback_users() -> Vec<UserJson> {
let p = path();
if !p.exists() {
let admin = default_admin();
save_fallback_users(&[admin.clone()]);
return vec![admin];
}
match std::fs::read_to_string(&p).ok().and_then(|s| serde_json::from_str(&s).ok()) {
Some(users) => users,
None => {
tracing::error!("[AUTH] Error reading users.json");
vec![]
}
}
}
pub fn save_fallback_users(users: &[UserJson]) {
let p = path();
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Err(e) = std::fs::write(&p, serde_json::to_string_pretty(users).unwrap_or_default()) {
tracing::error!("[AUTH] Error writing users.json: {e}");
}
}
/// Base injoignable : le fichier de secours doit contenir au moins un administrateur.
pub fn assurer_admin_de_secours() {
if get_fallback_users().is_empty() {
tracing::info!("[AUTH] Initializing local JSON fallback with admin/admin...");
save_fallback_users(&[default_admin()]);
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Authentification : comptes, sessions, clés d'API.
pub mod api_key;
pub mod authz;
pub mod fallback;
pub mod password;
pub mod session;
pub use api_key::{ApiAuth, AuthContext};
pub use authz::{require_admin, require_session, AdminUser, SessionUser, UtilisateurCourant};
pub use session::{Claims, Token};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::Deserialize;
use serde_json::json;
use sqlx::Row;
use tokio::sync::Mutex;
// ---------------------------------------------------------------------------
// Amorçage du compte admin
// ---------------------------------------------------------------------------
/// `true` une fois l'amorçage réussi pour ce processus.
static AMORCE: Mutex<bool> = Mutex::const_new(false);
/// Prépare les comptes au premier démarrage, une seule fois par processus.
///
/// - Base joignable : crée la table `users` si besoin, puis admin/admin
/// **uniquement si la table est vide**.
/// - Base injoignable : s'assure que le fichier de secours contient un
/// administrateur ; on retentera la base à la prochaine connexion.
pub async fn ensure_admin_exists() -> bool {
let mut done = AMORCE.lock().await;
if *done {
return false;
}
match amorcer().await {
Ok(cree) => {
*done = true;
cree
}
Err(e) => {
tracing::warn!("[AUTH] Database seeding failed (expected if DB not configured yet): {e:#}");
false
}
}
}
async fn amorcer() -> anyhow::Result<bool> {
let pool = match crate::db::pool() {
Ok(p) => p,
Err(e) => {
fallback::assurer_admin_de_secours();
anyhow::bail!(e.message);
}
};
if let Err(e) = sqlx::query("SELECT 1").execute(&pool).await {
fallback::assurer_admin_de_secours();
return Err(e.into());
}
sqlx::raw_sql(
r#"CREATE TABLE IF NOT EXISTS "users" (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'user',
created_at TIMESTAMP DEFAULT NOW()
)"#,
)
.execute(&pool)
.await?;
let nb: i64 = sqlx::query_scalar("SELECT count(*) FROM users").fetch_one(&pool).await?;
if nb > 0 {
return Ok(false);
}
tracing::info!("[AUTH] Table users vide : création du compte admin/admin.");
let res = sqlx::query("INSERT INTO users (username, password_hash, role) VALUES ('admin', $1, 'admin') ON CONFLICT DO NOTHING")
.bind(password::hash_password("admin"))
.execute(&pool)
.await?;
Ok(res.rows_affected() > 0)
}
// ---------------------------------------------------------------------------
// Connexion / déconnexion
// ---------------------------------------------------------------------------
struct CompteTrouve {
id: String,
username: String,
password_hash: String,
role: String,
}
/// Vérifie les identifiants. `None` si refusés.
async fn authorize(username: &str, password: &str) -> Option<CompteTrouve> {
ensure_admin_exists().await;
if username.is_empty() || password.is_empty() {
return None;
}
tracing::info!("[AUTH] Checking user \"{username}\"...");
let db_lookup = async {
let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?;
let row = sqlx::query("SELECT id, username, password_hash, role FROM users WHERE username = $1")
.bind(username)
.fetch_optional(&pool)
.await?;
anyhow::Ok(row.map(|r| CompteTrouve {
id: r.try_get::<i32, _>("id").unwrap_or_default().to_string(),
username: r.try_get("username").unwrap_or_default(),
password_hash: r.try_get("password_hash").unwrap_or_default(),
role: r.try_get("role").unwrap_or_else(|_| "user".into()),
}))
};
let user = match db_lookup.await {
Ok(u) => {
tracing::info!("[AUTH] DB check: User found: {}", u.is_some());
u
}
Err(_) => {
// Base injoignable uniquement : un compte absent de la base ne doit
// pas pouvoir se connecter avec le fichier de secours.
tracing::warn!("[AUTH] DB unreachable, falling back to JSON.");
fallback::get_fallback_users().into_iter().find(|u| u.username == username).map(|u| CompteTrouve {
id: u.id,
username: u.username,
password_hash: u.password_hash,
role: u.role,
})
}
}?;
let ok = password::verify_password(password, &user.password_hash);
tracing::info!("[AUTH] Password valid for \"{username}\": {ok}");
ok.then_some(user)
}
#[derive(Deserialize)]
struct LoginBody {
#[serde(default)]
username: String,
#[serde(default)]
password: String,
}
async fn login(headers: HeaderMap, Json(body): Json<LoginBody>) -> Response {
let Some(user) = authorize(body.username.trim(), &body.password).await else {
return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "Utilisateur ou mot de passe incorrect." }))).into_response();
};
let claims = Claims {
id: user.id.clone(),
name: user.username.clone(),
role: user.role.clone(),
exp: chrono::Utc::now().timestamp() + session::MAX_AGE_SECS,
};
(
[(header::SET_COOKIE, session::set_cookie(&claims, &headers)), (header::CACHE_CONTROL, "no-store".into())],
Json(json!({ "ok": true, "user": { "id": user.id, "name": user.username, "role": user.role } })),
)
.into_response()
}
async fn logout() -> Response {
([(header::SET_COOKIE, session::clear_cookie())], Json(json!({ "ok": true }))).into_response()
}
/// Session courante pour l'interface (`useSession`). Relue en base : un compte
/// supprimé n'a plus de session.
async fn current_session(Token(token): Token) -> Response {
let user = match token.as_ref() {
Some(_) => authz::require_session(token.as_ref()).await.ok(),
None => None,
};
let body = match user {
Some(u) => json!({ "user": { "id": u.id, "name": u.username, "role": u.role } }),
None => json!({ "user": null }),
};
([(header::CACHE_CONTROL, "no-store")], Json(body)).into_response()
}
pub fn router() -> Router {
Router::new()
.route("/api/auth/login", post(login))
.route("/api/auth/logout", post(logout))
.route("/api/auth/session", get(current_session))
}
+60
View File
@@ -0,0 +1,60 @@
//! Mots de passe : scrypt au format `sel:hachage` (hex), compatible avec les
//! comptes créés par l'ancienne version Node (`scryptSync(pwd, sel, 64)`,
//! N=16384, r=8, p=1, le sel hexadécimal étant utilisé tel quel comme octets).
use rand::RngCore;
use subtle::ConstantTimeEq;
fn derive(password: &str, salt: &str) -> Option<[u8; 64]> {
let params = scrypt::Params::new(14, 8, 1, 64).ok()?;
let mut out = [0u8; 64];
scrypt::scrypt(password.as_bytes(), salt.as_bytes(), &params, &mut out).ok()?;
Some(out)
}
pub fn hash_password(password: &str) -> String {
let mut salt = [0u8; 16];
rand::thread_rng().fill_bytes(&mut salt);
let salt = hex::encode(salt);
let hash = derive(password, &salt).map(hex::encode).unwrap_or_default();
format!("{salt}:{hash}")
}
pub fn verify_password(password: &str, stored: &str) -> bool {
let Some((salt, hash)) = stored.split_once(':') else { return false };
if salt.is_empty() || hash.is_empty() {
return false;
}
let Ok(expected) = hex::decode(hash) else { return false };
let Some(actual) = derive(password, salt) else { return false };
expected.len() == actual.len() && bool::from(actual.as_slice().ct_eq(expected.as_slice()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip() {
let h = hash_password("admin");
assert!(verify_password("admin", &h));
assert!(!verify_password("nope", &h));
}
#[test]
fn compatible_node() {
// require("crypto").scryptSync("admin", "00112233445566778899aabbccddeeff", 64).toString("hex")
let stored = "00112233445566778899aabbccddeeff:".to_string()
+ &hex::encode(derive("admin", "00112233445566778899aabbccddeeff").unwrap());
assert!(verify_password("admin", &stored));
}
}
#[cfg(test)]
mod node_compat {
#[test]
fn hash_from_node() {
assert!(super::verify_password("s3cret", "abcdef0123456789abcdef0123456789:8e07d3f906fe581051e47f776fd3fb6307401dc4702369a6db2b7fbb892eb23571af616a59cf13a162cfd584035381c062b7d17fd4ca0d82a2cdf0e1ce2330aa"));
assert!(!super::verify_password("s3cre", "abcdef0123456789abcdef0123456789:8e07d3f906fe581051e47f776fd3fb6307401dc4702369a6db2b7fbb892eb23571af616a59cf13a162cfd584035381c062b7d17fd4ca0d82a2cdf0e1ce2330aa"));
}
}
+124
View File
@@ -0,0 +1,124 @@
//! Jeton de session signé (cookie HttpOnly), remplaçant le JWT NextAuth.
//!
//! Contenu : identifiant, nom et rôle de l'utilisateur + expiration, encodés en
//! base64url et signés HMAC-SHA256 avec `AUTH_SECRET` (ou un secret aléatoire
//! conservé dans `data/.auth-secret`).
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::HeaderMap;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use hmac::{Hmac, Mac};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::convert::Infallible;
use std::sync::LazyLock;
pub const COOKIE_NAME: &str = "cf_session";
/// Durée de vie d'une session : 30 jours (comme NextAuth).
pub const MAX_AGE_SECS: i64 = 30 * 24 * 3600;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
/// Identifiant (`users.id`, ou « 0 » pour le compte de secours).
pub id: String,
pub name: String,
pub role: String,
/// Expiration (secondes Unix).
pub exp: i64,
}
static SECRET: LazyLock<Vec<u8>> = LazyLock::new(|| {
if let Ok(s) = std::env::var("AUTH_SECRET") {
if !s.trim().is_empty() {
return s.into_bytes();
}
}
let path = crate::config::data_dir().join(".auth-secret");
if let Ok(s) = std::fs::read_to_string(&path) {
if !s.trim().is_empty() {
return s.trim().as_bytes().to_vec();
}
}
let mut buf = [0u8; 32];
rand::thread_rng().fill_bytes(&mut buf);
let s = hex::encode(buf);
let _ = std::fs::create_dir_all(crate::config::data_dir());
if let Err(e) = std::fs::write(&path, &s) {
tracing::warn!("[auth] secret de session non persisté ({e}) : les sessions ne survivront pas au redémarrage");
}
s.into_bytes()
});
fn sign(payload: &str) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(&SECRET).expect("hmac");
mac.update(payload.as_bytes());
URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
}
pub fn encode(claims: &Claims) -> String {
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap_or_default());
let sig = sign(&payload);
format!("{payload}.{sig}")
}
pub fn decode(token: &str) -> Option<Claims> {
let (payload, sig) = token.split_once('.')?;
let mut mac = Hmac::<Sha256>::new_from_slice(&SECRET).ok()?;
mac.update(payload.as_bytes());
let sig_bytes = URL_SAFE_NO_PAD.decode(sig).ok()?;
mac.verify_slice(&sig_bytes).ok()?;
let claims: Claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?;
if claims.exp < chrono::Utc::now().timestamp() {
return None;
}
Some(claims)
}
/// Jeton lu dans l'en-tête `Cookie`, s'il est présent et valide.
pub fn from_headers(headers: &HeaderMap) -> Option<Claims> {
for value in headers.get_all(axum::http::header::COOKIE) {
let Ok(s) = value.to_str() else { continue };
for part in s.split(';') {
let part = part.trim();
if let Some(v) = part.strip_prefix(COOKIE_NAME).and_then(|r| r.strip_prefix('=')) {
if let Some(c) = decode(v) {
return Some(c);
}
}
}
}
None
}
fn is_https(headers: &HeaderMap) -> bool {
headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|v| v.eq_ignore_ascii_case("https"))
.unwrap_or(false)
}
/// En-tête `Set-Cookie` qui ouvre la session.
pub fn set_cookie(claims: &Claims, req_headers: &HeaderMap) -> String {
let secure = if is_https(req_headers) { "; Secure" } else { "" };
format!("{COOKIE_NAME}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age={MAX_AGE_SECS}{secure}", encode(claims))
}
/// En-tête `Set-Cookie` qui ferme la session.
pub fn clear_cookie() -> String {
format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0")
}
/// Jeton de la requête (jamais de rejet : `None` si absent ou invalide).
#[derive(Debug, Clone)]
pub struct Token(pub Option<Claims>);
impl<S: Send + Sync> FromRequestParts<S> for Token {
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Ok(Token(from_headers(&parts.headers)))
}
}