feat: support downloading via token auth (#603)
parent
089d30c5a5
commit
9c9fca75d3
|
@ -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"
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
69
src/auth.rs
69
src/auth.rs
|
@ -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());
|
||||
|
|
|
@ -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(_)) => {}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue