]> OzVa Git service - ozva-cloud/commitdiff
feat: path level access control (#52)
authorsigoden <sigoden@gmail.com>
Sun, 19 Jun 2022 03:26:03 +0000 (11:26 +0800)
committerGitHub <noreply@github.com>
Sun, 19 Jun 2022 03:26:03 +0000 (11:26 +0800)
BREAKING CHANGE: `--auth` is changed, `--no-auth-access` is removed

README.md
src/args.rs
src/auth.rs
src/main.rs
src/server.rs
src/utils.rs [new file with mode: 0644]
tests/auth.rs

index 39459adeeaa023b0ec6ba43d2372cafc3e912a00..4be6c945f091986453695c0efb6a21a51ed47b32 100644 (file)
--- 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
+
+<details>
+
+<summary>Duf supports path level access control with --auth/-a option.</summary>
+
+```
+duf -a <path>@<readwrite>[@<readonly>]
+```
+
+- `<path>`: Path to protected
+- `<readwrite>`: Account with readwrite permission, required
+- `<readonly>`: Account with readonly permission, optional
+
+> `*` as `<readonly>` means `<path>` 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
+```
+
+</details>
+
 ## License
 
 Copyright (c) 2022 duf-developers.
index d5e38975e313a8d892eeffa6c3cc0812d18a0d39..867e3b4c5a0f9c29352dce2f97962170630cbda0 100644 (file)
@@ -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<IpAddr>,
     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,
index ba238856540de587dc6470bd1d99063bb2decec5..b3d47bbd248a6674b98bf291350fcc938efb71fe 100644 (file)
@@ -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<String, PathControl>,
+}
+
+#[derive(Debug, Clone)]
+pub struct PathControl {
+    readwrite: Account,
+    readonly: Option<Account>,
+    share: bool,
+}
+
+impl AccessControl {
+    pub fn new(raw_rules: &[&str], uri_prefix: &str) -> BoxResult<Self> {
+        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<Item = &str> {
+    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<Self> {
+        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
index 30d4ac8f4bfefc4f2f2d552751e844aed114bcfd..5af8add937ea0328a949ceef63242a49e0eeb62f 100644 (file)
@@ -3,6 +3,7 @@ mod auth;
 mod server;
 mod streamer;
 mod tls;
+mod utils;
 
 #[macro_use]
 extern crate log;
index 5bcd8ead91d276db59acfb970564876a054cf835..81f01f7e7987dcaf6f73477edd13a799cea03285 100644 (file)
@@ -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<Self>, req: Request) -> BoxResult<Response> {
         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<PathBuf> {
-        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<HeaderValue>) -> Option<RangeValue> {
     }
 }
 
-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 (file)
index 0000000..ac2c8fe
--- /dev/null
@@ -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<Cow<str>> {
+    percent_encoding::percent_decode(v.as_bytes())
+        .decode_utf8()
+        .ok()
+}
index 48174b2b48a5f435658cf77e6314bb5434605fb1..e95c23964e897379b343785799ac4dd5bb239271 100644 (file)
@@ -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(())
+}