use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; use axum::extract::{ConnectInfo, Path}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; use serde::{Deserialize, Serialize}; use crate::auth::imap_auth::{self, AuthResult}; use crate::auth::login_guard::{LoginBlocked, LoginGuard}; use crate::auth::session::{NewAccountSession, SessionState, SessionStore}; use crate::auth::user_data; use crate::config::AppConfig; use crate::db; pub const BROWSER_COOKIE: &str = "rustbox_browser"; #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct AuthPolicyResponse { remember_me_enabled: bool, } pub async fn auth_policy(Extension(config): Extension>) -> Json { Json(AuthPolicyResponse { remember_me_enabled: config.remember_me_enabled, }) } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SessionPolicyResponse { pgp_enabled: bool, sieve_enabled: bool, diagnostics_enabled: bool, about_enabled: bool, multi_account_enabled: bool, } pub async fn session_policy( Extension(config): Extension>, ) -> Json { Json(SessionPolicyResponse { pgp_enabled: config.pgp_enabled, sieve_enabled: config.sieve_enabled, diagnostics_enabled: config.diagnostics_enabled, about_enabled: config.about_enabled, multi_account_enabled: config.multi_account_enabled, }) } #[derive(Deserialize)] pub struct LoginRequest { pub email: String, pub password: String, #[serde(default)] pub remember: bool, pub browser_id: Option, } fn same_site_policy(_config: &AppConfig) -> &'static str { "Strict" } fn cookie_path(config: &AppConfig) -> &str { config.normalized_base_path() } fn browser_cookie( browser_id: &str, max_age_secs: u64, secure: bool, same_site: &str, path: &str, ) -> String { let secure_flag = if secure { " Secure;" } else { "" }; format!( "{BROWSER_COOKIE}={browser_id};{secure_flag} HttpOnly; SameSite={same_site}; Path={path}; Max-Age={max_age_secs}" ) } fn account_session_cookie( account_id: &str, token: &str, max_age_secs: u64, secure: bool, same_site: &str, path: &str, ) -> String { let secure_flag = if secure { " Secure;" } else { "" }; format!( "rustbox_session_{account_id}={token};{secure_flag} HttpOnly; SameSite={same_site}; Path={path}; Max-Age={max_age_secs}" ) } fn clearing_browser_cookie(secure: bool, same_site: &str, path: &str) -> String { let secure_flag = if secure { " Secure;" } else { "" }; format!( "{BROWSER_COOKIE}=;{secure_flag} HttpOnly; SameSite={same_site}; Path={path}; Max-Age=0" ) } fn clearing_account_cookie(account_id: &str, secure: bool, same_site: &str, path: &str) -> String { let secure_flag = if secure { " Secure;" } else { "" }; format!( "rustbox_session_{account_id}=;{secure_flag} HttpOnly; SameSite={same_site}; Path={path}; Max-Age=0" ) } fn extract_browser_id(headers: &axum::http::HeaderMap) -> Option { headers .get_all("cookie") .iter() .filter_map(|v| v.to_str().ok()) .find_map(|cookie| { for segment in cookie.split(';') { let trimmed = segment.trim(); if let Some(id) = trimmed.strip_prefix("rustbox_browser=") { let id = id.trim(); if !id.is_empty() { return Some(id.to_string()); } } } None }) } fn extract_session_cookies(headers: &axum::http::HeaderMap) -> Vec<(String, String)> { let prefix = "rustbox_session_"; let mut sessions = Vec::new(); for cookie in headers .get_all("cookie") .iter() .filter_map(|v| v.to_str().ok()) { for segment in cookie.split(';') { let trimmed = segment.trim(); if let Some(rest) = trimmed.strip_prefix(prefix) && let Some(eq_pos) = rest.find('=') { let account_id = &rest[..eq_pos]; let token = &rest[eq_pos + 1..]; if !account_id.is_empty() && !token.is_empty() { sessions.push((account_id.to_string(), token.to_string())); } } } } sessions } /// Client IP used for login throttling; honours `trusted_proxies` the same way /// as the per-IP token bucket and the security telemetry. pub(crate) fn login_client_ip( config: &AppConfig, extensions: &axum::http::Extensions, headers: &axum::http::HeaderMap, ) -> IpAddr { crate::routes::system_diagnostics::effective_client_ip(config, headers, peer_ip(extensions)) } fn peer_ip(extensions: &axum::http::Extensions) -> IpAddr { extensions .get::>() .map(|info| info.0.ip()) .unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)) } /// Whether session cookies get `Secure` (`auto`: only when reached over HTTPS). pub(crate) fn cookie_secure( config: &AppConfig, extensions: &axum::http::Extensions, headers: &axum::http::HeaderMap, ) -> bool { match config.cookie_secure.trim().to_ascii_lowercase().as_str() { "always" => true, "never" => false, _ => request_is_https(config, extensions, headers), } } /// Request scheme: trusted proxy headers first, then the browser `Origin`/`Referer`. fn request_is_https( config: &AppConfig, extensions: &axum::http::Extensions, headers: &axum::http::HeaderMap, ) -> bool { let peer = peer_ip(extensions); let peer_is_trusted_proxy = config .parsed_trusted_proxies() .iter() .any(|proxy| proxy.contains(&peer)); if peer_is_trusted_proxy && let Some(proto) = forwarded_proto(headers) { return proto == "https"; } header_url_is_https(headers, "origin") || header_url_is_https(headers, "referer") } fn forwarded_proto(headers: &axum::http::HeaderMap) -> Option { let header = |name: &str| headers.get(name).and_then(|value| value.to_str().ok()); if let Some(proto) = header("x-forwarded-proto") .and_then(|value| value.split(',').next()) .map(str::trim) .filter(|value| !value.is_empty()) { return Some(proto.to_ascii_lowercase()); } if let Some(forwarded) = header("forwarded") { for element in forwarded.split(',') { for pair in element.split(';') { if let Some((key, value)) = pair.split_once('=') && key.trim().eq_ignore_ascii_case("proto") { return Some(value.trim().trim_matches('"').to_ascii_lowercase()); } } } } if header("x-forwarded-ssl").is_some_and(|value| value.trim().eq_ignore_ascii_case("on")) { return Some("https".to_string()); } None } fn header_url_is_https(headers: &axum::http::HeaderMap, name: &str) -> bool { headers .get(name) .and_then(|value| value.to_str().ok()) .and_then(|value| value.trim().get(..8)) .is_some_and(|prefix| prefix.eq_ignore_ascii_case("https://")) } fn too_many_login_attempts(blocked: LoginBlocked) -> Response { let retry_after = blocked.retry_after_secs(); ( StatusCode::TOO_MANY_REQUESTS, [ ("content-type", "application/json".to_string()), ("retry-after", retry_after.to_string()), ], serde_json::json!({ "error": { "code": "TOO_MANY_LOGIN_ATTEMPTS", "message": format!( "Too many failed login attempts. Try again in {}.", human_wait(retry_after) ), "status": 429, "retryAfterSecs": retry_after } }) .to_string(), ) .into_response() } fn human_wait(secs: u64) -> String { if secs < 60 { format!("{secs} s") } else { format!("{} min", secs.div_ceil(60)) } } pub async fn login( Extension(store): Extension>, Extension(config): Extension>, extensions: axum::http::Extensions, headers: axum::http::HeaderMap, Json(body): Json, ) -> Response { if body.email.trim().is_empty() || body.password.trim().is_empty() { return ( StatusCode::BAD_REQUEST, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "BAD_REQUEST", "message": "Email and password are required", "status": 400 } }) .to_string(), ) .into_response(); } let Some(imap_host) = config .imap_host .clone() .filter(|host| !host.trim().is_empty()) else { return ( StatusCode::SERVICE_UNAVAILABLE, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "SERVICE_UNAVAILABLE", "message": "IMAP server is not configured in RustBox", "status": 503 } }) .to_string(), ) .into_response(); }; let login_guard = extensions.get::>().cloned(); let client_ip = login_client_ip(&config, &extensions, &headers); if let Some(guard) = login_guard.as_deref() && let Err(blocked) = guard.check(&body.email, client_ip) { tracing::warn!( source_ip = %client_ip, scope = blocked.scope.as_str(), retry_after_secs = blocked.retry_after_secs(), "login refused: brute-force lockout active" ); return too_many_login_attempts(blocked); } let smtp_host = config .smtp_host .clone() .filter(|host| !host.trim().is_empty()) .unwrap_or_else(|| imap_host.clone()); let imap_port = config.imap_port; let imap_tls = config.tls_enabled; let imap_tls_verify = config.tls_verify_for_host(&imap_host); let smtp_port = config.smtp_port; let smtp_tls = config.tls_enabled; let result = imap_auth::validate_imap_credentials( &imap_host, imap_port, imap_tls, imap_tls_verify, &body.email, &body.password, ) .await; if let Some(guard) = login_guard.as_deref() { match &result { AuthResult::Success => guard.record_success(&body.email), AuthResult::InvalidCredentials => { if let Some(locked) = guard.record_failure(&body.email, client_ip) { tracing::warn!( source_ip = %client_ip, scope = locked.scope.as_str(), lockout_secs = locked.retry_after_secs(), "login brute-force lockout triggered" ); } } AuthResult::ServerUnreachable(_) => {} } } match result { AuthResult::Success => { let provided_browser_id = body .browser_id .clone() .or_else(|| extract_browser_id(&headers)); if let Some(ref browser_id) = provided_browser_id { let existing_accounts = store.get_browser_accounts_shared(browser_id).await; let duplicate = existing_accounts .iter() .find(|a| a.email == body.email && a.imap_host == imap_host); if let Some(dup) = duplicate { tracing::debug!( email = %body.email, imap_host = %imap_host, existing_account_id = %dup.account_id, "login: duplicate account detected" ); return ( StatusCode::CONFLICT, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "DUPLICATE_ACCOUNT", "message": "This account is already logged in", "status": 409 } }) .to_string(), ) .into_response(); } if !config.multi_account_enabled && !existing_accounts.is_empty() { return ( StatusCode::FORBIDDEN, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "MULTI_ACCOUNT_DISABLED", "message": "Multiple accounts are disabled by the RustBox administrator", "status": 403 } }) .to_string(), ) .into_response(); } } let user_hash = user_data::hash_email(&body.email); let provision_result = user_data::provision_user_database(&config.data_dir, &user_hash); if let Err(e) = provision_result { tracing::error!(error = %e, "failed to provision user database"); return ( StatusCode::INTERNAL_SERVER_ERROR, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "INTERNAL_ERROR", "message": "Failed to provision user data", "status": 500 } }) .to_string(), ) .into_response(); } if let Ok(conn) = db::pool::open_user_db(&config.data_dir, &user_hash) { match db::identities::has_identities(&conn) { Ok(false) => { let default_identity = db::identities::CreateIdentity { email: body.email.clone(), display_name: String::new(), signature_html: String::new(), is_default: true, reply_to: String::new(), bcc: String::new(), organization: String::new(), signature_text: String::new(), signature_new: true, signature_reply: true, signature_forward: true, signature_position: "above".to_string(), signature_insert_mode: "silent".to_string(), pgp_fingerprint: None, pgp_sign_default: false, pgp_encrypt_default: false, pgp_inline_default: false, }; if let Err(e) = db::identities::create_identity(&conn, &default_identity) { tracing::warn!(error = %e, "Failed to auto-create default identity"); } } Err(e) => { tracing::warn!(error = %e, "Failed to check for existing identities"); } _ => {} } } let remember = body.remember && config.remember_me_enabled; let session_hours = if remember { config.remember_me_days.saturating_mul(24) } else { config.session_timeout_hours }; let browser_id = provided_browser_id .clone() .unwrap_or_else(|| store.create_browser()); tracing::debug!( email = %body.email, provided_browser_id = ?provided_browser_id, effective_browser_id = %browser_id, "login: creating session" ); let timeout_override = if remember { Some(std::time::Duration::from_secs(session_hours * 3600)) } else { None }; let (token, account_id) = store .add_account_to_browser_shared( &browser_id, NewAccountSession { email: body.email.clone(), password: body.password, user_hash, imap_host: imap_host.clone(), imap_port, imap_tls, smtp_host: smtp_host.clone(), smtp_port, smtp_tls, timeout_override, }, ) .await; let user_agent = headers .get(axum::http::header::USER_AGENT) .and_then(|value| value.to_str().ok()) .unwrap_or("") .to_string(); store.set_user_agent_shared(&account_id, user_agent).await; let max_age = session_hours * 3600; let secure = cookie_secure(&config, &extensions, &headers); let same_site = same_site_policy(&config); let path = cookie_path(&config); let browser_cookie_header = browser_cookie(&browser_id, max_age, secure, same_site, path); let session_cookie_header = account_session_cookie(&account_id, &token, max_age, secure, same_site, path); let response = ( StatusCode::CREATED, [("content-type", "application/json")], serde_json::json!({ "account": { "id": account_id, "email": body.email } }) .to_string(), ) .into_response(); let mut response = response; let headers = response.headers_mut(); if let Ok(header_value) = browser_cookie_header.parse() { headers.append("set-cookie", header_value); } if let Ok(header_value) = session_cookie_header.parse() { headers.append("set-cookie", header_value); } response } AuthResult::InvalidCredentials => ( StatusCode::UNAUTHORIZED, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "UNAUTHORIZED", "message": "Invalid email or password", "status": 401 } }) .to_string(), ) .into_response(), AuthResult::ServerUnreachable(msg) => ( StatusCode::SERVICE_UNAVAILABLE, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "SERVER_UNREACHABLE", "message": msg, "status": 503 } }) .to_string(), ) .into_response(), } } pub async fn get_session(Extension(session): Extension) -> Response { ( StatusCode::OK, [("content-type", "application/json")], serde_json::json!({ "user": { "email": session.email } }).to_string(), ) .into_response() } pub async fn list_accounts( Extension(store): Extension>, Extension(config): Extension>, extensions: axum::http::Extensions, headers: axum::http::HeaderMap, ) -> Response { let secure = cookie_secure(&config, &extensions, &headers); let same_site = same_site_policy(&config); let browser_id = extract_browser_id(&headers); let Some(browser_id) = browser_id else { tracing::debug!("list_accounts: no browser_id cookie found"); return ( StatusCode::OK, [("content-type", "application/json")], serde_json::json!({ "accounts": [], "browserSessionExpired": true }).to_string(), ) .into_response(); }; let browser_exists = store.browser_exists_shared(&browser_id).await; if !browser_exists { tracing::debug!(browser_id = %browser_id, "list_accounts: browser_id not found on server"); let clear_browser = clearing_browser_cookie(secure, same_site, cookie_path(&config)); return ( StatusCode::OK, [ ("content-type", "application/json"), ("set-cookie", &clear_browser), ], serde_json::json!({ "accounts": [], "browserSessionExpired": true }).to_string(), ) .into_response(); } let session_cookies = extract_session_cookies(&headers); let session_map: std::collections::HashMap = session_cookies.into_iter().collect(); let browser_accounts = store.get_browser_accounts_shared(&browser_id).await; let browser_account_ids: Vec = browser_accounts .iter() .map(|s| s.account_id.clone()) .collect(); let mut valid_accounts: Vec = Vec::new(); let mut clear_cookies: Vec = Vec::new(); for session in &browser_accounts { if let Some(token) = session_map.get(&session.account_id) { if let Some(valid_session) = store.get_shared(token).await { valid_accounts.push(serde_json::json!({ "id": valid_session.account_id, "email": valid_session.email })); } else { store.remove_account_shared(&session.account_id).await; clear_cookies.push(clearing_account_cookie( &session.account_id, secure, same_site, cookie_path(&config), )); } } else { store.remove_account_shared(&session.account_id).await; } } for account_id in session_map.keys() { if !browser_account_ids.contains(account_id) { clear_cookies.push(clearing_account_cookie( account_id, secure, same_site, cookie_path(&config), )); } } if valid_accounts.is_empty() { tracing::debug!( browser_id = %browser_id, "list_accounts: no valid accounts found" ); let clear_browser = clearing_browser_cookie(secure, same_site, cookie_path(&config)); let body = serde_json::json!({ "accounts": [], "browserSessionExpired": true }).to_string(); let mut response = axum::response::Response::new(axum::body::Body::from(body)); *response.status_mut() = StatusCode::OK; response.headers_mut().insert( axum::http::header::CONTENT_TYPE, axum::http::HeaderValue::from_static("application/json"), ); response.headers_mut().append( axum::http::header::SET_COOKIE, axum::http::HeaderValue::from_str(&clear_browser).unwrap(), ); for cookie in clear_cookies { response.headers_mut().append( axum::http::header::SET_COOKIE, axum::http::HeaderValue::from_str(&cookie).unwrap(), ); } return response; } tracing::debug!( browser_id = %browser_id, account_count = valid_accounts.len(), accounts = ?valid_accounts.iter().filter_map(|a| a.get("email").and_then(|e| e.as_str())).collect::>(), "list_accounts: returning valid accounts" ); let body = serde_json::json!({ "accounts": valid_accounts }).to_string(); let mut response = axum::response::Response::new(axum::body::Body::from(body)); *response.status_mut() = StatusCode::OK; response.headers_mut().insert( axum::http::header::CONTENT_TYPE, axum::http::HeaderValue::from_static("application/json"), ); for cookie in clear_cookies { response.headers_mut().append( axum::http::header::SET_COOKIE, axum::http::HeaderValue::from_str(&cookie).unwrap(), ); } response } pub async fn remove_account( Extension(store): Extension>, Extension(config): Extension>, Path(account_id): Path, extensions: axum::http::Extensions, headers: axum::http::HeaderMap, ) -> Response { let browser_id = extract_browser_id(&headers); let Some(browser_id) = browser_id else { return ( StatusCode::UNAUTHORIZED, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "UNAUTHORIZED", "message": "No browser session", "status": 401 } }) .to_string(), ) .into_response(); }; let browser_accounts = store.get_browser_accounts_shared(&browser_id).await; let account_belongs_to_browser = browser_accounts.iter().any(|s| s.account_id == account_id); if !account_belongs_to_browser { return ( StatusCode::FORBIDDEN, [("content-type", "application/json")], serde_json::json!({ "error": { "code": "FORBIDDEN", "message": "Account does not belong to this browser session", "status": 403 } }) .to_string(), ) .into_response(); } store.remove_account_shared(&account_id).await; let secure = cookie_secure(&config, &extensions, &headers); let same_site = same_site_policy(&config); let cookie = clearing_account_cookie(&account_id, secure, same_site, cookie_path(&config)); ( StatusCode::OK, [ ("content-type", "application/json"), ("set-cookie", &cookie), ], serde_json::json!({ "status": "logged_out" }).to_string(), ) .into_response() } fn friendly_user_agent(user_agent: &str) -> String { let ua = user_agent.trim(); if ua.is_empty() { return "Unknown device".to_string(); } let browser = if ua.contains("Edg/") { "Edge" } else if ua.contains("Firefox/") || ua.contains("FxiOS/") { "Firefox" } else if ua.contains("OPR/") || ua.contains("Opera/") { "Opera" } else if ua.contains("Chrome/") || ua.contains("CriOS/") { "Chrome" } else if ua.contains("Safari/") && (ua.contains("Version/") || ua.contains("Mobile/")) { "Safari" } else { "Browser" }; let device = if ua.contains("iPhone") { "iPhone" } else if ua.contains("iPad") { "iPad" } else if ua.contains("Android") { "Android" } else if ua.contains("Windows") { "Windows" } else if ua.contains("Macintosh") || ua.contains("Mac OS X") { "macOS" } else if ua.contains("CrOS") { "ChromeOS" } else if ua.contains("Linux") { "Linux" } else { "Unknown OS" }; format!("{browser} · {device}") } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct ActiveSessionResponse { id: String, email: String, created_at: u64, last_active: u64, expires_at: u64, remembered: bool, current: bool, user_agent: String, client_name: String, } pub async fn list_active_sessions( Extension(store): Extension>, Extension(config): Extension>, Extension(session): Extension, ) -> Response { let sessions: Vec = store .get_user_sessions_shared(&session.user_hash) .await .into_iter() .map(|item| { let timeout_secs = item .timeout_override .map(|duration| duration.as_secs()) .unwrap_or(config.session_timeout_hours * 3600); ActiveSessionResponse { current: item.account_id == session.account_id, id: item.account_id, email: item.email, created_at: item.created_at_unix, last_active: item.last_accessed_unix, expires_at: item.last_accessed_unix.saturating_add(timeout_secs), remembered: item.timeout_override.is_some(), client_name: friendly_user_agent(&item.user_agent), user_agent: item.user_agent, } }) .collect(); Json(serde_json::json!({ "sessions": sessions })).into_response() } pub async fn revoke_active_session( Extension(store): Extension>, Extension(session): Extension, Path(account_id): Path, ) -> Response { if account_id == session.account_id { return ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": { "code": "CURRENT_SESSION", "message": "Use Log out to close the current session", "status": 400 } })), ) .into_response(); } if store .remove_user_session_shared(&session.user_hash, &account_id) .await { Json(serde_json::json!({ "status": "revoked" })).into_response() } else { ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": { "code": "NOT_FOUND", "message": "Session not found", "status": 404 } })), ) .into_response() } } pub async fn revoke_other_sessions( Extension(store): Extension>, Extension(session): Extension, ) -> Response { let revoked = store .remove_other_user_sessions_shared(&session.user_hash, &session.account_id) .await; Json(serde_json::json!({ "status": "revoked", "count": revoked })).into_response() } pub async fn logout( Extension(store): Extension>, Extension(config): Extension>, extensions: axum::http::Extensions, headers: axum::http::HeaderMap, ) -> Response { let browser_id = extract_browser_id(&headers); let mut cookies_to_clear: Vec = Vec::new(); let secure = cookie_secure(&config, &extensions, &headers); let same_site = same_site_policy(&config); if let Some(ref browser_id) = browser_id { let accounts = store.get_browser_accounts_shared(browser_id).await; for account in accounts { cookies_to_clear.push(clearing_account_cookie( &account.account_id, secure, same_site, cookie_path(&config), )); } store.remove_browser_shared(browser_id).await; } cookies_to_clear.push(clearing_browser_cookie( secure, same_site, cookie_path(&config), )); let response = ( StatusCode::OK, [("content-type", "application/json")], serde_json::json!({ "status": "logged_out" }).to_string(), ) .into_response(); let mut response = response; let headers = response.headers_mut(); for cookie in cookies_to_clear { if let Ok(header_value) = cookie.parse() { headers.append("set-cookie", header_value); } } response } #[cfg(test)] mod tests { use super::*; use serde::Serialize; #[derive(Serialize)] struct UserResponse { user: UserInfo, } #[derive(Serialize)] struct UserInfo { email: String, } #[derive(Serialize)] struct LogoutResponse { status: &'static str, } #[test] fn friendly_user_agent_labels_common_clients() { assert_eq!( friendly_user_agent( "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0" ), "Firefox · Linux" ); assert_eq!( friendly_user_agent( "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1" ), "Safari · iPhone" ); assert_eq!(friendly_user_agent(""), "Unknown device"); } #[test] fn user_response_serialization() { let resp = UserResponse { user: UserInfo { email: "test@example.com".to_string(), }, }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["user"]["email"], "test@example.com"); } #[test] fn logout_response_serialization() { let resp = LogoutResponse { status: "logged_out", }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["status"], "logged_out"); } fn test_config(environment: &str, _cors_origin: Option<&str>) -> AppConfig { AppConfig { host: "127.0.0.1".to_string(), port: 3001, imap_host: None, imap_port: 993, smtp_host: None, smtp_port: 587, tls_enabled: true, data_dir: "/data".to_string(), session_timeout_hours: 24, environment: environment.to_string(), base_path: None, sieve_host: None, sieve_port: 4190, sieve_tls_mode: "starttls".to_string(), sieve_allow_insecure_auth: false, sieve_timeout_secs: 10, trusted_proxies: None, outgoing_headers: Vec::new(), ..AppConfig::default() } } #[tokio::test] async fn login_reuses_scoped_browser_cookie_when_body_omits_browser_id() { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let imap = tokio::spawn(async move { for _ in 0..3 { let (stream, _) = listener.accept().await.unwrap(); let mut stream = BufReader::new(stream); stream .get_mut() .write_all(b"* OK test IMAP ready\r\n") .await .unwrap(); let mut command = String::new(); stream.read_line(&mut command).await.unwrap(); let tag = command.split_whitespace().next().unwrap(); let response = format!("{tag} OK LOGIN completed\r\n"); stream .get_mut() .write_all(response.as_bytes()) .await .unwrap(); command.clear(); stream.read_line(&mut command).await.unwrap(); let tag = command.split_whitespace().next().unwrap(); let response = format!("* BYE logging out\r\n{tag} OK LOGOUT completed\r\n"); stream .get_mut() .write_all(response.as_bytes()) .await .unwrap(); } }); let data_dir = tempfile::tempdir().unwrap(); let mut config = test_config("development", Some("http://localhost:3000")); config.base_path = Some("/mail".to_string()); config.data_dir = data_dir.path().to_str().unwrap().to_string(); config.imap_host = Some("127.0.0.1".to_string()); config.imap_port = port; config.smtp_host = Some("127.0.0.1".to_string()); config.tls_enabled = false; let config = Arc::new(config); let store = Arc::new(SessionStore::new(std::time::Duration::from_secs(3600))); let request = |email: &str| LoginRequest { email: email.to_string(), password: "password".to_string(), remember: false, browser_id: None, }; let first = login( Extension(store.clone()), Extension(config.clone()), axum::http::Extensions::new(), axum::http::HeaderMap::new(), Json(request("first@example.com")), ) .await; assert_eq!(first.status(), StatusCode::CREATED); let cookie = first .headers() .get_all("set-cookie") .iter() .map(|value| value.to_str().unwrap()) .find(|value| value.starts_with("rustbox_browser=")) .unwrap(); assert!(cookie.contains("Path=/mail;")); let cookie_pair = cookie.split(';').next().unwrap(); let browser_id = cookie_pair.strip_prefix("rustbox_browser=").unwrap(); let mut headers = axum::http::HeaderMap::new(); headers.insert("cookie", cookie_pair.parse().unwrap()); let second = login( Extension(store.clone()), Extension(config.clone()), axum::http::Extensions::new(), headers.clone(), Json(request("second@example.com")), ) .await; assert_eq!(second.status(), StatusCode::CREATED); assert!(second.headers().get_all("set-cookie").iter().any(|value| { value .to_str() .unwrap() .starts_with(&format!("{cookie_pair};")) })); let accounts = store.get_browser_accounts(browser_id); assert_eq!(accounts.len(), 2); assert!( accounts .iter() .any(|account| account.email == "first@example.com") ); assert!( accounts .iter() .any(|account| account.email == "second@example.com") ); let duplicate = login( Extension(store.clone()), Extension(config), axum::http::Extensions::new(), headers, Json(request("second@example.com")), ) .await; assert_eq!(duplicate.status(), StatusCode::CONFLICT); assert_eq!(store.get_browser_accounts(browser_id).len(), 2); imap.await.unwrap(); } #[tokio::test] async fn repeated_failed_logins_lock_the_account_before_contacting_imap() { use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let connections = Arc::new(AtomicUsize::new(0)); let seen = connections.clone(); let imap = tokio::spawn(async move { loop { let Ok((stream, _)) = listener.accept().await else { break; }; seen.fetch_add(1, Ordering::SeqCst); let mut stream = BufReader::new(stream); stream .get_mut() .write_all(b"* OK test IMAP ready\r\n") .await .unwrap(); let mut command = String::new(); stream.read_line(&mut command).await.unwrap(); let tag = command .split_whitespace() .next() .unwrap_or("A1") .to_string(); let response = format!("{tag} NO [AUTHENTICATIONFAILED] Invalid credentials\r\n"); let _ = stream.get_mut().write_all(response.as_bytes()).await; } }); let data_dir = tempfile::tempdir().unwrap(); let mut config = test_config("development", None); config.data_dir = data_dir.path().to_str().unwrap().to_string(); config.imap_host = Some("127.0.0.1".to_string()); config.imap_port = port; config.tls_enabled = false; config.login_rate_limit.account_max_failures = 2; config.login_rate_limit.lockout_secs = 120; let guard = Arc::new(LoginGuard::from_config(&config.login_rate_limit)); let config = Arc::new(config); let store = Arc::new(SessionStore::new(std::time::Duration::from_secs(3600))); let attempt = |email: &'static str| { let mut extensions = axum::http::Extensions::new(); extensions.insert(guard.clone()); extensions.insert(ConnectInfo::( "198.51.100.7:50000".parse().unwrap(), )); login( Extension(store.clone()), Extension(config.clone()), extensions, axum::http::HeaderMap::new(), Json(LoginRequest { email: email.to_string(), password: "wrong".to_string(), remember: false, browser_id: None, }), ) }; assert_eq!( attempt("victim@example.com").await.status(), StatusCode::UNAUTHORIZED ); assert_eq!( attempt("Victim@Example.com").await.status(), StatusCode::UNAUTHORIZED ); let locked = attempt("victim@example.com").await; assert_eq!(locked.status(), StatusCode::TOO_MANY_REQUESTS); let retry_after: u64 = locked .headers() .get("retry-after") .unwrap() .to_str() .unwrap() .parse() .unwrap(); assert!((1..=120).contains(&retry_after)); assert_eq!(connections.load(Ordering::SeqCst), 2); // Another mailbox from the same IP is still allowed to try. assert_eq!( attempt("other@example.com").await.status(), StatusCode::UNAUTHORIZED ); assert_eq!(connections.load(Ordering::SeqCst), 3); imap.abort(); } fn request_parts( peer: &str, headers: &[(&'static str, &'static str)], ) -> (axum::http::Extensions, axum::http::HeaderMap) { let mut extensions = axum::http::Extensions::new(); extensions.insert(ConnectInfo::(peer.parse().unwrap())); let mut map = axum::http::HeaderMap::new(); for &(name, value) in headers { map.append(name, axum::http::HeaderValue::from_static(value)); } (extensions, map) } fn secure_for( config: &AppConfig, peer: &str, headers: &[(&'static str, &'static str)], ) -> bool { let (extensions, headers) = request_parts(peer, headers); cookie_secure(config, &extensions, &headers) } #[test] fn auto_cookie_secure_follows_the_real_request_scheme() { let mut config = test_config("production", None); config.trusted_proxies = Some("127.0.0.1,10.0.0.0/8".to_string()); let client = "192.0.2.10:5000"; let proxy = "10.0.0.2:5000"; // Plain HTTP install, even in production: no Secure flag, login keeps working. assert!(!secure_for(&config, client, &[])); assert!(!secure_for( &config, client, &[("origin", "http://mail.example.com")] )); // Browser on HTTPS (TLS terminated in front of RustBox). assert!(secure_for( &config, client, &[("origin", "https://mail.example.com")] )); assert!(secure_for( &config, "127.0.0.1:5000", &[("referer", "HTTPS://mail.example.com/inbox")] )); // Trusted reverse proxy reports the scheme explicitly and wins. assert!(secure_for( &config, proxy, &[("x-forwarded-proto", "https")] )); assert!(secure_for( &config, proxy, &[("forwarded", "for=192.0.2.1;proto=https")] )); assert!(secure_for(&config, proxy, &[("x-forwarded-ssl", "on")])); assert!(!secure_for( &config, proxy, &[ ("x-forwarded-proto", "http"), ("origin", "https://mail.example.com") ] )); // Untrusted peers cannot assert the scheme through proxy headers. assert!(!secure_for( &config, client, &[("x-forwarded-proto", "https")] )); } #[test] fn cookie_secure_can_be_forced_from_config() { let (extensions, headers) = request_parts("192.0.2.10:5000", &[]); let mut config = test_config("development", None); config.cookie_secure = "always".to_string(); assert!(cookie_secure(&config, &extensions, &headers)); let (extensions, headers) = request_parts("192.0.2.10:5000", &[("origin", "https://mail.example.com")]); config.cookie_secure = "never".to_string(); assert!(!cookie_secure(&config, &extensions, &headers)); } #[test] fn issued_and_clearing_cookies_use_the_normalized_base_path() { for (base_path, expected_path) in [ (None, "/"), (Some(""), "/"), (Some("/"), "/"), (Some("/oxi"), "/oxi"), ] { let mut config = test_config("development", None); config.base_path = base_path.map(str::to_string); let path = cookie_path(&config); let cookies = [ browser_cookie("browser", 3600, false, "Strict", path), account_session_cookie("account", "token", 3600, false, "Strict", path), clearing_browser_cookie(false, "Strict", path), clearing_account_cookie("account", false, "Strict", path), ]; for cookie in cookies { let path_attribute = cookie .split(';') .map(str::trim) .find_map(|attribute| attribute.strip_prefix("Path=")); assert_eq!( path_attribute, Some(expected_path), "{base_path:?}: {cookie}" ); } } } #[test] fn same_site_is_strict_for_rustbox_development() { let config = test_config("development", Some("http://localhost:3000")); assert_eq!(same_site_policy(&config), "Strict"); let cookie = browser_cookie( "browser", 3600, false, same_site_policy(&config), cookie_path(&config), ); assert!(cookie.contains("SameSite=Strict")); } #[test] fn same_site_stays_strict_outside_development() { let config = test_config("production", Some("https://app.example.com")); assert_eq!(same_site_policy(&config), "Strict"); let cookie = browser_cookie( "browser", 3600, true, same_site_policy(&config), cookie_path(&config), ); assert!(cookie.contains("SameSite=Strict")); } }