security: add IP rate limiting + account lockout on auth endpoints
- Rate limit login (5/min), register (3/hr), refresh (10/min) per IP - Account lockout after 5 consecutive failed logins (15 min cooldown) - Fix stored XSS in admin panel (escapeHtml on all user-controlled data) - All limits configurable via OXICLOUD_RATE_LIMIT_* / OXICLOUD_LOCKOUT_* env vars - Zero new dependencies (uses existing moka crate for in-memory caches) - Includes unit tests for lockout service
This commit is contained in:
@@ -262,6 +262,44 @@ pub struct AuthConfig {
|
||||
pub hash_time_cost: u32,
|
||||
/// Argon2id parallelism lanes (default 2)
|
||||
pub hash_parallelism: u32,
|
||||
/// Rate limiting / account lockout configuration
|
||||
pub rate_limit: RateLimitConfig,
|
||||
}
|
||||
|
||||
/// Rate limiting and brute-force protection configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RateLimitConfig {
|
||||
/// Max login attempts per IP per window (default: 10)
|
||||
pub login_max_requests: u32,
|
||||
/// Login rate-limit window in seconds (default: 60)
|
||||
pub login_window_secs: u64,
|
||||
/// Max registration attempts per IP per window (default: 5)
|
||||
pub register_max_requests: u32,
|
||||
/// Registration rate-limit window in seconds (default: 3600)
|
||||
pub register_window_secs: u64,
|
||||
/// Max token refresh attempts per IP per window (default: 20)
|
||||
pub refresh_max_requests: u32,
|
||||
/// Refresh rate-limit window in seconds (default: 60)
|
||||
pub refresh_window_secs: u64,
|
||||
/// Consecutive failed logins before account lockout (default: 5)
|
||||
pub lockout_max_failures: u32,
|
||||
/// Account lockout duration in seconds (default: 900 = 15 min)
|
||||
pub lockout_duration_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for RateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
login_max_requests: 10,
|
||||
login_window_secs: 60,
|
||||
register_max_requests: 5,
|
||||
register_window_secs: 3600,
|
||||
refresh_max_requests: 20,
|
||||
refresh_window_secs: 60,
|
||||
lockout_max_failures: 5,
|
||||
lockout_duration_secs: 900,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AuthConfig {
|
||||
@@ -276,6 +314,7 @@ impl Default for AuthConfig {
|
||||
hash_memory_cost: 65536, // 64 MiB
|
||||
hash_time_cost: 3,
|
||||
hash_parallelism: 2,
|
||||
rate_limit: RateLimitConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -581,6 +620,48 @@ impl AppConfig {
|
||||
config.auth.hash_parallelism = val;
|
||||
}
|
||||
|
||||
// Rate limiting / account lockout
|
||||
if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_LOGIN_MAX").map(|v| v.parse::<u32>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.auth.rate_limit.login_max_requests = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_LOGIN_WINDOW_SECS").map(|v| v.parse::<u64>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.auth.rate_limit.login_window_secs = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_REGISTER_MAX").map(|v| v.parse::<u32>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.auth.rate_limit.register_max_requests = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_REGISTER_WINDOW_SECS").map(|v| v.parse::<u64>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.auth.rate_limit.register_window_secs = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_REFRESH_MAX").map(|v| v.parse::<u32>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.auth.rate_limit.refresh_max_requests = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_RATE_LIMIT_REFRESH_WINDOW_SECS").map(|v| v.parse::<u64>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.auth.rate_limit.refresh_window_secs = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_LOCKOUT_MAX_FAILURES").map(|v| v.parse::<u32>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.auth.rate_limit.lockout_max_failures = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_LOCKOUT_DURATION_SECS").map(|v| v.parse::<u64>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.auth.rate_limit.lockout_duration_secs = val;
|
||||
}
|
||||
|
||||
// Feature flags
|
||||
if let Ok(enable_auth) = env::var("OXICLOUD_ENABLE_AUTH").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = enable_auth
|
||||
|
||||
@@ -818,6 +818,7 @@ pub struct ApplicationServices {
|
||||
pub struct AuthServices {
|
||||
pub token_service: Arc<dyn crate::application::ports::auth_ports::TokenServicePort>,
|
||||
pub auth_application_service: Arc<AuthApplicationService>,
|
||||
pub login_lockout: Arc<crate::infrastructure::services::login_lockout_service::LoginLockoutService>,
|
||||
}
|
||||
|
||||
/// Global application state for dependency injection
|
||||
|
||||
@@ -68,8 +68,23 @@ pub async fn create_auth_services(
|
||||
// Package service in Arc
|
||||
let auth_application_service = Arc::new(auth_app_service);
|
||||
|
||||
// Account lockout service — in-memory brute-force protection
|
||||
let login_lockout = Arc::new(
|
||||
crate::infrastructure::services::login_lockout_service::LoginLockoutService::new(
|
||||
config.auth.rate_limit.lockout_max_failures,
|
||||
config.auth.rate_limit.lockout_duration_secs,
|
||||
100_000, // Track up to 100k accounts concurrently
|
||||
),
|
||||
);
|
||||
tracing::info!(
|
||||
"Login lockout service initialized: max {} failures, {}s lockout",
|
||||
config.auth.rate_limit.lockout_max_failures,
|
||||
config.auth.rate_limit.lockout_duration_secs,
|
||||
);
|
||||
|
||||
Ok(AuthServices {
|
||||
token_service,
|
||||
auth_application_service,
|
||||
login_lockout,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Account lockout service — blocks login for an account after N consecutive
|
||||
//! failed attempts.
|
||||
//!
|
||||
//! Uses a `moka` TTL cache so that:
|
||||
//! * Failed-attempt counters automatically expire after the lockout window.
|
||||
//! * No database writes are needed — this is **in-memory** and therefore
|
||||
//! per-instance. If OxiCloud is deployed behind a load balancer with
|
||||
//! multiple replicas, a sticky-session or shared Redis store would be
|
||||
//! needed for cross-instance coordination (out of scope for v1).
|
||||
//!
|
||||
//! Typical flow:
|
||||
//! 1. **Before password verification** → call [`LoginLockoutService::check`].
|
||||
//! If the account is locked, return `403` immediately without touching
|
||||
//! Argon2 (saves CPU).
|
||||
//! 2. **After failed verification** → call [`LoginLockoutService::record_failure`].
|
||||
//! 3. **After successful login** → call [`LoginLockoutService::record_success`]
|
||||
//! to reset the counter.
|
||||
|
||||
use moka::sync::Cache;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Tracks consecutive failures for a single username.
|
||||
#[derive(Clone, Debug)]
|
||||
struct FailureRecord {
|
||||
/// Number of consecutive failed attempts.
|
||||
count: u32,
|
||||
}
|
||||
|
||||
/// In-memory account lockout tracker.
|
||||
#[derive(Clone)]
|
||||
pub struct LoginLockoutService {
|
||||
/// Maps `username -> FailureRecord`. TTL = lockout window.
|
||||
cache: Cache<String, FailureRecord>,
|
||||
/// Maximum consecutive failures before the account is temporarily locked.
|
||||
max_failures: u32,
|
||||
/// How long the lockout lasts (seconds).
|
||||
lockout_secs: u64,
|
||||
}
|
||||
|
||||
impl LoginLockoutService {
|
||||
/// Create a new lockout service.
|
||||
///
|
||||
/// * `max_failures` — e.g. `5` (lock after 5 bad passwords)
|
||||
/// * `lockout_secs` — e.g. `900` (15-minute lockout)
|
||||
/// * `max_accounts` — upper bound on tracked accounts (evicts LRU)
|
||||
pub fn new(max_failures: u32, lockout_secs: u64, max_accounts: u64) -> Self {
|
||||
let cache = Cache::builder()
|
||||
.time_to_live(Duration::from_secs(lockout_secs))
|
||||
.max_capacity(max_accounts)
|
||||
.build();
|
||||
Self {
|
||||
cache,
|
||||
max_failures,
|
||||
lockout_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the account is currently locked.
|
||||
///
|
||||
/// Returns `Ok(())` if the user may attempt login, or
|
||||
/// `Err(remaining_secs)` with the *approximate* remaining lockout time.
|
||||
pub fn check(&self, username: &str) -> Result<(), u64> {
|
||||
if let Some(rec) = self.cache.get(&username.to_lowercase()) {
|
||||
if rec.count >= self.max_failures {
|
||||
// The entry exists and is over the threshold. Because moka
|
||||
// evicts at TTL we know the lockout window has not yet elapsed.
|
||||
return Err(self.lockout_secs);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a failed login attempt. Returns the new failure count.
|
||||
pub fn record_failure(&self, username: &str) -> u32 {
|
||||
let key = username.to_lowercase();
|
||||
let new_count = self
|
||||
.cache
|
||||
.get(&key)
|
||||
.map(|r| r.count + 1)
|
||||
.unwrap_or(1);
|
||||
self.cache.insert(key.clone(), FailureRecord { count: new_count });
|
||||
|
||||
if new_count >= self.max_failures {
|
||||
tracing::warn!(
|
||||
username = %username,
|
||||
attempts = new_count,
|
||||
lockout_secs = self.lockout_secs,
|
||||
"Account temporarily locked after {} consecutive failed login attempts",
|
||||
new_count,
|
||||
);
|
||||
}
|
||||
new_count
|
||||
}
|
||||
|
||||
/// Record a successful login — resets the failure counter.
|
||||
pub fn record_success(&self, username: &str) {
|
||||
self.cache.invalidate(&username.to_lowercase());
|
||||
}
|
||||
|
||||
/// Maximum failures before lockout (used to inform callers / error messages).
|
||||
pub fn max_failures(&self) -> u32 {
|
||||
self.max_failures
|
||||
}
|
||||
|
||||
/// Lockout duration in seconds.
|
||||
pub fn lockout_secs(&self) -> u64 {
|
||||
self.lockout_secs
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn allows_login_under_threshold() {
|
||||
let svc = LoginLockoutService::new(3, 60, 100);
|
||||
assert!(svc.check("alice").is_ok());
|
||||
svc.record_failure("alice");
|
||||
svc.record_failure("alice");
|
||||
// 2 failures — still under threshold
|
||||
assert!(svc.check("alice").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locks_after_threshold() {
|
||||
let svc = LoginLockoutService::new(3, 60, 100);
|
||||
svc.record_failure("bob");
|
||||
svc.record_failure("bob");
|
||||
svc.record_failure("bob");
|
||||
assert!(svc.check("bob").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resets_on_success() {
|
||||
let svc = LoginLockoutService::new(3, 60, 100);
|
||||
svc.record_failure("carol");
|
||||
svc.record_failure("carol");
|
||||
svc.record_success("carol");
|
||||
// Counter reset — should be allowed again
|
||||
assert!(svc.check("carol").is_ok());
|
||||
svc.record_failure("carol"); // starts over at 1
|
||||
assert!(svc.check("carol").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive() {
|
||||
let svc = LoginLockoutService::new(2, 60, 100);
|
||||
svc.record_failure("Dave");
|
||||
svc.record_failure("dave");
|
||||
assert!(svc.check("DAVE").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod chunked_upload_service;
|
||||
pub mod compression_service;
|
||||
pub mod dedup_service;
|
||||
pub mod login_lockout_service;
|
||||
pub mod file_content_cache;
|
||||
pub mod file_system_i18n_service;
|
||||
pub mod image_transcode_service;
|
||||
|
||||
@@ -19,9 +19,6 @@ use crate::interfaces::middleware::auth::CurrentUserId;
|
||||
pub fn auth_routes() -> Router<Arc<AppState>> {
|
||||
// Routes that do NOT require authentication
|
||||
let public_routes = Router::new()
|
||||
.route("/register", post(register))
|
||||
.route("/login", post(login))
|
||||
.route("/refresh", post(refresh_token))
|
||||
.route("/status", get(get_system_status))
|
||||
// OIDC endpoints (all public)
|
||||
.route("/oidc/providers", get(oidc_providers))
|
||||
@@ -40,6 +37,20 @@ pub fn auth_routes() -> Router<Arc<AppState>> {
|
||||
public_routes.merge(protected_routes)
|
||||
}
|
||||
|
||||
/// Rate-limited auth routes — split out so main.rs can apply per-endpoint
|
||||
/// rate limiting middleware independently.
|
||||
pub fn login_route() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/login", post(login))
|
||||
}
|
||||
|
||||
pub fn register_route() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/register", post(register))
|
||||
}
|
||||
|
||||
pub fn refresh_route() -> Router<Arc<AppState>> {
|
||||
Router::new().route("/refresh", post(refresh_token))
|
||||
}
|
||||
|
||||
async fn register(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(dto): Json<RegisterDto>,
|
||||
@@ -123,6 +134,25 @@ async fn login(
|
||||
}
|
||||
};
|
||||
|
||||
// ── Account lockout check ──────────────────────────────────────────
|
||||
// Reject immediately if the account has too many consecutive failures.
|
||||
// This runs BEFORE Argon2 to save CPU under brute-force attacks.
|
||||
if let Err(lockout_secs) = auth_service.login_lockout.check(&dto.username) {
|
||||
tracing::warn!(
|
||||
username = %dto.username,
|
||||
lockout_secs = lockout_secs,
|
||||
"Login rejected — account temporarily locked"
|
||||
);
|
||||
return Err(AppError::new(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
&format!(
|
||||
"Account temporarily locked due to too many failed attempts. Try again in {} seconds.",
|
||||
lockout_secs
|
||||
),
|
||||
"AccountLocked",
|
||||
));
|
||||
}
|
||||
|
||||
// Check if password login is disabled (OIDC-only mode)
|
||||
if auth_service
|
||||
.auth_application_service
|
||||
@@ -140,6 +170,9 @@ async fn login(
|
||||
.await
|
||||
{
|
||||
Ok(auth_response) => {
|
||||
// ── Successful login — reset lockout counter ──
|
||||
auth_service.login_lockout.record_success(&dto.username);
|
||||
|
||||
tracing::info!("Login successful for user: {}", dto.username);
|
||||
// Log the response structure for debugging
|
||||
tracing::debug!("Auth response: {:?}", &auth_response);
|
||||
@@ -171,6 +204,8 @@ async fn login(
|
||||
Ok(response)
|
||||
}
|
||||
Err(err) => {
|
||||
// ── Record failed attempt for lockout tracking ──
|
||||
auth_service.login_lockout.record_failure(&dto.username);
|
||||
tracing::error!("Login failed for user {}: {}", dto.username, err);
|
||||
Err(err.into())
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod auth;
|
||||
pub mod csrf;
|
||||
pub mod rate_limit;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! 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;
|
||||
+44
-2
@@ -170,12 +170,50 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
);
|
||||
}
|
||||
if config.features.enable_auth {
|
||||
use interfaces::api::handlers::auth_handler::auth_routes;
|
||||
use interfaces::api::handlers::auth_handler::{auth_routes, login_route, register_route, refresh_route};
|
||||
use oxicloud::interfaces::api::handlers::device_auth_handler;
|
||||
use oxicloud::interfaces::api::handlers::app_password_handler;
|
||||
use oxicloud::interfaces::middleware::auth::auth_middleware;
|
||||
use oxicloud::interfaces::middleware::csrf::csrf_middleware;
|
||||
use oxicloud::interfaces::middleware::rate_limit::{
|
||||
RateLimiter, rate_limit_login, rate_limit_register, rate_limit_refresh,
|
||||
};
|
||||
|
||||
// ── Rate limiters (IP-based, in-memory via moka) ────────────────
|
||||
let rl = &config.auth.rate_limit;
|
||||
let login_limiter = Arc::new(RateLimiter::new(
|
||||
rl.login_max_requests,
|
||||
rl.login_window_secs,
|
||||
100_000,
|
||||
));
|
||||
let register_limiter = Arc::new(RateLimiter::new(
|
||||
rl.register_max_requests,
|
||||
rl.register_window_secs,
|
||||
100_000,
|
||||
));
|
||||
let refresh_limiter = Arc::new(RateLimiter::new(
|
||||
rl.refresh_max_requests,
|
||||
rl.refresh_window_secs,
|
||||
100_000,
|
||||
));
|
||||
tracing::info!(
|
||||
"Rate limiting enabled — login: {}/{} s, register: {}/{} s, refresh: {}/{} s",
|
||||
rl.login_max_requests, rl.login_window_secs,
|
||||
rl.register_max_requests, rl.register_window_secs,
|
||||
rl.refresh_max_requests, rl.refresh_window_secs,
|
||||
);
|
||||
|
||||
// Auth routes split by rate-limit policy
|
||||
let auth_login = login_route()
|
||||
.layer(axum::middleware::from_fn_with_state(login_limiter.clone(), rate_limit_login))
|
||||
.with_state(app_state.clone());
|
||||
let auth_register = register_route()
|
||||
.layer(axum::middleware::from_fn_with_state(register_limiter.clone(), rate_limit_register))
|
||||
.with_state(app_state.clone());
|
||||
let auth_refresh = refresh_route()
|
||||
.layer(axum::middleware::from_fn_with_state(refresh_limiter.clone(), rate_limit_refresh))
|
||||
.with_state(app_state.clone());
|
||||
// Remaining auth routes (status, OIDC, protected /me, /logout, etc.)
|
||||
let auth_router = auth_routes().with_state(app_state.clone());
|
||||
|
||||
// Device Authorization Grant (RFC 8628)
|
||||
@@ -223,7 +261,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
));
|
||||
|
||||
app = Router::new()
|
||||
// Auth endpoints (login, register, refresh) are public — no middleware
|
||||
// Rate-limited auth endpoints (login, register, refresh)
|
||||
.nest("/api/auth", auth_login)
|
||||
.nest("/api/auth", auth_register)
|
||||
.nest("/api/auth", auth_refresh)
|
||||
// Other auth endpoints (status, OIDC, protected /me, /logout)
|
||||
.nest("/api/auth", auth_router)
|
||||
// Device Auth Grant public endpoints (authorize + token polling)
|
||||
.nest("/api/auth/device", device_public)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>OxiCloud — Admin Panel</title>
|
||||
<script>if(localStorage.getItem('oxicloud_theme')==='dark')document.documentElement.setAttribute('data-theme','dark');</script>
|
||||
<script src="/js/core/icons.js" defer></script>
|
||||
<script src="/js/core/formatters.js" defer></script>
|
||||
<script src="/js/core/csrf.js" defer></script>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/admin.css">
|
||||
|
||||
@@ -4,6 +4,15 @@ let usersPage = 0;
|
||||
const PAGE_SIZE = 50;
|
||||
let totalUsers = 0;
|
||||
|
||||
/** Escape a string for safe embedding inside a JS string literal within an HTML attribute.
|
||||
* Converts all non-alphanumeric/space/dot/hyphen/underscore chars to \xHH escapes. */
|
||||
function _escJs(s) {
|
||||
if (typeof s !== 'string') return '';
|
||||
return s.replace(/[^\w .\-]/g, function(c) {
|
||||
return '\\x' + c.charCodeAt(0).toString(16).padStart(2, '0');
|
||||
});
|
||||
}
|
||||
|
||||
function hideElement(id) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
@@ -108,21 +117,21 @@ async function loadUsers() {
|
||||
const isSelf = u.id === currentAdminId;
|
||||
const isOidc = u.auth_provider && u.auth_provider !== 'local';
|
||||
const authBadge = isOidc
|
||||
? '<span class="badge badge-oidc" title="Authenticated via ' + u.auth_provider + '"><i class="fas fa-key badge-admin-icon-small"></i> ' + u.auth_provider + '</span>'
|
||||
? '<span class="badge badge-oidc" title="Authenticated via ' + escapeHtml(u.auth_provider) + '"><i class="fas fa-key badge-admin-icon-small"></i> ' + escapeHtml(u.auth_provider) + '</span>'
|
||||
: '<span class="badge badge-local">Local</span>';
|
||||
return '<tr>' +
|
||||
'<td><div class="user-info"><span class="user-name">' + u.username + (isSelf ? ' <span class="user-self-badge">(you)</span>' : '') + '</span><span class="user-email">' + u.email + '</span></div></td>' +
|
||||
'<td><span class="badge badge-' + u.role + '">' + (u.role === 'admin' ? '<i class="fas fa-shield-alt badge-admin-icon-small"></i> ' : '') + u.role + '</span></td>' +
|
||||
'<td><div class="user-info"><span class="user-name">' + escapeHtml(u.username) + (isSelf ? ' <span class="user-self-badge">(you)</span>' : '') + '</span><span class="user-email">' + escapeHtml(u.email) + '</span></div></td>' +
|
||||
'<td><span class="badge badge-' + escapeHtml(u.role) + '">' + (u.role === 'admin' ? '<i class="fas fa-shield-alt badge-admin-icon-small"></i> ' : '') + escapeHtml(u.role) + '</span></td>' +
|
||||
'<td>' + authBadge + '</td>' +
|
||||
'<td><span class="badge badge-' + (u.active ? 'active' : 'inactive') + '">' + (u.active ? 'Active' : 'Inactive') + '</span></td>' +
|
||||
'<td><div class="quota-bar"><div class="progress-bar quota-progress-fixed"><div class="progress-fill ' + quotaColor + '" style="width:' + Math.min(quotaPct, 100) + '%"></div></div><span class="quota-text">' + quotaText + '</span></div></td>' +
|
||||
'<td class="user-last-login-cell">' + timeAgo(u.last_login_at) + '</td>' +
|
||||
'<td><div class="actions-row">' +
|
||||
'<button class="btn btn-sm btn-secondary" onclick="openQuotaModal(\'' + u.id + '\',\'' + u.username + '\',' + u.storage_quota_bytes + ')" title="Edit quota"><i class="fas fa-box"></i></button>' +
|
||||
(isOidc ? '' : '<button class="btn btn-sm btn-secondary" onclick="openResetPasswordModal(\'' + u.id + '\',\'' + u.username + '\')" title="Reset password"><i class="fas fa-key"></i></button>') +
|
||||
'<button class="btn btn-sm btn-secondary" onclick="toggleRole(\'' + u.id + '\',\'' + u.role + '\')" title="Toggle role"' + (isSelf ? ' disabled' : '') + '><i class="fas fa-' + (u.role === 'admin' ? 'user' : 'crown') + '"></i></button>' +
|
||||
'<button class="btn btn-sm ' + (u.active ? 'btn-danger' : 'btn-success') + '" onclick="toggleActive(\'' + u.id + '\',' + u.active + ')" title="' + (u.active ? 'Deactivate' : 'Activate') + '"' + (isSelf && u.active ? ' disabled' : '') + '><i class="fas fa-' + (u.active ? 'ban' : 'check') + '"></i></button>' +
|
||||
'<button class="btn btn-sm btn-danger" onclick="deleteUser(\'' + u.id + '\',\'' + u.username + '\')" title="Delete"' + (isSelf ? ' disabled' : '') + '><i class="fas fa-trash-alt"></i></button>' +
|
||||
'<button class="btn btn-sm btn-secondary" onclick="openQuotaModal(\'' + _escJs(u.id) + '\',\'' + _escJs(u.username) + '\',' + u.storage_quota_bytes + ')" title="Edit quota"><i class="fas fa-box"></i></button>' +
|
||||
(isOidc ? '' : '<button class="btn btn-sm btn-secondary" onclick="openResetPasswordModal(\'' + _escJs(u.id) + '\',\'' + _escJs(u.username) + '\')" title="Reset password"><i class="fas fa-key"></i></button>') +
|
||||
'<button class="btn btn-sm btn-secondary" onclick="toggleRole(\'' + _escJs(u.id) + '\',\'' + _escJs(u.role) + '\')" title="Toggle role"' + (isSelf ? ' disabled' : '') + '><i class="fas fa-' + (u.role === 'admin' ? 'user' : 'crown') + '"></i></button>' +
|
||||
'<button class="btn btn-sm ' + (u.active ? 'btn-danger' : 'btn-success') + '" onclick="toggleActive(\'' + _escJs(u.id) + '\',' + u.active + ')" title="' + (u.active ? 'Deactivate' : 'Activate') + '"' + (isSelf && u.active ? ' disabled' : '') + '><i class="fas fa-' + (u.active ? 'ban' : 'check') + '"></i></button>' +
|
||||
'<button class="btn btn-sm btn-danger" onclick="deleteUser(\'' + _escJs(u.id) + '\',\'' + _escJs(u.username) + '\')" title="Delete"' + (isSelf ? ' disabled' : '') + '><i class="fas fa-trash-alt"></i></button>' +
|
||||
'</div></td></tr>';
|
||||
}).join('');
|
||||
|
||||
@@ -130,7 +139,7 @@ async function loadUsers() {
|
||||
document.getElementById('prev-btn').disabled = usersPage === 0;
|
||||
document.getElementById('next-btn').disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers;
|
||||
} catch (e) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> Error: ' + e.message + '</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> Error: ' + escapeHtml(e.message) + '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,12 +333,12 @@ async function testConnection() {
|
||||
const resp = await fetch(API + '/admin/settings/oidc/test', { method: 'POST', headers: headers(), body: JSON.stringify({ issuer_url: url }) });
|
||||
const r = await resp.json();
|
||||
if (r.success) {
|
||||
resultDiv.innerHTML = '<div class="discovery-result ok"><strong><i class="fas fa-check-circle"></i> ' + r.message + '</strong><dl><dt>Issuer</dt><dd>' + (r.issuer||'—') + '</dd><dt>Auth Endpoint</dt><dd>' + (r.authorization_endpoint||'—') + '</dd></dl></div>';
|
||||
resultDiv.innerHTML = '<div class="discovery-result ok"><strong><i class="fas fa-check-circle"></i> ' + escapeHtml(r.message) + '</strong><dl><dt>Issuer</dt><dd>' + escapeHtml(r.issuer||'—') + '</dd><dt>Auth Endpoint</dt><dd>' + escapeHtml(r.authorization_endpoint||'—') + '</dd></dl></div>';
|
||||
if (!document.getElementById('provider-name').value && r.provider_name_suggestion) document.getElementById('provider-name').value = r.provider_name_suggestion;
|
||||
} else {
|
||||
resultDiv.innerHTML = '<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ' + r.message + '</strong></div>';
|
||||
resultDiv.innerHTML = '<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ' + escapeHtml(r.message) + '</strong></div>';
|
||||
}
|
||||
} catch (e) { resultDiv.innerHTML = '<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ' + e.message + '</div>'; }
|
||||
} catch (e) { resultDiv.innerHTML = '<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ' + escapeHtml(e.message) + '</div>'; }
|
||||
btn.disabled = false; btn.innerHTML = '<i class="fas fa-search"></i> Auto-discover';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user