This commit is contained in:
Mateusz Gruszczyński committed 2026-10-26 15:47:16 +01:00
1 parent f508b7c882
commit 25d04daaee
12 files changed
+323 -90

No files matched your search

+1
View File
@@ -107,6 +107,7 @@ pub fn router(
post(auth::resend_confirmation),
)
.route("/api/auth/me", get(auth::me))
.route("/api/auth/session", get(auth::session))
.route("/api/auth/profile", post(auth::update_profile))
.route(
"/api/auth/account/delete",
+79 -18
View File
@@ -374,6 +374,11 @@ pub struct SessionResponse {
language: String,
}
#[derive(Serialize)]
struct SessionStatusResponse {
authenticated: bool,
session: Option<SessionResponse>,
}
#[derive(Serialize)]
pub struct IdentityResponse {
nickname: String,
registered: bool,
@@ -808,6 +813,30 @@ fn suggested_directory_nickname(display_name: Option<&str>, email: &str) -> Opti
.and_then(|value| validate_nickname(&value).ok())
}
/// Check whether a visitor has an active account session without treating guests as errors.
pub async fn session(
State(state): State<SharedState>,
headers: HeaderMap,
) -> Result<Response, AuthError> {
let Some(token) = crate::security::session_token(&headers) else {
return Ok(session_status_response(None, state.user_session_ttl_days));
};
let Some(user) = user_from_token(&state, token).await? else {
let mut response = session_status_response(None, state.user_session_ttl_days);
response
.headers_mut()
.insert(header::SET_COOKIE, crate::security::clear_session_cookie());
return Ok(response);
};
debug!(user_id = user.id, "session validation successful");
let details = session_details(&state, token, user).await?;
Ok(session_status_response(
Some(details),
state.user_session_ttl_days,
))
}
/// Preserve the existing protected `/api/auth/me` API contract (401 for guests).
pub async fn me(
State(state): State<SharedState>,
headers: HeaderMap,
@@ -822,6 +851,19 @@ pub async fn me(
return Ok(response);
};
debug!(user_id = user.id, "session validation successful");
let details = session_details(&state, token, user).await?;
Ok(session_json_response(
StatusCode::OK,
details,
state.user_session_ttl_days,
))
}
async fn session_details(
state: &SharedState,
token: &str,
user: User,
) -> Result<SessionResponse, AuthError> {
let expires_at: String = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::AUTH_SESSION_EXPIRES_AT,
@@ -831,7 +873,7 @@ pub async fn me(
.await
.map_err(AuthError::database)?;
let (directory_managed, directory_display_name, directory_organization, suggested_nickname) =
directory_profile_metadata(&state, &user).await?;
directory_profile_metadata(state, &user).await?;
let editor_color: Option<String> = sqlx::query_scalar(queries::get(
state.db.kind(),
queries::AUTH_EDITOR_COLOR_BY_USER,
@@ -840,23 +882,19 @@ pub async fn me(
.fetch_one(state.db.pool())
.await
.map_err(AuthError::database)?;
Ok(session_json_response(
StatusCode::OK,
SessionResponse {
token: token.into(),
nickname: user.nickname,
email: user.email,
expires_at,
directory_managed,
directory_display_name,
directory_organization,
suggested_nickname,
editor_color,
theme: user.theme,
language: user.language,
},
state.user_session_ttl_days,
))
Ok(SessionResponse {
token: token.into(),
nickname: user.nickname,
email: user.email,
expires_at,
directory_managed,
directory_display_name,
directory_organization,
suggested_nickname,
editor_color,
theme: user.theme,
language: user.language,
})
}
pub async fn update_profile(
@@ -3135,6 +3173,29 @@ fn session_json_response(status: StatusCode, session: SessionResponse, ttl_days:
let cookie = crate::security::session_cookie(&session.token, ttl_days);
let mut response = (status, Json(session)).into_response();
response.headers_mut().insert(header::SET_COOKIE, cookie);
response.headers_mut().insert(
header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-store"),
);
response
}
fn session_status_response(session: Option<SessionResponse>, ttl_days: i64) -> Response {
let cookie = session
.as_ref()
.map(|details| crate::security::session_cookie(&details.token, ttl_days));
let mut response = Json(SessionStatusResponse {
authenticated: session.is_some(),
session,
})
.into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-store"),
);
if let Some(cookie) = cookie {
response.headers_mut().insert(header::SET_COOKIE, cookie);
}
response
}
+2
View File
@@ -38,6 +38,7 @@ fn classifies_other_routes_as_application() {
for path in [
"/",
"/api/auth/me",
"/api/auth/session",
"/static/missing.css",
"/files/legacy/image.png",
"/unknown",
@@ -163,6 +164,7 @@ async fn file_listener_never_exposes_application_routes() {
for path in [
"/",
"/api/auth/me",
"/api/auth/session",
"/assets/css/styles.css",
"/f",
"/robots.txt",
+170 -32
View File
@@ -8,11 +8,7 @@
*/
use super::*;
use crate::{
database::Database,
state::AppState,
storage::Storage,
};
use crate::{database::Database, state::AppState, storage::Storage};
use std::sync::Arc;
async fn logout_test_state() -> SharedState {
@@ -85,12 +81,8 @@ async fn logout_only_revokes_current_browser_session_and_password_access() {
.unwrap();
}
let access_cookie = crate::security::resource_cookie(
"pad",
"current-pad",
&current_access_token,
7,
);
let access_cookie =
crate::security::resource_cookie("pad", "current-pad", &current_access_token, 7);
let access_cookie_name = access_cookie
.to_str()
.unwrap()
@@ -129,20 +121,18 @@ async fn logout_only_revokes_current_browser_session_and_password_access() {
.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 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
@@ -160,11 +150,159 @@ async fn logout_only_revokes_current_browser_session_and_password_access() {
.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)));
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))
);
}
fn account_cookie_headers(token: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
axum::http::HeaderValue::from_str(&format!("{}={token}", crate::security::SESSION_COOKIE))
.unwrap(),
);
headers
}
async fn auth_response_body(response: Response) -> serde_json::Value {
let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn anonymous_session_status_is_ok_without_creating_a_cookie() {
let state = logout_test_state().await;
let response = session(State(state.clone()), HeaderMap::new())
.await
.unwrap_or_else(|error| panic!("session failed: {}", error.message));
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
assert!(!response.headers().contains_key(header::SET_COOKIE));
assert_eq!(
auth_response_body(response).await,
serde_json::json!({"authenticated": false, "session": null})
);
// The older `/api/auth/me` contract remains protected.
let error = me(State(state), HeaderMap::new()).await.err().unwrap();
assert_eq!(error.status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn valid_session_status_returns_profile_and_refreshes_cookie() {
let state = logout_test_state().await;
sqlx::query(
"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)",
)
.bind("Session Test")
.bind("session test")
.bind("session@example.test")
.bind("session@example.test")
.bind("password-hash")
.execute(state.db.pool())
.await
.unwrap();
sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, 1, ?)")
.bind("valid-session")
.bind((Utc::now() + Duration::days(7)).to_rfc3339())
.execute(state.db.pool())
.await
.unwrap();
let headers = account_cookie_headers("valid-session");
let response = session(State(state.clone()), headers.clone())
.await
.unwrap_or_else(|error| panic!("session failed: {}", error.message));
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
assert!(
response.headers()[header::SET_COOKIE]
.to_str()
.unwrap()
.starts_with("__Host-rustpad_session=valid-session;")
);
let body = auth_response_body(response).await;
assert_eq!(body["authenticated"], true);
assert_eq!(body["session"]["nickname"], "Session Test");
assert_eq!(body["session"]["email"], "session@example.test");
assert!(body["session"].get("token").is_none());
let me_response = me(State(state), headers)
.await
.unwrap_or_else(|error| panic!("me failed: {}", error.message));
assert_eq!(me_response.status(), StatusCode::OK);
let body = auth_response_body(me_response).await;
assert_eq!(body["nickname"], "Session Test");
assert!(body.get("authenticated").is_none());
}
#[tokio::test]
async fn missing_or_expired_session_returns_guest_and_clears_stale_cookie() {
let state = logout_test_state().await;
sqlx::query(
"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)",
)
.bind("Expired Test")
.bind("expired test")
.bind("expired@example.test")
.bind("expired@example.test")
.bind("password-hash")
.execute(state.db.pool())
.await
.unwrap();
sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, 1, ?)")
.bind("expired-session")
.bind((Utc::now() - Duration::days(1)).to_rfc3339())
.execute(state.db.pool())
.await
.unwrap();
for token in ["expired-session", "unknown-session"] {
let response = session(State(state.clone()), account_cookie_headers(token))
.await
.unwrap_or_else(|error| panic!("session failed: {}", error.message));
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
assert!(
response.headers()[header::SET_COOKIE]
.to_str()
.unwrap()
.starts_with("__Host-rustpad_session=;")
);
assert_eq!(
auth_response_body(response).await,
serde_json::json!({"authenticated": false, "session": null})
);
}
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions")
.fetch_one(state.db.pool())
.await
.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn session_check_propagates_database_errors_instead_of_reporting_guest() {
let state = logout_test_state().await;
state.db.pool().close().await;
let error = session(State(state), account_cookie_headers("valid-session"))
.await
.err()
.unwrap();
assert_eq!(error.status, StatusCode::INTERNAL_SERVER_ERROR);
}