244 lines
7.4 KiB
Rust
244 lines
7.4 KiB
Rust
//! 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,
|
|
/// The session store itself failed (e.g. the database is unreachable) — distinct
|
|
/// from "no session", so an outage surfaces as 500 rather than a misleading 401.
|
|
#[error("session store unavailable")]
|
|
Internal,
|
|
}
|
|
|
|
impl IntoResponse for AuthError {
|
|
fn into_response(self) -> Response {
|
|
match self {
|
|
AuthError::Unauthenticated => StatusCode::UNAUTHORIZED,
|
|
AuthError::Forbidden => StatusCode::FORBIDDEN,
|
|
AuthError::Internal => StatusCode::INTERNAL_SERVER_ERROR,
|
|
}
|
|
.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> {
|
|
// A failed extraction here means the SessionManagerLayer is missing from the
|
|
// stack — a wiring bug, not an auth failure: surface it as 500.
|
|
let session = Session::from_request_parts(parts, state)
|
|
.await
|
|
.map_err(|_| AuthError::Internal)?;
|
|
|
|
// For each key: a store error (DB down) is `Internal` (500); an absent key is
|
|
// `Unauthenticated` (401) — these must not be conflated.
|
|
let id: uuid::Uuid = session
|
|
.get(SESSION_USER_ID)
|
|
.await
|
|
.map_err(|_| AuthError::Internal)?
|
|
.ok_or(AuthError::Unauthenticated)?;
|
|
|
|
let email: String = session
|
|
.get(SESSION_EMAIL)
|
|
.await
|
|
.map_err(|_| AuthError::Internal)?
|
|
.ok_or(AuthError::Unauthenticated)?;
|
|
|
|
let role_str: String = session
|
|
.get(SESSION_ROLE)
|
|
.await
|
|
.map_err(|_| AuthError::Internal)?
|
|
.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 verify_dummy_does_not_panic() {
|
|
verify_dummy("any input");
|
|
verify_dummy("called again"); // exercises the already-initialized OnceLock path
|
|
}
|
|
|
|
#[test]
|
|
fn capability_markers_map_to_domain_capabilities() {
|
|
assert_eq!(ManageUsers::CAP, domain::Capability::ManageUsers);
|
|
assert_eq!(EditCatalogue::CAP, domain::Capability::EditCatalogue);
|
|
assert_eq!(PublishObjects::CAP, domain::Capability::PublishObjects);
|
|
assert_eq!(ViewInternal::CAP, domain::Capability::ViewInternal);
|
|
}
|
|
}
|