security: prevent re-use of refresh token (reduce surface for any stolen token)
Security: session hardening Refresh token rotation with theft detection (family_id) - Added family_id column to auth.sessions (migration 20260507000000_session_family.sql) grouping all tokens issued from the same login into a family - On refresh, the new session inherits the parent's family_id - If a revoked token is replayed (indicates the token was stolen after rotation), the entire family is immediately invalidated and a warning is logged — forcing re-authentication on all devices SameSite=Strict on refresh cookie - Access cookie stays SameSite=Lax (needed for top-level navigation) - Refresh cookie upgraded to SameSite=Strict — it is only ever used for explicit POST to /api/auth/refresh, never via cross-site navigation Refresh token TTL: 30 days → 7 days - With rotation, active sessions auto-renew and effectively never expire - Inactive sessions expire after 7 days instead of 30, reducing the theft window
This commit is contained in:
@@ -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<Session> {
|
||||
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<Session> {
|
||||
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<Vec<Session>> {
|
||||
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<u64> {
|
||||
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<u64> {
|
||||
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<u64, DomainError> {
|
||||
SessionRepository::revoke_session_family(self, family_id)
|
||||
.await
|
||||
.map_err(DomainError::from)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user