First commit

This commit is contained in:
jacob.eva 2023-03-08 14:57:27 +00:00
commit 937d9ab266
No known key found for this signature in database
GPG Key ID: 0B92E083BBCCAA1E
12 changed files with 659 additions and 0 deletions

6
.gitignore vendored Executable file
View File

@ -0,0 +1,6 @@
/target
Cargo.lock
.ycm_extra_conf.py
.vimspector.json
tls
.cargo

15
Cargo.toml Executable file
View File

@ -0,0 +1,15 @@
[package]
name = "foxtrot_mfa"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rocket = { version = "0.5.0-rc.2", features = ["mtls"] }
sqlx = { version = "0.6", features = [ "runtime-async-std-native-tls", "mysql" ] }
argon2 = "0.4.1"
rand = { version = "0.8", features = ["std"] }
openssl = "0.10.42"
totp-rs = "3.1.0"
data-encoding = "1.1.0"

23
README.md Normal file
View File

@ -0,0 +1,23 @@
# Foxtrot MFA
This application is an example implementation of 3 methods of authentication, all of which must be satisfied in order to be granted access.
These methods are:
* Username & password
* Time based one time password
* Mutual TLS certificate
## Setup
Create SQL database:
`sudo mysql -p < migrations/20221017163745_users.sql`
Create TLS directory:
`mkdir tls`
Generate TLS certs:
`openssl req -x509 -newkey rsa:4096 -keyout tls/key.pem -out tls/cert.pem -sha256 -days 365 -nodes`
Generate CA:
`openssl genrsa -out tls/ca.pem 4096
openssl req -x509 -new -sha512 -nodes -key tls/ca.pem -days 365 -out tls/ca.crt`
And fill in the options as required.

7
Rocket.toml Normal file
View File

@ -0,0 +1,7 @@
[default.tls]
key = "tls/key.pem"
certs = "tls/cert.pem"
[default.tls.mutual]
ca_certs = "tls/ca_crt.pem"
mandatory = false

View File

@ -0,0 +1,28 @@
drop database if exists foxtrot_mfa;
create database foxtrot_mfa;
grant all privileges on foxtrot_mfa.* to foxtrot@localhost;
use foxtrot_mfa;
create table users
(
id uuid DEFAULT uuid() PRIMARY KEY,
username varchar(255),
password varchar(255),
otpSecret varchar(255),
creationTime timestamp
);
create table certs
(
id uuid DEFAULT uuid() PRIMARY KEY,
userID uuid,
FOREIGN KEY (userID) REFERENCES users(id),
pkcs12 BLOB,
creationTime timestamp
);
insert into users ( id, username, password) values ( uuid(), "test", "test");

181
src/api.rs Executable file
View File

