597 lines
18 KiB
Rust
597 lines
18 KiB
Rust
use anyhow::{anyhow, bail, Result};
|
|
use base64::{engine::general_purpose, Engine as _};
|
|
use headers::HeaderValue;
|
|
use hyper::Method;
|
|
use indexmap::IndexMap;
|
|
use lazy_static::lazy_static;
|
|
use md5::Context;
|
|
use std::{
|
|
collections::HashMap,
|
|
path::{Path, PathBuf},
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
use crate::{args::Args, utils::unix_now};
|
|
|
|
const REALM: &str = "DUFS";
|
|
const DIGEST_AUTH_TIMEOUT: u32 = 604800; // 7 days
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct AccessControl {
|
|
use_hashed_password: bool,
|
|
users: IndexMap<String, (String, AccessPaths)>,
|
|
anony: Option<AccessPaths>,
|
|
}
|
|
|
|
impl Default for AccessControl {
|
|
fn default() -> Self {
|
|
AccessControl {
|
|
use_hashed_password: false,
|
|
anony: Some(AccessPaths::new(AccessPerm::ReadWrite)),
|
|
users: IndexMap::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AccessControl {
|
|
pub fn new(raw_rules: &[&str]) -> Result<Self> {
|
|
if raw_rules.is_empty() {
|
|
return Ok(Default::default());
|
|
}
|
|
let new_raw_rules = compact_split_rules(raw_rules);
|
|
if new_raw_rules.len() != raw_rules.len() {
|
|
eprintln!("Warning: deprecate the use of `|` to separate auth rules.")
|
|
}
|
|
let mut use_hashed_password = false;
|
|
let create_err = |v: &str| anyhow!("Invalid auth `{v}`");
|
|
let mut anony = None;
|
|
let mut anony_paths = vec![];
|
|
let mut users = IndexMap::new();
|
|
for rule in &new_raw_rules {
|
|
let (account, paths) = split_account_paths(rule).ok_or_else(|| create_err(rule))?;
|
|
if account.is_empty() && anony.is_some() {
|
|
bail!("Invalid auth, duplicate anonymous rules");
|
|
}
|
|
let mut access_paths = AccessPaths::default();
|
|
for item in paths.trim_matches(',').split(',') {
|
|
let (path, perm) = match item.split_once(':') {
|
|
None => (item, AccessPerm::ReadOnly),
|
|
Some((path, "rw")) => (path, AccessPerm::ReadWrite),
|
|
_ => return Err(create_err(rule)),
|
|
};
|
|
if account.is_empty() {
|
|
anony_paths.push((path, perm));
|
|
}
|
|
access_paths.add(path, perm);
|
|
}
|
|
if account.is_empty() {
|
|
anony = Some(access_paths);
|
|
} else if let Some((user, pass)) = account.split_once(':') {
|
|
if user.is_empty() || pass.is_empty() {
|
|
return Err(create_err(rule));
|
|
}
|
|
if pass.starts_with("$6$") {
|
|
use_hashed_password = true;
|
|
}
|
|
users.insert(user.to_string(), (pass.to_string(), access_paths));
|
|
} else {
|
|
return Err(create_err(rule));
|
|
}
|
|
}
|
|
for (path, perm) in anony_paths {
|
|
for (_, (_, paths)) in users.iter_mut() {
|
|
paths.add(path, perm)
|
|
}
|
|
}
|
|
Ok(Self {
|
|
use_hashed_password,
|
|
users,
|
|
anony,
|
|
})
|
|
}
|
|
|
|
pub fn exist(&self) -> bool {
|
|
!self.users.is_empty()
|
|
}
|
|
|
|
pub fn guard(
|
|
&self,
|
|
path: &str,
|
|
method: &Method,
|
|
authorization: Option<&HeaderValue>,
|
|
) -> (Option<String>, Option<AccessPaths>) {
|
|
if let Some(authorization) = authorization {
|
|
if let Some(user) = get_auth_user(authorization) {
|
|
if let Some((pass, paths)) = self.users.get(&user) {
|
|
if method == Method::OPTIONS {
|
|
return (Some(user), Some(AccessPaths::new(AccessPerm::ReadOnly)));
|
|
}
|
|
if check_auth(authorization, method.as_str(), &user, pass).is_some() {
|
|
return (Some(user), paths.find(path, !is_readonly_method(method)));
|
|
} else {
|
|
return (None, None);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if method == Method::OPTIONS {
|
|
return (None, Some(AccessPaths::new(AccessPerm::ReadOnly)));
|
|
}
|
|
|
|
if let Some(paths) = self.anony.as_ref() {
|
|
return (None, paths.find(path, !is_readonly_method(method)));
|
|
}
|
|
|
|
(None, None)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
|
pub struct AccessPaths {
|
|
perm: AccessPerm,
|
|
children: IndexMap<String, AccessPaths>,
|
|
}
|
|
|
|
impl AccessPaths {
|
|
pub fn new(perm: AccessPerm) -> Self {
|
|
Self {
|
|
perm,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn perm(&self) -> AccessPerm {
|
|
self.perm
|
|
}
|
|
|
|
fn set_perm(&mut self, perm: AccessPerm) {
|
|
if self.perm < perm {
|
|
self.perm = perm
|
|
}
|
|
}
|
|
|
|
pub fn add(&mut self, path: &str, perm: AccessPerm) {
|
|
let path = path.trim_matches('/');
|
|
if path.is_empty() {
|
|
self.set_perm(perm);
|
|
} else {
|
|
let parts: Vec<&str> = path.split('/').collect();
|
|
self.add_impl(&parts, perm);
|
|
}
|
|
}
|
|
|
|
fn add_impl(&mut self, parts: &[&str], perm: AccessPerm) {
|
|
let parts_len = parts.len();
|
|
if parts_len == 0 {
|
|
self.set_perm(perm);
|
|
return;
|
|
}
|
|
let child = self.children.entry(parts[0].to_string()).or_default();
|
|
child.add_impl(&parts[1..], perm)
|
|
}
|
|
|
|
pub fn find(&self, path: &str, writable: bool) -> Option<AccessPaths> {
|
|
let parts: Vec<&str> = path
|
|
.trim_matches('/')
|
|
.split('/')
|
|
.filter(|v| !v.is_empty())
|
|
.collect();
|
|
let target = self.find_impl(&parts, self.perm)?;
|
|
if writable && !target.perm().readwrite() {
|
|
return None;
|
|
}
|
|
Some(target)
|
|
}
|
|
|
|
fn find_impl(&self, parts: &[&str], perm: AccessPerm) -> Option<AccessPaths> {
|
|
let perm = self.perm.max(perm);
|
|
if parts.is_empty() {
|
|
if perm.indexonly() {
|
|
return Some(self.clone());
|
|
} else {
|
|
return Some(AccessPaths::new(perm));
|
|
}
|
|
}
|
|
let child = match self.children.get(parts[0]) {
|
|
Some(v) => v,
|
|
None => {
|
|
if perm.indexonly() {
|
|
return None;
|
|
} else {
|
|
return Some(AccessPaths::new(perm));
|
|
}
|
|
}
|
|
};
|
|
child.find_impl(&parts[1..], perm)
|
|
}
|
|
|
|
pub fn child_paths(&self) -> Vec<&String> {
|
|
self.children.keys().collect()
|
|
}
|
|
|
|
pub fn leaf_paths(&self, base: &Path) -> Vec<PathBuf> {
|
|
if !self.perm().indexonly() {
|
|
return vec![base.to_path_buf()];
|
|
}
|
|
let mut output = vec![];
|
|
self.leaf_paths_impl(&mut output, base);
|
|
output
|
|
}
|
|
|
|
fn leaf_paths_impl(&self, output: &mut Vec<PathBuf>, base: &Path) {
|
|
for (name, child) in self.children.iter() {
|
|
let base = base.join(name);
|
|
if child.perm().indexonly() {
|
|
child.leaf_paths_impl(output, &base);
|
|
} else {
|
|
output.push(base)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
|
|
pub enum AccessPerm {
|
|
#[default]
|
|
IndexOnly,
|
|
ReadOnly,
|
|
ReadWrite,
|
|
}
|
|
|
|
impl AccessPerm {
|
|
pub fn readwrite(&self) -> bool {
|
|
self == &AccessPerm::ReadWrite
|
|
}
|
|
|
|
pub fn indexonly(&self) -> bool {
|
|
self == &AccessPerm::IndexOnly
|
|
}
|
|
}
|
|
|
|
pub fn www_authenticate(args: &Args) -> Result<HeaderValue> {
|
|
let value = if args.auth.use_hashed_password {
|
|
format!("Basic realm=\"{}\"", REALM)
|
|
} else {
|
|
let nonce = create_nonce()?;
|
|
format!(
|
|
"Digest realm=\"{}\", nonce=\"{}\", qop=\"auth\", Basic realm=\"{}\"",
|
|
REALM, nonce, REALM
|
|
)
|
|
};
|
|
Ok(HeaderValue::from_str(&value)?)
|
|
}
|
|
|
|
pub fn get_auth_user(authorization: &HeaderValue) -> Option<String> {
|
|
if let Some(value) = strip_prefix(authorization.as_bytes(), b"Basic ") {
|
|
let value: Vec<u8> = general_purpose::STANDARD.decode(value).ok()?;
|
|
let parts: Vec<&str> = std::str::from_utf8(&value).ok()?.split(':').collect();
|
|
Some(parts[0].to_string())
|
|
} else if let Some(value) = strip_prefix(authorization.as_bytes(), b"Digest ") {
|
|
let digest_map = to_headermap(value).ok()?;
|
|
let username = digest_map.get(b"username".as_ref())?;
|
|
std::str::from_utf8(username).map(|v| v.to_string()).ok()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn check_auth(
|
|
authorization: &HeaderValue,
|
|
method: &str,
|
|
auth_user: &str,
|
|
auth_pass: &str,
|
|
) -> Option<()> {
|
|
if let Some(value) = strip_prefix(authorization.as_bytes(), b"Basic ") {
|
|
let value: Vec<u8> = general_purpose::STANDARD.decode(value).ok()?;
|
|
let parts: Vec<&str> = std::str::from_utf8(&value).ok()?.split(':').collect();
|
|
|
|
if parts[0] != auth_user {
|
|
return None;
|
|
}
|
|
|
|
if auth_pass.starts_with("$6$") {
|
|
if let Ok(()) = sha_crypt::sha512_check(parts[1], auth_pass) {
|
|
return Some(());
|
|
}
|
|
} else if parts[1] == auth_pass {
|
|
return Some(());
|
|
}
|
|
|
|
None
|
|
} else if let Some(value) = strip_prefix(authorization.as_bytes(), b"Digest ") {
|
|
let digest_map = to_headermap(value).ok()?;
|
|
if let (Some(username), Some(nonce), Some(user_response)) = (
|
|
digest_map
|
|
.get(b"username".as_ref())
|
|
.and_then(|b| std::str::from_utf8(b).ok()),
|
|
digest_map.get(b"nonce".as_ref()),
|
|
digest_map.get(b"response".as_ref()),
|
|
) {
|
|
match validate_nonce(nonce) {
|
|
Ok(true) => {}
|
|
_ => return None,
|
|
}
|
|
if auth_user != username {
|
|
return None;
|
|
}
|
|
|
|
let mut h = Context::new();
|
|
h.consume(format!("{}:{}:{}", auth_user, REALM, auth_pass).as_bytes());
|
|
let auth_pass = format!("{:x}", h.compute());
|
|
|
|
let mut ha = Context::new();
|
|
ha.consume(method);
|
|
ha.consume(b":");
|
|
if let Some(uri) = digest_map.get(b"uri".as_ref()) {
|
|
ha.consume(uri);
|
|
}
|
|
let ha = format!("{:x}", ha.compute());
|
|
let mut correct_response = None;
|
|
if let Some(qop) = digest_map.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) = digest_map.get(b"nc".as_ref()) {
|
|
c.consume(nc);
|
|
}
|
|
c.consume(b":");
|
|
if let Some(cnonce) = digest_map.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 {
|
|
return Some(());
|
|
}
|
|
}
|
|
None
|
|
} else {
|
|
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 {
|
|
bail!("invalid nonce");
|
|
}
|
|
//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 = unix_now()?;
|
|
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 < DIGEST_AUTH_TIMEOUT);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
bail!("invalid nonce");
|
|
}
|
|
|
|
fn is_readonly_method(method: &Method) -> bool {
|
|
method == Method::GET
|
|
|| method == Method::OPTIONS
|
|
|| method == Method::HEAD
|
|
|| method.as_str() == "PROPFIND"
|
|
}
|
|
|
|
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 assign = Vec::new();
|
|
let mut i: usize = 0;
|
|
let mut esc = false;
|
|
for c in header {
|
|
match (c, esc) {
|
|
(b'=', false) => assign.push(i),
|
|
(b',', false) => sep.push(i),
|
|
(b'"', false) => esc = true,
|
|
(b'"', true) => esc = false,
|
|
_ => {}
|
|
}
|
|
i += 1;
|
|
}
|
|
sep.push(i);
|
|
|
|
i = 0;
|
|
let mut ret = HashMap::new();
|
|
for (&k, &a) in sep.iter().zip(assign.iter()) {
|
|
while header[i] == b' ' {
|
|
i += 1;
|
|
}
|
|
if a <= i || k <= 1 + a {
|
|
//keys and values 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() -> Result<String> {
|
|
let now = unix_now()?;
|
|
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());
|
|
Ok(n[..34].to_string())
|
|
}
|
|
|
|
fn split_account_paths(s: &str) -> Option<(&str, &str)> {
|
|
let i = s.find("@/")?;
|
|
Some((&s[0..i], &s[i + 1..]))
|
|
}
|
|
|
|
/// Compatible with deprecated usage of `|` for role separation
|
|
fn compact_split_rules(rules: &[&str]) -> Vec<String> {
|
|
let mut output = vec![];
|
|
for rule in rules {
|
|
let parts: Vec<&str> = rule.split('|').collect();
|
|
let mut rules_list = vec![];
|
|
let mut concated_part = String::new();
|
|
for (i, part) in parts.iter().enumerate() {
|
|
if part.contains("@/") {
|
|
concated_part.push_str(part);
|
|
let mut concated_part_tmp = String::new();
|
|
std::mem::swap(&mut concated_part_tmp, &mut concated_part);
|
|
rules_list.push(concated_part_tmp);
|
|
continue;
|
|
}
|
|
concated_part.push_str(part);
|
|
if i < parts.len() - 1 {
|
|
concated_part.push('|');
|
|
}
|
|
}
|
|
if !concated_part.is_empty() {
|
|
rules_list.push(concated_part)
|
|
}
|
|
output.extend(rules_list);
|
|
}
|
|
output
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_split_account_paths() {
|
|
assert_eq!(
|
|
split_account_paths("user:pass@/:rw"),
|
|
Some(("user:pass", "/:rw"))
|
|
);
|
|
assert_eq!(
|
|
split_account_paths("user:pass@@/:rw"),
|
|
Some(("user:pass@", "/:rw"))
|
|
);
|
|
assert_eq!(
|
|
split_account_paths("user:pass@1@/:rw"),
|
|
Some(("user:pass@1", "/:rw"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_compact_split_rules() {
|
|
assert_eq!(
|
|
compact_split_rules(&["user1:pass1@/:rw|user2:pass2@/:rw"]),
|
|
["user1:pass1@/:rw", "user2:pass2@/:rw"]
|
|
);
|
|
assert_eq!(
|
|
compact_split_rules(&["user1:pa|ss1@/:rw|user2:pa|ss2@/:rw"]),
|
|
["user1:pa|ss1@/:rw", "user2:pa|ss2@/:rw"]
|
|
);
|
|
assert_eq!(
|
|
compact_split_rules(&["user1:pa|ss1@/:rw|@/"]),
|
|
["user1:pa|ss1@/:rw", "@/"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_access_paths() {
|
|
let mut paths = AccessPaths::default();
|
|
paths.add("/dir1", AccessPerm::ReadWrite);
|
|
paths.add("/dir2/dir1", AccessPerm::ReadWrite);
|
|
paths.add("/dir2/dir2", AccessPerm::ReadOnly);
|
|
paths.add("/dir2/dir3/dir1", AccessPerm::ReadWrite);
|
|
assert_eq!(
|
|
paths.leaf_paths(Path::new("/tmp")),
|
|
[
|
|
"/tmp/dir1",
|
|
"/tmp/dir2/dir1",
|
|
"/tmp/dir2/dir2",
|
|
"/tmp/dir2/dir3/dir1"
|
|
]
|
|
.iter()
|
|
.map(PathBuf::from)
|
|
.collect::<Vec<_>>()
|
|
);
|
|
assert_eq!(
|
|
paths
|
|
.find("dir2", false)
|
|
.map(|v| v.leaf_paths(Path::new("/tmp/dir2"))),
|
|
Some(
|
|
["/tmp/dir2/dir1", "/tmp/dir2/dir2", "/tmp/dir2/dir3/dir1"]
|
|
.iter()
|
|
.map(PathBuf::from)
|
|
.collect::<Vec<_>>()
|
|
)
|
|
);
|
|
assert_eq!(paths.find("dir2", true), None);
|
|
assert!(paths.find("dir1/file", true).is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_access_paths_perm() {
|
|
let mut paths = AccessPaths::default();
|
|
assert_eq!(paths.perm(), AccessPerm::IndexOnly);
|
|
paths.set_perm(AccessPerm::ReadOnly);
|
|
assert_eq!(paths.perm(), AccessPerm::ReadOnly);
|
|
paths.set_perm(AccessPerm::ReadWrite);
|
|
assert_eq!(paths.perm(), AccessPerm::ReadWrite);
|
|
paths.set_perm(AccessPerm::ReadOnly);
|
|
assert_eq!(paths.perm(), AccessPerm::ReadWrite);
|
|
}
|
|
}
|