diff --git a/README.md b/README.md
index 39459ad..4be6c94 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ Duf is a simple file server. Support static serve, search, upload, webdav...
- Upload files and folders (Drag & Drop)
- Search files
- Partial responses (Parallel/Resume download)
-- Authentication
+- Path level access control
- Support https
- Support webdav
- Easy to use with curl
@@ -88,12 +88,6 @@ Listen on a specific port
duf -p 80
```
-Protect with authentication
-
-```
-duf -a admin:admin
-```
-
For a single page application (SPA)
```
@@ -110,27 +104,60 @@ duf --tls-cert my.crt --tls-key my.key
Download a file
```
-curl http://127.0.0.1:5000/some-file
+curl http://127.0.0.1:5000/path-to-file
```
Download a folder as zip file
```
-curl -o some-folder.zip http://127.0.0.1:5000/some-folder?zip
+curl -o path-to-folder.zip http://127.0.0.1:5000/path-to-folder?zip
```
Upload a file
```
-curl --upload-file some-file http://127.0.0.1:5000/some-file
+curl --upload-file path-to-file http://127.0.0.1:5000/path-to-file
```
Delete a file/folder
```
-curl -X DELETE http://127.0.0.1:5000/some-file
+curl -X DELETE http://127.0.0.1:5000/path-to-file
```
+## Auth
+
+
+
+Duf supports path level access control with --auth/-a option.
+
+```
+duf -a @[@]
+```
+
+- ``: Path to protected
+- ``: Account with readwrite permission, required
+- ``: Account with readonly permission, optional
+
+> `*` as `` means `` is public, everyone can access/download it.
+
+For example:
+
+```
+duf -a /@admin:pass@* -a /ui@designer:pass1 -A
+```
+- All files/folders are public to access/download.
+- Account `admin:pass` can upload/delete/download any files/folders.
+- Account `designer:pass1` can upload/delete/download any files/folders in the `ui` folder.
+
+Curl with auth:
+
+```
+curl --digest -u designer:pass1 http://127.0.0.1:5000/ui/path-to-file
+```
+
+
+
## License
Copyright (c) 2022 duf-developers.
diff --git a/src/args.rs b/src/args.rs
index d5e3897..867e3b4 100644
--- a/src/args.rs
+++ b/src/args.rs
@@ -4,7 +4,7 @@ use std::env;
use std::net::IpAddr;
use std::path::{Path, PathBuf};
-use crate::auth::parse_auth;
+use crate::auth::AccessControl;
use crate::tls::{load_certs, load_private_key};
use crate::BoxResult;
@@ -51,13 +51,10 @@ fn app() -> Command<'static> {
Arg::new("auth")
.short('a')
.long("auth")
- .help("Use HTTP authentication")
- .value_name("user:pass"),
- )
- .arg(
- Arg::new("no-auth-access")
- .long("no-auth-access")
- .help("Not required auth when access static files"),
+ .help("Add auth for path")
+ .multiple_values(true)
+ .multiple_occurrences(true)
+ .value_name("rule"),
)
.arg(
Arg::new("allow-all")
@@ -118,15 +115,14 @@ pub fn matches() -> ArgMatches {
app().get_matches()
}
-#[derive(Debug, Clone, Eq, PartialEq)]
+#[derive(Debug, Clone)]
pub struct Args {
pub addrs: Vec,
pub port: u16,
pub path: PathBuf,
pub path_prefix: String,
pub uri_prefix: String,
- pub auth: Option<(String, String)>,
- pub no_auth_access: bool,
+ pub auth: AccessControl,
pub allow_upload: bool,
pub allow_delete: bool,
pub allow_symlink: bool,
@@ -160,11 +156,11 @@ impl Args {
format!("/{}/", &path_prefix)
};
let cors = matches.is_present("cors");
- let auth = match matches.value_of("auth") {
- Some(auth) => Some(parse_auth(auth)?),
- None => None,
- };
- let no_auth_access = matches.is_present("no-auth-access");
+ let auth: Vec<&str> = matches
+ .values_of("auth")
+ .map(|v| v.collect())
+ .unwrap_or_default();
+ let auth = AccessControl::new(&auth, &uri_prefix)?;
let allow_upload = matches.is_present("allow-all") || matches.is_present("allow-upload");
let allow_delete = matches.is_present("allow-all") || matches.is_present("allow-delete");
let allow_symlink = matches.is_present("allow-all") || matches.is_present("allow-symlink");
@@ -187,7 +183,6 @@ impl Args {
path_prefix,
uri_prefix,
auth,
- no_auth_access,
cors,
allow_delete,
allow_upload,
diff --git a/src/auth.rs b/src/auth.rs
index ba23885..b3d47bb 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -1,4 +1,5 @@
use headers::HeaderValue;
+use hyper::Method;
use lazy_static::lazy_static;
use md5::Context;
use std::{
@@ -7,6 +8,7 @@ use std::{
};
use uuid::Uuid;
+use crate::utils::encode_uri;
use crate::BoxResult;
const REALM: &str = "DUF";
@@ -20,6 +22,151 @@ lazy_static! {
};
}
+#[derive(Debug, Clone)]
+pub struct AccessControl {
+ rules: HashMap,
+}
+
+#[derive(Debug, Clone)]
+pub struct PathControl {
+ readwrite: Account,
+ readonly: Option,
+ share: bool,
+}
+
+impl AccessControl {
+ pub fn new(raw_rules: &[&str], uri_prefix: &str) -> BoxResult {
+ let mut rules = HashMap::default();
+ if raw_rules.is_empty() {
+ return Ok(Self { rules });
+ }
+ for rule in raw_rules {
+ let parts: Vec<&str> = rule.split('@').collect();
+ let create_err = || format!("Invalid auth `{}`", rule).into();
+ match parts.as_slice() {
+ [path, readwrite] => {
+ let control = PathControl {
+ readwrite: Account::new(readwrite).ok_or_else(create_err)?,
+ readonly: None,
+ share: false,
+ };
+ rules.insert(sanitize_path(path, uri_prefix), control);
+ }
+ [path, readwrite, readonly] => {
+ let (readonly, share) = if *readonly == "*" {
+ (None, true)
+ } else {
+ (Some(Account::new(readonly).ok_or_else(create_err)?), false)
+ };
+ let control = PathControl {
+ readwrite: Account::new(readwrite).ok_or_else(create_err)?,
+ readonly,
+ share,
+ };
+ rules.insert(sanitize_path(path, uri_prefix), control);
+ }
+ _ => return Err(create_err()),
+ }
+ }
+ Ok(Self { rules })
+ }
+
+ pub fn guard(
+ &self,
+ path: &str,
+ method: &Method,
+ authorization: Option<&HeaderValue>,
+ ) -> GuardType {
+ if self.rules.is_empty() {
+ return GuardType::ReadWrite;
+ }
+ let mut controls = vec![];
+ for path in walk_path(path) {
+ if let Some(control) = self.rules.get(path) {
+ controls.push(control);
+ if let Some(authorization) = authorization {
+ let Account { user, pass } = &control.readwrite;
+ if valid_digest(authorization, method.as_str(), user, pass).is_some() {
+ return GuardType::ReadWrite;
+ }
+ }
+ }
+ }
+ if is_readonly_method(method) {
+ for control in controls.into_iter() {
+ if control.share {
+ return GuardType::ReadOnly;
+ }
+ if let Some(authorization) = authorization {
+ if let Some(Account { user, pass }) = &control.readonly {
+ if valid_digest(authorization, method.as_str(), user, pass).is_some() {
+ return GuardType::ReadOnly;
+ }
+ }
+ }
+ }
+ }
+ GuardType::Reject
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub enum GuardType {
+ Reject,
+ ReadWrite,
+ ReadOnly,
+}
+
+impl GuardType {
+ pub fn is_reject(&self) -> bool {
+ *self == GuardType::Reject
+ }
+}
+
+fn sanitize_path(path: &str, uri_prefix: &str) -> String {
+ encode_uri(&format!("{}{}", uri_prefix, path.trim_matches('/')))
+}
+
+fn walk_path(path: &str) -> impl Iterator- {
+ let mut idx = 0;
+ path.split('/').enumerate().map(move |(i, part)| {
+ let end = if i == 0 { 1 } else { idx + part.len() + i };
+ let value = &path[..end];
+ idx += part.len();
+ value
+ })
+}
+
+fn is_readonly_method(method: &Method) -> bool {
+ method == Method::GET
+ || method == Method::OPTIONS
+ || method == Method::HEAD
+ || method.as_str() == "PROPFIND"
+}
+
+#[derive(Debug, Clone)]
+struct Account {
+ user: String,
+ pass: String,
+}
+
+impl Account {
+ fn new(data: &str) -> Option {
+ let p: Vec<&str> = data.trim().split(':').collect();
+ if p.len() != 2 {
+ return None;
+ }
+ let user = p[0];
+ let pass = p[1];
+ let mut h = Context::new();
+ h.consume(format!("{}:{}:{}", user, REALM, pass).as_bytes());
+ Some(Account {
+ user: user.to_owned(),
+ pass: format!("{:x}", h.compute()),
+ })
+ }
+}
+
pub fn generate_www_auth(stale: bool) -> String {
let str_stale = if stale { "stale=true," } else { "" };
format!(
@@ -30,26 +177,13 @@ pub fn generate_www_auth(stale: bool) -> String {
)
}
-pub fn parse_auth(auth: &str) -> BoxResult<(String, String)> {
- let p: Vec<&str> = auth.trim().split(':').collect();
- let err = "Invalid auth value";
- if p.len() != 2 {
- return Err(err.into());
- }
- let user = p[0];
- let pass = p[1];
- let mut h = Context::new();
- h.consume(format!("{}:{}:{}", user, REALM, pass).as_bytes());
- Ok((user.to_owned(), format!("{:x}", h.compute())))
-}
-
pub fn valid_digest(
- header_value: &HeaderValue,
+ authorization: &HeaderValue,
method: &str,
auth_user: &str,
auth_pass: &str,
) -> Option<()> {
- let digest_value = strip_prefix(header_value.as_bytes(), b"Digest ")?;
+ let digest_value = strip_prefix(authorization.as_bytes(), b"Digest ")?;
let user_vals = to_headermap(digest_value).ok()?;
if let (Some(username), Some(nonce), Some(user_response)) = (
user_vals
diff --git a/src/main.rs b/src/main.rs
index 30d4ac8..5af8add 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,6 +3,7 @@ mod auth;
mod server;
mod streamer;
mod tls;
+mod utils;
#[macro_use]
extern crate log;
diff --git a/src/server.rs b/src/server.rs
index 5bcd8ea..81f01f7 100644
--- a/src/server.rs
+++ b/src/server.rs
@@ -1,5 +1,6 @@
-use crate::auth::{generate_www_auth, valid_digest};
+use crate::auth::generate_www_auth;
use crate::streamer::Streamer;
+use crate::utils::{decode_uri, encode_uri};
use crate::{Args, BoxResult};
use xml::escape::escape_str_pcdata;
@@ -19,7 +20,6 @@ use hyper::header::{
CONTENT_TYPE, ORIGIN, RANGE, WWW_AUTHENTICATE,
};
use hyper::{Body, Method, StatusCode, Uri};
-use percent_encoding::percent_decode;
use serde::Serialize;
use std::fs::Metadata;
use std::io::SeekFrom;
@@ -86,16 +86,20 @@ impl Server {
pub async fn handle(self: Arc, req: Request) -> BoxResult {
let mut res = Response::default();
- if !self.auth_guard(&req, &mut res) {
- return Ok(res);
- }
-
let req_path = req.uri().path();
let headers = req.headers();
let method = req.method().clone();
+ let authorization = headers.get(AUTHORIZATION);
+ let guard_type = self.args.auth.guard(req_path, &method, authorization);
+
if req_path == "/favicon.ico" && method == Method::GET {
- self.handle_send_favicon(req.headers(), &mut res).await?;
+ self.handle_send_favicon(headers, &mut res).await?;
+ return Ok(res);
+ }
+
+ if guard_type.is_reject() {
+ self.auth_reject(&mut res);
return Ok(res);
}
@@ -106,6 +110,7 @@ impl Server {
return Ok(res);
}
};
+
let path = path.as_path();
let query = req.uri().query().unwrap_or_default();
@@ -218,7 +223,8 @@ impl Server {
"LOCK" => {
// Fake lock
if is_file {
- self.handle_lock(req_path, &mut res).await?;
+ let has_auth = authorization.is_some();
+ self.handle_lock(req_path, has_auth, &mut res).await?;
} else {
status_not_found(&mut res);
}
@@ -618,11 +624,11 @@ impl Server {
Ok(())
}
- async fn handle_lock(&self, req_path: &str, res: &mut Response) -> BoxResult<()> {
- let token = if self.args.auth.is_none() {
- Utc::now().timestamp().to_string()
- } else {
+ async fn handle_lock(&self, req_path: &str, auth: bool, res: &mut Response) -> BoxResult<()> {
+ let token = if auth {
format!("opaquelocktoken:{}", Uuid::new_v4())
+ } else {
+ Utc::now().timestamp().to_string()
};
res.headers_mut().insert(
@@ -708,34 +714,13 @@ const DATA =
Ok(())
}
- fn auth_guard(&self, req: &Request, res: &mut Response) -> bool {
- let method = req.method();
- let pass = {
- match &self.args.auth {
- None => true,
- Some((user, pass)) => match req.headers().get(AUTHORIZATION) {
- Some(value) => {
- valid_digest(value, method.as_str(), user.as_str(), pass.as_str()).is_some()
- }
- None => {
- self.args.no_auth_access
- && (method == Method::GET
- || method == Method::OPTIONS
- || method == Method::HEAD
- || method.as_str() == "PROPFIND")
- }
- },
- }
- };
- if !pass {
- let value = generate_www_auth(false);
- set_webdav_headers(res);
- *res.status_mut() = StatusCode::UNAUTHORIZED;
- res.headers_mut().typed_insert(Connection::close());
- res.headers_mut()
- .insert(WWW_AUTHENTICATE, value.parse().unwrap());
- }
- pass
+ fn auth_reject(&self, res: &mut Response) {
+ let value = generate_www_auth(false);
+ set_webdav_headers(res);
+ res.headers_mut().typed_insert(Connection::close());
+ res.headers_mut()
+ .insert(WWW_AUTHENTICATE, value.parse().unwrap());
+ *res.status_mut() = StatusCode::UNAUTHORIZED;
}
async fn is_root_contained(&self, path: &Path) -> bool {
@@ -753,7 +738,7 @@ const DATA =
}
fn extract_path(&self, path: &str) -> Option {
- let decoded_path = percent_decode(path[1..].as_bytes()).decode_utf8().ok()?;
+ let decoded_path = decode_uri(&path[1..])?;
let slashes_switched = if cfg!(windows) {
decoded_path.replace('/', "\\")
} else {
@@ -1023,13 +1008,9 @@ fn parse_range(headers: &HeaderMap) -> Option {
}
}
-fn encode_uri(v: &str) -> String {
- let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
- parts.join("/")
-}
-
fn status_forbid(res: &mut Response) {
*res.status_mut() = StatusCode::FORBIDDEN;
+ *res.body_mut() = Body::from("Forbidden");
}
fn status_not_found(res: &mut Response) {
diff --git a/src/utils.rs b/src/utils.rs
new file mode 100644
index 0000000..ac2c8fe
--- /dev/null
+++ b/src/utils.rs
@@ -0,0 +1,12 @@
+use std::borrow::Cow;
+
+pub fn encode_uri(v: &str) -> String {
+ let parts: Vec<_> = v.split('/').map(urlencoding::encode).collect();
+ parts.join("/")
+}
+
+pub fn decode_uri(v: &str) -> Option> {
+ percent_encoding::percent_decode(v.as_bytes())
+ .decode_utf8()
+ .ok()
+}
diff --git a/tests/auth.rs b/tests/auth.rs
index 48174b2..e95c239 100644
--- a/tests/auth.rs
+++ b/tests/auth.rs
@@ -6,7 +6,7 @@ use fixtures::{server, Error, TestServer};
use rstest::rstest;
#[rstest]
-fn no_auth(#[with(&["--auth", "user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
+fn no_auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 401);
assert!(resp.headers().contains_key("www-authenticate"));
@@ -17,7 +17,7 @@ fn no_auth(#[with(&["--auth", "user:pass", "-A"])] server: TestServer) -> Result
}
#[rstest]
-fn auth(#[with(&["--auth", "user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
+fn auth(#[with(&["--auth", "/@user:pass", "-A"])] server: TestServer) -> Result<(), Error> {
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401);
@@ -29,10 +29,54 @@ fn auth(#[with(&["--auth", "user:pass", "-A"])] server: TestServer) -> Result<()
}
#[rstest]
-fn auth_skip_access(
- #[with(&["--auth", "user:pass", "--no-auth-access"])] server: TestServer,
-) -> Result<(), Error> {
+fn auth_skip(#[with(&["--auth", "/@user:pass@*"])] server: TestServer) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 200);
Ok(())
}
+
+#[rstest]
+fn auth_readonly(
+ #[with(&["--auth", "/@user:pass@user2:pass2", "-A"])] server: TestServer,
+) -> Result<(), Error> {
+ let url = format!("{}index.html", server.url());
+ let resp = fetch!(b"GET", &url).send()?;
+ assert_eq!(resp.status(), 401);
+ let resp = fetch!(b"GET", &url).send_with_digest_auth("user2", "pass2")?;
+ assert_eq!(resp.status(), 200);
+ let url = format!("{}file1", server.url());
+ let resp = fetch!(b"PUT", &url)
+ .body(b"abc".to_vec())
+ .send_with_digest_auth("user2", "pass2")?;
+ assert_eq!(resp.status(), 401);
+ Ok(())
+}
+
+#[rstest]
+fn auth_nest(
+ #[with(&["--auth", "/@user:pass@user2:pass2", "--auth", "/dira@user3:pass3", "-A"])]
+ server: TestServer,
+) -> Result<(), Error> {
+ let url = format!("{}dira/file1", server.url());
+ let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
+ assert_eq!(resp.status(), 401);
+ let resp = fetch!(b"PUT", &url)
+ .body(b"abc".to_vec())
+ .send_with_digest_auth("user3", "pass3")?;
+ assert_eq!(resp.status(), 201);
+ let resp = fetch!(b"PUT", &url)
+ .body(b"abc".to_vec())
+ .send_with_digest_auth("user", "pass")?;
+ assert_eq!(resp.status(), 201);
+ Ok(())
+}
+
+#[rstest]
+fn auth_nest_share(
+ #[with(&["--auth", "/@user:pass@*", "--auth", "/dira@user3:pass3", "-A"])] server: TestServer,
+) -> Result<(), Error> {
+ let url = format!("{}index.html", server.url());
+ let resp = fetch!(b"GET", &url).send()?;
+ assert_eq!(resp.status(), 200);
+ Ok(())
+}