feat: use digest auth (#14)

* feat: switch to digest auth

* implement digest auth

* cargo fmt

* no lock
This commit is contained in:
sigoden 2022-06-05 00:09:21 +08:00 committed by GitHub
parent 05155aa532
commit 2f40313a54
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 316 additions and 24 deletions

66
Cargo.lock generated
View file

@ -297,6 +297,8 @@ dependencies = [
"get_if_addrs", "get_if_addrs",
"headers", "headers",
"hyper", "hyper",
"lazy_static",
"md5",
"mime_guess", "mime_guess",
"percent-encoding", "percent-encoding",
"rustls", "rustls",
@ -307,6 +309,7 @@ dependencies = [
"tokio-rustls", "tokio-rustls",
"tokio-stream", "tokio-stream",
"tokio-util", "tokio-util",
"uuid",
] ]
[[package]] [[package]]
@ -484,6 +487,17 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "getrandom"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad"
dependencies = [
"cfg-if",
"libc",
"wasi 0.10.0+wasi-snapshot-preview1",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.11.2" version = "0.11.2"
@ -656,6 +670,12 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.5.0" version = "2.5.0"
@ -770,6 +790,12 @@ version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae"
[[package]]
name = "ppv-lite86"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.39" version = "1.0.39"
@ -788,6 +814,36 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7"
dependencies = [
"getrandom",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.16.20" version = "0.16.20"
@ -1098,6 +1154,16 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "uuid"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6d5d669b51467dcf7b2f1a796ce0f955f05f01cafda6c19d6e95f730df29238"
dependencies = [
"getrandom",
"rand",
]
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.4" version = "0.9.4"

View file

@ -31,6 +31,9 @@ mime_guess = "2.0.4"
get_if_addrs = "0.5.3" get_if_addrs = "0.5.3"
rustls = { version = "0.20", default-features = false, features = ["tls12"] } rustls = { version = "0.20", default-features = false, features = ["tls12"] }
rustls-pemfile = "1" rustls-pemfile = "1"
md5 = "0.7.0"
lazy_static = "1.4.0"
uuid = { version = "1.1.1", features = ["v4", "fast-rng"] }
[profile.release] [profile.release]
lto = true lto = true

View file

@ -13,8 +13,8 @@ Duf is a simple file server. Support static serve, search, upload, webdav...
- Download folder as zip file - Download folder as zip file
- Upload files and folders (Drag & Drop) - Upload files and folders (Drag & Drop)
- Search files - Search files
- Basic authentication
- Partial responses (Parallel/Resume download) - Partial responses (Parallel/Resume download)
- Authentication
- Support https - Support https
- Support webdav - Support webdav
- Easy to use with curl - Easy to use with curl

View file

@ -5,6 +5,7 @@ use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::{env, fs, io}; use std::{env, fs, io};
use crate::auth::parse_auth;
use crate::BoxResult; use crate::BoxResult;
const ABOUT: &str = concat!("\n", crate_description!()); // Add extra newline. const ABOUT: &str = concat!("\n", crate_description!()); // Add extra newline.
@ -115,7 +116,7 @@ pub struct Args {
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>, pub auth: Option<(String, String)>,
pub no_auth_access: bool, pub no_auth_access: bool,
pub allow_upload: bool, pub allow_upload: bool,
pub allow_delete: bool, pub allow_delete: bool,
@ -145,7 +146,10 @@ impl Args {
format!("/{}/", &path_prefix) format!("/{}/", &path_prefix)
}; };
let cors = matches.is_present("cors"); let cors = matches.is_present("cors");
let auth = matches.value_of("auth").map(|v| v.to_owned()); 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 no_auth_access = matches.is_present("no-auth-access");
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");

209
src/auth.rs Normal file
View file

@ -0,0 +1,209 @@
use headers::HeaderValue;
use lazy_static::lazy_static;
use md5::Context;
use std::{
collections::HashMap,
time::{SystemTime, UNIX_EPOCH},
};
use uuid::Uuid;
use crate::BoxResult;
const REALM: &str = "DUF";
lazy_static! {
static ref NONCESTARTHASH: Context = {
let mut h = Context::new();
h.consume(Uuid::new_v4().as_bytes());
h.consume(std::process::id().to_be_bytes());
h
};
}
pub fn generate_www_auth(stale: bool) -> String {
let str_stale = if stale { "stale=true," } else { "" };
format!(
"Digest realm=\"{}\",nonce=\"{}\",{}qop=\"auth\",algorithm=\"MD5\"",
REALM,
create_nonce(),
str_stale
)
}
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,
method: &str,
auth_user: &str,
auth_pass: &str,
) -> Option<()> {
let digest_value = strip_prefix(header_value.as_bytes(), b"Digest ")?;
let user_vals = to_headermap(digest_value).ok()?;
if let (Some(username), Some(nonce), Some(user_response)) = (
user_vals
.get(b"username".as_ref())
.and_then(|b| std::str::from_utf8(*b).ok()),
user_vals.get(b"nonce".as_ref()),
user_vals.get(b"response".as_ref()),
) {
match validate_nonce(nonce) {
Ok(true) => {}
_ => return None,
}
if auth_user != username {
return None;
}
let mut ha = Context::new();
ha.consume(method);
ha.consume(b":");
if let Some(uri) = user_vals.get(b"uri".as_ref()) {
ha.consume(uri);
}
let ha = format!("{:x}", ha.compute());
let mut correct_response = None;
if let Some(qop) = user_vals.get(b"qop".as_ref()) {
if qop == &b"auth".as_ref() || qop == &b"auth-int".as_ref() {
correct_response = Some({
let mut c = Context::new();
c.consume(&auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
if let Some(nc) = user_vals.get(b"nc".as_ref()) {
c.consume(nc);
}
c.consume(b":");
if let Some(cnonce) = user_vals.get(b"cnonce".as_ref()) {
c.consume(cnonce);
}
c.consume(b":");
c.consume(qop);
c.consume(b":");
c.consume(&*ha);
format!("{:x}", c.compute())
});
}
}
let correct_response = match correct_response {
Some(r) => r,
None => {
let mut c = Context::new();
c.consume(&auth_pass);
c.consume(b":");
c.consume(nonce);
c.consume(b":");
c.consume(&*ha);
format!("{:x}", c.compute())
}
};
if correct_response.as_bytes() == *user_response {
// grant access
return Some(());
}
}
None
}
/// Check if a nonce is still valid.
/// Return an error if it was never valid
fn validate_nonce(nonce: &[u8]) -> Result<bool, ()> {
if nonce.len() != 34 {
return Err(());
}
//parse hex
if let Ok(n) = std::str::from_utf8(nonce) {
//get time
if let Ok(secs_nonce) = u32::from_str_radix(&n[..8], 16) {
//check time
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
let secs_now = now.as_secs() as u32;
if let Some(dur) = secs_now.checked_sub(secs_nonce) {
//check hash
let mut h = NONCESTARTHASH.clone();
h.consume(secs_nonce.to_be_bytes());
let h = format!("{:x}", h.compute());
if h[..26] == n[8..34] {
return Ok(dur < 300); // from the last 5min
//Authentication-Info ?
}
}
}
}
Err(())
}
fn strip_prefix<'a>(search: &'a [u8], prefix: &[u8]) -> Option<&'a [u8]> {
let l = prefix.len();
if search.len() < l {
return None;
}
if &search[..l] == prefix {
Some(&search[l..])
} else {
None
}
}
fn to_headermap(header: &[u8]) -> Result<HashMap<&[u8], &[u8]>, ()> {
let mut sep = Vec::new();
let mut asign = Vec::new();
let mut i: usize = 0;
let mut esc = false;
for c in header {
match (c, esc) {
(b'=', false) => asign.push(i),
(b',', false) => sep.push(i),
(b'"', false) => esc = true,
(b'"', true) => esc = false,
_ => {}
}
i += 1;
}
sep.push(i); // same len for both Vecs
i = 0;
let mut ret = HashMap::new();
for (&k, &a) in sep.iter().zip(asign.iter()) {
while header[i] == b' ' {
i += 1;
}
if a <= i || k <= 1 + a {
//keys and vals must contain one char
return Err(());
}
let key = &header[i..a];
let val = if header[1 + a] == b'"' && header[k - 1] == b'"' {
//escaped
&header[2 + a..k - 1]
} else {
//not escaped
&header[1 + a..k]
};
i = 1 + k;
ret.insert(key, val);
}
Ok(ret)
}
fn create_nonce() -> String {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
let secs = now.as_secs() as u32;
let mut h = NONCESTARTHASH.clone();
h.consume(secs.to_be_bytes());
let n = format!("{:08x}{:032x}", secs, h.compute());
n[..34].to_string()
}

View file

@ -1,4 +1,5 @@
mod args; mod args;
mod auth;
mod server; mod server;
pub type BoxResult<T> = Result<T, Box<dyn std::error::Error>>; pub type BoxResult<T> = Result<T, Box<dyn std::error::Error>>;

View file

@ -1,3 +1,4 @@
use crate::auth::{generate_www_auth, valid_digest};
use crate::{Args, BoxResult}; use crate::{Args, BoxResult};
use async_walkdir::WalkDir; use async_walkdir::WalkDir;
@ -34,6 +35,7 @@ use tokio::{fs, io};
use tokio_rustls::TlsAcceptor; use tokio_rustls::TlsAcceptor;
use tokio_util::codec::{BytesCodec, FramedRead}; use tokio_util::codec::{BytesCodec, FramedRead};
use tokio_util::io::{ReaderStream, StreamReader}; use tokio_util::io::{ReaderStream, StreamReader};
use uuid::Uuid;
type Request = hyper::Request<Body>; type Request = hyper::Request<Body>;
type Response = hyper::Response<Body>; type Response = hyper::Response<Body>;
@ -364,7 +366,10 @@ impl InnerService {
async fn handle_zip_dir(&self, path: &Path, res: &mut Response) -> BoxResult<()> { async fn handle_zip_dir(&self, path: &Path, res: &mut Response) -> BoxResult<()> {
let (mut writer, reader) = tokio::io::duplex(BUF_SIZE); let (mut writer, reader) = tokio::io::duplex(BUF_SIZE);
let filename = path.file_name().unwrap().to_str().unwrap(); let filename = path
.file_name()
.and_then(|v| v.to_str())
.ok_or_else(|| format!("Failed to get name of `{}`", path.display()))?;
let path = path.to_owned(); let path = path.to_owned();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = zip_dir(&mut writer, &path).await { if let Err(e) = zip_dir(&mut writer, &path).await {
@ -509,7 +514,7 @@ impl InnerService {
return Ok(()); return Ok(());
} }
}, },
None => 1, None => 0,
}; };
let mut paths = vec![self.to_pathitem(path, &self.args.path).await?.unwrap()]; let mut paths = vec![self.to_pathitem(path, &self.args.path).await?.unwrap()];
if depth > 0 { if depth > 0 {
@ -598,22 +603,26 @@ impl InnerService {
} }
async fn handle_lock(&self, req_path: &str, res: &mut Response) -> BoxResult<()> { async fn handle_lock(&self, req_path: &str, res: &mut Response) -> BoxResult<()> {
let now = Utc::now().timestamp(); let token = if self.args.auth.is_none() {
Utc::now().timestamp().to_string()
} else {
format!("opaquelocktoken:{}", Uuid::new_v4())
};
res.headers_mut().insert( res.headers_mut().insert(
"content-type", "content-type",
"application/xml; charset=utf-8".parse().unwrap(), "application/xml; charset=utf-8".parse().unwrap(),
); );
res.headers_mut() res.headers_mut()
.insert("lock-token", format!("<{}>", now).parse().unwrap()); .insert("lock-token", format!("<{}>", token).parse().unwrap());
*res.body_mut() = Body::from(format!( *res.body_mut() = Body::from(format!(
r#"<?xml version="1.0" encoding="utf-8"?> r#"<?xml version="1.0" encoding="utf-8"?>
<D:prop xmlns:D="DAV:"><D:lockdiscovery><D:activelock> <D:prop xmlns:D="DAV:"><D:lockdiscovery><D:activelock>
<D:locktype><D:write/></D:locktype>
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktoken><D:href>{}</D:href></D:locktoken> <D:locktoken><D:href>{}</D:href></D:locktoken>
<D:lockroot><D:href>{}</D:href></D:lockroot> <D:lockroot><D:href>{}</D:href></D:lockroot>
</D:activelock></D:lockdiscovery></D:prop>"#, </D:activelock></D:lockdiscovery></D:prop>"#,
now, req_path token, req_path
)); ));
Ok(()) Ok(())
} }
@ -656,29 +665,29 @@ impl InnerService {
} }
fn auth_guard(&self, req: &Request, res: &mut Response) -> bool { fn auth_guard(&self, req: &Request, res: &mut Response) -> bool {
let method = req.method();
let pass = { let pass = {
match &self.args.auth { match &self.args.auth {
None => true, None => true,
Some(auth) => match req.headers().get(AUTHORIZATION) { Some((user, pass)) => match req.headers().get(AUTHORIZATION) {
Some(value) => match value.to_str().ok().map(|v| { Some(value) => {
let mut it = v.split(' '); valid_digest(value, method.as_str(), user.as_str(), pass.as_str()).is_some()
(it.next(), it.next()) }
}) { None => {
Some((Some("Basic"), Some(tail))) => base64::decode(tail) self.args.no_auth_access
.ok() && (method == Method::GET
.and_then(|v| String::from_utf8(v).ok()) || method == Method::OPTIONS
.map(|v| v.as_str() == auth) || method == Method::HEAD
.unwrap_or_default(), || method.as_str() == "PROPFIND")
_ => false, }
},
None => self.args.no_auth_access && req.method() == Method::GET,
}, },
} }
}; };
if !pass { if !pass {
let value = generate_www_auth(false);
status!(res, StatusCode::UNAUTHORIZED); status!(res, StatusCode::UNAUTHORIZED);
res.headers_mut() res.headers_mut()
.insert(WWW_AUTHENTICATE, HeaderValue::from_static("Basic")); .insert(WWW_AUTHENTICATE, value.parse().unwrap());
} }
pass pass
} }