From f26e22e726bc9cf624621a937e9562e0800d00a1 Mon Sep 17 00:00:00 2001 From: 52funny Date: Tue, 15 Jul 2025 21:06:41 +0800 Subject: [PATCH] feat: support authentication via token (#522) --- Cargo.lock | 11 +++---- Cargo.toml | 2 ++ assets/index.js | 19 +++++++----- src/auth.rs | 80 +++++++++++++++++++++++++++++++++++++++++++++++-- src/server.rs | 47 +++++++++++++++++++++-------- 5 files changed, 130 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81a6319..02fe92d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -514,6 +514,7 @@ dependencies = [ "futures-util", "glob", "headers", + "hex", "http-body-util", "hyper", "hyper-util", @@ -527,6 +528,7 @@ dependencies = [ "pin-project-lite", "port_check", "predicates", + "rand 0.9.1", "regex", "reqwest", "rstest", @@ -1444,7 +1446,7 @@ checksum = "b820744eb4dc9b57a3398183639c511b5a26d2ed702cedd3febaa1393caa22cc" dependencies = [ "bytes", "getrandom 0.3.2", - "rand 0.9.0", + "rand 0.9.1", "ring", "rustc-hash", "rustls", @@ -1498,13 +1500,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", - "zerocopy", ] [[package]] @@ -2251,7 +2252,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ "getrandom 0.3.2", - "rand 0.9.0", + "rand 0.9.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 51a1e1d..4ea8a18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,8 @@ http-body-util = "0.1" bytes = "1.5" pin-project-lite = "0.2" sha2 = "0.10.8" +rand = "0.9.1" +hex = "0.4.3" [features] default = ["tls"] diff --git a/assets/index.js b/assets/index.js index b87e354..66feae1 100644 --- a/assets/index.js +++ b/assets/index.js @@ -18,6 +18,7 @@ * @property {boolean} allow_archive * @property {boolean} auth * @property {string} user + * @property {string} token * @property {boolean} dir_exists * @property {string} editable */ @@ -417,12 +418,13 @@ function renderPathsTableHead() { */ function renderPathsTableBody() { if (DATA.paths && DATA.paths.length > 0) { + const token = DATA.token || ""; const len = DATA.paths.length; if (len > 0) { $pathsTable.classList.remove("hidden"); } for (let i = 0; i < len; i++) { - addPath(DATA.paths[i], i); + addPath(DATA.paths[i], i, token); } } else { $emptyFolder.textContent = DIR_EMPTY_NOTE; @@ -434,8 +436,9 @@ function renderPathsTableBody() { * Add pathitem * @param {PathItem} file * @param {number} index + * @param {string} token */ -function addPath(file, index) { +function addPath(file, index, token) { const encodedName = encodedStr(file.name); let url = newUrl(file.name); let actionDelete = ""; @@ -449,27 +452,27 @@ function addPath(file, index) { if (DATA.allow_archive) { actionDownload = `
- ${ICONS.download} + ${ICONS.download}
`; } } else { actionDownload = `
- ${ICONS.download} + ${ICONS.download}
`; } if (DATA.allow_delete) { if (DATA.allow_upload) { actionMove = `
${ICONS.move}
`; if (!isDir) { - actionEdit = `${ICONS.edit}`; + actionEdit = `${ICONS.edit}`; } } actionDelete = `
${ICONS.delete}
`; } if (!actionEdit && !isDir) { - actionView = `${ICONS.view}`; + actionView = `${ICONS.view}`; } let actionCell = ` @@ -488,7 +491,7 @@ function addPath(file, index) { ${getPathSvg(file.path_type)} - ${encodedName} + ${encodedName} ${formatMtime(file.mtime)} ${sizeDisplay} @@ -530,7 +533,7 @@ async function setupAuth() { $loginBtn.addEventListener("click", async () => { try { await checkAuth(); - } catch {} + } catch { } location.reload(); }); } diff --git a/src/auth.rs b/src/auth.rs index fae91bf..9da8ac6 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -10,11 +10,13 @@ use md5::Context; use std::{ collections::HashMap, path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, }; use uuid::Uuid; const REALM: &str = "DUFS"; const DIGEST_AUTH_TIMEOUT: u32 = 604800; // 7 days +const TOKEN_LENGTH: usize = 32; lazy_static! { static ref NONCESTARTHASH: Context = { @@ -28,7 +30,8 @@ lazy_static! { #[derive(Debug, Clone, PartialEq)] pub struct AccessControl { use_hashed_password: bool, - users: IndexMap, + users: IndexMap, + tokens: IndexMap, anonymous: Option, } @@ -37,11 +40,53 @@ impl Default for AccessControl { AccessControl { use_hashed_password: false, users: IndexMap::new(), + tokens: IndexMap::new(), anonymous: Some(AccessPaths::new(AccessPerm::ReadWrite)), } } } +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +pub struct UserToken { + token: String, +} + +impl UserToken { + pub fn new() -> Self { + let mut token_seq = [0; TOKEN_LENGTH]; + rand::fill(&mut token_seq); + + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_nanos(); + + // To avoid collisions, we XOR the current timestamp with the last 8 bytes of a randomly token. + token_seq[TOKEN_LENGTH - 8..] + .iter_mut() + .zip(ts.to_be_bytes().iter()) + .for_each(|(dst, src)| *dst ^= *src); + + Self { + token: hex::encode(token_seq), + } + } +} + +impl std::fmt::Display for UserToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.token) + } +} + +impl From<&String> for UserToken { + fn from(token: &String) -> Self { + Self { + token: token.to_string(), + } + } +} + impl AccessControl { pub fn new(raw_rules: &[&str]) -> Result { if raw_rules.is_empty() { @@ -86,12 +131,20 @@ impl AccessControl { if pass.starts_with("$6$") { use_hashed_password = true; } - users.insert(user.to_string(), (pass.to_string(), access_paths)); + users.insert( + user.to_string(), + (pass.to_string(), UserToken::new(), access_paths), + ); } + let mut tokens = IndexMap::new(); + users.iter().for_each(|(user, (_, token, _))| { + tokens.insert(token.clone(), user.clone()); + }); Ok(Self { use_hashed_password, users, + tokens, anonymous, }) } @@ -100,19 +153,40 @@ impl AccessControl { !self.users.is_empty() } + pub fn user_token(&self, user: &str) -> Option<&UserToken> { + self.users.get(user).map(|(_, token, _)| token) + } + pub fn guard( &self, path: &str, method: &Method, authorization: Option<&HeaderValue>, + query_token: Option<&String>, guard_options: bool, ) -> (Option, Option) { if self.users.is_empty() { return (None, Some(AccessPaths::new(AccessPerm::ReadWrite))); } + + if let Some(token_str) = query_token { + let token: UserToken = token_str.into(); + if let Some(user) = self.tokens.get(&token) { + if let Some((_, _, ap)) = self.users.get(user) { + if method == Method::OPTIONS { + return ( + Some(user.clone()), + Some(AccessPaths::new(AccessPerm::ReadOnly)), + ); + } + return (Some(user.clone()), 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) { + if let Some((pass, _, ap)) = self.users.get(&user) { if method == Method::OPTIONS { return (Some(user), Some(AccessPaths::new(AccessPerm::ReadOnly))); } diff --git a/src/server.rs b/src/server.rs index e78f0d5..cfcac8c 100644 --- a/src/server.rs +++ b/src/server.rs @@ -163,6 +163,13 @@ impl Server { let headers = req.headers(); let method = req.method().clone(); + let query = req.uri().query().unwrap_or_default(); + let query_params: HashMap = form_urlencoded::parse(query.as_bytes()) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + let query_token = query_params.get("token"); + let relative_path = match self.resolve_path(req_path) { Some(v) => v, None => { @@ -180,10 +187,14 @@ impl Server { } let authorization = headers.get(AUTHORIZATION); - let guard = - self.args - .auth - .guard(&relative_path, &method, authorization, is_microsoft_webdav); + + let guard = self.args.auth.guard( + &relative_path, + &method, + authorization, + query_token, + is_microsoft_webdav, + ); let (user, access_paths) = match guard { (None, None) => { @@ -197,11 +208,6 @@ impl Server { (x, Some(y)) => (x, y), }; - let query = req.uri().query().unwrap_or_default(); - let query_params: HashMap = form_urlencoded::parse(query.as_bytes()) - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - if method.as_str() == "CHECKAUTH" { *res.body_mut() = body_full(user.clone().unwrap_or_default()); return Ok(res); @@ -1186,6 +1192,12 @@ impl Server { normalize_path(path.strip_prefix(&self.args.serve_path)?) ); let readwrite = access_paths.perm().readwrite(); + + let token = match &user { + Some(user) => self.args.auth.user_token(user).map(|v| v.to_string()), + None => None, + }; + let data = IndexData { kind: DataKind::Index, href, @@ -1197,6 +1209,7 @@ impl Server { dir_exists: exist, auth: self.args.auth.exist(), user, + token, paths, }; let output = if has_query_flag(query_params, "json") { @@ -1259,11 +1272,18 @@ impl Server { } }; + let query = req.uri().query().unwrap_or_default(); + let query_params: HashMap = form_urlencoded::parse(query.as_bytes()) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + let query_token = query_params.get("token"); + let authorization = headers.get(AUTHORIZATION); - let guard = self - .args - .auth - .guard(&dest_path, req.method(), authorization, false); + let guard = + self.args + .auth + .guard(&dest_path, req.method(), authorization, query_token, false); match guard { (_, Some(_)) => {} @@ -1435,6 +1455,7 @@ struct IndexData { dir_exists: bool, auth: bool, user: Option, + token: Option, paths: Vec, }