Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: auth supports forbidden permissions #329

Merged
merged 3 commits into from
Dec 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,20 +207,21 @@ curl http://192.168.8.10:5000/file --user user:pass --digest # digest aut
Dufs supports account based access control. You can control who can do what on which path with `--auth`/`-a`.

```
dufs -a user:pass@/path1:rw,/path2 -a user2:pass2@/path3 -a @/path4
dufs -a admin:admin@/:rw -a guest:guest@/
dufs -a user:pass@/:rw,/dir1,/dir2:- -a @/
```

1. Use `@` to separate the account and paths. No account means anonymous user.
2. Use `:` to separate the username and password of the account.
3. Use `,` to separate paths.
4. Use `:rw` suffix to indicate that the account has read-write permission on the path.
4. Use path suffix `:rw`, `:ro`, `:-` to set permissions: `read-write`, `read-only`, `forbidden`. `:ro` can be omitted.

- `-a admin:amdin@/:rw`: `admin` has complete permissions for all paths.
- `-a admin:admin@/:rw`: `admin` has complete permissions for all paths.
- `-a guest:guest@/`: `guest` has read-only permissions for all paths.
- `-a user:pass@/dir1:rw,/dir2`: `user` has complete permissions for `/dir1/*`, has read-only permissions for `/dir2/`.
- `-a user:pass@/:rw,/dir1,/dir2:-`: `user` has read-write permissions for `/*`, has read-only permissions for `/dir1/*`, but is fordden for `/dir2/*`.
- `-a @/`: All paths is publicly accessible, everyone can view/download it.

> There are no restrictions on using ':' and '@' characters in a password, `user:pa:ss@1@/:rw` is valid, and the password is `pa:ss@1`.
> There are no restrictions on using ':' and '@' characters in a password. For example, `user:pa:ss@1@/:rw` is valid, the password is `pa:ss@1`.

#### Hashed Password

