1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-22 03:05:32 +02:00

feat(auth): AuthUser extractor, password helpers, token generation

This commit is contained in:
TheGeneralist 2026-06-26 11:50:29 +02:00
parent a4239ba5fc
commit db28d038d9
Signed by: thegeneralist01
SSH key fingerprint: SHA256:pp9qddbCNmVNoSjevdvQvM5z0DHN7LTa8qBMbcMq/R4
4 changed files with 144 additions and 2 deletions

View file

@ -0,0 +1,127 @@
use anyhow::Result;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::password_hash::{SaltString, rand_core::OsRng};
use axum::{async_trait, extract::FromRequestParts, http::request::Parts};
use axum_extra::extract::CookieJar;
use rand::RngCore;
use crate::routes::{ApiError, AppState};
use archivr_core::database;
// Role bit constants
pub const ROLE_GUEST: u32 = 1; // bit 0
pub const ROLE_USER: u32 = 2; // bit 1
pub const ROLE_ADMIN: u32 = 4; // bit 2
pub const ROLE_OWNER: u32 = 8; // bit 3
#[derive(Clone, Debug)]
pub enum AuthUser {
Guest,
Authenticated { user_id: i64, role_bits: u32 },
}
impl AuthUser {
pub fn require_auth(&self) -> Result<(i64, u32), ApiError> {
match self {
AuthUser::Authenticated { user_id, role_bits } => Ok((*user_id, *role_bits)),
AuthUser::Guest => Err(ApiError::unauthorized("login required")),
}
}
pub fn require_role(&self, bit: u32) -> Result<(), ApiError> {
match self {
AuthUser::Authenticated { role_bits, .. } if role_bits & bit != 0 => Ok(()),
AuthUser::Authenticated { .. } => Err(ApiError::forbidden("insufficient permissions")),
AuthUser::Guest => Err(ApiError::unauthorized("login required")),
}
}
pub fn has_role(&self, bit: u32) -> bool {
matches!(self, AuthUser::Authenticated { role_bits, .. } if role_bits & bit != 0)
}
}
#[async_trait]
impl FromRequestParts<AppState> for AuthUser {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, std::convert::Infallible> {
let auth_db_path = state.auth_db_path.as_ref();
// 1. Try session cookie
let jar = CookieJar::from_headers(&parts.headers);
if let Some(cookie) = jar.get("session") {
let session_uid = cookie.value().to_string();
if let Ok(conn) = database::open_auth_db(auth_db_path) {
if let Ok(Some(session)) = database::get_session(&conn, &session_uid) {
// Conditional touch: only update if >60s since last_seen_at
let should_touch = chrono::DateTime::parse_from_rfc3339(&session.last_seen_at)
.map(|last| {
chrono::Utc::now() - last.with_timezone(&chrono::Utc)
> chrono::Duration::seconds(60)
})
.unwrap_or(true);
if should_touch {
let _ = database::touch_session(&conn, &session_uid);
}
return Ok(AuthUser::Authenticated {
user_id: session.user_id,
role_bits: session.role_bits,
});
}
}
}
// 2. Try Bearer token
if let Some(auth_header) = parts.headers.get("Authorization") {
if let Ok(header_str) = auth_header.to_str() {
if let Some(raw_token) = header_str.strip_prefix("Bearer ") {
let token_hash = hash_token(raw_token);
if let Ok(conn) = database::open_auth_db(auth_db_path) {
if let Ok(Some(user_id)) = database::get_user_for_token(&conn, &token_hash) {
if let Ok(role_bits) = database::compute_role_bits(&conn, user_id) {
return Ok(AuthUser::Authenticated { user_id, role_bits });
}
}
}
}
}
}
Ok(AuthUser::Guest)
}
}
// Password helpers
pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("password hashing failed: {e}"))?
.to_string();
Ok(hash)
}
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
let parsed = PasswordHash::new(hash)
.map_err(|e| anyhow::anyhow!("invalid password hash: {e}"))?;
Ok(Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok())
}
// Token helpers
pub fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bytes)
}
pub fn hash_token(raw: &str) -> String {
archivr_core::hash::hash_bytes(raw.as_bytes())
}