1 parent
0421994fe6
commit
425ea7449b
138 files changed
+5705
-1012
No files matched your search
+371
-38
@@ -6,12 +6,29 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use dashmap::DashMap;
|
||||
use rand::RngCore;
|
||||
|
||||
use crate::session_store::crypto::SessionCrypto;
|
||||
use crate::session_store::{PersistedSession, SessionPersistence};
|
||||
|
||||
pub type SessionId = String;
|
||||
pub type BrowserId = String;
|
||||
pub type AccountId = String;
|
||||
|
||||
pub type SessionState = AccountSession;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewAccountSession {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub user_hash: String,
|
||||
pub imap_host: String,
|
||||
pub imap_port: u16,
|
||||
pub imap_tls: bool,
|
||||
pub smtp_host: String,
|
||||
pub smtp_port: u16,
|
||||
pub smtp_tls: bool,
|
||||
pub timeout_override: Option<Duration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountSession {
|
||||
pub account_id: AccountId,
|
||||
@@ -37,7 +54,7 @@ pub struct AccountSession {
|
||||
pub user_agent: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct SessionStore {
|
||||
sessions: Arc<DashMap<SessionId, AccountSession>>,
|
||||
browsers: Arc<DashMap<BrowserId, Vec<AccountId>>>,
|
||||
@@ -45,6 +62,8 @@ pub struct SessionStore {
|
||||
account_to_browser: Arc<DashMap<AccountId, BrowserId>>,
|
||||
timeout: Duration,
|
||||
tls_verify_override: Option<bool>,
|
||||
persistence: Option<Arc<dyn SessionPersistence>>,
|
||||
crypto: Option<SessionCrypto>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
@@ -55,10 +74,12 @@ impl SessionStore {
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new(timeout: Duration) -> Self {
|
||||
Self::new_with_tls_verify(timeout, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_with_tls_verify(timeout: Duration, tls_verify_override: Option<bool>) -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(DashMap::new()),
|
||||
@@ -67,6 +88,26 @@ impl SessionStore {
|
||||
account_to_browser: Arc::new(DashMap::new()),
|
||||
timeout,
|
||||
tls_verify_override,
|
||||
persistence: None,
|
||||
crypto: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_persistent(
|
||||
timeout: Duration,
|
||||
tls_verify_override: Option<bool>,
|
||||
persistence: Arc<dyn SessionPersistence>,
|
||||
crypto: SessionCrypto,
|
||||
) -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(DashMap::new()),
|
||||
browsers: Arc::new(DashMap::new()),
|
||||
account_to_session: Arc::new(DashMap::new()),
|
||||
account_to_browser: Arc::new(DashMap::new()),
|
||||
timeout,
|
||||
tls_verify_override,
|
||||
persistence: Some(persistence),
|
||||
crypto: Some(crypto),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +140,7 @@ impl SessionStore {
|
||||
browser_id
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn add_account_to_browser(
|
||||
&self,
|
||||
@@ -270,43 +312,6 @@ impl SessionStore {
|
||||
result
|
||||
}
|
||||
|
||||
pub fn remove_user_session(&self, user_hash: &str, account_id: &str) -> bool {
|
||||
let Some(session_id) = self
|
||||
.account_to_session
|
||||
.get(account_id)
|
||||
.map(|entry| entry.clone())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let belongs_to_user = self
|
||||
.sessions
|
||||
.get(&session_id)
|
||||
.is_some_and(|session| session.user_hash == user_hash);
|
||||
if !belongs_to_user {
|
||||
return false;
|
||||
}
|
||||
self.remove_account(account_id)
|
||||
}
|
||||
|
||||
pub fn remove_other_user_sessions(&self, user_hash: &str, current_account_id: &str) -> usize {
|
||||
let account_ids: Vec<String> = self
|
||||
.sessions
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
entry.value().user_hash == user_hash
|
||||
&& entry.value().account_id != current_account_id
|
||||
})
|
||||
.map(|entry| entry.value().account_id.clone())
|
||||
.collect();
|
||||
let mut removed = 0;
|
||||
for account_id in account_ids {
|
||||
if self.remove_user_session(user_hash, &account_id) {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
pub fn remove_browser(&self, browser_id: &str) {
|
||||
if let Some((_, account_ids)) = self.browsers.remove(browser_id) {
|
||||
for account_id in account_ids {
|
||||
@@ -474,6 +479,282 @@ impl SessionStore {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.sessions.is_empty()
|
||||
}
|
||||
|
||||
fn cache_persisted(&self, record: &PersistedSession) -> Result<AccountSession, String> {
|
||||
let crypto = self
|
||||
.crypto
|
||||
.as_ref()
|
||||
.ok_or("session crypto is not configured")?;
|
||||
let password = crypto.decrypt(&record.password_ciphertext)?;
|
||||
let age = Self::unix_now().saturating_sub(record.last_accessed_unix);
|
||||
let now = Instant::now();
|
||||
let last_accessed = now.checked_sub(Duration::from_secs(age)).unwrap_or(now);
|
||||
let timeout_override = if record.timeout_secs == self.timeout.as_secs() {
|
||||
None
|
||||
} else {
|
||||
Some(Duration::from_secs(record.timeout_secs))
|
||||
};
|
||||
let session = AccountSession {
|
||||
account_id: record.account_id.clone(),
|
||||
email: record.email.clone(),
|
||||
password,
|
||||
user_hash: record.user_hash.clone(),
|
||||
imap_host: record.imap_host.clone(),
|
||||
imap_port: record.imap_port,
|
||||
imap_tls: record.imap_tls,
|
||||
imap_tls_verify: record.imap_tls_verify,
|
||||
smtp_host: record.smtp_host.clone(),
|
||||
smtp_port: record.smtp_port,
|
||||
smtp_tls: record.smtp_tls,
|
||||
smtp_tls_verify: record.smtp_tls_verify,
|
||||
last_accessed,
|
||||
timeout_override,
|
||||
created_at_unix: record.created_at_unix,
|
||||
last_accessed_unix: record.last_accessed_unix,
|
||||
user_agent: record.user_agent.clone(),
|
||||
};
|
||||
self.sessions.insert(record.token.clone(), session.clone());
|
||||
self.account_to_session
|
||||
.insert(record.account_id.clone(), record.token.clone());
|
||||
self.account_to_browser
|
||||
.insert(record.account_id.clone(), record.browser_id.clone());
|
||||
let mut entry = self.browsers.entry(record.browser_id.clone()).or_default();
|
||||
if !entry.contains(&record.account_id) {
|
||||
entry.push(record.account_id.clone());
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn persisted_from_memory(
|
||||
&self,
|
||||
token: &str,
|
||||
browser_id: &str,
|
||||
session: &AccountSession,
|
||||
) -> Result<PersistedSession, String> {
|
||||
let crypto = self
|
||||
.crypto
|
||||
.as_ref()
|
||||
.ok_or("session crypto is not configured")?;
|
||||
Ok(PersistedSession {
|
||||
token: token.to_string(),
|
||||
account_id: session.account_id.clone(),
|
||||
browser_id: browser_id.to_string(),
|
||||
email: session.email.clone(),
|
||||
password_ciphertext: crypto.encrypt(&session.password)?,
|
||||
user_hash: session.user_hash.clone(),
|
||||
imap_host: session.imap_host.clone(),
|
||||
imap_port: session.imap_port,
|
||||
imap_tls: session.imap_tls,
|
||||
imap_tls_verify: session.imap_tls_verify,
|
||||
smtp_host: session.smtp_host.clone(),
|
||||
smtp_port: session.smtp_port,
|
||||
smtp_tls: session.smtp_tls,
|
||||
smtp_tls_verify: session.smtp_tls_verify,
|
||||
timeout_secs: session.timeout_override.unwrap_or(self.timeout).as_secs(),
|
||||
created_at_unix: session.created_at_unix,
|
||||
last_accessed_unix: session.last_accessed_unix,
|
||||
user_agent: session.user_agent.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn persist_account(&self, browser_id: &str, account_id: &str) {
|
||||
let (Some(persistence), Some(token)) = (
|
||||
self.persistence.as_ref(),
|
||||
self.account_to_session.get(account_id).map(|v| v.clone()),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let Some(session) = self.sessions.get(&token).map(|v| v.clone()) else {
|
||||
return;
|
||||
};
|
||||
match self.persisted_from_memory(&token, browser_id, &session) {
|
||||
Ok(record) => {
|
||||
if let Err(error) = persistence.save(&record).await {
|
||||
tracing::error!(%error, "failed to persist session");
|
||||
}
|
||||
}
|
||||
Err(error) => tracing::error!(%error, "failed to encrypt session"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn add_account_to_browser_shared(
|
||||
&self,
|
||||
browser_id: &str,
|
||||
input: NewAccountSession,
|
||||
) -> (SessionId, AccountId) {
|
||||
let result = self.add_account_to_browser_with_timeout(
|
||||
browser_id,
|
||||
input.email,
|
||||
input.password,
|
||||
input.user_hash,
|
||||
input.imap_host,
|
||||
input.imap_port,
|
||||
input.imap_tls,
|
||||
input.smtp_host,
|
||||
input.smtp_port,
|
||||
input.smtp_tls,
|
||||
input.timeout_override,
|
||||
);
|
||||
self.persist_account(browser_id, &result.1).await;
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn get_browser_accounts_shared(&self, browser_id: &str) -> Vec<AccountSession> {
|
||||
let Some(persistence) = self.persistence.as_ref() else {
|
||||
return self.get_browser_accounts(browser_id);
|
||||
};
|
||||
match persistence.load_browser(browser_id).await {
|
||||
Ok(records) => records
|
||||
.into_iter()
|
||||
.filter(|r| !r.expired_at(Self::unix_now()))
|
||||
.filter_map(|r| {
|
||||
self.cache_persisted(&r)
|
||||
.map_err(|e| tracing::error!(error=%e, "failed to restore session"))
|
||||
.ok()
|
||||
})
|
||||
.collect(),
|
||||
Err(error) => {
|
||||
tracing::error!(%error, "failed to load browser sessions");
|
||||
self.get_browser_accounts(browser_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn browser_exists_shared(&self, browser_id: &str) -> bool {
|
||||
let Some(persistence) = self.persistence.as_ref() else {
|
||||
return self.browser_exists(browser_id);
|
||||
};
|
||||
persistence
|
||||
.browser_exists(browser_id)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::error!(%error, "failed to query browser session");
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_shared(&self, token: &str) -> Option<AccountSession> {
|
||||
let Some(persistence) = self.persistence.as_ref() else {
|
||||
return self.get(token);
|
||||
};
|
||||
let record = persistence.load_token(token).await.ok().flatten()?;
|
||||
if record.expired_at(Self::unix_now()) {
|
||||
let _ = persistence.delete_account(&record.account_id).await;
|
||||
return None;
|
||||
}
|
||||
self.cache_persisted(&record).ok()?;
|
||||
let refreshed = self.get(token)?;
|
||||
self.persist_account(&record.browser_id, &record.account_id)
|
||||
.await;
|
||||
Some(refreshed)
|
||||
}
|
||||
|
||||
pub async fn get_account_session_shared(
|
||||
&self,
|
||||
browser_id: &str,
|
||||
account_id: &str,
|
||||
token: &str,
|
||||
) -> Option<AccountSession> {
|
||||
let Some(persistence) = self.persistence.as_ref() else {
|
||||
return self.get_account_session(browser_id, account_id, token);
|
||||
};
|
||||
let record = persistence.load_token(token).await.ok().flatten()?;
|
||||
if record.browser_id != browser_id
|
||||
|| record.account_id != account_id
|
||||
|| record.expired_at(Self::unix_now())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.cache_persisted(&record).ok()?;
|
||||
let session = self.get(token)?;
|
||||
self.persist_account(browser_id, account_id).await;
|
||||
Some(session)
|
||||
}
|
||||
|
||||
pub async fn set_user_agent_shared(&self, account_id: &str, user_agent: String) {
|
||||
self.set_user_agent(account_id, user_agent);
|
||||
if let Some(browser_id) = self.account_to_browser.get(account_id).map(|v| v.clone()) {
|
||||
self.persist_account(&browser_id, account_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_account_shared(&self, account_id: &str) -> bool {
|
||||
let local = self.remove_account(account_id);
|
||||
let Some(persistence) = self.persistence.as_ref() else {
|
||||
return local;
|
||||
};
|
||||
match persistence.delete_account(account_id).await {
|
||||
Ok(remote) => local || remote,
|
||||
Err(error) => {
|
||||
tracing::error!(%error, "failed to delete persisted session");
|
||||
local
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_browser_shared(&self, browser_id: &str) {
|
||||
self.remove_browser(browser_id);
|
||||
if let Some(persistence) = self.persistence.as_ref()
|
||||
&& let Err(error) = persistence.delete_browser(browser_id).await
|
||||
{
|
||||
tracing::error!(%error, "failed to delete persisted browser sessions");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user_sessions_shared(&self, user_hash: &str) -> Vec<AccountSession> {
|
||||
let Some(persistence) = self.persistence.as_ref() else {
|
||||
return self.get_user_sessions(user_hash);
|
||||
};
|
||||
match persistence.load_user(user_hash).await {
|
||||
Ok(records) => records
|
||||
.into_iter()
|
||||
.filter(|r| !r.expired_at(Self::unix_now()))
|
||||
.filter_map(|r| self.cache_persisted(&r).ok())
|
||||
.collect(),
|
||||
Err(error) => {
|
||||
tracing::error!(%error, "failed to load user sessions");
|
||||
self.get_user_sessions(user_hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_user_session_shared(&self, user_hash: &str, account_id: &str) -> bool {
|
||||
let sessions = self.get_user_sessions_shared(user_hash).await;
|
||||
if !sessions.iter().any(|s| s.account_id == account_id) {
|
||||
return false;
|
||||
}
|
||||
self.remove_account_shared(account_id).await
|
||||
}
|
||||
|
||||
pub async fn remove_other_user_sessions_shared(
|
||||
&self,
|
||||
user_hash: &str,
|
||||
current_account_id: &str,
|
||||
) -> usize {
|
||||
let ids: Vec<String> = self
|
||||
.get_user_sessions_shared(user_hash)
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|s| s.account_id != current_account_id)
|
||||
.map(|s| s.account_id)
|
||||
.collect();
|
||||
let mut removed = 0;
|
||||
for id in ids {
|
||||
if self.remove_account_shared(&id).await {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
pub async fn purge_expired_shared(&self) {
|
||||
self.purge_expired();
|
||||
if let Some(persistence) = self.persistence.as_ref()
|
||||
&& let Err(error) = persistence.purge_expired(Self::unix_now()).await
|
||||
{
|
||||
tracing::error!(%error, "failed to purge persisted sessions");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1318,4 +1599,56 @@ mod tests {
|
||||
"remembered session should outlive default timeout"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persistent_sqlite_session_survives_store_restart() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let config = crate::config::AppConfig {
|
||||
data_dir: temp.path().to_string_lossy().into_owned(),
|
||||
database_backend: "sqlite".to_string(),
|
||||
session_backend: "database".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let persistence = crate::session_store::from_config(&config)
|
||||
.await
|
||||
.expect("persistence");
|
||||
let crypto =
|
||||
crate::session_store::crypto::SessionCrypto::load_or_create(&config).expect("crypto");
|
||||
let store =
|
||||
SessionStore::new_persistent(Duration::from_secs(3600), None, persistence, crypto);
|
||||
let browser_id = store.create_browser();
|
||||
let (token, account_id) = store
|
||||
.add_account_to_browser_shared(
|
||||
&browser_id,
|
||||
NewAccountSession {
|
||||
email: "restart@example.com".to_string(),
|
||||
password: "secret-password".to_string(),
|
||||
user_hash: "user-hash".to_string(),
|
||||
imap_host: "imap.example.com".to_string(),
|
||||
imap_port: 993,
|
||||
imap_tls: true,
|
||||
smtp_host: "smtp.example.com".to_string(),
|
||||
smtp_port: 587,
|
||||
smtp_tls: true,
|
||||
timeout_override: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
drop(store);
|
||||
|
||||
let persistence = crate::session_store::from_config(&config)
|
||||
.await
|
||||
.expect("persistence after restart");
|
||||
let crypto = crate::session_store::crypto::SessionCrypto::load_or_create(&config)
|
||||
.expect("crypto after restart");
|
||||
let restarted =
|
||||
SessionStore::new_persistent(Duration::from_secs(3600), None, persistence, crypto);
|
||||
let restored = restarted
|
||||
.get_account_session_shared(&browser_id, &account_id, &token)
|
||||
.await
|
||||
.expect("session should survive restart");
|
||||
assert_eq!(restored.email, "restart@example.com");
|
||||
assert_eq!(restored.password, "secret-password");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user