1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +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

@ -15,8 +15,10 @@ tower-http.workspace = true
argon2.workspace = true
rand.workspace = true
axum-extra.workspace = true
chrono.workspace = true
base64.workspace = true
serde_json.workspace = true
[dev-dependencies]
tempfile.workspace = true
tower.workspace = true
serde_json.workspace = true

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

View file

@ -1,3 +1,4 @@
mod auth;
mod registry;
mod routes;

View file

@ -36,6 +36,9 @@ use tower_http::services::{ServeDir, ServeFile};
use tower::ServiceExt;
use crate::registry::{MountedArchive, ServerRegistry};
use crate::auth;
pub use crate::auth::{AuthUser, ROLE_ADMIN, ROLE_OWNER, ROLE_USER};
use axum_extra::extract::CookieJar;
#[derive(Clone)]
pub struct AppState {
@ -362,6 +365,14 @@ impl ApiError {
message: message.to_string(),
}
}
pub fn unauthorized(message: &str) -> Self {
Self { status: StatusCode::UNAUTHORIZED, message: message.to_string() }
}
pub fn forbidden(message: &str) -> Self {
Self { status: StatusCode::FORBIDDEN, message: message.to_string() }
}
}
impl<E> From<E> for ApiError
@ -379,7 +390,8 @@ where
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, self.message).into_response()
let body = serde_json::json!({ "error": self.message });
(self.status, axum::Json(body)).into_response()
}
}