Skip to content

Commit

Permalink
there are public endpoints which don't require authorization
Browse files Browse the repository at this point in the history
  • Loading branch information
kolja committed Dec 28, 2024
1 parent e1c0e99 commit 80afba5
Show file tree
Hide file tree
Showing 9 changed files with 237 additions and 32 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/target
/docker/docker.orca.toml
coverage/cobertura.xml
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,13 @@ protocol = "Https" # or "Http"
cert = "/path/to/cert.pem"
key = "/path/to/key.pem"

[authentication]
[authentication.login]
alice = "$argon2id$v=19$m=19456,t=2,p=1$bK0qYfzAokhthFP0fKBQvg$QPPf54SN74dT2YX4aGoN+KxoWD+xV+c6OBrrPnvxj24"
bob = "$argon2id$v=19$m=19456,t=2,p=1$FMnONzRzIAkaIuy3c+A9cg$DE3+UC62d/f+L0jqEWgz9GAfNWQkKfugeZFSL/FG5XQ"

[authentication]
public = ["/", "/library/**"] # the root endpoint is publicly accessible, so is everything under /library

[calibre.libraries]
library = "/Volumes/library"
nonfiction = "/Volumes/nonfiction"
Expand All @@ -57,7 +60,9 @@ The server supports basic authentication: You can generate a password hash like
```bash
orca --hash <login>:<password> # e.g. orca --hash alice:secretpassword
```
The server will print the hash which you have to copy to the `[authentication]` section of your config file.
The server will print the hash which you have to copy to the `[authentication.login]` section of your config file.

Under the `public` array in the `[authentication]` section you can specify which paths should be accessible without authentication. You can use wildcards like `*` and `**` to match multiple paths.

## Development

