Compare commits
	
		
			No commits in common. "master" and "v0.3.0" have entirely different histories.
		
	
	
		
	
		
							
								
								
									
										598
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										598
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -1,6 +1,6 @@ | ||||
| [package] | ||||
| name = "simple-auth" | ||||
| version = "0.3.2" | ||||
| version = "0.3.0" | ||||
| edition = "2021" | ||||
| 
 | ||||
| # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html | ||||
| @ -15,9 +15,6 @@ jwt-simple = "0.11.1" | ||||
| simple_logger = "4.0.0" | ||||
| log = "0.4.17" | ||||
| base64 = "0.13.1" | ||||
| serde_json = "1.0" | ||||
| 
 | ||||
| http = { git = "https://gitea.thegux.fr/rmanach/http", version = "0.1.6" } | ||||
| 
 | ||||
| # useful for tests (embedded files should be delete in release ?) | ||||
| #rust-embed="6.4.1" | ||||
| @ -33,7 +30,3 @@ features = ["derive"] | ||||
| [dependencies.async-std] | ||||
| version = "1.6" | ||||
| features = ["attributes"] | ||||
| 
 | ||||
| [dependencies.serde] | ||||
| version = "1.0" | ||||
| features = ["derive"] | ||||
|  | ||||
							
								
								
									
										13
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								README.md
									
									
									
									
									
								
							| @ -8,11 +8,12 @@ cargo build --release | ||||
