Compare commits

..

No commits in common. "master" and "v0.3.1" have entirely different histories.

22 changed files with 1170 additions and 495 deletions

597
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[package]
name = "simple-auth"
version = "0.3.2"
version = "0.3.1"
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"

View File

@ -8,6 +8,7 @@ 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
@ -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
```

View File

@ -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
View 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("email", "toto@toto.fr");
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("email"));
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
View 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
View 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
View 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))
}
}

View File

@ -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 credentials = store.is_auth(&b.get_data()).await;
if credentials.is_none() {
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(credentials.unwrap().email) {
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()
);
}

View File

@ -5,9 +5,6 @@ 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,
@ -61,8 +58,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, email: String) -> Result<String, String> {
let jwt_key = {
match RS384KeyPair::from_pem(&self.private_key) {
Ok(k) => k,
@ -72,18 +69,13 @@ impl JWTSigner {
}
};
let mut claims = Claims::with_custom_claims(
JWTCustomClaims {
email: credentials.get_email(),
},
JWTCustomClaims { email },
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));
}

View File

@ -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;

View File

@ -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();

View File

@ -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();
}
}

View File

@ -1,5 +0,0 @@
//! **message** module holds all structs to manage JSON response body for the authentication.
mod message;
pub use message::{JWTMessage, ValidationMessage};

View File

@ -1,4 +0,0 @@
//! **router** module includes all the handlers to get and validate JWT.
mod router;
pub use router::ROUTER;

View File

@ -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,31 +47,41 @@ 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, email: String, password: String) -> Option<Credentials> {
let credentials: Vec<&Credentials> = self
.credentials
.iter()
.filter(|x| *x.get_email() == email && *x.get_password() == password)
.filter(|x| x.email == email && x.password == password)
.collect();
if credentials.len() == 1 {
return true;
// no need to store the password again
return Some(Credentials::new(
credentials[0].email.clone(),
"".to_string(),
));
}
false
None
}
}
#[async_trait]
impl Store for FileStore {
async fn is_auth(&mut self, credentials: &Credentials) -> bool {
async fn is_auth(&mut self, data: &json::JsonValue) -> Option<Credentials> {
// 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;
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;
}
self.parse_contents().await;
self.auth(credentials.get_email(), credentials.get_password())
self.auth(credentials.email, credentials.password)
}
}
@ -80,14 +90,13 @@ 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 credentials = store.is_auth(&data).await;
assert_eq!(false, credentials.is_none());
assert_eq!(credentials.unwrap().email, "toto@toto.fr");
}

View File

@ -1,7 +1,8 @@
//! **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;

View File

@ -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) -> Option<Credentials>;
}
#[derive(Default, Debug, Deserialize)]
#[derive(Default, Debug)]
pub struct Credentials {
email: String,
password: String,
pub email: 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 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 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.email = extract_json_value(&d, "email").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] = [

4
src/utils/mod.rs Normal file
View 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
View 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),
}
}
}

View File

@ -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

View File

@ -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()["access_token"]
token = resp.json()["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):
@ -78,7 +78,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,7 +88,7 @@ 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",
)
@ -98,7 +98,7 @@ class TestResponse(TestCase):
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 +110,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",
)
@ -133,6 +133,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",
)