feat: support authentication via token (#522)

pull/595/head
52funny 2025-07-15 21:06:41 +08:00
parent f8b69f4df8
commit f26e22e726
5 changed files with 130 additions and 29 deletions

11
Cargo.lock generated
View File

@ -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]]

View File

@ -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"]

View File

@ -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 = `
<div class="action-btn">
<a href="${url}?zip" title="Download folder as a .zip file">${ICONS.download}</a>
<a href="${url}?token=${token}&zip" title="Download folder as a .zip file">${ICONS.download}</a>
</div>`;
}
} else {
actionDownload = `
<div class="action-btn" >
<a href="${url}" title="Download file" download>${ICONS.download}</a>
<a href="${url}?token=${token}" title="Download file" download>${ICONS.download}</a>
</div>`;
}
if (DATA.allow_delete) {
if (DATA.allow_upload) {
actionMove = `<div onclick="movePath(${index})" class="action-btn" id="moveBtn${index}" title="Move to new path">${ICONS.move}</div>`;
if (!isDir) {
actionEdit = `<a class="action-btn" title="Edit file" target="_blank" href="${url}?edit">${ICONS.edit}</a>`;
actionEdit = `<a class="action-btn" title="Edit file" target="_blank" href="${url}?token=${token}&edit">${ICONS.edit}</a>`;
}
}
actionDelete = `
<div onclick="deletePath(${index})" class="action-btn" id="deleteBtn${index}" title="Delete">${ICONS.delete}</div>`;
}
if (!actionEdit && !isDir) {
actionView = `<a class="action-btn" title="View file" target="_blank" href="${url}?view">${ICONS.view}</a>`;
actionView = `<a class="action-btn" title="View file" target="_blank" href="${url}?token=${token}&view">${ICONS.view}</a>`;
}
let actionCell = `
<td class="cell-actions">
@ -488,7 +491,7 @@ function addPath(file, index) {
${getPathSvg(file.path_type)}
</td>
<td class="path cell-name">
<a href="${url}" ${isDir ? "" : `target="_blank"`}>${encodedName}</a>
<a href="${url}?token=${token}" ${isDir ? "" : `target="_blank"`}>${encodedName}</a>
</td>
<td class="cell-mtime">${formatMtime(file.mtime)}</td>
<td class="cell-size">${sizeDisplay}</td>
@ -530,7 +533,7 @@ async function setupAuth() {
$loginBtn.addEventListener("click", async () => {
try {
await checkAuth();
} catch {}
} catch { }
location.reload();
});
}

View File

@ -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<String, (String, AccessPaths)>,
users: IndexMap<String, (String, UserToken, AccessPaths)>,
tokens: IndexMap<UserToken, String>,
anonymous: Option<AccessPaths>,
}
@ -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<Self> {
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<String>, Option<AccessPaths>) {
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)));
}

View File

@ -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<String, String> = 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<String, String> = 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<String, String> = 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<String>,
token: Option<String>,
paths: Vec<PathItem>,
}