diff --git a/Cargo.toml b/Cargo.toml index 7ac8b4243..8aac469dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,5 @@ travis-ci = { repository = "svartalf/actix-web-httpauth", branch = "master" } [dependencies] actix-web = "0.6" base64 = "0.9" +percent-encoding = "1.0.1" +bytes = "0.4.7" \ No newline at end of file diff --git a/examples/basic.rs b/examples/basic.rs index 2ff715fd9..3a32eb938 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -1,25 +1,24 @@ extern crate actix_web; extern crate actix_web_httpauth; -use actix_web::http::StatusCode; use actix_web::{server, App, HttpRequest, FromRequest, Result}; use actix_web::middleware::{Middleware, Started}; -use actix_web_httpauth::BasicAuth; +use actix_web_httpauth::basic::{BasicAuth, Config}; struct AuthMiddleware; impl Middleware for AuthMiddleware { fn start(&self, req: &mut HttpRequest) -> Result { - let auth = BasicAuth::extract(&req)?; + let mut config = Config::default(); + config.realm("Restricted area".to_string()); + let auth = BasicAuth::from_request(&req, &config)?; // Please note that this is only an example, // do not ever hardcode your credentials! if auth.username == "root" && auth.password == "pass" { Ok(Started::Done) } else { - let response = req.build_response(StatusCode::UNAUTHORIZED) - .header("WWW-Authenticate", "Basic") - .finish(); + let response = BasicAuth::error_response(&config); Ok(Started::Response(response)) } } @@ -32,8 +31,8 @@ fn index(auth: BasicAuth) -> String { fn main() { server::new(|| App::new() - // Comment line below to pass authentication handling - // directly to `index` handler. + // Comment the `.middleware()` line and let `BasicAuth` extractor + // in the `index` handler do the authentication routine .middleware(AuthMiddleware) .resource("/", |r| r.with(index))) .bind("127.0.0.1:8088").unwrap() diff --git a/src/basic/config.rs b/src/basic/config.rs new file mode 100644 index 000000000..298ffac3e --- /dev/null +++ b/src/basic/config.rs @@ -0,0 +1,61 @@ +use std::default::Default; + +use bytes::Bytes; +use percent_encoding; +use actix_web::http::header::{HeaderValue, IntoHeaderValue, InvalidHeaderValue}; + +use challenge::Challenge; + +/// Challenge configuration for [BasicAuth](./struct.BasicAuth.html) extractor. +#[derive(Debug, Clone)] +pub struct Config { + // "realm" parameter is optional now: https://tools.ietf.org/html/rfc7235#appendix-A + realm: Option, +} + +impl Config { + pub fn realm(&mut self, value: String) -> &mut Self { + self.realm = Some(value); + self + } + + fn as_bytes(&self) -> Bytes { + let mut bytes = Bytes::from_static(b"Basic"); + if let Some(ref realm) = self.realm { + bytes.extend_from_slice(b" realm=\""); + let realm = percent_encoding::utf8_percent_encode(realm, percent_encoding::SIMPLE_ENCODE_SET); + for part in realm { + bytes.extend_from_slice(part.as_bytes()); + } + bytes.extend_from_slice(b"\""); + } + + bytes + } +} + +impl IntoHeaderValue for Config { + type Error = InvalidHeaderValue; + + fn try_into(self) -> Result::Error> { + HeaderValue::from_bytes(&self.as_bytes()) + } +} + +impl<'a> IntoHeaderValue for &'a Config { + type Error = InvalidHeaderValue; + + fn try_into(self) -> Result::Error> { + HeaderValue::from_bytes(&self.as_bytes()) + } +} + +impl Default for Config { + fn default() -> Self { + Config { + realm: None, + } + } +} + +impl Challenge for Config {} diff --git a/src/basic/mod.rs b/src/basic/mod.rs new file mode 100644 index 000000000..c2c508d0a --- /dev/null +++ b/src/basic/mod.rs @@ -0,0 +1,101 @@ +use std::string; +use std::convert::From; + +use base64; +use actix_web::{HttpRequest, HttpMessage, HttpResponse, FromRequest, ResponseError}; +use actix_web::http::header; + +mod config; + +use errors::Error; +pub use self::config::Config; + +/// Extractor for `Authorization: Basic {payload}` HTTP request header. +/// +/// If header is not present or malformed, `HTTP 401` response will be returned. +/// See [Config](./struct.Config.html) struct also. +/// +/// # Example +/// +/// As a handler-level extractor: +/// +/// ```rust +/// use actix_web_httpauth::basic::BasicAuth; +/// +/// pub fn handler(auth: BasicAuth) -> String { +/// format!("Hello, {}", auth.username) +/// } +/// ``` +/// +/// See `examples/basic.rs` file in sources +#[derive(Debug, PartialEq)] +pub struct BasicAuth { + pub username: String, + pub password: String, +} + +impl BasicAuth { + pub fn error_response(cfg: &Config) -> HttpResponse { + Error::new(cfg.clone()).error_response() + } + + fn parse(req: &HttpRequest) -> Result { + let header = req.headers().get(header::AUTHORIZATION) + .ok_or(ParseError)? + .to_str()?; + let mut parts = header.splitn(2, ' '); + + // Authorization mechanism + match parts.next() { + Some(mechanism) if mechanism == "Basic" => (), + _ => return Err(ParseError), + } + + // Authorization payload + let payload = parts.next().ok_or(ParseError)?; + let payload = base64::decode(payload)?; + let payload = String::from_utf8(payload)?; + let mut parts = payload.splitn(2, ':'); + let user = parts.next().ok_or(ParseError)?; + let password = parts.next().ok_or(ParseError)?; + + Ok(BasicAuth{ + username: user.to_string(), + password: password.to_string(), + }) + } +} + + +impl FromRequest for BasicAuth { + type Config = Config; + type Result = Result; + + fn from_request(req: &HttpRequest, cfg: &>::Config) -> >::Result { + BasicAuth::parse(req).map_err(|_| Error::new(cfg.clone())) + } +} + +#[derive(Debug)] +struct ParseError; + +impl From for ParseError { + fn from(_: base64::DecodeError) -> Self { + Self{} + } +} + +impl From for ParseError { + fn from(_: header::ToStrError) -> Self { + Self{} + } +} + +impl From for ParseError { + fn from(_: string::FromUtf8Error) -> Self { + Self{} + } +} + +#[cfg(test)] +mod tests; diff --git a/src/basic/tests.rs b/src/basic/tests.rs new file mode 100644 index 000000000..25b9436fb --- /dev/null +++ b/src/basic/tests.rs @@ -0,0 +1,56 @@ +use base64; +use actix_web::FromRequest; +use actix_web::test::TestRequest; + +use super::BasicAuth; + +#[test] +fn test_valid_auth() { + let value = format!("Basic {}", base64::encode("user:pass")); + let req = TestRequest::with_header("Authorization", value).finish(); + let auth = BasicAuth::extract(&req); + + assert!(auth.is_ok()); + let auth = auth.unwrap(); + assert_eq!(auth.username, "user".to_string()); + assert_eq!(auth.password, "pass".to_string()); +} + +#[test] +fn test_missing_header() { + let req = TestRequest::default().finish(); + let auth = BasicAuth::extract(&req); + + assert!(auth.is_err()); +} + +#[test] +fn test_invalid_mechanism() { + let value = format!("Digest {}", base64::encode("user:pass")); + let req = TestRequest::with_header("Authorization", value).finish(); + let auth = BasicAuth::extract(&req); + + assert!(auth.is_err()); +} + +#[test] +fn test_invalid_format() { + let value = format!("Basic {}", base64::encode("user")); + let req = TestRequest::with_header("Authorization", value).finish(); + let auth = BasicAuth::extract(&req); + + assert!(auth.is_err()); +} + +#[test] +fn test_user_without_password() { + let value = format!("Basic {}", base64::encode("user:")); + let req = TestRequest::with_header("Authorization", value).finish(); + let auth = BasicAuth::extract(&req); + + assert!(auth.is_ok()); + assert_eq!(auth.unwrap(), BasicAuth { + username: "user".to_string(), + password: "".to_string(), + }) +} diff --git a/src/challenge.rs b/src/challenge.rs new file mode 100644 index 000000000..de2eb566b --- /dev/null +++ b/src/challenge.rs @@ -0,0 +1,6 @@ +use std::fmt::Debug; +use std::default::Default; + +use actix_web::http::header::IntoHeaderValue; + +pub trait Challenge: 'static + Debug + Clone + Send + Sync + IntoHeaderValue + Default {} \ No newline at end of file diff --git a/src/errors.rs b/src/errors.rs index 59ccd3db5..22be9ad6d 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,65 +1,41 @@ use std::fmt; -use std::error::Error; -use std::string; +use std::error::Error as StdError; -use base64; use actix_web::HttpResponse; use actix_web::error::ResponseError; use actix_web::http::{StatusCode, header}; -#[derive(Debug, PartialEq)] -pub enum AuthError { - HeaderMissing, // HTTP 401 - // TODO: Ensure that 401 should be returned if not a `Basic` mechanism is received - InvalidMechanism, // HTTP 401 ? - HeaderMalformed, // HTTP 400 +use basic::Config; + +#[derive(Debug)] +pub struct Error { + challenge: Config, } -impl fmt::Display for AuthError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str(self.description()) - } -} - -impl Error for AuthError { - fn description(&self) -> &str { - match *self { - AuthError::HeaderMissing => "HTTP 'Authorization' header is missing", - AuthError::InvalidMechanism => "Wrong mechanism for a HTTP 'Authorization' header, expected 'Basic'", - AuthError::HeaderMalformed => "Malformed HTTP 'Authorization' header", +impl Error { + pub fn new(config: Config) -> Error { + Error { + challenge: config, } } } -impl From for AuthError { - fn from(_: header::ToStrError) -> Self { - AuthError::HeaderMalformed - } -} - -impl From for AuthError { - fn from(_: base64::DecodeError) -> Self { - AuthError::HeaderMalformed - } -} - -impl From for AuthError { - fn from(_: string::FromUtf8Error) -> Self { - AuthError::HeaderMalformed - } -} - -impl ResponseError for AuthError { +impl ResponseError for Error { fn error_response(&self) -> HttpResponse { - let status = match *self { - AuthError::HeaderMissing => StatusCode::UNAUTHORIZED, - AuthError::InvalidMechanism => StatusCode::UNAUTHORIZED, - AuthError::HeaderMalformed => StatusCode::BAD_REQUEST, - }; - - HttpResponse::build(status) - .header("WWW-Authenticate", "Basic") + HttpResponse::build(StatusCode::UNAUTHORIZED) + .header(header::WWW_AUTHENTICATE, &self.challenge) .finish() } - +} + +impl StdError for Error { + fn description(&self) -> &str { + "Unauthorized request" + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.description()) + } } diff --git a/src/lib.rs b/src/lib.rs index 1b1d0654b..40aa75125 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,13 @@ +//! HTTP authorization routines for [actix-web](https://github.com/actix/actix-web) framework. +//! +//! Currently supported schemas: +//! * Basic ([RFC-7617](https://tools.ietf.org/html/rfc7617)) + +extern crate bytes; +extern crate percent_encoding; extern crate actix_web; extern crate base64; -mod schemes; mod errors; - -pub use schemes::*; -pub use errors::AuthError; +mod challenge; +pub mod basic;