1027 lines
33 KiB
Rust
1027 lines
33 KiB
Rust
use std::sync::Arc;
|
|
|
|
use axum::extract::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::session::{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,
|
|
remember_me_days: u64,
|
|
session_timeout_hours: u64,
|
|
}
|
|
|
|
pub async fn auth_policy(Extension(config): Extension<Arc<AppConfig>>) -> Json<AuthPolicyResponse> {
|
|
Json(AuthPolicyResponse {
|
|
remember_me_enabled: config.remember_me_enabled,
|
|
remember_me_days: config.remember_me_days,
|
|
session_timeout_hours: config.session_timeout_hours,
|
|
})
|
|
}
|
|
|
|
#[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!(
|
|
"{}={};{} HttpOnly; SameSite={}; Path={}; Max-Age={}",
|
|
BROWSER_COOKIE, browser_id, secure_flag, same_site, path, 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_{}={};{} HttpOnly; SameSite={}; Path={}; Max-Age={}",
|
|
account_id, token, secure_flag, same_site, path, max_age_secs
|
|
)
|
|
}
|
|
|
|
fn clearing_browser_cookie(secure: bool, same_site: &str, path: &str) -> String {
|
|
let secure_flag = if secure { " Secure;" } else { "" };
|
|
format!(
|
|
"{}=;{} HttpOnly; SameSite={}; Path={}; Max-Age=0",
|
|
BROWSER_COOKIE, secure_flag, same_site, path
|
|
)
|
|
}
|
|
|
|
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_{}=;{} HttpOnly; SameSite={}; Path={}; Max-Age=0",
|
|
account_id, secure_flag, same_site, path
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
pub async fn login(
|
|
Extension(store): Extension<Arc<SessionStore>>,
|
|
Extension(config): Extension<Arc<AppConfig>>,
|
|
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 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;
|
|
|
|
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(browser_id);
|
|
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();
|
|
}
|
|
}
|
|
|
|
let user_hash = user_data::hash_email(&body.email);
|
|
if let Err(e) = user_data::provision_user_data(&config.data_dir, &user_hash) {
|
|
tracing::error!(error = %e, "failed to provision user data directory");
|
|
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(),
|
|
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_with_timeout(
|
|
&browser_id,
|
|
body.email.clone(),
|
|
body.password,
|
|
user_hash,
|
|
imap_host.clone(),
|
|
imap_port,
|
|
imap_tls,
|
|
smtp_host.clone(),
|
|
smtp_port,
|
|
smtp_tls,
|
|
timeout_override,
|
|
);
|
|
|
|
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(&account_id, user_agent);
|
|
|
|
let max_age = session_hours * 3600;
|
|
let secure = config.environment != "development";
|
|
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>>,
|
|
headers: axum::http::HeaderMap,
|
|
) -> Response {
|
|
let secure = config.environment != "development";
|
|
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(&browser_id);
|
|
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(&browser_id);
|
|
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(token) {
|
|
valid_accounts.push(serde_json::json!({
|
|
"id": valid_session.account_id,
|
|
"email": valid_session.email
|
|
}));
|
|
} else {
|
|
store.remove_account(&session.account_id);
|
|
clear_cookies.push(clearing_account_cookie(
|
|
&session.account_id,
|
|
secure,
|
|
same_site,
|
|
cookie_path(&config),
|
|
));
|
|
}
|
|
} else {
|
|
store.remove_account(&session.account_id);
|
|
}
|
|
}
|
|
|
|
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>,
|
|
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(&browser_id);
|
|
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(&account_id);
|
|
|
|
let secure = config.environment != "development";
|
|
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(&session.user_hash)
|
|
.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(&session.user_hash, &account_id) {
|
|
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(&session.user_hash, &session.account_id);
|
|
Json(serde_json::json!({ "status": "revoked", "count": revoked })).into_response()
|
|
}
|
|
|
|
pub async fn logout(
|
|
Extension(store): Extension<Arc<SessionStore>>,
|
|
Extension(config): Extension<Arc<AppConfig>>,
|
|
headers: axum::http::HeaderMap,
|
|
) -> Response {
|
|
let browser_id = extract_browser_id(&headers);
|
|
|
|
let mut cookies_to_clear: Vec<String> = Vec::new();
|
|
|
|
let secure = config.environment != "development";
|
|
let same_site = same_site_policy(&config);
|
|
|
|
if let Some(ref browser_id) = browser_id {
|
|
let accounts = store.get_browser_accounts(browser_id);
|
|
for account in accounts {
|
|
cookies_to_clear.push(clearing_account_cookie(
|
|
&account.account_id,
|
|
secure,
|
|
same_site,
|
|
cookie_path(&config),
|
|
));
|
|
}
|
|
store.remove_browser(browser_id);
|
|
}
|
|
|
|
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::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()),
|
|
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),
|
|
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();
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|