feat: support downloading via token auth (#603)

pull/604/head
sigoden 2025-08-02 14:37:49 +08:00 committed by GitHub
parent 089d30c5a5
commit 9c9fca75d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 277 additions and 33 deletions

104
Cargo.lock generated
View File

@ -378,6 +378,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "content_inspector"
version = "0.2.4"
@ -446,6 +452,43 @@ dependencies = [
"typenum",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "difflib"
version = "0.4.0"
@ -510,10 +553,12 @@ dependencies = [
"clap_complete",
"content_inspector",
"digest_auth",
"ed25519-dalek",
"form_urlencoded",
"futures-util",
"glob",
"headers",
"hex",
"http-body-util",
"hyper",
"hyper-util",
@ -549,6 +594,30 @@ dependencies = [
"xml-rs",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
@ -580,6 +649,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "flate2"
version = "1.1.1"
@ -1347,6 +1422,16 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
@ -1879,6 +1964,15 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "slab"
version = "0.4.9"
@ -1915,6 +2009,16 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.0"

View File

@ -53,6 +53,8 @@ http-body-util = "0.1"
bytes = "1.5"
pin-project-lite = "0.2"
sha2 = "0.10.8"
ed25519-dalek = "2.2.0"
hex = "0.4.3"
[features]
default = ["tls"]

View File

@ -347,6 +347,7 @@ async function setupIndexPage() {
const $download = document.querySelector(".download");
$download.href = baseUrl() + "?zip";
$download.title = "Download folder as a .zip file";
$download.classList.add("dlwt");
$download.classList.remove("hidden");
}
@ -367,6 +368,10 @@ async function setupIndexPage() {
renderPathsTableHead();
renderPathsTableBody();
if (DATA.user) {
setupDownloadWithToken();
}
}
/**
@ -449,13 +454,13 @@ function addPath(file, index) {
if (DATA.allow_archive) {
actionDownload = `
<div class="action-btn">
<a href="${url}?zip" title="Download folder as a .zip file">${ICONS.download}</a>
<a class="dlwt" href="${url}?zip" title="Download folder as a .zip file" download>${ICONS.download}</a>
</div>`;
}
} else {
actionDownload = `
<div class="action-btn" >
<a href="${url}" title="Download file" download>${ICONS.download}</a>
<a class="dlwt" href="${url}" title="Download file" download>${ICONS.download}</a>
</div>`;
}
if (DATA.allow_delete) {
@ -530,12 +535,39 @@ async function setupAuth() {
$loginBtn.addEventListener("click", async () => {
try {
await checkAuth();
} catch {}
} catch { }
location.reload();
});
}
}
function setupDownloadWithToken() {
document.querySelectorAll("a.dlwt").forEach(link => {
link.addEventListener("click", async e => {
e.preventDefault();
try {
const link = e.currentTarget || e.target;
const originalHref = link.getAttribute("href");
const tokengenUrl = new URL(originalHref);
tokengenUrl.searchParams.set("tokengen", "");
const res = await fetch(tokengenUrl);
if (!res.ok) throw new Error("Failed to fetch token");
const token = await res.text();
const downloadUrl = new URL(originalHref);
downloadUrl.searchParams.set("token", token);
const tempA = document.createElement("a");
tempA.href = downloadUrl.toString();
tempA.download = "";
document.body.appendChild(tempA);
tempA.click();
document.body.removeChild(tempA);
} catch (err) {
alert(`Failed to download, ${err.message}`);
}
});
});
}
function setupSearch() {
const $searchbar = document.querySelector(".searchbar");
$searchbar.classList.remove("hidden");
@ -646,7 +678,7 @@ async function setupEditorPage() {
$editor.value = decoder.decode(dataView);
}
} catch (err) {
alert(`Failed get file, ${err.message}`);
alert(`Failed to get file, ${err.message}`);
}
}

View File

@ -2,11 +2,13 @@ use crate::{args::Args, server::Response, utils::unix_now};
use anyhow::{anyhow, bail, Result};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use ed25519_dalek::{ed25519::signature::SignerMut, Signature, SigningKey};
use headers::HeaderValue;
use hyper::{header::WWW_AUTHENTICATE, Method};
use indexmap::IndexMap;
use lazy_static::lazy_static;
use md5::Context;
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
path::{Path, PathBuf},
@ -14,7 +16,8 @@ use std::{
use uuid::Uuid;
const REALM: &str = "DUFS";
const DIGEST_AUTH_TIMEOUT: u32 = 604800; // 7 days
const DIGEST_AUTH_TIMEOUT: u32 = 60 * 60 * 24 * 7; // 7 days
const TOKEN_EXPIRATION: u64 = 1000 * 60 * 60 * 24 * 3; // 3 days
lazy_static! {
static ref NONCESTARTHASH: Context = {
@ -105,11 +108,21 @@ impl AccessControl {
path: &str,
method: &Method,
authorization: Option<&HeaderValue>,
token: Option<&String>,
guard_options: bool,
) -> (Option<String>, Option<AccessPaths>) {
if self.users.is_empty() {
return (None, Some(AccessPaths::new(AccessPerm::ReadWrite)));
}
if method == Method::GET {
if let Some(token) = token {
if let Ok((user, ap)) = self.verifty_token(token, path) {
return (Some(user), ap.guard(path, method));
}
}
}
if let Some(authorization) = authorization {
if let Some(user) = get_auth_user(authorization) {
if let Some((pass, ap)) = self.users.get(&user) {
@ -135,6 +148,49 @@ impl AccessControl {
(None, None)
}
pub fn generate_token(&self, path: &str, user: &str) -> Result<String> {
let (pass, _) = self
.users
.get(user)
.ok_or_else(|| anyhow!("Not found user '{user}'"))?;
let exp = unix_now().as_millis() as u64 + TOKEN_EXPIRATION;
let message = format!("{path}:{exp}");
let mut signing_key = derive_secret_key(user, pass);
let sig = signing_key.sign(message.as_bytes()).to_bytes();
let mut raw = Vec::with_capacity(64 + 8 + user.len());
raw.extend_from_slice(&sig);
raw.extend_from_slice(&exp.to_be_bytes());
raw.extend_from_slice(user.as_bytes());
Ok(hex::encode(raw))
}
fn verifty_token<'a>(&'a self, token: &str, path: &str) -> Result<(String, &'a AccessPaths)> {
let raw = hex::decode(token)?;
let sig_bytes = &raw[..64];
let exp_bytes = &raw[64..72];
let user_bytes = &raw[72..];
let exp = u64::from_be_bytes(exp_bytes.try_into()?);
if unix_now().as_millis() as u64 > exp {
bail!("Token expired");
}
let user = std::str::from_utf8(user_bytes)?;
let (pass, ap) = self
.users
.get(user)
.ok_or_else(|| anyhow!("Not found user '{user}'"))?;
let sig = Signature::from_bytes(&<[u8; 64]>::try_from(sig_bytes)?);
let message = format!("{path}:{exp}");
derive_secret_key(user, pass).verify(message.as_bytes(), &sig)?;
Ok((user.to_string(), ap))
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
@ -422,6 +478,13 @@ pub fn check_auth(
}
}
fn derive_secret_key(user: &str, pass: &str) -> SigningKey {
let mut hasher = Sha256::new();
hasher.update(format!("{user}:{pass}").as_bytes());
let hash = hasher.finalize();
SigningKey::from_bytes(&hash.into())
}
/// Check if a nonce is still valid.
/// Return an error if it was never valid
fn validate_nonce(nonce: &[u8]) -> Result<bool> {
@ -433,7 +496,7 @@ fn validate_nonce(nonce: &[u8]) -> Result<bool> {
//get time
if let Ok(secs_nonce) = u32::from_str_radix(&n[..8], 16) {
//check time
let now = unix_now()?;
let now = unix_now();
let secs_now = now.as_secs() as u32;
if let Some(dur) = secs_now.checked_sub(secs_nonce) {
@ -513,7 +576,7 @@ fn to_headermap(header: &[u8]) -> Result<HashMap<&[u8], &[u8]>, ()> {
}
fn create_nonce() -> Result<String> {
let now = unix_now()?;
let now = unix_now();
let secs = now.as_secs() as u32;
let mut h = NONCESTARTHASH.clone();
h.consume(secs.to_be_bytes());

View File

@ -149,20 +149,6 @@ impl Server {
let headers = req.headers();
let method = req.method().clone();
let user_agent = headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.map(|v| v.to_lowercase())
.unwrap_or_default();
let is_microsoft_webdav = user_agent.starts_with("microsoft-webdav-miniredir/");
if is_microsoft_webdav {
// microsoft webdav requires this.
res.headers_mut()
.insert(CONNECTION, HeaderValue::from_static("close"));
}
let relative_path = match self.resolve_path(req_path) {
Some(v) => v,
None => {
@ -179,11 +165,34 @@ impl Server {
return Ok(res);
}
let user_agent = headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.map(|v| v.to_lowercase())
.unwrap_or_default();
let is_microsoft_webdav = user_agent.starts_with("microsoft-webdav-miniredir/");
if is_microsoft_webdav {
// microsoft webdav requires this.
res.headers_mut()
.insert(CONNECTION, HeaderValue::from_static("close"));
}
let authorization = headers.get(AUTHORIZATION);
let guard =
self.args
.auth
.guard(&relative_path, &method, authorization, is_microsoft_webdav);
let query = req.uri().query().unwrap_or_default();
let mut query_params: HashMap<String, String> = form_urlencoded::parse(query.as_bytes())
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let guard = self.args.auth.guard(
&relative_path,
&method,
authorization,
query_params.get("token"),
is_microsoft_webdav,
);
let (user, access_paths) = match guard {
(None, None) => {
@ -197,11 +206,6 @@ impl Server {
(x, Some(y)) => (x, y),
};
let query = req.uri().query().unwrap_or_default();
let mut query_params: HashMap<String, String> = form_urlencoded::parse(query.as_bytes())
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
if detect_noscript(&user_agent) {
query_params.insert("noscript".to_string(), String::new());
}
@ -214,6 +218,11 @@ impl Server {
return Ok(res);
}
if has_query_flag(&query_params, "tokengen") {
self.handle_tokengen(&relative_path, user, &mut res).await?;
return Ok(res);
}
let head_only = method == Method::HEAD;
if self.args.path_is_file {
@ -996,6 +1005,24 @@ impl Server {
Ok(())
}
async fn handle_tokengen(
&self,
relative_path: &str,
user: Option<String>,
res: &mut Response,
) -> Result<()> {
let output = self
.args
.auth
.generate_token(relative_path, &user.unwrap_or_default())?;
res.headers_mut()
.typed_insert(ContentType::from(mime_guess::mime::TEXT_PLAIN_UTF_8));
res.headers_mut()
.typed_insert(ContentLength(output.len() as u64));
*res.body_mut() = body_full(output);
Ok(())
}
async fn handle_propfind_dir(
&self,
path: &Path,
@ -1271,7 +1298,7 @@ impl Server {
let guard = self
.args
.auth
.guard(&dest_path, req.method(), authorization, false);
.guard(&dest_path, req.method(), authorization, None, false);
match guard {
(_, Some(_)) => {}

View File

@ -8,10 +8,10 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};
pub fn unix_now() -> Result<Duration> {
pub fn unix_now() -> Duration {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.with_context(|| "Invalid system time")
.expect("Unable to get unix epoch time")
}
pub fn encode_uri(v: &str) -> String {

View File

@ -348,3 +348,19 @@ fn auth_shadow(
Ok(())
}
#[rstest]
fn token_auth(#[with(&["-a", "user:pass@/"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}index.html", server.url());
let resp = fetch!(b"GET", &url).send()?;
assert_eq!(resp.status(), 401);
let url = format!("{}index.html?tokengen", server.url());
let resp = fetch!(b"GET", &url)
.basic_auth("user", Some("pass"))
.send()?;
let token = resp.text()?;
let url = format!("{}index.html?token={token}", server.url());
let resp = fetch!(b"GET", &url).send()?;
assert_eq!(resp.status(), 200);
Ok(())
}