fixes and functions
This commit is contained in:
Generated
+1
-1
@@ -2581,7 +2581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustpad"
|
||||
version = "0.2.43"
|
||||
version = "0.2.44"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"aws-config",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustpad"
|
||||
version = "0.2.43"
|
||||
version = "0.2.44"
|
||||
edition = "2024"
|
||||
rust-version = "1.94"
|
||||
description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL"
|
||||
|
||||
+60
-12
@@ -2218,23 +2218,67 @@ pub async fn logout(
|
||||
State(state): State<SharedState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AuthError> {
|
||||
if let Some(token) = crate::security::session_token(&headers) {
|
||||
let result = sqlx::query(queries::get(
|
||||
state.db.kind(),
|
||||
queries::AUTH_DELETE_SESSION_BY_TOKEN,
|
||||
))
|
||||
.bind(token)
|
||||
.execute(state.db.pool())
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
info!(rows_affected = result.rows_affected(), "logout processed");
|
||||
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(
|
||||
state.db.kind(),
|
||||
queries::AUTH_DELETE_SESSION_BY_TOKEN,
|
||||
))
|
||||
.bind(token)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(AuthError::database)?;
|
||||
info!(rows_affected = result.rows_affected(), "logout processed");
|
||||
} else {
|
||||
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");
|
||||
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();
|
||||
response
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -3009,3 +3053,7 @@ impl axum::response::IntoResponse for AuthError {
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../tests/auth.rs"]
|
||||
mod logout_tests;
|
||||
|
||||
@@ -88,6 +88,7 @@ pub enum Query {
|
||||
USER_SET_WORKSPACE_PRIVACY,
|
||||
USER_SET_PAD_PRIVACY,
|
||||
RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE,
|
||||
RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
|
||||
RESOURCE_ACCESS_TOKENS_INSERT,
|
||||
RESOURCE_ACCESS_TOKENS_VALID_COUNT,
|
||||
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 RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: Query =
|
||||
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_VALID_COUNT: Query = Query::RESOURCE_ACCESS_TOKENS_VALID_COUNT;
|
||||
pub const RESOURCE_PERMISSION_DELETE_USER: Query = Query::RESOURCE_PERMISSION_DELETE_USER;
|
||||
|
||||
@@ -200,6 +200,9 @@ pub fn get(query: Query) -> &'static str {
|
||||
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
|
||||
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 => {
|
||||
r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"#
|
||||
}
|
||||
|
||||
@@ -202,6 +202,9 @@ pub fn get(query: Query) -> &'static str {
|
||||
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
|
||||
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 => {
|
||||
r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES ($1, $2, $3, $4)"#
|
||||
}
|
||||
|
||||
@@ -200,6 +200,9 @@ pub fn get(query: Query) -> &'static str {
|
||||
Query::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE => {
|
||||
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 => {
|
||||
r#"INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"#
|
||||
}
|
||||
|
||||
+36
-1
@@ -22,6 +22,9 @@ pub const CSRF_HEADER: &str = "x-rustpad-csrf";
|
||||
|
||||
const CSRF_TOKEN_BYTES: usize = 32;
|
||||
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)]
|
||||
pub struct CsrfResponse {
|
||||
@@ -50,6 +53,26 @@ pub fn resource_token<'a>(headers: &'a HeaderMap, kind: &str, slug: &str) -> Opt
|
||||
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> {
|
||||
let name = share_session_cookie_name(kind, slug);
|
||||
cookie_value(headers, &name)
|
||||
@@ -63,6 +86,10 @@ pub fn clear_session_cookie() -> HeaderValue {
|
||||
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 {
|
||||
let token = csrf_cookie_token(&headers)
|
||||
.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 {
|
||||
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 {
|
||||
|
||||
@@ -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",
|
||||
¤t_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(¤t_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)));
|
||||
}
|
||||
@@ -19,6 +19,7 @@ fn every_backend_has_explicit_queries() {
|
||||
Q033,
|
||||
USER_LIST_WORKSPACES,
|
||||
USER_LIST_PADS,
|
||||
RESOURCE_ACCESS_TOKENS_DELETE_BY_TOKEN_HASH,
|
||||
SHARE_LINK_SESSION_SOURCE,
|
||||
SHARE_SESSION_INSERT,
|
||||
SHARE_SESSION_PERMISSION,
|
||||
|
||||
@@ -104,6 +104,42 @@ fn share_sessions_use_separate_scoped_opaque_cookies() {
|
||||
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]
|
||||
fn csrf_requires_matching_cookie_and_header() {
|
||||
let token = "a".repeat(CSRF_TOKEN_BYTES * 2);
|
||||
|
||||
@@ -19,6 +19,12 @@ const clearAuthSession = sessionStore.clearAuthSession || (() => {
|
||||
localStorage.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 }) {
|
||||
const directoryLogin = loginMode && externalAuth;
|
||||
@@ -324,6 +330,7 @@ export async function logoutCurrentSession() {
|
||||
try {
|
||||
await api("/api/auth/logout", { method: "POST" });
|
||||
} catch { }
|
||||
clearResourceAccessState();
|
||||
clearAuthSession();
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ export function setAccessToken(resourceKind, resourceSlug, granted) {
|
||||
if (granted) localStorage.setItem(key, "1");
|
||||
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.
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const key = localStorage.key(i);
|
||||
|
||||
Reference in New Issue
Block a user