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
+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)