diff --git a/Cargo.toml b/Cargo.toml index fdb73ac..6032968 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/domain", "crates/db", "crates/api", "crates/server", "crates/search"] +members = ["crates/domain", "crates/db", "crates/api", "crates/server", "crates/search", "crates/auth"] [workspace.package] edition = "2024" @@ -24,3 +24,7 @@ tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } meilisearch-sdk = "0.33" +argon2 = "0.5" +tower-sessions = "0.14" +tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] } +rpassword = "7" diff --git a/crates/auth/Cargo.toml b/crates/auth/Cargo.toml new file mode 100644 index 0000000..6f791a1 --- /dev/null +++ b/crates/auth/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "auth" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +axum.workspace = true +domain = { path = "../domain" } +argon2.workspace = true +tower-sessions.workspace = true +serde.workspace = true +uuid.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tokio.workspace = true diff --git a/crates/auth/src/lib.rs b/crates/auth/src/lib.rs new file mode 100644 index 0000000..ce31dc0 --- /dev/null +++ b/crates/auth/src/lib.rs @@ -0,0 +1,229 @@ +//! Authentication & authorization: argon2id password hashing and the type-driven +//! axum extractors that gate handlers. Identity is read from the session (set at +//! login); these extractors do not touch the database. + +use std::marker::PhantomData; +use std::sync::OnceLock; + +use argon2::Argon2; +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use axum::extract::FromRequestParts; +use axum::http::StatusCode; +use axum::http::request::Parts; +use axum::response::{IntoResponse, Response}; +use domain::{Capability, Email, Role, UserId}; +use tower_sessions::Session; + +const SESSION_USER_ID: &str = "user_id"; +const SESSION_EMAIL: &str = "email"; +const SESSION_ROLE: &str = "role"; + +/// Hash a plaintext password as an argon2id PHC string. +pub fn hash_password(plaintext: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + + Ok(Argon2::default() + .hash_password(plaintext.as_bytes(), &salt)? + .to_string()) +} + +/// Verify a plaintext password against an argon2id PHC string. Returns `false` for a +/// wrong password OR a malformed/unparseable hash (never errors out). +pub fn verify_password(plaintext: &str, phc: &str) -> bool { + let Ok(parsed) = PasswordHash::new(phc) else { + return false; + }; + + Argon2::default() + .verify_password(plaintext.as_bytes(), &parsed) + .is_ok() +} + +/// Spend a verify's worth of time against a fixed dummy hash. Call this on the +/// "user not found" login path to blunt user-enumeration via response timing. +pub fn verify_dummy(plaintext: &str) { + static DUMMY: OnceLock = OnceLock::new(); + let hash = + DUMMY.get_or_init(|| hash_password("dummy-password-for-timing").expect("hash dummy")); + + let _ = verify_password(plaintext, hash); +} + +/// Record the authenticated identity into the session (call after a successful +/// password check). Cycles the session id first to prevent session fixation. +pub async fn establish_session( + session: &Session, + id: UserId, + email: &Email, + role: Role, +) -> Result<(), tower_sessions::session::Error> { + session.cycle_id().await?; + session.insert(SESSION_USER_ID, id.to_uuid()).await?; + session.insert(SESSION_EMAIL, email.as_str()).await?; + session.insert(SESSION_ROLE, role.as_str()).await?; + + Ok(()) +} + +/// Rejection for the auth extractors. +#[derive(Debug, Clone, Copy, thiserror::Error)] +pub enum AuthError { + #[error("authentication required")] + Unauthenticated, + #[error("insufficient permissions")] + Forbidden, +} + +impl IntoResponse for AuthError { + fn into_response(self) -> Response { + match self { + AuthError::Unauthenticated => StatusCode::UNAUTHORIZED, + AuthError::Forbidden => StatusCode::FORBIDDEN, + } + .into_response() + } +} + +/// The authenticated user, reconstructed from the session. Extracting this proves +/// the request carries a valid session (else `401`). +#[derive(Debug, Clone)] +pub struct AuthUser { + pub id: UserId, + pub email: Email, + pub role: Role, +} + +impl FromRequestParts for AuthUser +where + S: Send + Sync, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let session = Session::from_request_parts(parts, state) + .await + .map_err(|_| AuthError::Unauthenticated)?; + + let id: uuid::Uuid = session + .get(SESSION_USER_ID) + .await + .ok() + .flatten() + .ok_or(AuthError::Unauthenticated)?; + + let email: String = session + .get(SESSION_EMAIL) + .await + .ok() + .flatten() + .ok_or(AuthError::Unauthenticated)?; + + let role_str: String = session + .get(SESSION_ROLE) + .await + .ok() + .flatten() + .ok_or(AuthError::Unauthenticated)?; + + let role = Role::from_db(&role_str).ok_or(AuthError::Unauthenticated)?; + + Ok(AuthUser { + id: UserId::from_uuid(id), + email: Email::from_db(email), + role, + }) + } +} + +/// A zero-sized type naming a required [`Capability`]. Implementors are used as the +/// type parameter of [`Authorized`]. +pub trait CapabilityMarker { + const CAP: Capability; +} + +/// Require `ManageUsers`. +pub struct ManageUsers; + +impl CapabilityMarker for ManageUsers { + const CAP: Capability = Capability::ManageUsers; +} + +/// Require `EditCatalogue`. +pub struct EditCatalogue; + +impl CapabilityMarker for EditCatalogue { + const CAP: Capability = Capability::EditCatalogue; +} + +/// Require `PublishObjects`. +pub struct PublishObjects; + +impl CapabilityMarker for PublishObjects { + const CAP: Capability = Capability::PublishObjects; +} + +/// Require `ViewInternal`. +pub struct ViewInternal; + +impl CapabilityMarker for ViewInternal { + const CAP: Capability = Capability::ViewInternal; +} + +/// An [`AuthUser`] proven to hold capability `C`. A handler taking `Authorized` +/// cannot run without the request's role allowing `C` (else `403`). +#[derive(Debug, Clone)] +pub struct Authorized { + pub user: AuthUser, + _capability: PhantomData, +} + +impl FromRequestParts for Authorized +where + S: Send + Sync, + C: CapabilityMarker, +{ + type Rejection = AuthError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let user = AuthUser::from_request_parts(parts, state).await?; + + if user.role.allows(C::CAP) { + Ok(Authorized { + user, + _capability: PhantomData, + }) + } else { + Err(AuthError::Forbidden) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_then_verify_round_trips() { + let hash = hash_password("correct horse battery staple").unwrap(); + assert!(hash.starts_with("$argon2id$")); + assert!(verify_password("correct horse battery staple", &hash)); + } + + #[test] + fn verify_rejects_wrong_password() { + let hash = hash_password("right").unwrap(); + assert!(!verify_password("wrong", &hash)); + } + + #[test] + fn verify_rejects_malformed_hash() { + assert!(!verify_password("anything", "not-a-phc-string")); + } + + #[test] + fn capability_markers_map_to_domain_capabilities() { + assert_eq!(ManageUsers::CAP, domain::Capability::ManageUsers); + assert_eq!(PublishObjects::CAP, domain::Capability::PublishObjects); + } +}