diff --git a/Cargo.toml b/Cargo.toml index 50a6ceb..dd8928f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,9 @@ rust-version = "1.70.0" [features] default = ["basic-scheme", "digest-scheme"] +# Enable client side or server side libraries. +server = [] + # Enable code to respond to challenges of the given scheme. basic-scheme = ["base64"] digest-scheme = ["digest", "hex", "md-5", "rand", "sha2"] diff --git a/src/basic.rs b/src/basic.rs index 14873c8..31f5c4c 100644 --- a/src/basic.rs +++ b/src/basic.rs @@ -6,8 +6,14 @@ use std::convert::TryFrom; +use crate::credentials::{Credentials, User}; +use crate::digest::Algorithm; +#[cfg(feature = "server")] +use crate::errors::AuthError; use crate::ChallengeRef; +const PREFIX: &str = "Basic "; + /// Encodes the given credentials. /// /// This can be used to preemptively send `Basic` authentication, without @@ -30,7 +36,6 @@ use crate::ChallengeRef; pub fn encode_credentials(username: &str, password: &str) -> String { use base64::Engine as _; let user_pass = format!("{}:{}", username, password); - const PREFIX: &str = "Basic "; let mut value = String::with_capacity(PREFIX.len() + base64_encoded_len(user_pass.len())); value.push_str(PREFIX); base64::engine::general_purpose::STANDARD.encode_string(&user_pass[..], &mut value); @@ -42,6 +47,77 @@ fn base64_encoded_len(input_len: usize) -> usize { (input_len + 2) / 3 * 4 } +#[cfg(feature = "server")] +pub struct PlaintextCredentials { + username: String, + realm: String, + password: String, +} + +#[cfg(feature = "server")] +impl Credentials for PlaintextCredentials { + fn get_user(&self) -> User { + User::Username(self.username.clone()) + } + + fn equals_plaintext(&self, username: &str, password: &str) -> Result<(), AuthError> { + if self.username == username && self.password == password { + Ok(()) + } else { + Err(AuthError::IncorrectPassword) + } + } + + #[cfg(feature = "digest-scheme")] + fn equals_digest(&self, digested: &str, algorithm: &Algorithm) -> Result<(), AuthError> { + let my_digest = algorithm.h(&[ + self.username.as_bytes(), + b":", + self.realm.as_bytes(), + b":", + self.password.as_bytes(), + ]); + if my_digest == digested { + Ok(()) + } else { + Err(AuthError::IncorrectPassword) + } + } +} + +/// Decode the credentials from the header +/// +/// These are the credentials added to the `Authorization` or `Proxy-Authorization` header value by +/// the client. +/// +/// This is a reversal of `encode_credentials`. +/// +/// The realm is the realm that was set in the request, This is used to calculate the digest if +/// needed but can be ignored otherwise. +#[cfg(feature = "server")] +pub fn decode_credentials( + realm: &str, + header_value: &str, +) -> Result { + use base64::Engine as _; + let encoded = header_value + .strip_prefix(PREFIX) + .ok_or(AuthError::IncorrectScheme)? + .trim(); + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| AuthError::MalformedRequest)?; + let decoded = String::from_utf8(decoded).map_err(|_| AuthError::MalformedRequest)?; + decoded + .split_once(":") + .map(|(username, password)| PlaintextCredentials { + username: username.to_string(), + realm: realm.to_string(), + password: password.to_string(), + }) + .ok_or(AuthError::MalformedRequest) +} + /// Client for a `Basic` challenge, as in /// [RFC 7617](https://datatracker.ietf.org/doc/html/rfc7617). /// @@ -67,6 +143,38 @@ impl BasicClient { } } +/// Server side support for a `Basic` challenge, as in +/// [RFC 7617](https://datatracker.ietf.org/doc/html/rfc7617). +#[cfg(feature = "server")] +pub struct BasicServer { + realm: Box, +} + +#[cfg(feature = "server")] +impl BasicServer { + /// Creates a new client for issuing challenges and verifying responses. + pub fn new(realm: String) -> Self { + Self { + realm: realm.into(), + } + } + + /// Issues the challenge for the given client + /// + /// This should be included by the server in the `WWW-Authenticate` header of a 401 response or + /// the `Proxy-Authenticate` header in a 407 response. + #[inline] + pub fn challenge(&self) -> String { + format!("{}realm={}", PREFIX, self.realm) + } + + /// Parses the password + #[inline] + pub fn parse_response(&self, response: &str) -> Result { + decode_credentials(self.realm.as_ref(), response) + } +} + impl TryFrom<&ChallengeRef<'_>> for BasicClient { type Error = String; @@ -95,7 +203,7 @@ mod tests { use super::*; #[test] - fn basic() { + fn basic_respond() { // Example from https://datatracker.ietf.org/doc/html/rfc7617#section-2 let ctx = BasicClient { realm: "WallyWorld".into(), @@ -113,3 +221,49 @@ mod tests { assert_eq!(ctx.respond("test", "123\u{A3}"), "Basic dGVzdDoxMjPCow=="); } } + +#[cfg(test)] +#[cfg(feature = "server")] +mod server_tests { + use super::*; + use crate::ChallengeParser; + + #[test] + fn basic_round_trip() { + let server = BasicServer { + realm: "foo".into(), + }; + let challenge = server.challenge(); + let mut challenge_parser = ChallengeParser::new(challenge.as_str()); + let challenge_ref = challenge_parser + .next() + .expect("Missing ChallengeRef") + .expect("Malformed ChallengeRef"); + let client = BasicClient::try_from(&challenge_ref).expect("Challenge should be basic"); + assert_eq!(client.realm, server.realm); + + let response = client.respond("AzureDiamond", "hunter2"); + let credentials = server + .parse_response(response.as_str()) + .expect("Failed to parse credentials"); + assert_eq!(credentials.username, "AzureDiamond", "username"); + assert_eq!(credentials.password, "hunter2", "password"); + } + + #[test] + fn fail_to_parse() { + let server = BasicServer { + realm: "foo".into(), + }; + + assert!(server.parse_response("Does not start with Basic").is_err()); + assert!(server + .parse_response("Basic Invalid Base64 encoded string") + .is_err()); + use base64::Engine as _; + let mut buf = String::new(); + base64::engine::general_purpose::STANDARD + .encode_string("not username colon password", &mut buf); + assert!(server.parse_response(&buf).is_err()); + } +} diff --git a/src/credentials.rs b/src/credentials.rs new file mode 100644 index 0000000..7fb0382 --- /dev/null +++ b/src/credentials.rs @@ -0,0 +1,36 @@ +// Copyright (C) 2026 George Bargoud & Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use crate::digest::Algorithm; +use crate::errors::AuthError; + +/// The user that a given set of Credentials are for. +/// +/// For digest auth, this may be hashed. +pub enum User { + Username(String), + #[cfg(feature = "digest-scheme")] + UserHash(String, Algorithm), +} + +/// The credentials received from the user that can be used for verification +pub trait Credentials { + /// The user that these credentials are for. + fn get_user(&self) -> User; + + /// Whether these credentials match the given plaintext username and password + fn equals_plaintext(&self, username: &str, password: &str) -> Result<(), AuthError>; + + /// Whether these credentials match the given digest which was made with the given algorithm. + /// + /// This function allows servers to store the digest in their database in the place of plaintext + /// passwords and then use that for authentication. + /// + /// The digest should be a hash of "username:realm:password" where realm is the realm that was + /// sent in the HTTP auth request. + /// + /// The algorithm is the one that was used to calculate the digest. If using digest auth, then + /// this must match the algorithm in the request. + #[cfg(feature = "digest-scheme")] + fn equals_digest(&self, digest: &str, algorithm: &Algorithm) -> Result<(), AuthError>; +} diff --git a/src/digest.rs b/src/digest.rs index 3130258..550d0e2 100644 --- a/src/digest.rs +++ b/src/digest.rs @@ -608,7 +608,7 @@ impl Algorithm { } #[inline(never)] - fn h(&self, items: &[&[u8]]) -> String { + pub(crate) fn h(&self, items: &[&[u8]]) -> String { match self { Algorithm::Md5 => h(md5::Md5::new(), items), Algorithm::Sha256 => h(sha2::Sha256::new(), items), diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..736765a --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,14 @@ +// Copyright (C) 2026 George Bargoud & Scott Lamb +// SPDX-License-Identifier: MIT OR Apache-2.0 + + +/// The errors that can occur when parsing the response server side +#[derive(Debug)] +pub enum AuthError { + /// The parser used did not match the scheme + IncorrectScheme, + /// The request was malformed in some way + MalformedRequest, + /// The password was not correct for the user. + IncorrectPassword, +} diff --git a/src/lib.rs b/src/lib.rs index 4c15995..85fdf4a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -88,6 +88,11 @@ pub mod digest; mod table; +#[cfg(feature = "server")] +pub mod errors; +#[cfg(feature = "server")] +pub mod credentials; + pub use parser::ChallengeParser; #[cfg(feature = "basic-scheme")] @@ -185,7 +190,7 @@ impl std::fmt::Debug for ParamsPrinter<'_> { /// ## Example /// #[cfg_attr( - feature = "digest", + feature = "digest-scheme", doc = r##" ```rust use http_auth::PasswordClient;