diff --git a/.gitignore b/.gitignore index ddf9e12..8779a8a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ simple-auth tests/python/__pycache__ tests/bash/response.txt +tests/data/*.ini diff --git a/Cargo.lock b/Cargo.lock index b4e4710..c9a5d07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1205,7 +1205,7 @@ dependencies = [ [[package]] name = "simple-auth" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-std", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 95e5ae6..4121298 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "simple-auth" -version = "0.1.0" +version = "0.2.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/README.md b/README.md index 43810ba..b8bc60f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # simple-auth -A little web server providing JWT token for auth auser. +A little web server providing JWT token for auth user. ## Build ```bash @@ -48,6 +48,10 @@ expiration_time = 2 # in hours curl http://:/get/ -d '{"username":"", "password":""}' # should returned {"token":"
.."} + +curl http://:/validate/ -d '{"token":"
.."}' +# should returned (if valid) +{"valid":"true"} ``` ## Test @@ -58,7 +62,13 @@ cargo test ``` ### integration tests -* run the server locally or remotly (the URL must be changed if needed in `curling.bash` and `test_requests.py`) +* do the **configuration** step for your env tests +* set the following env variables: +```bash +export SIMPLE_AUTH_URL="http://:" +export SIMPLE_AUTH_PUB_KEY="" # DO NOT USE THE ONE IN PRODUCTION ! +``` +* run the server (if no one is running remotly) * run curl tests ```bash cd tests/bash/ @@ -75,7 +85,7 @@ source venv/bin/activate pip install -r requirements # launch the tests -python -m unitest +python -m unittest ``` ## Documentation diff --git a/src/config/config.rs b/src/config/config.rs new file mode 100644 index 0000000..1f77698 --- /dev/null +++ b/src/config/config.rs @@ -0,0 +1,129 @@ +//! config module implements all the utilities to properly create and validate a router config + +use configparser::ini::Ini; +use std::str::FromStr; + +#[derive(Clone)] +pub struct Config { + pub jwt_exp_time: u64, + pub jwt_issuer: String, + pub jwt_priv_key: String, + pub jwt_pub_key: String, + pub filestore_path: String, +} + +impl Default for Config { + fn default() -> Self { + Config { + jwt_exp_time: 0, + jwt_issuer: "".to_string(), + jwt_priv_key: "".to_string(), + jwt_pub_key: "".to_string(), + filestore_path: "".to_string(), + } + } +} + +impl TryFrom for Config { + type Error = String; + fn try_from(config: Ini) -> Result { + let exp_time = config + .get("jwt", "expiration_time") + .unwrap_or("".to_string()); + let jwt_exp_time = { + match u64::from_str(&exp_time) { + Ok(v) => v, + Err(e) => { + eprintln!("unable to convert JWT expiration time into u64 err={}", e); + 0 + } + } + }; + let config = Config { + jwt_exp_time, + jwt_issuer: config.get("jwt", "issuer").unwrap_or("".to_string()), + jwt_pub_key: config.get("jwt", "public_key").unwrap_or("".to_string()), + jwt_priv_key: config.get("jwt", "private_key").unwrap_or("".to_string()), + filestore_path: config.get("store", "path").unwrap_or("".to_string()), + }; + + if !config.validate() { + return Err("ini file configuration validation failed".to_string()); + } + + Ok(config) + } +} + +impl Config { + /// validates config ini file + fn validate(&self) -> bool { + if self.jwt_exp_time <= 0 { + eprintln!("invalid config parameter: JWT expiration time is negative or equals to 0"); + return false; + } + + if self.jwt_issuer == "" { + eprintln!("invalid config parameter: JWT issuer is empty"); + return false; + } + + if self.jwt_pub_key == "" { + eprintln!("invalid config parameter: JWT public key file path is empty"); + return false; + } + + if self.jwt_priv_key == "" { + eprintln!("invalid config parameter: JWT private key file path is empty"); + return false; + } + + if self.filestore_path == "" { + eprintln!("invalid config parameter: filestore path is empty"); + return false; + } + + true + } +} + +#[test] +fn test_config() { + use std::env; + + let root_path = env::var("CARGO_MANIFEST_DIR").unwrap(); + + let config_path = format!("{}/{}/{}/{}", root_path, "tests", "data", "config.ini"); + let mut config = Ini::new(); + let _r = config.load(config_path); + + let router_config = Config::try_from(config); + assert!(router_config.is_ok()); +} + +#[test] +fn test_bad_config() { + use std::env; + + let root_path = env::var("CARGO_MANIFEST_DIR").unwrap(); + + let config_path = format!("{}/{}/{}/{}", root_path, "tests", "data", "bad_config.ini"); + let mut config = Ini::new(); + let _r = config.load(config_path); + + let router_config = Config::try_from(config); + assert!(router_config.is_err()); +} + +#[test] +fn test_bad_config_path() { + use std::env; + + let root_path = env::var("CARGO_MANIFEST_DIR").unwrap(); + + let config_path = format!("{}/{}/{}/{}", root_path, "tests", "data", "con.ini"); + let mut config = Ini::new(); + + let result = config.load(config_path); + assert!(result.is_err()); +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..43fe76c --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,3 @@ +mod config; + +pub use config::Config; diff --git a/src/http/message.rs b/src/http/message.rs new file mode 100644 index 0000000..78b5ac4 --- /dev/null +++ b/src/http/message.rs @@ -0,0 +1,93 @@ +use json; +use std::collections::HashMap; + +const JSON_DELIMITER: &'static str = ","; + +/// `HashMap` wrapper, represents the JSON response body +pub struct HTTPMessage { + message: HashMap, +} + +impl Default for HTTPMessage { + fn default() -> Self { + HTTPMessage { + message: HashMap::new(), + } + } +} + +/// try to convert `HTTPMessage` in `json::JsonValue` +impl TryInto for HTTPMessage { + type Error = String; + fn try_into(self) -> Result { + let message = format!(r#"{{{}}}"#, self.build_json()); + match json::parse(&message) { + Ok(r) => Ok(r), + Err(e) => Err(format!( + "unable to parse the HTTPMessage correctly: {}, err={}", + message, e + )), + } + } +} + +impl HTTPMessage { + pub fn put(&mut self, key: &str, value: &str) { + self.message.insert(key.to_string(), value.to_string()); + } + + /// associated function to build an HTTPMessage error + pub fn error(message: &str) -> Option { + let mut http_message = HTTPMessage::default(); + http_message.put("error", message); + + match message.try_into() { + Ok(m) => Some(m), + Err(e) => { + eprintln!( + "unable to parse the message: {} into JSON, err={}", + message, e + ); + return None; + } + } + } + + /// loops over all the HashMap keys, builds a JSON key value for each one and join them with `JSON_DELIMITER` + fn build_json(self) -> String { + let unstruct: Vec = self + .message + .keys() + .map(|k| format!(r#""{}":{:?}"#, k, self.message.get(k).unwrap())) + .collect(); + let joined = unstruct.join(JSON_DELIMITER); + joined + } +} + +#[test] +fn test_message() { + let mut http_message = HTTPMessage::default(); + http_message.put("username", "toto"); + http_message.put("password", "tata"); + + let mut json_result: Result = http_message.try_into(); + assert!(json_result.is_ok()); + + let mut json = json_result.unwrap(); + assert!(json.has_key("username")); + assert!(json.has_key("password")); + + let empty_http_message = HTTPMessage::default(); + json_result = empty_http_message.try_into(); + assert!(json_result.is_ok()); + + json = json_result.unwrap(); + assert_eq!("{}", json.dump().to_string()); + + let mut bad_http_message = HTTPMessage::default(); + bad_http_message.put("\"", ""); + + json_result = bad_http_message.try_into(); + assert!(json_result.is_err()); +} diff --git a/src/http/mod.rs b/src/http/mod.rs index 38d58b0..077d5b4 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1,9 +1,11 @@ //! http module includes tools to parse an HTTP request and build and HTTP response +pub mod message; pub mod request; pub mod response; pub mod router; -pub use request::HTTPRequest; +pub use message::HTTPMessage; +pub use request::{HTTPRequest, HTTPVersion}; pub use response::{HTTPResponse, HTTPStatusCode}; -pub use router::{Config, ROUTER}; +pub use router::ROUTER; diff --git a/src/http/request.rs b/src/http/request.rs index d80471e..2d1bc4c 100644 --- a/src/http/request.rs +++ b/src/http/request.rs @@ -8,6 +8,8 @@ use lazy_static::lazy_static; use regex::Regex; use std::collections::VecDeque; +use crate::utils::extract_json_value; + type RequestParts = (String, VecDeque, String); const HTTP_REQUEST_SEPARATOR: &'static str = "\r\n"; @@ -105,7 +107,6 @@ impl Default for HTTPStartLine { fn default() -> Self { HTTPStartLine { method: "".to_string(), - target: "".to_string(), version: HTTPVersion::Unknown, } @@ -206,6 +207,17 @@ impl HTTPRequest { } } + /// retrieve value in `HTTPBody` (returns None if empty or does not exist) + pub fn get_body_value(&self, key: &str) -> Option { + match self.body { + Some(ref b) => match &b.data { + json::JsonValue::Object(d) => extract_json_value(&d, key), + _ => None, + }, + None => None, + } + } + #[allow(dead_code)] pub fn is_valid(&self) -> bool { return self.start_line.is_valid(); @@ -239,15 +251,17 @@ fn test_request() { start_line: String, body: Option, is_valid: bool, + has_token: bool, } - let test_cases: [(String, Expect); 11] = [ + let test_cases: [(String, Expect); 12] = [ ( "POST /get/ HTTP/1.1\r\n\r\n".to_string(), Expect { start_line: "POST /get/ HTTP/1.1".to_string(), body: None, is_valid: true, + has_token: false, }, ), ( @@ -256,6 +270,7 @@ fn test_request() { start_line: "POST /refresh/ HTTP/2".to_string(), body: None, is_valid: true, + has_token: false, }, ), ( @@ -264,6 +279,7 @@ fn test_request() { start_line: "POST /validate/ HTTP/1.0".to_string(), body: None, is_valid: true, + has_token: false, }, ), ( @@ -272,6 +288,7 @@ fn test_request() { start_line: "GET / HTTP/1.1".to_string(), body: None, is_valid: true, + has_token: false, }, ), // intentionally add HTTP with no version number @@ -281,6 +298,7 @@ fn test_request() { start_line: " UNKNOWN".to_string(), body: None, is_valid: false, + has_token: false, }, ), ( @@ -289,6 +307,7 @@ fn test_request() { start_line: " UNKNOWN".to_string(), body: None, is_valid: false, + has_token: false } ), ( @@ -297,6 +316,7 @@ fn test_request() { start_line: " UNKNOWN".to_string(), body: None, is_valid: false, + has_token: false } ), ( @@ -305,6 +325,7 @@ fn test_request() { start_line: "fjlqskjd /oks?id=65 HTTP/2".to_string(), body: None, is_valid: true, + has_token: false } ), ( @@ -313,6 +334,7 @@ fn test_request() { start_line: " UNKNOWN".to_string(), body: None, is_valid: false, + has_token: false, } ), ( @@ -321,6 +343,7 @@ fn test_request() { start_line: " UNKNOWN".to_string(), body: Some(r#"{"access_token":"AAAAAAAAAAAA.BBBBBBBBBB.CCCCCCCCCC","refresh_token": "DDDDDDDDDDD.EEEEEEEEEEE.FFFFF"}"#.to_string()), is_valid: false, + has_token: false } ), ( @@ -329,15 +352,25 @@ fn test_request() { start_line: "POST /refresh/ HTTP/1.1".to_string(), body: Some(r#"{"access_token":"toto","refresh_token":"tutu"}"#.to_string()), is_valid: true, + has_token: false + } + ), + ( + format!("{}\r\nuselessheaders\r\n{}", "POST /get/ HTTP/1.1", r#"{"token": "toto", "refresh_token": "tutu"}"#), + Expect { + start_line: "POST /get/ HTTP/1.1".to_string(), + body: Some(r#"{"token":"toto","refresh_token":"tutu"}"#.to_string()), + is_valid: true, + has_token: true } ), ]; for (request, expect) in test_cases { let http_request = HTTPRequest::from(request.as_str()); - println!("{:?}", http_request); assert_eq!(expect.is_valid, http_request.is_valid()); + let token = http_request.get_body_value("token"); let start_line: String = http_request.start_line.into(); assert_eq!(expect.start_line, start_line); @@ -347,6 +380,11 @@ fn test_request() { } None => continue, } + + match expect.has_token { + true => assert!(token.is_some()), + false => assert!(token.is_none()), + } } } diff --git a/src/http/response.rs b/src/http/response.rs index fcf7fc0..451e876 100644 --- a/src/http/response.rs +++ b/src/http/response.rs @@ -2,7 +2,7 @@ //! it will build an HTTPResponse corresponding to the HTTP message specs. see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages //! NOTE: only few parts of the specification has been implemented -use crate::http::request::HTTPVersion; +use super::{HTTPMessage, HTTPVersion}; use json; #[derive(Debug, PartialEq, Clone)] @@ -92,13 +92,19 @@ impl Into for HTTPResponse { } impl HTTPResponse { - pub fn as_500() -> Self { + pub fn as_500(message: Option) -> Self { let mut response = Self::default(); response .status_line .set_status_code(HTTPStatusCode::Http500); - response.body = json::parse(r#"{"error": "unexpected error occurred"}"#).unwrap(); + + response.body = { + match message { + Some(m) => m, + None => json::parse(r#"{"error": "unexpected error occurred"}"#).unwrap(), + } + }; response } @@ -119,9 +125,11 @@ impl HTTPResponse { status_line: HTTPStatusLine::default(), body: json::parse(r#"{"error": "invalid credentials"}"#).unwrap(), }; + response .status_line .set_status_code(HTTPStatusCode::Http403); + response } @@ -130,16 +138,35 @@ impl HTTPResponse { Self::default() } - // TODO: need to be adjust to accept `json::JsonValue` - pub fn as_200(token: String) -> Self { + pub fn as_200(message: Option) -> Self { let mut response = Self::default(); response .status_line .set_status_code(HTTPStatusCode::Http200); - response.body = json::parse(format!(r#"{{"token": "{}"}}"#, token).as_str()).unwrap(); + response.body = { + match message { + Some(m) => m, + None => json::parse(r#"{"status": "ok"}"#).unwrap(), + } + }; response } + + /// builds an HTTP 200 response with the generated JWT + pub fn send_token(token: &str) -> Self { + let mut http_message = HTTPMessage::default(); + http_message.put("token", token); + + let message = { + match http_message.try_into() { + Ok(m) => m, + Err(_e) => json::parse(r#"{"token": "error.generation.token"}"#).unwrap(), + } + }; + + HTTPResponse::as_200(Some(message)) + } } diff --git a/src/http/router.rs b/src/http/router.rs index c242fc7..d1f60ca 100644 --- a/src/http/router.rs +++ b/src/http/router.rs @@ -1,177 +1,90 @@ //! router aims to handle correctly the request corresponding to the target //! it implements all the logic to build an `HTTPResponse` -use super::{HTTPRequest, HTTPResponse}; -use crate::stores::FileStore; -use crate::stores::Store; -use configparser::ini::Ini; -use jwt_simple::prelude::*; -use lazy_static::lazy_static; -use std::collections::HashMap; -use std::future::Future; -use std::pin::Pin; -use std::str::FromStr; +use json; -type FuturePinned = Pin>>; -type Handler = fn(HTTPRequest, Config) -> FuturePinned; +use super::{HTTPMessage, HTTPRequest, HTTPResponse}; +use crate::config::Config; +use crate::jwt::JWTSigner; +use crate::stores::{FileStore, Store}; -#[derive(Clone)] -pub struct Config { - jwt_exp_time: u64, - jwt_issuer: String, - jwt_priv_key: String, - jwt_pub_key: String, - filestore_path: String, -} +// TODO: must be mapped with corresponding handler +const GET_ROUTE: &'static str = "/get/"; +const VALIDATE_ROUTE: &'static str = "/validate/"; -impl Default for Config { - fn default() -> Self { - Config { - jwt_exp_time: 0, - jwt_issuer: "".to_string(), - jwt_priv_key: "".to_string(), - jwt_pub_key: "".to_string(), - filestore_path: "".to_string(), - } - } -} - -impl TryFrom for Config { - type Error = String; - fn try_from(config: Ini) -> Result { - let exp_time = config - .get("jwt", "expiration_time") - .unwrap_or("".to_string()); - let jwt_exp_time = { - match u64::from_str(&exp_time) { - Ok(v) => v, - Err(e) => { - eprintln!("unable to convert JWT expiration time into u64 err={}", e); - 0 - } +async fn handle_get(request: HTTPRequest, config: Config) -> HTTPResponse { + let mut store = FileStore::new(config.filestore_path.clone()); + match &request.body { + Some(ref b) => { + let is_auth = store.is_auth(&b.get_data()).await; + if !is_auth { + return HTTPResponse::as_403(); } - }; - let config = Config { - jwt_exp_time, - jwt_issuer: config.get("jwt", "issuer").unwrap_or("".to_string()), - jwt_pub_key: config.get("jwt", "public_key").unwrap_or("".to_string()), - jwt_priv_key: config.get("jwt", "private_key").unwrap_or("".to_string()), - filestore_path: config.get("store", "path").unwrap_or("".to_string()), - }; - if !config.validate() { - return Err("ini file configuration validation failed".to_string()); - } - - Ok(config) - } -} - -impl Config { - /// validates config ini file - fn validate(&self) -> bool { - if self.jwt_exp_time <= 0 { - eprintln!("invalid config parameter: JWT expiration time is negative or equals to 0"); - return false; - } - - if self.jwt_issuer == "" { - eprintln!("invalid config parameter: JWT issuer is empty"); - return false; - } - - // TODO: check if the file exists and rights are ok - if self.jwt_pub_key == "" { - eprintln!("invalid config parameter: JWT public key file path is empty"); - return false; - } - - // TODO: check if the file exists and rights are ok - if self.jwt_priv_key == "" { - eprintln!("invalid config parameter: JWT private key file path is empty"); - return false; - } - - if self.filestore_path == "" { - eprintln!("invalid config parameter: filestore path is empty"); - return false; - } - - true - } -} - -fn handle_get(request: HTTPRequest, config: Config) -> FuturePinned { - Box::pin(async move { - let mut store = FileStore::new(config.filestore_path); - match &request.body { - Some(ref b) => { - let is_auth = store.is_auth(&b.get_data()).await; - if !is_auth { - return HTTPResponse::as_403(); - } - - let priv_key_content = { - match std::fs::read_to_string(config.jwt_priv_key) { - Ok(c) => c, - Err(e) => { - eprintln!("error while reading JWT priv key content err={}", e); - "".to_string() - } - } - }; - let jwt_key = { - match RS384KeyPair::from_pem(priv_key_content.as_str()) { - Ok(k) => k, - // TODO: set error in the message body - Err(e) => { - eprintln!("error occurred while getting private key err={}", e); - return HTTPResponse::as_500(); - } - } - }; - let mut claims = Claims::create(Duration::from_hours(config.jwt_exp_time)); - claims.issuer = Some(config.jwt_issuer); - - match jwt_key.sign(claims) { - Ok(token) => HTTPResponse::as_200(token), - // TODO: set the error in the message body + let jwt_signer = { + match JWTSigner::new(config).await { + Ok(s) => s, Err(e) => { - eprintln!("error occurred while signing the token err={}", e); - return HTTPResponse::as_500(); + let message = HTTPMessage::error(&e); + return HTTPResponse::as_500(message); } } + }; + + match jwt_signer.sign() { + Ok(t) => HTTPResponse::send_token(&t), + Err(e) => { + let message = HTTPMessage::error(&e); + return HTTPResponse::as_500(message); + } } - None => HTTPResponse::as_400(), } - }) + None => HTTPResponse::as_400(), + } } /// validates the token by checking: /// * expiration time -fn handle_validate(request: HTTPRequest, _config: Config) -> FuturePinned { - Box::pin(async move { - match &request.body { - Some(ref _b) => { - // TODO: impl the JWT validation - HTTPResponse::as_200("header.payload.signature".to_string()) - } - None => HTTPResponse::as_400(), - } - }) -} +/// * signature +async fn handle_validate(request: HTTPRequest, config: Config) -> HTTPResponse { + let token = { + match request.get_body_value("token") { + Some(t) => t, + None => { + let mut message = HTTPMessage::default(); + message.put("valid", "false"); + message.put("reason", "no token provided in the request body"); + let json = message.try_into().unwrap(); -lazy_static! { - /// defines the map between the URL and its associated callback - /// each authorized targets must implement a function returning `FuturePinned` - // TODO: a macro should be implemented to mask the implementation details - static ref HTTP_METHODS: HashMap<&'static str, Handler> = - HashMap::from( - [ - ("/get/", handle_get as Handler), - ("/validate/", handle_validate as Handler) - ] - ); + return HTTPResponse::as_200(Some(json)); + } + } + }; + + let jwt_signer = { + match JWTSigner::new(config).await { + Ok(s) => s, + Err(e) => { + let message = HTTPMessage::error(&e); + let json = message.try_into().unwrap(); + return HTTPResponse::as_500(Some(json)); + } + } + }; + + let mut message = HTTPMessage::default(); + match jwt_signer.validate(&token) { + Ok(()) => { + message.put("valid", "true"); + } + Err(e) => { + message.put("valid", "false"); + message.put("reason", &e); + } + } + + let json: json::JsonValue = message.try_into().unwrap(); + HTTPResponse::as_200(Some(json)) } pub struct Router; @@ -181,9 +94,10 @@ impl Router { let request = HTTPRequest::from(request_str); let target = request.start_line.get_target(); - match HTTP_METHODS.get(target.as_str()) { - Some(f) => f(request, config).await, - None => HTTPResponse::as_404(), + match target.as_str() { + GET_ROUTE => handle_get(request, config).await, + VALIDATE_ROUTE => handle_validate(request, config).await, + _ => HTTPResponse::as_404(), } } } @@ -205,47 +119,3 @@ async fn test_route() { response.status_line.get_status_code() ); } - -#[test] -fn test_config() { - use std::env; - - let root_path = env::var("CARGO_MANIFEST_DIR").unwrap(); - - // TODO: path::Path should be better - let config_path = format!("{}/{}/{}/{}", root_path, "tests", "data", "config.ini"); - let mut config = Ini::new(); - let _r = config.load(config_path); - - let router_config = Config::try_from(config); - assert!(router_config.is_ok()); -} - -#[test] -fn test_bad_config() { - use std::env; - - let root_path = env::var("CARGO_MANIFEST_DIR").unwrap(); - - // TODO: path::Path should be better - let config_path = format!("{}/{}/{}/{}", root_path, "tests", "data", "bad_config.ini"); - let mut config = Ini::new(); - let _r = config.load(config_path); - - let router_config = Config::try_from(config); - assert!(router_config.is_err()); -} - -#[test] -fn test_bad_config_path() { - use std::env; - - let root_path = env::var("CARGO_MANIFEST_DIR").unwrap(); - - // TODO: path::Path should be better - let config_path = format!("{}/{}/{}/{}", root_path, "tests", "data", "con.ini"); - let mut config = Ini::new(); - - let result = config.load(config_path); - assert!(result.is_err()); -} diff --git a/src/jwt/jwt.rs b/src/jwt/jwt.rs new file mode 100644 index 0000000..df9f932 --- /dev/null +++ b/src/jwt/jwt.rs @@ -0,0 +1,113 @@ +//! simple module to read `.pem` files and sign the token + +use crate::config::Config; +use jwt_simple::common::VerificationOptions; +use jwt_simple::prelude::*; +use std::collections::HashSet; +use tokio::fs; + +pub struct JWTSigner { + private_key: String, + public_key: String, + issuer: String, + exp_time: u64, +} + +impl JWTSigner { + // NOTE: could be included in a Trait: `TryFrom` but difficult to handle with async + pub async fn new(config: Config) -> Result { + let mut jwt_signer = JWTSigner { + private_key: "".to_string(), + public_key: "".to_string(), + issuer: config.jwt_issuer, + exp_time: config.jwt_exp_time, + }; + + match fs::read_to_string(config.jwt_priv_key).await { + Ok(c) => { + jwt_signer.private_key = c; + } + Err(e) => { + return Err(format!("unable to read the private key err={}", e)); + } + } + + match fs::read_to_string(config.jwt_pub_key).await { + Ok(c) => { + jwt_signer.public_key = c; + } + Err(e) => { + return Err(format!("unable to read the public key err={}", e)); + } + } + + Ok(jwt_signer) + } + + fn get_verification_options(&self) -> VerificationOptions { + let mut verification_options = VerificationOptions::default(); + + let mut issuers: HashSet = HashSet::new(); + issuers.insert(self.issuer.clone()); + verification_options.allowed_issuers = Some(issuers); + + verification_options + } + + /// builds and signs the token + pub fn sign(&self) -> Result { + let jwt_key = { + match RS384KeyPair::from_pem(&self.private_key) { + Ok(k) => k, + Err(e) => { + return Err(format!("unable to load the private key err={}", e)); + } + } + }; + let mut claims = Claims::create(Duration::from_hours(self.exp_time)); + claims.issuer = Some(self.issuer.clone()); + + match jwt_key.sign(claims) { + Ok(token) => Ok(token), + Err(e) => { + return Err(format!("unable to sign the token err={}", e)); + } + } + } + + pub fn validate(&self, token: &str) -> Result<(), String> { + let verification_options = self.get_verification_options(); + match RS384PublicKey::from_pem(&self.public_key) { + Ok(key) => { + if let Err(e) = + key.verify_token::(token, Some(verification_options)) + { + return Err(format!("token validation failed err={}", e)); + } + Ok(()) + } + Err(e) => Err(format!( + "token validation failed can't read the public key err={}", + e + )), + } + } +} + +#[tokio::test] +async fn test_signer() { + use configparser::ini::Ini; + use std::env; + + let root_path = env::var("CARGO_MANIFEST_DIR").unwrap(); + + let config_path = format!("{}/{}/{}/{}", root_path, "tests", "data", "config.ini"); + let mut config = Ini::new(); + let _r = config.load(config_path); + + let router_config = Config::try_from(config); + assert!(router_config.is_ok()); + + let jwt_signer = JWTSigner::new(router_config.unwrap()); + assert!(jwt_signer.await.is_ok()); +} diff --git a/src/jwt/mod.rs b/src/jwt/mod.rs new file mode 100644 index 0000000..051f27f --- /dev/null +++ b/src/jwt/mod.rs @@ -0,0 +1,3 @@ +mod jwt; + +pub use jwt::JWTSigner; diff --git a/src/main.rs b/src/main.rs index cb91c62..87eb6cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,19 @@ +mod config; mod http; +mod jwt; mod stores; +mod utils; use clap::Parser; use configparser::ini::Ini; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::{TcpListener, TcpStream}, + time::{timeout, Duration}, }; -use http::{Config, ROUTER}; +use config::Config; +use http::ROUTER; #[derive(Parser)] #[clap(author, version, about, long_about = None)] @@ -44,24 +49,43 @@ async fn main() { } }; - let router_config: Config = if let Ok(c) = Config::try_from(config) { - c - } else { - std::process::exit(1); + let router_config: Config = { + match Config::try_from(config) { + Ok(c) => c, + Err(_e) => { + std::process::exit(1); + } + } }; loop { let (stream, _) = listener.accept().await.unwrap(); - handle_connection(stream, router_config.clone()).await; + let conf = router_config.clone(); + tokio::spawn(handle_connection(stream, conf.clone())); } } /// parses the incoming request (partial spec implementation) and build an HTTP response async fn handle_connection(mut stream: TcpStream, config: Config) { + let mut message = vec![]; let mut buffer: [u8; 1024] = [0; 1024]; - let n = stream.read(&mut buffer).await.unwrap(); - let request_string = std::str::from_utf8(&buffer[0..n]).unwrap(); + let duration = Duration::from_micros(500); + + // loop until the message is read + // the stream can be fragmented so, using a timeout (500um should be enough) for the future for completion + // after the timeout, the message is "considered" as entirely read + loop { + match timeout(duration, stream.read(&mut buffer)).await { + Ok(v) => { + let n = v.unwrap(); + message.extend_from_slice(&buffer[0..n]); + } + Err(_e) => break, + } + } + + let request_string = std::str::from_utf8(&message).unwrap(); let response = ROUTER.route(request_string, config).await; let response_str: String = response.into(); diff --git a/src/stores/store.rs b/src/stores/store.rs index f1c0a42..6011da5 100644 --- a/src/stores/store.rs +++ b/src/stores/store.rs @@ -1,23 +1,13 @@ use async_trait::async_trait; use json; -use json::object::Object; + +use crate::utils::extract_json_value; #[async_trait] pub trait Store { async fn is_auth(&mut self, data: &json::JsonValue) -> bool; } -/// extracts `String` json value from a key -fn extract_json_value(data: &Object, key: &str) -> String { - if let Some(u) = data.get(key) { - match u.as_str() { - Some(s) => return s.to_string(), - None => return "".to_string(), - } - }; - "".to_string() -} - #[derive(Default, Debug)] pub struct Credentials { pub username: String, @@ -39,8 +29,8 @@ impl From<&json::JsonValue> for Credentials { let mut credentials = Credentials::default(); match data { json::JsonValue::Object(ref d) => { - credentials.username = extract_json_value(&d, "username"); - credentials.password = extract_json_value(&d, "password"); + credentials.username = extract_json_value(&d, "username").unwrap_or("".to_string()); + credentials.password = extract_json_value(&d, "password").unwrap_or("".to_string()); } _ => return credentials, } diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..de6e774 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,3 @@ +mod utils; + +pub use utils::extract_json_value; diff --git a/src/utils/utils.rs b/src/utils/utils.rs new file mode 100644 index 0000000..e330062 --- /dev/null +++ b/src/utils/utils.rs @@ -0,0 +1,30 @@ +use json::object::Object; + +/// extracts JSON value from a key +pub fn extract_json_value(data: &Object, key: &str) -> Option { + match data.get(key) { + Some(u) => match u.as_str() { + Some(s) => return Some(s.to_string()), + None => None, + }, + None => None, + } +} + +#[test] +fn test_extract_json_value() { + let test_cases: [(json::JsonValue, bool, bool); 3] = [ + (json::parse(r#"{"test": ""}"#).unwrap(), true, true), + (json::parse(r#"{}"#).unwrap(), true, false), + (json::parse(r#"[]"#).unwrap(), false, false), + ]; + + for (value, is_valid, has_key) in test_cases { + match value { + json::JsonValue::Object(d) => { + assert_eq!(has_key, extract_json_value(&d, "test").is_some()); + } + _ => assert!(!is_valid), + } + } +} diff --git a/tests/bash/curling.bash b/tests/bash/curling.bash index 5642cca..0b2c7b6 100755 --- a/tests/bash/curling.bash +++ b/tests/bash/curling.bash @@ -6,7 +6,11 @@ # ####################################### -URL="https://dev.thegux.fr" +if [ -z ${SIMPLE_AUTH_URL} ] +then + echo "[WARN]: SIMPLE_AUTH_URL is empty, set to http://localhost:9001" + URL="http://localhost:9001" +fi for i in {0..10} do diff --git a/tests/python/requirements.txt b/tests/python/requirements.txt index 0997272..7dc6ab4 100644 --- a/tests/python/requirements.txt +++ b/tests/python/requirements.txt @@ -1,8 +1,10 @@ attrs==22.1.0 black==22.8.0 certifi==2022.9.14 +cffi==1.15.1 charset-normalizer==2.1.1 click==8.1.3 +cryptography==38.0.1 idna==3.4 iniconfig==1.1.1 mypy-extensions==0.4.3 @@ -11,7 +13,10 @@ pathspec==0.10.1 platformdirs==2.5.2 pluggy==1.0.0 py==1.11.0 +pycparser==2.21 +PyJWT==2.5.0 pyparsing==3.0.9 requests==2.28.1 tomli==2.0.1 +types-cryptography==3.3.23 urllib3==1.26.12 diff --git a/tests/python/test_requests.py b/tests/python/test_requests.py index 19a88c2..ba45ce5 100644 --- a/tests/python/test_requests.py +++ b/tests/python/test_requests.py @@ -1,14 +1,19 @@ import jwt +import os import requests from datetime import datetime - from unittest import TestCase -URL = "https://dev.thegux.fr" +URL = os.getenv("SIMPLE_AUTH_URL", "http://127.0.0.1:9001") +PUB_KEY_PATH = os.getenv("SIMPLE_AUTH_PUB_KEY", "") class TestResponse(TestCase): + def setUp(self): + with open(PUB_KEY_PATH, "r") as f: + self.pub_key = f.read() + def test_get_target(self): resp = requests.post( URL + "/get/", json={"username": "toto", "password": "tata"} @@ -17,25 +22,52 @@ class TestResponse(TestCase): self.assertIsNotNone(resp.json(), "response data can't be empty") token = resp.json()["token"] - jwt_decoded = jwt.decode(token, options={"verify_signature": False}) + jwt_decoded = jwt.decode( + token, + self.pub_key, + algorithms=["RS384"], + options={ + "verify_signature": True, + "verify_claims": True, + "verify_iss": True, + }, + ) self.assertEqual("thegux.fr", jwt_decoded["iss"]) jwt_exp = datetime.fromtimestamp(jwt_decoded["exp"]) jwt_iat = datetime.fromtimestamp(jwt_decoded["iat"]) date_exp = datetime.strptime(str(jwt_exp - jwt_iat), "%H:%M:%S") self.assertEqual(2, date_exp.hour) + return token - def test_validate_target(self): + def test_validate_target_no_token(self): resp = requests.post( URL + "/validate/", json={"username": "toto", "password": "tata"} ) self.assertEqual(resp.status_code, 200, "bad status code returned") self.assertIsNotNone(resp.json(), "response data can't be empty") + self.assertEqual(resp.json()["valid"], "false", "bad status returned") + self.assertEqual(resp.json()["reason"], "no token provided in the request body") + + def test_validate_target_empty_token(self): + resp = requests.post(URL + "/validate/", json={"tutu": "tutu", "token": ""}) + self.assertEqual(resp.status_code, 200, "bad status code returned") + self.assertIsNotNone(resp.json(), "response data can't be empty") + self.assertEqual(resp.json()["valid"], "false", "bad status returned") self.assertEqual( - resp.json()["token"], "header.payload.signature", "bad status returned" + resp.json()["reason"], + "token validation failed err=JWT compact encoding error", ) - # TODO: must be updated after implmenting `/refresh/` url handler + def test_validate_target(self): + token = self.test_get_target() + + resp = requests.post(URL + "/validate/", json={"token": token}) + self.assertEqual(resp.status_code, 200, "bad status code returned") + self.assertIsNotNone(resp.json(), "response data can't be empty") + self.assertEqual(resp.json()["valid"], "true", "bad status returned") + + # TODO: must be updated after implementing `/refresh/` url handler def test_refresh_target(self): resp = requests.post( URL + "/refresh/", json={"username": "toto", "password": "tata"} @@ -62,7 +94,7 @@ class TestResponse(TestCase): resp = requests.post( URL + "/get/", json={"username": "tutu", "password": "titi"} ) - self.assertEqual(resp.status_code, 403, "bas status code returned") + self.assertEqual(resp.status_code, 403, "bad status code returned") self.assertIsNotNone(resp.json(), "response data must not be empty") self.assertEqual( resp.json()["error"],