diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/args.rs | 128 | ||||
-rw-r--r-- | src/auth.rs | 122 | ||||
-rw-r--r-- | src/errors.rs | 12 | ||||
-rw-r--r-- | src/main.rs | 2 |
4 files changed, 233 insertions, 31 deletions
diff --git a/src/args.rs b/src/args.rs index 8d2e105..4077f35 100644 --- a/src/args.rs +++ b/src/args.rs @@ -38,9 +38,10 @@ struct CLIArgs { )] interfaces: Vec<IpAddr>, - /// Set authentication (username:password) + /// Set authentication. Currently supported formats: + /// username:password, username:sha256:hash, username:sha512:hash #[structopt(short = "a", long = "auth", parse(try_from_str = "parse_auth"))] - auth: Option<(String, String)>, + auth: Option<auth::RequiredAuth>, /// Generate a random 6-hexdigit route #[structopt(long = "random-route")] @@ -77,34 +78,55 @@ fn parse_interface(src: &str) -> Result<IpAddr, std::net::AddrParseError> { } /// Checks wether the auth string is valid, i.e. it follows the syntax username:password -fn parse_auth(src: &str) -> Result<(String, String), ContextualError> { - let mut split = src.splitn(2, ':'); +fn parse_auth(src: &str) -> Result<auth::RequiredAuth, ContextualError> { + let mut split = src.splitn(3, ':'); + let invalid_auth_format = Err( + ContextualError::new(ContextualErrorKind::InvalidAuthFormat) + ); let username = match split.next() { Some(username) => username, - None => { - return Err(ContextualError::new(ContextualErrorKind::InvalidAuthFormat)); - } + None => return invalid_auth_format, }; - let password = match split.next() { + // second_part is either password in username:password or method in username:method:hash + let second_part = match split.next() { // This allows empty passwords, as the spec does not forbid it Some(password) => password, - None => { - return Err(ContextualError::new(ContextualErrorKind::InvalidAuthFormat)); - } + None => return invalid_auth_format, }; - // To make it Windows-compatible, the password needs to be shorter than 255 characters. - // After 255 characters, Windows will truncate the value. - // As for the username, the spec does not mention a limit in length - if password.len() > 255 { - return Err(ContextualError::new( - ContextualErrorKind::PasswordTooLongError, - )); - } + let password = if let Some(hash_hex) = split.next() { + let hash_bin = if let Ok(hash_bin) = hex::decode(hash_hex) { + hash_bin + } else { + return Err(ContextualError::new(ContextualErrorKind::InvalidPasswordHash)) + }; + + match second_part { + "sha256" => auth::RequiredAuthPassword::Sha256(hash_bin.to_owned()), + "sha512" => auth::RequiredAuthPassword::Sha512(hash_bin.to_owned()), + _ => { + return Err(ContextualError::new( + ContextualErrorKind::InvalidHashMethod(second_part.to_owned()) + )) + }, + } + } else { + // To make it Windows-compatible, the password needs to be shorter than 255 characters. + // After 255 characters, Windows will truncate the value. + // As for the username, the spec does not mention a limit in length + if second_part.len() > 255 { + return Err(ContextualError::new(ContextualErrorKind::PasswordTooLongError)); + } + + auth::RequiredAuthPassword::Plain(second_part.to_owned()) + }; - Ok((username.to_owned(), password.to_owned())) + Ok(auth::RequiredAuth { + username: username.to_owned(), + password, + }) } /// Parses the command line arguments @@ -120,11 +142,6 @@ pub fn parse_args() -> crate::MiniserveConfig { ] }; - let auth = match args.auth { - Some((username, password)) => Some(auth::BasicAuthParams { username, password }), - None => None, - }; - let random_route = if args.random_route { Some(nanoid::custom(6, &ROUTE_ALPHABET)) } else { @@ -140,7 +157,7 @@ pub fn parse_args() -> crate::MiniserveConfig { path: args.path.unwrap_or_else(|| PathBuf::from(".")), port: args.port, interfaces, - auth, + auth: args.auth, path_explicitly_chosen, no_symlinks: args.no_symlinks, random_route, @@ -149,3 +166,62 @@ pub fn parse_args() -> crate::MiniserveConfig { file_upload: args.file_upload, } } + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest_parametrize; + + /// Helper function that creates a `RequiredAuth` structure + fn create_required_auth(username: &str, password: &str, encrypt: &str) -> auth::RequiredAuth { + use auth::*; + use RequiredAuthPassword::*; + + RequiredAuth { + username: username.to_owned(), + password: match encrypt { + "plain" => Plain(password.to_owned()), + "sha256" => Sha256(hex::decode(password.to_owned()).unwrap()), + "sha512" => Sha512(hex::decode(password.to_owned()).unwrap()), + _ => panic!("Unknown encryption type"), + }, + } + } + + #[rstest_parametrize( + auth_string, username, password, encrypt, + case("username:password", "username", "password", "plain"), + case("username:sha256:abcd", "username", "abcd", "sha256"), + case("username:sha512:abcd", "username", "abcd", "sha512") + )] + fn parse_auth_valid(auth_string: &str, username: &str, password: &str, encrypt: &str) { + assert_eq!( + parse_auth(auth_string).unwrap(), + create_required_auth(username, password, encrypt), + ); + } + + #[rstest_parametrize( + auth_string, err_msg, + case( + "foo", + "Invalid format for credentials string. Expected username:password, username:sha256:hash or username:sha512:hash" + ), + case( + "username:blahblah:abcd", + "blahblah is not a valid hashing method. Expected sha256 or sha512" + ), + case( + "username:sha256:invalid", + "Invalid format for password hash. Expected hex code" + ), + case( + "username:sha512:invalid", + "Invalid format for password hash. Expected hex code" + ), + )] + fn parse_auth_invalid(auth_string: &str, err_msg: &str) { + let err = parse_auth(auth_string).unwrap_err(); + assert_eq!(format!("{}", err), err_msg.to_owned()); + } +} diff --git a/src/auth.rs b/src/auth.rs index 1bdf0be..e75f498 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,6 +1,7 @@ use actix_web::http::header; use actix_web::middleware::{Middleware, Response}; use actix_web::{HttpRequest, HttpResponse, Result}; +use sha2::{Digest, Sha256, Sha512}; use crate::errors::{ContextualError, ContextualErrorKind}; @@ -13,6 +14,21 @@ pub struct BasicAuthParams { pub password: String, } +#[derive(Clone, Debug, PartialEq)] +/// `password` field of `RequiredAuth` +pub enum RequiredAuthPassword { + Plain(String), + Sha256(Vec<u8>), + Sha512(Vec<u8>), +} + +#[derive(Clone, Debug, PartialEq)] +/// Authentication structure to match `BasicAuthParams` against +pub struct RequiredAuth { + pub username: String, + pub password: RequiredAuthPassword, +} + /// Decode a HTTP basic auth string into a tuple of username and password. pub fn parse_basic_auth( authorization_header: &header::HeaderValue, @@ -39,6 +55,37 @@ pub fn parse_basic_auth( }) } +/// Verify authentication +pub fn match_auth(basic_auth: BasicAuthParams, required_auth: &RequiredAuth) -> bool { + if basic_auth.username != required_auth.username { + return false; + } + + match &required_auth.password { + RequiredAuthPassword::Plain(ref required_password) => { + basic_auth.password == *required_password + } + RequiredAuthPassword::Sha256(password_hash) => { + compare_hash::<Sha256>(basic_auth.password, password_hash) + } + RequiredAuthPassword::Sha512(password_hash) => { + compare_hash::<Sha512>(basic_auth.password, password_hash) + } + } +} + +/// Return `true` if hashing of `password` by `T` algorithm equals to `hash` +pub fn compare_hash<T: Digest>(password: String, hash: &Vec<u8>) -> bool { + get_hash::<T>(password) == *hash +} + +/// Get hash of a `text` +pub fn get_hash<T: Digest>(text: String) -> Vec<u8> { + let mut hasher = T::new(); + hasher.input(text); + hasher.result().to_vec() +} + impl Middleware<crate::MiniserveConfig> for Auth { fn response( &self, @@ -58,9 +105,7 @@ impl Middleware<crate::MiniserveConfig> for Auth { )); } }; - if auth_req.username != required_auth.username - || auth_req.password != required_auth.password - { + if !match_auth(auth_req, required_auth) { let new_resp = HttpResponse::Unauthorized().finish(); return Ok(Response::Done(new_resp)); } @@ -77,3 +122,74 @@ impl Middleware<crate::MiniserveConfig> for Auth { Ok(Response::Done(resp)) } } + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest_parametrize; + + /// Return a hashing function corresponds to given name + fn get_hash_func(name: &str) -> impl FnOnce(String) -> Vec<u8> { + match name { + "sha256" => get_hash::<Sha256>, + "sha512" => get_hash::<Sha512>, + _ => panic!("Invalid hash method"), + } + } + + #[rstest_parametrize( + password, hash_method, hash, + case("abc", "sha256", "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"), + case("abc", "sha512", "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"), + )] + fn test_get_hash(password: &str, hash_method: &str, hash: &str) { + let hash_func = get_hash_func(hash_method); + let expected = hex::decode(hash).expect("Provided hash is not a valid hex code"); + let received = hash_func(password.to_owned()); + assert_eq!(received, expected); + } + + /// Helper function that creates a `RequiredAuth` structure and encrypt `password` if necessary + fn create_required_auth(username: &str, password: &str, encrypt: &str) -> RequiredAuth { + use RequiredAuthPassword::*; + + RequiredAuth { + username: username.to_owned(), + password: match encrypt { + "plain" => Plain(password.to_owned()), + "sha256" => Sha256(get_hash::<sha2::Sha256>(password.to_owned())), + "sha512" => Sha512(get_hash::<sha2::Sha512>(password.to_owned())), + _ => panic!("Unknown encryption type"), + }, + } + } + + #[rstest_parametrize( + should_pass, param_username, param_password, required_username, required_password, encrypt, + case(true, "obi", "hello there", "obi", "hello there", "plain"), + case(false, "obi", "hello there", "obi", "hi!", "plain"), + case(true, "obi", "hello there", "obi", "hello there", "sha256"), + case(false, "obi", "hello there", "obi", "hi!", "sha256"), + case(true, "obi", "hello there", "obi", "hello there", "sha512"), + case(false, "obi", "hello there", "obi", "hi!", "sha512"), + )] + fn test_auth( + should_pass: bool, + param_username: &str, + param_password: &str, + required_username: &str, + required_password: &str, + encrypt: &str, + ) { + assert_eq!( + match_auth( + BasicAuthParams { + username: param_username.to_owned(), + password: param_password.to_owned(), + }, + &create_required_auth(required_username, required_password, encrypt), + ), + should_pass, + ) + } +} diff --git a/src/errors.rs b/src/errors.rs index bd6dc66..833e9c4 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -27,9 +27,19 @@ pub enum ContextualErrorKind { InvalidPathError(String), /// This error might occur if the HTTP credential string does not respect the expected format - #[fail(display = "Invalid format for credentials string. Expected is username:format")] + #[fail( + display = "Invalid format for credentials string. Expected username:password, username:sha256:hash or username:sha512:hash" + )] InvalidAuthFormat, + /// This error might occure if the hash method is neither sha256 nor sha512 + #[fail(display = "{} is not a valid hashing method. Expected sha256 or sha512", _0)] + InvalidHashMethod(String), + + /// This error might occur if the HTTP auth hash password is not a valid hex code + #[fail(display = "Invalid format for password hash. Expected hex code")] + InvalidPasswordHash, + /// This error might occur if the HTTP auth password exceeds 255 characters #[fail(display = "HTTP password length exceeds 255 characters")] PasswordTooLongError, diff --git a/src/main.rs b/src/main.rs index cf7ca93..bc8f3f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,7 +37,7 @@ pub struct MiniserveConfig { pub interfaces: Vec<IpAddr>, /// Enable HTTP basic authentication - pub auth: Option<auth::BasicAuthParams>, + pub auth: Option<auth::RequiredAuth>, /// If false, miniserve will serve the current working directory pub path_explicitly_chosen: bool, |