| ``` | ||||
| 
 | ||||
| ## Configuration | ||||
| 
 | ||||
| ### Store | ||||
| The store represents the credentials. For now, this a `.txt` file with plain passwords. You have to create one like: | ||||
| ```txt | ||||
| # acts as a comment (only on a start line) | ||||
| <email>:<password> | ||||
| <username>:<password> | ||||
| ``` | ||||
| **WARN**: the file should have a chmod to **600**. | ||||
| 
 | ||||
| @ -45,7 +46,7 @@ expiration_time = 2 # in hours | ||||
| ./simple-auth <ini_path> | ||||
| 
 | ||||
| # get a JWT | ||||
| curl http://<ip>:<port>/get/ -d '{"email":"<email>", "password":"<password>"}' | ||||
| curl http://<ip>:<port>/get/ -d '{"username":"<user>", "password":"<password>"}' | ||||
| # should returned | ||||
| {"token":"<header>.<payload>.<signature>"} | ||||
| 
 | ||||
| @ -71,7 +72,7 @@ cargo test | ||||
| * set the following env variables: | ||||
| ```bash | ||||
| export SIMPLE_AUTH_URL="http://<url>:<port>" | ||||
| export SIMPLE_AUTH_PUB_KEY="<path_to_pem_pub_key>" # DO NOT USE THIS ONE IN PRODUCTION ! | ||||
| export SIMPLE_AUTH_PUB_KEY="<path_to_pem_pub_key>" # DO NOT USE THE ONE IN PRODUCTION ! | ||||
| ``` | ||||
| * run the server (if no one is running remotly) | ||||
| * run curl tests | ||||
| @ -79,14 +80,14 @@ export SIMPLE_AUTH_PUB_KEY="<path_to_pem_pub_key>" # DO NOT USE THIS ONE IN PROD | ||||
| cd tests/bash/ | ||||
| ./curling.bash && echo "passed" | ||||
| ``` | ||||
| * run python tests | ||||
| * run python requests tests | ||||
| ```bash | ||||
| # create a python venv | ||||
| cd tests/python | ||||
| python3 -m venv venv | ||||
| source venv/bin/activate | ||||
| 
 | ||||
| # install the requirements | ||||
| # intall the requirements | ||||
| pip install -r requirements | ||||
| 
 | ||||
| # launch the tests | ||||
| @ -96,5 +97,5 @@ python -m unittest | ||||
| ## Documentation | ||||
| ```bash | ||||
| # add the '--open' arg to open the doc on a browser | ||||
| cargo doc -r --no-deps | ||||
| cargo doc --no-deps | ||||
| ``` | ||||
|  | ||||
| @ -1,5 +1,4 @@ | ||||
| //! **config** module provides `Config` struct to load and validate `.ini` file.
 | ||||
| 
 | ||||
| //! provides `Config` struct to load and validate `.ini` file
 | ||||
| mod config; | ||||
| 
 | ||||
| pub use config::Config; | ||||
|  | ||||
							
								
								
									
										93
									
								
								src/http/message.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								src/http/message.rs
									
									
									
									
									
										Normal file
									
								
							| @ -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<String, String>, | ||||
| } | ||||
| 
 | ||||
| impl Default for HTTPMessage { | ||||
|     fn default() -> Self { | ||||
|         HTTPMessage { | ||||
|             message: HashMap::new(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// try to convert `HTTPMessage` in `json::JsonValue`
 | ||||
| impl TryInto<json::JsonValue> for HTTPMessage { | ||||
|     type Error = String; | ||||
|     fn try_into(self) -> Result<json::JsonValue, Self::Error> { | ||||
|         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<json::JsonValue> { | ||||
|         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<String> = 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<json::JsonValue, String> = 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()); | ||||
| } | ||||
							
								
								
									
										11
									
								
								src/http/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/http/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +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 message::HTTPMessage; | ||||
| pub use request::{HTTPRequest, HTTPVersion}; | ||||
| pub use response::{HTTPResponse, HTTPStatusCode}; | ||||
| pub use router::ROUTER; | ||||
							
								
								
									
										416
									
								
								src/http/request.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										416
									
								
								src/http/request.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,416 @@ | ||||
| //! request handles properly the incoming request
 | ||||
| //! it will parse the request according to the HTTP message specifications
 | ||||
| //! see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages
 | ||||
| //! NOTE: only few parts of the specification has been implemented
 | ||||
| 
 | ||||
| use json; | ||||
| use lazy_static::lazy_static; | ||||
| use regex::Regex; | ||||
| use std::collections::VecDeque; | ||||
| 
 | ||||
| use crate::utils::extract_json_value; | ||||
| 
 | ||||
| type RequestParts = (String, VecDeque<String>, String); | ||||
| 
 | ||||
| const HTTP_REQUEST_SEPARATOR: &'static str = "\r\n"; | ||||
| const NULL_CHAR: &'static str = "\0"; | ||||
| 
 | ||||
| lazy_static! { | ||||
|     static ref HTTP_VERSION_REGEX: Regex = Regex::new("^HTTP/(1.1|1.0|2)$").unwrap(); | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug, Copy, Clone)] | ||||
| pub enum HTTPVersion { | ||||
|     Http1_0, | ||||
|     Http1_1, | ||||
|     Http2, | ||||
|     Unknown, | ||||
| } | ||||
| 
 | ||||
| impl Into<String> for HTTPVersion { | ||||
|     fn into(self) -> String { | ||||
|         match self { | ||||
|             Self::Http1_0 => "HTTP/1.0".to_string(), | ||||
|             Self::Http1_1 => "HTTP/1.1".to_string(), | ||||
|             Self::Http2 => "HTTP/2".to_string(), | ||||
|             Self::Unknown => "UNKNOWN".to_string(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| // TODO: not really satifying... could accept `String` too
 | ||||
| impl From<&String> for HTTPVersion { | ||||
|     fn from(http_version: &String) -> Self { | ||||
|         match http_version.as_str() { | ||||
|             "HTTP/1.0" => Self::Http1_0, | ||||
|             "HTTP/1.1" => Self::Http1_1, | ||||
|             "HTTP/2" => Self::Http2, | ||||
|             _ => Self::Unknown, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Debug)] | ||||
| pub struct HTTPStartLine { | ||||
|     method: String, | ||||
|     target: String, | ||||
|     version: HTTPVersion, | ||||
| } | ||||
| 
 | ||||
| impl HTTPStartLine { | ||||
|     fn new(method: String, target: String, version: HTTPVersion) -> Self { | ||||
|         HTTPStartLine { | ||||
|             method, | ||||
|             target, | ||||
|             version, | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fn parse(start_line: &str) -> Result<Self, &str> { | ||||
|         // declare a new `start_line` var to borrow to &str `start_line`
 | ||||
|         let start_line = start_line.to_string(); | ||||
| 
 | ||||
|         let parts: Vec<&str> = start_line.split(" ").collect(); | ||||
|         if parts.len() < 3 { | ||||
|             return Err("unable to parse the start correctly"); | ||||
|         } | ||||
| 
 | ||||
|         let method = parts[0].to_string(); | ||||
|         let target = parts[1].to_string(); | ||||
|         let version = parts[2].to_string(); | ||||
| 
 | ||||
|         if !Self::check_version(&version) { | ||||
|             return Err("http version validation failed, unknown version"); | ||||
|         } | ||||
| 
 | ||||
|         Ok(HTTPStartLine::new( | ||||
|             method, | ||||
|             target, | ||||
|             HTTPVersion::from(&version), | ||||
|         )) | ||||
|     } | ||||
| 
 | ||||
|     fn check_version(version: &String) -> bool { | ||||
|         HTTP_VERSION_REGEX.is_match(version) | ||||
|     } | ||||
| 
 | ||||
|     pub fn is_valid(&self) -> bool { | ||||
|         return self.method != "" && self.target != ""; | ||||
|     } | ||||
| 
 | ||||
|     pub fn get_target(&self) -> String { | ||||
|         self.target.clone() | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl Default for HTTPStartLine { | ||||
|     fn default() -> Self { | ||||
|         HTTPStartLine { | ||||
|             method: "".to_string(), | ||||
|             target: "".to_string(), | ||||
|             version: HTTPVersion::Unknown, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl Into<String> for HTTPStartLine { | ||||
|     fn into(self) -> String { | ||||
|         let version: String = self.version.into(); | ||||
|         return format!("{} {} {}", self.method, self.target, version); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// represents an HTTP request body
 | ||||
| /// for simplicity, only json body is accepted
 | ||||
| #[derive(Debug)] | ||||
| pub struct HTTPBody { | ||||
|     data: json::JsonValue, | ||||
| } | ||||
| 
 | ||||
| impl HTTPBody { | ||||
|     fn new(data: json::JsonValue) -> HTTPBody { | ||||
|         HTTPBody { data } | ||||
|     } | ||||
| 
 | ||||
|     pub fn get_data(&self) -> &json::JsonValue { | ||||
|         &self.data | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl TryFrom<String> for HTTPBody { | ||||
|     type Error = String; | ||||
|     fn try_from(body: String) -> Result<HTTPBody, Self::Error> { | ||||
|         let body = body.replace(NULL_CHAR, ""); | ||||
|         match json::parse(&body) { | ||||
|             Ok(v) => Ok(HTTPBody::new(v)), | ||||
|             Err(e) => Err(format!("during request body parsing details={}", e)), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// Represents an HTTP request (headers are not parsed)
 | ||||
| #[derive(Debug)] | ||||
| pub struct HTTPRequest { | ||||
|     pub start_line: HTTPStartLine, | ||||
|     pub body: Option<HTTPBody>, | ||||
|     // includes the client IP + port (should be in the headers)
 | ||||
|     pub addr: String, | ||||
| } | ||||
| 
 | ||||
| impl HTTPRequest { | ||||
|     /// split correctly the incoming request in order to get :
 | ||||
|     /// * start_line
 | ||||
|     /// * headers
 | ||||
|     /// * data (if exists)
 | ||||
|     fn get_request_parts(request: &str) -> Result<RequestParts, String> { | ||||
|         // separate the body part from the start_line and the headers
 | ||||
|         let mut request_parts: VecDeque<String> = request | ||||
|             .split(HTTP_REQUEST_SEPARATOR) | ||||
|             .map(|r| r.to_string()) | ||||
|             .collect(); | ||||
| 
 | ||||
|         if request_parts.len() < 3 { | ||||
|             return Err("request has no enough informations to be correctly parsed".to_string()); | ||||
|         } | ||||
|         let start_line = request_parts.pop_front().unwrap(); | ||||
|         let body = request_parts.pop_back().unwrap(); | ||||
| 
 | ||||
|         Ok((start_line, request_parts, body)) | ||||
|     } | ||||
| 
 | ||||
|     /// parse the request by spliting the incoming request with the separator `\r\n`
 | ||||
|     fn parse(request: &str) -> Result<HTTPRequest, String> { | ||||
|         let request = request.to_string(); | ||||
| 
 | ||||
|         match HTTPRequest::get_request_parts(&request) { | ||||
|             Ok(rp) => { | ||||
|                 let mut request = HTTPRequest::default(); | ||||
| 
 | ||||
|                 let start_line = HTTPStartLine::parse(&rp.0); | ||||
|                 match start_line { | ||||
|                     Ok(v) => request.start_line = v, | ||||
|                     Err(e) => log::error!("while parsing start_line details={}", e), | ||||
|                 } | ||||
| 
 | ||||
|                 let body = HTTPBody::try_from(rp.2); | ||||
|                 match body { | ||||
|                     Ok(v) => request.body = Some(v), | ||||
|                     Err(e) => log::warn!("{}", e), | ||||
|                 } | ||||
| 
 | ||||
|                 return Ok(request); | ||||
|             } | ||||
|             Err(e) => { | ||||
|                 return Err(e); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /// retrieve value in `HTTPBody` (returns None if empty or does not exist)
 | ||||
|     pub fn get_body_value(&self, key: &str) -> Option<String> { | ||||
|         match self.body { | ||||
|             Some(ref b) => match &b.data { | ||||
|                 json::JsonValue::Object(d) => extract_json_value(&d, key), | ||||
|                 _ => None, | ||||
|             }, | ||||
|             None => None, | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pub fn get_method(&self) -> String { | ||||
|         self.start_line.method.clone() | ||||
|     } | ||||
| 
 | ||||
|     #[allow(dead_code)] | ||||
|     pub fn is_valid(&self) -> bool { | ||||
|         return self.start_line.is_valid(); | ||||
|     } | ||||
| 
 | ||||
|     pub fn set_addr(&mut self, addr: String) { | ||||
|         self.addr = addr; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl Default for HTTPRequest { | ||||
|     fn default() -> Self { | ||||
|         HTTPRequest { | ||||
|             start_line: HTTPStartLine::default(), | ||||
|             body: None, | ||||
|             addr: "".to_string(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl From<&str> for HTTPRequest { | ||||
|     fn from(request: &str) -> Self { | ||||
|         match Self::parse(request) { | ||||
|             Ok(v) => v, | ||||
|             Err(e) => { | ||||
|                 log::error!("{}", e); | ||||
|                 return HTTPRequest::default(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn test_request() { | ||||
|     struct Expect { | ||||
|         start_line: String, | ||||
|         body: Option<String>, | ||||
|         is_valid: bool, | ||||
|         has_token: bool, | ||||
|     } | ||||
| 
 | ||||
|     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, | ||||
|             }, | ||||
|         ), | ||||
|         ( | ||||
|             "POST /refresh/ HTTP/2\r\n\r\n".to_string(), | ||||
|             Expect { | ||||
|                 start_line: "POST /refresh/ HTTP/2".to_string(), | ||||
|                 body: None, | ||||
|                 is_valid: true, | ||||
|                 has_token: false, | ||||
|             }, | ||||
|         ), | ||||
|         ( | ||||
|             "POST /validate/ HTTP/1.0\r\n\r\n".to_string(), | ||||
|             Expect { | ||||
|                 start_line: "POST /validate/ HTTP/1.0".to_string(), | ||||
|                 body: None, | ||||
|                 is_valid: true, | ||||
|                 has_token: false, | ||||
|             }, | ||||
|         ), | ||||
|         ( | ||||
|             "GET / HTTP/1.1\r\n\r\n".to_string(), | ||||
|             Expect { | ||||
|                 start_line: "GET / HTTP/1.1".to_string(), | ||||
|                 body: None, | ||||
|                 is_valid: true, | ||||
|                 has_token: false, | ||||
|             }, | ||||
|         ), | ||||
|         // intentionally add HTTP with no version number
 | ||||
|         ( | ||||
|             "OPTIONS /admin/2 HTTP/\r\nContent-Type: application/json\r\n".to_string(), | ||||
|             Expect { | ||||
|                 start_line: "  UNKNOWN".to_string(), | ||||
|                 body: None, | ||||
|                 is_valid: false, | ||||
|                 has_token: false, | ||||
|             }, | ||||
|         ), | ||||
|         ( | ||||
|             "POST HTTP".to_string(), | ||||
|             Expect { | ||||
|                 start_line: "  UNKNOWN".to_string(), | ||||
|                 body: None, | ||||
|                 is_valid: false, | ||||
|                 has_token: false | ||||
|             } | ||||
|         ), | ||||
|         ( | ||||
|             "".to_string(), | ||||
|             Expect { | ||||
|                 start_line: "  UNKNOWN".to_string(), | ||||
|                 body: None, | ||||
|                 is_valid: false, | ||||
|                 has_token: false | ||||
|             } | ||||
|         ), | ||||
|         ( | ||||
|             "fjlqskjd /oks?id=65 HTTP/2\r\n\r\n".to_string(), | ||||
|             Expect { | ||||
|                 start_line: "fjlqskjd /oks?id=65 HTTP/2".to_string(), | ||||
|                 body: None, | ||||
|                 is_valid: true, | ||||
|                 has_token: false | ||||
|             } | ||||
|         ), | ||||
|         ( | ||||
|             "   ".to_string(), | ||||
|             Expect { | ||||
|                 start_line: "  UNKNOWN".to_string(), | ||||
|                 body: None, | ||||
|                 is_valid: false, | ||||
|                 has_token: false, | ||||
|             } | ||||
|         ), | ||||
|         ( | ||||
|             r#"lm //// skkss\r\ndkldklkdl\r\n"{"access_token":"AAAAAAAAAAAA.BBBBBBBBBB.CCCCCCCCCC","refresh_token": "DDDDDDDDDDD.EEEEEEEEEEE.FFFFF"}""#.to_string(), | ||||
|             Expect { | ||||
|                 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 | ||||
|             } | ||||
|         ), | ||||
|         ( | ||||
|             format!("{}\r\nuselessheaders\r\n{}", "POST /refresh/ HTTP/1.1", r#"{"access_token": "toto", "refresh_token": "tutu"}"#), | ||||
|             Expect { | ||||
|                 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()); | ||||
|         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); | ||||
| 
 | ||||
|         match http_request.body { | ||||
|             Some(v) => { | ||||
|                 assert_eq!(expect.body.unwrap(), v.data.dump()) | ||||
|             } | ||||
|             None => continue, | ||||
|         } | ||||
| 
 | ||||
|         match expect.has_token { | ||||
|             true => assert!(token.is_some()), | ||||
|             false => assert!(token.is_none()), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn test_http_body() { | ||||
|     let test_cases: [(&str, bool); 3] = [ | ||||
|         ("hello, how are you ?", false), | ||||
|         ("qsdfqsdffqsdffsq", false), | ||||
|         ( | ||||
|             r#"{"access_token":"AAAAAAAAAAAA.BBBBBBBBBB.CCCCCCCCCC","refresh_token": "DDDDDDDDDDD.EEEEEEEEEEE.FFFFF"}"#, | ||||
|             true, | ||||
|         ), | ||||
|     ]; | ||||
| 
 | ||||
|     for (body, is_valid) in test_cases { | ||||
|         match HTTPBody::try_from(body.to_string()) { | ||||
|             Ok(_) => assert!(is_valid), | ||||
|             Err(_) => assert!(!is_valid), | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										172
									
								
								src/http/response.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								src/http/response.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,172 @@ | ||||
| //! response handles the incoming request parsed `HTTPRequest`
 | ||||
| //! 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 super::{HTTPMessage, HTTPVersion}; | ||||
| use json; | ||||
| 
 | ||||
| #[derive(Debug, PartialEq, Clone)] | ||||
| pub enum HTTPStatusCode { | ||||
|     Http200, | ||||
|     Http400, | ||||
|     Http403, | ||||
|     Http404, | ||||
|     Http500, | ||||
| } | ||||
| 
 | ||||
| impl Into<String> for HTTPStatusCode { | ||||
|     fn into(self) -> String { | ||||
|         match self { | ||||
|             Self::Http200 => "200".to_string(), | ||||
|             Self::Http400 => "400".to_string(), | ||||
|             Self::Http404 => "404".to_string(), | ||||
|             Self::Http403 => "403".to_string(), | ||||
|             Self::Http500 => "500".to_string(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| pub struct HTTPStatusLine { | ||||
|     version: HTTPVersion, | ||||
|     status_code: HTTPStatusCode, | ||||
| } | ||||
| 
 | ||||
| impl Default for HTTPStatusLine { | ||||
|     fn default() -> HTTPStatusLine { | ||||
|         HTTPStatusLine { | ||||
|             version: HTTPVersion::Http1_1, | ||||
|             status_code: HTTPStatusCode::Http400, | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl Into<String> for HTTPStatusLine { | ||||
|     fn into(self) -> String { | ||||
|         let version: String = self.version.into(); | ||||
|         let status_code: String = self.status_code.into(); | ||||
|         format! {"{} {}", version, status_code} | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl HTTPStatusLine { | ||||
|     pub fn set_status_code(&mut self, code: HTTPStatusCode) { | ||||
|         self.status_code = code; | ||||
|     } | ||||
| 
 | ||||
|     #[allow(dead_code)] | ||||
|     pub fn get_status_code(&self) -> HTTPStatusCode { | ||||
|         self.status_code.clone() | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// represents an HTTP response (headers are not parsed)
 | ||||
| /// NOTE: for simplicity, only JSON body are accepted
 | ||||
| pub struct HTTPResponse { | ||||
|     pub status_line: HTTPStatusLine, | ||||
|     body: json::JsonValue, | ||||
| } | ||||
| 
 | ||||
| impl Default for HTTPResponse { | ||||
|     fn default() -> Self { | ||||
|         HTTPResponse { | ||||
|             status_line: HTTPStatusLine::default(), | ||||
|             body: json::parse(r#"{"error": "the incoming request is not valid"}"#).unwrap(), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl Into<String> for HTTPResponse { | ||||
|     fn into(self) -> String { | ||||
|         // move `self.body` into a new var
 | ||||
|         let b = self.body; | ||||
|         let body: String = json::stringify(b); | ||||
| 
 | ||||
|         let status_line: String = self.status_line.into(); | ||||
|         format!( | ||||
|             "{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}", | ||||
|             status_line, | ||||
|             body.len(), | ||||
|             body | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| impl HTTPResponse { | ||||
|     pub fn as_500(message: Option<json::JsonValue>) -> Self { | ||||
|         let mut response = Self::default(); | ||||
| 
 | ||||
|         response | ||||
|             .status_line | ||||
|             .set_status_code(HTTPStatusCode::Http500); | ||||
| 
 | ||||
|         response.body = { | ||||
|             match message { | ||||
|                 Some(m) => m, | ||||
|                 None => json::parse(r#"{"error": "unexpected error occurred"}"#).unwrap(), | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         response | ||||
|     } | ||||
| 
 | ||||
|     pub fn as_404() -> Self { | ||||
|         let mut response = Self::default(); | ||||
| 
 | ||||
|         response | ||||
|             .status_line | ||||
|             .set_status_code(HTTPStatusCode::Http404); | ||||
|         response.body = json::parse(r#"{"error": "the url requested does not exist"}"#).unwrap(); | ||||
| 
 | ||||
|         response | ||||
|     } | ||||
| 
 | ||||
|     pub fn as_403() -> Self { | ||||
|         let mut response = HTTPResponse { | ||||
|             status_line: HTTPStatusLine::default(), | ||||
|             body: json::parse(r#"{"error": "invalid credentials"}"#).unwrap(), | ||||
|         }; | ||||
| 
 | ||||
|         response | ||||
|             .status_line | ||||
|             .set_status_code(HTTPStatusCode::Http403); | ||||
| 
 | ||||
|         response | ||||
|     } | ||||
| 
 | ||||
|     /// wrap the `Self::default()` associated func (not really clear)
 | ||||
|     pub fn as_400() -> Self { | ||||
|         Self::default() | ||||
|     } | ||||
| 
 | ||||
|     pub fn as_200(message: Option<json::JsonValue>) -> Self { | ||||
|         let mut response = Self::default(); | ||||
| 
 | ||||
|         response | ||||
|             .status_line | ||||
|             .set_status_code(HTTPStatusCode::Http200); | ||||
| 
 | ||||
|         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)) | ||||
|     } | ||||
| } | ||||
| @ -1,32 +1,29 @@ | ||||
| use http::{HTTPRequest, HTTPResponse, JSONMessage}; | ||||
| use json::JsonValue; | ||||
| //! router aims to handle correctly the request corresponding to the target
 | ||||
| //! it implements all the logic to build an `HTTPResponse`
 | ||||
| 
 | ||||
| use base64; | ||||
| use json; | ||||
| 
 | ||||
| use super::{HTTPMessage, HTTPRequest, HTTPResponse}; | ||||
| use crate::config::Config; | ||||
| use crate::jwt::JWTSigner; | ||||
| use crate::message::{JWTMessage, ValidationMessage}; | ||||
| use crate::stores::{Credentials, FileStore, Store}; | ||||
| use crate::stores::{FileStore, Store}; | ||||
| 
 | ||||
| // TODO: must be mapped with corresponding handler
 | ||||
| const GET_ROUTE: &'static str = "/get/"; | ||||
| const VALIDATE_ROUTE: &'static str = "/validate/"; | ||||
| const PUBKEY_ROUTE: &'static str = "/pubkey/"; | ||||
| 
 | ||||
| async fn handle_get(request: HTTPRequest<'_>, config: Config, method: &str) -> HTTPResponse { | ||||
| async fn handle_get(request: HTTPRequest, config: Config, method: &str) -> HTTPResponse { | ||||
|     if method.trim().to_lowercase() != "post" { | ||||
|         return HTTPResponse::as_400(); | ||||
|     } | ||||
| 
 | ||||
|     let mut store = FileStore::new(config.filestore_path.clone()); | ||||
| 
 | ||||
|     match request.get_body() { | ||||
|         Some(d) => { | ||||
|             let credentials = Credentials::from(d); | ||||
|             if credentials.is_empty() { | ||||
|                 log::error!("unable to parse the credentials correctly from the incoming request"); | ||||
|                 return HTTPResponse::as_400(); | ||||
|             } | ||||
| 
 | ||||
|             if !store.is_auth(&credentials).await { | ||||
|     match &request.body { | ||||
|         Some(ref b) => { | ||||
|             let is_auth = store.is_auth(&b.get_data()).await; | ||||
|             if !is_auth { | ||||
|                 return HTTPResponse::as_403(); | ||||
|             } | ||||
| 
 | ||||
| @ -34,16 +31,16 @@ async fn handle_get(request: HTTPRequest<'_>, config: Config, method: &str) -> H | ||||
|                 match JWTSigner::new(config).await { | ||||
|                     Ok(s) => s, | ||||
|                     Err(e) => { | ||||
|                         let message = JSONMessage::error(&e); | ||||
|                         let message = HTTPMessage::error(&e); | ||||
|                         return HTTPResponse::as_500(message); | ||||
|                     } | ||||
|                 } | ||||
|             }; | ||||
| 
 | ||||
|             match jwt_signer.sign(credentials) { | ||||
|                 Ok(t) => send_token(&t), | ||||
|             match jwt_signer.sign() { | ||||
|                 Ok(t) => HTTPResponse::send_token(&t), | ||||
|                 Err(e) => { | ||||
|                     let message = JSONMessage::error(&e); | ||||
|                     let message = HTTPMessage::error(&e); | ||||
|                     return HTTPResponse::as_500(message); | ||||
|                 } | ||||
|             } | ||||
| @ -52,10 +49,10 @@ async fn handle_get(request: HTTPRequest<'_>, config: Config, method: &str) -> H | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// handle_validate validates the token by checking:
 | ||||
| /// validates the token by checking:
 | ||||
| /// * expiration time
 | ||||
| /// * signature
 | ||||
| async fn handle_validate(request: HTTPRequest<'_>, config: Config, method: &str) -> HTTPResponse { | ||||
| async fn handle_validate(request: HTTPRequest, config: Config, method: &str) -> HTTPResponse { | ||||
|     if request.get_method().trim().to_lowercase() != method { | ||||
|         return HTTPResponse::as_400(); | ||||
|     } | ||||
| @ -64,10 +61,11 @@ async fn handle_validate(request: HTTPRequest<'_>, config: Config, method: &str) | ||||
|         match request.get_body_value("token") { | ||||
|             Some(t) => t, | ||||
|             None => { | ||||
|                 let mut message = ValidationMessage::default(); | ||||
|                 message.set_reason("no token provided in the request body"); | ||||
|                 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(); | ||||
| 
 | ||||
|                 let json = json::parse(&serde_json::to_string(&message).unwrap()).unwrap(); | ||||
|                 return HTTPResponse::as_200(Some(json)); | ||||
|             } | ||||
|         } | ||||
| @ -77,29 +75,30 @@ async fn handle_validate(request: HTTPRequest<'_>, config: Config, method: &str) | ||||
|         match JWTSigner::new(config).await { | ||||
|             Ok(s) => s, | ||||
|             Err(e) => { | ||||
|                 let message = JSONMessage::error(&e); | ||||
|                 let message = HTTPMessage::error(&e); | ||||
|                 let json = message.try_into().unwrap(); | ||||
|                 return HTTPResponse::as_500(Some(json)); | ||||
|             } | ||||
|         } | ||||
|     }; | ||||
| 
 | ||||
|     let mut message = ValidationMessage::default(); | ||||
|     let mut message = HTTPMessage::default(); | ||||
|     match jwt_signer.validate(&token) { | ||||
|         Ok(()) => { | ||||
|             message.set_valid(true); | ||||
|             message.put("valid", "true"); | ||||
|         } | ||||
|         Err(e) => { | ||||
|             message.set_reason(&e); | ||||
|             message.put("valid", "false"); | ||||
|             message.put("reason", &e); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     let json: JsonValue = json::parse(&serde_json::to_string(&message).unwrap()).unwrap(); | ||||
|     let json: json::JsonValue = message.try_into().unwrap(); | ||||
|     HTTPResponse::as_200(Some(json)) | ||||
| } | ||||
| 
 | ||||
| /// handle_public_key returns the JWT public key in base64 encoded
 | ||||
| async fn handle_public_key(request: HTTPRequest<'_>, config: Config, method: &str) -> HTTPResponse { | ||||
| /// returns the JWT public key in base64 encoded
 | ||||
| async fn handle_public_key(request: HTTPRequest, config: Config, method: &str) -> HTTPResponse { | ||||
|     if request.get_method().trim().to_lowercase() != method { | ||||
|         return HTTPResponse::as_400(); | ||||
|     } | ||||
| @ -108,7 +107,7 @@ async fn handle_public_key(request: HTTPRequest<'_>, config: Config, method: &st | ||||
|         match JWTSigner::new(config).await { | ||||
|             Ok(s) => s, | ||||
|             Err(e) => { | ||||
|                 let message = JSONMessage::error(&e); | ||||
|                 let message = HTTPMessage::error(&e); | ||||
|                 let json = message.try_into().unwrap(); | ||||
|                 return HTTPResponse::as_500(Some(json)); | ||||
|             } | ||||
| @ -116,18 +115,25 @@ async fn handle_public_key(request: HTTPRequest<'_>, config: Config, method: &st | ||||
|     }; | ||||
| 
 | ||||
|     let public_key = jwt_signer.get_public_key(); | ||||
|     let message = serde_json::to_string(&JWTMessage::with_pubkey(public_key)).unwrap(); | ||||
| 
 | ||||
|     HTTPResponse::as_200(Some(json::parse(&message).unwrap())) | ||||
|     let mut message = HTTPMessage::default(); | ||||
|     message.put("pubkey", &base64::encode(public_key)); | ||||
| 
 | ||||
|     let json = message.try_into().unwrap(); | ||||
|     HTTPResponse::as_200(Some(json)) | ||||
| } | ||||
| 
 | ||||
| pub struct Router; | ||||
| 
 | ||||
| impl Router { | ||||
|     /// route routes the request to the corresponding handler
 | ||||
|     pub async fn route(&self, request_str: &str, config: Config) -> HTTPResponse { | ||||
|         let request = HTTPRequest::from(request_str); | ||||
|         match request.get_target() { | ||||
|     /// routes the request to the corresponding handling method
 | ||||
|     pub async fn route(&self, request_str: &str, addr: String, config: Config) -> HTTPResponse { | ||||
|         let mut request = HTTPRequest::from(request_str); | ||||
|         request.set_addr(addr); | ||||
| 
 | ||||
|         let target = request.start_line.get_target(); | ||||
| 
 | ||||
|         match target.as_str() { | ||||
|             GET_ROUTE => handle_get(request, config, "post").await, | ||||
|             VALIDATE_ROUTE => handle_validate(request, config, "post").await, | ||||
|             PUBKEY_ROUTE => handle_public_key(request, config, "get").await, | ||||
| @ -136,27 +142,20 @@ impl Router { | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// send_token generates an HTTPResponse with the new token
 | ||||
| pub fn send_token(jwt_message: &str) -> HTTPResponse { | ||||
|     let message = if jwt_message != "" { | ||||
|         jwt_message | ||||
|     } else { | ||||
|         r#"{"token": "error.generation.token"}"# | ||||
|     }; | ||||
|     HTTPResponse::as_200(Some(json::parse(message).unwrap())) | ||||
| } | ||||
| 
 | ||||
| // this **MUST** be used like a Singleton
 | ||||
| // this MUST be used like a Singleton
 | ||||
| pub const ROUTER: Router = Router {}; | ||||
| 
 | ||||
| #[tokio::test] | ||||
| async fn test_route() { | ||||
|     use http::HTTPStatusCode; | ||||
|     use super::HTTPStatusCode; | ||||
| 
 | ||||
|     let router: &Router = &ROUTER; | ||||
|     let config: Config = Config::default(); | ||||
|     let request_str = "POST /get/ HTTP/1.1\r\n\r\n"; | ||||
| 
 | ||||
|     let response: HTTPResponse = router.route(request_str, config).await; | ||||
|     assert_eq!(HTTPStatusCode::Http400, response.get_status_code()); | ||||
|     let response: HTTPResponse = router.route(request_str, "".to_string(), config).await; | ||||
|     assert_eq!( | ||||
|         HTTPStatusCode::Http400, | ||||
|         response.status_line.get_status_code() | ||||
|     ); | ||||
| } | ||||
| @ -1,18 +1,9 @@ | ||||
| use crate::config::Config; | ||||
| use jwt_simple::common::VerificationOptions; | ||||
| use jwt_simple::prelude::*; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use std::collections::HashSet; | ||||
| use tokio::fs; | ||||
| 
 | ||||
| use crate::message::JWTMessage; | ||||
| use crate::stores::Credentials; | ||||
| 
 | ||||
| #[derive(Serialize, Deserialize)] | ||||
| struct JWTCustomClaims { | ||||
|     email: String, | ||||
| } | ||||
| 
 | ||||
| pub struct JWTSigner { | ||||
|     private_key: String, | ||||
|     public_key: String, | ||||
| @ -61,8 +52,8 @@ impl JWTSigner { | ||||
|         verification_options | ||||
|     } | ||||
| 
 | ||||
|     /// sign builds and signs the token
 | ||||
|     pub fn sign(&self, credentials: Credentials) -> Result<String, String> { | ||||
|     /// builds and signs the token
 | ||||
|     pub fn sign(&self) -> Result<String, String> { | ||||
|         let jwt_key = { | ||||
|             match RS384KeyPair::from_pem(&self.private_key) { | ||||
|                 Ok(k) => k, | ||||
| @ -71,19 +62,11 @@ impl JWTSigner { | ||||
|                 } | ||||
|             } | ||||
|         }; | ||||
|         let mut claims = Claims::with_custom_claims( | ||||
|             JWTCustomClaims { | ||||
|                 email: credentials.get_email(), | ||||
|             }, | ||||
|             Duration::from_hours(self.exp_time), | ||||
|         ); | ||||
|         let mut claims = Claims::create(Duration::from_hours(self.exp_time)); | ||||
|         claims.issuer = Some(self.issuer.clone()); | ||||
| 
 | ||||
|         match jwt_key.sign(claims) { | ||||
|             Ok(token) => { | ||||
|                 // TODO: need to generate the refresh token
 | ||||
|                 return Ok(serde_json::to_string(&JWTMessage::with_access(token)).unwrap()); | ||||
|             } | ||||
|             Ok(token) => Ok(token), | ||||
|             Err(e) => { | ||||
|                 return Err(format!("unable to sign the token details={}", e)); | ||||
|             } | ||||
|  | ||||
| @ -1,5 +1,4 @@ | ||||
| //! **jwt** module aims to read `.pem` files and sign/validate the token.
 | ||||
| 
 | ||||
| //! simple module to read `.pem` files and sign the token
 | ||||
| mod jwt; | ||||
| 
 | ||||
| pub use jwt::JWTSigner; | ||||
|  | ||||
							
								
								
									
										10
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								src/main.rs
									
									
									
									
									
								
							| @ -1,8 +1,8 @@ | ||||
| mod config; | ||||
| mod http; | ||||
| mod jwt; | ||||
| mod message; | ||||
| mod router; | ||||
| mod stores; | ||||
| mod utils; | ||||
| 
 | ||||
| use clap::Parser; | ||||
| use configparser::ini::Ini; | ||||
| @ -12,8 +12,8 @@ use tokio::{ | ||||
|     time::{timeout, Duration}, | ||||
| }; | ||||
| 
 | ||||
| use crate::router::ROUTER; | ||||
| use config::Config; | ||||
| use http::ROUTER; | ||||
| 
 | ||||
| #[derive(Parser)] | ||||
| #[clap(author, version, about, long_about = None)] | ||||
| @ -67,7 +67,7 @@ async fn main() { | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /// handle_connection parses the incoming request and builds an HTTP response
 | ||||
| /// parses the incoming request (partial spec implementation) and build an HTTP response
 | ||||
| async fn handle_connection(mut stream: TcpStream, addr: String, config: Config) { | ||||
|     log::info!("client connected: {}", addr); | ||||
| 
 | ||||
| @ -90,7 +90,7 @@ async fn handle_connection(mut stream: TcpStream, addr: String, config: Config) | ||||
|     } | ||||
| 
 | ||||
|     let request_string = std::str::from_utf8(&message).unwrap(); | ||||
|     let response = ROUTER.route(request_string, config).await; | ||||
|     let response = ROUTER.route(request_string, addr.clone(), config).await; | ||||
|     let response_str: String = response.into(); | ||||
| 
 | ||||
|     stream.write(response_str.as_bytes()).await.unwrap(); | ||||
|  | ||||
| @ -1,48 +0,0 @@ | ||||
| use serde::Serialize; | ||||
| 
 | ||||
| #[derive(Serialize)] | ||||
| /// JWTMessage aims to have a generic struct to build JSON HTTP response message with JWT informations
 | ||||
| pub struct JWTMessage { | ||||
|     #[serde(skip_serializing_if = "String::is_empty")] | ||||
|     access_token: String, | ||||
|     #[serde(skip_serializing_if = "String::is_empty")] | ||||
|     refresh_token: String, | ||||
|     #[serde(skip_serializing_if = "String::is_empty")] | ||||
|     pubkey: String, | ||||
| } | ||||
| 
 | ||||
| impl JWTMessage { | ||||
|     pub fn with_access(access_token: String) -> Self { | ||||
|         JWTMessage { | ||||
|             access_token: access_token, | ||||
|             refresh_token: "".to_string(), | ||||
|             pubkey: "".to_string(), | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     pub fn with_pubkey(pubkey: String) -> Self { | ||||
|         JWTMessage { | ||||
|             access_token: "".to_string(), | ||||
|             refresh_token: "".to_string(), | ||||
|             pubkey: base64::encode(pubkey), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Serialize, Default)] | ||||
| /// ValidationMessage aims to build a JSON HTTP response body for JWT validation
 | ||||
| pub struct ValidationMessage { | ||||
|     valid: bool, | ||||
|     #[serde(skip_serializing_if = "String::is_empty")] | ||||
|     reason: String, | ||||
| } | ||||
| 
 | ||||
| impl ValidationMessage { | ||||
|     pub fn set_valid(&mut self, valid: bool) { | ||||
|         self.valid = valid; | ||||
|     } | ||||
| 
 | ||||
|     pub fn set_reason(&mut self, reason: &str) { | ||||
|         self.reason = reason.to_string(); | ||||
|     } | ||||
| } | ||||
| @ -1,5 +0,0 @@ | ||||
| //! **message** module holds all structs to manage JSON response body for the authentication.
 | ||||
| 
 | ||||
| mod message; | ||||
| 
 | ||||
| pub use message::{JWTMessage, ValidationMessage}; | ||||
| @ -1,4 +0,0 @@ | ||||
| //! **router** module includes all the handlers to get and validate JWT.
 | ||||
| 
 | ||||
| mod router; | ||||
| pub use router::ROUTER; | ||||
| @ -1,9 +1,10 @@ | ||||
| use async_trait::async_trait; | ||||
| use json; | ||||
| use std::path::Path; | ||||
| 
 | ||||
| use super::store::{Credentials, Store}; | ||||
| 
 | ||||
| /// FileStore references a credentials store file
 | ||||
| /// references a credentials store file
 | ||||
| pub struct FileStore { | ||||
|     path: String, | ||||
|     credentials: Vec<Credentials>, | ||||
| @ -17,9 +18,8 @@ impl FileStore { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /// parse_contents loads and reads the file asynchonously
 | ||||
|     ///
 | ||||
|     /// It parses the file line by line to retrieve the credentials
 | ||||
|     /// loads and reads the file asynchonously
 | ||||
|     /// parses the file line by line to retrieve the credentials
 | ||||
|     async fn parse_contents(&mut self) { | ||||
|         let contents = tokio::fs::read_to_string(&self.path).await; | ||||
|         let mut credentials: Vec<Credentials> = vec![]; | ||||
| @ -47,12 +47,12 @@ impl FileStore { | ||||
|         self.credentials = credentials; | ||||
|     } | ||||
| 
 | ||||
|     /// auth checks if the credentials exist in the `FileStore`
 | ||||
|     fn auth(&self, email: String, password: String) -> bool { | ||||
|     /// checks if the credentials exist in the `FileStore`
 | ||||
|     fn auth(&self, username: String, password: String) -> bool { | ||||
|         let credentials: Vec<&Credentials> = self | ||||
|             .credentials | ||||
|             .iter() | ||||
|             .filter(|x| *x.get_email() == email && *x.get_password() == password) | ||||
|             .filter(|x| x.username == username && x.password == password) | ||||
|             .collect(); | ||||
|         if credentials.len() == 1 { | ||||
|             return true; | ||||
| @ -63,15 +63,21 @@ impl FileStore { | ||||
| 
 | ||||
| #[async_trait] | ||||
| impl Store for FileStore { | ||||
|     async fn is_auth(&mut self, credentials: &Credentials) -> bool { | ||||
|     async fn is_auth(&mut self, data: &json::JsonValue) -> bool { | ||||
|         // ensure that the store file already exists even after its instanciation
 | ||||
|         if !Path::new(&self.path).is_file() { | ||||
|             log::error!("{} path referencing file store does not exist", self.path); | ||||
|             return false; | ||||
|         } | ||||
| 
 | ||||
|         let credentials = Credentials::from(data); | ||||
|         if credentials.is_empty() { | ||||
|             log::error!("unable to parse the credentials correctly from the incoming request"); | ||||
|             return false; | ||||
|         } | ||||
| 
 | ||||
|         self.parse_contents().await; | ||||
|         self.auth(credentials.get_email(), credentials.get_password()) | ||||
|         self.auth(credentials.username, credentials.password) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @ -80,14 +86,11 @@ async fn test_store() { | ||||
|     use std::env; | ||||
| 
 | ||||
|     let root_path = env::var("CARGO_MANIFEST_DIR").unwrap(); | ||||
|     // TODO: path::Path should be better
 | ||||
|     let store_path = format!("{}/{}/{}/{}", root_path, "tests", "data", "store.txt"); | ||||
| 
 | ||||
|     let mut store = FileStore::new(store_path); | ||||
| 
 | ||||
|     let data = json::parse(r#"{"email": "toto@toto.fr", "password": "tata"}"#).unwrap(); | ||||
|     let credentials = Credentials::from(&data); | ||||
|     assert_eq!(credentials.get_email(), "toto@toto.fr"); | ||||
| 
 | ||||
|     let is_auth = store.is_auth(&credentials).await; | ||||
|     assert_eq!(true, is_auth); | ||||
|     let data = json::parse(r#"{"username": "toto", "password": "tata"}"#).unwrap(); | ||||
|     assert_eq!(store.is_auth(&data).await, true); | ||||
| } | ||||
|  | ||||
| @ -1,10 +1,11 @@ | ||||
| //! **store** module lists interfaces available to check credentials. Each store must implement the trait `is_auth`.
 | ||||
| //!
 | ||||
| //! For now one store is available:
 | ||||
| //! store module lists interfaces available to check request credentials
 | ||||
| //! each store must implement the trait `is_auth`
 | ||||
| //! two stores are available :
 | ||||
| //! * `FileStore`: credentials stored in a text file (like **/etc/passwd**)
 | ||||
| //! * `DBStore`: credentials stored in a database (TODO)
 | ||||
| 
 | ||||
| mod file; | ||||
| mod store; | ||||
| 
 | ||||
| pub use file::FileStore; | ||||
| pub use store::{Credentials, Store}; | ||||
| pub use store::Store; | ||||
|  | ||||
| @ -1,55 +1,47 @@ | ||||
| use async_trait::async_trait; | ||||
| use json::JsonValue; | ||||
| use serde::Deserialize; | ||||
| use json; | ||||
| 
 | ||||
| use crate::utils::extract_json_value; | ||||
| 
 | ||||
| #[async_trait] | ||||
| pub trait Store { | ||||
|     async fn is_auth(&mut self, data: &Credentials) -> bool; | ||||
|     async fn is_auth(&mut self, data: &json::JsonValue) -> bool; | ||||
| } | ||||
| 
 | ||||
| #[derive(Default, Debug, Deserialize)] | ||||
| #[derive(Default, Debug)] | ||||
| pub struct Credentials { | ||||
|     email: String, | ||||
|     password: String, | ||||
|     pub username: String, | ||||
|     pub password: String, | ||||
| } | ||||
| 
 | ||||
| /// Credentials represents the incoming user credentials for authentication checking
 | ||||
| impl Credentials { | ||||
|     pub fn new(email: String, password: String) -> Self { | ||||
|         Credentials { email, password } | ||||
|     } | ||||
| 
 | ||||
|     pub fn get_email(&self) -> String { | ||||
|         self.email.clone() | ||||
|     } | ||||
| 
 | ||||
|     pub fn get_password(&self) -> String { | ||||
|         self.password.clone() | ||||
|     pub fn new(username: String, password: String) -> Self { | ||||
|         Credentials { username, password } | ||||
|     } | ||||
| 
 | ||||
|     pub fn is_empty(&self) -> bool { | ||||
|         self.email == "" || self.password == "" | ||||
|         self.username == "" || self.password == "" | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| // TODO: could be less restrictive with `From<&str>`
 | ||||
| impl From<&JsonValue> for Credentials { | ||||
|     fn from(data: &JsonValue) -> Self { | ||||
|         let res = serde_json::from_str(&data.dump()); | ||||
|         match res { | ||||
|             Ok(c) => c, | ||||
|             Err(e) => { | ||||
|                 log::warn!("unable to deserialize credentials: {}", e); | ||||
|                 return Credentials::default(); | ||||
| impl From<&json::JsonValue> for Credentials { | ||||
|     fn from(data: &json::JsonValue) -> Self { | ||||
|         let mut credentials = Credentials::default(); | ||||
|         match data { | ||||
|             json::JsonValue::Object(ref d) => { | ||||
|                 credentials.username = extract_json_value(&d, "username").unwrap_or("".to_string()); | ||||
|                 credentials.password = extract_json_value(&d, "password").unwrap_or("".to_string()); | ||||
|             } | ||||
|             _ => return credentials, | ||||
|         } | ||||
|         credentials | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[test] | ||||
| fn test_credentials() { | ||||
|     struct Expect { | ||||
|         data: JsonValue, | ||||
|         data: json::JsonValue, | ||||
|         is_empty: bool, | ||||
|     } | ||||
|     let test_cases: [Expect; 2] = [ | ||||
| @ -58,7 +50,7 @@ fn test_credentials() { | ||||
|             is_empty: true | ||||
|         }, | ||||
|         Expect { | ||||
|             data: json::parse(r#"{"email":"toto@toto.fr","password": "tata"}"#).unwrap(), | ||||
|             data: json::parse(r#"{"username":"toto","password": "tata"}"#).unwrap(), | ||||
|             is_empty: false | ||||
|         } | ||||
|     ]; | ||||
|  | ||||
							
								
								
									
										4
									
								
								src/utils/mod.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/utils/mod.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| //! includes utility function, that's all !
 | ||||
| mod utils; | ||||
| 
 | ||||
| pub use utils::extract_json_value; | ||||
							
								
								
									
										30
									
								
								src/utils/utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/utils/utils.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,30 @@ | ||||
| use json::object::Object; | ||||
| 
 | ||||
| /// extracts JSON value from a key
 | ||||
| pub fn extract_json_value(data: &Object, key: &str) -> Option<String> { | ||||
|     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), | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -16,31 +16,15 @@ fi | ||||
| for i in {0..10} | ||||
| do | ||||
| 	http_response=$(curl -s -o response.txt -w "%{http_code}" ${URL}/get/ -d '{"username":"toto", "password":"tutu"}') | ||||
| 	if [ $http_response != "400" ] | ||||
| 	then | ||||
| 		echo "bad http status code : ${http_response}, expect 400" | ||||
| 		exit 1 | ||||
| 	fi | ||||
| 
 | ||||
| 	if [ "$(cat response.txt | jq -r '.error')" != "bad request" ] | ||||
| 	then | ||||
| 		echo "bad data returned, expect : bad request" | ||||
| 		exit 1 | ||||
| 	fi | ||||
| done | ||||
| 
 | ||||
| for i in {0..10} | ||||
| do | ||||
| 	http_response=$(curl -s -o response.txt -w "%{http_code}" ${URL}/get/ -d '{"email":"toto", "password":"tutu"}') | ||||
| 	if [ $http_response != "403" ] | ||||
| 	then | ||||
| 		echo "bad http status code : ${http_response}, expect 403" | ||||
| 		echo "bad http status code : ${http_response}, expect 200" | ||||
| 		exit 1 | ||||
| 	fi | ||||
| 
 | ||||
| 	if [ "$(cat response.txt | jq -r '.error')" != "url forbidden" ] | ||||
| 	if [ "$(cat response.txt | jq -r '.error')" != "invalid credentials" ] | ||||
| 	then | ||||
| 		echo "bad data returned, expect : url forbidden" | ||||
| 		echo "bad data returned, expect : invalid credentials" | ||||
| 		exit 1 | ||||
| 	fi | ||||
| done | ||||
| @ -51,7 +35,7 @@ do | ||||
| 	http_response=$(curl -s -o response.txt -w "%{http_code}" ${URL}/ge/ -d '{"username":"toto", "password":"tutu"}') | ||||
| 	if [ $http_response != "404" ] | ||||
| 	then | ||||
| 		echo "bad http status code : ${http_response}, expect 404" | ||||
| 		echo "bad http status code : ${http_response}, expect 400" | ||||
| 		exit 1 | ||||
| 	fi | ||||
| done | ||||
| @ -62,7 +46,7 @@ do | ||||
| 	http_response=$(curl -s -o response.txt -w "%{http_code}" ${URL}/pubkey/) | ||||
| 	if [ $http_response != "200" ] | ||||
| 	then | ||||
| 		echo "bad http status code : ${http_response}, expect 200" | ||||
| 		echo "bad http status code : ${http_response}, expect 400" | ||||
| 		exit 1 | ||||
| 	fi | ||||
| done | ||||
|  | ||||
| @ -1,4 +1,4 @@ | ||||
| # this a test password storage with password in clear | ||||
| # need to be updated in the future to encrypt or hash the password | ||||
| # <email>:<password> | ||||
| toto@toto.fr:tata | ||||
| # <username>:<password> | ||||
| toto:tata | ||||
|  | ||||
| @ -15,17 +15,17 @@ class TestResponse(TestCase): | ||||
|         with open(PUB_KEY_PATH, "r") as f: | ||||
|             self.pub_key = f.read() | ||||
| 
 | ||||
|     def test_get_target(self, pubkey=None): | ||||
|     def test_get_target(self): | ||||
|         resp = requests.post( | ||||
|             URL + "/get/", json={"email": "toto@toto.fr", "password": "tata"} | ||||
|             URL + "/get/", 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") | ||||
| 
 | ||||
|         token = resp.json()["access_token"] | ||||
|         token = resp.json()["token"] | ||||
|         jwt_decoded = jwt.decode( | ||||
|             token, | ||||
|             pubkey or self.pub_key, | ||||
|             self.pub_key, | ||||
|             algorithms=["RS384"], | ||||
|             options={ | ||||
|                 "verify_signature": True, | ||||
| @ -34,7 +34,6 @@ class TestResponse(TestCase): | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual("thegux.fr", jwt_decoded["iss"]) | ||||
|         self.assertEqual("toto@toto.fr", jwt_decoded["email"]) | ||||
| 
 | ||||
|         jwt_exp = datetime.fromtimestamp(jwt_decoded["exp"]) | ||||
|         jwt_iat = datetime.fromtimestamp(jwt_decoded["iat"]) | ||||
| @ -44,18 +43,18 @@ class TestResponse(TestCase): | ||||
| 
 | ||||
|     def test_validate_target_no_token(self): | ||||
|         resp = requests.post( | ||||
|             URL + "/validate/", json={"username": "toto@toto.fr", "password": "tata"} | ||||
|             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()["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()["valid"], "false", "bad status returned") | ||||
|         self.assertEqual( | ||||
|             resp.json()["reason"], | ||||
|             "token validation failed details=JWT compact encoding error", | ||||
| @ -67,7 +66,7 @@ class TestResponse(TestCase): | ||||
|         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") | ||||
|         self.assertEqual(resp.json()["valid"], "true", "bad status returned") | ||||
| 
 | ||||
|     # TODO: must be updated after implementing `/refresh/` url handler | ||||
|     def test_refresh_target(self): | ||||
| @ -78,7 +77,7 @@ class TestResponse(TestCase): | ||||
|         self.assertIsNotNone(resp.json(), "response data can't be empty") | ||||
|         self.assertEqual( | ||||
|             resp.json()["error"], | ||||
|             "url not found", | ||||
|             "the url requested does not exist", | ||||
|             "bad status returned", | ||||
|         ) | ||||
| 
 | ||||
| @ -88,17 +87,19 @@ class TestResponse(TestCase): | ||||
|         self.assertIsNotNone(resp.json(), "response data must not be empty") | ||||
|         self.assertEqual( | ||||
|             resp.json()["error"], | ||||
|             "bad request", | ||||
|             "the incoming request is not valid", | ||||
|             "invalid error message returned", | ||||
|         ) | ||||
| 
 | ||||
|     def test_bad_credentials(self): | ||||
|         resp = requests.post(URL + "/get/", json={"email": "tutu", "password": "titi"}) | ||||
|         resp = requests.post( | ||||
|             URL + "/get/", json={"username": "tutu", "password": "titi"} | ||||
|         ) | ||||
|         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"], | ||||
|             "url forbidden", | ||||
|             "invalid credentials", | ||||
|             "invalid error message returned", | ||||
|         ) | ||||
| 
 | ||||
| @ -110,7 +111,7 @@ class TestResponse(TestCase): | ||||
|         self.assertIsNotNone(resp.json(), "response data must not be empty") | ||||
|         self.assertEqual( | ||||
|             resp.json()["error"], | ||||
|             "url not found", | ||||
|             "the url requested does not exist", | ||||
|             "invalid error message returned", | ||||
|         ) | ||||
| 
 | ||||
| @ -122,10 +123,7 @@ class TestResponse(TestCase): | ||||
| 
 | ||||
|         b64_pubkey = base64.b64decode(resp.json()["pubkey"]) | ||||
|         self.assertIsNotNone(b64_pubkey, "public key b64 decoded can't be empty") | ||||
|         b64_pubkey_decoded = b64_pubkey.decode() | ||||
|         self.assertIn("-BEGIN PUBLIC KEY-", b64_pubkey_decoded) | ||||
| 
 | ||||
|         self.test_get_target(b64_pubkey_decoded) | ||||
|         self.assertIn("-BEGIN PUBLIC KEY-", b64_pubkey.decode()) | ||||
| 
 | ||||
|     def test_get_pubkey_bad_method(self): | ||||
|         resp = requests.post(URL + "/pubkey/", json={"tutu": "toto"}) | ||||
| @ -133,6 +131,6 @@ class TestResponse(TestCase): | ||||
|         self.assertIsNotNone(resp.json(), "response data must not be empty") | ||||
|         self.assertEqual( | ||||
|             resp.json()["error"], | ||||
|             "bad request", | ||||
|             "the incoming request is not valid", | ||||
|             "invalid error message returned", | ||||
|         ) | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user