diff --git a/crates/archivr-server/Cargo.toml b/crates/archivr-server/Cargo.toml index 61ba19e..371950a 100644 --- a/crates/archivr-server/Cargo.toml +++ b/crates/archivr-server/Cargo.toml @@ -15,8 +15,10 @@ tower-http.workspace = true argon2.workspace = true rand.workspace = true axum-extra.workspace = true +chrono.workspace = true +base64.workspace = true +serde_json.workspace = true [dev-dependencies] tempfile.workspace = true tower.workspace = true -serde_json.workspace = true diff --git a/crates/archivr-server/src/auth.rs b/crates/archivr-server/src/auth.rs new file mode 100644 index 0000000..195e2b0 --- /dev/null +++ b/crates/archivr-server/src/auth.rs @@ -0,0 +1,127 @@ +use anyhow::Result; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; +use argon2::password_hash::{SaltString, rand_core::OsRng}; +use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; +use axum_extra::extract::CookieJar; +use rand::RngCore; + +use crate::routes::{ApiError, AppState}; +use archivr_core::database; + +// Role bit constants +pub const ROLE_GUEST: u32 = 1; // bit 0 +pub const ROLE_USER: u32 = 2; // bit 1 +pub const ROLE_ADMIN: u32 = 4; // bit 2 +pub const ROLE_OWNER: u32 = 8; // bit 3 + +#[derive(Clone, Debug)] +pub enum AuthUser { + Guest, + Authenticated { user_id: i64, role_bits: u32 }, +} + +impl AuthUser { + pub fn require_auth(&self) -> Result<(i64, u32), ApiError> { + match self { + AuthUser::Authenticated { user_id, role_bits } => Ok((*user_id, *role_bits)), + AuthUser::Guest => Err(ApiError::unauthorized("login required")), + } + } + + pub fn require_role(&self, bit: u32) -> Result<(), ApiError> { + match self { + AuthUser::Authenticated { role_bits, .. } if role_bits & bit != 0 => Ok(()), + AuthUser::Authenticated { .. } => Err(ApiError::forbidden("insufficient permissions")), + AuthUser::Guest => Err(ApiError::unauthorized("login required")), + } + } + + pub fn has_role(&self, bit: u32) -> bool { + matches!(self, AuthUser::Authenticated { role_bits, .. } if role_bits & bit != 0) + } +} + +#[async_trait] +impl FromRequestParts for AuthUser { + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let auth_db_path = state.auth_db_path.as_ref(); + + // 1. Try session cookie + let jar = CookieJar::from_headers(&parts.headers); + if let Some(cookie) = jar.get("session") { + let session_uid = cookie.value().to_string(); + if let Ok(conn) = database::open_auth_db(auth_db_path) { + if let Ok(Some(session)) = database::get_session(&conn, &session_uid) { + // Conditional touch: only update if >60s since last_seen_at + let should_touch = chrono::DateTime::parse_from_rfc3339(&session.last_seen_at) + .map(|last| { + chrono::Utc::now() - last.with_timezone(&chrono::Utc) + > chrono::Duration::seconds(60) + }) + .unwrap_or(true); + if should_touch { + let _ = database::touch_session(&conn, &session_uid); + } + return Ok(AuthUser::Authenticated { + user_id: session.user_id, + role_bits: session.role_bits, + }); + } + } + } + + // 2. Try Bearer token + if let Some(auth_header) = parts.headers.get("Authorization") { + if let Ok(header_str) = auth_header.to_str() { + if let Some(raw_token) = header_str.strip_prefix("Bearer ") { + let token_hash = hash_token(raw_token); + if let Ok(conn) = database::open_auth_db(auth_db_path) { + if let Ok(Some(user_id)) = database::get_user_for_token(&conn, &token_hash) { + if let Ok(role_bits) = database::compute_role_bits(&conn, user_id) { + return Ok(AuthUser::Authenticated { user_id, role_bits }); + } + } + } + } + } + } + + Ok(AuthUser::Guest) + } +} + +// Password helpers + +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|e| anyhow::anyhow!("password hashing failed: {e}"))? + .to_string(); + Ok(hash) +} + +pub fn verify_password(password: &str, hash: &str) -> Result { + let parsed = PasswordHash::new(hash) + .map_err(|e| anyhow::anyhow!("invalid password hash: {e}"))?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok()) +} + +// Token helpers + +pub fn generate_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes) +} + +pub fn hash_token(raw: &str) -> String { + archivr_core::hash::hash_bytes(raw.as_bytes()) +} diff --git a/crates/archivr-server/src/main.rs b/crates/archivr-server/src/main.rs index cce37c8..3373fcc 100644 --- a/crates/archivr-server/src/main.rs +++ b/crates/archivr-server/src/main.rs @@ -1,3 +1,4 @@ +mod auth; mod registry; mod routes; diff --git a/crates/archivr-server/src/routes.rs b/crates/archivr-server/src/routes.rs index 8f2e26f..da23686 100644 --- a/crates/archivr-server/src/routes.rs +++ b/crates/archivr-server/src/routes.rs @@ -36,6 +36,9 @@ use tower_http::services::{ServeDir, ServeFile}; use tower::ServiceExt; use crate::registry::{MountedArchive, ServerRegistry}; +use crate::auth; +pub use crate::auth::{AuthUser, ROLE_ADMIN, ROLE_OWNER, ROLE_USER}; +use axum_extra::extract::CookieJar; #[derive(Clone)] pub struct AppState { @@ -362,6 +365,14 @@ impl ApiError { message: message.to_string(), } } + + pub fn unauthorized(message: &str) -> Self { + Self { status: StatusCode::UNAUTHORIZED, message: message.to_string() } + } + + pub fn forbidden(message: &str) -> Self { + Self { status: StatusCode::FORBIDDEN, message: message.to_string() } + } } impl From for ApiError @@ -379,7 +390,8 @@ where impl IntoResponse for ApiError { fn into_response(self) -> Response { - (self.status, self.message).into_response() + let body = serde_json::json!({ "error": self.message }); + (self.status, axum::Json(body)).into_response() } }