use argon2::{ Argon2, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, }; use axum::{ extract::{OriginalUri, Request, State}, middleware::Next, response::Response, }; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use crank_core::{User, UserSessionId, WorkspaceId}; use crank_registry::WorkspaceMembershipRecord; use rand::RngCore; use serde::Serialize; use sha2::{Digest, Sha256}; use time::{Duration, OffsetDateTime}; use crate::{error::ApiError, state::AppState}; pub const SESSION_COOKIE_NAME: &str = "crank_session"; #[derive(Clone)] pub struct BootstrapAdminConfig { pub email: String, pub password: String, pub display_name: String, } #[derive(Clone)] pub struct AuthSettings { pub session_secret: String, pub password_pepper: String, pub session_ttl_hours: i64, pub cookie_secure: bool, pub bootstrap_admin: BootstrapAdminConfig, } #[derive(Clone, Debug, Serialize)] pub struct AuthenticatedSession { pub session_id: UserSessionId, pub user: User, pub memberships: Vec, pub current_workspace_id: Option, } #[derive(Clone)] pub struct SessionCookie { pub session_id: UserSessionId, pub value: String, pub expires_at: String, } pub fn hash_password(password: &str, pepper: &str) -> Result { let salt = SaltString::generate(&mut OsRng); let password = format!("{password}{pepper}"); Argon2::default() .hash_password(password.as_bytes(), &salt) .map(|hash| hash.to_string()) .map_err(|error| ApiError::internal(format!("failed to hash password: {error}"))) } pub fn verify_password(password: &str, pepper: &str, password_hash: &str) -> bool { let Ok(parsed) = PasswordHash::new(password_hash) else { return false; }; let password = format!("{password}{pepper}"); Argon2::default() .verify_password(password.as_bytes(), &parsed) .is_ok() } pub fn create_session_cookie(settings: &AuthSettings) -> Result { let session_id = UserSessionId::new(format!("sess_{}", uuid::Uuid::now_v7().simple())); let mut secret_bytes = [0_u8; 32]; rand::thread_rng().fill_bytes(&mut secret_bytes); let secret = URL_SAFE_NO_PAD.encode(secret_bytes); let expires_at = OffsetDateTime::now_utc() .checked_add(Duration::hours(settings.session_ttl_hours)) .ok_or_else(|| ApiError::internal("failed to compute session expiration"))?; Ok(SessionCookie { session_id, value: format!("{secret}.{}", expires_at.unix_timestamp_nanos()), expires_at: expires_at .format(&time::format_description::well_known::Rfc3339) .map_err(|error| { ApiError::internal(format!("failed to format session expiration: {error}")) })?, }) } pub fn hash_session_secret( session_id: &UserSessionId, session_value: &str, session_secret: &str, ) -> String { let mut digest = Sha256::new(); digest.update(session_id.as_str().as_bytes()); digest.update(b":"); digest.update(session_value.as_bytes()); digest.update(b":"); digest.update(session_secret.as_bytes()); URL_SAFE_NO_PAD.encode(digest.finalize()) } pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> { Cookie::build((SESSION_COOKIE_NAME, token.to_owned())) .http_only(true) .same_site(SameSite::Lax) .secure(settings.cookie_secure) .path("/") .max_age(Duration::hours(settings.session_ttl_hours)) .build() } pub fn cleared_session_cookie(settings: &AuthSettings) -> Cookie<'static> { Cookie::build((SESSION_COOKIE_NAME, String::new())) .http_only(true) .same_site(SameSite::Lax) .secure(settings.cookie_secure) .path("/") .max_age(Duration::seconds(0)) .build() } pub fn extract_session_token(jar: &CookieJar) -> Option<(UserSessionId, String)> { let cookie = jar.get(SESSION_COOKIE_NAME)?; let value = cookie.value(); let (session_id, session_value) = value.split_once('.')?; Some((UserSessionId::new(session_id), session_value.to_owned())) } pub async fn require_session( State(state): State, jar: CookieJar, mut request: Request, next: Next, ) -> Result { let session = resolve_authenticated_session(state.clone(), &jar).await?; request.extensions_mut().insert(session); Ok(next.run(request).await) } pub async fn require_workspace_session( State(state): State, jar: CookieJar, original_uri: OriginalUri, mut request: Request, next: Next, ) -> Result { let session = resolve_authenticated_session(state.clone(), &jar).await?; let workspace_id = workspace_id_from_path(original_uri.path()) .ok_or_else(|| ApiError::internal("workspace middleware was applied to an invalid path"))?; let has_access = state .service .user_has_workspace_access(&session.user.id, &workspace_id) .await?; if !has_access { return Err(ApiError::forbidden("workspace access denied")); } request.extensions_mut().insert(session); Ok(next.run(request).await) } async fn resolve_authenticated_session( state: AppState, jar: &CookieJar, ) -> Result { let (session_id, session_value) = extract_session_token(jar) .ok_or_else(|| ApiError::unauthorized("authentication required"))?; let session = state .service .get_session(&session_id, &session_value) .await? .ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?; state.service.touch_session(&session_id).await?; Ok(session) } fn workspace_id_from_path(path: &str) -> Option { let marker = "/api/admin/workspaces/"; let (_, suffix) = path.split_once(marker)?; let workspace_id = suffix.split('/').next()?; if workspace_id.is_empty() { return None; } Some(WorkspaceId::new(workspace_id)) }