@ -0,0 +1,181 @@
use rocket::form::Form; // for creating form struct
use rocket::State;
use rocket::fs::NamedFile;
use sqlx::mysql::MySqlPool;
use std::fs::File;
use std::io::prelude::*;
use rocket::mtls::Certificate;
use rocket::response::content::RawHtml;
use rocket::http::ContentType;
#[derive(FromForm)]
pub struct RegisterForm {
username: String,
password: String,
confirmpassword: String,
}
#[derive(FromForm)]
pub struct LoginForm {
username: String,
password: String,
otp: String,
}
pub struct Pool(pub MySqlPool);
/* This function takes the user's submitted registration form and adds them to the users table of
* the database if the user's password and confirmation password are equal. If there are any
* errors during the database operation, the error is safely handled. */
#[post("/user/create", data = "<register_form>")]
pub async fn user_create(register_form: Form<RegisterForm>, pool: &State<Pool>) -> RawHtml<String>
{
if !register_form.password.eq(&register_form.confirmpassword) // if the password and the
// confirmation password aren't
// the same
{
let response = format!("Password and confirmation password are not the same.");
RawHtml(response) // return error to user
}
else
{
let hash_result = super::database::hash_pass(&register_form.password).await; // hash password
let hash = match hash_result { // if the function returned a hash, then save it, if it
// returned an error then show the error to the user
Ok(hash) => hash,
Err(e) => {
return RawHtml(format!("Error hashing password, {}", e)) // return error to the user
}
};
// add user
let uuid_result = super::database::add_user(&register_form.username, &hash, &pool.0).await;
let uuid = match uuid_result { // if the function returned an id, then save it, if it returned an error
// then show the error to the user
Ok(uuid) => uuid,
Err(e) => return RawHtml(format!("Error adding user, {}", e)) // return error to the user
};
let otp_result = super::database::create_otp(&uuid, &pool.0).await;
let otp = match otp_result {
Ok(otp) => otp,
Err(e) => return RawHtml(format!("Error generating OTP, {}", e))
};
let response = format!("<html><body><p>Your OTP secret is: {}</p>\n<p>Please click <a href='/user/{}/cert.p12'>here</a> to download your p12 file.</p></body</html>", otp, uuid);
RawHtml(response)
}
}
/* This function is absolutely bloody awful right now but I have no idea how to generate a
* reference to a file without actually writing to the hard disk :( */
#[get("/user/<uuid>/cert.p12")]
pub async fn user_cert(uuid: String, pool: &State<Pool>) -> (ContentType, NamedFile)
{
let cert_result = super::database::create_cert(&uuid, &pool.0).await;
let cert = match cert_result {
Ok(cert) => cert,
Err(e) => panic!("Error creating cert, {}", e)
};
let cert_file_result = File::create("/tmp/browser.p12");
let mut cert_file = match cert_file_result {
Ok(cert_file) => cert_file,
Err(e) => panic!("Error creating cert file, {}", e)
};
let cert_der = match cert.to_der() {
Ok(cert_der) => cert_der,
Err(e) => panic!("Error converting cert to der, {}", e)
};
let write_result = cert_file.write_all(&cert_der[..]);
match write_result {
Ok(_w) => _w,
Err(e) => panic!("Error writing to cert file, {}", e)
};
let named_cert_result = NamedFile::open("/tmp/browser.p12").await;
let named_cert = match named_cert_result {
Ok(named_cert) => named_cert,
Err(e) => panic!("Error converting cert to der, {}", e)
};
let content_type = ContentType::new("application", "x-pkcs12");
(content_type, named_cert)
}
#[post("/user/login", data = "<login_form>")]
pub async fn user_login(_cert: Certificate<'_>, login_form: Form<LoginForm>, pool: &State<Pool>) -> String
{
let hash_result = super::database::retrieve_hash(&login_form.username, &pool.0).await;
let hash_result_value = match hash_result {
Ok(hash_result_value) => hash_result_value,
Err(e) => return format!("Error, username doesn't exist: {}", e)
};
let comparison_result = super::database::verify_pass(&login_form.password, &hash_result_value).await;
let comparison_result_value = match comparison_result {
Ok(comparison_result_value) => comparison_result_value,
Err(e) => return format!("Error when comparing hash and password: {}", e)
};
let otp_result = super::database::verify_otp(&login_form.username, &login_form.otp, &pool.0).await;
let otp_result_value = match otp_result {
Ok(result_value) => result_value,
Err(e) => return format!("Error when verifying otp: {}", e)
};
if otp_result_value && comparison_result_value
{
return format!("You've successfully authenticated!");
}
else
{
return format!("Incorrect login.");
}
}
#[cfg(test)]
mod main_tests {
use super::super::rocket;
use rocket::local::asynchronous::Client;
use rocket::http::Status;
use rocket::http::ContentType;
/* This unit test checks if the /register route outputs that the password and confirmation
* password are not equal when provided with two different inputs for those fields. */
#[rocket::async_test]
async fn register_test() {
let client = Client::tracked(super::super::rocket().await).await.expect("Valid instance");
let response = client
.post("/register")
.body("username=test&password=1&confirmpassword=2")
.header(ContentType::Form)
.dispatch()
.await;
assert_eq!(response.status(), Status::Ok);
assert_eq!(response.into_string().await.unwrap(), "Password and confirmation password are not equal.");
}
/* This unit test checks if the connection to the database works properly */
#[rocket::async_test]
async fn pool_test() {
let pool_result = super::super::database::connect().await;
match pool_result {
Ok(_p) => assert!(true),
Err(_e) => assert!(false)
};
}
}

98
src/cert.rs Normal file
View File

@ -0,0 +1,98 @@
use openssl::asn1::Asn1Time;
use openssl::bn::{BigNum, MsbOption};
use openssl::error::ErrorStack;
use openssl::hash::MessageDigest;
use openssl::pkey::{PKey, Private};
use openssl::rsa::Rsa;
use openssl::x509::extension::{
AuthorityKeyIdentifier, BasicConstraints, KeyUsage, SubjectAlternativeName,
SubjectKeyIdentifier, ExtendedKeyUsage
};
use openssl::x509::{X509NameBuilder, X509Req, X509ReqBuilder, X509};
use openssl::pkcs12::Pkcs12;
async fn create_req(key_pair: &PKey<Private>) -> Result<X509Req, ErrorStack>
{
let mut req_builder = X509ReqBuilder::new()?;
req_builder.set_pubkey(key_pair)?;
let mut x509_name = X509NameBuilder::new()?;
x509_name.append_entry_by_text("C", "GB")?;
x509_name.append_entry_by_text("ST", "Cornwall")?;
x509_name.append_entry_by_text("O", "Foxtrot MFA Corp.")?;
x509_name.append_entry_by_text("CN", "foxtrot.mfa")?;
let x509_name = x509_name.build();
req_builder.set_subject_name(&x509_name)?;
req_builder.sign(key_pair, MessageDigest::sha256())?;
let req = req_builder.build();
Ok(req)
}
pub async fn create_ca_signed_cert(ca_cert: &X509, ca_key_pair: &PKey<Private>) -> Result<(X509, PKey<Private>), ErrorStack>
{
let rsa = Rsa::generate(4096)?;
let key_pair = PKey::from_rsa(rsa)?;
let req = create_req(&key_pair).await?;
let mut cert_builder = X509::builder()?;
cert_builder.set_version(2)?;
let serial_number = {
let mut serial = BigNum::new()?;
serial.rand(159, MsbOption::MAYBE_ZERO, false)?;
serial.to_asn1_integer()?
};
cert_builder.set_serial_number(&serial_number)?;
cert_builder.set_subject_name(req.subject_name())?;
cert_builder.set_issuer_name(ca_cert.subject_name())?;
cert_builder.set_pubkey(&key_pair)?;
let not_before = Asn1Time::days_from_now(0)?;
cert_builder.set_not_before(&not_before)?;
let not_after = Asn1Time::days_from_now(365)?;
cert_builder.set_not_after(&not_after)?;
cert_builder.append_extension(BasicConstraints::new().build()?)?;
cert_builder.append_extension(
KeyUsage::new()
.critical()
.non_repudiation()
.digital_signature()
.key_encipherment()
.build()?,
)?;
cert_builder.append_extension(
ExtendedKeyUsage::new()
.client_auth()
.build()?,
)?;
let subject_key_identifier =
SubjectKeyIdentifier::new().build(&cert_builder.x509v3_context(Some(ca_cert), None))?;
cert_builder.append_extension(subject_key_identifier)?;
let auth_key_identifier = AuthorityKeyIdentifier::new()
.keyid(false)
.issuer(false)
.build(&cert_builder.x509v3_context(Some(ca_cert), None))?;
cert_builder.append_extension(auth_key_identifier)?;
let subject_alt_name = SubjectAlternativeName::new()
.dns("foxtrot.mfa")
.build(&cert_builder.x509v3_context(Some(ca_cert), None))?;
cert_builder.append_extension(subject_alt_name)?;
cert_builder.sign(ca_key_pair, MessageDigest::sha256())?;
let cert = cert_builder.build();
Ok((cert, key_pair))
}
pub async fn create_pkcs12(cert: &X509, key: &PKey<Private>) -> Result<Pkcs12, ErrorStack>
{
let pkcs12_builder = Pkcs12::builder();
let pkcs12_keystore = pkcs12_builder.build("", "browser", key, cert)?;
Ok(pkcs12_keystore)
}

231
src/database.rs Executable file
View File

@ -0,0 +1,231 @@
use sqlx::mysql::MySqlPool; // for connecting to db
use argon2::Argon2;
use std::{env, fs};
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use rand::rngs::OsRng;
use rand::Rng;
use openssl::error::ErrorStack;
use openssl::pkey::PKey;
use openssl::x509::X509;
use totp_rs::{Algorithm, TOTP, Secret};
use openssl::pkcs12::Pkcs12;
use data_encoding::base32;
pub async fn connect() -> Result<MySqlPool, sqlx::Error>
{
let url = env!("DATABASE_URL");
let pool = MySqlPool::connect(url).await?;
Ok(pool)
}
pub async fn hash_pass(password: &String) -> Result<String, argon2::password_hash::Error> {
let argon2 = Argon2::default(); // init new instance of argon2 hashing
let password_u8 = password.as_bytes(); // get value of password as bytes
let salt = SaltString::generate(&mut OsRng); // generate salt
let hash = argon2.hash_password(password_u8, &salt)?.to_string(); // generate hash of password using salt
Ok(hash) // return hash
}
pub async fn verify_pass(password: &String, hash: &String) -> Result<bool, argon2::password_hash::Error> {
let argon2 = Argon2::default();
let password_u8 = password.as_bytes();
let hash_parsed = PasswordHash::new(hash)?;
let result = argon2.verify_password(password_u8, &hash_parsed).is_ok();
if result
{
return Ok(true);
}
else
{
return Ok(false);
}
}
pub async fn retrieve_hash(username: &String, pool: &MySqlPool) -> Result<String, sqlx::Error>
{
let result = sqlx::query!(
r#"
select password from users
where username = ?
"#,
username)
.fetch_one(pool)
.await?;
let hash: String = result.password.unwrap();
Ok(hash)
}
pub async fn add_user(username: &String, hash: &String, pool: &MySqlPool) -> Result<String, sqlx::Error>
{
sqlx::query!(
r#"
insert into users ( username, password )
values ( ?, ? )
"#,
username,
hash
)
.execute(pool)
.await?;
let user_id_result = sqlx::query!(
r#"
select id from users
where username=?
"#,
username
)
.fetch_one(pool)
.await?;
Ok(user_id_result.id)
}
pub async fn create_otp(uuid: &String, pool: &MySqlPool) -> Result<String, sqlx::Error>
{
let mut secret = String::from("");
for _ in 1..22
{
let mut rng = rand::thread_rng();
let letter: char = rng.gen_range(b'a'..b'z') as char;
secret.push(letter);
}
let base32_secret = base32::encode(secret.as_bytes());
let trimmed_secret = base32_secret.replace("=", ""); // trim the equals from the base32 encoded
// string
sqlx::query!(
r#"
update users
set otpSecret = ?
where id = ?
"#,
trimmed_secret, // store base32 encoded secret
uuid
)
.execute(pool)
.await?
.rows_affected();
return Ok(trimmed_secret);
}
pub async fn verify_otp(username: &String, otp_code: &String, pool: &MySqlPool) -> Result<bool, sqlx::Error>
{
let otp_result = sqlx::query!(
r#"
select otpSecret from users
where username = ?
"#,
username
)
.fetch_one(pool)
.await?;
let otp_secret: String = otp_result.otpSecret.unwrap();
let totp = TOTP::new(
Algorithm::SHA1,
6,
1,
30,
Secret::Encoded(otp_secret.to_string()).to_bytes().unwrap(),
).unwrap();
let expected_code = totp.generate_current().unwrap();
if otp_code.eq(&expected_code)
{
return Ok(true)
}
else
{
return Ok(false)
}
}
pub async fn create_cert(uuid: &String, pool: &MySqlPool) -> Result<Pkcs12, ErrorStack>
{
let ca_crt_pem_result = fs::read_to_string("tls/ca_crt.pem");
let ca_crt_pem = match ca_crt_pem_result {
Ok(ca_crt_pem) => ca_crt_pem,
Err(e) => panic!("{}", e)
};
let ca_key_pem_result = fs::read_to_string("tls/ca_key.pem");
let ca_key_pem = match ca_key_pem_result {
Ok(ca_key_pem) => ca_key_pem,
Err(e) => panic!("{}", e)
};
let ca_crt_pem_obj = X509::from_pem(ca_crt_pem.as_bytes())?;
let ca_key_pem_obj = PKey::private_key_from_pem(ca_key_pem.as_bytes())?;
let cert_and_key = super::cert::create_ca_signed_cert(&ca_crt_pem_obj, &ca_key_pem_obj).await?;
let cert = cert_and_key.0;
let key = cert_and_key.1;
let pkcs12_keystore = super::cert::create_pkcs12(&cert, &key).await?;
let pkcs12_der = match pkcs12_keystore.to_der() {
Ok(pkcs12_der) => pkcs12_der,
Err(e) => panic!("{}", e)
};
let cert_id_result = sqlx::query!(
r#"
insert into certs ( userID, pkcs12 )
values ( ?, ? )
"#,
uuid,
&pkcs12_der[..]
)
.execute(pool)
.await;
let _o = match cert_id_result {
Ok(_o) => _o,
Err(e) => panic!("{}", e)
};
Ok(pkcs12_keystore)
}
#[cfg(test)]
mod database_tests {
use sqlx::mysql::MySqlPool;
/* This unit test checks if the add user functionality successfully inserts the record
* into the database */
#[sqlx::test]
async fn add_user_test(pool: MySqlPool) {
use super::*;
let username = String::from("test");
let password = String::from("test");
let id_result = add_user(&username, &password, &pool).await; // add test user to database
match id_result { // panic if add user returns an error
Ok(_p) => assert!(true),
Err(_e) => assert!(false)
};
}
/* This unit test checks if the connection to the database works properly */
#[rocket::async_test]
async fn pool_test() {
use super::*;
let pool_result = connect().await; // connect to the database
match pool_result { // panic if connect returns an error
Ok(_p) => assert!(true),
Err(_e) => assert!(false)
};
}
}

24
src/main.rs Executable file
View File

@ -0,0 +1,24 @@
#[macro_use] extern crate rocket;
use rocket::fs::{FileServer, relative}; // for serving static dir
mod database;
mod api;
mod cert;
#[launch]
pub async fn rocket() -> _ {
let pool_result = database::connect().await;
let pool = match pool_result {
Ok(pool) => pool,
Err(e) => panic!("Error with database connection, {}", e)
};
rocket::build()
.manage(api::Pool(pool))
.mount("/", FileServer::from(relative!("static")))
.mount("/", routes![api::user_create])
.mount("/", routes![api::user_login])
.mount("/", routes![api::user_cert])
}

14
static/index.html Executable file
View File

@ -0,0 +1,14 @@
<!DOCTYPE HTML>
<head>
<title>Foxtrot MFA system</title>
</head>
<body>
<h1>Welcome</h1>
<a href="register.html">
<button>Register</button>
</a>
<a href="login.html">
<button>Login</button>
</a>
</body>

16
static/login.html Executable file
View File

@ -0,0 +1,16 @@
<!DOCTYPE HTML>
<head>
<title>Foxtrot MFA system</title>
</head>
<body>
<form action="user/login" method="post">
<label for="username">Username:</label>
<input type="text" id="username" name="username"/><br>
<label for="username">Password:</label>
<input type="password" id="password" name="password"/><br>
<label for="username">OTP:</label>
<input type="password" id="otp" name="otp"/><br>
<input type="submit" value="Submit"/>
</form>
</body>

16
static/register.html Executable file
View File

@ -0,0 +1,16 @@
<!DOCTYPE HTML>
<head>
<title>Foxtrot MFA system</title>
</head>
<body>
<form action="user/create" method="post">
<label for="username">Username:</label>
<input type="text" id="username" name="username"/><br>
<label for="username">Password:</label>
<input type="password" id="password" name="password"/><br>
<label for="username">Confirm password:</label>
<input type="password" id="confirmpassword" name="confirmpassword"/><br>
<input type="submit" value="Submit"/>
</form>
</body>