feat(auth): argon2id hashing + AuthUser/Authorized<Cap> session extractors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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