WIP - postgres
Build nomnom / Build-NomNom (push) Failing after 3m59s Details

This commit is contained in:
Félix Baylac Jacqué 2023-10-06 15:47:15 +02:00
parent 5437600b1c
commit afef067479
8 changed files with 860 additions and 85 deletions

688
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -14,3 +14,8 @@ webauthn-rs = "0.4.8"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
handlebars = "4.3.6"
deadpool-postgres = "0.11.0"
tokio-postgres = "0.7.10"
anyhow = "1.0.75"
refinery = { version = "0.8.11", features = ["tokio-postgres"] }
uuid = { version = "1.4.1", features = ["v4"] }

View File

@ -1,7 +1,7 @@
use webauthn_rs::prelude::{RegisterPublicKeyCredential, Uuid};
use actix_web::{error, HttpResponse, http::header::{ContentType, self}, web, cookie::{Cookie, SameSite}, HttpRequest};
use crate::{models::{AppState, User}, templates};
use crate::{models::{AppState, User, PendingRegistration}, templates};
pub async fn landing_page (app_state: web::Data<AppState<'_>>) -> HttpResponse {
let content: String = templates::landing_page(app_state.hbs.clone()).unwrap();
@ -10,16 +10,21 @@ pub async fn landing_page (app_state: web::Data<AppState<'_>>) -> HttpResponse {
.body(content)
}
/// First phase of the webauthn key enrolling.
///
/// For now, we don't save anything to the DB. We just generate the
/// challenge, the UUID and store everything in the session hashmap
/// 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 (creation_challenge_response, passkey_registration) = app_state.webauthn.start_passkey_registration(user.uuid, &user.user_name, &user.display_name, None).unwrap();
let uuid_str = user.uuid.to_string();
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 uuid_str = uuid.to_string();
{
let mut session = app_state.session.user_registrations.write().await;
session.insert(user.clone(), passkey_registration);
}
{
let mut uuid_db = app_state.db.user_uuid_object.write().await;
uuid_db.insert(user.uuid, user.into_inner());
let mut user_registrations = app_state.session.user_registrations.write().await;
user_registrations.insert(uuid, PendingRegistration{
user: user.into_inner(),
registration: passkey_registration
});
}
let res = serde_json::to_string(&creation_challenge_response).unwrap();
let cookie = Cookie::build("uuid", &uuid_str)
@ -32,24 +37,37 @@ pub async fn start_webauthn_registration(app_state: web::Data<AppState<'_>>, use
.body(res)
}
/// Final phase of the webauthn key enrolling.
///
/// We verify the key enrolling challenge, then store the new key in the DB.
pub async fn finish_webauthn_registration(req: HttpRequest, app_state: web::Data<AppState<'_>>, register: web::Json<RegisterPublicKeyCredential>) -> impl actix_web::Responder {
let cook = req.cookie("uuid");
let uuid = Uuid::parse_str(cook.unwrap().value()).unwrap();
let registration_result = {
let users = app_state.db.user_uuid_object.read().await;
let user = users.get(&uuid).unwrap();
let session = app_state.session.user_registrations.read().await;
let passkey_registration = session.get(user).unwrap();
app_state.webauthn.finish_passkey_registration(&register, passkey_registration)
let pending_registration: Option<PendingRegistration> = {
let user_registrations = app_state.session.user_registrations.read().await;
user_registrations.get(&uuid).cloned()
};
let mut user_keys = app_state.db.user_keys.write().await;
match registration_result {
Ok(passkey) => {
user_keys.insert(uuid, passkey);
HttpResponse::Ok()
.body("ok")
// 1. Check registration.
// 2. Save user.
// 3. Save key.
match pending_registration {
Some(PendingRegistration { user, registration }) => {
let registration_result = app_state.webauthn.finish_passkey_registration(&register, &registration);
match registration_result {
Ok(passkey) => {
let _ = app_state.save_user(&user).await;
let _ = app_state.save_user_key(&uuid, &passkey).await;
HttpResponse::Ok()
.body("ok")
},
Err(_) =>
HttpResponse::from_error(error::ErrorUnauthorized("Webauthn challenge failed"))
}
},
Err(_) =>
HttpResponse::from_error(error::ErrorUnauthorized("Webauthn challenge failed"))
None => {
return HttpResponse::from_error(error::ErrorInternalServerError("Session expired"))
}
}
}

4
src/lib.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod app;
pub mod handlers;
pub mod models;
pub mod templates;

View File

@ -1,6 +1,11 @@
use std::net::SocketAddr;
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;
@ -11,19 +16,30 @@ mod templates;
#[command(author, version, about, long_about = None)]
struct CLIArgs {
#[arg(short, long)]
bind: String
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)
}
#[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);
HttpServer::new(
|| {
let state = models::AppState::new();
move || {
let config = read_config(&args.config)
.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-init", web::post().to(handlers::start_webauthn_registration))

View File

@ -1,30 +1,47 @@
use serde::{Deserialize, Serialize};
use url::Url;
use std::ops::DerefMut;
use std::sync::Arc;
use std::collections::HashMap;
use tokio::sync::RwLock;
use anyhow::Result;
use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod};
use handlebars::Handlebars;
use tokio::sync::RwLock;
use tokio_postgres::NoTls;
use webauthn_rs::prelude::{Uuid, PasskeyRegistration, Passkey};
use webauthn_rs::{Webauthn, WebauthnBuilder};
#[derive(Deserialize, Debug)]
pub struct Configuration {
pub url: String,
pub db_host: Option<String>,
pub db_port: Option<u16>,
pub db_name: String
}
pub type DbField<T> = Arc<RwLock<T>>;
#[derive(Clone)]
pub struct Db {
pub user_keys: DbField<HashMap<Uuid, Passkey>>,
pub user_uuid_object: DbField<HashMap<Uuid, User>>,
}
#[derive(Clone)]
pub struct TempSession {
pub user_registrations: Arc<RwLock<HashMap<User,PasskeyRegistration>>>
pub user_registrations: Arc<RwLock<HashMap<Uuid,PendingRegistration>>>
}
#[derive(Clone)]
pub struct PendingRegistration {
pub user: User,
pub registration: PasskeyRegistration
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
pub struct User {
pub uuid: Uuid,
pub user_name: String,
pub display_name: String,
}
@ -32,13 +49,18 @@ pub struct User {
#[derive(Clone)]
pub struct AppState<'a>{
pub webauthn: Arc<Webauthn>,
pub db: Db,
pub db: Pool,
pub hbs: Arc<Handlebars<'a>>,
pub session: TempSession
}
mod embedded {
use refinery::embed_migrations;
embed_migrations!("src/sql/01-init.sql");
}
impl AppState<'_> {
pub fn new() -> Self {
pub fn new(args: Configuration) -> Self {
let rp = "localhost";
let rp_origin = Url::parse("http://localhost:8000").expect("Invalid URL");
let builder = WebauthnBuilder::new(rp, &rp_origin).expect("Invalid configuration");
@ -47,12 +69,21 @@ impl AppState<'_> {
let db: Db = Db {
user_keys: Arc::new(RwLock::new(HashMap::new())),
user_uuid_object: Arc::new(RwLock::new(HashMap::new()))
};
let hbs = Arc::new(crate::templates::new().unwrap());
let session: TempSession = TempSession {
user_registrations: Arc::new(RwLock::new(HashMap::new()))
};
let mut pg_config = tokio_postgres::Config::new();
pg_config.host_path("/run/postgresql");
pg_config.host_path("/tmp");
pg_config.user("deadpool");
pg_config.dbname("deadpool");
let mgr_config = ManagerConfig {
recycling_method: RecyclingMethod::Fast
};
let mgr = Manager::from_config(pg_config, NoTls, mgr_config);
let db = Pool::builder(mgr).max_size(16).build().unwrap();
AppState {
webauthn,
@ -61,4 +92,40 @@ impl AppState<'_> {
session
}
}
pub async fn run_migrations(&mut self) -> Result<()> {
let mut conn = self.db.get().await?;
let client = conn.deref_mut().deref_mut();
embedded::migrations::runner().run_async(client).await?;
Ok(())
}
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]);
Ok(())
}
pub async fn save_user_key(&self, uuid: &Uuid, passkey: &Passkey) -> Result<()> {
let passkey_json: String = serde_json::to_string(&passkey)?;
let mut conn = self.db.get().await?;
let client = conn.deref_mut();
let stmt = client.prepare_cached("INSERT INTO Keys (key_dump, user_id) VALUES ($1,$2)").await?;
client.query(&stmt, &[&passkey_json, &uuid.to_string()]).await?;
Ok(())
}
pub async fn get_user_keys(&self, user_uuid: &Uuid) -> Result<Vec<Result<Passkey, serde_json::Error>>> {
let mut conn = self.db.get().await?;
let client = conn.deref_mut();
let stmt = client.prepare_cached("SELECT key_dump FROM Keys WHERE user_id = $1").await?;
let res = client.query(&stmt, &[&user_uuid.to_string()]).await?;
let res2 = res.iter().map(|row| {
serde_json::from_str(row.get(0))
}).collect();
Ok(res2)
}
}

16
src/sql/01-init.sql Normal file
View File

@ -0,0 +1,16 @@
CREATE TABLE Users (
id uuid DEFAULT uuid_generate_v4 (),
user_name text NOT NULL,
display_name text NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE Keys (
id integer PRIMARY KEY,
key_dump jsonb NOT NULL,
user_id uuid NOT NULL,
CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES Users(id)
);
-- We'll mostly querying the Keys using the associated uid.
CREATE INDEX idx_keys_uid ON Keys USING HASH (user_id);

61
tests/db.rs Normal file
View File

@ -0,0 +1,61 @@
use std::{process::{Command, Child}, env::temp_dir, path::PathBuf, fs::remove_dir_all, time::Duration};
use nix_cache_bucket_gc::models::{self, Configuration, AppState};
use tokio::time::sleep;
use uuid::Uuid;
use anyhow::Result;
struct TestDB {
path: PathBuf,
pid: Child,
db_name: String,
port: u16
}
async fn setup_db() -> Result<TestDB> {
let mut dbdir = temp_dir();
let dir_uuid = Uuid::new_v4();
let db_name = "nom-nom-integration-test".to_string();
let port: u16 = 12345;
dbdir.push(dir_uuid.to_string());
let dbdir_str = dbdir.to_str().unwrap();
Command::new("initdb")
.arg(&dbdir)
.spawn()?
.wait()?;
let db_proc_handle = Command::new("postgres")
.args(["-D", dbdir_str, "-c", &format!("unix_socket_directories={}", dbdir_str) , "-c", &format!("port={}", &port.to_string())])
.spawn()?;
let test = sleep(Duration::from_secs(1)).await;
Command::new("createdb")
.args([ "-h", dbdir_str, "-p", &port.to_string(), &db_name])
.spawn()?
.wait()?;
Ok(TestDB{ path: dbdir, pid: db_proc_handle, db_name, port })
}
fn teardown_db(mut db: TestDB) -> Result <()> {
db.pid.kill()?;
remove_dir_all(db.path)?;
Ok(())
}
#[tokio::test]
async fn test_migratios() {
let mdb = setup_db().await;
let db = mdb.expect("setup db");
let conf = Configuration {
url: db.path.to_str().unwrap().to_string(),
db_port: Some(db.port),
db_host: Some(db.path.to_str().unwrap().to_string()),
db_name: db.db_name.clone()
};
let mut state = AppState::new(conf);
let res = state.run_migrations().await;
res.expect("migrations should not fail");
teardown_db(db).expect("Failed to teardown DB.");
}