user_sessions

This commit is contained in:
2025-11-01 22:45:46 +01:00
parent 1c5b920e13
commit 8efe2d19ba
10 changed files with 147 additions and 94 deletions
@@ -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();
@@ -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();
+4 -4
View File
@@ -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,
+52 -51
View File
@@ -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<Response> {
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::<RefreshToken>(&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::<UserSession>(&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<Response> {
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<Utc>,
) -> 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")
}
+6 -6
View File
@@ -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)?;
+4 -4
View File
@@ -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,
+5 -5
View File
@@ -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::<Text, _>(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)
+11 -11
View File
@@ -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<String> {
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())
+12 -12
View File
@@ -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())
+1 -1
View File
@@ -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