diff --git a/Cargo.lock b/Cargo.lock index d925400..da33ad9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -635,8 +635,8 @@ dependencies = [ [[package]] name = "http" -version = "0.1.3" -source = "git+https://gitea.thegux.fr/rmanach/http#b8c0fbba0b62906823a79e34bb2eadc1fe419d90" +version = "0.1.6" +source = "git+https://gitea.thegux.fr/rmanach/http#0e616570907f3427be99f4bfc227bd57a252c8c1" dependencies = [ "json", "lazy_static", @@ -1239,6 +1239,7 @@ dependencies = [ "log", "regex", "serde", + "serde_json", "simple_logger", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index a0dc9e1..f327306 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,9 @@ 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.3" } +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" diff --git a/README.md b/README.md index b5e57fc..a0238c6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ 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 @@ -72,7 +71,7 @@ cargo test * set the following env variables: ```bash export SIMPLE_AUTH_URL="http://:" -export SIMPLE_AUTH_PUB_KEY="" # DO NOT USE THE ONE IN PRODUCTION ! +export SIMPLE_AUTH_PUB_KEY="" # DO NOT USE THIS ONE IN PRODUCTION ! ``` * run the server (if no one is running remotly) * run curl tests @@ -80,14 +79,14 @@ export SIMPLE_AUTH_PUB_KEY="" # DO NOT USE THE ONE IN PRODU cd tests/bash/ ./curling.bash && echo "passed" ``` -* run python requests tests +* run python tests ```bash # create a python venv cd tests/python python3 -m venv venv source venv/bin/activate -# intall the requirements +# install the requirements pip install -r requirements # launch the tests @@ -97,5 +96,5 @@ python -m unittest ## Documentation ```bash # add the '--open' arg to open the doc on a browser -cargo doc --no-deps +cargo doc -r --no-deps ``` diff --git a/src/config/mod.rs b/src/config/mod.rs index b82b271..bd20f7d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,4 +1,5 @@ -//! provides `Config` struct to load and validate `.ini` file +//! **config** module provides `Config` struct to load and validate `.ini` file. + mod config; pub use config::Config; diff --git a/src/jwt/jwt.rs b/src/jwt/jwt.rs index 56f3aa8..f642376 100644 --- a/src/jwt/jwt.rs +++ b/src/jwt/jwt.rs @@ -5,6 +5,9 @@ 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, @@ -58,8 +61,8 @@ impl JWTSigner { verification_options } - /// builds and signs the token - pub fn sign(&self, email: String) -> Result { + /// sign builds and signs the token + pub fn sign(&self, credentials: Credentials) -> Result { let jwt_key = { match RS384KeyPair::from_pem(&self.private_key) { Ok(k) => k, @@ -69,13 +72,18 @@ impl JWTSigner { } }; let mut claims = Claims::with_custom_claims( - JWTCustomClaims { email }, + JWTCustomClaims { + email: credentials.get_email(), + }, Duration::from_hours(self.exp_time), ); claims.issuer = Some(self.issuer.clone()); match jwt_key.sign(claims) { - Ok(token) => Ok(token), + Ok(token) => { + // TODO: need to generate the refresh token + return Ok(serde_json::to_string(&JWTMessage::with_access(token)).unwrap()); + } Err(e) => { return Err(format!("unable to sign the token details={}", e)); } diff --git a/src/jwt/mod.rs b/src/jwt/mod.rs index 9b43d55..7eb999b 100644 --- a/src/jwt/mod.rs +++ b/src/jwt/mod.rs @@ -1,4 +1,5 @@ -//! simple module to read `.pem` files and sign the token +//! **jwt** module aims to read `.pem` files and sign/validate the token. + mod jwt; pub use jwt::JWTSigner; diff --git a/src/main.rs b/src/main.rs index 9b9384d..90f47ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod config; mod jwt; +mod message; mod router; mod stores; @@ -66,7 +67,7 @@ async fn main() { } } -/// parses the incoming request (partial spec implementation) and build an HTTP response +/// handle_connection parses the incoming request and builds an HTTP response async fn handle_connection(mut stream: TcpStream, addr: String, config: Config) { log::info!("client connected: {}", addr); diff --git a/src/message/message.rs b/src/message/message.rs new file mode 100644 index 0000000..8b365c8 --- /dev/null +++ b/src/message/message.rs @@ -0,0 +1,48 @@ +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(); + } +} diff --git a/src/message/mod.rs b/src/message/mod.rs new file mode 100644 index 0000000..072badf --- /dev/null +++ b/src/message/mod.rs @@ -0,0 +1,5 @@ +//! **message** module holds all structs to manage JSON response body for the authentication. + +mod message; + +pub use message::{JWTMessage, ValidationMessage}; diff --git a/src/router/mod.rs b/src/router/mod.rs index 16b5ebb..343295a 100644 --- a/src/router/mod.rs +++ b/src/router/mod.rs @@ -1,4 +1,4 @@ -//! router module includes all the handlers to get and validate JWT +//! **router** module includes all the handlers to get and validate JWT. mod router; pub use router::ROUTER; diff --git a/src/router/router.rs b/src/router/router.rs index 3000e13..63dcd00 100644 --- a/src/router/router.rs +++ b/src/router/router.rs @@ -1,13 +1,10 @@ -//! router aims to handle correctly the request corresponding to the target -//! it implements all the logic to build an `HTTPResponse` - -use base64; use http::{HTTPRequest, HTTPResponse, JSONMessage}; use json::JsonValue; use crate::config::Config; use crate::jwt::JWTSigner; -use crate::stores::{FileStore, Store}; +use crate::message::{JWTMessage, ValidationMessage}; +use crate::stores::{Credentials, FileStore, Store}; // TODO: must be mapped with corresponding handler const GET_ROUTE: &'static str = "/get/"; @@ -23,8 +20,13 @@ async fn handle_get(request: HTTPRequest<'_>, config: Config, method: &str) -> H match request.get_body() { Some(d) => { - let credentials = store.is_auth(d).await; - if credentials.is_none() { + 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 { return HTTPResponse::as_403(); } @@ -38,7 +40,7 @@ async fn handle_get(request: HTTPRequest<'_>, config: Config, method: &str) -> H } }; - match jwt_signer.sign(credentials.unwrap().email) { + match jwt_signer.sign(credentials) { Ok(t) => send_token(&t), Err(e) => { let message = JSONMessage::error(&e); @@ -50,14 +52,10 @@ async fn handle_get(request: HTTPRequest<'_>, config: Config, method: &str) -> H } } -/// validates the token by checking: +/// handle_validate 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(); } @@ -66,11 +64,10 @@ async fn handle_validate( match request.get_body_value("token") { Some(t) => t, None => { - let mut message = JSONMessage::default(); - message.put("valid", "false"); - message.put("reason", "no token provided in the request body"); - let json = message.try_into().unwrap(); + let mut message = ValidationMessage::default(); + message.set_reason("no token provided in the request body"); + let json = json::parse(&serde_json::to_string(&message).unwrap()).unwrap(); return HTTPResponse::as_200(Some(json)); } } @@ -87,27 +84,22 @@ async fn handle_validate( } }; - let mut message = JSONMessage::default(); + let mut message = ValidationMessage::default(); match jwt_signer.validate(&token) { Ok(()) => { - message.put("valid", "true"); + message.set_valid(true); } Err(e) => { - message.put("valid", "false"); - message.put("reason", &e); + message.set_reason(&e); } } - let json: JsonValue = message.try_into().unwrap(); + let json: JsonValue = json::parse(&serde_json::to_string(&message).unwrap()).unwrap(); HTTPResponse::as_200(Some(json)) } -/// returns the JWT public key in base64 encoded -async fn handle_public_key( - request: HTTPRequest<'_>, - config: Config, - method: &str, -) -> HTTPResponse { +/// handle_public_key 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(); } @@ -124,18 +116,15 @@ async fn handle_public_key( }; let public_key = jwt_signer.get_public_key(); + let message = serde_json::to_string(&JWTMessage::with_pubkey(public_key)).unwrap(); - let mut message = JSONMessage::default(); - message.put("pubkey", &base64::encode(public_key)); - - let json = message.try_into().unwrap(); - HTTPResponse::as_200(Some(json)) + HTTPResponse::as_200(Some(json::parse(&message).unwrap())) } pub struct Router; impl Router { - /// routes the request to the corresponding handling method + /// 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() { @@ -148,21 +137,16 @@ impl Router { } /// send_token generates an HTTPResponse with the new token -pub fn send_token(token: &str) -> HTTPResponse { - let mut message = JSONMessage::default(); - message.put("token", token); - - let json = { - match message.try_into() { - Ok(m) => m, - Err(_e) => json::parse(r#"{"token": "error.generation.token"}"#).unwrap(), - } +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)) + 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] @@ -173,9 +157,6 @@ async fn test_route() { let config: Config = Config::default(); let request_str = "POST /get/ HTTP/1.1\r\n\r\n"; - let response: HTTPResponse = router.route(request_str, "".to_string(), config).await; - assert_eq!( - HTTPStatusCode::Http400, - response.status_line.get_status_code() - ); + let response: HTTPResponse = router.route(request_str, config).await; + assert_eq!(HTTPStatusCode::Http400, response.get_status_code()); } diff --git a/src/stores/file.rs b/src/stores/file.rs index f3839d0..5783320 100644 --- a/src/stores/file.rs +++ b/src/stores/file.rs @@ -1,10 +1,9 @@ use async_trait::async_trait; -use json::JsonValue; use std::path::Path; use super::store::{Credentials, Store}; -/// references a credentials store file +/// FileStore references a credentials store file pub struct FileStore { path: String, credentials: Vec, @@ -18,8 +17,9 @@ impl FileStore { } } - /// loads and reads the file asynchonously - /// parses the file line by line to retrieve the credentials + /// parse_contents loads and reads the file asynchonously + /// + /// It 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 = vec![]; @@ -47,41 +47,31 @@ impl FileStore { self.credentials = credentials; } - /// checks if the credentials exist in the `FileStore` - fn auth(&self, email: String, password: String) -> Option { + /// auth checks if the credentials exist in the `FileStore` + fn auth(&self, email: String, password: String) -> bool { let credentials: Vec<&Credentials> = self .credentials .iter() - .filter(|x| x.email == email && x.password == password) + .filter(|x| *x.get_email() == email && *x.get_password() == password) .collect(); if credentials.len() == 1 { - // no need to store the password again - return Some(Credentials::new( - credentials[0].email.clone(), - "".to_string(), - )); + return true; } - None + false } } #[async_trait] impl Store for FileStore { - async fn is_auth(&mut self, data: &JsonValue) -> Option { + async fn is_auth(&mut self, credentials: &Credentials) -> 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 None; - } - - let credentials = Credentials::from(data); - if credentials.is_empty() { - log::error!("unable to parse the credentials correctly from the incoming request"); - return None; + return false; } self.parse_contents().await; - self.auth(credentials.email, credentials.password) + self.auth(credentials.get_email(), credentials.get_password()) } } @@ -90,13 +80,14 @@ 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 = store.is_auth(&data).await; - assert_eq!(false, credentials.is_none()); - assert_eq!(credentials.unwrap().email, "toto@toto.fr"); + 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); } diff --git a/src/stores/mod.rs b/src/stores/mod.rs index 2a768fc..ae56d8e 100644 --- a/src/stores/mod.rs +++ b/src/stores/mod.rs @@ -1,8 +1,7 @@ -//! store module lists interfaces available to check request credentials -//! each store must implement the trait `is_auth` -//! two stores are available : +//! **store** module lists interfaces available to check credentials. Each store must implement the trait `is_auth`. +//! +//! For now one store is available: //! * `FileStore`: credentials stored in a text file (like **/etc/passwd**) -//! * `DBStore`: credentials stored in a database (TODO) mod file; mod store; diff --git a/src/stores/store.rs b/src/stores/store.rs index ec84e04..e5fa8fb 100644 --- a/src/stores/store.rs +++ b/src/stores/store.rs @@ -1,40 +1,48 @@ use async_trait::async_trait; use json::JsonValue; - -use http::extract_json_value; +use serde::Deserialize; #[async_trait] pub trait Store { - async fn is_auth(&mut self, data: &JsonValue) -> Option; + async fn is_auth(&mut self, data: &Credentials) -> bool; } -#[derive(Default, Debug)] +#[derive(Default, Debug, Deserialize)] pub struct Credentials { - pub email: String, - pub password: String, + email: String, + 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 is_empty(&self) -> bool { self.email == "" || self.password == "" } } +// TODO: could be less restrictive with `From<&str>` impl From<&JsonValue> for Credentials { fn from(data: &JsonValue) -> Self { - let mut credentials = Credentials::default(); - match data { - JsonValue::Object(ref d) => { - credentials.email = extract_json_value(&d, "email").unwrap_or("".to_string()); - credentials.password = extract_json_value(&d, "password").unwrap_or("".to_string()); + 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(); } - _ => return credentials, } - credentials } } diff --git a/tests/bash/curling.bash b/tests/bash/curling.bash index fe863c4..bee216a 100755 --- a/tests/bash/curling.bash +++ b/tests/bash/curling.bash @@ -16,15 +16,31 @@ 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 200" + echo "bad http status code : ${http_response}, expect 403" exit 1 fi if [ "$(cat response.txt | jq -r '.error')" != "url forbidden" ] then - echo "bad data returned, expect : invalid credentials" + echo "bad data returned, expect : url forbidden" exit 1 fi done @@ -35,7 +51,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 400" + echo "bad http status code : ${http_response}, expect 404" exit 1 fi done @@ -46,7 +62,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 400" + echo "bad http status code : ${http_response}, expect 200" exit 1 fi done diff --git a/tests/python/test_requests.py b/tests/python/test_requests.py index 8701f04..e1c7b19 100644 --- a/tests/python/test_requests.py +++ b/tests/python/test_requests.py @@ -22,7 +22,7 @@ class TestResponse(TestCase): self.assertEqual(resp.status_code, 200, "bad status code returned") self.assertIsNotNone(resp.json(), "response data can't be empty") - token = resp.json()["token"] + token = resp.json()["access_token"] jwt_decoded = jwt.decode( token, pubkey or self.pub_key, @@ -48,14 +48,14 @@ class TestResponse(TestCase): ) 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 +67,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):