From 8efe2d19ba653c3623e44b51042bebf863dd65a9 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sat, 1 Nov 2025 22:37:15 +0100 Subject: [PATCH] user_sessions --- .../down.sql | 26 +++++ .../up.sql | 26 +++++ backend/src/models.rs | 8 +- backend/src/routes/auth.rs | 103 +++++++++--------- backend/src/routes/documents.rs | 12 +- backend/src/schema.rs | 8 +- backend/src/tenants.rs | 10 +- backend/tests/auth_flow.rs | 22 ++-- backend/tests/common/mod.rs | 24 ++-- docs/document-model.md | 2 +- 10 files changed, 147 insertions(+), 94 deletions(-) create mode 100644 backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/down.sql create mode 100644 backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/up.sql diff --git a/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/down.sql b/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/down.sql new file mode 100644 index 0000000..5a16912 --- /dev/null +++ b/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/down.sql @@ -0,0 +1,26 @@ +CREATE OR REPLACE FUNCTION shared.current_refresh_token_hash() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.refresh_token_hash', true), '') +$$ LANGUAGE SQL STABLE; + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_refresh_token_hash() IS NOT NULL + AND token_hash = shared.current_refresh_token_hash() + ) + ); + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + WITH CHECK (tenant_id = shared.current_tenant_id()); + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + RENAME TO tenant_refresh_token_policy; + +ALTER INDEX tenant.idx_user_sessions_user_id RENAME TO idx_refresh_tokens_user_id; +ALTER INDEX tenant.idx_user_sessions_token_hash RENAME TO idx_refresh_tokens_token_hash; +ALTER INDEX tenant.user_sessions_tenant_id_idx RENAME TO refresh_tokens_tenant_id_idx; + +ALTER TABLE tenant.user_sessions RENAME TO refresh_tokens; + +DROP FUNCTION IF EXISTS shared.current_user_session_hash(); diff --git a/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/up.sql b/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/up.sql new file mode 100644 index 0000000..a6bb642 --- /dev/null +++ b/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/up.sql @@ -0,0 +1,26 @@ +ALTER TABLE tenant.refresh_tokens RENAME TO user_sessions; + +ALTER INDEX tenant.idx_refresh_tokens_user_id RENAME TO idx_user_sessions_user_id; +ALTER INDEX tenant.idx_refresh_tokens_token_hash RENAME TO idx_user_sessions_token_hash; +ALTER INDEX tenant.refresh_tokens_tenant_id_idx RENAME TO user_sessions_tenant_id_idx; + +ALTER POLICY tenant_refresh_token_policy ON tenant.user_sessions + RENAME TO tenant_user_session_policy; + +CREATE OR REPLACE FUNCTION shared.current_user_session_hash() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.user_session_hash', true), '') +$$ LANGUAGE SQL STABLE; + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_user_session_hash() IS NOT NULL + AND token_hash = shared.current_user_session_hash() + ) + ); + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + WITH CHECK (tenant_id = shared.current_tenant_id()); + +DROP FUNCTION IF EXISTS shared.current_refresh_token_hash(); diff --git a/backend/src/models.rs b/backend/src/models.rs index 3512e8c..7dddb83 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -596,9 +596,9 @@ pub struct NewDocumentCorrespondent { } #[derive(Debug, Clone, Queryable, Identifiable, Associations)] -#[diesel(table_name = refresh_tokens)] +#[diesel(table_name = user_sessions)] #[diesel(belongs_to(User))] -pub struct RefreshToken { +pub struct UserSession { pub id: Uuid, pub user_id: Uuid, pub token_hash: String, @@ -611,8 +611,8 @@ pub struct RefreshToken { } #[derive(Debug, Insertable)] -#[diesel(table_name = refresh_tokens)] -pub struct NewRefreshToken { +#[diesel(table_name = user_sessions)] +pub struct NewUserSession { pub id: Uuid, pub user_id: Uuid, pub token_hash: String, diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 29f6b7b..ef9ee93 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -27,24 +27,25 @@ use crate::{ }, error::{AppError, AppResult}, models::{ - ApiTokenCapability, MagicToken, MagicTokenKind, NewRefreshToken, NewUser, RefreshToken, - TenantStatus, User, + ApiTokenCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, + User, UserSession, }, schema::{ - magic_tokens::dsl as magic_dsl, refresh_tokens, tenants::dsl as tenant_dsl, - user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, users::dsl, + magic_tokens::dsl as magic_dsl, tenants::dsl as tenant_dsl, + user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, user_sessions, + users::dsl, }, state::AppState, tenants::{ - apply_refresh_token_hash, apply_tenant_guc, apply_user_guc, clear_refresh_token_hash, - clear_user_guc, + apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc, + clear_user_session_hash, }, }; -use crate::schema::refresh_tokens::dsl as refresh_dsl; +use crate::schema::user_sessions::dsl as session_dsl; use webauthn_rs::prelude::RegisterPublicKeyCredential; -const REFRESH_COOKIE_NAME: &str = "refresh_token"; +const SESSION_COOKIE_NAME: &str = "refresh_token"; #[derive(Deserialize, ToSchema)] pub struct LoginRequest { @@ -405,34 +406,34 @@ pub async fn refresh( ) -> AppResult { let cookies = jar.ok_or_else(AppError::unauthorized)?; let refresh_value = cookies - .get(REFRESH_COOKIE_NAME) + .get(SESSION_COOKIE_NAME) .ok_or_else(AppError::unauthorized)?; - let hashed = hash_refresh_token(refresh_value); + let hashed = hash_session_token(refresh_value); let mut conn = state.db_unscoped()?; let now = Utc::now(); let now_naive = now.naive_utc(); - apply_refresh_token_hash(&mut conn, &hashed)?; - let token = match refresh_dsl::refresh_tokens - .filter(refresh_dsl::token_hash.eq(&hashed)) - .filter(refresh_dsl::revoked_at.is_null()) - .filter(refresh_dsl::expires_at.gt(now_naive)) - .first::(&mut conn) + apply_user_session_hash(&mut conn, &hashed)?; + let token = match session_dsl::user_sessions + .filter(session_dsl::token_hash.eq(&hashed)) + .filter(session_dsl::revoked_at.is_null()) + .filter(session_dsl::expires_at.gt(now_naive)) + .first::(&mut conn) { Ok(token) => token, Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()), Err(err) => return Err(AppError::from(err)), }; - clear_refresh_token_hash(&mut conn)?; + clear_user_session_hash(&mut conn)?; apply_tenant_guc(&mut conn, token.tenant_id)?; clear_user_guc(&mut conn)?; - diesel::update(refresh_dsl::refresh_tokens.filter(refresh_dsl::id.eq(token.id))) + diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id))) .set(( - refresh_dsl::revoked_at.eq(now_naive), - refresh_dsl::updated_at.eq(now_naive), + session_dsl::revoked_at.eq(now_naive), + session_dsl::updated_at.eq(now_naive), )) .execute(&mut conn)?; @@ -518,17 +519,17 @@ pub async fn logout( let mut rows_affected = 0; if let Some(cookies) = jar { - if let Some(value) = cookies.get(REFRESH_COOKIE_NAME) { - let hashed = hash_refresh_token(value); + if let Some(value) = cookies.get(SESSION_COOKIE_NAME) { + let hashed = hash_session_token(value); rows_affected = diesel::update( - refresh_dsl::refresh_tokens - .filter(refresh_dsl::token_hash.eq(hashed)) - .filter(refresh_dsl::user_id.eq(user.user_id)) - .filter(refresh_dsl::revoked_at.is_null()), + session_dsl::user_sessions + .filter(session_dsl::token_hash.eq(hashed)) + .filter(session_dsl::user_id.eq(user.user_id)) + .filter(session_dsl::revoked_at.is_null()), ) .set(( - refresh_dsl::revoked_at.eq(now), - refresh_dsl::updated_at.eq(now), + session_dsl::revoked_at.eq(now), + session_dsl::updated_at.eq(now), )) .execute(&mut conn) .unwrap_or(0); @@ -537,19 +538,19 @@ pub async fn logout( if rows_affected == 0 { let _ = diesel::update( - refresh_dsl::refresh_tokens - .filter(refresh_dsl::user_id.eq(user.user_id)) - .filter(refresh_dsl::revoked_at.is_null()), + session_dsl::user_sessions + .filter(session_dsl::user_id.eq(user.user_id)) + .filter(session_dsl::revoked_at.is_null()), ) .set(( - refresh_dsl::revoked_at.eq(now), - refresh_dsl::updated_at.eq(now), + session_dsl::revoked_at.eq(now), + session_dsl::updated_at.eq(now), )) .execute(&mut conn); } let mut headers = HeaderMap::new(); - headers.insert(SET_COOKIE, build_clear_refresh_cookie(&state)); + headers.insert(SET_COOKIE, build_clear_session_cookie(&state)); Ok((headers, StatusCode::NO_CONTENT)) } @@ -853,7 +854,7 @@ fn issue_session( ) -> AppResult { apply_tenant_guc(conn, tenant_id)?; clear_user_guc(conn)?; - clear_refresh_token_hash(conn)?; + clear_user_session_hash(conn)?; let now = Utc::now(); let access_token = state @@ -867,21 +868,21 @@ fn issue_session( .first(conn) .map_err(AppError::from)?; - let refresh_value = generate_refresh_token(); - let refresh_hash = hash_refresh_token(&refresh_value); + let session_value = generate_session_token(); + let session_hash = hash_session_token(&session_value); let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); - let new_refresh = NewRefreshToken { + let new_session = NewUserSession { id: Uuid::new_v4(), user_id: user.id, - token_hash: refresh_hash, + token_hash: session_hash, issued_at: now.naive_utc(), expires_at: refresh_expires_at.naive_utc(), tenant_id, }; - diesel::insert_into(refresh_tokens::table) - .values(&new_refresh) + diesel::insert_into(user_sessions::table) + .values(&new_session) .execute(conn)?; let mut response = Json(LoginResponse { @@ -897,36 +898,36 @@ fn issue_session( response.headers_mut().insert( SET_COOKIE, - build_refresh_cookie(state, &refresh_value, refresh_expires_at), + build_session_cookie(state, &session_value, refresh_expires_at), ); Ok(response) } -fn hash_refresh_token(token: &str) -> String { +fn hash_session_token(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); hex::encode(hasher.finalize()) } fn hash_magic_token(token: &str) -> String { - hash_refresh_token(token) + hash_session_token(token) } -fn generate_refresh_token() -> String { +fn generate_session_token() -> String { let mut bytes = [0u8; 32]; OsRng.fill_bytes(&mut bytes); hex::encode(bytes) } -fn build_refresh_cookie( +fn build_session_cookie( state: &AppState, token: &str, expires_at: chrono::DateTime, ) -> HeaderValue { let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds(); - let mut parts = vec![format!("{}={}", REFRESH_COOKIE_NAME, token)]; + let mut parts = vec![format!("{}={}", SESSION_COOKIE_NAME, token)]; parts.push("Path=/".into()); parts.push("HttpOnly".into()); parts.push("SameSite=Strict".into()); @@ -939,11 +940,11 @@ fn build_refresh_cookie( parts.push(format!("Domain={}", domain)); } - HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie") + HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie") } -fn build_clear_refresh_cookie(state: &AppState) -> HeaderValue { - let mut parts = vec![format!("{}=", REFRESH_COOKIE_NAME)]; +fn build_clear_session_cookie(state: &AppState) -> HeaderValue { + let mut parts = vec![format!("{}=", SESSION_COOKIE_NAME)]; parts.push("Path=/".into()); parts.push("HttpOnly".into()); parts.push("SameSite=Strict".into()); @@ -956,5 +957,5 @@ fn build_clear_refresh_cookie(state: &AppState) -> HeaderValue { parts.push(format!("Domain={}", domain)); } - HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie") + HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie") } diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index c3caa9d..e45d255 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -42,7 +42,7 @@ use crate::models::{ }; use crate::schema::{ document_asset_objects, document_assets, document_correspondents, document_tags, - document_versions, documents, folders, refresh_tokens::dsl as refresh_dsl, tags, + document_versions, documents, folders, tags, user_sessions::dsl as session_dsl, }; use crate::state::AppState; use crate::utils::{ @@ -1251,11 +1251,11 @@ pub async fn download_with_token( let now = Utc::now().naive_utc(); let has_active_refresh: bool = select(exists( - refresh_dsl::refresh_tokens - .filter(refresh_dsl::user_id.eq(claims.user_id)) - .filter(refresh_dsl::tenant_id.eq(claims.tenant_id)) - .filter(refresh_dsl::revoked_at.is_null()) - .filter(refresh_dsl::expires_at.gt(now)), + session_dsl::user_sessions + .filter(session_dsl::user_id.eq(claims.user_id)) + .filter(session_dsl::tenant_id.eq(claims.tenant_id)) + .filter(session_dsl::revoked_at.is_null()) + .filter(session_dsl::expires_at.gt(now)), )) .get_result(&mut conn)?; diff --git a/backend/src/schema.rs b/backend/src/schema.rs index dff3a24..cd8d215 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -155,7 +155,7 @@ diesel::table! { } diesel::table! { - refresh_tokens (id) { + user_sessions (id) { id -> Uuid, user_id -> Uuid, token_hash -> Text, @@ -286,8 +286,8 @@ diesel::joinable!(documents -> folders (folder_id)); diesel::joinable!(documents -> tenants (tenant_id)); diesel::joinable!(folders -> tenants (tenant_id)); diesel::joinable!(jobs -> tenants (tenant_id)); -diesel::joinable!(refresh_tokens -> tenants (tenant_id)); -diesel::joinable!(refresh_tokens -> users (user_id)); +diesel::joinable!(user_sessions -> tenants (tenant_id)); +diesel::joinable!(user_sessions -> users (user_id)); diesel::joinable!(tags -> tenants (tenant_id)); diesel::joinable!(user_memberships -> tenants (tenant_id)); diesel::joinable!(user_memberships -> users (user_id)); @@ -307,7 +307,7 @@ diesel::allow_tables_to_appear_in_same_query!( folders, jobs, magic_tokens, - refresh_tokens, + user_sessions, tags, tenants, user_memberships, diff --git a/backend/src/tenants.rs b/backend/src/tenants.rs index 08e60a4..baaded7 100644 --- a/backend/src/tenants.rs +++ b/backend/src/tenants.rs @@ -148,7 +148,7 @@ pub fn clear_tenant_context(conn: &mut PgConnection) -> AppResult<()> { "SELECT \ set_config('papercrate.tenant_id', '', false), \ set_config('papercrate.user_id', '', false), \ - set_config('papercrate.refresh_token_hash', '', false), \ + set_config('papercrate.user_session_hash', '', false), \ set_config('papercrate.api_token_prefix', '', false)", ) .execute(conn) @@ -163,16 +163,16 @@ pub fn clear_user_guc(conn: &mut PgConnection) -> AppResult<()> { .map_err(AppError::from) } -pub fn apply_refresh_token_hash(conn: &mut PgConnection, hash: &str) -> AppResult<()> { - diesel::sql_query("SELECT set_config('papercrate.refresh_token_hash', $1, false)") +pub fn apply_user_session_hash(conn: &mut PgConnection, hash: &str) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.user_session_hash', $1, false)") .bind::(hash) .execute(conn) .map(|_| ()) .map_err(AppError::from) } -pub fn clear_refresh_token_hash(conn: &mut PgConnection) -> AppResult<()> { - diesel::sql_query("SELECT set_config('papercrate.refresh_token_hash', '', false)") +pub fn clear_user_session_hash(conn: &mut PgConnection) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.user_session_hash', '', false)") .execute(conn) .map(|_| ()) .map_err(AppError::from) diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs index 089f017..8fdad44 100644 --- a/backend/tests/auth_flow.rs +++ b/backend/tests/auth_flow.rs @@ -9,9 +9,9 @@ use papercrate::auth::passkeys::{ PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, RegistrationChallengeResponse, }; -use papercrate::models::{NewRefreshToken, NewUserMembership, TenantStatus, UserPasskey}; +use papercrate::models::{NewUserMembership, NewUserSession, TenantStatus, UserPasskey}; use papercrate::openapi::schemas::PasskeySummary; -use papercrate::schema::{refresh_tokens, tenants, user_memberships, users}; +use papercrate::schema::{tenants, user_memberships, user_sessions, users}; use rand::rngs::OsRng; use rand::RngCore; use serde::Deserialize; @@ -598,21 +598,21 @@ async fn login_with_session( .generate_token(user.id, tenant.id, &user.username) .map_err(|err| anyhow!(err))?; - let refresh_value = generate_refresh_token(); - let refresh_hash = hash_refresh_token(&refresh_value); + let session_value = generate_session_token(); + let session_hash = hash_session_token(&session_value); let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); - let new_refresh = NewRefreshToken { + let new_session = NewUserSession { id: Uuid::new_v4(), user_id: user.id, - token_hash: refresh_hash, + token_hash: session_hash, issued_at: now.naive_utc(), expires_at: refresh_expires_at.naive_utc(), tenant_id: tenant.id, }; - diesel::insert_into(refresh_tokens::table) - .values(&new_refresh) + diesel::insert_into(user_sessions::table) + .values(&new_session) .execute(conn)?; let login = LoginResponse { @@ -623,7 +623,7 @@ async fn login_with_session( }, }; - let cookie = format!("refresh_token={refresh_value}"); + let cookie = format!("refresh_token={session_value}"); Ok((login, cookie)) }) .await @@ -643,13 +643,13 @@ fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result { Ok(cookie) } -fn generate_refresh_token() -> String { +fn generate_session_token() -> String { let mut bytes = [0u8; 32]; OsRng.fill_bytes(&mut bytes); hex::encode(bytes) } -fn hash_refresh_token(value: &str) -> String { +fn hash_session_token(value: &str) -> String { let mut hasher = Sha256::new(); hasher.update(value.as_bytes()); hex::encode(hasher.finalize()) diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index 7b0d67e..4e106c0 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -20,11 +20,11 @@ use papercrate::auth::jwt::JwtService; use papercrate::config::AppConfig; use papercrate::db::{self, PgPool}; use papercrate::models::{ - Job, NewRefreshToken, NewUser, NewUserMembership, NewUserPasskey, Tenant, TenantStatus, User, + Job, NewUser, NewUserMembership, NewUserPasskey, NewUserSession, Tenant, TenantStatus, User, UserMembership, }; use papercrate::routes; -use papercrate::schema::refresh_tokens::dsl as refresh_dsl; +use papercrate::schema::user_sessions::dsl as session_dsl; use papercrate::state::AppState; use papercrate::storage::ObjectStorage; use rand::rngs::OsRng; @@ -359,25 +359,25 @@ impl TestApp { .generate_token(user.id, tenant.id, &user.username) .map_err(|err| anyhow!(err))?; - let refresh_value = generate_refresh_token(); - let refresh_hash = hash_refresh_token(&refresh_value); + let session_value = generate_session_token(); + let session_hash = hash_session_token(&session_value); let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); - let new_refresh = NewRefreshToken { + let new_session = NewUserSession { id: Uuid::new_v4(), user_id: user.id, - token_hash: refresh_hash, + token_hash: session_hash, issued_at: now.naive_utc(), expires_at: refresh_expires_at.naive_utc(), tenant_id: tenant.id, }; - diesel::insert_into(refresh_dsl::refresh_tokens) - .values(&new_refresh) + diesel::insert_into(session_dsl::user_sessions) + .values(&new_session) .execute(conn)?; - let cookie = format!("refresh_token={refresh_value}"); + let cookie = format!("refresh_token={session_value}"); Ok((access_token, cookie, tenant.id)) }) .await @@ -787,7 +787,7 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> { tenant.documents, \ tenant.folders, \ shared.jobs, \ - tenant.refresh_tokens, \ + tenant.user_sessions, \ tenant.tags, \ tenant.api_tokens, \ shared.webauthn_challenges, \ @@ -802,13 +802,13 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> { Ok(()) } -fn generate_refresh_token() -> String { +fn generate_session_token() -> String { let mut bytes = [0u8; 32]; OsRng.fill_bytes(&mut bytes); hex::encode(bytes) } -fn hash_refresh_token(value: &str) -> String { +fn hash_session_token(value: &str) -> String { let mut hasher = Sha256::new(); hasher.update(value.as_bytes()); hex::encode(hasher.finalize()) diff --git a/docs/document-model.md b/docs/document-model.md index 45f182c..4bfc2a5 100644 --- a/docs/document-model.md +++ b/docs/document-model.md @@ -75,7 +75,7 @@ objects under a single logical asset. - `document_tags` and `document_correspondents` provide many-to-many relationships for categorisation. - `jobs` records background work (OCR, thumbnails, indexing) keyed by tenant. -- `webdav_tokens`, `refresh_tokens`, and `user_passkeys` live alongside but do +- `api_tokens`, `user_sessions`, and `user_passkeys` live alongside but do not alter the document schema directly. ## Lifecycle summary