feat: path level access control (#52)

BREAKING CHANGE: `--auth` is changed, `--no-auth-access` is removed
This commit is contained in:
sigoden 2022-06-19 11:26:03 +08:00 committed by GitHub
parent 9384cc8587
commit 9c2e9d1503
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 288 additions and 94 deletions

View file

@ -14,7 +14,7 @@ Duf is a simple file server. Support static serve, search, upload, webdav...
- Upload files and folders (Drag & Drop) - Upload files and folders (Drag & Drop)
- Search files - Search files
- Partial responses (Parallel/Resume download) - Partial responses (Parallel/Resume download)
- Authentication - Path level access control
- Support https - Support https
- Support webdav - Support webdav
- Easy to use with curl - Easy to use with curl
@ -88,12 +88,6 @@ Listen on a specific port
duf -p 80 duf -p 80
``` ```
Protect with authentication
```
duf -a admin:admin
```
For a single page application (SPA) For a single page application (SPA)
``` ```
@ -110,27 +104,60 @@ duf --tls-cert my.crt --tls-key my.key
Download a file 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 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 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 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 ## License
Copyright (c) 2022 duf-developers. Copyright (c) 2022 duf-developers.

View file

@ -4,7 +4,7 @@ use std::env;
use std::net::IpAddr; use std::net::IpAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::auth::parse_auth; use crate::auth::AccessControl;
use crate::tls::{load_certs, load_private_key}; use crate::tls::{load_certs, load_private_key};
use crate::BoxResult; use crate::BoxResult;
@ -51,13 +51,10 @@ fn app() -> Command<'static> {
Arg::new("auth") Arg::new("auth")
.short('a') .short('a')
.long("auth") .long("auth")
.help("Use HTTP authentication") .help("Add auth for path")
.value_name("user:pass"), .multiple_values(true)
) .multiple_occurrences(true)
.arg( .value_name("rule"),
Arg::new("no-auth-access")
.long("no-auth-access")
.help("Not required auth when access static files"),
) )
.arg( .arg(
Arg::new("allow-all") Arg::new("allow-all")
@ -118,15 +115,14 @@ pub fn matches() -> ArgMatches {
app().get_matches() app().get_matches()
} }
#[derive(Debug, Clone, Eq, PartialEq)] #[derive(Debug, Clone)]
pub struct Args { pub struct Args {
pub addrs: Vec<IpAddr>, pub addrs: Vec<IpAddr>,
pub port: u16, pub port: u16,
pub path: PathBuf, pub path: PathBuf,
pub path_prefix: String, pub path_prefix: String,
pub uri_prefix: String, pub uri_prefix: String,
pub auth: Option<(String, String)>, pub auth: AccessControl,
pub no_auth_access: bool,
pub allow_upload: bool, pub allow_upload: bool,
pub allow_delete: bool, pub allow_delete: bool,
pub allow_symlink: bool, pub allow_symlink: bool,
@ -160,11 +156,11 @@ impl Args {
format!("/{}/", &path_prefix) format!("/{}/", &path_prefix)
}; };
let cors = matches.is_present("cors"); let cors = matches.is_present("cors");
let auth = match matches.value_of("auth") { let auth: Vec<&str> = matches
Some(auth) => Some(parse_auth(auth)?), .values_of("auth")
None => None, .map(|v| v.collect())
}; .unwrap_or_default();
let no_auth_access = matches.is_present("no-auth-access"); let auth = AccessControl::new(&auth, &uri_prefix)?;
let allow_upload = matches.is_present("allow-all") || matches.is_present("allow-upload"); 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_delete = matches.is_present("allow-all") || matches.is_present("allow-delete");
let allow_symlink = matches.is_present("allow-all") || matches.is_present("allow-symlink"); let allow_symlink = matches.is_present("allow-all") || matches.is_present("allow-symlink");
@ -187,7 +183,6 @@ impl Args {
path_prefix, path_prefix,
uri_prefix, uri_prefix,
auth, auth,
no_auth_access,
cors, cors,
allow_delete, allow_delete,
allow_upload, allow_upload,

View file

@ -1,4 +1,5 @@
use headers::HeaderValue; use headers::HeaderValue;
use hyper::Method;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use md5::Context; use md5::Context;
use std::{ use std::{
@ -7,6 +8,7 @@ use std::{
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::utils::encode_uri;
use crate::BoxResult; use crate::BoxResult;
const REALM: &str = "DUF"; 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 { pub fn generate_www_auth(stale: bool) -> String {
let str_stale = if stale { "stale=true," } else { "" }; let str_stale = if stale { "stale=true," } else { "" };
format!( 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( pub fn valid_digest(
header_value: &HeaderValue, authorization: &HeaderValue,
method: &str, method: &str,
auth_user: &str, auth_user: &str,
auth_pass: &str, auth_pass: &str,
) -> Option<()> { ) -> 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()?; let user_vals = to_headermap(digest_value).ok()?;
if let (Some(username), Some(nonce), Some(user_response)) = ( if let (Some(username), Some(nonce), Some(user_response)) = (
user_vals user_vals

View file

@ -3,6 +3,7 @@ mod auth;
mod server; mod server;
mod streamer; mod streamer;
mod tls; mod tls;
mod utils;
#[macro_use] #[macro_use]
extern crate log; extern crate log;

View 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::streamer::Streamer;
use crate::utils::{decode_uri, encode_uri};
use crate::{Args, BoxResult}; use crate::{Args, BoxResult};
use xml::escape::escape_str_pcdata; use xml::escape::escape_str_pcdata;
@ -19,7 +20,6 @@ use hyper::header::{
CONTENT_TYPE, ORIGIN, RANGE, WWW_AUTHENTICATE, CONTENT_TYPE, ORIGIN, RANGE, WWW_AUTHENTICATE,
}; };
use hyper::{Body, Method, StatusCode, Uri}; use hyper::{Body, Method, StatusCode, Uri};
use percent_encoding::percent_decode;
use serde::Serialize; use serde::Serialize;
use std::fs::Metadata; use std::fs::Metadata;
use std::io::SeekFrom; use std::io::SeekFrom;
@ -86,16 +86,20 @@ impl Server {
pub async fn handle(self: Arc<Self>, req: Request) -> BoxResult<Response> { pub async fn handle(self: Arc<Self>, req: Request) -> BoxResult<Response> {
let mut res = Response::default(); let mut res = Response::default();
if !self.auth_guard(&req, &mut res) {
return Ok(res);
}
let req_path = req.uri().path(); let req_path = req.uri().path();
let headers = req.headers(); let headers = req.headers();
let method = req.method().clone(); 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 { 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); return Ok(res);
} }
@ -106,6 +110,7 @@ impl Server {
return Ok(res); return Ok(res);
} }
}; };
let path = path.as_path(); let path = path.as_path();
let query = req.uri().query().unwrap_or_default(); let query = req.uri().query().unwrap_or_default();
@ -218,7 +223,8 @@ impl Server {
"LOCK" => { "LOCK" => {
// Fake lock // Fake lock
if is_file { 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 { } else {
status_not_found(&mut res); status_not_found(&mut res);
} }
@ -618,11 +624,11 @@ impl Server {
Ok(()) Ok(())
} }
async fn handle_lock(&self, req_path: &str, res: &mut Response) -> BoxResult<()> { async fn handle_lock(&self, req_path: &str, auth: bool, res: &mut Response) -> BoxResult<()> {
let token = if self.args.auth.is_none() { let token = if auth {
Utc::now().timestamp().to_string()
} else {
format!("opaquelocktoken:{}", Uuid::new_v4()) format!("opaquelocktoken:{}", Uuid::new_v4())
} else {
Utc::now().timestamp().to_string()
}; };
res.headers_mut().insert( res.headers_mut().insert(
@ -708,34 +714,13 @@ const DATA =
Ok(()) Ok(())
} }
fn auth_guard(&self, req: &Request, res: &mut Response) -> bool { fn auth_reject(&self, res: &mut Response) {
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); let value = generate_www_auth(false);
set_webdav_headers(res); set_webdav_headers(res);
*res.status_mut() = StatusCode::UNAUTHORIZED;
res.headers_mut().typed_insert(Connection::close()); res.headers_mut().typed_insert(Connection::close());
res.headers_mut() res.headers_mut()
.insert(WWW_AUTHENTICATE, value.parse().unwrap()); .insert(WWW_AUTHENTICATE, value.parse().unwrap());
} *res.status_mut() = StatusCode::UNAUTHORIZED;
pass
} }
async fn is_root_contained(&self, path: &Path) -> bool { async fn is_root_contained(&self, path: &Path) -> bool {
@ -753,7 +738,7 @@ const DATA =
} }
fn extract_path(&self, path: &str) -> Option<PathBuf> { 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) { let slashes_switched = if cfg!(windows) {
decoded_path.replace('/', "\\") decoded_path.replace('/', "\\")
} else { } 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) { fn status_forbid(res: &mut Response) {
*res.status_mut() = StatusCode::FORBIDDEN; *res.status_mut() = StatusCode::FORBIDDEN;
*res.body_mut() = Body::from("Forbidden");
} }
fn status_not_found(res: &mut Response) { fn status_not_found(res: &mut Response) {

12
src/utils.rs Normal file
View file

@ -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()
}

View file

@ -6,7 +6,7 @@ use fixtures::{server, Error, TestServer};
use rstest::rstest; use rstest::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())?; let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 401);
assert!(resp.headers().contains_key("www-authenticate")); assert!(resp.headers().contains_key("www-authenticate"));
@ -17,7 +17,7 @@ fn no_auth(#[with(&["--auth", "user:pass", "-A"])] server: TestServer) -> Result
} }
#[rstest] #[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 url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?; let resp = fetch!(b"PUT", &url).body(b"abc".to_vec()).send()?;
assert_eq!(resp.status(), 401); assert_eq!(resp.status(), 401);
@ -29,10 +29,54 @@ fn auth(#[with(&["--auth", "user:pass", "-A"])] server: TestServer) -> Result<()
} }
#[rstest] #[rstest]
fn auth_skip_access( fn auth_skip(#[with(&["--auth", "/@user:pass@*"])] server: TestServer) -> Result<(), Error> {
#[with(&["--auth", "user:pass", "--no-auth-access"])] server: TestServer,
) -> Result<(), Error> {
let resp = reqwest::blocking::get(server.url())?; let resp = reqwest::blocking::get(server.url())?;
assert_eq!(resp.status(), 200); assert_eq!(resp.status(), 200);
Ok(()) 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(())
}