Compare commits
No commits in common. "master" and "v0.2.0" have entirely different histories.
626
Cargo.lock
generated
626
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
12
Cargo.toml
12
Cargo.toml
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "simple-auth"
|
||||
version = "0.3.2"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
@ -12,12 +12,6 @@ regex = "1"
|
||||
tokio = { version = "1.21.1", features = ["full"] }
|
||||
async-trait = "0.1.57"
|
||||
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 +27,3 @@ features = ["derive"]
|
||||
[dependencies.async-std]
|
||||
version = "1.6"
|
||||
features = ["attributes"]
|
||||
|
||||
[dependencies.serde]
|
||||
version = "1.0"
|
||||
features = ["derive"]
|
||||
|
||||
19
README.md
19
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**.
|
||||
|
||||
@ -44,19 +45,13 @@ expiration_time = 2 # in hours
|
||||
```bash
|
||||
./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>"}
|
||||
|
||||
# validate a JWT
|
||||
curl http://<ip>:<port>/validate/ -d '{"token":"<header>.<payload>.<signature>"}'
|
||||
# should returned (if valid)
|
||||
{"valid":"true"}
|
||||
|
||||
# get the public key for local validation
|
||||
curl http://<ip>:<port>/pubkey/
|
||||
{"pubkey":"<b64_encoded_public_key>"}
|
||||
```
|
||||
|
||||
## Test
|
||||
@ -71,7 +66,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 +74,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 +91,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,3 +1,5 @@
|
||||
//! config module implements all the utilities to properly create and validate a router config
|
||||
|
||||
use configparser::ini::Ini;
|
||||
use std::str::FromStr;
|
||||
|
||||
@ -32,10 +34,7 @@ impl TryFrom<Ini> for Config {
|
||||
match u64::from_str(&exp_time) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"unable to convert JWT expiration time into u64 details={}",
|
||||
e
|
||||
);
|
||||
eprintln!("unable to convert JWT expiration time into u64 err={}", e);
|
||||
0
|
||||
}
|
||||
}
|
||||
@ -60,27 +59,27 @@ impl Config {
|
||||
/// validates config ini file
|
||||
fn validate(&self) -> bool {
|
||||
if self.jwt_exp_time <= 0 {
|
||||
log::error!("invalid config parameter: JWT expiration time is negative or equals to 0");
|
||||
eprintln!("invalid config parameter: JWT expiration time is negative or equals to 0");
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.jwt_issuer == "" {
|
||||
log::error!("invalid config parameter: JWT issuer is empty");
|
||||
eprintln!("invalid config parameter: JWT issuer is empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.jwt_pub_key == "" {
|
||||
log::error!("invalid config parameter: JWT public key file path is empty");
|
||||
eprintln!("invalid config parameter: JWT public key file path is empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.jwt_priv_key == "" {
|
||||
log::error!("invalid config parameter: JWT private key file path is empty");
|
||||
eprintln!("invalid config parameter: JWT private key file path is empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.filestore_path == "" {
|
||||
log::error!("invalid config parameter: filestore path is empty");
|
||||
eprintln!("invalid config parameter: filestore path is empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
//! **config** module 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;
|
||||
408
src/http/request.rs
Normal file
408
src/http/request.rs
Normal file
@ -0,0 +1,408 @@
|
||||
//! 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!(
|
||||
"error occurred during request body parsing err={}",
|
||||
e
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an HTTP request (headers are not parsed)
|
||||
#[derive(Debug)]
|
||||
pub struct HTTPRequest {
|
||||
pub start_line: HTTPStartLine,
|
||||
pub body: Option<HTTPBody>,
|
||||
}
|
||||
|
||||
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) => eprintln!("error occurred while parsing start_line err={}", e),
|
||||
}
|
||||
|
||||
let body = HTTPBody::try_from(rp.2);
|
||||
match body {
|
||||
Ok(v) => request.body = Some(v),
|
||||
Err(e) => eprintln!("error occurred during body parsing err={}", e),
|
||||
}
|
||||
|
||||
return Ok(request);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("error occurred getting request parts 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,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_valid(&self) -> bool {
|
||||
return self.start_line.is_valid();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HTTPRequest {
|
||||
fn default() -> Self {
|
||||
HTTPRequest {
|
||||
start_line: HTTPStartLine::default(),
|
||||
body: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for HTTPRequest {
|
||||
fn from(request: &str) -> Self {
|
||||
match Self::parse(request) {
|
||||
Ok(v) => v,
|
||||
Err(v) => {
|
||||
eprintln!("{}", format!("[ERR]: {v}"));
|
||||
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))
|
||||
}
|
||||
}
|
||||
121
src/http/router.rs
Normal file
121
src/http/router.rs
Normal file
@ -0,0 +1,121 @@
|
||||
//! router aims to handle correctly the request corresponding to the target
|
||||
//! it implements all the logic to build an `HTTPResponse`
|
||||
|
||||
use json;
|
||||
|
||||
use super::{HTTPMessage, HTTPRequest, HTTPResponse};
|
||||
use crate::config::Config;
|
||||
use crate::jwt::JWTSigner;
|
||||
use crate::stores::{FileStore, Store};
|
||||
|
||||
// TODO: must be mapped with corresponding handler
|
||||
const GET_ROUTE: &'static str = "/get/";
|
||||
const VALIDATE_ROUTE: &'static str = "/validate/";
|
||||
|
||||
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 jwt_signer = {
|
||||
match JWTSigner::new(config).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// validates the token by checking:
|
||||
/// * expiration time
|
||||
/// * 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();
|
||||
|
||||
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;
|
||||
|
||||
impl Router {
|
||||
pub async fn route(&self, request_str: &str, config: Config) -> HTTPResponse {
|
||||
let request = HTTPRequest::from(request_str);
|
||||
let target = request.start_line.get_target();
|
||||
|
||||
match target.as_str() {
|
||||
GET_ROUTE => handle_get(request, config).await,
|
||||
VALIDATE_ROUTE => handle_validate(request, config).await,
|
||||
_ => HTTPResponse::as_404(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this MUST be used like a Singleton
|
||||
pub const ROUTER: Router = Router {};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_route() {
|
||||
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.status_line.get_status_code()
|
||||
);
|
||||
}
|
||||
@ -1,18 +1,11 @@
|
||||
//! simple module to read `.pem` files and sign the token
|
||||
|
||||
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,
|
||||
@ -35,7 +28,7 @@ impl JWTSigner {
|
||||
jwt_signer.private_key = c;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("unable to read the private key details={}", e));
|
||||
return Err(format!("unable to read the private key err={}", e));
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,7 +37,7 @@ impl JWTSigner {
|
||||
jwt_signer.public_key = c;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("unable to read the public key details={}", e));
|
||||
return Err(format!("unable to read the public key err={}", e));
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,31 +54,23 @@ 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,
|
||||
Err(e) => {
|
||||
return Err(format!("unable to load the private key details={}", e));
|
||||
return Err(format!("unable to load the private key err={}", e));
|
||||
}
|
||||
}
|
||||
};
|
||||
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));
|
||||
return Err(format!("unable to sign the token err={}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -97,20 +82,16 @@ impl JWTSigner {
|
||||
if let Err(e) =
|
||||
key.verify_token::<NoCustomClaims>(token, Some(verification_options))
|
||||
{
|
||||
return Err(format!("token validation failed details={}", e));
|
||||
return Err(format!("token validation failed err={}", e));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(format!(
|
||||
"token validation failed, can't read the public key details={}",
|
||||
"token validation failed can't read the public key err={}",
|
||||
e
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_public_key(&self) -> String {
|
||||
self.public_key.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
//! **jwt** module aims to read `.pem` files and sign/validate the token.
|
||||
|
||||
mod jwt;
|
||||
|
||||
pub use jwt::JWTSigner;
|
||||
|
||||
32
src/main.rs
32
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)]
|
||||
@ -24,14 +24,13 @@ struct Cli {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
simple_logger::init_with_level(log::Level::Info).unwrap();
|
||||
let args = Cli::parse();
|
||||
|
||||
let mut config = Ini::new();
|
||||
match config.load(args.config) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("error while loading the config file details={}", e);
|
||||
eprintln!("error while loading the config file, err={}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
@ -40,11 +39,11 @@ async fn main() {
|
||||
let listener = {
|
||||
match TcpListener::bind(&server_url).await {
|
||||
Ok(t) => {
|
||||
log::info!("server is listening on '{}'", server_url);
|
||||
println!("server is listening on '{}'", server_url);
|
||||
t
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("while initializing tcp listener details={}", e);
|
||||
eprintln!("error occurred while initializing tcp listener err={}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@ -53,31 +52,28 @@ async fn main() {
|
||||
let router_config: Config = {
|
||||
match Config::try_from(config) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("unable to load the configuration details={}", e);
|
||||
Err(_e) => {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
let (stream, addr) = listener.accept().await.unwrap();
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let conf = router_config.clone();
|
||||
tokio::spawn(handle_connection(stream, addr.to_string(), conf.clone()));
|
||||
tokio::spawn(handle_connection(stream, conf.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
/// 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 duration = Duration::from_millis(5);
|
||||
let duration = Duration::from_micros(500);
|
||||
|
||||
// loop until the message is read
|
||||
// the stream can be fragmented so, using a timeout (5ms should be far enough) for the future for completion
|
||||
// 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 {
|
||||
@ -95,6 +91,4 @@ async fn handle_connection(mut stream: TcpStream, addr: String, config: Config)
|
||||
|
||||
stream.write(response_str.as_bytes()).await.unwrap();
|
||||
stream.flush().await.unwrap();
|
||||
|
||||
log::info!("connection closed: {}", addr);
|
||||
}
|
||||
|
||||
@ -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,162 +0,0 @@
|
||||
use http::{HTTPRequest, HTTPResponse, JSONMessage};
|
||||
use json::JsonValue;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::jwt::JWTSigner;
|
||||
use crate::message::{JWTMessage, ValidationMessage};
|
||||
use crate::stores::{Credentials, 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 {
|
||||
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 {
|
||||
return HTTPResponse::as_403();
|
||||
}
|
||||
|
||||
let jwt_signer = {
|
||||
match JWTSigner::new(config).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let message = JSONMessage::error(&e);
|
||||
return HTTPResponse::as_500(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match jwt_signer.sign(credentials) {
|
||||
Ok(t) => send_token(&t),
|
||||
Err(e) => {
|
||||
let message = JSONMessage::error(&e);
|
||||
return HTTPResponse::as_500(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => HTTPResponse::as_400(),
|
||||
}
|
||||
}
|
||||
|
||||
/// handle_validate validates the token by checking:
|
||||
/// * expiration time
|
||||
/// * signature
|
||||
async fn handle_validate(request: HTTPRequest<'_>, config: Config, method: &str) -> HTTPResponse {
|
||||
if request.get_method().trim().to_lowercase() != method {
|
||||
return HTTPResponse::as_400();
|
||||
}
|
||||
|
||||
let token = {
|
||||
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 json = json::parse(&serde_json::to_string(&message).unwrap()).unwrap();
|
||||
return HTTPResponse::as_200(Some(json));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let jwt_signer = {
|
||||
match JWTSigner::new(config).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let message = JSONMessage::error(&e);
|
||||
let json = message.try_into().unwrap();
|
||||
return HTTPResponse::as_500(Some(json));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut message = ValidationMessage::default();
|
||||
match jwt_signer.validate(&token) {
|
||||
Ok(()) => {
|
||||
message.set_valid(true);
|
||||
}
|
||||
Err(e) => {
|
||||
message.set_reason(&e);
|
||||
}
|
||||
}
|
||||
|
||||
let json: JsonValue = json::parse(&serde_json::to_string(&message).unwrap()).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 {
|
||||
if request.get_method().trim().to_lowercase() != method {
|
||||
return HTTPResponse::as_400();
|
||||
}
|
||||
|
||||
let jwt_signer = {
|
||||
match JWTSigner::new(config).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let message = JSONMessage::error(&e);
|
||||
let json = message.try_into().unwrap();
|
||||
return HTTPResponse::as_500(Some(json));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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()))
|
||||
}
|
||||
|
||||
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() {
|
||||
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,
|
||||
_ => HTTPResponse::as_404(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
pub const ROUTER: Router = Router {};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_route() {
|
||||
use http::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());
|
||||
}
|
||||
@ -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![];
|
||||
@ -41,18 +41,21 @@ impl FileStore {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("while reading store file: {}, details={:?}", self.path, e);
|
||||
eprintln!(
|
||||
"error occurred while reading store file: {}, err={:?}",
|
||||
self.path, e
|
||||
);
|
||||
}
|
||||
}
|
||||
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 +66,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);
|
||||
eprintln!("{} path referencing file store does not exist", self.path);
|
||||
return false;
|
||||
}
|
||||
|
||||
let credentials = Credentials::from(data);
|
||||
if credentials.is_empty() {
|
||||
eprintln!("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 +89,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
|
||||
}
|
||||
];
|
||||
|
||||
3
src/utils/mod.rs
Normal file
3
src/utils/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,41 +6,24 @@
|
||||
#
|
||||
#######################################
|
||||
|
||||
URL=${SIMPLE_AUTH_URL}
|
||||
if [ -z ${URL} ]
|
||||
if [ -z ${SIMPLE_AUTH_URL} ]
|
||||
then
|
||||
echo "[WARN]: SIMPLE_AUTH_URL is empty, set to http://localhost:5555"
|
||||
URL="http://localhost:5555"
|
||||
echo "[WARN]: SIMPLE_AUTH_URL is empty, set to http://localhost:9001"
|
||||
URL="http://localhost:9001"
|
||||
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,18 +34,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"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
for i in {0..10}
|
||||
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
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import base64
|
||||
import jwt
|
||||
import os
|
||||
import requests
|
||||
@ -6,7 +5,7 @@ import requests
|
||||
from datetime import datetime
|
||||
from unittest import TestCase
|
||||
|
||||
URL = os.getenv("SIMPLE_AUTH_URL", "http://127.0.0.1:5555")
|
||||
URL = os.getenv("SIMPLE_AUTH_URL", "http://127.0.0.1:9001")
|
||||
PUB_KEY_PATH = os.getenv("SIMPLE_AUTH_PUB_KEY", "")
|
||||
|
||||
|
||||
@ -15,17 +14,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 +33,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,21 +42,21 @@ 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",
|
||||
"token validation failed err=JWT compact encoding error",
|
||||
)
|
||||
|
||||
def test_validate_target(self):
|
||||
@ -67,7 +65,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 +76,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 +86,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,29 +110,6 @@ class TestResponse(TestCase):
|
||||
self.assertIsNotNone(resp.json(), "response data must not be empty")
|
||||
self.assertEqual(
|
||||
resp.json()["error"],
|
||||
"url not found",
|
||||
"invalid error message returned",
|
||||
)
|
||||
|
||||
def test_get_pubkey(self):
|
||||
resp = requests.get(URL + "/pubkey/")
|
||||
self.assertEqual(resp.status_code, 200, "bad status code returned")
|
||||
self.assertIsNotNone(resp.json(), "response data must not be empty")
|
||||
self.assertIsNotNone(resp.json()["pubkey"], "invalid error message returned")
|
||||
|
||||
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)
|
||||
|
||||
def test_get_pubkey_bad_method(self):
|
||||
resp = requests.post(URL + "/pubkey/", json={"tutu": "toto"})
|
||||
self.assertEqual(resp.status_code, 400, "bad status code returned")
|
||||
self.assertIsNotNone(resp.json(), "response data must not be empty")
|
||||
self.assertEqual(
|
||||
resp.json()["error"],
|
||||
"bad request",
|
||||
"the url requested does not exist",
|
||||
"invalid error message returned",
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user