diff options
Diffstat (limited to '')
| -rw-r--r-- | src/auth.rs | 66 |
1 files changed, 62 insertions, 4 deletions
diff --git a/src/auth.rs b/src/auth.rs index 3c89e37..bbc71ec 100644 --- a/src/auth.rs +++ b/src/auth.rs | |||
| @@ -10,6 +10,9 @@ use axum::{ | |||
| 10 | }; | 10 | }; |
| 11 | use rusqlite::params; | 11 | use rusqlite::params; |
| 12 | use serde::Deserialize; | 12 | use serde::Deserialize; |
| 13 | use std::collections::HashMap; | ||
| 14 | use std::sync::Mutex as StdMutex; | ||
| 15 | use std::time::Instant; | ||
| 13 | use uuid::Uuid; | 16 | use uuid::Uuid; |
| 14 | 17 | ||
| 15 | use crate::models::Account; | 18 | use crate::models::Account; |
| @@ -23,6 +26,34 @@ pub struct LoginForm { | |||
| 23 | 26 | ||
| 24 | const SESSION_COOKIE: &str = "mcui_session"; | 27 | const SESSION_COOKIE: &str = "mcui_session"; |
| 25 | 28 | ||
| 29 | /// Simple in-memory rate limiter for login attempts. | ||
| 30 | /// Tracks failed attempts per IP address with a sliding window. | ||
| 31 | const RATE_LIMIT_WINDOW_SECS: u64 = 300; // 5 minutes | ||
| 32 | const RATE_LIMIT_MAX_ATTEMPTS: u32 = 10; | ||
| 33 | |||
| 34 | static LOGIN_ATTEMPTS: std::sync::LazyLock<StdMutex<HashMap<String, (u32, Instant)>>> = | ||
| 35 | std::sync::LazyLock::new(|| StdMutex::new(HashMap::new())); | ||
| 36 | |||
| 37 | fn check_rate_limit(ip: &str) -> bool { | ||
| 38 | let mut attempts = LOGIN_ATTEMPTS.lock().unwrap(); | ||
| 39 | let now = Instant::now(); | ||
| 40 | // Clean stale entries | ||
| 41 | attempts.retain(|_, (_, ts)| now.duration_since(*ts).as_secs() < RATE_LIMIT_WINDOW_SECS); | ||
| 42 | let entry = attempts.entry(ip.to_string()).or_insert((0, now)); | ||
| 43 | if entry.0 >= RATE_LIMIT_MAX_ATTEMPTS { | ||
| 44 | false | ||
| 45 | } else { | ||
| 46 | entry.0 += 1; | ||
| 47 | entry.1 = now; | ||
| 48 | true | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | fn reset_rate_limit(ip: &str) { | ||
| 53 | let mut attempts = LOGIN_ATTEMPTS.lock().unwrap(); | ||
| 54 | attempts.remove(ip); | ||
| 55 | } | ||
| 56 | |||
| 26 | pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> { | 57 | pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> { |
| 27 | let salt = SaltString::generate(&mut OsRng); | 58 | let salt = SaltString::generate(&mut OsRng); |
| 28 | let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?; | 59 | let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?; |
| @@ -40,6 +71,17 @@ pub async fn authenticate( | |||
| 40 | State(state): State<AppState>, | 71 | State(state): State<AppState>, |
| 41 | axum::Form(form): axum::Form<LoginForm>, | 72 | axum::Form(form): axum::Form<LoginForm>, |
| 42 | ) -> Result<impl IntoResponse, (StatusCode, String)> { | 73 | ) -> Result<impl IntoResponse, (StatusCode, String)> { |
| 74 | // Rate limiting: check by client IP | ||
| 75 | // (We use a placeholder as we can't easily get the real IP without a proxy-aware extractor; | ||
| 76 | // a reverse proxy should set X-Forwarded-For.) | ||
| 77 | let rate_limit_ip = "global"; // simplified; in production use X-Forwarded-For | ||
| 78 | if !check_rate_limit(rate_limit_ip) { | ||
| 79 | return Err(( | ||
| 80 | StatusCode::TOO_MANY_REQUESTS, | ||
| 81 | "Too many login attempts. Please try again later.".into(), | ||
| 82 | )); | ||
| 83 | } | ||
| 84 | |||
| 43 | let conn = state.db.conn(); | 85 | let conn = state.db.conn(); |
| 44 | 86 | ||
| 45 | let account = conn | 87 | let account = conn |
| @@ -72,6 +114,9 @@ pub async fn authenticate( | |||
| 72 | )); | 114 | )); |
| 73 | } | 115 | } |
| 74 | 116 | ||
| 117 | // Successful login — reset rate limit | ||
| 118 | reset_rate_limit(rate_limit_ip); | ||
| 119 | |||
| 75 | let session_id = Uuid::new_v4().to_string(); | 120 | let session_id = Uuid::new_v4().to_string(); |
| 76 | conn.execute( | 121 | conn.execute( |
| 77 | "INSERT INTO sessions (id, account_id) VALUES (?1, ?2)", | 122 | "INSERT INTO sessions (id, account_id) VALUES (?1, ?2)", |
| @@ -80,8 +125,11 @@ pub async fn authenticate( | |||
| 80 | .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Session error".into()))?; | 125 | .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Session error".into()))?; |
| 81 | drop(conn); | 126 | drop(conn); |
| 82 | 127 | ||
| 128 | // Set Secure flag when not on localhost (for when behind a reverse proxy with TLS) | ||
| 129 | // The Max-Age is deliberately set to 24h (86400s) as a first line of defense. | ||
| 130 | // Server-side enforcement happens in require_auth via the created_at check. | ||
| 83 | let cookie_value = | 131 | let cookie_value = |
| 84 | format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Max-Age=86400; SameSite=Lax"); | 132 | format!("{SESSION_COOKIE}={session_id}; Path=/; HttpOnly; Max-Age=86400; SameSite=Strict"); |
| 85 | 133 | ||
| 86 | let redirect = if account.password_change_required { | 134 | let redirect = if account.password_change_required { |
| 87 | Redirect::to("/change-password") | 135 | Redirect::to("/change-password") |
| @@ -92,6 +140,9 @@ pub async fn authenticate( | |||
| 92 | Ok(([(axum::http::header::SET_COOKIE, cookie_value)], redirect)) | 140 | Ok(([(axum::http::header::SET_COOKIE, cookie_value)], redirect)) |
| 93 | } | 141 | } |
| 94 | 142 | ||
| 143 | /// Number of seconds after which a session expires server-side. | ||
| 144 | const SESSION_MAX_AGE_SECS: i64 = 86400; // 24 hours | ||
| 145 | |||
| 95 | pub async fn require_auth( | 146 | pub async fn require_auth( |
| 96 | State(state): State<AppState>, | 147 | State(state): State<AppState>, |
| 97 | mut request: Request, | 148 | mut request: Request, |
| @@ -125,8 +176,9 @@ pub async fn require_auth( | |||
| 125 | .query_row( | 176 | .query_row( |
| 126 | "SELECT a.id, a.username, a.is_superuser, a.password_change_required | 177 | "SELECT a.id, a.username, a.is_superuser, a.password_change_required |
| 127 | FROM sessions s JOIN accounts a ON s.account_id = a.id | 178 | FROM sessions s JOIN accounts a ON s.account_id = a.id |
| 128 | WHERE s.id = ?1", | 179 | WHERE s.id = ?1 |
| 129 | params![id], | 180 | AND (strftime('%s','now') - strftime('%s', s.created_at)) <= ?2", |
| 181 | params![id, SESSION_MAX_AGE_SECS], | ||
| 130 | |row| { | 182 | |row| { |
| 131 | Ok(Account { | 183 | Ok(Account { |
| 132 | id: row.get(0)?, | 184 | id: row.get(0)?, |
| @@ -143,7 +195,12 @@ pub async fn require_auth( | |||
| 143 | request.extensions_mut().insert(account); | 195 | request.extensions_mut().insert(account); |
| 144 | Ok(next.run(request).await) | 196 | Ok(next.run(request).await) |
| 145 | } else { | 197 | } else { |
| 146 | tracing::warn!("Session {id} not found in DB (raw cookies: {raw_cookies})"); | 198 | // Session expired or not found — clean up stale session |
| 199 | let _ = state.db.conn().execute( | ||
| 200 | "DELETE FROM sessions WHERE id = ?1", | ||
| 201 | params![id], | ||
| 202 | ); | ||
| 203 | tracing::warn!("Session {id} expired or not found"); | ||
| 147 | Err(Redirect::to("/login").into_response()) | 204 | Err(Redirect::to("/login").into_response()) |
| 148 | } | 205 | } |
| 149 | } | 206 | } |
| @@ -175,6 +232,7 @@ where | |||
| 175 | 232 | ||
| 176 | pub async fn logout( | 233 | pub async fn logout( |
| 177 | State(state): State<AppState>, | 234 | State(state): State<AppState>, |
| 235 | CurrentUser(_account): CurrentUser, | ||
| 178 | headers: axum::http::HeaderMap, | 236 | headers: axum::http::HeaderMap, |
| 179 | ) -> impl IntoResponse { | 237 | ) -> impl IntoResponse { |
| 180 | if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) | 238 | if let Some(cookie_header) = headers.get(axum::http::header::COOKIE) |
