diff --git a/Cargo.toml b/Cargo.toml index 420da40c..f4184ea7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,5 +3,6 @@ members = [ "okapi", "rocket-okapi", "rocket-okapi-codegen", - "examples/json-web-api" + "examples/json-web-api", + "examples/secure_request_guard" ] diff --git a/examples/secure_request_guard/.gitignore b/examples/secure_request_guard/.gitignore new file mode 100644 index 00000000..2e4fa7f2 --- /dev/null +++ b/examples/secure_request_guard/.gitignore @@ -0,0 +1,4 @@ +/target +**/*.rs.bk +Cargo.lock +/.idea diff --git a/examples/secure_request_guard/Cargo.toml b/examples/secure_request_guard/Cargo.toml new file mode 100644 index 00000000..615888f9 --- /dev/null +++ b/examples/secure_request_guard/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "secure_request_guard" +version = "0.1.0" +authors = ["Kristoffer Ödmark "] +edition = "2018" + +[dependencies] +rocket = { version = "0.5.0-rc.1", default-features = false, features = ["json"] } +schemars = { version = "0.8"} +okapi = { version = "0.6.0-alpha-1", path = "../../okapi" } +rocket_okapi = { version = "0.7.0-alpha-1", path = "../../rocket-okapi" } +serde = "1.0" +tokio = "1.6" diff --git a/examples/secure_request_guard/README.md b/examples/secure_request_guard/README.md new file mode 100644 index 00000000..665831a8 --- /dev/null +++ b/examples/secure_request_guard/README.md @@ -0,0 +1,8 @@ +# Secure Request Guard + +A simple web API written using [Rocket](https://rocket.rs/) including openapi. It contains one route that needs the correct key provided +to allow access. This example implements the security called by an api key that needs to be included +in the header from the following: [apikeys] (https://swagger.io/docs/specification/authentication/api-keys/) + +the key is "hello" + diff --git a/examples/secure_request_guard/src/main.rs b/examples/secure_request_guard/src/main.rs new file mode 100644 index 00000000..e4a50f67 --- /dev/null +++ b/examples/secure_request_guard/src/main.rs @@ -0,0 +1,113 @@ +use okapi::openapi3::{Object, Responses, SecurityRequirement, SecurityScheme, SecuritySchemeData}; +use rocket::serde::json::Json; +use rocket::{ + get, + http::Status, + request::{self, FromRequest, Outcome}, + Config, Response, +}; +use rocket_okapi::{ + gen::OpenApiGenerator, + openapi, + request::{OpenApiFromRequest, RequestHeaderInput}, + response::OpenApiResponder, + routes_with_openapi, + swagger_ui::{make_swagger_ui, SwaggerUIConfig}, +}; + +pub struct KeyAuthorize; + +#[rocket::async_trait] +impl<'a> FromRequest<'a> for KeyAuthorize { + type Error = (); + async fn from_request( + request: &'a request::Request<'_>, + ) -> request::Outcome { + // Same as in the name + let keys: Vec<_> = request.headers().get("x-api-key").collect(); + + // Get the key from the http header + let out = match keys.len() { + 1 => { + let key = &keys[0][..]; + if key == "hello" { + Outcome::Success(KeyAuthorize) + } else { + Outcome::Failure((Status::Unauthorized, ())) + } + } + _ => { + println!("wrong amount of authorization headers found"); + Outcome::Failure((Status::BadRequest, ())) + } + }; + out + } +} + +impl<'a, 'r> OpenApiFromRequest<'a> for KeyAuthorize { + fn request_input( + _gen: &mut OpenApiGenerator, + _name: String, + ) -> rocket_okapi::Result { + let mut security_req = SecurityRequirement::new(); + // each security requirement needs a specific key in the openapi docs + security_req.insert("example_security".into(), Vec::new()); + + // The scheme for the security needs to be defined as well + // https://swagger.io/docs/specification/authentication/basic-authentication/ + let security_scheme = SecurityScheme { + description: Some("requires a key to access".into()), + scheme_identifier: "FixedKeyApiKeyAuth".into(), + data: SecuritySchemeData::ApiKey { + name: "x-api-key".into(), + location: "header".into(), + }, + extensions: Object::default(), + }; + + Ok(RequestHeaderInput::Security(( + security_scheme, + security_req, + ))) + } +} + +/// Defines the possible responses for this request guard for the openapi docs (not used yet) +impl<'a, 'r: 'a> OpenApiResponder<'a, 'r> for KeyAuthorize { + fn responses(_: &mut OpenApiGenerator) -> rocket_okapi::Result { + let responses = Responses::default(); + Ok(responses) + } +} + +/// Returns an empty, default `Response`. Always returns `Ok`. +/// Defines the possible response for this request guard +impl<'a, 'r: 'a> rocket::response::Responder<'a, 'r> for KeyAuthorize { + fn respond_to(self, _: &rocket::request::Request<'_>) -> rocket::response::Result<'static> { + Ok(Response::new()) + } +} + +#[openapi] +#[get("/restricted")] +pub fn restricted(_key: KeyAuthorize) -> Json { + Json("You got access here, hurray".into()) +} + +#[tokio::main] +async fn main() { + let rocket_config = Config::debug_default(); + + let e = rocket::custom(rocket_config) + .mount("/", routes_with_openapi![restricted]) + .mount( + "/api/", + make_swagger_ui(&SwaggerUIConfig { + url: "/openapi.json".to_string(), + ..Default::default() + }), + ) + .launch() + .await; +} diff --git a/okapi/src/openapi3.rs b/okapi/src/openapi3.rs index fb62d927..7041294c 100644 --- a/okapi/src/openapi3.rs +++ b/okapi/src/openapi3.rs @@ -4,9 +4,9 @@ pub use schemars::schema::SchemaObject; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; -type Object = Map; +pub type Object = Map; -type SecurityRequirement = Map>; +pub type SecurityRequirement = Map>; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[cfg_attr(feature = "derive_json_schema", derive(JsonSchema))] @@ -352,8 +352,8 @@ pub struct Header { #[cfg_attr(feature = "derive_json_schema", derive(JsonSchema))] #[serde(rename_all = "camelCase")] pub struct SecurityScheme { - #[serde(rename = "type")] - pub schema_type: String, + // unique name for the security scheme + pub scheme_identifier: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(flatten)] diff --git a/rocket-okapi-codegen/src/openapi_attr/mod.rs b/rocket-okapi-codegen/src/openapi_attr/mod.rs index bbb13247..6729cede 100644 --- a/rocket-okapi-codegen/src/openapi_attr/mod.rs +++ b/rocket-okapi-codegen/src/openapi_attr/mod.rs @@ -118,6 +118,46 @@ fn create_route_operation_fn( }) } + // Request quards, checks if the items are not found in the rocket route parameters, if that is the + // case, we assume they are request guards + let mut responses = Vec::new(); + responses.push(quote! { + <#return_type as ::rocket_okapi::response::OpenApiResponder>::responses(gen)? + }); + + let data_param_arg = route.data_param.clone().unwrap_or_else(|| String::new()); + for arg_type in arg_types { + let ty = arg_type.1; + let arg = arg_type.0; + + // If the items are not found in the list of path/query parameters, assume the item is a request + // guard and let them add to the openapi specification from the trait OpenApiFromRequest + // Request guards can add or define their own responses, and can thus add to the possible + // responses from an API + if route + .path_params() + .find(|item| arg == item.to_string()) + .is_none() + // Verify it is not in query parameters + && route + .query_params() + .find(|item| arg == item.to_string()) + .is_none() + && data_param_arg != arg + { + // println!("assuming request guard for: {:?}", arg); + params.push(quote! { + <#ty as ::rocket_okapi::request::OpenApiFromRequest>::request_input(gen, #arg.to_owned())?.into() + }); + //TODO: implement that RequestGuards can specify the different types of responses + + // Create a response for this one + // responses.push(quote! { + // <#ty as ::rocket_okapi::response::OpenApiResponder>::responses(gen)? + // }) + } + } + let fn_name = get_add_operation_fn_name(&route_fn.sig.ident); let path = route .origin @@ -148,7 +188,38 @@ fn create_route_operation_fn( ) -> ::rocket_okapi::Result<()> { let responses = <#return_type as ::rocket_okapi::response::OpenApiResponder>::responses(gen)?; let request_body = #request_body; - let mut parameters: Vec<::okapi::openapi3::RefOr<::okapi::openapi3::Parameter>> = vec![#(#params),*]; + + //############### + use ::rocket_okapi::request::RequestHeaderInput; + use ::okapi::openapi3::Parameter; + use ::okapi::openapi3::RefOr; + + let request_inputs: Vec = vec![#(#params),*]; + + let mut parameters: Vec<::okapi::openapi3::RefOr> = Vec::new(); + use std::collections::BTreeMap as Map; + let mut security_schemes = Map::new(); + for inp in request_inputs { + match inp { + RequestHeaderInput::Parameter(p) => { + parameters.push(p.into()); + } + RequestHeaderInput::Security(s) => { + // Make sure to add the security scheme listing + security_schemes.insert(s.0.scheme_identifier.clone(), Vec::new()); + // Add the scheme to components definition of openapi + gen.add_security_scheme(s.0.scheme_identifier.clone(), s.0.clone()); + } + _ => { + } + } + } + let security = if security_schemes.is_empty() { + None + } else { + Some(vec![security_schemes]) + }; + // add nested lists let parameters_nested_list: Vec> = vec![#(#params_nested_list),*]; for inner_list in parameters_nested_list{ @@ -167,8 +238,9 @@ fn create_route_operation_fn( parameters, summary: #title, description: #desc, + security, tags: vec![#(#tags),*], - ..okapi::openapi3::Operation::default() + ..::okapi::openapi3::Operation::default() }, }); Ok(()) diff --git a/rocket-okapi/src/gen.rs b/rocket-okapi/src/gen.rs index 35098c88..c665563c 100644 --- a/rocket-okapi/src/gen.rs +++ b/rocket-okapi/src/gen.rs @@ -1,6 +1,6 @@ use crate::settings::OpenApiSettings; use crate::OperationInfo; -use okapi::openapi3::{Components, OpenApi, Operation, PathItem}; +use okapi::openapi3::{Components, OpenApi, Operation, PathItem, RefOr, SecurityScheme}; use rocket::http::Method; use schemars::gen::SchemaGenerator; use schemars::schema::SchemaObject; @@ -13,6 +13,7 @@ use std::collections::HashMap; pub struct OpenApiGenerator { settings: OpenApiSettings, schema_generator: SchemaGenerator, + security_schemes: Map, operations: Map>, } @@ -23,10 +24,16 @@ impl OpenApiGenerator { OpenApiGenerator { schema_generator: settings.schema_settings.clone().into_generator(), settings, - operations: okapi::Map::default(), + security_schemes: Default::default(), + operations: Default::default(), } } + /// Adds a security scheme to the generated output + pub fn add_security_scheme(&mut self, name: String, scheme: SecurityScheme) { + self.security_schemes.insert(name, scheme); + } + /// Add a new `HTTP Method` to the collection of endpoints in the `OpenApiGenerator`. pub fn add_operation(&mut self, mut op: OperationInfo) { if let Some(op_id) = op.operation.operation_id { @@ -73,6 +80,12 @@ impl OpenApiGenerator { let mut schema_generator = self.schema_generator; let mut schemas = schema_generator.take_definitions(); + // Add the security schemes + let mut schemes: Map> = Default::default(); + for (name, schema) in self.security_schemes { + schemes.insert(name, schema.into()); + } + for visitor in schema_generator.visitors_mut() { for schema in schemas.values_mut() { visitor.visit_schema(schema) @@ -93,7 +106,8 @@ impl OpenApiGenerator { }, components: Some(Components { schemas: schemas.into_iter().map(|(k, v)| (k, v.into())).collect(), - ..Components::default() + security_schemes: schemes, + ..Default::default() }), ..OpenApi::default() } diff --git a/rocket-okapi/src/request/from_request_impls.rs b/rocket-okapi/src/request/from_request_impls.rs new file mode 100644 index 00000000..9c5e8097 --- /dev/null +++ b/rocket-okapi/src/request/from_request_impls.rs @@ -0,0 +1,23 @@ +use super::{OpenApiFromRequest, RequestHeaderInput}; +use crate::gen::OpenApiGenerator; +use okapi::openapi3::*; + +impl<'a, T: Send + Sync> OpenApiFromRequest<'a> for &'a rocket::State { + fn request_input( + _gen: &mut OpenApiGenerator, + name: String, + ) -> crate::Result { + Ok(RequestHeaderInput::Parameter(Parameter { + required: false, + name: name, + location: "header".into(), + description: None, + deprecated: false, + allow_empty_value: true, + extensions: Object::default(), + value: ParameterValue::Content { + content: Default::default(), + }, + })) + } +} diff --git a/rocket-okapi/src/request/mod.rs b/rocket-okapi/src/request/mod.rs index df1b042c..45529315 100644 --- a/rocket-okapi/src/request/mod.rs +++ b/rocket-okapi/src/request/mod.rs @@ -2,10 +2,11 @@ mod from_data_impls; mod from_form_multi_param_impls; mod from_form_param_impls; mod from_param_impls; +mod from_request_impls; use super::gen::OpenApiGenerator; use super::Result; -use okapi::openapi3::{Parameter, RequestBody}; +use okapi::openapi3::{Parameter, RequestBody, SecurityRequirement, SecurityScheme}; /// Expose this to the public to be use when manualy implementing a /// [Form Guard](https://api.rocket.rs/master/rocket/form/trait.FromForm.html). @@ -58,3 +59,29 @@ pub trait OpenApiFromForm<'r>: rocket::form::FromForm<'r> { required: bool, ) -> Result>; } + +/// Commonly the items in the request header can be parameters, or authorization methods in rocket, +/// this item will let the implementer choose what they are +pub enum RequestHeaderInput { + /// This request header requires no input anywhere + None, + /// Parameter input to the path + Parameter(Parameter), + /// the path implements a security scheme + Security((SecurityScheme, SecurityRequirement)), +} + +impl Into for Parameter { + fn into(self) -> RequestHeaderInput { + RequestHeaderInput::Parameter(self) + } +} + +/// This will let the request guards add to the openapi spec +pub trait OpenApiFromRequest<'a>: rocket::request::FromRequest<'a> { + /// Return a parameter for items that are found in the function call, but are not found + /// anywhere in the path definition by rocket, defaults to a none implementation for simplicity + fn request_input(_gen: &mut OpenApiGenerator, _name: String) -> Result { + Ok(RequestHeaderInput::None) + } +}