From a21853c874a6cff8df95774e95281268477d6e51 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sat, 11 Oct 2025 13:12:05 +0200 Subject: [PATCH] preview stack, refresh token, design --- .../down.sql | 1 + .../202503090004_create_refresh_tokens/up.sql | 13 ++ backend/src/config.rs | 17 ++ backend/src/models.rs | 24 ++ backend/src/routes/auth.rs | 214 +++++++++++++++++- backend/src/routes/mod.rs | 35 ++- backend/src/schema.rs | 15 ++ frontend/src/index.jsx | 78 ++----- frontend/src/styles.css | 5 +- 9 files changed, 320 insertions(+), 82 deletions(-) create mode 100644 backend/migrations/202503090004_create_refresh_tokens/down.sql create mode 100644 backend/migrations/202503090004_create_refresh_tokens/up.sql diff --git a/backend/migrations/202503090004_create_refresh_tokens/down.sql b/backend/migrations/202503090004_create_refresh_tokens/down.sql new file mode 100644 index 0000000..7f0336a --- /dev/null +++ b/backend/migrations/202503090004_create_refresh_tokens/down.sql @@ -0,0 +1 @@ +DROP TABLE refresh_tokens; diff --git a/backend/migrations/202503090004_create_refresh_tokens/up.sql b/backend/migrations/202503090004_create_refresh_tokens/up.sql new file mode 100644 index 0000000..0ad106d --- /dev/null +++ b/backend/migrations/202503090004_create_refresh_tokens/up.sql @@ -0,0 +1,13 @@ +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); diff --git a/backend/src/config.rs b/backend/src/config.rs index 0d238b2..d4a72fc 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -11,6 +11,10 @@ pub struct AppConfig { pub jwt_issuer: String, pub jwt_audience: String, pub jwt_expiry_minutes: i64, + pub refresh_token_expiry_days: i64, + pub refresh_cookie_secure: bool, + pub refresh_cookie_domain: Option, + pub cors_allowed_origin: Option, pub aws_endpoint_url: Option, pub aws_access_key_id: Option, pub aws_secret_access_key: Option, @@ -34,6 +38,15 @@ impl AppConfig { .unwrap_or_else(|_| "60".to_string()) .parse() .context("JWT_EXPIRY_MINUTES must be an integer")?; + let refresh_token_expiry_days = env::var("REFRESH_TOKEN_EXPIRY_DAYS") + .unwrap_or_else(|_| "30".to_string()) + .parse() + .context("REFRESH_TOKEN_EXPIRY_DAYS must be an integer")?; + let refresh_cookie_secure = env::var("REFRESH_COOKIE_SECURE") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + let refresh_cookie_domain = env::var("REFRESH_COOKIE_DOMAIN").ok(); + let cors_allowed_origin = env::var("CORS_ALLOWED_ORIGIN").ok(); let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok(); let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok(); let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok(); @@ -48,6 +61,10 @@ impl AppConfig { jwt_issuer, jwt_audience, jwt_expiry_minutes, + refresh_token_expiry_days, + refresh_cookie_secure, + refresh_cookie_domain, + cors_allowed_origin, aws_endpoint_url, aws_access_key_id, aws_secret_access_key, diff --git a/backend/src/models.rs b/backend/src/models.rs index b9d2a34..ec0202e 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -191,3 +191,27 @@ pub struct NewDocumentTag { pub tag_id: Uuid, pub assigned_by: Option, } + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = refresh_tokens)] +#[diesel(belongs_to(User))] +pub struct RefreshToken { + pub id: Uuid, + pub user_id: Uuid, + pub token_hash: String, + pub issued_at: NaiveDateTime, + pub expires_at: NaiveDateTime, + pub revoked_at: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = refresh_tokens)] +pub struct NewRefreshToken { + pub id: Uuid, + pub user_id: Uuid, + pub token_hash: String, + pub issued_at: NaiveDateTime, + pub expires_at: NaiveDateTime, +} diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index cb86e84..90f3c28 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -1,15 +1,28 @@ -use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use axum::{ + extract::State, + http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}, + Json, +}; +use axum_extra::{headers::Cookie, typed_header::TypedHeader}; +use chrono::{Duration as ChronoDuration, Utc}; use diesel::prelude::*; +use rand::{rngs::OsRng, RngCore}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; use crate::{ auth::{password, AuthenticatedUser}, error::{AppError, AppResult}, - models::User, - schema::users::dsl, + models::{NewRefreshToken, RefreshToken, User}, + schema::{refresh_tokens, users::dsl}, state::AppState, }; +use crate::schema::refresh_tokens::dsl as refresh_dsl; + +const REFRESH_COOKIE_NAME: &str = "refresh_token"; + #[derive(Deserialize)] pub struct LoginRequest { pub username: String, @@ -26,7 +39,7 @@ pub struct LoginResponse { pub async fn login( State(state): State, Json(payload): Json, -) -> AppResult> { +) -> AppResult<(HeaderMap, Json)> { let mut conn = state.db()?; let user: User = dsl::users @@ -40,22 +53,199 @@ pub async fn login( return Err(AppError::unauthorized()); } - let token = state + let access_token = state .jwt .generate_token(user.id, &user.username, &user.role) .map_err(AppError::from)?; - Ok(Json(LoginResponse { - access_token: token, - token_type: "Bearer".to_string(), - expires_in: state.config.jwt_expiry_minutes * 60, - })) + let now = Utc::now(); + let refresh_value = generate_refresh_token(); + let refresh_hash = hash_refresh_token(&refresh_value); + let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); + + let new_refresh = NewRefreshToken { + id: Uuid::new_v4(), + user_id: user.id, + token_hash: refresh_hash, + issued_at: now.naive_utc(), + expires_at: refresh_expires_at.naive_utc(), + }; + + diesel::insert_into(refresh_tokens::table) + .values(&new_refresh) + .execute(&mut conn)?; + + let mut headers = HeaderMap::new(); + headers.insert( + SET_COOKIE, + build_refresh_cookie(&state, &refresh_value, refresh_expires_at), + ); + + Ok(( + headers, + Json(LoginResponse { + access_token, + token_type: "Bearer".to_string(), + expires_in: state.config.jwt_expiry_minutes * 60, + }), + )) } -pub async fn logout(_user: AuthenticatedUser) -> impl IntoResponse { - StatusCode::NO_CONTENT +pub async fn refresh( + State(state): State, + jar: Option>, +) -> AppResult<(HeaderMap, Json)> { + let cookies = jar.ok_or_else(AppError::unauthorized)?; + let refresh_value = cookies + .get(REFRESH_COOKIE_NAME) + .ok_or_else(AppError::unauthorized)?; + + let hashed = hash_refresh_token(refresh_value); + let mut conn = state.db()?; + let now = Utc::now(); + let now_naive = now.naive_utc(); + + 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) + { + Ok(token) => token, + Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()), + Err(err) => return Err(AppError::from(err)), + }; + + diesel::update(refresh_dsl::refresh_tokens.filter(refresh_dsl::id.eq(token.id))) + .set(( + refresh_dsl::revoked_at.eq(now_naive), + refresh_dsl::updated_at.eq(now_naive), + )) + .execute(&mut conn)?; + + let user: User = dsl::users + .find(token.user_id) + .first(&mut conn) + .map_err(AppError::from)?; + + let access_token = state + .jwt + .generate_token(user.id, &user.username, &user.role) + .map_err(AppError::from)?; + + let new_refresh_value = generate_refresh_token(); + let new_refresh_hash = hash_refresh_token(&new_refresh_value); + let new_refresh_expires = now + ChronoDuration::days(state.config.refresh_token_expiry_days); + + let new_refresh = NewRefreshToken { + id: Uuid::new_v4(), + user_id: user.id, + token_hash: new_refresh_hash, + issued_at: now_naive, + expires_at: new_refresh_expires.naive_utc(), + }; + + diesel::insert_into(refresh_tokens::table) + .values(&new_refresh) + .execute(&mut conn)?; + + let mut headers = HeaderMap::new(); + headers.insert( + SET_COOKIE, + build_refresh_cookie(&state, &new_refresh_value, new_refresh_expires), + ); + + Ok(( + headers, + Json(LoginResponse { + access_token, + token_type: "Bearer".to_string(), + expires_in: state.config.jwt_expiry_minutes * 60, + }), + )) +} + +pub async fn logout( + State(state): State, + user: AuthenticatedUser, + jar: Option>, +) -> AppResult<(HeaderMap, StatusCode)> { + let mut conn = state.db()?; + if let Some(cookies) = jar { + if let Some(value) = cookies.get(REFRESH_COOKIE_NAME) { + let hashed = hash_refresh_token(value); + let now = Utc::now().naive_utc(); + let _ = 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()), + ) + .set(( + refresh_dsl::revoked_at.eq(now), + refresh_dsl::updated_at.eq(now), + )) + .execute(&mut conn); + } + } + + let mut headers = HeaderMap::new(); + headers.insert(SET_COOKIE, build_clear_refresh_cookie(&state)); + Ok((headers, StatusCode::NO_CONTENT)) } pub async fn me(user: AuthenticatedUser) -> Json { Json(user) } + +fn hash_refresh_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn generate_refresh_token() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + hex::encode(bytes) +} + +fn build_refresh_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)]; + parts.push("Path=/".into()); + parts.push("HttpOnly".into()); + parts.push("SameSite=Strict".into()); + parts.push(format!("Max-Age={}", max_age)); + parts.push(format!("Expires={}", expires_at.to_rfc2822())); + if state.config.refresh_cookie_secure { + parts.push("Secure".into()); + } + if let Some(domain) = &state.config.refresh_cookie_domain { + parts.push(format!("Domain={}", domain)); + } + + HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie") +} + +fn build_clear_refresh_cookie(state: &AppState) -> HeaderValue { + let mut parts = vec![format!("{}=", REFRESH_COOKIE_NAME)]; + parts.push("Path=/".into()); + parts.push("HttpOnly".into()); + parts.push("SameSite=Strict".into()); + parts.push("Max-Age=0".into()); + parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT".into()); + if state.config.refresh_cookie_secure { + parts.push("Secure".into()); + } + if let Some(domain) = &state.config.refresh_cookie_domain { + parts.push(format!("Domain={}", domain)); + } + + HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie") +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 26e2c46..6117d59 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -1,10 +1,11 @@ +use axum::http::HeaderValue; use axum::{ extract::DefaultBodyLimit, middleware, routing::{delete, get, patch, post}, Router, }; -use tower_http::cors::{Any, CorsLayer}; +use tower_http::cors::{AllowOrigin, CorsLayer}; use crate::{auth::AuthenticatedUser, state::AppState}; @@ -15,13 +16,37 @@ pub mod health; pub mod tags; pub fn create_router(state: AppState) -> Router<()> { - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); + let cors = if let Some(origins) = state.config.cors_allowed_origin.as_ref() { + let headers: Vec = origins + .split(',') + .filter_map(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| { + trimmed + .parse::() + .expect("invalid CORS allowed origin") + }) + }) + .collect(); + + let allow_origin = AllowOrigin::list(headers); + + CorsLayer::new() + .allow_origin(allow_origin) + .allow_methods(tower_http::cors::AllowMethods::mirror_request()) + .allow_headers(tower_http::cors::AllowHeaders::mirror_request()) + .allow_credentials(true) + } else { + CorsLayer::new() + .allow_origin(AllowOrigin::mirror_request()) + .allow_methods(tower_http::cors::AllowMethods::mirror_request()) + .allow_headers(tower_http::cors::AllowHeaders::mirror_request()) + .allow_credentials(true) + }; let auth_routes = Router::new() .route("/login", post(auth::login)) + .route("/refresh", post(auth::refresh)) .route("/logout", post(auth::logout)) .route("/me", get(auth::me)); diff --git a/backend/src/schema.rs b/backend/src/schema.rs index 889762b..4ee36a9 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -86,6 +86,19 @@ diesel::table! { } } +diesel::table! { + refresh_tokens (id) { + id -> Uuid, + user_id -> Uuid, + token_hash -> Text, + issued_at -> Timestamptz, + expires_at -> Timestamptz, + revoked_at -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + diesel::table! { tags (id) { id -> Uuid, @@ -117,6 +130,7 @@ diesel::joinable!(document_tags -> tags (tag_id)); diesel::joinable!(document_tags -> users (assigned_by)); diesel::joinable!(document_versions -> documents (document_id)); diesel::joinable!(documents -> folders (folder_id)); +diesel::joinable!(refresh_tokens -> users (user_id)); diesel::allow_tables_to_appear_in_same_query!( document_assets, @@ -125,6 +139,7 @@ diesel::allow_tables_to_appear_in_same_query!( documents, folders, jobs, + refresh_tokens, tags, users, ); diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 9480abc..239685d 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -21,6 +21,7 @@ const API_ROOT = (process.env.API_BASE_URL || '').replace(/\/$/, ''); const api = axios.create({ baseURL: API_ROOT ? `${API_ROOT}/api` : '/api', + withCredentials: true, }); const DEFAULT_FOLDER_NAME = 'All Documents'; @@ -562,7 +563,7 @@ const computeStackAngle = (docId, index) => { return magnitude * sign; }; -const MAX_PREVIEW_STACK_ITEMS = 4; +const MAX_PREVIEW_STACK_ITEMS = 15; const PreviewStack = ({ items = [], @@ -572,75 +573,27 @@ const PreviewStack = ({ onOpenPreview, activeItemId = null, }) => { - const angleMapRef = useRef(new Map()); - const previousOrderRef = useRef([]); - if (!items.length) { return {emptyMessage}; } const limited = items.slice(0, maxItems); const hasMultiple = limited.length > 1; - - const preparedItems = useMemo(() => { - const angleMap = angleMapRef.current; - const previousOrder = previousOrderRef.current; - const prevFrontId = previousOrder.length ? previousOrder[0] : null; - const currentFrontId = limited.length ? limited[0].id : null; - const currentIds = new Set(); - - limited.forEach((entry, index) => { - const id = entry.id; - currentIds.add(id); - if (!angleMap.has(id)) { - const initialAngle = index === 0 ? 0 : computeStackAngle(id, index); - angleMap.set(id, initialAngle); - } - }); - - if ( - onItemActivate && - activeItemId && - currentFrontId === activeItemId && - prevFrontId && - prevFrontId !== activeItemId - ) { - const frontAngle = angleMap.get(prevFrontId); - const activeAngle = angleMap.get(activeItemId); - angleMap.set( - prevFrontId, - typeof activeAngle === 'number' ? activeAngle : computeStackAngle(prevFrontId, 1), - ); - angleMap.set(activeItemId, typeof frontAngle === 'number' ? frontAngle : 0); - } else if (currentFrontId && (!angleMap.has(currentFrontId) || angleMap.get(currentFrontId) !== 0)) { - angleMap.set(currentFrontId, 0); - } - - angleMap.forEach((_, id) => { - if (!currentIds.has(id)) { - angleMap.delete(id); - } - }); - - const result = limited.map((entry, index) => { - const angle = angleMap.get(entry.id); - return { + const preparedItems = useMemo( + () => + limited.map((entry, index) => ({ entry, - angle: typeof angle === 'number' ? angle : computeStackAngle(entry.id, index), - offset: hasMultiple ? index * 8 : 0, - }; - }); - - previousOrderRef.current = limited.map((entry) => entry.id); - - return result; - }, [limited, hasMultiple, activeItemId, onItemActivate]); + angle: index === 0 ? 0 : computeStackAngle(entry.id, index), + offset: 0, + })), + [limited], + ); return (
{preparedItems.map(({ entry, angle, offset }, index) => { const transform = hasMultiple - ? `translate(-50%, -50%) rotate(${angle}deg) translateY(${offset}px)` + ? `translate(-50%, -50%) rotate(${angle}deg)` : 'translate(-50%, -50%)'; const isFront = index === 0; return ( @@ -659,8 +612,6 @@ const PreviewStack = ({ src={entry.url} alt={entry.alt || ''} className="preview-stack__image" - role={onItemActivate || onOpenPreview ? 'button' : undefined} - tabIndex={onItemActivate || onOpenPreview ? 0 : -1} onClick={(event) => { event.stopPropagation(); if (isFront && onOpenPreview) { @@ -1195,7 +1146,7 @@ const Sidebar = ({ ); }; -function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) { +function App({ routeFolderId = null, routeDocumentId = null, navigate }) { const [token, setToken] = useState(() => window.localStorage.getItem('paperless_token') || ''); const [status, setStatus] = useState(null); const [loading, setLoading] = useState(false); @@ -1292,6 +1243,7 @@ function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) { if (!selectedDocumentIds.includes(activePreviewId)) { setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]); } + initializedRef.current = true; }, [selectedDocumentIds, activePreviewId]); const currentFolderName = useMemo(() => { @@ -1318,6 +1270,10 @@ function App({ routeFolderId = 'root', routeDocumentId = null, navigate }) { let nextSelection = []; setSelectedDocumentIds((previous) => { + if (initializedRef.current) { + nextSelection = previous.filter((id) => availableIds.has(id)); + return nextSelection; + } const filtered = previous.filter((id) => availableIds.has(id)); if (filtered.length) { nextSelection = filtered; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 79703a9..6e6876a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -198,7 +198,6 @@ button.icon-button.ghost:hover:not([disabled]) { flex: 1; display: flex; flex-direction: column; - padding: 0.75rem 1.5rem 1.25rem; min-height: 0; } @@ -215,6 +214,7 @@ button.icon-button.ghost:hover:not([disabled]) { align-items: center; justify-content: space-between; gap: 1rem; + padding: 0.75rem 1.5rem 0; } .preview-workspace__meta { @@ -242,9 +242,6 @@ button.icon-button.ghost:hover:not([disabled]) { .preview-workspace__body { flex: 1; - background: var(--surface-subtle); - border: 1px solid var(--border); - border-radius: 2px; display: flex; align-items: center; justify-content: center;