From d9b2297eca08f318041dffd0dc9802a04de4cc64 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 29 Oct 2025 00:12:58 +0100 Subject: [PATCH] backend: webdav tokens --- .../202512010100_add_webdav_tokens/down.sql | 3 + .../202512010100_add_webdav_tokens/up.sql | 16 ++ backend/src/auth/mod.rs | 1 + backend/src/auth/webdav_tokens.rs | 231 ++++++++++++++++++ backend/src/models.rs | 31 +++ backend/src/openapi.rs | 80 +++++- backend/src/routes/mod.rs | 9 + backend/src/routes/profile.rs | 112 +++++++++ backend/src/routes/webdav/mod.rs | 170 ++++--------- backend/src/schema.rs | 19 ++ backend/tests/common/mod.rs | 1 + backend/tests/webdav_flow.rs | 153 ++++++++++++ 12 files changed, 707 insertions(+), 119 deletions(-) create mode 100644 backend/migrations/202512010100_add_webdav_tokens/down.sql create mode 100644 backend/migrations/202512010100_add_webdav_tokens/up.sql create mode 100644 backend/src/auth/webdav_tokens.rs create mode 100644 backend/src/routes/profile.rs create mode 100644 backend/tests/webdav_flow.rs diff --git a/backend/migrations/202512010100_add_webdav_tokens/down.sql b/backend/migrations/202512010100_add_webdav_tokens/down.sql new file mode 100644 index 0000000..dc42dd5 --- /dev/null +++ b/backend/migrations/202512010100_add_webdav_tokens/down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS webdav_tokens_user_tenant_idx; +DROP INDEX IF EXISTS webdav_tokens_token_prefix_key; +DROP TABLE IF EXISTS webdav_tokens; diff --git a/backend/migrations/202512010100_add_webdav_tokens/up.sql b/backend/migrations/202512010100_add_webdav_tokens/up.sql new file mode 100644 index 0000000..83ec511 --- /dev/null +++ b/backend/migrations/202512010100_add_webdav_tokens/up.sql @@ -0,0 +1,16 @@ +CREATE TABLE webdav_tokens ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + token_prefix TEXT NOT NULL, + token_hash TEXT NOT NULL, + label TEXT, + scopes JSONB NOT NULL DEFAULT '["webdav"]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX webdav_tokens_token_prefix_key ON webdav_tokens(token_prefix); +CREATE INDEX webdav_tokens_user_tenant_idx ON webdav_tokens(user_id, tenant_id); diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs index bd08cf4..e1bf0b9 100644 --- a/backend/src/auth/mod.rs +++ b/backend/src/auth/mod.rs @@ -1,5 +1,6 @@ pub mod jwt; pub mod password; +pub mod webdav_tokens; use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; use axum_extra::headers::{authorization::Bearer, Authorization}; diff --git a/backend/src/auth/webdav_tokens.rs b/backend/src/auth/webdav_tokens.rs new file mode 100644 index 0000000..4e5e7b4 --- /dev/null +++ b/backend/src/auth/webdav_tokens.rs @@ -0,0 +1,231 @@ +use argon2::{ + password_hash::{PasswordHasher, SaltString}, + Argon2, +}; +use chrono::{NaiveDateTime, Utc}; +use diesel::prelude::*; +use rand::rngs::OsRng; +use rand::RngCore; +use serde_json::json; +use uuid::Uuid; + +use crate::{ + error::AppError, + models::{NewWebdavToken, WebdavToken}, + schema::webdav_tokens, + state::PgPooledConnection, +}; + +const WEB_DAV_SCOPE: &str = "webdav"; +const TOKEN_PREFIX_LENGTH: usize = 12; +const TOKEN_SECRET_LENGTH: usize = 32; + +pub struct IssuedWebdavToken { + pub token: String, + pub record: WebdavToken, +} + +pub fn create_webdav_token( + conn: &mut PgPooledConnection, + user_id: Uuid, + tenant_id: Uuid, + label: Option, + scopes: Option>, + expires_at: Option, +) -> Result { + let raw_secret = generate_secret()?; + let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); + let token_hash = hash_secret(&raw_secret)?; + let scopes_value = scopes + .map(|scopes| json!(scopes)) + .unwrap_or_else(|| json!([WEB_DAV_SCOPE])); + + let new_token = NewWebdavToken { + id: Uuid::new_v4(), + user_id, + tenant_id, + token_prefix, + token_hash, + label, + scopes: scopes_value, + expires_at, + }; + + let record = diesel::insert_into(webdav_tokens::table) + .values(&new_token) + .get_result::(conn)?; + + Ok(IssuedWebdavToken { + token: raw_secret, + record, + }) +} + +pub fn list_webdav_tokens( + conn: &mut PgPooledConnection, + user_id: Uuid, + tenant_id: Option, +) -> Result, AppError> { + let mut query = webdav_tokens::table + .filter(webdav_tokens::user_id.eq(user_id)) + .into_boxed(); + + if let Some(tenant_id) = tenant_id { + query = query.filter(webdav_tokens::tenant_id.eq(tenant_id)); + } + + let tokens = query + .order(webdav_tokens::created_at.asc()) + .load::(conn)?; + + Ok(tokens) +} + +pub fn find_active_token_by_secret( + conn: &mut PgPooledConnection, + user_id: Uuid, + tenant_id: Option, + secret: &str, +) -> Result, AppError> { + if secret.len() < TOKEN_PREFIX_LENGTH { + return Ok(None); + } + + let prefix = &secret[..TOKEN_PREFIX_LENGTH]; + let mut query = webdav_tokens::table + .filter(webdav_tokens::user_id.eq(user_id)) + .filter(webdav_tokens::token_prefix.eq(prefix)) + .filter(webdav_tokens::revoked_at.is_null()) + .into_boxed(); + + let now = Utc::now().naive_utc(); + query = query.filter( + webdav_tokens::expires_at + .is_null() + .or(webdav_tokens::expires_at.gt(now)), + ); + + if let Some(tenant_id) = tenant_id { + query = query.filter(webdav_tokens::tenant_id.eq(tenant_id)); + } + + let candidates = query.load::(conn)?; + + for token in candidates { + if verify_token_secret(secret, &token.token_hash)? && token_allows_webdav(&token.scopes) { + return Ok(Some(token)); + } + } + + Ok(None) +} + +pub fn revoke_webdav_token( + conn: &mut PgPooledConnection, + token_id: Uuid, + user_id: Uuid, +) -> Result<(), AppError> { + let affected = diesel::update( + webdav_tokens::table + .filter(webdav_tokens::id.eq(token_id)) + .filter(webdav_tokens::user_id.eq(user_id)), + ) + .set(webdav_tokens::revoked_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + + if affected == 0 { + return Err(AppError::not_found()); + } + + Ok(()) +} + +pub fn touch_webdav_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> { + diesel::update(webdav_tokens::table.filter(webdav_tokens::id.eq(token_id))) + .set(webdav_tokens::last_used_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + Ok(()) +} + +pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result { + crate::auth::password::verify_password(secret, token_hash) + .map_err(|err| AppError::internal(format!("failed to verify token: {err}"))) +} + +fn generate_secret() -> Result { + let mut buffer = [0u8; TOKEN_SECRET_LENGTH]; + OsRng + .try_fill_bytes(&mut buffer) + .map_err(|err| AppError::internal(format!("failed to generate token: {err}")))?; + Ok(hex::encode(buffer)) +} + +fn hash_secret(secret: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(secret.as_bytes(), &salt) + .map_err(|err| AppError::internal(format!("failed to hash token: {err}")))?; + Ok(hash.to_string()) +} + +pub fn parse_scopes(scopes: &serde_json::Value) -> Vec { + match scopes { + serde_json::Value::Array(values) => values + .iter() + .filter_map(|value| value.as_str().map(|s| s.to_string())) + .collect(), + serde_json::Value::String(value) => value + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(), + _ => vec![], + } +} + +pub fn token_allows_webdav(scopes: &serde_json::Value) -> bool { + parse_scopes(scopes) + .into_iter() + .any(|scope| scope == WEB_DAV_SCOPE) +} + +fn _ensure_constants() { + assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_secret_has_expected_length() { + let secret = generate_secret().unwrap(); + assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2); + } + + #[test] + fn hash_and_verify_secret_round_trip() { + let secret = generate_secret().unwrap(); + let hash = hash_secret(&secret).unwrap(); + assert!(verify_token_secret(&secret, &hash).unwrap()); + assert!(!verify_token_secret("wrong", &hash).unwrap()); + } + + #[test] + fn parse_scopes_handles_strings_and_arrays() { + let from_array = parse_scopes(&json!(["webdav", "other"])); + assert_eq!(from_array, vec!["webdav", "other"]); + + let from_string = parse_scopes(&json!("webdav, other")); + assert_eq!(from_string, vec!["webdav", "other"]); + + assert!(parse_scopes(&serde_json::Value::Null).is_empty()); + } + + #[test] + fn token_allows_webdav_matches_scope() { + assert!(token_allows_webdav(&json!(["webdav"]))); + assert!(!token_allows_webdav(&json!(["api"]))); + } +} diff --git a/backend/src/models.rs b/backend/src/models.rs index e96c4aa..59d70d8 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -58,6 +58,37 @@ pub struct NewUser { pub password_hash: String, } +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = webdav_tokens)] +#[diesel(belongs_to(User))] +#[diesel(belongs_to(Tenant))] +pub struct WebdavToken { + pub id: Uuid, + pub user_id: Uuid, + pub tenant_id: Uuid, + pub token_prefix: String, + pub token_hash: String, + pub label: Option, + pub scopes: serde_json::Value, + pub created_at: NaiveDateTime, + pub last_used_at: Option, + pub expires_at: Option, + pub revoked_at: Option, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = webdav_tokens)] +pub struct NewWebdavToken { + pub id: Uuid, + pub user_id: Uuid, + pub tenant_id: Uuid, + pub token_prefix: String, + pub token_hash: String, + pub label: Option, + pub scopes: serde_json::Value, + pub expires_at: Option, +} + #[derive(Debug, Clone, Queryable, Identifiable)] #[diesel(table_name = folders)] pub struct Folder { diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index 9ce5684..2d9d310 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -11,6 +11,7 @@ use uuid::Uuid; doc::refresh, doc::logout, doc::me, + doc::list_tenants, doc::select_tenant, doc::list_documents, doc::check_document, @@ -45,6 +46,9 @@ use uuid::Uuid; doc::create_correspondent, doc::update_correspondent, doc::delete_correspondent, + doc::list_webdav_tokens, + doc::create_webdav_token, + doc::delete_webdav_token, ), components( schemas( @@ -53,6 +57,7 @@ use uuid::Uuid; schemas::TenantSummary, schemas::TenantSelectionResponse, schemas::TenantSelectionRequest, + schemas::TenantListResponse, schemas::LoginResponseVariants, schemas::DocumentResponse, schemas::DocumentDetailResponse, @@ -98,6 +103,9 @@ use uuid::Uuid; schemas::CorrespondentCatalogEntry, schemas::CreateCorrespondentRequest, schemas::UpdateCorrespondentRequest, + schemas::WebdavTokenResponse, + schemas::WebdavTokenCreatedResponse, + schemas::CreateWebdavTokenRequest, ) ), tags( @@ -107,7 +115,8 @@ use uuid::Uuid; (name = "Assets", description = "Document assets"), (name = "Folders", description = "Folder management"), (name = "Tags", description = "Tag catalog"), - (name = "Correspondents", description = "Correspondent catalog") + (name = "Correspondents", description = "Correspondent catalog"), + (name = "Profile", description = "User profile and WebDAV tokens") ) )] pub struct ApiDoc; @@ -169,6 +178,14 @@ mod doc { )] pub(super) fn me() {} + #[utoipa::path( + get, + path = "/api/auth/tenants", + responses((status = 200, description = "Available tenants", body = TenantListResponse)), + tag = "Auth" + )] + pub(super) fn list_tenants() {} + #[utoipa::path( post, path = "/api/auth/select-tenant", @@ -529,6 +546,32 @@ mod doc { tag = "Correspondents" )] pub(super) fn delete_correspondent() {} + + #[utoipa::path( + get, + path = "/api/profile/webdav-tokens", + responses((status = 200, description = "List WebDAV tokens", body = [WebdavTokenResponse])), + tag = "Profile" + )] + pub(super) fn list_webdav_tokens() {} + + #[utoipa::path( + post, + path = "/api/profile/webdav-tokens", + request_body = CreateWebdavTokenRequest, + responses((status = 201, description = "WebDAV token created", body = WebdavTokenCreatedResponse)), + tag = "Profile" + )] + pub(super) fn create_webdav_token() {} + + #[utoipa::path( + delete, + path = "/api/profile/webdav-tokens/{id}", + params(("id" = Uuid, Path, description = "WebDAV token ID")), + responses((status = 204, description = "WebDAV token revoked")), + tag = "Profile" + )] + pub(super) fn delete_webdav_token() {} } #[cfg(test)] @@ -585,6 +628,11 @@ pub mod schemas { pub tenant_id: Uuid, } + #[derive(Serialize, Deserialize, ToSchema)] + pub struct TenantListResponse { + pub tenants: Vec, + } + #[derive(Serialize, Deserialize, ToSchema)] #[serde(untagged)] pub enum LoginResponseVariants { @@ -1001,4 +1049,34 @@ pub mod schemas { #[schema(nullable)] pub metadata: Option, } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct WebdavTokenResponse { + pub id: Uuid, + pub tenant_id: Uuid, + #[schema(nullable)] + pub label: Option, + pub scopes: Vec, + pub created_at: String, + #[schema(nullable)] + pub last_used_at: Option, + #[schema(nullable)] + pub expires_at: Option, + #[schema(nullable)] + pub revoked_at: Option, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct WebdavTokenCreatedResponse { + pub token: String, + pub token_info: WebdavTokenResponse, + } + + #[derive(Serialize, Deserialize, ToSchema)] + pub struct CreateWebdavTokenRequest { + #[schema(nullable)] + pub label: Option, + #[schema(nullable, example = "2025-01-01T00:00:00Z")] + pub expires_at: Option, + } } diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 6321f8a..2d4a0ca 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -20,6 +20,7 @@ pub mod correspondents; pub mod documents; pub mod folders; pub mod health; +pub mod profile; pub mod tags; pub mod webdav; @@ -130,6 +131,13 @@ pub fn create_router(state: AppState) -> Router<()> { .delete(correspondents::delete_correspondent), ); + let profile_routes = Router::new() + .route( + "/webdav-tokens", + get(profile::list_webdav_tokens).post(profile::create_webdav_token), + ) + .route("/webdav-tokens/:id", delete(profile::delete_webdav_token)); + let protected_state = state.clone(); let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset)); @@ -138,6 +146,7 @@ pub fn create_router(state: AppState) -> Router<()> { .nest("/api/folders", folders_routes) .nest("/api/tags", tags_routes) .nest("/api/correspondents", correspondents_routes) + .nest("/api/profile", profile_routes) .nest("/api/assets", assets_routes) .layer(middleware::from_extractor_with_state::(protected_state)); diff --git a/backend/src/routes/profile.rs b/backend/src/routes/profile.rs new file mode 100644 index 0000000..28b6945 --- /dev/null +++ b/backend/src/routes/profile.rs @@ -0,0 +1,112 @@ +use axum::{extract::Path, http::StatusCode, Json}; +use chrono::{DateTime, NaiveDateTime}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::{ + webdav_tokens::{ + create_webdav_token as issue_token, list_webdav_tokens as load_tokens, parse_scopes, + revoke_webdav_token as revoke_token, + }, + TenantScopedConn, +}; +use crate::error::{AppError, AppResult}; +use crate::models::WebdavToken; +use crate::utils::{db::no_content, time::to_iso}; + +#[derive(Debug, Serialize)] +pub struct WebdavTokenResponse { + pub id: Uuid, + pub tenant_id: Uuid, + pub label: Option, + pub scopes: Vec, + pub created_at: String, + pub last_used_at: Option, + pub expires_at: Option, + pub revoked_at: Option, +} + +#[derive(Debug, Serialize)] +pub struct WebdavTokenCreatedResponse { + pub token: String, + pub token_info: WebdavTokenResponse, +} + +#[derive(Debug, Deserialize)] +pub struct CreateWebdavTokenRequest { + pub label: Option, + pub expires_at: Option, +} + +pub async fn list_webdav_tokens( + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let tokens = load_tokens(&mut conn, user_id, Some(tenant_id))?; + let responses = tokens.into_iter().map(webdav_token_to_response).collect(); + Ok(Json(responses)) +} + +pub async fn create_webdav_token( + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult<(StatusCode, Json)> { + let expires_at = match payload.expires_at { + Some(ref value) => Some(parse_timestamp(value)?), + None => None, + }; + + let issued = issue_token( + &mut conn, + user_id, + tenant_id, + payload.label.clone(), + None, + expires_at, + )?; + + let response = WebdavTokenCreatedResponse { + token: issued.token, + token_info: webdav_token_to_response(issued.record), + }; + + Ok((StatusCode::CREATED, Json(response))) +} + +pub async fn delete_webdav_token( + TenantScopedConn { + mut conn, user_id, .. + }: TenantScopedConn, + Path(token_id): Path, +) -> AppResult { + revoke_token(&mut conn, token_id, user_id)?; + no_content() +} + +fn webdav_token_to_response(token: WebdavToken) -> WebdavTokenResponse { + WebdavTokenResponse { + id: token.id, + tenant_id: token.tenant_id, + label: token.label, + scopes: parse_scopes(&token.scopes), + created_at: to_iso(token.created_at), + last_used_at: token.last_used_at.map(to_iso), + expires_at: token.expires_at.map(to_iso), + revoked_at: token.revoked_at.map(to_iso), + } +} + +fn parse_timestamp(value: &str) -> AppResult { + let dt = DateTime::parse_from_rfc3339(value) + .map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?; + Ok(dt.naive_utc()) +} diff --git a/backend/src/routes/webdav/mod.rs b/backend/src/routes/webdav/mod.rs index ad12bab..c514421 100644 --- a/backend/src/routes/webdav/mod.rs +++ b/backend/src/routes/webdav/mod.rs @@ -8,6 +8,7 @@ use axum::Router; use base64::engine::general_purpose::STANDARD as BASE64; use base64::Engine; use diesel::prelude::*; +use diesel::OptionalExtension; use diesel::PgConnection; use futures_util::StreamExt; use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; @@ -15,7 +16,7 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event}; use quick_xml::Writer; use uuid::Uuid; -use crate::auth::password; +use crate::auth::webdav_tokens::{find_active_token_by_secret, touch_webdav_token}; use crate::error::{AppError, AppResult}; use crate::models::{Document, DocumentVersion, Folder, User}; use crate::schema::{ @@ -29,17 +30,11 @@ use crate::utils::{http::inline_content_disposition, time::to_http_date}; const REALM: &str = "Papercrate WebDAV"; const DOWNLOAD_URL_TTL_SECONDS: u64 = 300; -#[derive(Clone, Debug)] -struct TenantEntry { - tenant_id: Uuid, - slug: String, -} - #[derive(Clone, Debug)] struct WebDavContext { + tenant_id: Uuid, _user_id: Uuid, _username: String, - tenants: Vec, } pub fn create_router() -> Router { @@ -89,35 +84,20 @@ async fn handle_propfind( let segments = parse_segments(path)?; - let resources = if segments.is_empty() { - build_account_root_resources(&context.tenants, depth) - } else { - let (requested_slug, remainder) = segments.split_first().unwrap(); - let tenant_entry = match context - .tenants - .iter() - .find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug)) - { - Some(entry) => TenantEntry { - tenant_id: entry.tenant_id, - slug: entry.slug.clone(), - }, - None => return Ok(not_found_response()), - }; + let tenant_id = context.tenant_id; - let resolution = match resolve_path(state, &tenant_entry, remainder)? { + let resources = if segments.is_empty() { + let contents = fetch_folder_contents(state, tenant_id, None)?; + build_resources_for_folder(None, &[], &contents, depth) + } else { + let resolution = match resolve_path(state, tenant_id, &segments)? { Some(resolved) => resolved, None => return Ok(not_found_response()), }; match resolution { - ResolvedPath::TenantRoot { chain } => { - let contents = fetch_folder_contents(state, tenant_entry.tenant_id, None)?; - build_resources_for_folder(None, &chain, &contents, depth) - } ResolvedPath::Folder { folder, chain } => { - let contents = - fetch_folder_contents(state, tenant_entry.tenant_id, Some(folder.id))?; + let contents = fetch_folder_contents(state, tenant_id, Some(folder.id))?; build_resources_for_folder(Some(&folder), &chain, &contents, depth) } ResolvedPath::Document { @@ -151,29 +131,13 @@ async fn handle_get_or_head( None => return Ok(unauthorized_response()), }; + let tenant_id = context.tenant_id; let segments = parse_segments(path)?; - let (requested_slug, remainder) = match segments.split_first() { - Some(values) => values, - None => return Ok(method_not_allowed()), - }; - - let tenant_entry = match context - .tenants - .iter() - .find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug)) - { - Some(entry) => TenantEntry { - tenant_id: entry.tenant_id, - slug: entry.slug.clone(), - }, - None => return Ok(not_found_response()), - }; - - if remainder.is_empty() { + if segments.is_empty() { return Ok(method_not_allowed()); } - let resolution = match resolve_path(state, &tenant_entry, remainder)? { + let resolution = match resolve_path(state, tenant_id, &segments)? { Some(resolved) => resolved, None => return Ok(not_found_response()), }; @@ -458,8 +422,8 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result (username, password), + let (username, secret) = match credential_str.split_once(':') { + Some((username, secret)) if !username.is_empty() => (username, secret), _ => return Ok(None), }; @@ -478,35 +442,46 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result return Err(AppError::from(err)), }; - let valid = password::verify_password(password, &user.password_hash) - .map_err(|_| AppError::internal("failed to verify password"))?; + let token = match find_active_token_by_secret(&mut conn, user.id, None, secret)? { + Some(token) => token, + None => { + tracing::warn!(%username, "webdav token invalid or expired"); + return Ok(None); + } + }; - if !valid { - tracing::warn!(%username, "webdav password invalid"); - return Ok(None); - } - - let tenant_rows: Vec<(Uuid, String)> = memberships_dsl::user_memberships + let tenant_row = memberships_dsl::user_memberships .inner_join(tenant_dsl::tenants) .filter(memberships_dsl::user_id.eq(user.id)) + .filter(memberships_dsl::tenant_id.eq(token.tenant_id)) .select((tenant_dsl::id, tenant_dsl::slug)) - .load(&mut conn)?; + .first::<(Uuid, String)>(&mut conn) + .optional()?; - if tenant_rows.is_empty() { - tracing::warn!(%username, "webdav user has no tenant memberships"); - return Ok(None); - } + let (tenant_id, _slug) = match tenant_row { + Some(row) => row, + None => { + tracing::warn!( + %username, + tenant_id = %token.tenant_id, + "webdav token tenant membership missing" + ); + return Ok(None); + } + }; - let tenants: Vec = tenant_rows - .into_iter() - .map(|(tenant_id, slug)| TenantEntry { tenant_id, slug }) - .collect(); + touch_webdav_token(&mut conn, token.id)?; - tracing::debug!(%username, tenant_count = tenants.len(), "webdav login success"); + tracing::debug!( + %username, + tenant_id = %tenant_id, + token_id = %token.id, + "webdav token login success" + ); Ok(Some(WebDavContext { + tenant_id, _user_id: user.id, _username: user.username, - tenants, })) } @@ -564,37 +539,6 @@ fn build_resources_for_folder( resources } -fn build_account_root_resources(tenants: &[TenantEntry], depth: u8) -> Vec { - let mut resources = Vec::new(); - - resources.push(DavResource { - href: "/".to_string(), - display_name: "/".to_string(), - is_collection: true, - content_length: None, - content_type: None, - last_modified: None, - }); - - if depth == 0 { - return resources; - } - - for tenant in tenants { - let href = build_href(&[tenant.slug.clone()], true); - resources.push(DavResource { - href, - display_name: tenant.slug.clone(), - is_collection: true, - content_length: None, - content_type: None, - last_modified: None, - }); - } - - resources -} - fn build_resources_for_document( chain: &[String], document: &Document, @@ -717,9 +661,6 @@ struct DavResource { last_modified: Option, } enum ResolvedPath { - TenantRoot { - chain: Vec, - }, Folder { folder: Folder, chain: Vec, @@ -733,23 +674,18 @@ enum ResolvedPath { fn resolve_path( state: &AppState, - tenant: &TenantEntry, + tenant_id: Uuid, segments: &[String], ) -> AppResult> { - let mut conn = state.db_for_tenant(tenant.tenant_id)?; + let mut conn = state.db_for_tenant(tenant_id)?; let mut parent_id: Option = None; - let mut chain: Vec = vec![tenant.slug.clone()]; + let mut chain: Vec = Vec::new(); let mut current_folder: Option = None; - if segments.is_empty() { - return Ok(Some(ResolvedPath::TenantRoot { chain })); - } - for (index, segment) in segments.iter().enumerate() { let is_last = index == segments.len() - 1; - if let Some(folder) = find_folder_by_name(&mut conn, tenant.tenant_id, parent_id, segment)? - { + if let Some(folder) = find_folder_by_name(&mut conn, tenant_id, parent_id, segment)? { chain.push(folder.name.clone()); if is_last { return Ok(Some(ResolvedPath::Folder { folder, chain })); @@ -761,7 +697,7 @@ fn resolve_path( if is_last { if let Some((document, version)) = - find_document_by_filename(&mut conn, tenant.tenant_id, parent_id, segment)? + find_document_by_filename(&mut conn, tenant_id, parent_id, segment)? { chain.push(document.filename.clone()); return Ok(Some(ResolvedPath::Document { @@ -773,7 +709,7 @@ fn resolve_path( } if let Ok(uuid) = Uuid::parse_str(segment) { - if let Some(folder) = find_folder_by_id(&mut conn, tenant.tenant_id, uuid)? { + if let Some(folder) = find_folder_by_id(&mut conn, tenant_id, uuid)? { if folder.parent_id != parent_id { return Ok(None); } @@ -786,9 +722,7 @@ fn resolve_path( continue; } - if let Some((document, version)) = - find_document_by_id(&mut conn, tenant.tenant_id, uuid)? - { + if let Some((document, version)) = find_document_by_id(&mut conn, tenant_id, uuid)? { if document.folder_id != parent_id { return Ok(None); } diff --git a/backend/src/schema.rs b/backend/src/schema.rs index 84d7b4d..d80d47c 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -183,6 +183,22 @@ diesel::table! { } } +diesel::table! { + webdav_tokens (id) { + id -> Uuid, + user_id -> Uuid, + tenant_id -> Uuid, + token_prefix -> Text, + token_hash -> Text, + label -> Nullable, + scopes -> Jsonb, + created_at -> Timestamptz, + last_used_at -> Nullable, + expires_at -> Nullable, + revoked_at -> Nullable, + } +} + diesel::joinable!(correspondents -> tenants (tenant_id)); diesel::joinable!(document_asset_objects -> document_assets (asset_id)); diesel::joinable!(document_asset_objects -> tenants (tenant_id)); @@ -206,6 +222,8 @@ diesel::joinable!(refresh_tokens -> users (user_id)); diesel::joinable!(tags -> tenants (tenant_id)); diesel::joinable!(user_memberships -> tenants (tenant_id)); diesel::joinable!(user_memberships -> users (user_id)); +diesel::joinable!(webdav_tokens -> tenants (tenant_id)); +diesel::joinable!(webdav_tokens -> users (user_id)); diesel::allow_tables_to_appear_in_same_query!( correspondents, @@ -222,4 +240,5 @@ diesel::allow_tables_to_appear_in_same_query!( tenants, user_memberships, users, + webdav_tokens, ); diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index a1eaddf..3653ff0 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -707,6 +707,7 @@ fn truncate_all(conn: &mut PgConnection) -> Result<()> { jobs, \ refresh_tokens, \ tags, \ + webdav_tokens, \ user_memberships, \ users, \ tenants \ diff --git a/backend/tests/webdav_flow.rs b/backend/tests/webdav_flow.rs new file mode 100644 index 0000000..0b4c0df --- /dev/null +++ b/backend/tests/webdav_flow.rs @@ -0,0 +1,153 @@ +mod common; + +use anyhow::Result; +use axum::body::Body; +use axum::http::{header, Method, Request, StatusCode}; +use backend::models::WebdavToken; +use backend::routes::webdav; +use backend::schema::webdav_tokens; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use common::{acquire_db_lock, body_to_vec, TestApp}; +use diesel::prelude::*; +use serde::Deserialize; +use serde_json::json; +use tower::ServiceExt; +use uuid::Uuid; + +#[derive(Deserialize)] +struct TokenInfo { + id: Uuid, + label: Option, + last_used_at: Option, + revoked_at: Option, +} + +#[derive(Deserialize)] +struct CreateTokenResponse { + token: String, + #[serde(rename = "token_info")] + info: TokenInfo, +} + +#[tokio::test] +async fn webdav_token_api_crud() -> Result<()> { + let _guard = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "alice"; + let password = "correct horse battery"; + app.insert_user(username, password, "admin").await?; + let access_token = app.login_token(username, password).await?; + + let create_response = app + .post_json( + "/api/profile/webdav-tokens", + &json!({ "label": "dav" }), + Some(&access_token), + ) + .await?; + assert_eq!(create_response.status(), StatusCode::CREATED); + let create_body = body_to_vec(create_response.into_body()).await?; + let created: CreateTokenResponse = serde_json::from_slice(&create_body)?; + let token_id = created.info.id; + assert_eq!(created.info.label.as_deref(), Some("dav")); + assert!(created.info.last_used_at.is_none()); + + let list_response = app + .get("/api/profile/webdav-tokens", Some(&access_token)) + .await?; + assert_eq!(list_response.status(), StatusCode::OK); + let list_body = body_to_vec(list_response.into_body()).await?; + let listed: Vec = serde_json::from_slice(&list_body)?; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, token_id); + + let delete_response = app + .delete( + &format!("/api/profile/webdav-tokens/{token_id}"), + Some(&access_token), + ) + .await?; + assert_eq!(delete_response.status(), StatusCode::NO_CONTENT); + + let list_after = app + .get("/api/profile/webdav-tokens", Some(&access_token)) + .await?; + let list_after_body = body_to_vec(list_after.into_body()).await?; + let listed_after: Vec = serde_json::from_slice(&list_after_body)?; + assert_eq!(listed_after.len(), 1); + assert_eq!(listed_after[0].id, token_id); + assert!(listed_after[0].revoked_at.is_some()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn webdav_basic_auth_uses_tokens() -> Result<()> { + let _guard = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "bruce"; + let password = "wayne"; + app.insert_user(username, password, "admin").await?; + let access_token = app.login_token(username, password).await?; + + let create_response = app + .post_json( + "/api/profile/webdav-tokens", + &json!({ "label": "webdav" }), + Some(&access_token), + ) + .await?; + let create_body = body_to_vec(create_response.into_body()).await?; + let created: CreateTokenResponse = serde_json::from_slice(&create_body)?; + let token_id = created.info.id; + + let router = webdav::create_router().with_state(app.state.clone()); + let auth_header = format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, created.token)) + ); + + let propfind = Method::from_bytes(b"PROPFIND")?; + let success_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header(header::AUTHORIZATION, auth_header.clone()) + .header("depth", "0") + .body(Body::empty())?; + let response = router.clone().oneshot(success_request).await?; + assert_eq!(response.status(), StatusCode::MULTI_STATUS); + + let used = app + .with_conn(move |conn| { + let record = webdav_tokens::table + .find(token_id) + .first::(conn)?; + Ok::<_, anyhow::Error>(record.last_used_at) + }) + .await?; + assert!(used.is_some()); + + let delete_response = app + .delete( + &format!("/api/profile/webdav-tokens/{token_id}"), + Some(&access_token), + ) + .await?; + assert_eq!(delete_response.status(), StatusCode::NO_CONTENT); + + let failure_request = Request::builder() + .method(propfind) + .uri("/") + .header(header::AUTHORIZATION, auth_header) + .header("depth", "0") + .body(Body::empty())?; + let response = router.oneshot(failure_request).await?; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + app.cleanup().await?; + Ok(()) +}