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:
abnvle
2026-05-05 21:56:49 +02:00
parent e4ea2211ea
commit 3bc5c0873b
4 changed files with 260 additions and 31 deletions
+55 -27
View File
@@ -159,6 +159,60 @@ impl ShareService {
Ok(share)
}
/// `allow_password_protected = true` only after the caller's right to
/// bypass has been verified (e.g. via an unlock cookie).
async fn fetch_share_resolved(
&self,
token: &str,
allow_password_protected: bool,
) -> Result<ShareDto, DomainError> {
let share = self
.share_repository
.find_share_by_token(token)
.await
.map_err(|e| {
ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e))
})?;
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
if share.has_password() && !allow_password_protected {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Share",
"This share is password protected",
));
}
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
}
pub fn issue_unlock_jwt(&self, share_token: &str) -> Result<String, DomainError> {
crate::infrastructure::services::share_unlock_cookie::issue_jwt(
&self.config.auth.jwt_secret,
share_token,
crate::infrastructure::services::share_unlock_cookie::DEFAULT_TTL_SECS,
)
}
pub async fn get_shared_link_with_unlock(
&self,
token: &str,
unlock_jwt: Option<&str>,
) -> Result<ShareDto, DomainError> {
let unlocked = match unlock_jwt {
Some(jwt) => crate::infrastructure::services::share_unlock_cookie::verify_jwt(
&self.config.auth.jwt_secret,
token,
jwt,
),
None => false,
};
self.fetch_share_resolved(token, unlocked).await
}
}
impl ShareUseCase for ShareService {
@@ -221,33 +275,7 @@ impl ShareUseCase for ShareService {
}
async fn get_shared_link_by_token(&self, token: &str) -> Result<ShareDto, DomainError> {
// Find the shared link by its token
let share = self
.share_repository
.find_share_by_token(token)
.await
.map_err(|e| {
ShareServiceError::NotFound(format!("Share with token {} not found: {}", token, e))
})?;
// Check if it has expired
if share.is_expired() {
return Err(ShareServiceError::Expired.into());
}
// SECURITY: If the share is password-protected, do NOT return
// the full metadata. Force the caller to verify the password
// first via `verify_shared_link_password`.
if share.has_password() {
return Err(DomainError::new(
ErrorKind::AccessDenied,
"Share",
"This share is password protected",
));
}
// Convert the entity to DTO for the response
Ok(ShareDto::from_entity(&share, &self.config.base_url()))
self.fetch_share_resolved(token, false).await
}
async fn get_shared_links_for_item(