fix(share): password-protected downloads via signed unlock cookie
After successful POST /api/s/{token}/verify, the server issues a
short-lived signed JWT cookie (oxi_share_unlock_<token>; HttpOnly;
SameSite=Lax; 1h TTL). Subsequent /api/s/{token} and
/api/s/{token}/download requests honour the cookie to bypass the
password gate, so password-protected file downloads work end-to-end.
- New module src/infrastructure/services/share_unlock_cookie.rs:
issue_jwt, verify_jwt, extract_from_cookie_header, build_set_cookie
+ 10 unit tests.
- New ShareService methods issue_unlock_jwt and
get_shared_link_with_unlock; trait method get_shared_link_by_token
delegates to a private fetch_share_resolved with an
allow_password_protected flag.
- access_shared_item, verify_shared_item_password, and
download_shared_file honour the unlock cookie.
Reuses OXICLOUD_JWT_SECRET, no new env var. Auth-token JWTs and
unlock-cookie JWTs cannot be confused: auth requires
username/email/role/jti claims, unlock has only sub/exp/iat.
Cross-share replay rejected via claims.sub == requested_token check.
This commit is contained in:
@@ -5,7 +5,7 @@ use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
extract::{Path, Query, State},
|
||||
http::{StatusCode, header},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
@@ -13,6 +13,7 @@ use serde_json::json;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::application::services::share_service::ShareService;
|
||||
use crate::infrastructure::services::share_unlock_cookie;
|
||||
use crate::{
|
||||
application::{
|
||||
dtos::share_dto::{CreateShareDto, UpdateShareDto},
|
||||
@@ -27,6 +28,15 @@ use crate::{
|
||||
interfaces::middleware::auth::AuthUser,
|
||||
};
|
||||
|
||||
fn unlock_jwt_from_headers(headers: &HeaderMap, share_token: &str) -> Option<String> {
|
||||
headers
|
||||
.get(header::COOKIE)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|cookie_header| {
|
||||
share_unlock_cookie::extract_from_cookie_header(cookie_header, share_token)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetSharesQuery {
|
||||
pub page: Option<usize>,
|
||||
@@ -211,12 +221,19 @@ pub async fn delete_shared_link(
|
||||
pub async fn access_shared_item(
|
||||
State(share_use_case): State<Arc<ShareService>>,
|
||||
Path(token): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
// Register the access
|
||||
let _ = share_use_case.register_shared_link_access(&token).await;
|
||||
|
||||
// Honour an unlock cookie if one was issued by a prior `/verify` call.
|
||||
let unlock_jwt = unlock_jwt_from_headers(&headers, &token);
|
||||
|
||||
// Get the shared link
|
||||
match share_use_case.get_shared_link_by_token(&token).await {
|
||||
match share_use_case
|
||||
.get_shared_link_with_unlock(&token, unlock_jwt.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(item) => (StatusCode::OK, Json(item)).into_response(),
|
||||
Err(err) => {
|
||||
// Special handling for share access errors
|
||||
@@ -261,7 +278,17 @@ pub async fn verify_shared_item_password(
|
||||
.verify_shared_link_password(&token, &req.password)
|
||||
.await
|
||||
{
|
||||
Ok(item) => (StatusCode::OK, Json(item)).into_response(),
|
||||
Ok(item) => match share_use_case.issue_unlock_jwt(&token) {
|
||||
Ok(jwt) => {
|
||||
let cookie = share_unlock_cookie::build_set_cookie(
|
||||
&token,
|
||||
&jwt,
|
||||
share_unlock_cookie::DEFAULT_TTL_SECS,
|
||||
);
|
||||
(StatusCode::OK, [(header::SET_COOKIE, cookie)], Json(item)).into_response()
|
||||
}
|
||||
Err(_) => (StatusCode::OK, Json(item)).into_response(),
|
||||
},
|
||||
Err(err) => {
|
||||
if err.kind == ErrorKind::AccessDenied {
|
||||
if err.message.contains("expired") {
|
||||
@@ -296,6 +323,7 @@ pub async fn verify_shared_item_password(
|
||||
pub async fn download_shared_file(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(token): Path<String>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
// 1. Resolve share service
|
||||
let share_service = match &state.share_service {
|
||||
@@ -311,7 +339,11 @@ pub async fn download_shared_file(
|
||||
};
|
||||
|
||||
// 2. Validate the share token (handles expiry + password checks)
|
||||
let share_dto = match share_service.get_shared_link_by_token(&token).await {
|
||||
let unlock_jwt = unlock_jwt_from_headers(&headers, &token);
|
||||
let share_dto = match share_service
|
||||
.get_shared_link_with_unlock(&token, unlock_jwt.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(dto) => dto,
|
||||
Err(err) => {
|
||||
if err.kind == ErrorKind::AccessDenied {
|
||||
|
||||
Reference in New Issue
Block a user