style: cargo fmt --all
This commit is contained in:
+140
-148
@@ -1,148 +1,140 @@
|
||||
//! HttpOnly cookie helpers for secure token transport.
|
||||
//!
|
||||
//! Tokens are set as `HttpOnly; SameSite=Lax` cookies so that
|
||||
//! browser-based JavaScript cannot read them (mitigates XSS token theft).
|
||||
//! The `Secure` flag is controlled by the `OXICLOUD_COOKIE_SECURE` env var
|
||||
//! (default: auto-detect from `OXICLOUD_BASE_URL`).
|
||||
//!
|
||||
//! A companion **non-HttpOnly** CSRF cookie (`oxicloud_csrf`) is set
|
||||
//! alongside the auth cookies. The frontend must read it and echo its
|
||||
//! value back as `X-CSRF-Token` on every state-changing request.
|
||||
//! A middleware (`csrf_middleware`) validates the match.
|
||||
//!
|
||||
//! DAV clients continue to use `Authorization: Basic` with app passwords
|
||||
//! and are completely unaffected by this mechanism.
|
||||
|
||||
use axum::http::header::SET_COOKIE;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
/// Cookie name for the JWT access token.
|
||||
pub const ACCESS_COOKIE: &str = "oxicloud_access";
|
||||
/// Cookie name for the opaque refresh token.
|
||||
pub const REFRESH_COOKIE: &str = "oxicloud_refresh";
|
||||
/// Cookie name for the CSRF double-submit token (readable by JS).
|
||||
pub const CSRF_COOKIE: &str = "oxicloud_csrf";
|
||||
/// Header the frontend must send with the CSRF token value.
|
||||
pub const CSRF_HEADER: &str = "x-csrf-token";
|
||||
|
||||
/// Whether the `Secure` flag should be set on cookies.
|
||||
/// Auto-detected from `OXICLOUD_BASE_URL` (if it starts with `https`)
|
||||
/// or overridden with `OXICLOUD_COOKIE_SECURE=true|false`.
|
||||
fn cookie_secure() -> bool {
|
||||
if let Ok(v) = std::env::var("OXICLOUD_COOKIE_SECURE") {
|
||||
return v == "true" || v == "1";
|
||||
}
|
||||
// Auto-detect from base URL
|
||||
std::env::var("OXICLOUD_BASE_URL")
|
||||
.map(|u| u.starts_with("https"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Build a `Set-Cookie` header value.
|
||||
fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64) -> String {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
format!(
|
||||
"{name}={value}; HttpOnly; SameSite=Lax; Path={path}; Max-Age={max_age_secs}{secure}",
|
||||
)
|
||||
}
|
||||
|
||||
/// Append `Set-Cookie` headers for both access and refresh tokens.
|
||||
///
|
||||
/// The access cookie covers all paths (`/`) because the API lives under
|
||||
/// `/api`, CalDAV under `/caldav`, WebDAV under `/webdav`, etc.
|
||||
///
|
||||
/// The refresh cookie is restricted to `/api/auth` so it is only sent
|
||||
/// when the client explicitly calls the refresh or logout endpoints.
|
||||
pub fn append_auth_cookies(
|
||||
headers: &mut HeaderMap,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
access_expiry_secs: i64,
|
||||
refresh_expiry_secs: i64,
|
||||
) {
|
||||
if let Ok(val) = HeaderValue::from_str(&build_cookie(
|
||||
ACCESS_COOKIE,
|
||||
access_token,
|
||||
"/",
|
||||
access_expiry_secs,
|
||||
)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
if let Ok(val) = HeaderValue::from_str(&build_cookie(
|
||||
REFRESH_COOKIE,
|
||||
refresh_token,
|
||||
"/api/auth",
|
||||
refresh_expiry_secs,
|
||||
)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `Set-Cookie` headers that immediately expire both auth cookies,
|
||||
/// effectively logging the user out on the browser side.
|
||||
pub fn append_clear_cookies(headers: &mut HeaderMap) {
|
||||
for (name, path) in [(ACCESS_COOKIE, "/"), (REFRESH_COOKIE, "/api/auth")] {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
let val = format!(
|
||||
"{name}=; HttpOnly; SameSite=Lax; Path={path}; Max-Age=0{secure}",
|
||||
);
|
||||
if let Ok(hv) = HeaderValue::from_str(&val) {
|
||||
headers.append(SET_COOKIE, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a named cookie value from the `Cookie` request header.
|
||||
pub fn extract_cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
let cookie_header = headers.get(axum::http::header::COOKIE)?;
|
||||
let cookie_str = cookie_header.to_str().ok()?;
|
||||
|
||||
for pair in cookie_str.split(';') {
|
||||
let pair = pair.trim();
|
||||
if let Some(val) = pair.strip_prefix(name) {
|
||||
let val = val.strip_prefix('=')?;
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// CSRF double-submit cookie helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate a cryptographically random CSRF token (128-bit UUIDv4, hex-like).
|
||||
pub fn generate_csrf_token() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Build a **non-HttpOnly** CSRF cookie so that frontend JS can read it
|
||||
/// via `document.cookie` and echo it back in the `X-CSRF-Token` header.
|
||||
fn build_csrf_cookie(value: &str, max_age_secs: i64) -> String {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
format!(
|
||||
"{CSRF_COOKIE}={value}; SameSite=Lax; Path=/; Max-Age={max_age_secs}{secure}",
|
||||
)
|
||||
}
|
||||
|
||||
/// Append a CSRF double-submit cookie alongside the auth cookies.
|
||||
/// Should be called in every endpoint that also sets auth cookies.
|
||||
pub fn append_csrf_cookie(headers: &mut HeaderMap, access_expiry_secs: i64) {
|
||||
let token = generate_csrf_token();
|
||||
if let Ok(val) = HeaderValue::from_str(&build_csrf_cookie(&token, access_expiry_secs)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the CSRF cookie (on logout).
|
||||
pub fn append_clear_csrf_cookie(headers: &mut HeaderMap) {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
let val = format!(
|
||||
"{CSRF_COOKIE}=; SameSite=Lax; Path=/; Max-Age=0{secure}",
|
||||
);
|
||||
if let Ok(hv) = HeaderValue::from_str(&val) {
|
||||
headers.append(SET_COOKIE, hv);
|
||||
}
|
||||
}
|
||||
//! HttpOnly cookie helpers for secure token transport.
|
||||
//!
|
||||
//! Tokens are set as `HttpOnly; SameSite=Lax` cookies so that
|
||||
//! browser-based JavaScript cannot read them (mitigates XSS token theft).
|
||||
//! The `Secure` flag is controlled by the `OXICLOUD_COOKIE_SECURE` env var
|
||||
//! (default: auto-detect from `OXICLOUD_BASE_URL`).
|
||||
//!
|
||||
//! A companion **non-HttpOnly** CSRF cookie (`oxicloud_csrf`) is set
|
||||
//! alongside the auth cookies. The frontend must read it and echo its
|
||||
//! value back as `X-CSRF-Token` on every state-changing request.
|
||||
//! A middleware (`csrf_middleware`) validates the match.
|
||||
//!
|
||||
//! DAV clients continue to use `Authorization: Basic` with app passwords
|
||||
//! and are completely unaffected by this mechanism.
|
||||
|
||||
use axum::http::header::SET_COOKIE;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
/// Cookie name for the JWT access token.
|
||||
pub const ACCESS_COOKIE: &str = "oxicloud_access";
|
||||
/// Cookie name for the opaque refresh token.
|
||||
pub const REFRESH_COOKIE: &str = "oxicloud_refresh";
|
||||
/// Cookie name for the CSRF double-submit token (readable by JS).
|
||||
pub const CSRF_COOKIE: &str = "oxicloud_csrf";
|
||||
/// Header the frontend must send with the CSRF token value.
|
||||
pub const CSRF_HEADER: &str = "x-csrf-token";
|
||||
|
||||
/// Whether the `Secure` flag should be set on cookies.
|
||||
/// Auto-detected from `OXICLOUD_BASE_URL` (if it starts with `https`)
|
||||
/// or overridden with `OXICLOUD_COOKIE_SECURE=true|false`.
|
||||
fn cookie_secure() -> bool {
|
||||
if let Ok(v) = std::env::var("OXICLOUD_COOKIE_SECURE") {
|
||||
return v == "true" || v == "1";
|
||||
}
|
||||
// Auto-detect from base URL
|
||||
std::env::var("OXICLOUD_BASE_URL")
|
||||
.map(|u| u.starts_with("https"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Build a `Set-Cookie` header value.
|
||||
fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64) -> String {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
format!("{name}={value}; HttpOnly; SameSite=Lax; Path={path}; Max-Age={max_age_secs}{secure}",)
|
||||
}
|
||||
|
||||
/// Append `Set-Cookie` headers for both access and refresh tokens.
|
||||
///
|
||||
/// The access cookie covers all paths (`/`) because the API lives under
|
||||
/// `/api`, CalDAV under `/caldav`, WebDAV under `/webdav`, etc.
|
||||
///
|
||||
/// The refresh cookie is restricted to `/api/auth` so it is only sent
|
||||
/// when the client explicitly calls the refresh or logout endpoints.
|
||||
pub fn append_auth_cookies(
|
||||
headers: &mut HeaderMap,
|
||||
access_token: &str,
|
||||
refresh_token: &str,
|
||||
access_expiry_secs: i64,
|
||||
refresh_expiry_secs: i64,
|
||||
) {
|
||||
if let Ok(val) = HeaderValue::from_str(&build_cookie(
|
||||
ACCESS_COOKIE,
|
||||
access_token,
|
||||
"/",
|
||||
access_expiry_secs,
|
||||
)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
if let Ok(val) = HeaderValue::from_str(&build_cookie(
|
||||
REFRESH_COOKIE,
|
||||
refresh_token,
|
||||
"/api/auth",
|
||||
refresh_expiry_secs,
|
||||
)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `Set-Cookie` headers that immediately expire both auth cookies,
|
||||
/// effectively logging the user out on the browser side.
|
||||
pub fn append_clear_cookies(headers: &mut HeaderMap) {
|
||||
for (name, path) in [(ACCESS_COOKIE, "/"), (REFRESH_COOKIE, "/api/auth")] {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
let val = format!("{name}=; HttpOnly; SameSite=Lax; Path={path}; Max-Age=0{secure}",);
|
||||
if let Ok(hv) = HeaderValue::from_str(&val) {
|
||||
headers.append(SET_COOKIE, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a named cookie value from the `Cookie` request header.
|
||||
pub fn extract_cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
let cookie_header = headers.get(axum::http::header::COOKIE)?;
|
||||
let cookie_str = cookie_header.to_str().ok()?;
|
||||
|
||||
for pair in cookie_str.split(';') {
|
||||
let pair = pair.trim();
|
||||
if let Some(val) = pair.strip_prefix(name) {
|
||||
let val = val.strip_prefix('=')?;
|
||||
if !val.is_empty() {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// CSRF double-submit cookie helpers
|
||||
// ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Generate a cryptographically random CSRF token (128-bit UUIDv4, hex-like).
|
||||
pub fn generate_csrf_token() -> String {
|
||||
uuid::Uuid::new_v4().to_string()
|
||||
}
|
||||
|
||||
/// Build a **non-HttpOnly** CSRF cookie so that frontend JS can read it
|
||||
/// via `document.cookie` and echo it back in the `X-CSRF-Token` header.
|
||||
fn build_csrf_cookie(value: &str, max_age_secs: i64) -> String {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
format!("{CSRF_COOKIE}={value}; SameSite=Lax; Path=/; Max-Age={max_age_secs}{secure}",)
|
||||
}
|
||||
|
||||
/// Append a CSRF double-submit cookie alongside the auth cookies.
|
||||
/// Should be called in every endpoint that also sets auth cookies.
|
||||
pub fn append_csrf_cookie(headers: &mut HeaderMap, access_expiry_secs: i64) {
|
||||
let token = generate_csrf_token();
|
||||
if let Ok(val) = HeaderValue::from_str(&build_csrf_cookie(&token, access_expiry_secs)) {
|
||||
headers.append(SET_COOKIE, val);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the CSRF cookie (on logout).
|
||||
pub fn append_clear_csrf_cookie(headers: &mut HeaderMap) {
|
||||
let secure = if cookie_secure() { "; Secure" } else { "" };
|
||||
let val = format!("{CSRF_COOKIE}=; SameSite=Lax; Path=/; Max-Age=0{secure}",);
|
||||
if let Ok(hv) = HeaderValue::from_str(&val) {
|
||||
headers.append(SET_COOKIE, hv);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,10 +197,7 @@ async fn login(
|
||||
auth_response.expires_in,
|
||||
state.core.config.auth.refresh_token_expiry_secs,
|
||||
);
|
||||
cookie_auth::append_csrf_cookie(
|
||||
response.headers_mut(),
|
||||
auth_response.expires_in,
|
||||
);
|
||||
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
|
||||
Ok(response)
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -253,10 +250,7 @@ async fn refresh_token(
|
||||
auth_response.expires_in,
|
||||
state.core.config.auth.refresh_token_expiry_secs,
|
||||
);
|
||||
cookie_auth::append_csrf_cookie(
|
||||
response.headers_mut(),
|
||||
auth_response.expires_in,
|
||||
);
|
||||
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -522,9 +516,6 @@ async fn oidc_exchange(
|
||||
auth_response.expires_in,
|
||||
state.core.config.auth.refresh_token_expiry_secs,
|
||||
);
|
||||
cookie_auth::append_csrf_cookie(
|
||||
response.headers_mut(),
|
||||
auth_response.expires_in,
|
||||
);
|
||||
cookie_auth::append_csrf_cookie(response.headers_mut(), auth_response.expires_in);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -52,11 +52,10 @@ pub fn caldav_routes() -> Router<Arc<AppState>> {
|
||||
/// Creates RFC 6764 well-known discovery routes.
|
||||
/// These are public (no auth) and simply redirect to the CalDAV root.
|
||||
pub fn well_known_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/.well-known/caldav",
|
||||
axum::routing::any(handle_well_known_caldav),
|
||||
)
|
||||
Router::new().route(
|
||||
"/.well-known/caldav",
|
||||
axum::routing::any(handle_well_known_caldav),
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle_well_known_caldav() -> Response<Body> {
|
||||
@@ -114,13 +113,9 @@ fn extract_caldav_path(uri_path: &str) -> String {
|
||||
} else if uri_path.ends_with("/caldav") {
|
||||
""
|
||||
} else {
|
||||
uri_path
|
||||
.trim_start_matches('/')
|
||||
.trim_end_matches('/')
|
||||
uri_path.trim_start_matches('/').trim_end_matches('/')
|
||||
};
|
||||
percent_decode_str(encoded)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned()
|
||||
percent_decode_str(encoded).decode_utf8_lossy().into_owned()
|
||||
}
|
||||
|
||||
// ─── Helper: extract user from request ───────────────────────────────
|
||||
@@ -198,9 +193,7 @@ async fn handle_propfind(
|
||||
calendar_service
|
||||
.list_my_calendars(&user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to list calendars: {}", e))
|
||||
})?
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to list calendars: {}", e)))?
|
||||
};
|
||||
|
||||
let base_href = "/caldav/";
|
||||
@@ -221,9 +214,7 @@ async fn handle_propfind(
|
||||
.unwrap())
|
||||
} else if path.starts_with("principals/") || path == "principals" {
|
||||
// Principal resource — return user principal properties
|
||||
let username = path
|
||||
.strip_prefix("principals/")
|
||||
.unwrap_or(&user.username);
|
||||
let username = path.strip_prefix("principals/").unwrap_or(&user.username);
|
||||
let username = if username.is_empty() {
|
||||
&user.username
|
||||
} else {
|
||||
@@ -255,9 +246,7 @@ async fn handle_propfind(
|
||||
|
||||
if parts.len() == 1 {
|
||||
// Single path segment: try as calendar ID first, fall back to user home
|
||||
let calendar_result = calendar_service
|
||||
.get_calendar(first_segment, &user.id)
|
||||
.await;
|
||||
let calendar_result = calendar_service.get_calendar(first_segment, &user.id).await;
|
||||
|
||||
if let Ok(calendar) = calendar_result {
|
||||
// Valid calendar ID — return calendar collection
|
||||
@@ -281,9 +270,7 @@ async fn handle_propfind(
|
||||
base_href,
|
||||
&depth,
|
||||
)
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to generate XML: {}", e))
|
||||
})?;
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
@@ -293,12 +280,13 @@ async fn handle_propfind(
|
||||
} else {
|
||||
// Not a calendar ID — treat as user calendar home (e.g. /caldav/{username}/)
|
||||
// List all calendars for this user
|
||||
let calendars = calendar_service
|
||||
.list_my_calendars(&user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to list calendars: {}", e))
|
||||
})?;
|
||||
let calendars =
|
||||
calendar_service
|
||||
.list_my_calendars(&user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to list calendars: {}", e))
|
||||
})?;
|
||||
|
||||
let base_href = &format!("/caldav/{}/", first_segment);
|
||||
let mut response_body = Vec::new();
|
||||
@@ -309,9 +297,7 @@ async fn handle_propfind(
|
||||
&propfind_request,
|
||||
base_href,
|
||||
)
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to generate XML: {}", e))
|
||||
})?;
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
@@ -324,9 +310,7 @@ async fn handle_propfind(
|
||||
let rest = parts[1];
|
||||
|
||||
// Check if first_segment is a valid calendar ID
|
||||
let calendar_result = calendar_service
|
||||
.get_calendar(first_segment, &user.id)
|
||||
.await;
|
||||
let calendar_result = calendar_service.get_calendar(first_segment, &user.id).await;
|
||||
|
||||
let (calendar_id, event_path) = if calendar_result.is_ok() {
|
||||
// first_segment is a calendar ID, rest is event path
|
||||
@@ -341,9 +325,7 @@ async fn handle_propfind(
|
||||
let cal = calendar_service
|
||||
.get_calendar(sub_parts[0], &user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::not_found(format!("Calendar not found: {}", e))
|
||||
})?;
|
||||
.map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?;
|
||||
|
||||
let events = if depth != "0" {
|
||||
calendar_service
|
||||
@@ -354,8 +336,7 @@ async fn handle_propfind(
|
||||
vec![]
|
||||
};
|
||||
|
||||
let base_href =
|
||||
&format!("/caldav/{}/{}/", first_segment, sub_parts[0]);
|
||||
let base_href = &format!("/caldav/{}/{}/", first_segment, sub_parts[0]);
|
||||
let mut response_body = Vec::new();
|
||||
|
||||
CalDavAdapter::generate_calendar_collection_propfind(
|
||||
@@ -387,16 +368,12 @@ async fn handle_propfind(
|
||||
let events = calendar_service
|
||||
.list_events(calendar_id, None, None, &user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to list events: {}", e))
|
||||
})?;
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?;
|
||||
|
||||
let event = events
|
||||
.iter()
|
||||
.find(|e| e.ical_uid == ical_uid)
|
||||
.ok_or_else(|| {
|
||||
AppError::not_found(format!("Event not found: {}", ical_uid))
|
||||
})?;
|
||||
.ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?;
|
||||
|
||||
let base_href = &format!("/caldav/{}/", calendar_id);
|
||||
let report_type = CalDavReportType::CalendarMultiget {
|
||||
@@ -411,9 +388,7 @@ async fn handle_propfind(
|
||||
&report_type,
|
||||
base_href,
|
||||
)
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to generate XML: {}", e))
|
||||
})?;
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to generate XML: {}", e)))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
@@ -718,7 +693,10 @@ fn generate_event_ical(event: &crate::application::dtos::calendar_dto::CalendarE
|
||||
}
|
||||
|
||||
/// Writes a VEVENT block directly into `buf` — zero intermediate allocations.
|
||||
fn write_vevent(buf: &mut String, event: &crate::application::dtos::calendar_dto::CalendarEventDto) {
|
||||
fn write_vevent(
|
||||
buf: &mut String,
|
||||
event: &crate::application::dtos::calendar_dto::CalendarEventDto,
|
||||
) {
|
||||
let _ = write!(
|
||||
buf,
|
||||
"BEGIN:VEVENT\r\nUID:{}\r\nSUMMARY:{}\r\nDTSTART:{}\r\nDTEND:{}\r\n",
|
||||
|
||||
@@ -99,9 +99,7 @@ fn extract_carddav_path(uri_path: &str) -> String {
|
||||
} else if uri_path.ends_with("/carddav") {
|
||||
""
|
||||
} else {
|
||||
uri_path
|
||||
.trim_start_matches('/')
|
||||
.trim_end_matches('/')
|
||||
uri_path.trim_start_matches('/').trim_end_matches('/')
|
||||
};
|
||||
percent_encoding::percent_decode_str(encoded)
|
||||
.decode_utf8_lossy()
|
||||
|
||||
@@ -1,227 +1,225 @@
|
||||
//! HTTP handlers for OAuth 2.0 Device Authorization Grant (RFC 8628).
|
||||
//!
|
||||
//! Endpoints:
|
||||
//! POST /api/auth/device/authorize — Client starts the device flow (public)
|
||||
//! GET /api/auth/device/verify — Check user_code validity (authenticated)
|
||||
//! POST /api/auth/device/verify — User approves/denies (authenticated)
|
||||
//! POST /api/auth/device/token — Client polls for tokens (public)
|
||||
//! GET /api/auth/device/devices — List user's authorized devices (authenticated)
|
||||
//! DELETE /api/auth/device/devices/{id} — Revoke a device (authenticated)
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Json, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::device_auth_dto::*;
|
||||
use crate::application::services::device_auth_service::DeviceAuthService;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
/// Create the device auth router.
|
||||
///
|
||||
/// Public endpoints (no auth middleware): authorize, token
|
||||
/// Protected endpoints (behind auth middleware): verify (GET+POST), devices
|
||||
pub fn device_auth_public_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// Client-facing endpoints (no auth needed — the client doesn't have tokens yet)
|
||||
.route("/authorize", post(device_authorize))
|
||||
.route("/token", post(device_token))
|
||||
}
|
||||
|
||||
pub fn device_auth_protected_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// User-facing endpoints (require valid session)
|
||||
.route("/verify", get(device_verify_info))
|
||||
.route("/verify", post(device_verify_action))
|
||||
.route("/devices", get(list_devices))
|
||||
.route("/devices/{id}", delete(revoke_device))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/authorize — Client initiates the device flow
|
||||
// ============================================================================
|
||||
|
||||
/// Client sends: `{ "client_name": "rclone", "scope": "webdav" }`
|
||||
/// Server returns: device_code, user_code, verification_uri, etc.
|
||||
async fn device_authorize(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<DeviceAuthorizeRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let response = device_service.initiate(body).await.map_err(|e| {
|
||||
tracing::error!("Device authorize failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/token — Client polls for tokens
|
||||
// ============================================================================
|
||||
|
||||
/// Client sends: `{ "device_code": "...", "grant_type": "urn:ietf:params:oauth:grant-type:device_code" }`
|
||||
/// Returns tokens on success, or RFC 8628 error codes while pending.
|
||||
async fn device_token(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<DeviceTokenRequestDto>,
|
||||
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||
let device_service = match get_device_service(&state) {
|
||||
Ok(svc) => svc,
|
||||
Err(e) => return Err(e.into_response()),
|
||||
};
|
||||
|
||||
// Validate grant_type if provided (RFC compliance)
|
||||
if !body.grant_type.is_empty()
|
||||
&& body.grant_type != "urn:ietf:params:oauth:grant-type:device_code"
|
||||
{
|
||||
let error_body = serde_json::json!({
|
||||
"error": "unsupported_grant_type",
|
||||
"error_description": "grant_type must be urn:ietf:params:oauth:grant-type:device_code"
|
||||
});
|
||||
return Err((StatusCode::BAD_REQUEST, Json(error_body)).into_response());
|
||||
}
|
||||
|
||||
match device_service.poll(&body.device_code).await {
|
||||
Ok(tokens) => Ok((StatusCode::OK, Json(tokens)).into_response()),
|
||||
Err(poll_err) => {
|
||||
let status = StatusCode::from_u16(poll_err.http_status())
|
||||
.unwrap_or(StatusCode::BAD_REQUEST);
|
||||
let error_body = serde_json::json!({
|
||||
"error": poll_err.error_code(),
|
||||
"error_description": poll_err.description()
|
||||
});
|
||||
Err((status, Json(error_body)).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/auth/device/verify?code=ABCD-1234 — Check if user_code is valid
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct VerifyQuery {
|
||||
#[serde(default)]
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
async fn device_verify_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_auth_user: AuthUser,
|
||||
Query(query): Query<VerifyQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let info = device_service
|
||||
.verify_user_code(&query.code)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!("Device verify lookup failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(info)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/verify — User approves or denies
|
||||
// ============================================================================
|
||||
|
||||
async fn device_verify_action(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Json(body): Json<DeviceVerifyRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
match body.action.to_lowercase().as_str() {
|
||||
"approve" | "allow" | "accept" => {
|
||||
device_service
|
||||
.approve(&body.user_code, &auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Device approve failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "status": "approved" })),
|
||||
))
|
||||
}
|
||||
"deny" | "reject" | "cancel" => {
|
||||
device_service.deny(&body.user_code).await.map_err(|e| {
|
||||
tracing::error!("Device deny failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "status": "denied" })),
|
||||
))
|
||||
}
|
||||
_ => Err(AppError::bad_request(
|
||||
"action must be 'approve' or 'deny'",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/auth/device/devices — List user's authorized devices
|
||||
// ============================================================================
|
||||
|
||||
async fn list_devices(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let devices = device_service
|
||||
.list_user_devices(&auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("List devices failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(devices)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DELETE /api/auth/device/devices/{id} — Revoke a device authorization
|
||||
// ============================================================================
|
||||
|
||||
async fn revoke_device(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(device_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
device_service
|
||||
.revoke_device(&device_id, &auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Revoke device failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper
|
||||
// ============================================================================
|
||||
|
||||
fn get_device_service(state: &AppState) -> Result<&Arc<DeviceAuthService>, AppError> {
|
||||
state
|
||||
.device_auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Device authorization service not configured"))
|
||||
}
|
||||
//! HTTP handlers for OAuth 2.0 Device Authorization Grant (RFC 8628).
|
||||
//!
|
||||
//! Endpoints:
|
||||
//! POST /api/auth/device/authorize — Client starts the device flow (public)
|
||||
//! GET /api/auth/device/verify — Check user_code validity (authenticated)
|
||||
//! POST /api/auth/device/verify — User approves/denies (authenticated)
|
||||
//! POST /api/auth/device/token — Client polls for tokens (public)
|
||||
//! GET /api/auth/device/devices — List user's authorized devices (authenticated)
|
||||
//! DELETE /api/auth/device/devices/{id} — Revoke a device (authenticated)
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Json, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::application::dtos::device_auth_dto::*;
|
||||
use crate::application::services::device_auth_service::DeviceAuthService;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
/// Create the device auth router.
|
||||
///
|
||||
/// Public endpoints (no auth middleware): authorize, token
|
||||
/// Protected endpoints (behind auth middleware): verify (GET+POST), devices
|
||||
pub fn device_auth_public_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// Client-facing endpoints (no auth needed — the client doesn't have tokens yet)
|
||||
.route("/authorize", post(device_authorize))
|
||||
.route("/token", post(device_token))
|
||||
}
|
||||
|
||||
pub fn device_auth_protected_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
// User-facing endpoints (require valid session)
|
||||
.route("/verify", get(device_verify_info))
|
||||
.route("/verify", post(device_verify_action))
|
||||
.route("/devices", get(list_devices))
|
||||
.route("/devices/{id}", delete(revoke_device))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/authorize — Client initiates the device flow
|
||||
// ============================================================================
|
||||
|
||||
/// Client sends: `{ "client_name": "rclone", "scope": "webdav" }`
|
||||
/// Server returns: device_code, user_code, verification_uri, etc.
|
||||
async fn device_authorize(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<DeviceAuthorizeRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let response = device_service.initiate(body).await.map_err(|e| {
|
||||
tracing::error!("Device authorize failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(response)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/token — Client polls for tokens
|
||||
// ============================================================================
|
||||
|
||||
/// Client sends: `{ "device_code": "...", "grant_type": "urn:ietf:params:oauth:grant-type:device_code" }`
|
||||
/// Returns tokens on success, or RFC 8628 error codes while pending.
|
||||
async fn device_token(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<DeviceTokenRequestDto>,
|
||||
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||
let device_service = match get_device_service(&state) {
|
||||
Ok(svc) => svc,
|
||||
Err(e) => return Err(e.into_response()),
|
||||
};
|
||||
|
||||
// Validate grant_type if provided (RFC compliance)
|
||||
if !body.grant_type.is_empty()
|
||||
&& body.grant_type != "urn:ietf:params:oauth:grant-type:device_code"
|
||||
{
|
||||
let error_body = serde_json::json!({
|
||||
"error": "unsupported_grant_type",
|
||||
"error_description": "grant_type must be urn:ietf:params:oauth:grant-type:device_code"
|
||||
});
|
||||
return Err((StatusCode::BAD_REQUEST, Json(error_body)).into_response());
|
||||
}
|
||||
|
||||
match device_service.poll(&body.device_code).await {
|
||||
Ok(tokens) => Ok((StatusCode::OK, Json(tokens)).into_response()),
|
||||
Err(poll_err) => {
|
||||
let status =
|
||||
StatusCode::from_u16(poll_err.http_status()).unwrap_or(StatusCode::BAD_REQUEST);
|
||||
let error_body = serde_json::json!({
|
||||
"error": poll_err.error_code(),
|
||||
"error_description": poll_err.description()
|
||||
});
|
||||
Err((status, Json(error_body)).into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/auth/device/verify?code=ABCD-1234 — Check if user_code is valid
|
||||
// ============================================================================
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct VerifyQuery {
|
||||
#[serde(default)]
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
async fn device_verify_info(
|
||||
State(state): State<Arc<AppState>>,
|
||||
_auth_user: AuthUser,
|
||||
Query(query): Query<VerifyQuery>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let info = device_service
|
||||
.verify_user_code(&query.code)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!("Device verify lookup failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(info)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/auth/device/verify — User approves or denies
|
||||
// ============================================================================
|
||||
|
||||
async fn device_verify_action(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Json(body): Json<DeviceVerifyRequestDto>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
match body.action.to_lowercase().as_str() {
|
||||
"approve" | "allow" | "accept" => {
|
||||
device_service
|
||||
.approve(&body.user_code, &auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Device approve failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "status": "approved" })),
|
||||
))
|
||||
}
|
||||
"deny" | "reject" | "cancel" => {
|
||||
device_service.deny(&body.user_code).await.map_err(|e| {
|
||||
tracing::error!("Device deny failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({ "status": "denied" })),
|
||||
))
|
||||
}
|
||||
_ => Err(AppError::bad_request("action must be 'approve' or 'deny'")),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/auth/device/devices — List user's authorized devices
|
||||
// ============================================================================
|
||||
|
||||
async fn list_devices(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
let devices = device_service
|
||||
.list_user_devices(&auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("List devices failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok((StatusCode::OK, Json(devices)))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DELETE /api/auth/device/devices/{id} — Revoke a device authorization
|
||||
// ============================================================================
|
||||
|
||||
async fn revoke_device(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(device_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let device_service = get_device_service(&state)?;
|
||||
|
||||
device_service
|
||||
.revoke_device(&device_id, &auth_user.id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Revoke device failed: {}", e);
|
||||
AppError::from(e)
|
||||
})?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper
|
||||
// ============================================================================
|
||||
|
||||
fn get_device_service(state: &AppState) -> Result<&Arc<DeviceAuthService>, AppError> {
|
||||
state
|
||||
.device_auth_service
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::internal_error("Device authorization service not configured"))
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ pub mod admin_handler;
|
||||
pub mod app_password_handler;
|
||||
pub mod auth_handler;
|
||||
pub mod batch_handler;
|
||||
pub mod device_auth_handler;
|
||||
pub mod caldav_handler;
|
||||
pub mod carddav_handler;
|
||||
pub mod chunked_upload_handler;
|
||||
pub mod dedup_handler;
|
||||
pub mod device_auth_handler;
|
||||
pub mod favorites_handler;
|
||||
pub mod file_handler;
|
||||
pub mod folder_handler;
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::common::di::AppState;
|
||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::CurrentUser;
|
||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC, AsciiSet};
|
||||
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Characters that MUST NOT be percent-encoded inside a URI path segment.
|
||||
@@ -115,9 +115,7 @@ fn extract_webdav_path(uri: &axum::http::Uri) -> String {
|
||||
trimmed.trim_end_matches('/')
|
||||
};
|
||||
// Decode percent-encoded characters (e.g. %20 → space)
|
||||
percent_decode_str(encoded)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned()
|
||||
percent_decode_str(encoded).decode_utf8_lossy().into_owned()
|
||||
}
|
||||
|
||||
async fn handle_webdav_methods_root(
|
||||
@@ -324,8 +322,13 @@ async fn handle_propfind(
|
||||
let mut xml_writer = Writer::new(&mut buf);
|
||||
WebDavAdapter::write_multistatus_start(&mut xml_writer)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
WebDavAdapter::write_file_entry(&mut xml_writer, &file, &propfind_request, &base_href)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
WebDavAdapter::write_file_entry(
|
||||
&mut xml_writer,
|
||||
&file,
|
||||
&propfind_request,
|
||||
&base_href,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
WebDavAdapter::write_multistatus_end(&mut xml_writer)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
@@ -358,8 +361,13 @@ async fn handle_propfind(
|
||||
let mut xml_writer = Writer::new(&mut buf);
|
||||
WebDavAdapter::write_multistatus_start(&mut xml_writer)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
WebDavAdapter::write_file_entry(&mut xml_writer, &file, &propfind_request, &base_href)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
WebDavAdapter::write_file_entry(
|
||||
&mut xml_writer,
|
||||
&file,
|
||||
&propfind_request,
|
||||
&base_href,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
WebDavAdapter::write_multistatus_end(&mut xml_writer)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
@@ -910,13 +918,17 @@ async fn handle_delete(
|
||||
folder_service
|
||||
.delete_folder(&folder.id, caller_id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to delete folder: {}", e))
|
||||
})?;
|
||||
}
|
||||
Ok(ResolvedResource::File(file)) => {
|
||||
file_management_service
|
||||
.delete_file(&file.id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to delete file: {}", e))
|
||||
})?;
|
||||
}
|
||||
Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", path))),
|
||||
}
|
||||
@@ -1002,8 +1014,14 @@ async fn handle_move(
|
||||
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
||||
resolver.exists(&destination_path).await.unwrap_or(false)
|
||||
} else {
|
||||
folder_service.get_folder_by_path(&destination_path).await.is_ok()
|
||||
|| file_retrieval_service.get_file_by_path(&destination_path).await.is_ok()
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
if dest_exists {
|
||||
return Err(AppError::precondition_failed(
|
||||
@@ -1044,7 +1062,9 @@ async fn handle_move(
|
||||
folder.owner_id.as_deref().unwrap_or("webdav"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to move folder: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to move folder: {}", e))
|
||||
})?;
|
||||
|
||||
if folder.name != dest_folder_name {
|
||||
let rename_dto = crate::application::dtos::folder_dto::RenameFolderDto {
|
||||
@@ -1057,7 +1077,9 @@ async fn handle_move(
|
||||
folder.owner_id.as_deref().unwrap_or("webdav"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to rename folder: {}", e))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(ResolvedResource::File(file)) => {
|
||||
@@ -1080,16 +1102,25 @@ async fn handle_move(
|
||||
file_management_service
|
||||
.move_file(&file.id, Some(dest_parent_path.to_string()))
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to move file: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to move file: {}", e))
|
||||
})?;
|
||||
}
|
||||
if file.name != dest_filename {
|
||||
file_management_service
|
||||
.rename_file(&file.id, dest_filename)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to rename file: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to rename file: {}", e))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", source_path))),
|
||||
Err(_) => {
|
||||
return Err(AppError::not_found(format!(
|
||||
"Resource not found: {}",
|
||||
source_path
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: legacy double-query path
|
||||
@@ -1137,13 +1168,17 @@ async fn handle_move(
|
||||
folder.owner_id.as_deref().unwrap_or("webdav"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to rename folder: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to rename folder: {}", e))
|
||||
})?;
|
||||
}
|
||||
} else {
|
||||
let file = file_retrieval_service
|
||||
.get_file_by_path(&source_path)
|
||||
.await
|
||||
.map_err(|_e| AppError::not_found(format!("Resource not found: {}", source_path)))?;
|
||||
.map_err(|_e| {
|
||||
AppError::not_found(format!("Resource not found: {}", source_path))
|
||||
})?;
|
||||
|
||||
let dest_filename = destination_path
|
||||
.split('/')
|
||||
@@ -1170,7 +1205,9 @@ async fn handle_move(
|
||||
file_management_service
|
||||
.rename_file(&file.id, dest_filename)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to rename file: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to rename file: {}", e))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1240,8 +1277,14 @@ async fn handle_copy(
|
||||
let dest_exists = if let Some(resolver) = &state.path_resolver {
|
||||
resolver.exists(&destination_path).await.unwrap_or(false)
|
||||
} else {
|
||||
folder_service.get_folder_by_path(&destination_path).await.is_ok()
|
||||
|| file_retrieval_service.get_file_by_path(&destination_path).await.is_ok()
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
if dest_exists {
|
||||
return Err(AppError::precondition_failed(
|
||||
@@ -1296,7 +1339,10 @@ async fn handle_copy(
|
||||
.create_folder(create_dto)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to create destination folder: {}", e))
|
||||
AppError::internal_error(format!(
|
||||
"Failed to create destination folder: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
@@ -1322,7 +1368,12 @@ async fn handle_copy(
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to copy file: {}", e)))?;
|
||||
}
|
||||
Err(_) => return Err(AppError::not_found(format!("Resource not found: {}", source_path))),
|
||||
Err(_) => {
|
||||
return Err(AppError::not_found(format!(
|
||||
"Resource not found: {}",
|
||||
source_path
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: legacy double-query path
|
||||
@@ -1371,14 +1422,19 @@ async fn handle_copy(
|
||||
.create_folder(create_dto)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to create destination folder: {}", e))
|
||||
AppError::internal_error(format!(
|
||||
"Failed to create destination folder: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
} else {
|
||||
let file = file_retrieval_service
|
||||
.get_file_by_path(&source_path)
|
||||
.await
|
||||
.map_err(|_e| AppError::not_found(format!("Resource not found: {}", source_path)))?;
|
||||
.map_err(|_e| {
|
||||
AppError::not_found(format!("Resource not found: {}", source_path))
|
||||
})?;
|
||||
|
||||
let dest_parent_path = if let Some(idx) = destination_path.rfind('/') {
|
||||
&destination_path[..idx]
|
||||
|
||||
@@ -205,10 +205,7 @@ pub async fn auth_middleware(
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Bearer token validation failed: {}", e);
|
||||
return Err(AuthError::InvalidToken(format!(
|
||||
"Invalid token: {}",
|
||||
e
|
||||
)));
|
||||
return Err(AuthError::InvalidToken(format!("Invalid token: {}", e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,7 +270,9 @@ pub async fn auth_middleware(
|
||||
{
|
||||
use crate::interfaces::api::cookie_auth;
|
||||
|
||||
if let Some(token_str) = cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE) {
|
||||
if let Some(token_str) =
|
||||
cookie_auth::extract_cookie_value(&headers, cookie_auth::ACCESS_COOKIE)
|
||||
{
|
||||
if !token_str.is_empty() {
|
||||
tracing::debug!("Processing cookie-based authentication");
|
||||
|
||||
@@ -281,10 +280,7 @@ pub async fn auth_middleware(
|
||||
let token_service = &auth_service.token_service;
|
||||
match token_service.validate_token(&token_str) {
|
||||
Ok(claims) => {
|
||||
tracing::debug!(
|
||||
"Cookie token validated for user: {}",
|
||||
claims.username
|
||||
);
|
||||
tracing::debug!("Cookie token validated for user: {}", claims.username);
|
||||
let current_user = CurrentUser {
|
||||
id: claims.sub,
|
||||
username: claims.username,
|
||||
|
||||
@@ -1,75 +1,73 @@
|
||||
//! CSRF double-submit cookie middleware.
|
||||
//!
|
||||
//! State-changing requests (`POST`, `PUT`, `DELETE`, `PATCH`) that were
|
||||
//! authenticated via an HttpOnly cookie (i.e. browser sessions) **must**
|
||||
//! include an `X-CSRF-Token` header whose value matches the `oxicloud_csrf`
|
||||
//! cookie. Requests authenticated via `Bearer` or `Basic` headers are
|
||||
//! exempt because they are not vulnerable to CSRF — the browser never
|
||||
//! attaches those automatically.
|
||||
//!
|
||||
//! Safe methods (`GET`, `HEAD`, `OPTIONS`) are always allowed through.
|
||||
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{Method, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::interfaces::api::cookie_auth;
|
||||
use crate::interfaces::middleware::auth::CookieAuthenticated;
|
||||
|
||||
/// Methods considered safe (no side-effects) — CSRF check is skipped.
|
||||
const SAFE_METHODS: [Method; 3] = [Method::GET, Method::HEAD, Method::OPTIONS];
|
||||
|
||||
/// Middleware that enforces CSRF protection for cookie-authenticated browser
|
||||
/// sessions using the **double-submit cookie** pattern.
|
||||
///
|
||||
/// Must be applied **after** `auth_middleware` so that the
|
||||
/// `CookieAuthenticated` marker is available in extensions.
|
||||
pub async fn csrf_middleware(request: Request, next: Next) -> Result<Response, Response> {
|
||||
// Safe methods never need CSRF validation.
|
||||
if SAFE_METHODS.contains(request.method()) {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
// Only enforce for cookie-authenticated sessions.
|
||||
let is_cookie_auth = request.extensions().get::<CookieAuthenticated>().is_some();
|
||||
if !is_cookie_auth {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
// Extract the CSRF token from the cookie.
|
||||
let cookie_token = cookie_auth::extract_cookie_value(
|
||||
request.headers(),
|
||||
cookie_auth::CSRF_COOKIE,
|
||||
);
|
||||
|
||||
// Extract the CSRF token from the request header.
|
||||
let header_token = request
|
||||
.headers()
|
||||
.get(cookie_auth::CSRF_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
match (cookie_token, header_token) {
|
||||
(Some(c), Some(h)) if !c.is_empty() && c == h => {
|
||||
// Tokens match — allow the request through.
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
method = %request.method(),
|
||||
uri = %request.uri(),
|
||||
"CSRF validation failed: missing or mismatched token"
|
||||
);
|
||||
Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
axum::Json(serde_json::json!({
|
||||
"error": "CSRF token missing or invalid"
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
//! CSRF double-submit cookie middleware.
|
||||
//!
|
||||
//! State-changing requests (`POST`, `PUT`, `DELETE`, `PATCH`) that were
|
||||
//! authenticated via an HttpOnly cookie (i.e. browser sessions) **must**
|
||||
//! include an `X-CSRF-Token` header whose value matches the `oxicloud_csrf`
|
||||
//! cookie. Requests authenticated via `Bearer` or `Basic` headers are
|
||||
//! exempt because they are not vulnerable to CSRF — the browser never
|
||||
//! attaches those automatically.
|
||||
//!
|
||||
//! Safe methods (`GET`, `HEAD`, `OPTIONS`) are always allowed through.
|
||||
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{Method, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
use crate::interfaces::api::cookie_auth;
|
||||
use crate::interfaces::middleware::auth::CookieAuthenticated;
|
||||
|
||||
/// Methods considered safe (no side-effects) — CSRF check is skipped.
|
||||
const SAFE_METHODS: [Method; 3] = [Method::GET, Method::HEAD, Method::OPTIONS];
|
||||
|
||||
/// Middleware that enforces CSRF protection for cookie-authenticated browser
|
||||
/// sessions using the **double-submit cookie** pattern.
|
||||
///
|
||||
/// Must be applied **after** `auth_middleware` so that the
|
||||
/// `CookieAuthenticated` marker is available in extensions.
|
||||
pub async fn csrf_middleware(request: Request, next: Next) -> Result<Response, Response> {
|
||||
// Safe methods never need CSRF validation.
|
||||
if SAFE_METHODS.contains(request.method()) {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
// Only enforce for cookie-authenticated sessions.
|
||||
let is_cookie_auth = request.extensions().get::<CookieAuthenticated>().is_some();
|
||||
if !is_cookie_auth {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
// Extract the CSRF token from the cookie.
|
||||
let cookie_token =
|
||||
cookie_auth::extract_cookie_value(request.headers(), cookie_auth::CSRF_COOKIE);
|
||||
|
||||
// Extract the CSRF token from the request header.
|
||||
let header_token = request
|
||||
.headers()
|
||||
.get(cookie_auth::CSRF_HEADER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
match (cookie_token, header_token) {
|
||||
(Some(c), Some(h)) if !c.is_empty() && c == h => {
|
||||
// Tokens match — allow the request through.
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
method = %request.method(),
|
||||
uri = %request.uri(),
|
||||
"CSRF validation failed: missing or mismatched token"
|
||||
);
|
||||
Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
axum::Json(serde_json::json!({
|
||||
"error": "CSRF token missing or invalid"
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,202 +1,196 @@
|
||||
//! IP-based rate limiting middleware for authentication endpoints.
|
||||
//!
|
||||
//! Uses `moka` TTL caches (already a project dependency) to track request
|
||||
//! counts per client IP. Each protected endpoint group gets its own
|
||||
//! [`RateLimiter`] instance with independently tuneable limits.
|
||||
//!
|
||||
//! The middleware extracts the client IP from (in order):
|
||||
//! 1. `X-Forwarded-For` header (first entry — set by reverse proxies)
|
||||
//! 2. `X-Real-Ip` header
|
||||
//! 3. The TCP peer address from the connection info
|
||||
//!
|
||||
//! When the limit is exceeded a `429 Too Many Requests` response is returned
|
||||
//! with a `Retry-After` header indicating how many seconds to wait.
|
||||
|
||||
use axum::{
|
||||
extract::ConnectInfo,
|
||||
http::{HeaderValue, Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use moka::sync::Cache;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A simple sliding-window counter keyed by IP address.
|
||||
///
|
||||
/// Each key lives for `window` seconds; every request increments the counter.
|
||||
/// Once the counter reaches `max_requests` the request is rejected.
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimiter {
|
||||
/// Maps `IP -> request_count` with automatic TTL expiration.
|
||||
cache: Cache<String, u32>,
|
||||
/// Maximum requests allowed within the window.
|
||||
max_requests: u32,
|
||||
/// Window duration in seconds (also used for `Retry-After`).
|
||||
window_secs: u64,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
/// Create a new rate limiter.
|
||||
///
|
||||
/// * `max_requests` — ceiling per IP within the window
|
||||
/// * `window_secs` — sliding window duration
|
||||
/// * `max_entries` — upper bound on tracked IPs (evicts LRU when exceeded)
|
||||
pub fn new(max_requests: u32, window_secs: u64, max_entries: u64) -> Self {
|
||||
let cache = Cache::builder()
|
||||
.time_to_live(Duration::from_secs(window_secs))
|
||||
.max_capacity(max_entries)
|
||||
.build();
|
||||
Self {
|
||||
cache,
|
||||
max_requests,
|
||||
window_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the IP is allowed. Returns `Ok(current_count)` or
|
||||
/// `Err(StatusCode::TOO_MANY_REQUESTS)`.
|
||||
pub fn check_and_increment(&self, ip: &str) -> Result<u32, ()> {
|
||||
let key = ip.to_string();
|
||||
// moka's entry API lets us atomically read-modify-write.
|
||||
// On first access the entry is inserted with count = 1 and the TTL
|
||||
// starts. Subsequent accesses within the window increment the count.
|
||||
let count = self
|
||||
.cache
|
||||
.entry(key)
|
||||
.or_insert_with(|| 0)
|
||||
.into_value()
|
||||
+ 1;
|
||||
|
||||
// Write back the incremented value. Because `or_insert_with` returns
|
||||
// the *existing* value when the key was already present, we must always
|
||||
// re-insert so the counter actually advances. The TTL of the **first**
|
||||
// insert still governs eviction because moka uses insert-time TTL.
|
||||
// However, on re-insert moka resets the TTL — for rate limiting this
|
||||
// is fine because it means the window "slides" forward on activity.
|
||||
self.cache
|
||||
.insert(ip.to_string(), count);
|
||||
|
||||
if count > self.max_requests {
|
||||
Err(())
|
||||
} else {
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds the client should wait before retrying.
|
||||
pub fn retry_after(&self) -> u64 {
|
||||
self.window_secs
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Axum middleware factories ──────────────────────────────────────────────
|
||||
|
||||
/// Extract the most-likely real client IP from headers / connection info.
|
||||
pub fn extract_client_ip<B>(req: &Request<B>) -> String {
|
||||
let headers = req.headers();
|
||||
|
||||
// 1. X-Forwarded-For (first entry — closest to the client)
|
||||
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
|
||||
if let Some(first) = xff.split(',').next() {
|
||||
let ip = first.trim();
|
||||
if !ip.is_empty() {
|
||||
return ip.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. X-Real-Ip
|
||||
if let Some(xri) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
|
||||
let ip = xri.trim();
|
||||
if !ip.is_empty() {
|
||||
return ip.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. TCP peer (ConnectInfo extension set by axum::serve)
|
||||
if let Some(addr) = req.extensions().get::<ConnectInfo<SocketAddr>>() {
|
||||
return addr.0.ip().to_string();
|
||||
}
|
||||
|
||||
// Fallback — should never happen behind axum::serve
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
/// Build a rate-limit response with the standard `Retry-After` header.
|
||||
fn too_many_requests(retry_after: u64) -> Response {
|
||||
let body = serde_json::json!({
|
||||
"error": "Too many requests",
|
||||
"retry_after_secs": retry_after,
|
||||
});
|
||||
let mut resp = (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
if let Ok(val) = HeaderValue::from_str(&retry_after.to_string()) {
|
||||
resp.headers_mut().insert("retry-after", val);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// Axum middleware: rate-limit login attempts.
|
||||
///
|
||||
/// Inject via:
|
||||
/// ```ignore
|
||||
/// .layer(axum::middleware::from_fn_with_state(limiter, rate_limit_login))
|
||||
/// ```
|
||||
pub async fn rate_limit_login(
|
||||
State(limiter): axum::extract::State<Arc<RateLimiter>>,
|
||||
req: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let ip = extract_client_ip(&req);
|
||||
match limiter.check_and_increment(&ip) {
|
||||
Ok(_) => next.run(req).await,
|
||||
Err(()) => {
|
||||
tracing::warn!(
|
||||
ip = %ip,
|
||||
"Rate limit exceeded on login endpoint"
|
||||
);
|
||||
too_many_requests(limiter.retry_after())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Axum middleware: rate-limit registration attempts.
|
||||
pub async fn rate_limit_register(
|
||||
State(limiter): axum::extract::State<Arc<RateLimiter>>,
|
||||
req: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let ip = extract_client_ip(&req);
|
||||
match limiter.check_and_increment(&ip) {
|
||||
Ok(_) => next.run(req).await,
|
||||
Err(()) => {
|
||||
tracing::warn!(
|
||||
ip = %ip,
|
||||
"Rate limit exceeded on register endpoint"
|
||||
);
|
||||
too_many_requests(limiter.retry_after())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Axum middleware: rate-limit token refresh attempts.
|
||||
pub async fn rate_limit_refresh(
|
||||
State(limiter): axum::extract::State<Arc<RateLimiter>>,
|
||||
req: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let ip = extract_client_ip(&req);
|
||||
match limiter.check_and_increment(&ip) {
|
||||
Ok(_) => next.run(req).await,
|
||||
Err(()) => {
|
||||
tracing::warn!(
|
||||
ip = %ip,
|
||||
"Rate limit exceeded on refresh endpoint"
|
||||
);
|
||||
too_many_requests(limiter.retry_after())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use axum::extract::State;
|
||||
//! IP-based rate limiting middleware for authentication endpoints.
|
||||
//!
|
||||
//! Uses `moka` TTL caches (already a project dependency) to track request
|
||||
//! counts per client IP. Each protected endpoint group gets its own
|
||||
//! [`RateLimiter`] instance with independently tuneable limits.
|
||||
//!
|
||||
//! The middleware extracts the client IP from (in order):
|
||||
//! 1. `X-Forwarded-For` header (first entry — set by reverse proxies)
|
||||
//! 2. `X-Real-Ip` header
|
||||
//! 3. The TCP peer address from the connection info
|
||||
//!
|
||||
//! When the limit is exceeded a `429 Too Many Requests` response is returned
|
||||
//! with a `Retry-After` header indicating how many seconds to wait.
|
||||
|
||||
use axum::{
|
||||
extract::ConnectInfo,
|
||||
http::{HeaderValue, Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use moka::sync::Cache;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// A simple sliding-window counter keyed by IP address.
|
||||
///
|
||||
/// Each key lives for `window` seconds; every request increments the counter.
|
||||
/// Once the counter reaches `max_requests` the request is rejected.
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimiter {
|
||||
/// Maps `IP -> request_count` with automatic TTL expiration.
|
||||
cache: Cache<String, u32>,
|
||||
/// Maximum requests allowed within the window.
|
||||
max_requests: u32,
|
||||
/// Window duration in seconds (also used for `Retry-After`).
|
||||
window_secs: u64,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
/// Create a new rate limiter.
|
||||
///
|
||||
/// * `max_requests` — ceiling per IP within the window
|
||||
/// * `window_secs` — sliding window duration
|
||||
/// * `max_entries` — upper bound on tracked IPs (evicts LRU when exceeded)
|
||||
pub fn new(max_requests: u32, window_secs: u64, max_entries: u64) -> Self {
|
||||
let cache = Cache::builder()
|
||||
.time_to_live(Duration::from_secs(window_secs))
|
||||
.max_capacity(max_entries)
|
||||
.build();
|
||||
Self {
|
||||
cache,
|
||||
max_requests,
|
||||
window_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the IP is allowed. Returns `Ok(current_count)` or
|
||||
/// `Err(StatusCode::TOO_MANY_REQUESTS)`.
|
||||
pub fn check_and_increment(&self, ip: &str) -> Result<u32, ()> {
|
||||
let key = ip.to_string();
|
||||
// moka's entry API lets us atomically read-modify-write.
|
||||
// On first access the entry is inserted with count = 1 and the TTL
|
||||
// starts. Subsequent accesses within the window increment the count.
|
||||
let count = self.cache.entry(key).or_insert_with(|| 0).into_value() + 1;
|
||||
|
||||
// Write back the incremented value. Because `or_insert_with` returns
|
||||
// the *existing* value when the key was already present, we must always
|
||||
// re-insert so the counter actually advances. The TTL of the **first**
|
||||
// insert still governs eviction because moka uses insert-time TTL.
|
||||
// However, on re-insert moka resets the TTL — for rate limiting this
|
||||
// is fine because it means the window "slides" forward on activity.
|
||||
self.cache.insert(ip.to_string(), count);
|
||||
|
||||
if count > self.max_requests {
|
||||
Err(())
|
||||
} else {
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds the client should wait before retrying.
|
||||
pub fn retry_after(&self) -> u64 {
|
||||
self.window_secs
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Axum middleware factories ──────────────────────────────────────────────
|
||||
|
||||
/// Extract the most-likely real client IP from headers / connection info.
|
||||
pub fn extract_client_ip<B>(req: &Request<B>) -> String {
|
||||
let headers = req.headers();
|
||||
|
||||
// 1. X-Forwarded-For (first entry — closest to the client)
|
||||
if let Some(xff) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
|
||||
if let Some(first) = xff.split(',').next() {
|
||||
let ip = first.trim();
|
||||
if !ip.is_empty() {
|
||||
return ip.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. X-Real-Ip
|
||||
if let Some(xri) = headers.get("x-real-ip").and_then(|v| v.to_str().ok()) {
|
||||
let ip = xri.trim();
|
||||
if !ip.is_empty() {
|
||||
return ip.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. TCP peer (ConnectInfo extension set by axum::serve)
|
||||
if let Some(addr) = req.extensions().get::<ConnectInfo<SocketAddr>>() {
|
||||
return addr.0.ip().to_string();
|
||||
}
|
||||
|
||||
// Fallback — should never happen behind axum::serve
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
/// Build a rate-limit response with the standard `Retry-After` header.
|
||||
fn too_many_requests(retry_after: u64) -> Response {
|
||||
let body = serde_json::json!({
|
||||
"error": "Too many requests",
|
||||
"retry_after_secs": retry_after,
|
||||
});
|
||||
let mut resp = (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response();
|
||||
if let Ok(val) = HeaderValue::from_str(&retry_after.to_string()) {
|
||||
resp.headers_mut().insert("retry-after", val);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// Axum middleware: rate-limit login attempts.
|
||||
///
|
||||
/// Inject via:
|
||||
/// ```ignore
|
||||
/// .layer(axum::middleware::from_fn_with_state(limiter, rate_limit_login))
|
||||
/// ```
|
||||
pub async fn rate_limit_login(
|
||||
State(limiter): axum::extract::State<Arc<RateLimiter>>,
|
||||
req: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let ip = extract_client_ip(&req);
|
||||
match limiter.check_and_increment(&ip) {
|
||||
Ok(_) => next.run(req).await,
|
||||
Err(()) => {
|
||||
tracing::warn!(
|
||||
ip = %ip,
|
||||
"Rate limit exceeded on login endpoint"
|
||||
);
|
||||
too_many_requests(limiter.retry_after())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Axum middleware: rate-limit registration attempts.
|
||||
pub async fn rate_limit_register(
|
||||
State(limiter): axum::extract::State<Arc<RateLimiter>>,
|
||||
req: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let ip = extract_client_ip(&req);
|
||||
match limiter.check_and_increment(&ip) {
|
||||
Ok(_) => next.run(req).await,
|
||||
Err(()) => {
|
||||
tracing::warn!(
|
||||
ip = %ip,
|
||||
"Rate limit exceeded on register endpoint"
|
||||
);
|
||||
too_many_requests(limiter.retry_after())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Axum middleware: rate-limit token refresh attempts.
|
||||
pub async fn rate_limit_refresh(
|
||||
State(limiter): axum::extract::State<Arc<RateLimiter>>,
|
||||
req: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let ip = extract_client_ip(&req);
|
||||
match limiter.check_and_increment(&ip) {
|
||||
Ok(_) => next.run(req).await,
|
||||
Err(()) => {
|
||||
tracing::warn!(
|
||||
ip = %ip,
|
||||
"Rate limit exceeded on refresh endpoint"
|
||||
);
|
||||
too_many_requests(limiter.retry_after())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use axum::extract::State;
|
||||
|
||||
Reference in New Issue
Block a user