feat(auth): argon2id hashing + AuthUser/Authorized<Cap> session extractors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
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]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
@@ -24,3 +24,7 @@ tower = { version = "0.5", features = ["util"] }
|
|||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
meilisearch-sdk = "0.33"
|
meilisearch-sdk = "0.33"
|
||||||
|
argon2 = "0.5"
|
||||||
|
tower-sessions = "0.14"
|
||||||
|
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
|
||||||
|
rpassword = "7"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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<String, argon2::password_hash::Error> {
|
||||||
|
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<String> = 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<S> FromRequestParts<S> for AuthUser
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
{
|
||||||
|
type Rejection = AuthError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
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<C>`
|
||||||
|
/// cannot run without the request's role allowing `C` (else `403`).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Authorized<C: CapabilityMarker> {
|
||||||
|
pub user: AuthUser,
|
||||||
|
_capability: PhantomData<C>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S, C> FromRequestParts<S> for Authorized<C>
|
||||||
|
where
|
||||||
|
S: Send + Sync,
|
||||||
|
C: CapabilityMarker,
|
||||||
|
{
|
||||||
|
type Rejection = AuthError;
|
||||||
|
|
||||||
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user