Initial public release v0.1.0
This commit is contained in:
commit
6c39e0bfbf
87 files changed
+10867
No files matched your search
@@ -0,0 +1,50 @@
|
||||
use axum::{
|
||||
extract::{ConnectInfo, FromRequestParts},
|
||||
http::request::Parts,
|
||||
};
|
||||
use std::net::SocketAddr;
|
||||
|
||||
/// Request context for audit logging — captures IP and User-Agent.
|
||||
///
|
||||
/// Handlers include this extractor to forward client metadata to the audit log
|
||||
/// without threading raw request headers through the call stack.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AuditContext {
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for AuditContext
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
// Prefer X-Forwarded-For (set by reverse proxies like Nginx)
|
||||
let ip_address = parts
|
||||
.headers
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(',').next())
|
||||
.map(|s| s.trim().to_string())
|
||||
.or_else(|| {
|
||||
// Fall back to direct peer address (requires ConnectInfo extension)
|
||||
parts
|
||||
.extensions
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().to_string())
|
||||
});
|
||||
|
||||
let user_agent = parts
|
||||
.headers
|
||||
.get(axum::http::header::USER_AGENT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Ok(AuditContext {
|
||||
ip_address,
|
||||
user_agent,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use axum::{
|
||||
extract::{FromRef, FromRequestParts},
|
||||
http::request::Parts,
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
|
||||
use crate::{
|
||||
db::models::User,
|
||||
db::repository::users as user_repo,
|
||||
error::AppError,
|
||||
security::{sessions, tokens},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
/// Describes how the current request was authenticated.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthMethod {
|
||||
Session,
|
||||
Token,
|
||||
}
|
||||
|
||||
/// Axum extractor that resolves the authenticated user from either a session
|
||||
/// cookie or a Bearer token in the Authorization header.
|
||||
///
|
||||
/// Handlers that need an authenticated user simply include `auth: AuthUser`
|
||||
/// in their parameter list.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub user: User,
|
||||
pub method: AuthMethod,
|
||||
/// Session ID — populated when `method == Session`, used for logout.
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for AuthUser
|
||||
where
|
||||
AppState: FromRef<S>,
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, AppError> {
|
||||
let app_state = AppState::from_ref(state);
|
||||
|
||||
// 1. Try session cookie first
|
||||
let jar = CookieJar::from_headers(&parts.headers);
|
||||
if let Some(cookie) = jar.get(sessions::SESSION_COOKIE) {
|
||||
let raw = cookie.value();
|
||||
if let Some(session) =
|
||||
sessions::validate_session(&app_state.pool, raw, &app_state.config.security).await?
|
||||
{
|
||||
let user = user_repo::find_by_id(&app_state.pool, &session.user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
if !user.is_active() {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
return Ok(AuthUser {
|
||||
user,
|
||||
method: AuthMethod::Session,
|
||||
session_id: Some(session.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try Bearer token in Authorization header
|
||||
if let Some(auth_header) = parts.headers.get(axum::http::header::AUTHORIZATION) {
|
||||
if let Ok(value) = auth_header.to_str() {
|
||||
if let Some(raw) = value.strip_prefix("Bearer ") {
|
||||
if let Some(token) = tokens::validate_token(&app_state.pool, raw.trim()).await?
|
||||
{
|
||||
let user = user_repo::find_by_id(&app_state.pool, &token.user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
if !user.is_active() {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
return Ok(AuthUser {
|
||||
user,
|
||||
method: AuthMethod::Token,
|
||||
session_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod permissions;
|
||||
@@ -0,0 +1,12 @@
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::{error::AppError, identity::permissions};
|
||||
|
||||
/// Enforce that the calling user has the given permission.
|
||||
///
|
||||
/// Alias for `permissions::require_permission` — imported in handlers for
|
||||
/// readability: `require(pool, user_id, "users:create").await?`
|
||||
#[inline]
|
||||
pub async fn require(pool: &SqlitePool, user_id: &str, permission: &str) -> Result<(), AppError> {
|
||||
permissions::require_permission(pool, user_id, permission).await
|
||||
}
|
||||
Reference in new issue
Block a user