diff --git a/.appveyor.yml b/.appveyor.yml index a3543f088..36ba4cf0b 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -7,7 +7,6 @@ environment: matrix: - TARGET: x86_64-pc-windows-msvc - - TARGET: x86_64-pc-windows-gnu install: - curl -fsS --retry 3 --retry-connrefused -o rustup-init.exe https://win.rustup.rs/ - rustup-init -yv --default-toolchain stable --default-host %target% diff --git a/Cargo.toml b/Cargo.toml index 777393ec5..16ce42476 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ ## Middleware "middleware/template", "middleware/under_development/diesel", + "middleware/jwt", ## Examples (these crates are not published) "examples/hello_world", diff --git a/middleware/jwt/Cargo.toml b/middleware/jwt/Cargo.toml new file mode 100644 index 000000000..0b1571031 --- /dev/null +++ b/middleware/jwt/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "gotham_middleware_jwt" +version = "0.3.1" +authors = ["Nicholas Young ", + "Colin Bankier ", + "Isaac Whitfield ", + "Judson Lester ", + "Bradley Beddoes "] +description = "JWT middleware for the Gotham web framework." +repository = "https://github.com/gotham-rs/gotham" +keywords = ["gotham-middleware", "jwt", "jsonwebtoken", "authentication"] +homepage = "https://gotham.rs" +readme = "README.md" +license = "MIT/Apache-2.0" +edition = "2018" + +[dependencies] +futures = "0.1" +gotham = "0.3" +gotham_derive = "0.3" +serde = "1.0" +serde_derive = "1.0" +hyper = "0.12" +jsonwebtoken = "5.0" +log = "0.4" diff --git a/middleware/jwt/README.md b/middleware/jwt/README.md new file mode 100644 index 000000000..7f52d9743 --- /dev/null +++ b/middleware/jwt/README.md @@ -0,0 +1,77 @@ +# gotham_middleware_jwt + +A middleware for the [Gotham](https://gotham.rs) Web +Framework that verifies JSON Web Tokens, returning +`StatusCode::UNAUTHORIZED` if a request fails validation. + +## Usage + +First, ensure you're using at least Gotham version `0.3`. Then, add the +following to your `Cargo.toml`: `gotham_middleware_jwt = "0.3"`. + +Second, create a struct you wish to deserialize into. For our example below, +we've used `Claims`: + +```rust +extern crate futures; +extern crate gotham; +extern crate gotham_middleware_jwt; +extern crate hyper; +extern crate serde; +#[macro_use] +extern crate serde_derive; + +use futures::future; +use gotham::{ + helpers::http::response::create_empty_response, + handler::HandlerFuture, + pipeline::{ + new_pipeline, + set::{finalize_pipeline_set, new_pipeline_set}, + }, + router::{builder::*, Router}, + state::{State, FromState}, +}; +use gotham_middleware_jwt::{JWTMiddleware, AuthorizationToken}; +use hyper::{Response, StatusCode}; + +#[derive(Deserialize, Debug)] +struct Claims { + sub: String, + exp: usize, +} + +fn handler(state: State) -> Box { + { + let token = AuthorizationToken::::borrow_from(&state); + // token -> TokenData + } + let res = create_empty_response(&state, StatusCode::OK); + Box::new(future::ok((state, res))) +} + +fn router() -> Router { + let pipelines = new_pipeline_set(); + let (pipelines, defaults) = pipelines.add( + new_pipeline() + .add(JWTMiddleware::::new("secret".as_ref())) + .build(), + ); + let default_chain = (defaults, ()); + let pipeline_set = finalize_pipeline_set(pipelines); + build_router(default_chain, pipeline_set, |route| { + route.get("/").to(handler); + }) +} +``` +## License + +This middleware crate was originally created by [Nicholas +Young](https://www.secretfader.com) of Uptime Ventures, Ltd., +and is maintained by the [Gotham](https://gotham.rs) core +team. + +Licensed under your option of: + +* [MIT License](../../LICENSE-MIT) +* [Apache License, Version 2.0](../../LICENSE-APACHE) diff --git a/middleware/jwt/src/lib.rs b/middleware/jwt/src/lib.rs new file mode 100644 index 000000000..14ac614a3 --- /dev/null +++ b/middleware/jwt/src/lib.rs @@ -0,0 +1,27 @@ +//! Ensures that only requests with valid JSON Web Tokens +//! included in the HTTP `Authorization` header are allowed +//! to pass. +//! +//! Requests that lack a token are returned with the +//! Status Code `400: Bad Request`. Tokens that fail +//! validation cause the middleware to return Status Code +//! `401: Unauthorized`. +#![warn(missing_docs, deprecated)] +extern crate futures; +extern crate gotham; +#[macro_use] +extern crate gotham_derive; +extern crate hyper; +extern crate jsonwebtoken; +extern crate serde; +#[macro_use] +extern crate log; +#[cfg(test)] +#[macro_use] +extern crate serde_derive; + +mod middleware; +mod state_data; + +pub use self::middleware::JWTMiddleware; +pub use self::state_data::AuthorizationToken; diff --git a/middleware/jwt/src/middleware.rs b/middleware/jwt/src/middleware.rs new file mode 100644 index 000000000..007a75c73 --- /dev/null +++ b/middleware/jwt/src/middleware.rs @@ -0,0 +1,328 @@ +use crate::state_data::AuthorizationToken; +use futures::{future, Future}; +use gotham::{ + handler::HandlerFuture, + helpers::http::response::create_empty_response, + middleware::{Middleware, NewMiddleware}, + state::{request_id, FromState, State}, +}; +use hyper::{ + header::{HeaderMap, AUTHORIZATION}, + StatusCode, +}; +use jsonwebtoken::{decode, Validation}; +use serde::de::Deserialize; +use std::{io, marker::PhantomData, panic::RefUnwindSafe}; + +/// This middleware verifies that JSON Web Token +/// credentials, provided via the HTTP `Authorization` +/// header, are extracted, parsed, and validated +/// according to best practices before passing control +/// to middleware beneath this middleware for a given +/// mount point. +/// +/// Requests that lack the `Authorization` header are +/// returned with the Status Code `400: Bad Request`. +/// Tokens that fail validation cause the middleware +/// to return Status Code `401: Unauthorized`. +/// +/// Example: +/// ```rust +/// extern crate futures; +/// extern crate gotham; +/// extern crate gotham_middleware_jwt; +/// extern crate hyper; +/// extern crate serde; +/// #[macro_use] +/// extern crate serde_derive; +/// +/// use futures::future; +/// use gotham::{ +/// helpers::http::response::create_empty_response, +/// handler::HandlerFuture, +/// pipeline::{ +/// new_pipeline, +/// set::{finalize_pipeline_set, new_pipeline_set}, +/// }, +/// router::{builder::*, Router}, +/// state::{State, FromState}, +/// }; +/// use gotham_middleware_jwt::{JWTMiddleware, AuthorizationToken}; +/// use hyper::{Response, StatusCode}; +/// +/// #[derive(Deserialize, Debug)] +/// struct Claims { +/// sub: String, +/// exp: usize, +/// } +/// +/// fn handler(state: State) -> Box { +/// { +/// let token = AuthorizationToken::::borrow_from(&state); +/// // token -> TokenData +/// } +/// let res = create_empty_response(&state, StatusCode::OK); +/// Box::new(future::ok((state, res))) +/// } +/// +/// fn router() -> Router { +/// let pipelines = new_pipeline_set(); +/// let (pipelines, defaults) = pipelines.add( +/// new_pipeline() +/// .add(JWTMiddleware::::new("secret".as_ref())) +/// .build(), +/// ); +/// let default_chain = (defaults, ()); +/// let pipeline_set = finalize_pipeline_set(pipelines); +/// build_router(default_chain, pipeline_set, |route| { +/// route.get("/").to(handler); +/// }) +/// } +/// +/// # fn main() { +/// # let _ = router(); +/// # } +/// ``` +pub struct JWTMiddleware { + secret: &'static str, + validation: Validation, + claims: PhantomData, +} + +impl JWTMiddleware +where + T: for<'de> Deserialize<'de> + Send + Sync, +{ + /// Creates a JWTMiddleware instance from the provided secret, + /// which, by default, uses HS256 as the crypto scheme. + pub fn new(secret: &'static str) -> Self { + let validation = Validation::default(); + + JWTMiddleware { + secret, + validation, + claims: PhantomData, + } + } + + /// Create a new instance of the middleware by appending new + /// validation constraints. + pub fn validation(self, validation: Validation) -> Self { + JWTMiddleware { validation, ..self } + } +} + +impl Middleware for JWTMiddleware +where + T: for<'de> Deserialize<'de> + Send + Sync + 'static, +{ + fn call(self, mut state: State, chain: Chain) -> Box + where + Chain: FnOnce(State) -> Box, + { + trace!("[{}] pre-chain jwt middleware", request_id(&state)); + + let token = match HeaderMap::borrow_from(&state).get(AUTHORIZATION) { + Some(h) => match h.to_str() { + Ok(hx) => hx.get(8..), + _ => None, + }, + _ => None, + }; + + if token.is_none() { + trace!("[{}] bad request jwt middleware", request_id(&state)); + let res = create_empty_response(&state, StatusCode::BAD_REQUEST); + return Box::new(future::ok((state, res))); + } + + match decode::(&token.unwrap(), self.secret.as_ref(), &self.validation) { + Ok(token) => { + state.put(AuthorizationToken(token)); + + let res = chain(state).and_then(|(state, res)| { + trace!("[{}] post-chain jwt middleware", request_id(&state)); + future::ok((state, res)) + }); + + Box::new(res) + } + Err(e) => { + trace!("[{}] error jwt middleware", e); + let res = create_empty_response(&state, StatusCode::UNAUTHORIZED); + Box::new(future::ok((state, res))) + } + } + } +} + +impl NewMiddleware for JWTMiddleware +where + T: for<'de> Deserialize<'de> + RefUnwindSafe + Send + Sync + 'static, +{ + type Instance = JWTMiddleware; + + fn new_middleware(&self) -> io::Result { + Ok(JWTMiddleware { + secret: self.secret, + validation: self.validation.clone(), + claims: PhantomData, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::future; + use gotham::{ + handler::HandlerFuture, + pipeline::{new_pipeline, single::*}, + router::{builder::*, Router}, + state::State, + test::TestServer, + }; + use jsonwebtoken::{encode, Algorithm, Header}; + + const SECRET: &'static str = "some-secret"; + + #[derive(Debug, Deserialize, Serialize)] + pub struct Claims { + sub: String, + exp: usize, + } + + fn token(alg: Algorithm) -> String { + let claims = &Claims { + sub: "test@example.net".to_owned(), + exp: 10000000000, + }; + + let mut header = Header::default(); + header.kid = Some("signing-key".to_owned()); + header.alg = alg; + + let token = match encode(&header, &claims, SECRET.as_ref()) { + Ok(t) => t, + Err(_) => panic!(), + }; + + token + } + + fn handler(state: State) -> Box { + { + // If this compiles, the token is available. + let _ = AuthorizationToken::::borrow_from(&state); + } + let res = create_empty_response(&state, StatusCode::OK); + Box::new(future::ok((state, res))) + } + + fn router() -> Router { + // Create JWTMiddleware with HS256 algorithm (default). + let valid = Validation { + ..Validation::default() + }; + + let middleware = JWTMiddleware::::new(SECRET.as_ref()).validation(valid); + + let (chain, pipelines) = single_pipeline(new_pipeline().add(middleware).build()); + + build_router(chain, pipelines, |route| { + route.get("/").to(handler); + }) + } + + #[test] + fn jwt_middleware_no_header_test() { + let test_server = TestServer::new(router()).unwrap(); + let res = test_server + .client() + .get("https://example.com") + .perform() + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn jwt_middleware_no_value_test() { + let test_server = TestServer::new(router()).unwrap(); + let res = test_server + .client() + .get("https://example.com") + .with_header(AUTHORIZATION, format!("").parse().unwrap()) + .perform() + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn jwt_middleware_no_token_test() { + let test_server = TestServer::new(router()).unwrap(); + let res = test_server + .client() + .get("https://example.com") + .with_header(AUTHORIZATION, format!("Bearer ").parse().unwrap()) + .perform() + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn jwt_middleware_malformatted_token_test() { + let test_server = TestServer::new(router()).unwrap(); + let res = test_server + .client() + .get("https://example.com") + .with_header(AUTHORIZATION, format!("Bearer xxxx").parse().unwrap()) + .perform() + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn jwt_middleware_malformatted_token_no_space_test() { + let test_server = TestServer::new(router()).unwrap(); + let res = test_server + .client() + .get("https://example.com") + .with_header(AUTHORIZATION, format!("Bearer").parse().unwrap()) + .perform() + .unwrap(); + + assert_eq!(res.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn jwt_middleware_invalid_algorithm_token_test() { + let test_server = TestServer::new(router()).unwrap(); + let res = test_server + .client() + .get("https://example.com") + .with_header(AUTHORIZATION, format!("Bearer: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1MzA0MDE1MjcsImlhdCI6MTUzMDM5OTcyN30.lhg7K9SK3DXsvimVb6o_h6VcsINtkT-qHR-tvDH1bGI").parse().unwrap()) + .perform() + .unwrap(); + + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn jwt_middleware_valid_token_test() { + let token = token(Algorithm::HS256); + let test_server = TestServer::new(router()).unwrap(); + println!("Requesting with token... {}", token); + let res = test_server + .client() + .get("https://example.com") + .with_header(AUTHORIZATION, format!("Bearer: {}", token).parse().unwrap()) + .perform() + .unwrap(); + + assert_eq!(res.status(), StatusCode::OK); + } +} diff --git a/middleware/jwt/src/state_data.rs b/middleware/jwt/src/state_data.rs new file mode 100644 index 000000000..7c1f6f12e --- /dev/null +++ b/middleware/jwt/src/state_data.rs @@ -0,0 +1,5 @@ +pub use jsonwebtoken::TokenData; + +/// Struct to contain the JSON Web Token on a per-request basis. +#[derive(StateData, Debug)] +pub struct AuthorizationToken(pub TokenData);