Enroll FIDO key: implement end-to-end flow

But it's broken :(
This commit is contained in:
Félix Baylac Jacqué 2023-11-19 17:02:40 +01:00
parent 1acb78fdd5
commit 9a85a5cd76
16 changed files with 253 additions and 98 deletions

23
Cargo.lock generated
View File

@ -1078,7 +1078,17 @@ dependencies = [
]
[[package]]
name = "nix-cache-bucket-gc"
name = "nom"
version = "7.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "nom-nom-gc"
version = "0.1.0"
dependencies = [
"actix-web",
@ -1096,16 +1106,6 @@ dependencies = [
"webauthn-rs",
]
[[package]]
name = "nom"
version = "7.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-bigint"
version = "0.4.3"
@ -1357,6 +1357,7 @@ dependencies = [
"bytes",
"fallible-iterator",
"postgres-protocol",
"uuid",
]
[[package]]

View File

@ -1,5 +1,5 @@
[package]
name = "nix-cache-bucket-gc"
name = "nom-nom-gc"
version = "0.1.0"
edition = "2021"
@ -15,7 +15,15 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
handlebars = "4.3.6"
deadpool-postgres = "0.11.0"
tokio-postgres = "0.7.10"
tokio-postgres = { version = "0.7.10", features = ["with-uuid-1"] }
anyhow = "1.0.75"
refinery = { version = "0.8.11", features = ["tokio-postgres"] }
uuid = { version = "1.4.1", features = ["v4"] }
[[bin]]
name = "nom-nom-gc-server"
path = "src/server/bin/main.rs"
[[bin]]
name = "nom-nom-gc-cli"
path = "src/cli/bin/main.rs"

View File

@ -1,7 +1,6 @@
CREATE TABLE Users (
id uuid DEFAULT uuid_generate_v4 (),
id uuid,
user_name text NOT NULL,
display_name text NOT NULL,
PRIMARY KEY (id)
);
@ -12,5 +11,10 @@ CREATE TABLE Keys (
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES Users(id)
);
CREATE TABLE PendingRegistrations (
id uuid,
user_name text NOT NULL
);
-- We'll mostly querying the Keys using the associated uid.
CREATE INDEX idx_keys_uid ON Keys USING HASH (user_id);

4
nom-nom-gc Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
source /tmp/nom-nom-dev-args
cargo run --bin nom-nom-gc-cli -- -c "${cfgfile}" $@

4
psql Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
source /tmp/nom-nom-dev-args
psql -h "${host}" -p "${port}" -d "${dbname}"

View File

@ -1,5 +1,7 @@
#!/usr/bin/env bash
set -eau
dbname="nomnomdev"
port="12345"
@ -10,20 +12,28 @@ trap 'rm -rf ${dbdir}' EXIT
initdb "$dbdir"
postgres -D "${dbdir}" -c unix_socket_directories="${dbdir}" -c listen_addresses= -c port="${port}" &
pgpid=$!
trap 'rm -rf ${dbdir} && kill ${pgpid}' EXIT
# Trick to help the "./psql" script to find the DB dir & co
cat <<EOF > "/tmp/nom-nom-dev-args"
export host="$dbdir"
export port="$port"
export dbname="$dbname"
export cfgfile="$cfgfile"
EOF
trap 'rm -rf ${dbdir} && rm /tmp/nom-nom-dev-args && kill ${pgpid}' EXIT
# Yeah, this is very meh. We need to wait for the server to be ready
#to receive requests to create the DB.
sleep 2
createdb -h "${dbdir}" -p "${port}" "${dbname}"
cat <<EOF > "${cfgfile}"
{
"url": "http://localhost",
"url": "http://localhost:8001",
"db_host": "${dbdir}",
"db_port": ${port},
"db_name": "${dbname}"
}
EOF
echo "hello"
cat "${cfgfile}"
cargo run -- --bind "[::1]:8001" --config "${cfgfile}"
cargo run --bin nom-nom-gc-server -- --bind "[::1]:8001" --config "${cfgfile}"

42
src/cli/bin/main.rs Normal file
View File

@ -0,0 +1,42 @@
use anyhow::Result;
use clap::{Parser, Subcommand, Args};
use nom_nom_gc::models::{read_config, self, AppState, User, Configuration};
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct CLIArgs {
#[arg(short, long)]
config: Option<String>,
#[command(subcommand)]
command: Command
}
#[derive(Subcommand, Debug)]
enum Command {
RegisterUser(RegisterUserArgs)
}
#[derive(Args, Debug)]
struct RegisterUserArgs {
username: String
}
#[tokio::main]
async fn main() -> Result<()> {
let args = CLIArgs::parse();
let config = read_config(&args.config.unwrap_or("/etc/nom-nom-gc/config.json".to_owned()))
.unwrap_or_else(|e| panic!("Cannot read config file: {}", e.to_string()));
// todo: don't consume config in appstate new
let state = models::AppState::new(config.clone());
match args.command {
Command::RegisterUser(args) => register_user(args.username, state, config).await,
}
}
async fn register_user(user_name: String, state: AppState<'_>, conf: Configuration) -> Result<()> {
let user = User { user_name };
let uuid = state.generate_registration_link(user).await?;
println!("Registration link: {}/account/register/{}", &conf.url, &uuid.to_string());
Ok(())
}

View File

@ -10,6 +10,27 @@ pub async fn landing_page (app_state: web::Data<AppState<'_>>) -> HttpResponse {
.body(content)
}
/// Page you get after clicking a register-user link.
///
/// The path contains the registration Uuid segment.
pub async fn webauthn_registration(app_state: web::Data<AppState<'_>>, path: web::Path<String>) -> HttpResponse {
let requested_uuid = match Uuid::parse_str(&path.into_inner()) {
Ok(p) => p,
Err(_) => return HttpResponse::from_error(error::ErrorBadRequest("This registration token is invalid: invalid UUID")),
};
let registration_link = app_state.retrieve_registration_link(requested_uuid).await;
let user = match registration_link {
Ok(Some(user)) => user,
Ok(None) => return HttpResponse::from_error(error::ErrorBadRequest("This registration token is invalid: cannot find it in the database")),
Err(e) => return HttpResponse::from_error(error::ErrorInternalServerError(e.to_string())),
};
let response = templates::register_user_start(app_state.hbs.clone(), requested_uuid, user.user_name).unwrap();
HttpResponse::Ok()
.content_type(ContentType::html())
.body(response)
}
/// First phase of the webauthn key enrolling.
///
/// For now, we don't save anything to the DB. We just generate the
@ -17,7 +38,7 @@ pub async fn landing_page (app_state: web::Data<AppState<'_>>) -> HttpResponse {
/// server-side, in the response and cookie on the client-side.
pub async fn start_webauthn_registration(app_state: web::Data<AppState<'_>>, user: web::Json<User>) -> HttpResponse {
let uuid = Uuid::new_v4();
let (creation_challenge_response, passkey_registration) = app_state.webauthn.start_passkey_registration(uuid, &user.user_name, &user.display_name, None).unwrap();
let (creation_challenge_response, passkey_registration) = app_state.webauthn.start_passkey_registration(uuid, &user.user_name, &user.user_name, None).unwrap();
let uuid_str = uuid.to_string();
{
let mut user_registrations = app_state.session.user_registrations.write().await;

View File

@ -1,18 +1,20 @@
use serde::{Deserialize, Serialize};
use url::Url;
use std::collections::HashMap;
use std::fs;
use std::ops::DerefMut;
use std::sync::Arc;
use std::collections::HashMap;
use url::Url;
use anyhow::Result;
use anyhow::{Result, Context};
use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod};
use handlebars::Handlebars;
use serde::{Deserialize, Serialize};
use serde_json;
use tokio::sync::RwLock;
use tokio_postgres::NoTls;
use webauthn_rs::prelude::{Uuid, PasskeyRegistration, Passkey};
use webauthn_rs::{Webauthn, WebauthnBuilder};
#[derive(Deserialize, Debug)]
#[derive(Deserialize, Debug, Clone)]
pub struct Configuration {
pub url: String,
pub db_host: Option<String>,
@ -20,6 +22,15 @@ pub struct Configuration {
pub db_name: String
}
pub fn read_config(config_path: &str) -> anyhow::Result<Configuration> {
let content = fs::read_to_string(config_path)
.with_context(|| format!("Cannot read the configuration file at {}.", config_path))?;
let res: Configuration = serde_json::from_str(&content)
.context("Cannot parse JSON configuration.")?;
Ok(res)
}
pub type DbField<T> = Arc<RwLock<T>>;
#[derive(Clone)]
@ -43,7 +54,6 @@ pub struct PendingRegistration {
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
pub struct User {
pub user_name: String,
pub display_name: String,
}
#[derive(Clone)]
@ -56,7 +66,7 @@ pub struct AppState<'a>{
mod embedded {
use refinery::embed_migrations;
embed_migrations!("src/sql/01-init.sql");
embed_migrations!();
}
impl AppState<'_> {
@ -89,10 +99,38 @@ impl AppState<'_> {
}
}
pub async fn run_migrations(&mut self) -> Result<()> {
/**
Generate a new registration uuid for the given `username` and
saves it to the DB.
*/
pub async fn generate_registration_link(&self, user: User) -> Result<Uuid> {
let uuid = Uuid::new_v4();
let mut conn = self.db.get().await?;
let client = conn.deref_mut();
let stmt = client.prepare_cached("INSERT INTO PendingRegistrations (id, user_name) VALUES ($1, $2)").await?;
client.query(&stmt, &[&uuid, &user.user_name]).await?;
Ok(uuid)
}
/**
Retrieves the `User` attached to the registration `Uuid`.
Returns `Some` `User` if it can find it, `None` if the `Uuid`
doesn't exist in the DB.
*/
pub async fn retrieve_registration_link(&self, uuid: Uuid) -> Result<Option<User>> {
let mut conn = self.db.get().await?;
let client = conn.deref_mut();
let stmt = client.prepare_cached("SELECT user_name FROM PendingRegistrations WHERE id=$1").await?;
let row = client.query_one(&stmt, &[&uuid]).await?;
Ok(Some(User{ user_name: row.get(0)}))
}
pub async fn run_migrations(&self) -> Result<()> {
let mut conn = self.db.get().await?;
let client = conn.deref_mut().deref_mut();
embedded::migrations::runner().run_async(client).await?;
let report = embedded::migrations::runner().run_async(client).await?;
println!("{:?}", report);
Ok(())
}
@ -100,8 +138,8 @@ impl AppState<'_> {
pub async fn save_user(&self, user: &User) -> Result<()> {
let mut conn = self.db.get().await?;
let client = conn.deref_mut();
let stmt = client.prepare_cached("INSERT INTO Users (user_name, display_name) VALUES ($1, $2, $3)").await?;
client.query(&stmt, &[&user.user_name, &user.display_name]);
let stmt = client.prepare_cached("INSERT INTO Users (user_name) VALUES ($1, $2, $3)").await?;
let _ = client.query(&stmt, &[&user.user_name]).await?;
Ok(())
}

View File

@ -1,16 +1,11 @@
use actix_web::{App, web, HttpServer};
use anyhow::{Result, Context};
use clap::Parser;
use models::Configuration;
use serde::Deserialize;
use serde_json;
use std::fs;
use std::net::SocketAddr;
mod app;
mod handlers;
mod models;
mod templates;
use actix_web::{App, web, HttpServer};
use clap::Parser;
use nom_nom_gc::handlers;
use nom_nom_gc::models::read_config;
use nom_nom_gc::models;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
@ -18,30 +13,29 @@ struct CLIArgs {
#[arg(short, long)]
bind: String,
#[arg(short, long)]
config: String
}
fn read_config(config_path: &str) -> Result<Configuration> {
let content = fs::read_to_string(config_path)
.with_context(|| format!("Cannot read the configuration file at {}.", config_path))?;
let res: Configuration = serde_json::from_str(&content)
.context("Cannot parse JSON configuration.")?;
Ok(res)
config: Option<String>
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let args = CLIArgs::parse();
let addr: SocketAddr = args.bind.parse().unwrap_or_else(|_| panic!("Cannot bind to {}. Please provide a host and port like [::1]:8000", &args.bind));
println!("Server listening to {}", &args.bind);
let config_path = args.config.unwrap_or("/etc/nom-nom-gc/config.json".to_owned());
let config = read_config(&config_path)
.unwrap_or_else(|e| panic!("Cannot read config file: {}", e.to_string()));
let migration_state = models::AppState::new(config);
println!("Running DB migrations");
migration_state.run_migrations().await.unwrap_or_else(|e| panic!("Db migration error: {}", e));
println!("Server listening to {}", &args.bind);
HttpServer::new(
move || {
let config = read_config(&args.config)
let config = read_config(&config_path)
.unwrap_or_else(|e| panic!("Cannot read config file: {}", e.to_string()));
let state = models::AppState::new(config);
App::new().app_data(web::Data::new(state))
.route("/", web::get().to(handlers::landing_page))
.route("/account/register/{uuid}", web::get().to(handlers::webauthn_registration))
.route("/account/register-init", web::post().to(handlers::start_webauthn_registration))
.route("/account/register-finish", web::post().to(handlers::finish_webauthn_registration))
})

View File

@ -1,29 +1,3 @@
<!DOCTYPE html>
<html>
<head>
<title>Nom Nom GC</title>
<style>{{> css}}</style>
<script
src="https://cdn.jsdelivr.net/npm/js-base64@3.7.4/base64.min.js"
integrity="sha384-VkKbwLiG7C18stSGuvcw9W0BHk45Ba7P9LJG5c01Yo4BI6qhFoWSa9TQLNA6EOzI"
crossorigin="anonymous">
</script>
</head>
<body>
<nav>
<ul>
<li><h1>Nom Nom GC</h1></li>
<li><a href="/">Home</a></li>
<li><a href="/projects">Projects</a></li>
<li><a href="/login">Login</a></li>
</nav>
<main>
<div id="main-container">
Hello world
</div>
</main>
<script>
{{> js}}
</script>
</body>
</html>
{{#> template }}
<p>Hello world, this is the nom nom S3 GC</p>
{{ /template }}

View File

@ -1,24 +1,45 @@
use handlebars::RenderError;
use serde_json::json;
use handlebars::Handlebars;
use uuid::Uuid;
use std::{path::PathBuf, sync::Arc};
pub fn new<'a>() -> Result<Handlebars<'a>, RenderError> {
let rootpath = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let landing_path = rootpath.join("src/templates/landing.hbs");
let template_path = rootpath.join("src/templates/template.hbs");
let register_user = rootpath.join("src/templates/register-user.hbs");
let mut hbs = handlebars::Handlebars::new();
let css = rootpath.join("src/templates/main.css");
let js = rootpath.join("src/templates/webauthn.js");
let webauthn_js = rootpath.join("src/templates/webauthn.js");
hbs.register_template_file("landing", landing_path.to_str().unwrap())?;
hbs.register_template_file("template", template_path.to_str().unwrap())?;
hbs.register_template_file("css", css.to_str().unwrap())?;
hbs.register_template_file("js", js.to_str().unwrap())?;
hbs.register_template_file("webauthn-js", webauthn_js.to_str().unwrap())?;
hbs.register_template_file("register-user", register_user.to_str().unwrap())?;
Ok(hbs)
}
pub fn landing_page(hb: Arc<Handlebars<'_>>) -> Result<String, RenderError> {
let data = json!({
});
hb.render("landing", &data)
}
pub fn register_user_start(hb: Arc<Handlebars<'_>>, uuid: Uuid, username: String) -> Result<String, RenderError> {
let uuid = uuid.to_string();
let js_data = json!({
"uuid": &uuid,
"username": username,
});
let js = hb.render("webauthn-js", &js_data)?;
let data = json!({
"js": js,
"uuid": &uuid,
});
hb.render("register-user", &data)
}

View File

@ -0,0 +1,3 @@
{{#> template js=js }}
<p>Registering {{ uuid }}</p>
{{ /template }}

View File

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html>
<head>
<title>Nom Nom GC</title>
<style>{{> css}}</style>
<script
src="https://cdn.jsdelivr.net/npm/js-base64@3.7.4/base64.min.js"
integrity="sha384-VkKbwLiG7C18stSGuvcw9W0BHk45Ba7P9LJG5c01Yo4BI6qhFoWSa9TQLNA6EOzI"
crossorigin="anonymous">
</script>
</head>
<body>
<nav>
<ul>
<li><h1>Nom Nom GC</h1></li>
<li><a href="/">Home</a></li>
<li><a href="/projects">Projects</a></li>
<li><a href="/login">Login</a></li>
</nav>
<main>
<div id="main-container">
{{> @partial-block }}
</div>
</main>
<script>
{{{ js }}}
</script>
</body>
</html>

View File

@ -1,8 +1,7 @@
const start_webauthn = async () => {
const init_data = {
uuid: self.crypto.randomUUID(),
user_name: "ninjatrappeur",
display_name: "ninjatrappeur"
uuid: '{{ uuid }}',
user_name: '{{ username }}',
};
const challenge_resp = await fetch("/account/register-init", {
method: 'POST',

View File

@ -1,6 +1,6 @@
use std::{process::{Command, Child}, env::temp_dir, path::PathBuf, fs::remove_dir_all, fs::read_dir, time::Duration};
use nix_cache_bucket_gc::models::{self, Configuration, AppState};
use nom_nom_gc::models::{Configuration, AppState, User};
use tokio::time::sleep;
use uuid::Uuid;
use anyhow::Result;
@ -27,12 +27,11 @@ async fn setup_db() -> Result<TestDB> {
let db_proc_handle = Command::new("postgres")
.args(["-D", dbdir_str, "-c", &format!("unix_socket_directories={}", dbdir_str), "-c", "listen_addresses=", "-c", &format!("port={}", &port.to_string())])
.spawn()?;
let test = sleep(Duration::from_secs(1)).await;
let cmd = Command::new("createdb")
sleep(Duration::from_secs(1)).await;
Command::new("createdb")
.args([ "-h", dbdir_str, "-p", &port.to_string(), &db_name])
.spawn()?
.wait()?;
println!("{:?}", cmd);
Ok(TestDB{ path: dbdir, pid: db_proc_handle, db_name, port })
}
@ -44,7 +43,7 @@ fn teardown_db(mut db: TestDB) -> Result <()> {
}
#[tokio::test]
async fn test_migratios() {
async fn test_db() {
let mdb = setup_db().await;
let db = mdb.expect("setup db");
let mut dbpath = db.path.to_str().unwrap().to_string();
@ -56,16 +55,20 @@ async fn test_migratios() {
db_name: db.db_name.clone()
};
println!("{:?}", conf);
println!("Listing dir:");
let postgres_content = read_dir(&db.path).unwrap();
for file in postgres_content {
println!("{}", file.unwrap().path().display());
}
let mut state = AppState::new(conf);
let res = state.run_migrations().await;
let state = AppState::new(conf);
let migrations_res = state.run_migrations().await;
let test_user = User { user_name: "test-user".to_owned() };
let uuid = state.generate_registration_link(test_user.clone()).await.expect("should generate registration uuid for test user");
let usr2 = state.retrieve_registration_link(uuid).await.expect("should retrieve user from reg uuid");
let usr2 = usr2.expect("should retrieve user from reg uuid");
assert_eq!(test_user.user_name, usr2.user_name);
teardown_db(db).expect("Failed to teardown DB.");
res.expect("migrations should not fail");
migrations_res.expect("migrations should not fail");
}