summaryrefslogtreecommitdiffstats
path: root/src/auth.rs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/auth.rs66
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};
11use rusqlite::params; 11use rusqlite::params;
12use serde::Deserialize; 12use serde::Deserialize;
13use std::collections::HashMap;
14use std::sync::Mutex as StdMutex;
15use std::time::Instant;
13use uuid::Uuid; 16use uuid::Uuid;
14 17
15use crate::models::Account; 18use crate::models::Account;
@@ -23,6 +26,34 @@ pub struct LoginForm {
23 26
24const SESSION_COOKIE: &str = "mcui_session"; 27const 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.
31const RATE_LIMIT_WINDOW_SECS: u64 = 300; // 5 minutes
32const RATE_LIMIT_MAX_ATTEMPTS: u32 = 10;
33
34static LOGIN_ATTEMPTS: std::sync::LazyLock<StdMutex<HashMap<String, (u32, Instant)>>> =
35 std::sync::LazyLock::new(|| StdMutex::new(HashMap::new()));
36
37fn 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
52fn reset_rate_limit(ip: &str) {
53 let mut attempts = LOGIN_ATTEMPTS.lock().unwrap();
54 attempts.remove(ip);
55}
56
26pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> { 57pub 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.
144const SESSION_MAX_AGE_SECS: i64 = 86400; // 24 hours
145
95pub async fn require_auth( 146pub 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
176pub async fn logout( 233pub 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)