use crate::Error;
use crate::dashboard::password;
use crate::helpers::deserialize;
use crate::network::{AppStateExt, serialize_network};
use axum::Json;
use axum::extract::FromRequestParts;
use axum::http::header::SET_COOKIE;
use axum::http::{HeaderMap, Method, request};
use axum::response::{IntoResponse, Response};
use axum_extra::extract::CookieJar;
use chrono::Utc;
use cryptr::EncValue;
use cryptr::utils::{b64_decode, b64_encode};
use serde::{Deserialize, Serialize};
use std::env;
use std::sync::LazyLock;
use tracing::debug;
const COOKIE_NAME: &str = "__Host-Hiqlite-Session";
const COOKIE_NAME_DEV: &str = "Hiqlite-Session";
const SESSION_LIFETIME: i64 = 3600;
pub static INSECURE_COOKIES: LazyLock<bool> = LazyLock::new(|| {
env::var("HQL_INSECURE_COOKIE")
.unwrap_or_else(|_| "false".to_string())
.parse::<bool>()
.expect("Cannot parse HQL_INSECURE_COOKIE as bool")
});
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct Session {
created: i64,
expires: i64,
}
impl<S> FromRequestParts<S> for Session
where
S: Send + Sync,
{
type Rejection = Error;
async fn from_request_parts(
parts: &mut request::Parts,
_state: &S,
) -> Result<Self, Self::Rejection> {
let headers = &parts.headers;
check_csrf(&parts.method, headers).await?;
let jar = CookieJar::from_headers(headers);
Session::try_from_jar(&jar)
}
}
impl Session {
fn new() -> Self {
let created = Utc::now().timestamp();
let expires = created + SESSION_LIFETIME;
Self { created, expires }
}
fn as_cookie(&self) -> Result<String, Error> {
let bytes = serialize_network(self);
let enc = EncValue::encrypt(&bytes)?;
let enc_bytes = enc.into_bytes().to_vec();
let b64 = b64_encode(&enc_bytes);
let max_age = self.expires - Utc::now().timestamp();
let cookie_header = if *INSECURE_COOKIES {
format!("{COOKIE_NAME_DEV}={b64}; HttpOnly; SameSite=Lax; Max-Age={max_age}")
} else {
format!("{COOKIE_NAME}={b64}; Secure; HttpOnly; SameSite=Lax; Max-Age={max_age}; Path=/")
};
Ok(cookie_header)
}
fn try_from_jar(jar: &CookieJar) -> Result<Self, Error> {
let name = if *INSECURE_COOKIES {
COOKIE_NAME_DEV
} else {
COOKIE_NAME
};
let cookie = jar
.get(name)
.ok_or(Error::Unauthorized("no session found".into()))?;
let enc_bytes = b64_decode(cookie.value())?;
let dec = EncValue::try_from_bytes(enc_bytes)?.decrypt()?;
let slf: Self = deserialize(dec.as_ref())?;
slf.is_valid()?;
Ok(slf)
}
#[inline]
fn is_valid(&self) -> Result<(), Error> {
if self.expires < Utc::now().timestamp() {
Err(Error::Unauthorized("session has expired".into()))
} else {
Ok(())
}
}
}
pub async fn set_session_verify(
state: &AppStateExt,
method: Method,
headers: &HeaderMap,
password: String,
) -> Result<Response, Error> {
check_csrf(&method, headers).await?;
if let Some(pwd) = state.dashboard.password_dashboard.clone() {
password::verify_password(password, pwd).await?;
let session = Session::new();
let cookie = session.as_cookie()?;
Ok(([(SET_COOKIE, cookie)], Json(session)).into_response())
} else {
Err(Error::Unauthorized("unauthorized".into()))
}
}
async fn check_csrf(method: &Method, headers: &HeaderMap) -> Result<(), Error> {
if let Some(site) = headers.get("sec-fetch-site") {
let site = site.to_str().unwrap_or_default();
if site == "same-origin" {
return Ok(());
}
if method == Method::GET {
if site == "none" {
return Ok(());
}
let dest = headers
.get("sec-fetch-dest")
.map(|h| h.to_str().unwrap_or_default())
.unwrap_or_default();
let mode = headers
.get("sec-fetch-mode")
.map(|h| h.to_str().unwrap_or_default())
.unwrap_or_default();
debug!("sec-fetch-dest: {dest}, sec-fetch-mode: {mode}");
if dest == "image" && mode == "no-cors" {
return Ok(());
}
if mode == "navigate" && !["embed", "iframe", "object"].contains(&dest) {
return Ok(());
}
}
Err(Error::Unauthorized(
"cross-origin request forbidden for this resource".into(),
))
} else {
debug!("sec-fetch-site is missing");
if *INSECURE_COOKIES {
Ok(())
} else {
Err(Error::Unauthorized("CSRF violation".into()))
}
}
}