From 9c2e9d1503ec1eb18cd1195d795a570f82b3117a Mon Sep 17 00:00:00 2001 From: sigoden Date: Sun, 19 Jun 2022 11:26:03 +0800 Subject: [PATCH] feat: path level access control (#52) BREAKING CHANGE: `--auth` is changed, `--no-auth-access` is removed --- README.md | 49 +++++++++++---- src/args.rs | 29 ++++----- src/auth.rs | 164 +++++++++++++++++++++++++++++++++++++++++++++----- src/main.rs | 1 + src/server.rs | 73 +++++++++------------- src/utils.rs | 12 ++++ tests/auth.rs | 54 +++++++++++++++-- 7 files changed, 288 insertions(+), 94 deletions(-) create mode 100644 src/utils.rs 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(()) +}