From 2f40313a5458c4b08b9b665a3621d2295cddd46c Mon Sep 17 00:00:00 2001 From: sigoden Date: Sun, 5 Jun 2022 00:09:21 +0800 Subject: [PATCH] feat: use digest auth (#14) * feat: switch to digest auth * implement digest auth * cargo fmt * no lock --- Cargo.lock | 66 ++++++++++++++++ Cargo.toml | 3 + README.md | 2 +- src/args.rs | 8 +- src/auth.rs | 209 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/server.rs | 51 +++++++----- 7 files changed, 316 insertions(+), 24 deletions(-) create mode 100644 src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index 88bed88..01b8bad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -297,6 +297,8 @@ dependencies = [ "get_if_addrs", "headers", "hyper", + "lazy_static", + "md5", "mime_guess", "percent-encoding", "rustls", @@ -307,6 +309,7 @@ dependencies = [ "tokio-rustls", "tokio-stream", "tokio-util", + "uuid", ] [[package]] @@ -484,6 +487,17 @@ dependencies = [ "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]] name = "hashbrown" version = "0.11.2" @@ -656,6 +670,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.5.0" @@ -770,6 +790,12 @@ version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" +[[package]] +name = "ppv-lite86" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" + [[package]] name = "proc-macro2" version = "1.0.39" @@ -788,6 +814,36 @@ dependencies = [ "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]] name = "ring" version = "0.16.20" @@ -1098,6 +1154,16 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "version_check" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index 40bef50..55999e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,9 @@ mime_guess = "2.0.4" get_if_addrs = "0.5.3" rustls = { version = "0.20", default-features = false, features = ["tls12"] } rustls-pemfile = "1" +md5 = "0.7.0" +lazy_static = "1.4.0" +uuid = { version = "1.1.1", features = ["v4", "fast-rng"] } [profile.release] lto = true diff --git a/README.md b/README.md index a6d7ac9..a23165b 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ Duf is a simple file server. Support static serve, search, upload, webdav... - Download folder as zip file - Upload files and folders (Drag & Drop) - Search files -- Basic authentication - Partial responses (Parallel/Resume download) +- Authentication - Support https - Support webdav - Easy to use with curl diff --git a/src/args.rs b/src/args.rs index 3341276..55e70e8 100644 --- a/src/args.rs +++ b/src/args.rs @@ -5,6 +5,7 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::{env, fs, io}; +use crate::auth::parse_auth; use crate::BoxResult; const ABOUT: &str = concat!("\n", crate_description!()); // Add extra newline. @@ -115,7 +116,7 @@ pub struct Args { pub path: PathBuf, pub path_prefix: String, pub uri_prefix: String, - pub auth: Option, + pub auth: Option<(String, String)>, pub no_auth_access: bool, pub allow_upload: bool, pub allow_delete: bool, @@ -145,7 +146,10 @@ impl Args { format!("/{}/", &path_prefix) }; 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 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"); diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..d93b911 --- /dev/null +++ b/src/auth.rs @@ -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 { + 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, ()> { + 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() +} diff --git a/src/main.rs b/src/main.rs index e6c8e25..6f2b9e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod args; +mod auth; mod server; pub type BoxResult = Result>; diff --git a/src/server.rs b/src/server.rs index 5fe8360..982022f 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,3 +1,4 @@ +use crate::auth::{generate_www_auth, valid_digest}; use crate::{Args, BoxResult}; use async_walkdir::WalkDir; @@ -34,6 +35,7 @@ use tokio::{fs, io}; use tokio_rustls::TlsAcceptor; use tokio_util::codec::{BytesCodec, FramedRead}; use tokio_util::io::{ReaderStream, StreamReader}; +use uuid::Uuid; type Request = hyper::Request; type Response = hyper::Response; @@ -364,7 +366,10 @@ impl InnerService { async fn handle_zip_dir(&self, path: &Path, res: &mut Response) -> BoxResult<()> { 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(); tokio::spawn(async move { if let Err(e) = zip_dir(&mut writer, &path).await { @@ -509,7 +514,7 @@ impl InnerService { return Ok(()); } }, - None => 1, + None => 0, }; let mut paths = vec![self.to_pathitem(path, &self.args.path).await?.unwrap()]; if depth > 0 { @@ -598,22 +603,26 @@ impl InnerService { } 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( "content-type", "application/xml; charset=utf-8".parse().unwrap(), ); res.headers_mut() - .insert("lock-token", format!("<{}>", now).parse().unwrap()); + .insert("lock-token", format!("<{}>", token).parse().unwrap()); + *res.body_mut() = Body::from(format!( r#" - - {} {} "#, - now, req_path + token, req_path )); Ok(()) } @@ -656,29 +665,29 @@ impl InnerService { } fn auth_guard(&self, req: &Request, res: &mut Response) -> bool { + let method = req.method(); let pass = { match &self.args.auth { None => true, - Some(auth) => match req.headers().get(AUTHORIZATION) { - Some(value) => match value.to_str().ok().map(|v| { - let mut it = v.split(' '); - (it.next(), it.next()) - }) { - Some((Some("Basic"), Some(tail))) => base64::decode(tail) - .ok() - .and_then(|v| String::from_utf8(v).ok()) - .map(|v| v.as_str() == auth) - .unwrap_or_default(), - _ => false, - }, - None => self.args.no_auth_access && req.method() == Method::GET, + 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); status!(res, StatusCode::UNAUTHORIZED); res.headers_mut() - .insert(WWW_AUTHENTICATE, HeaderValue::from_static("Basic")); + .insert(WWW_AUTHENTICATE, value.parse().unwrap()); } pass }