Expand Down
20 changes: 15 additions & 5 deletions src/authorized.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ fn verify_credentials(header: &HeaderValue, config: &Config) -> Option<Authorize
.and_then(|vec| String::from_utf8(vec).ok())
.and_then(|loginpassword| {
let (login, password) = loginpassword.split_once(":")?;
let hash = config.authentication.get(login)?;
let hash = config.authentication.login.get(login)?;
match hash::verify_password(password, hash).ok()? {
true => Some(Authorized {
login: login.to_string(),
Expand All @@ -65,17 +65,27 @@ impl FromRequest for Authorized {
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
let data = req.app_data::<web::Data<AppState>>().unwrap();
let config = &data.config;
let path = req.uri().path();

let result = req.headers().get(header::AUTHORIZATION)
.and_then(|header| verify_credentials(header, config));

match result {
Some(auth) => ready(Ok(auth)),
None => {
let error = UnauthorizedError {
message: "Unauthorized",
};
ready(Err(error.into()))
let public_routes = &config.authentication.public;
let is_public = public_routes.iter().any(|pat| pat.regex.is_match(path));

if is_public {
ready(Ok(Authorized {
login: "Guest".to_string(),
}))
} else {
let error = UnauthorizedError {
message: "Unauthorized",
};
ready(Err(error.into()))
}
}
}
}
Expand Down
55 changes: 40 additions & 15 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@

use dirs::home_dir;
use std::{env, fmt, fs, collections::HashMap};
// use regex::Regex;
use serde_derive::{Deserialize, Serialize};
use std::{fs, env, collections::HashMap, fmt};
use crate::pattern::Pattern;

use once_cell::sync::Lazy;
use anyhow::{Context, Error, Result, anyhow};

#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize, Deserialize)]
pub struct Config {
pub server: Server,
pub authentication: HashMap<String, String>,
pub authentication: Authentication,
pub calibre: Calibre,
}

Expand All @@ -29,6 +33,22 @@ pub struct Server {
pub protocol: Protocol,
}

#[derive(Serialize, Deserialize)]
pub struct Authentication {
pub login: HashMap<String, String>,
#[serde(default)]
pub public: Vec<Pattern>,
}

impl Default for Authentication {
fn default() -> Self {
Authentication {
login: HashMap::new(),
public: vec![]
}
}
}

#[derive(Serialize, Deserialize, Clone)]
pub struct Calibre {
pub libraries: HashMap<String, String>,
Expand Down Expand Up @@ -121,6 +141,7 @@ pub fn get() -> &'static Config {
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use tempfile::NamedTempFile;
use std::io::Write;

Expand All @@ -133,8 +154,8 @@ mod tests {
port = 8080
protocol = "Http"
[authentication]
key = "value"
[authentication.login]
alice = "...passwordhash..."
[calibre]
libraries = {}
Expand Down Expand Up @@ -207,7 +228,7 @@ mod tests {
let mut tmp_file1 = NamedTempFile::new().unwrap();
let invalid_toml = r#"
[server
ip = "127.0.0.1"
foo = "127.0.0.1"
port = 8080
"#;
write!(tmp_file1, "{}", invalid_toml).unwrap();
Expand All @@ -219,8 +240,8 @@ mod tests {
port = 8080
protocol = "Http"
[authentication]
key = "value"
[authentication.login]
alice = "...passwordhash..."
[calibre]
libraries = {}
Expand All @@ -245,8 +266,8 @@ mod tests {
let mut tmp_file1 = NamedTempFile::new().unwrap();
let invalid_toml = r#"
[server
ip = "127.0.0.1"
port = 8080
foo = "127.0.0.1"
bar = 8080
"#;
write!(tmp_file1, "{}", invalid_toml).unwrap();

Expand All @@ -257,8 +278,8 @@ mod tests {
port = 8080
protocol = "Http"
[authentication]
key = "value"
[authentication.login]
bob = "...passwordhash..."
[calibre]
libraries = {}
Expand All @@ -282,8 +303,8 @@ mod tests {
cert = "/path/to/cert.pem"
key = "/path/to/key.pem"
[authentication]
key = "value"
[authentication.login]
alice = "...passwordhash..."
[calibre]
libraries = {}
Expand Down Expand Up @@ -317,8 +338,12 @@ mod tests {
port = 8080
protocol = "Http"
[authentication.login]
alice = "...reallylonghash..."
bob = "...relativelylonghash..."
[authentication]
key = "value"
public = ["/", "/library/*"]
[calibre]
libraries = {}
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub mod config;
pub mod tls;
pub mod hash;
pub mod routes;
pub mod pattern;

use actix_web::{web, App, HttpServer};
use rusqlite::Connection;
Expand Down
131 changes: 131 additions & 0 deletions src/pattern.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
extern crate serde;
extern crate regex;
extern crate toml;
extern crate anyhow;

use regex::Regex;
use serde::{Serialize, Serializer};
use serde::de::{self, Deserialize, Deserializer};
use anyhow::{Result, anyhow};

#[derive(Debug)]
pub struct Pattern {
pub pattern: String,
pub regex: Regex,
}

impl Pattern {
pub fn new(pattern: &str) -> Result<Self> {
if pattern.matches("**").count() > 1 {
return Err(anyhow!("Pattern contains more than one '**'"));
}

let regex_pattern = pattern
.replace("/", "\\/")
.replace(".", "\\.")
.replace("**", "_DOUBLESTAR_") // Replace '**' with a unique string
.replace("*", "[^/]*")
.replace("_DOUBLESTAR_", ".*") // ...to avoid clashes with the single '*'
.replace("?", "[^/]")
.replace("\\/\\/", "\\/"); // x/**/y -> x/y not x//y

let regex_pattern = format!("^{}$", regex_pattern);
let regex = Regex::new(&regex_pattern)?;

Ok(Self {
pattern: pattern.to_string(),
regex,
})
}
}

impl<'de> Deserialize<'de> for Pattern {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Pattern::new(&s).map_err(de::Error::custom)
}
}

impl Serialize for Pattern {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.pattern)
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_derive::{Deserialize, Serialize};
use toml;

#[derive(Deserialize, Serialize)]
struct TestWrap {
pattern: Pattern,
}

#[test]
fn pattern_new() {
let pattern_str = "/baz/*";
let pattern = Pattern::new(pattern_str).unwrap();
assert_eq!(pattern.pattern, pattern_str);
assert!(pattern.regex.is_match("/baz/anything"));
assert!(!pattern.regex.is_match("/foo/bar"));
}

#[test]
fn pattern_deserialize() {
let toml_data = r#"pattern = "/baz/*""#;
let wrap: TestWrap = toml::from_str(&toml_data).unwrap();
assert_eq!(wrap.pattern.pattern, "/baz/*");
assert!(wrap.pattern.regex.is_match("/baz/anything"));
assert!(!wrap.pattern.regex.is_match("/foo/bar"));
}

#[test]
fn pattern_serialize() {
let pattern = Pattern::new("/baz/*").unwrap();
let wrap = TestWrap { pattern };
let toml_data = toml::to_string(&wrap).unwrap();
let expected = r#"pattern = "/baz/*""#;
assert_eq!(toml_data.trim(), expected);
}

#[test]
fn pattern_roundtrip() {
let toml_data = r#"pattern = "/baz/*""#;
let wrap: TestWrap = toml::from_str(&toml_data).unwrap();
let serialized = toml::to_string(&wrap).unwrap();
assert_eq!(toml_data.trim(), serialized.trim());
}

#[test]
fn pattern_regex_conversion() {
let patterns = [
("/", r"^\/$"),
("/bar", r"^\/bar$"),
("/baz/*", r"^\/baz\/[^/]*$"),
("/baz/**", r"^\/baz\/.*$"),
("/foo/*/bar", r"^\/foo\/[^/]*\/bar$"),
("/foo/?/bar", r"^\/foo\/[^/]\/bar$"),
];

for (pattern_str, expected_regex) in &patterns {
let pattern = Pattern::new(pattern_str).unwrap();
assert_eq!(pattern.regex.as_str(), *expected_regex,
"Failed for pattern: '{}'", pattern_str);
}
}

#[test]
fn pattern_multiple_double_asterisks() {
let pattern_str = "/foo/**/bar/**";
let pattern = Pattern::new(pattern_str);
assert!(pattern.is_err());
}
}
6 changes: 5 additions & 1 deletion tests/orca.http.test.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ ip = "127.0.0.1"
port = 8888
protocol = "Http"

[authentication]
[authentication.login]
alice = "$argon2id$v=19$m=19456,t=2,p=1$G57mIrlohNqdISyznvXyhw$qNaLVhDp+FJfK38DfJKQOORVG9Mpp00I6EqWz6lsrnQ"

[authentication]
pubilc = ["/"]


[calibre.libraries]
library = "tests/calibre"
5 changes: 4 additions & 1 deletion tests/orca.https.test.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ protocol = "Https"
cert = "tests/cert.pem"
key = "tests/key.pem"

[authentication]
[authentication.login]
alice = "$argon2id$v=19$m=19456,t=2,p=1$G57mIrlohNqdISyznvXyhw$qNaLVhDp+FJfK38DfJKQOORVG9Mpp00I6EqWz6lsrnQ"

[authentication]
public = ["/"]

[calibre.libraries]
library = "tests/calibre"
Loading

0 comments on commit 80afba5

Please sign in to comment.