Files
biggus-dickus/crates/auth/src/lib.rs
T

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);
}
}