From 5f93d3642b7ee88b2e4ed7f5b73930a3f9e870a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 26 Jul 2026 09:36:23 +0200 Subject: [PATCH] session check --- src/auth/mod.rs | 36 ++++++++++++++++++++++++++++++++---- src/queries.rs | 1 + static/js/api.js | 24 ++++++++++++++++++++++++ static/js/home.js | 1 + 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/auth/mod.rs b/src/auth/mod.rs index fc6c565..dccf923 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1199,13 +1199,41 @@ pub async fn confirm_reset( } pub async fn user_from_token(state: &SharedState, token: &str) -> Result, AuthError> { - let now = Utc::now().to_rfc3339(); - sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_SESSION)) + let now = Utc::now(); + let now_rfc3339 = now.to_rfc3339(); + let user = sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_SESSION)) .bind(token) - .bind(now) + .bind(&now_rfc3339) .fetch_optional(state.db.pool()) .await - .map_err(AuthError::database) + .map_err(AuthError::database)?; + + if let Some(user) = user { + let expires_at = (now + Duration::days(state.user_session_ttl_days)).to_rfc3339(); + let refreshed = sqlx::query(queries::get(state.db.kind(), queries::AUTH_REFRESH_SESSION)) + .bind(&expires_at) + .bind(token) + .bind(&now_rfc3339) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + if refreshed.rows_affected() == 1 { + debug!(user_id = user.id, expires_at = %expires_at, "authentication session extended"); + Ok(Some(user)) + } else { + Ok(None) + } + } else { + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_DELETE_SESSION_BY_TOKEN, + )) + .bind(token) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + Ok(None) + } } pub async fn authorize_nickname( diff --git a/src/queries.rs b/src/queries.rs index 1281c96..8b99e1d 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -23,6 +23,7 @@ pub const AUTH_USER_BY_EXTERNAL_ID: &str = "SELECT id, nickname, email, password pub const AUTH_DELETE_USER: &str = "DELETE FROM users WHERE id = ?"; pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?"; pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?"; +pub const AUTH_REFRESH_SESSION: &str = "UPDATE user_sessions SET expires_at = ? WHERE token = ? AND expires_at > ?"; pub const AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER: &str = "DELETE FROM account_confirmation_tokens WHERE user_id = ?"; pub const AUTH_INSERT_CONFIRMATION_TOKEN: &str = diff --git a/static/js/api.js b/static/js/api.js index 4928c67..b194620 100644 --- a/static/js/api.js +++ b/static/js/api.js @@ -6,6 +6,29 @@ function formatBytes(bytes) { return `${bytes} B`; } +function clearExpiredSession() { + localStorage.removeItem("rustpad:auth-token"); + sessionStorage.removeItem("rustpad:auth-token"); + localStorage.removeItem("rustpad:nickname"); + sessionStorage.removeItem("rustpad:nickname"); + document.cookie = "rustpad_nickname=; Path=/; SameSite=Lax; Max-Age=0"; + window.dispatchEvent(new CustomEvent("rustpad:session-expired")); +} + +async function clearSessionIfInvalid() { + const token = localStorage.getItem("rustpad:auth-token") || sessionStorage.getItem("rustpad:auth-token"); + if (!token) return; + try { + const response = await fetch("/api/auth/me", { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(5000), + }); + if (response.status === 401) clearExpiredSession(); + } catch { + // A network failure does not prove that the session is invalid. + } +} + function validateUploadSize(body) { if (!(body instanceof FormData)) return; const maxBytes = Number(window.__RUSTPAD_CONFIG__?.uploadMaxSizeBytes || 0); @@ -34,6 +57,7 @@ export async function api(path, options = {}) { const contentType = response.headers.get("content-type") || ""; const data = contentType.includes("application/json") ? await response.json().catch(() => ({})) : {}; if (!response.ok) { + if (response.status === 401) await clearSessionIfInvalid(); const defaults = { 400: "Invalid request.", 401: "Authentication required.", 403: "Access denied.", 404: "The requested resource was not found.", 405: "This operation is not allowed.", 409: "The requested change conflicts with existing data.", 413: "The selected file exceeds the allowed upload limit.", 429: "Too many requests. Try again later.", 500: "Server error. Try again later.", 503: "Service temporarily unavailable." }; const requestError = new Error(data.error || defaults[response.status] || `Request failed (${response.status}).`); requestError.status = response.status; diff --git a/static/js/home.js b/static/js/home.js index cf72fef..6a07e25 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -259,6 +259,7 @@ if (identityDialog) { renderAccount(null); }); + window.addEventListener("rustpad:session-expired", () => renderAccount(null)); renderAccount(null); validateCurrentSession().then(renderAccount); }