Pour tout problème contactez-nous par mail : support@froggit.fr | La FAQ :grey_question: | Rejoignez-nous sur le Chat :speech_balloon:

Skip to content
Snippets Groups Projects
Commit a996871e authored by Pierre Jarriges's avatar Pierre Jarriges
Browse files

Merge branch 'dev' into 'master'

Dev

See merge request !1
parents d30c3361 b68000c1
No related branches found
No related tags found
No related merge requests found
Showing
with 575 additions and 6 deletions
public/assets/images/mental-eau.png

16.8 KiB

public/assets/images/obj2htm-logo.png

952 B

public/assets/images/screen_make_frames.png

19.3 KiB

public/standard/favicon.ico

7.58 KiB

User-agent: *
Disallow: /articles/
Disallow: /style/
Sitemap: https://kuadrado-software.fr/sitemap.xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://kuadrado-software.fr</loc>
<lastmod>2021-11-21</lastmod>
</url>
<url>
<loc>https://kuadrado-software.fr/games/</loc>
<lastmod>2021-11-21</lastmod>
</url>
<url>
<loc>https://kuadrado-software.fr/education/</loc>
<lastmod>2021-11-21</lastmod>
</url>
<url>
<loc>https://kuadrado-software.fr/software-development/</loc>
<lastmod>2021-11-21</lastmod>
</url>
</urlset>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Page not found</title>
</head>
<body>
<h1>404 : Page not found</h1>
</body>
</html>
\ No newline at end of file
const login_form = document.getElementById("login-form");
login_form.onsubmit = e => {
e.preventDefault();
fetch("/admin-auth", {
method: "POST",
body: new URLSearchParams(new FormData(e.target))
}).then(res => {
if (res.status >= 400) {
alert(res.statusText)
} else {
window.location.assign("/v/admin-panel")
}
}).catch(err => {
alert(err.statusText || "Server error")
});
}
\ No newline at end of file
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Kuadrado admin login</title>
</head>
<body>
<h1>Admin login</h1>
<form id="login-form">
<label for="username"></label>
<input type="text" id="username" name="username">
<label for="password"></label>
<input type="password" id="password" name="password">
<input type="submit">
</form>
</body>
<script src="/v/admin-login/assets/login.js"></script>
</html>
\ No newline at end of file
body * {
font-family: monospace;
box-sizing: border-box;
}
input[type="text"],
textarea {
padding: 8px;
}
button,
input[type="submit"] {
padding: 10px;
cursor: pointer;
}
nav {
display: flex;
gap: 1px;
margin: 20px 0;
}
nav span {
padding: 20px;
font-weight: bold;
background-color: #ddd;
cursor: pointer;
}
nav span:hover,
nav span.selected {
background-color: #555;
color: white;
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Kuadrado admin panel</title>
<link rel="stylesheet" href="/v/admin-panel/assets/style.css">
</head>
<body></body>
<script src="/v/admin-panel/assets/bundle.js"></script>
</html>
\ No newline at end of file
<h1>TEST AUTH</h1>
\ No newline at end of file
<h1>TEST</h1>
\ No newline at end of file
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Unauthorized</title>
</head>
<body>
<h1>Unauthorized</h1>
<p>You must login as an administrator to access this page</p>
<a href='/v/admin-login'>Login page</a>
</body>
</html>
\ No newline at end of file
use crate::{crypto::Encryption, env::Env, init_admin::create_default_admin_if_none};
use wither::mongodb::{options::ClientOptions, Client, Database};
#[derive(Debug, Clone)]
/// The app_state that must be given to the actix::App instance using App::new().app_data(web::Data::new(AppState::new()))
/// It holds the database client connection, an Env struct which provides all values defined in the .env file,
/// and an Encryption struct which is reponsible for encrypting and decrypting data such as passwords and auth tokens.
pub struct AppState {
pub db: Database,
pub env: Env,
pub encryption: Encryption,
}
impl AppState {
/// Creates the Mongodb database client connection
async fn get_db_connection(host: &str, env: &Env) -> Database {
let db_connection_string = format!(
"mongodb://{}:{}@{}:{}/{}",
env.db_username, env.db_user_pwd, host, env.db_port, env.db_name
);
let client_options = ClientOptions::parse(&db_connection_string)
.await
.expect("Error creating database client options");
let client = Client::with_options(client_options).expect("Couldn't connect to database.");
client.database(&env.db_name)
}
pub async fn new() -> Self {
let env = Env::new();
let db = Self::get_db_connection(&env.db_name, &env).await;
let encryption = Encryption::new(env.crypt_key.to_owned());
AppState {
db,
env,
encryption,
}
}
/// This calls Self::new() and creates a default administrator before returning the instance
pub async fn with_default_admin_user() -> Self {
let instance = Self::new().await;
if let Err(e) = create_default_admin_if_none(&instance).await {
panic!("Error creating admin user: {}\nWill exit process now.", e);
};
instance
}
#[cfg(test)]
/// Provides an instance with some specificities for testing
pub async fn for_test() -> Self {
let env = Env::for_test();
let db = Self::get_db_connection("localhost", &env).await;
let encryption = Encryption::new(env.crypt_key.to_owned());
AppState {
db,
env,
encryption,
}
}
}
use magic_crypt::{MagicCrypt, MagicCryptTrait, SecureBit};
use rand::{distributions::Alphanumeric, Rng};
#[derive(Debug, Clone)]
/// A structure responsible of encrypting and decrypting data such as auth token, passwords and email addresses.
pub struct Encryption {
/// The encryption key must be keeped secret and is loaded from the $CRYPT_KEY environment variable
pub key: String,
}
impl Encryption {
pub fn new(key: String) -> Self {
Encryption { key }
}
/// Gets a string as an argument and returns a base64 hash of the string based on the secret key and magic_crypt::SecureBit::Bit256 algorithm
pub fn encrypt(&self, source: &String) -> String {
let mc = MagicCrypt::new(&self.key, SecureBit::Bit256, None::<String>);
mc.encrypt_str_to_base64(source)
}
/// Gets a string base64 hash as an argument and returns the decryted string.
/// Panics if the source base64 string cannot be decrypted (should happen if trying to decrypt a regular string)
#[cfg(test)]
pub fn decrypt(&self, source: &String) -> String {
let mc = MagicCrypt::new(&self.key, SecureBit::Bit256, None::<String>);
mc.decrypt_base64_to_string(source).unwrap()
}
/// Generates a random ascii lowercase string. Length being given as argument.
pub fn random_ascii_lc_string(&self, length: usize) -> String {
// Thanks to https://stackoverflow.com/questions/54275459/how-do-i-create-a-random-string-by-sampling-from-alphanumeric-characters#54277357
rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(length)
.map(char::from)
.collect::<String>()
.to_ascii_lowercase()
}
}
/*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*@@
*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*@@
* _______ ______ ______ _______ *@@
* |__ __@ | ____@ / ____@ |__ __@ *@@
* | @ | @__ \_ @_ | @ *@@
* | @ | __@ \ @_ | @ *@@
* | @ | @___ ____\ @ | @ *@@
* |__@ |______@ \______@ |__@ *@@
* *@@
*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*@@
*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*@*/
#[cfg(test)]
mod test_encryption {
use super::*;
#[test]
fn test_random_ascii_lc_string() {
dotenv::dotenv().ok();
let key = std::env::var("CRYPT_KEY").unwrap();
let enc = Encryption::new(key);
let rdm_str = enc.random_ascii_lc_string(32);
assert_eq!(rdm_str.len(), 32);
assert!(rdm_str.chars().all(char::is_alphanumeric));
assert_eq!(rdm_str, rdm_str.to_lowercase());
}
#[test]
fn test_encrypt() {
dotenv::dotenv().ok();
let key = std::env::var("CRYPT_KEY").unwrap();
let enc = Encryption::new(key);
let an_email = String::from("kuadrado-email@test.com");
let email_hash = enc.encrypt(&an_email);
assert_ne!(an_email, email_hash);
}
#[test]
fn test_decrypt() {
dotenv::dotenv().ok();
let key = std::env::var("CRYPT_KEY").unwrap();
let enc = Encryption::new(key);
let an_email = String::from("kuadrado-email@test.com");
let email_hash = enc.encrypt(&an_email);
let decrypted = enc.decrypt(&email_hash);
assert_eq!(an_email, decrypted);
}
}
use std::env;
#[derive(Debug, Clone)]
/// Makes a copy of all required values defined in the system environment variables
pub struct Env {
pub release_mode: String,
pub db_username: String,
pub db_user_pwd: String,
pub db_name: String,
pub db_port: String,
pub server_host: String,
pub crypt_key: String,
pub default_admin_username: String,
pub default_admin_password: String,
}
static RELEASE_MODES: [&str; 3] = ["debug", "test", "prod"];
pub fn get_release_mode() -> String {
......@@ -25,3 +39,39 @@ pub fn get_log_level() -> String {
_ => String::from("info"),
}
}
impl Env {
pub fn new() -> Env {
Env {
release_mode: get_release_mode(),
db_username: env::var("DB_USERNAME").expect("DB_USERNAME is not defined."),
db_user_pwd: env::var("DB_USER_PASSWORD").expect("DB_USER_PASSWORD is not defined."),
db_name: env::var("DATABASE_NAME").expect("DATABASE_NAME is not defined."),
db_port: env::var("DB_PORT").expect("DB_PORT is not defined."),
server_host: env::var("SERVER_HOST").expect("SERVER_HOST is not defined"),
crypt_key: env::var("CRYPT_KEY").expect("CRYPT_KEY is not defined."),
default_admin_username: env::var("DEFAULT_ADMIN_USERNAME")
.expect("DEFAULT_ADMIN_USERNAME is not defined"),
default_admin_password: env::var("DEFAULT_ADMIN_PASSWORD")
.expect("DEFAULT_ADMIN_PASSWORD is not defined"),
}
}
#[cfg(test)]
/// Returns an instance with some values adjusted for testing such as email addresses
pub fn for_test() -> Env {
Env {
release_mode: String::from("debug"),
db_username: env::var("DB_USERNAME").expect("DB_USERNAME is not defined."),
db_user_pwd: env::var("DB_USER_PASSWORD").expect("DB_USER_PASSWORD is not defined."),
db_name: env::var("DATABASE_NAME").expect("DATABASE_NAME is not defined."),
db_port: env::var("DB_PORT").expect("DB_PORT is not defined."),
server_host: env::var("SERVER_HOST").expect("SERVER_HOST is not defined"),
crypt_key: env::var("CRYPT_KEY").expect("CRYPT_KEY is not defined."),
default_admin_username: env::var("DEFAULT_ADMIN_USERNAME")
.expect("DEFAULT_ADMIN_USERNAME is not defined"),
default_admin_password: env::var("DEFAULT_ADMIN_PASSWORD")
.expect("DEFAULT_ADMIN_PASSWORD is not defined"),
}
}
}
use crate::model::Administrator;
use crate::AppState;
use wither::{bson::doc, prelude::Model};
/// Creates the default administrator if it doesn't already exists and returns a Result.
pub async fn create_default_admin_if_none(app_state: &AppState) -> Result<(), String> {
let admin_username = app_state.env.default_admin_username.to_owned();
let admin_password = app_state.env.default_admin_password.to_owned();
let admin = Administrator::from_values(app_state, admin_username, admin_password);
let admin_doc = doc! {
"username": &admin.username,
"password_hash": &admin.password_hash
};
match Administrator::find_one(&app_state.db, admin_doc, None).await {
Ok(found_user) => match found_user {
Some(_) => Ok(()),
None => {
println!("Kuadrado admin will be created");
match app_state
.db
.collection_with_type::<Administrator>("administrators")
.insert_one(admin, None)
.await
{
Ok(_) => Ok(()),
Err(e) => Err(format!("Error creating administrator: {:?}", e)),
}
}
},
Err(e) => Err(format!("Error creating administrator: {:?}", e)),
}
}
//! # REST API server for the Mentalo application
//! # WEB SERVER FOR THE KUADRADO SOFTWARE WEBSITE
mod app_state;
mod crypto;
mod env;
mod init_admin;
mod middleware;
mod model;
mod service;
mod standard_static_files;
mod tls;
mod view;
mod view_resource;
use actix_files::Files;
use actix_web::{
middleware::Logger,
web::{get, resource, to},
middleware::{normalize::TrailingSlash, Logger, NormalizePath},
web::{get, resource, scope, to, Data},
App, HttpResponse, HttpServer,
};
use actix_web_middleware_redirect_https::RedirectHTTPS;
use app_state::AppState;
use env::get_log_level;
use env_logger::Env;
use middleware::AuthenticatedAdminMiddleware;
use service::*;
use standard_static_files::{favicon, robots, sitemap};
use std::env::var as env_var;
use tls::get_tls_config;
use view::get_view;
use view_resource::{ViewResourceDescriptor, ViewResourceManager};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
......@@ -25,6 +38,8 @@ async fn main() -> std::io::Result<()> {
std::path::PathBuf::from(env_var("RESOURCES_DIR").expect("RESOURCES_DIR is not defined"))
.join("public");
let app_state = AppState::with_default_admin_user().await;
HttpServer::new(move || {
App::new()
.wrap(Logger::default())
......@@ -33,15 +48,68 @@ async fn main() -> std::io::Result<()> {
format!(":{}", server_port),
format!(":{}", server_port_tls),
)]))
// .wrap(NormalizePath::new(TrailingSlash::Trim))
// Allow json payload to have size until ~32MB
// .app_data(JsonConfig::default().limit(1 << 25u8))
.app_data(Data::new(app_state.clone()))
.app_data(Data::new(AuthenticatedAdminMiddleware::new(
"kuadrado-admin-auth",
)))
.app_data(Data::new(ViewResourceManager::with_views(vec![
ViewResourceDescriptor {
path_str: "admin-panel",
index_file_name: "index.html",
resource_name: "admin-panel",
apply_auth_middleware: true,
},
ViewResourceDescriptor {
path_str: "admin-login",
index_file_name: "index.html",
resource_name: "admin-login",
apply_auth_middleware: false,
},
ViewResourceDescriptor {
path_str: "404",
index_file_name: "404.html",
resource_name: "404",
apply_auth_middleware: false,
},
ViewResourceDescriptor {
path_str: "unauthorized",
index_file_name: "unauthorized.html",
resource_name: "unauthorized",
apply_auth_middleware: false,
},
])))
.wrap(NormalizePath::new(TrailingSlash::Trim))
// .app_data(JsonConfig::default().limit(1 << 25u8)) // Allow json payload to have size until ~32MB
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// REST API /////////////////////////////////////////////////////////////////////////////////////////////////
.service(admin_authentication)
.service(post_article)
.service(update_article)
.service(delete_article)
.service(get_articles_by_category)
.service(get_article)
.service(get_article_by_title)
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// STANDARD FILES ///////////////////////////////////////////////////////////////////////////////////////////
.service(resource("/favicon.ico").route(get().to(favicon)))
.service(resource("/robots.txt").route(get().to(robots)))
.service(resource("/sitemap.xml").route(get().to(sitemap)))
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// VIEWS ////////////////////////////////////////////////////////////////////////////////////////////////////
.service(
scope("/v")
.service(Files::new(
"/admin-panel/assets",
public_dir.join("views/admin-panel/assets"),
))
.service(Files::new(
"/admin-login/assets",
public_dir.join("views/admin-login/assets"),
))
// get_view will match any url to we put it at last
.service(get_view),
)
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// PUBLIC WEBSITE //////////////////////////////////////////////////////////////////////////////////////////////
.service(Files::new("/", &public_dir).index_file("index.html"))
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
......
use crate::{
model::{AdminAuthCredentials, Administrator},
AppState,
};
use actix_web::{cookie::SameSite, http::Cookie, web::Form, HttpMessage, HttpRequest};
use wither::{bson::doc, prelude::Model};
/// Returns a Secure actix_web::http::Cookie.
pub fn get_auth_cookie(name: &'static str, value: String) -> Cookie<'static> {
Cookie::build(name, value)
.secure(true)
.http_only(true)
.same_site(SameSite::Strict)
.path("/")
.finish()
}
/// This is not a real middleware as it is meant to be executed only after having processed the request and not before.
/// It must be registered in the actix App instance with app_data.
/// ```
/// App::new()
/// .app_data(Data::new(AuthenticatedAdminMiddleware::new("some-auth-cookie-name")))
/// ```
/// If a service need to perform an authentication before doing anything, this "pseudo-middleware" should be run before anything else in the function.
/// Example:
/// ```
/// #[post("/some-url")]
/// pub async fn some_service(
/// app_state: Data<AppState>,
/// middleware: Data<AuthenticatedAdminMiddleware<'_>>,
/// req: HttpRequest,
/// ) -> impl Responder {
/// if middleware.exec(&app_state, &req, None).await.is_err() {
/// return HttpResponse::Unauthorized().finish();
/// }
/// ... Authenticated action ....
/// }
/// ```
#[derive(Debug, Clone)]
pub struct AuthenticatedAdminMiddleware<'a> {
/// The name of the authentication cookie
pub cookie_name: &'a str,
}
impl<'a> AuthenticatedAdminMiddleware<'a> {
pub fn new(cookie_name: &'a str) -> Self {
AuthenticatedAdminMiddleware { cookie_name }
}
/// Performs Administrator authentication from form data with username and password
/// Returns an authentication Cookie instance if the authentication succeeds, or an error.
async fn try_auth_from_form_data(
&self,
app_state: &AppState,
form_data: Form<AdminAuthCredentials>,
) -> Result<Cookie<'static>, ()> {
match Administrator::authenticated(app_state, form_data.into_inner()).await {
Ok(ref mut admin) => {
let auth_token = app_state.encryption.random_ascii_lc_string(256);
admin.auth_token = Some(app_state.encryption.encrypt(&auth_token));
if admin
.save(&app_state.db, Some(doc!("_id": admin.id().unwrap())))
.await
.is_err()
{
println!("Failed to update admin auth_token");
return Err(());
}
let cookie = get_auth_cookie("kuadrado-admin-auth", auth_token.to_owned());
return Ok(cookie);
}
Err(_) => return Err(()),
}
}
/// Performs Administrator authentication from the authentication cookie value
async fn try_auth_from_auth_cookie(
&self,
app_state: &AppState,
cookie: &Cookie<'static>,
) -> Result<Cookie<'static>, ()> {
match Administrator::authenticated_with_cookie(app_state, &cookie).await {
Ok(_) => return Ok(cookie.clone()),
Err(_) => return Err(()),
}
}
/// The function that must be called in order to execute the verification.
/// Example :
/// ```
/// #[post("/some-url")]
/// pub async fn some_service(
/// app_state: Data<AppState>,
/// middleware: Data<AuthenticatedAdminMiddleware<'_>>,
/// req: HttpRequest,
/// ) -> impl Responder {
/// if middleware.exec(&app_state, &req, None).await.is_err() {
/// return HttpResponse::Unauthorized().finish();
/// }
/// ... Authenticated actions ....
/// }
/// ```
pub async fn exec(
&self,
app_state: &AppState,
req: &HttpRequest,
auth_form_data: Option<Form<AdminAuthCredentials>>,
) -> Result<Cookie<'static>, ()> {
let auth_cookie = req.cookie(self.cookie_name);
if let Some(form_data) = auth_form_data {
return self.try_auth_from_form_data(app_state, form_data).await;
} else if let Some(cookie) = auth_cookie {
return self.try_auth_from_auth_cookie(app_state, &cookie).await;
} else {
return Err(());
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment