1407 lines
47 KiB
Rust
1407 lines
47 KiB
Rust
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<Arc<AppConfig>>) -> Json<AuthPolicyResponse> {
|
|
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<Arc<AppConfig>>,
|
|
) -> Json<SessionPolicyResponse> {
|
|
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<String>,
|
|
}
|
|
|
|
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<String> {
|
|
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::<ConnectInfo<SocketAddr>>()
|
|
.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<String> {
|
|
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<Arc<SessionStore>>,
|
|
Extension(config): Extension<Arc<AppConfig>>,
|
|
extensions: axum::http::Extensions,
|
|
headers: axum::http::HeaderMap,
|
|
Json(body): Json<LoginRequest>,
|
|
) -> 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::<Arc<LoginGuard>>().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<SessionState>) -> 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<Arc<SessionStore>>,
|
|
Extension(config): Extension<Arc<AppConfig>>,
|
|
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<String, String> =
|
|
session_cookies.into_iter().collect();
|
|
|
|
let browser_accounts = store.get_browser_accounts_shared(&browser_id).await;
|
|
let browser_account_ids: Vec<String> = browser_accounts
|
|
.iter()
|
|
.map(|s| s.account_id.clone())
|
|
.collect();
|
|
|
|
let mut valid_accounts: Vec<serde_json::Value> = Vec::new();
|
|
let mut clear_cookies: Vec<String> = 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::<Vec<_>>(),
|
|
"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<Arc<SessionStore>>,
|
|
Extension(config): Extension<Arc<AppConfig>>,
|
|
Path(account_id): Path<String>,
|
|
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<Arc<SessionStore>>,
|
|
Extension(config): Extension<Arc<AppConfig>>,
|
|
Extension(session): Extension<SessionState>,
|
|
) -> Response {
|
|
let sessions: Vec<ActiveSessionResponse> = 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<Arc<SessionStore>>,
|
|
Extension(session): Extension<SessionState>,
|
|
Path(account_id): Path<String>,
|
|
) -> 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<Arc<SessionStore>>,
|
|
Extension(session): Extension<SessionState>,
|
|
) -> 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<Arc<SessionStore>>,
|
|
Extension(config): Extension<Arc<AppConfig>>,
|
|
extensions: axum::http::Extensions,
|
|
headers: axum::http::HeaderMap,
|
|
) -> Response {
|
|
let browser_id = extract_browser_id(&headers);
|
|
|
|
let mut cookies_to_clear: Vec<String> = 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::<SocketAddr>(
|
|
"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::<SocketAddr>(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"));
|
|
}
|
|
}
|