diff --git a/Cargo.lock b/Cargo.lock index 1c651c6..bf4d83a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index d5c6722..263cc57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/sql/01-init.sql b/migrations/V1__init.sql similarity index 80% rename from src/sql/01-init.sql rename to migrations/V1__init.sql index e6ae594..ab1178d 100644 --- a/src/sql/01-init.sql +++ b/migrations/V1__init.sql @@ -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); diff --git a/nom-nom-gc b/nom-nom-gc new file mode 100755 index 0000000..2ba808e --- /dev/null +++ b/nom-nom-gc @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +source /tmp/nom-nom-dev-args +cargo run --bin nom-nom-gc-cli -- -c "${cfgfile}" $@ diff --git a/psql b/psql new file mode 100755 index 0000000..c4f3f8b --- /dev/null +++ b/psql @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +source /tmp/nom-nom-dev-args +psql -h "${host}" -p "${port}" -d "${dbname}" diff --git a/run-dev-server b/run-dev-server index 26add52..526eb68 100755 --- a/run-dev-server +++ b/run-dev-server @@ -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 < "/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 < "${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}" diff --git a/src/cli/bin/main.rs b/src/cli/bin/main.rs new file mode 100644 index 0000000..d8456ed --- /dev/null +++ b/src/cli/bin/main.rs @@ -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, + #[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(()) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 48cb2d7..9247b4a 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -10,6 +10,27 @@ pub async fn landing_page (app_state: web::Data>) -> 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>, path: web::Path) -> 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>) -> HttpResponse { /// server-side, in the response and cookie on the client-side. pub async fn start_webauthn_registration(app_state: web::Data>, user: web::Json) -> 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; diff --git a/src/models/mod.rs b/src/models/mod.rs index f768493..a84d67f 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -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, @@ -20,6 +22,15 @@ pub struct Configuration { pub db_name: String } + +pub fn read_config(config_path: &str) -> anyhow::Result { + 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 = Arc>; #[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 { + 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> { + 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(()) } diff --git a/src/main.rs b/src/server/bin/main.rs similarity index 63% rename from src/main.rs rename to src/server/bin/main.rs index a2e8515..9a3488c 100644 --- a/src/main.rs +++ b/src/server/bin/main.rs @@ -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 { - 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 } #[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)) }) diff --git a/src/templates/landing.hbs b/src/templates/landing.hbs index 762395b..6c10701 100644 --- a/src/templates/landing.hbs +++ b/src/templates/landing.hbs @@ -1,29 +1,3 @@ - - - - Nom Nom GC - - - - - -
-
- Hello world -
-
- - - +{{#> template }} +

Hello world, this is the nom nom S3 GC

+{{ /template }} diff --git a/src/templates/mod.rs b/src/templates/mod.rs index b935dca..a3a15a5 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -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, 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>) -> Result { let data = json!({ + }); hb.render("landing", &data) } + +pub fn register_user_start(hb: Arc>, uuid: Uuid, username: String) -> Result { + 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) +} diff --git a/src/templates/register-user.hbs b/src/templates/register-user.hbs new file mode 100644 index 0000000..7e20a65 --- /dev/null +++ b/src/templates/register-user.hbs @@ -0,0 +1,3 @@ +{{#> template js=js }} +

Registering {{ uuid }}

+{{ /template }} \ No newline at end of file diff --git a/src/templates/template.hbs b/src/templates/template.hbs new file mode 100644 index 0000000..69cf62a --- /dev/null +++ b/src/templates/template.hbs @@ -0,0 +1,29 @@ + + + + Nom Nom GC + + + + + +
+
+ {{> @partial-block }} +
+
+ + + diff --git a/src/templates/webauthn.js b/src/templates/webauthn.js index 8076be5..e033024 100644 --- a/src/templates/webauthn.js +++ b/src/templates/webauthn.js @@ -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', diff --git a/tests/db.rs b/tests/db.rs index 83d28fb..2f00f39 100644 --- a/tests/db.rs +++ b/tests/db.rs @@ -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 { 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"); }