fixes and functions
This commit is contained in:
Generated
+1
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
+60
-12
@@ -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 result = sqlx::query(queries::get(
|
let access_cookies = crate::security::resource_access_cookies(&headers);
|
||||||
state.db.kind(),
|
let has_access_tokens = access_cookies.iter().any(|(_, token)| token.is_some());
|
||||||
queries::AUTH_DELETE_SESSION_BY_TOKEN,
|
let mut revoked_access_tokens = 0_u64;
|
||||||
))
|
|
||||||
.bind(token)
|
if session_token.is_some() || has_access_tokens {
|
||||||
.execute(state.db.pool())
|
let mut tx = state
|
||||||
.await
|
.db
|
||||||
.map_err(AuthError::database)?;
|
.pool()
|
||||||
info!(rows_affected = result.rows_affected(), "logout processed");
|
.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 {
|
} 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();
|
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 (?, ?, ?, ?)"#
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)"#
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user