Expand Down
117 changes: 62 additions & 55 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,46 +49,41 @@ impl AccessControl {
}
let new_raw_rules = split_rules(raw_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();
let mut annoy_paths = None;
let mut account_paths_pairs = vec![];
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);
}
let (account, paths) =
split_account_paths(rule).ok_or_else(|| anyhow!("Invalid auth `{rule}`"))?;
if account.is_empty() {
anony = Some(access_paths);
if annoy_paths.is_some() {
bail!("Invalid auth, no duplicate anonymous rules");
}
annoy_paths = Some(paths)
} else if let Some((user, pass)) = account.split_once(':') {
if user.is_empty() || pass.is_empty() {
return Err(create_err(rule));
bail!("Invalid auth `{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));
account_paths_pairs.push((user, pass, paths));
}
}
for (path, perm) in anony_paths {
for (_, (_, paths)) in users.iter_mut() {
paths.add(path, perm)
let mut anony = None;
if let Some(paths) = annoy_paths {
let mut access_paths = AccessPaths::default();
access_paths.merge(paths);
anony = Some(access_paths);
}
let mut users = IndexMap::new();
for (user, pass, paths) in account_paths_pairs.into_iter() {
let mut access_paths = anony.clone().unwrap_or_default();
access_paths
.merge(paths)
.ok_or_else(|| anyhow!("Invalid auth `{user}:{pass}@{paths}"))?;
if pass.starts_with("$6$") {
use_hashed_password = true;
}
users.insert(user.to_string(), (pass.to_string(), access_paths));
}

Ok(Self {
use_hashed_password,
users,
Expand Down Expand Up @@ -151,13 +146,27 @@ impl AccessPaths {
self.perm
}

fn set_perm(&mut self, perm: AccessPerm) {
if self.perm < perm {
self.perm = perm
pub fn set_perm(&mut self, perm: AccessPerm) {
if !perm.inherit() {
self.perm = perm;
}
}

pub fn add(&mut self, path: &str, perm: AccessPerm) {
pub fn merge(&mut self, paths: &str) -> Option<()> {
for item in paths.trim_matches(',').split(',') {
let (path, perm) = match item.split_once(':') {
None => (item, AccessPerm::ReadOnly),
Some((path, "ro")) => (path, AccessPerm::ReadOnly),
Some((path, "rw")) => (path, AccessPerm::ReadWrite),
Some((path, "-")) => (path, AccessPerm::Forbidden),
_ => return None,
};
self.add(path, perm);
}
Some(())
}

fn add(&mut self, path: &str, perm: AccessPerm) {
let path = path.trim_matches('/');
if path.is_empty() {
self.set_perm(perm);
Expand All @@ -184,20 +193,23 @@ impl AccessPaths {
.filter(|v| !v.is_empty())
.collect();
let target = self.find_impl(&parts, self.perm)?;
if target.perm().forbidden() {
return None;
}
if writable && !target.perm().readwrite() {
return None;
}
Some(target)
}

fn find_impl(&self, parts: &[&str], perm: AccessPerm) -> Option<AccessPaths> {
let perm = if !self.perm.indexonly() {
let perm = if !self.perm.inherit() {
self.perm
} else {
perm
};
if parts.is_empty() {
if perm.indexonly() {
if perm.inherit() {
return Some(self.clone());
} else {
return Some(AccessPaths::new(perm));
Expand All @@ -206,7 +218,7 @@ impl AccessPaths {
let child = match self.children.get(parts[0]) {
Some(v) => v,
None => {
if perm.indexonly() {
if perm.inherit() {
return None;
} else {
return Some(AccessPaths::new(perm));
Expand All @@ -221,7 +233,7 @@ impl AccessPaths {
}

pub fn child_paths(&self, base: &Path) -> Vec<PathBuf> {
if !self.perm().indexonly() {
if !self.perm().inherit() {
return vec![base.to_path_buf()];
}
let mut output = vec![];
Expand All @@ -232,7 +244,7 @@ impl AccessPaths {
fn child_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() {
if child.perm().inherit() {
child.child_paths_impl(output, &base);
} else {
output.push(base)
Expand All @@ -244,18 +256,23 @@ impl AccessPaths {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum AccessPerm {
#[default]
IndexOnly,
Inherit,
ReadOnly,
ReadWrite,
Forbidden,
}

impl AccessPerm {
pub fn inherit(&self) -> bool {
self == &AccessPerm::Inherit
}

pub fn readwrite(&self) -> bool {
self == &AccessPerm::ReadWrite
}

pub fn indexonly(&self) -> bool {
self == &AccessPerm::IndexOnly
pub fn forbidden(&self) -> bool {
self == &AccessPerm::Forbidden
}
}

Expand Down Expand Up @@ -559,6 +576,7 @@ mod tests {
paths.add("/dir1", AccessPerm::ReadWrite);
paths.add("/dir2/dir21", AccessPerm::ReadWrite);
paths.add("/dir2/dir21/dir211", AccessPerm::ReadOnly);
paths.add("/dir2/dir21/dir212", AccessPerm::Forbidden);
paths.add("/dir2/dir22", AccessPerm::ReadOnly);
paths.add("/dir2/dir22/dir221", AccessPerm::ReadWrite);
paths.add("/dir2/dir23/dir231", AccessPerm::ReadWrite);
Expand Down Expand Up @@ -603,17 +621,6 @@ mod tests {
Some(AccessPaths::new(AccessPerm::ReadOnly))
);
assert_eq!(paths.find("dir2/dir21/dir211/file", true), None);
}

#[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);
assert_eq!(paths.find("dir2/dir21/dir212", false), None);
}
}
4 changes: 2 additions & 2 deletions src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ impl Server {
method => match method.as_str() {
"PROPFIND" => {
if is_dir {
let access_paths = if access_paths.perm().indexonly() {
let access_paths = if access_paths.perm().inherit() {
// see https://github.com/sigoden/dufs/issues/229
AccessPaths::new(AccessPerm::ReadOnly)
} else {
Expand Down Expand Up @@ -1183,7 +1183,7 @@ impl Server {
access_paths: AccessPaths,
) -> Result<Vec<PathItem>> {
let mut paths: Vec<PathItem> = vec![];
if access_paths.perm().indexonly() {
if access_paths.perm().inherit() {
for name in access_paths.child_names() {
let entry_path = entry_path.join(name);
self.add_pathitem(&mut paths, base_path, &entry_path).await;
Expand Down
17 changes: 17 additions & 0 deletions tests/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,23 @@ fn auth_readonly(
Ok(())
}

#[rstest]
fn auth_forbidden(
#[with(&["--auth", "user:pass@/:rw,/dir1:-", "-A"])] server: TestServer,
) -> Result<(), Error> {
let url = format!("{}file1", server.url());
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 201);
let url = format!("{}dir1/file1", server.url());
let resp = fetch!(b"PUT", &url)
.body(b"abc".to_vec())
.send_with_digest_auth("user", "pass")?;
assert_eq!(resp.status(), 403);
Ok(())
}

#[rstest]
fn auth_nest(
#[with(&["--auth", "user:pass@/:rw", "--auth", "user2:pass2@/", "--auth", "user3:pass3@/dir1:rw", "-A"])]
Expand Down