fixes and functions

This commit is contained in:
Mateusz Gruszczyński
2026-08-05 23:52:41 +02:00
parent 4643de4309
commit a7329f5535
13 changed files with 330 additions and 15 deletions
Generated
+1 -1
View File
@@ -2581,7 +2581,7 @@ dependencies = [
[[package]] [[package]]
name = "rustpad" name = "rustpad"
version = "0.2.43" version = "0.2.44"
dependencies = [ dependencies = [
"argon2", "argon2",
"aws-config", "aws-config",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "rustpad" name = "rustpad"
version = "0.2.43" version = "0.2.44"
edition = "2024" edition = "2024"
rust-version = "1.94" rust-version = "1.94"
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
+51 -3
View File
@@ -2218,23 +2218,67 @@ pub async fn logout(
State(state): State<SharedState>, State(state): State<SharedState>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, AuthError> { ) -> Result<Response, AuthError> {
if let Some(token) = crate::security::session_token(&headers) { let session_token = crate::security::session_token(&headers).map(str::to_owned);
let access_cookies = crate::security::resource_access_cookies(&headers);
let has_access_tokens = access_cookies.iter().any(|(_, token)| token.is_some());
let mut revoked_access_tokens = 0_u64;
if session_token.is_some() || has_access_tokens {
let mut tx = state
.db
.pool()
.begin()
.await
.map_err(AuthError::database)?;
if let Some(token) = session_token.as_deref() {
let result = sqlx::query(queries::get( let result = sqlx::query(queries::get(
state.db.kind(), state.db.kind(),
queries::AUTH_DELETE_SESSION_BY_TOKEN, queries::AUTH_DELETE_SESSION_BY_TOKEN,
)) ))
.bind(token) .bind(token)
.execute(state.db.pool()) .execute(&mut *tx)
.await .await
.map_err(AuthError::database)?; .map_err(AuthError::database)?;
info!(rows_affected = result.rows_affected(), "logout processed"); info!(rows_affected = result.rows_affected(), "logout processed");
} else { } else {
debug!("logout requested without an active session"); debug!("logout requested without an active session");
} }
for (_, token) in &access_cookies {
let Some(token) = token.as_deref() else {
continue;
};
let result = sqlx::query(queries::get(
state.db.kind(),
queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
))
.bind(hash_token(token))
.execute(&mut *tx)
.await
.map_err(AuthError::database)?;
revoked_access_tokens += result.rows_affected();
}
tx.commit().await.map_err(AuthError::database)?;
} else {
debug!("logout requested without an active session or resource access cookies");
}
debug!(
revoked_access_tokens,
cleared_access_cookies = access_cookies.len(),
"password-derived resource access cleared during logout"
);
let mut response = Json(serde_json::json!({"ok": true})).into_response(); let mut response = Json(serde_json::json!({"ok": true})).into_response();
response response
.headers_mut() .headers_mut()
.insert(header::SET_COOKIE, crate::security::clear_session_cookie()); .append(header::SET_COOKIE, crate::security::clear_session_cookie());
for (name, _) in access_cookies {
if let Some(cookie) = crate::security::clear_resource_access_cookie(&name) {
response.headers_mut().append(header::SET_COOKIE, cookie);
}
}
Ok(response) Ok(response)
} }
@@ -3009,3 +3053,7 @@ impl axum::response::IntoResponse for AuthError {
.into_response() .into_response()
} }
} }
#[cfg(test)]
#[path = "../tests/auth.rs"]
mod logout_tests;
+3
View File
@@ -88,6 +88,7 @@ pub enum Query {
USER_SET_WORKSPACE_PRIVACY, USER_SET_WORKSPACE_PRIVACY,
USER_SET_PAD_PRIVACY, USER_SET_PAD_PRIVACY,
RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE, RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE,
RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
RESOURCE_ACCESS_TOKENS_INSERT, RESOURCE_ACCESS_TOKENS_INSERT,
RESOURCE_ACCESS_TOKENS_VALID_COUNT, RESOURCE_ACCESS_TOKENS_VALID_COUNT,
RESOURCE_PERMISSION_DELETE_USER, RESOURCE_PERMISSION_DELETE_USER,
@@ -253,6 +254,8 @@ pub const USER_SET_WORKSPACE_PRIVACY: Query = Query::USER_SET_WORKSPACE_PRIVACY;
pub const USER_SET_PAD_PRIVACY: Query = Query::USER_SET_PAD_PRIVACY; pub const USER_SET_PAD_PRIVACY: Query = Query::USER_SET_PAD_PRIVACY;
pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: Query = pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: Query =
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE; Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE;
pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH: Query =
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH;
pub const RESOURCE_ACCESS_TOKENS_INSERT: Query = Query::RESOURCE_ACCESS_TOKENS_INSERT; pub const RESOURCE_ACCESS_TOKENS_INSERT: Query = Query::RESOURCE_ACCESS_TOKENS_INSERT;
pub const RESOURCE_ACCESS_TOKENS_VALID_COUNT: Query = Query::RESOURCE_ACCESS_TOKENS_VALID_COUNT; pub const RESOURCE_ACCESS_TOKENS_VALID_COUNT: Query = Query::RESOURCE_ACCESS_TOKENS_VALID_COUNT;
pub const RESOURCE_PERMISSION_DELETE_USER: Query = Query::RESOURCE_PERMISSION_DELETE_USER; pub const RESOURCE_PERMISSION_DELETE_USER: Query = Query::RESOURCE_PERMISSION_DELETE_USER;
+3
View File
@@ -200,6 +200,9 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => { Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"# r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"#
} }
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH => {
r#"DELETE FROM resource_access_tokens WHERE token_hash = ?"#
}
Query::RESOURCE_ACCESS_TOKENS_INSERT => { Query::RESOURCE_ACCESS_TOKENS_INSERT => {
r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"# r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"#
} }
+3
View File
@@ -202,6 +202,9 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => { Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
r#"DELETE FROM resource_access_tokens WHERE resource_kind = $1 AND resource_slug = $2"# r#"DELETE FROM resource_access_tokens WHERE resource_kind = $1 AND resource_slug = $2"#
} }
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH => {
r#"DELETE FROM resource_access_tokens WHERE token_hash = $1"#
}
Query::RESOURCE_ACCESS_TOKENS_INSERT => { Query::RESOURCE_ACCESS_TOKENS_INSERT => {
r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES ($1, $2, $3, $4)"# r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES ($1, $2, $3, $4)"#
} }
+3
View File
@@ -200,6 +200,9 @@ pub fn get(query: Query) -> &'static str {
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => { Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"# r#"DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"#
} }
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH => {
r#"DELETE FROM resource_access_tokens WHERE token_hash = ?"#
}
Query::RESOURCE_ACCESS_TOKENS_INSERT => { Query::RESOURCE_ACCESS_TOKENS_INSERT => {
r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"# r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"#
} }
+36 -1
View File
@@ -22,6 +22,9 @@ pub const CSRF_HEADER: &str = "x-rustpad-csrf";
const CSRF_TOKEN_BYTES: usize = 32; const CSRF_TOKEN_BYTES: usize = 32;
const CSRF_TTL_SECONDS: i64 = 24 * 60 * 60; const CSRF_TTL_SECONDS: i64 = 24 * 60 * 60;
const RESOURCE_ACCESS_COOKIE_PREFIX: &str = "__Host-rustpad_access_";
const RESOURCE_ACCESS_COOKIE_SUFFIX_LENGTH: usize = 24;
const RESOURCE_ACCESS_TOKEN_LENGTH: usize = 64;
#[derive(Serialize)] #[derive(Serialize)]
pub struct CsrfResponse { pub struct CsrfResponse {
@@ -50,6 +53,26 @@ pub fn resource_token<'a>(headers: &'a HeaderMap, kind: &str, slug: &str) -> Opt
cookie_value(headers, &name) cookie_value(headers, &name)
} }
pub(crate) fn resource_access_cookies(headers: &HeaderMap) -> Vec<(String, Option<String>)> {
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|cookies| cookies.split(';'))
.filter_map(|part| {
let (name, value) = part.trim().split_once('=')?;
if !valid_resource_access_cookie_name(name) {
return None;
}
let value = value.trim();
let token = (value.len() == RESOURCE_ACCESS_TOKEN_LENGTH
&& value.bytes().all(|byte| byte.is_ascii_hexdigit()))
.then(|| value.to_owned());
Some((name.to_owned(), token))
})
.collect()
}
pub fn share_session_token<'a>(headers: &'a HeaderMap, kind: &str, slug: &str) -> Option<&'a str> { pub fn share_session_token<'a>(headers: &'a HeaderMap, kind: &str, slug: &str) -> Option<&'a str> {
let name = share_session_cookie_name(kind, slug); let name = share_session_cookie_name(kind, slug);
cookie_value(headers, &name) cookie_value(headers, &name)
@@ -63,6 +86,10 @@ pub fn clear_session_cookie() -> HeaderValue {
clear_cookie(SESSION_COOKIE) clear_cookie(SESSION_COOKIE)
} }
pub(crate) fn clear_resource_access_cookie(name: &str) -> Option<HeaderValue> {
valid_resource_access_cookie_name(name).then(|| clear_cookie(name))
}
pub async fn csrf_token_endpoint(headers: HeaderMap) -> Response { pub async fn csrf_token_endpoint(headers: HeaderMap) -> Response {
let token = csrf_cookie_token(&headers) let token = csrf_cookie_token(&headers)
.filter(|value| valid_csrf_token(value)) .filter(|value| valid_csrf_token(value))
@@ -176,7 +203,15 @@ pub fn websocket_origin_allowed(headers: &HeaderMap) -> bool {
fn resource_cookie_name(kind: &str, slug: &str) -> String { fn resource_cookie_name(kind: &str, slug: &str) -> String {
let digest = Sha256::digest(format!("{kind}:{slug}").as_bytes()); let digest = Sha256::digest(format!("{kind}:{slug}").as_bytes());
format!("__Host-rustpad_access_{}", hex::encode(&digest[..12])) format!("{RESOURCE_ACCESS_COOKIE_PREFIX}{}", hex::encode(&digest[..12]))
}
fn valid_resource_access_cookie_name(name: &str) -> bool {
name.strip_prefix(RESOURCE_ACCESS_COOKIE_PREFIX)
.is_some_and(|suffix| {
suffix.len() == RESOURCE_ACCESS_COOKIE_SUFFIX_LENGTH
&& suffix.bytes().all(|byte| byte.is_ascii_hexdigit())
})
} }
fn share_session_cookie_name(kind: &str, slug: &str) -> String { fn share_session_cookie_name(kind: &str, slug: &str) -> String {
+170
View File
@@ -0,0 +1,170 @@
/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
use super::*;
use crate::{
database::Database,
state::AppState,
storage::Storage,
};
use std::sync::Arc;
async fn logout_test_state() -> SharedState {
let db = Database::connect("sqlite::memory:", 1)
.await
.expect("test database");
crate::run_migrations(&db).await.expect("test migrations");
Arc::new(AppState::new(
db,
"test".into(),
Storage::Local {
root: std::env::temp_dir().join("rustpad-logout-tests"),
},
1_000_000,
true,
1_000_000,
0,
None,
None,
true,
false,
false,
"error".into(),
7,
7,
7,
None,
))
}
#[tokio::test]
async fn logout_only_revokes_current_browser_session_and_password_access() {
let state = logout_test_state().await;
sqlx::query(
"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)",
)
.bind("Logout Test")
.bind("logout test")
.bind("logout@example.test")
.bind("logout@example.test")
.bind("password-hash")
.execute(state.db.pool())
.await
.unwrap();
let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339();
for session in ["current-session", "other-device-session"] {
sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, 1, ?)")
.bind(session)
.bind(&expires_at)
.execute(state.db.pool())
.await
.unwrap();
}
let current_access_token = "a".repeat(64);
let other_access_token = "b".repeat(64);
for (token, slug) in [
(current_access_token.as_str(), "current-pad"),
(other_access_token.as_str(), "other-pad"),
] {
sqlx::query(
"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, 'pad', ?, ?)",
)
.bind(hash_token(token))
.bind(slug)
.bind(&expires_at)
.execute(state.db.pool())
.await
.unwrap();
}
let access_cookie = crate::security::resource_cookie(
"pad",
"current-pad",
&current_access_token,
7,
);
let access_cookie_name = access_cookie
.to_str()
.unwrap()
.split_once('=')
.unwrap()
.0
.to_string();
let share_cookie_name = "__Host-rustpad_share_0123456789abcdef01234567";
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
axum::http::HeaderValue::from_str(&format!(
"{}=current-session; {}={}; {}=share-token",
crate::security::SESSION_COOKIE,
access_cookie_name,
current_access_token,
share_cookie_name,
))
.unwrap(),
);
let response = match logout(State(state.clone()), headers).await {
Ok(response) => response,
Err(error) => panic!("logout failed: {}", error.message),
};
let current_session_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE token = ?")
.bind("current-session")
.fetch_one(state.db.pool())
.await
.unwrap();
let other_session_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE token = ?")
.bind("other-device-session")
.fetch_one(state.db.pool())
.await
.unwrap();
let current_access_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?",
)
.bind(hash_token(&current_access_token))
.fetch_one(state.db.pool())
.await
.unwrap();
let other_access_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ?",
)
.bind(hash_token(&other_access_token))
.fetch_one(state.db.pool())
.await
.unwrap();
let user_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE id = 1")
.fetch_one(state.db.pool())
.await
.unwrap();
assert_eq!(current_session_count, 0);
assert_eq!(other_session_count, 1);
assert_eq!(current_access_count, 0);
assert_eq!(other_access_count, 1);
assert_eq!(user_count, 1);
let set_cookies = response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.map(|value| value.to_str().unwrap())
.collect::<Vec<_>>();
assert!(set_cookies.iter().any(|value| {
value.starts_with(&format!("{}=;", crate::security::SESSION_COOKIE))
}));
assert!(set_cookies.iter().any(|value| {
value.starts_with(&format!("{access_cookie_name}=;"))
}));
assert!(!set_cookies.iter().any(|value| value.starts_with(share_cookie_name)));
}
+1
View File
@@ -19,6 +19,7 @@ fn every_backend_has_explicit_queries() {
Q033, Q033,
USER_LIST_WORKSPACES, USER_LIST_WORKSPACES,
USER_LIST_PADS, USER_LIST_PADS,
RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
SHARE_LINK_SESSION_SOURCE, SHARE_LINK_SESSION_SOURCE,
SHARE_SESSION_INSERT, SHARE_SESSION_INSERT,
SHARE_SESSION_PERMISSION, SHARE_SESSION_PERMISSION,
+36
View File
@@ -104,6 +104,42 @@ fn share_sessions_use_separate_scoped_opaque_cookies() {
assert!(value.contains("SameSite=Lax")); assert!(value.contains("SameSite=Lax"));
} }
#[test]
fn logout_cookie_discovery_only_selects_password_access_cookies() {
let valid_name = "__Host-rustpad_access_0123456789abcdef01234567";
let valid_token = "a".repeat(64);
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
HeaderValue::from_str(&format!(
"{SESSION_COOKIE}=session-token; {valid_name}={valid_token}; __Host-rustpad_share_0123456789abcdef01234567=share-token; __Host-rustpad_access_too-short={valid_token}"
))
.unwrap(),
);
assert_eq!(
resource_access_cookies(&headers),
vec![(valid_name.to_string(), Some(valid_token))]
);
}
#[test]
fn malformed_password_access_token_is_cleared_but_not_revoked() {
let name = "__Host-rustpad_access_0123456789abcdef01234567";
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
HeaderValue::from_str(&format!("{name}=not-a-valid-token")).unwrap(),
);
assert_eq!(
resource_access_cookies(&headers),
vec![(name.to_string(), None)]
);
let cleared = clear_resource_access_cookie(name).unwrap();
assert!(cleared.to_str().unwrap().starts_with(&format!("{name}=;")));
}
#[test] #[test]
fn csrf_requires_matching_cookie_and_header() { fn csrf_requires_matching_cookie_and_header() {
let token = "a".repeat(CSRF_TOKEN_BYTES * 2); let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
+7
View File
@@ -19,6 +19,12 @@ const clearAuthSession = sessionStore.clearAuthSession || (() => {
localStorage.removeItem("rustpad:nickname"); localStorage.removeItem("rustpad:nickname");
sessionStorage.removeItem("rustpad:nickname"); sessionStorage.removeItem("rustpad:nickname");
}); });
const clearResourceAccessState = sessionStore.clearResourceAccessState || (() => {
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key?.startsWith("rustpad:access:")) localStorage.removeItem(key);
}
});
function configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth }) { function configureCredentialFields({ emailLabel, email, password, loginMode, externalAuth }) {
const directoryLogin = loginMode && externalAuth; const directoryLogin = loginMode && externalAuth;
@@ -324,6 +330,7 @@ export async function logoutCurrentSession() {
try { try {
await api("/api/auth/logout", { method: "POST" }); await api("/api/auth/logout", { method: "POST" });
} catch { } } catch { }
clearResourceAccessState();
clearAuthSession(); clearAuthSession();
} }
+6
View File
@@ -28,6 +28,12 @@ export function setAccessToken(resourceKind, resourceSlug, granted) {
if (granted) localStorage.setItem(key, "1"); if (granted) localStorage.setItem(key, "1");
else localStorage.removeItem(key); else localStorage.removeItem(key);
} }
export function clearResourceAccessState() {
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key?.startsWith("rustpad:access:")) localStorage.removeItem(key);
}
}
// Remove plaintext passwords saved by the previous frontend version. // Remove plaintext passwords saved by the previous frontend version.
for (let i = localStorage.length - 1; i >= 0; i--) { for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i); const key = localStorage.key(i);