diff --git a/migrations/20260507000000_session_family.sql b/migrations/20260507000000_session_family.sql new file mode 100644 index 00000000..1e90fa34 --- /dev/null +++ b/migrations/20260507000000_session_family.sql @@ -0,0 +1,19 @@ +-- Add token family tracking to sessions. +-- +-- family_id groups all refresh tokens issued from the same original login. +-- When a rotation detects a revoked token being replayed (possible theft), +-- the entire family is invalidated — forcing re-authentication on all devices +-- that shared that login event. +-- +-- Existing sessions are seeded with family_id = id (each is its own family). + +ALTER TABLE auth.sessions + ADD COLUMN family_id UUID; + +UPDATE auth.sessions + SET family_id = id; + +ALTER TABLE auth.sessions + ALTER COLUMN family_id SET NOT NULL; + +CREATE INDEX idx_sessions_family_id ON auth.sessions(family_id); diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 55ce1549..e652d865 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -202,6 +202,9 @@ pub trait SessionStoragePort: Send + Sync + 'static { /// Revokes all sessions of a user async fn revoke_all_user_sessions(&self, user_id: Uuid) -> Result; + + /// Revokes all sessions in a token family (used when replay of a revoked token is detected) + async fn revoke_session_family(&self, family_id: Uuid) -> Result; } // ============================================================================ diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 8aaf8687..aaecf820 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -410,13 +410,14 @@ impl AuthApplicationService { let refresh_token = self.token_service.generate_refresh_token(); - // Save session + // Save session — new login starts a new token family let session = Session::new( user.id(), refresh_token.clone(), None, // IP (can be added from the HTTP layer) None, // User-Agent (can be added from the HTTP layer) self.token_service.refresh_token_expiry_days(), + Uuid::new_v4(), ); self.session_storage.create_session(session).await?; @@ -484,8 +485,25 @@ impl AuthApplicationService { .get_session_by_refresh_token(&dto.refresh_token) .await?; - // Check if the session is expired or revoked - if session.is_expired() || session.is_revoked() { + // Reuse detection: a revoked token being replayed indicates the token was + // stolen after rotation. Invalidate the entire family to protect all devices. + if session.is_revoked() { + tracing::warn!( + user_id = %session.user_id(), + family_id = %session.family_id(), + "Refresh token reuse detected — revoking entire token family" + ); + self.session_storage + .revoke_session_family(session.family_id()) + .await?; + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Auth", + "Session expired or invalid", + )); + } + + if session.is_expired() { return Err(DomainError::new( ErrorKind::AccessDenied, "Auth", @@ -505,21 +523,22 @@ impl AuthApplicationService { )); } - // Revoke current session + // Revoke current session before issuing the next token in the family self.session_storage.revoke_session(session.id()).await?; // Generate new tokens let access_token = self.token_service.generate_access_token(&user)?; - let new_refresh_token = self.token_service.generate_refresh_token(); - // Create new session + // New session inherits the family_id so reuse of any ancestor triggers + // full-family revocation let new_session = Session::new( user.id(), new_refresh_token.clone(), None, None, self.token_service.refresh_token_expiry_days(), + session.family_id(), ); self.session_storage.create_session(new_session).await?; @@ -1245,6 +1264,7 @@ impl AuthApplicationService { None, None, self.token_service.refresh_token_expiry_days(), + Uuid::new_v4(), ); self.session_storage.create_session(session).await?; diff --git a/src/application/services/device_auth_service.rs b/src/application/services/device_auth_service.rs index aa047071..1553ca75 100644 --- a/src/application/services/device_auth_service.rs +++ b/src/application/services/device_auth_service.rs @@ -186,6 +186,7 @@ impl DeviceAuthService { None, // ip_address Some(format!("device:{}", dc.client_name())), // user_agent self.token_service.refresh_token_expiry_days(), + Uuid::new_v4(), ); self.session_storage.create_session(session).await?; diff --git a/src/common/config.rs b/src/common/config.rs index dc2a43d7..428ef70c 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -450,9 +450,9 @@ impl Default for AuthConfig { // to set OXICLOUD_JWT_SECRET in production. The from_env() method // will validate this and warn/panic if not configured. jwt_secret: String::new(), - access_token_expiry_secs: 3600, // 1 hour - refresh_token_expiry_secs: 2592000, // 30 days - hash_memory_cost: 65536, // 64 MiB + access_token_expiry_secs: 3600, // 1 hour + refresh_token_expiry_secs: 604800, // 7 days — with rotation, active sessions auto-renew + hash_memory_cost: 65536, // 64 MiB hash_time_cost: 3, hash_parallelism: 2, rate_limit: RateLimitConfig::default(), diff --git a/src/domain/entities/session.rs b/src/domain/entities/session.rs index 17e33be0..fefa05d3 100644 --- a/src/domain/entities/session.rs +++ b/src/domain/entities/session.rs @@ -11,6 +11,9 @@ pub struct Session { user_agent: Option, created_at: DateTime, revoked: bool, + /// Groups all tokens issued from the same original login. + /// Replaying a revoked token from this family triggers full-family revocation. + family_id: Uuid, } impl Session { @@ -20,6 +23,7 @@ impl Session { ip_address: Option, user_agent: Option, expires_in_days: i64, + family_id: Uuid, ) -> Self { if refresh_token.is_empty() { panic!("Session refresh_token cannot be empty"); @@ -35,6 +39,7 @@ impl Session { user_agent, created_at: now, revoked: false, + family_id, } } @@ -48,6 +53,7 @@ impl Session { user_agent: Option, created_at: DateTime, revoked: bool, + family_id: Uuid, ) -> Self { Self { id, @@ -58,6 +64,7 @@ impl Session { user_agent, created_at, revoked, + family_id, } } @@ -101,4 +108,8 @@ impl Session { pub fn revoke(&mut self) { self.revoked = true; } + + pub fn family_id(&self) -> Uuid { + self.family_id + } } diff --git a/src/domain/repositories/session_repository.rs b/src/domain/repositories/session_repository.rs index 84966d74..4ca17c84 100644 --- a/src/domain/repositories/session_repository.rs +++ b/src/domain/repositories/session_repository.rs @@ -52,6 +52,9 @@ pub trait SessionRepository: Send + Sync + 'static { /// Revokes all sessions for a user async fn revoke_all_user_sessions(&self, user_id: Uuid) -> SessionRepositoryResult; + /// Revokes all sessions in a token family (theft response) + async fn revoke_session_family(&self, family_id: Uuid) -> SessionRepositoryResult; + /// Deletes expired sessions async fn delete_expired_sessions(&self) -> SessionRepositoryResult; } diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index 2bd45e8d..65676be6 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -51,10 +51,10 @@ impl SessionRepository for SessionPgRepository { sqlx::query( r#" INSERT INTO auth.sessions ( - id, user_id, refresh_token, expires_at, - ip_address, user_agent, created_at, revoked + id, user_id, refresh_token, expires_at, + ip_address, user_agent, created_at, revoked, family_id ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8 + $1, $2, $3, $4, $5, $6, $7, $8, $9 ) "#, ) @@ -66,6 +66,7 @@ impl SessionRepository for SessionPgRepository { .bind(session_clone.user_agent()) .bind(session_clone.created_at()) .bind(session_clone.is_revoked()) + .bind(session_clone.family_id()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -108,9 +109,9 @@ impl SessionRepository for SessionPgRepository { async fn get_session_by_id(&self, id: Uuid) -> SessionRepositoryResult { let row = sqlx::query( r#" - SELECT - id, user_id, refresh_token, expires_at, - ip_address, user_agent, created_at, revoked + SELECT + id, user_id, refresh_token, expires_at, + ip_address, user_agent, created_at, revoked, family_id FROM auth.sessions WHERE id = $1 "#, @@ -129,19 +130,21 @@ impl SessionRepository for SessionPgRepository { row.get("user_agent"), row.get("created_at"), row.get("revoked"), + row.get("family_id"), )) } - /// Gets a session by refresh token + /// Gets a session by refresh token — returns revoked sessions too so the + /// application layer can distinguish "not found" from "replayed revoked token". async fn get_session_by_refresh_token( &self, refresh_token: &str, ) -> SessionRepositoryResult { let row = sqlx::query( r#" - SELECT - id, user_id, refresh_token, expires_at, - ip_address, user_agent, created_at, revoked + SELECT + id, user_id, refresh_token, expires_at, + ip_address, user_agent, created_at, revoked, family_id FROM auth.sessions WHERE refresh_token = $1 "#, @@ -160,6 +163,7 @@ impl SessionRepository for SessionPgRepository { row.get("user_agent"), row.get("created_at"), row.get("revoked"), + row.get("family_id"), )) } @@ -170,9 +174,9 @@ impl SessionRepository for SessionPgRepository { ) -> SessionRepositoryResult> { let rows = sqlx::query( r#" - SELECT - id, user_id, refresh_token, expires_at, - ip_address, user_agent, created_at, revoked + SELECT + id, user_id, refresh_token, expires_at, + ip_address, user_agent, created_at, revoked, family_id FROM auth.sessions WHERE user_id = $1 ORDER BY created_at DESC @@ -195,6 +199,7 @@ impl SessionRepository for SessionPgRepository { row.get("user_agent"), row.get("created_at"), row.get("revoked"), + row.get("family_id"), ) }) .collect(); @@ -270,6 +275,31 @@ impl SessionRepository for SessionPgRepository { .await } + /// Revokes all sessions in a token family (theft response) + async fn revoke_session_family(&self, family_id: Uuid) -> SessionRepositoryResult { + let result = sqlx::query( + r#" + UPDATE auth.sessions + SET revoked = true + WHERE family_id = $1 AND revoked = false + "#, + ) + .bind(family_id) + .execute(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + let affected = result.rows_affected(); + if affected > 0 { + tracing::warn!( + "Token reuse detected: revoked {} session(s) in family {}", + affected, + family_id + ); + } + Ok(affected) + } + /// Deletes expired sessions async fn delete_expired_sessions(&self) -> SessionRepositoryResult { let now = Utc::now(); @@ -317,4 +347,10 @@ impl SessionStoragePort for SessionPgRepository { .await .map_err(DomainError::from) } + + async fn revoke_session_family(&self, family_id: Uuid) -> Result { + SessionRepository::revoke_session_family(self, family_id) + .await + .map_err(DomainError::from) + } } diff --git a/src/interfaces/api/cookie_auth.rs b/src/interfaces/api/cookie_auth.rs index 63fd9d66..950e9d21 100644 --- a/src/interfaces/api/cookie_auth.rs +++ b/src/interfaces/api/cookie_auth.rs @@ -43,7 +43,7 @@ fn cookie_secure() -> bool { let secure = v == "true" || v == "1"; if !secure { tracing::warn!( - "OXICLOUD_COOKIE_SECURE is explicitly disabled — \ + "⚠️ SECURITY: OXICLOUD_COOKIE_SECURE is explicitly disabled — \ cookies will be sent over plain HTTP. \ Do NOT use this in production." ); @@ -55,7 +55,7 @@ fn cookie_secure() -> bool { Ok(url) if url.starts_with("https") => true, Ok(url) if url.starts_with("http://") => { tracing::info!( - "OXICLOUD_BASE_URL is HTTP — cookie Secure flag is OFF. \ + "⚠️ SECURITY: OXICLOUD_BASE_URL is HTTP — cookie Secure flag is OFF. \ Set OXICLOUD_COOKIE_SECURE=true to override if your proxy terminates TLS." ); false @@ -63,7 +63,7 @@ fn cookie_secure() -> bool { _ => { // Default to false for compatibility with HTTP deployments tracing::info!( - "OXICLOUD_BASE_URL not set — defaulting to non-secure cookies \ + "⚠️ SECURITY: OXICLOUD_BASE_URL not set — defaulting to non-secure cookies \ for HTTP compatibility. Set OXICLOUD_COOKIE_SECURE=true for HTTPS deployments." ); false @@ -72,9 +72,11 @@ fn cookie_secure() -> bool { } /// Build a `Set-Cookie` header value. -fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64) -> String { +fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64, same_site: &str) -> String { let secure = if cookie_secure() { "; Secure" } else { "" }; - format!("{name}={value}; HttpOnly; SameSite=Lax; Path={path}; Max-Age={max_age_secs}{secure}",) + format!( + "{name}={value}; HttpOnly; SameSite={same_site}; Path={path}; Max-Age={max_age_secs}{secure}", + ) } /// Append `Set-Cookie` headers for both access and refresh tokens. @@ -96,6 +98,7 @@ pub fn append_auth_cookies( access_token, "/", access_expiry_secs, + "Lax", // Lax: cookie is sent on top-level navigations (links from other sites) )) { headers.append(SET_COOKIE, val); } @@ -104,6 +107,7 @@ pub fn append_auth_cookies( refresh_token, "/api/auth", refresh_expiry_secs, + "Strict", // Strict: refresh endpoint is never reached via cross-site navigation )) { headers.append(SET_COOKIE, val); } diff --git a/src/main.rs b/src/main.rs index 38206a5d..155d1fec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -468,6 +468,17 @@ async fn main() -> Result<(), Box> { HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), )); + // Warn once at startup if auth cookies are not Secure. + // HttpOnly + SameSite protection is nullified over plain HTTP because tokens + // travel in cleartext and can be intercepted by a network observer. + if !crate::interfaces::api::cookie_auth::is_cookie_secure() { + tracing::warn!( + "⚠️ SECURITY: auth cookies are NOT marked Secure. \ + Tokens will be transmitted in plaintext over HTTP. \ + Set OXICLOUD_COOKIE_SECURE=true for any HTTPS deployment." + ); + } + // Start server — tuned socket for low-latency responses let addr = SocketAddr::from(([0, 0, 0, 0], config.server_port)); tracing::info!("Starting OxiCloud server on http://{}", addr);