diff --git a/Cargo.toml b/Cargo.toml index 03c02e3f..88413f0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,9 @@ r-cache = "0.5.0" lettre = "0.11.6" addr = "0.15.6" hickory-resolver = "0.24.0" +webauthn-rs = { version = "0.4.8", features = [ + "danger-allow-state-serialisation", +] } # If you're updating sqlx, make sure that chrono version below is the same as the one in sqlx sqlx = { version = "0.7.3", features = [ diff --git a/database/Cargo.toml b/database/Cargo.toml index ecbfd1ef..5302d00d 100644 --- a/database/Cargo.toml +++ b/database/Cargo.toml @@ -16,6 +16,7 @@ log = { workspace = true } strum = { workspace = true } anyhow = { workspace = true } chrono = { workspace = true } +webauthn-rs = { workspace = true } [features] cloud_db_tests = [] \ No newline at end of file diff --git a/database/migrations/0003_grafana_users.sql b/database/migrations/0003_grafana_users.sql deleted file mode 100644 index a137b653..00000000 --- a/database/migrations/0003_grafana_users.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE grafana_users( - user_id TEXT NOT NULL UNIQUE, - email TEXT NOT NULL UNIQUE, - password_hash TEXT, - creation_timestamp TIMESTAMPTZ NOT NULL -); - -CREATE INDEX grafana_users_name_idx ON grafana_users(user_id); -CREATE INDEX grafana_users_email_idx ON grafana_users(email); \ No newline at end of file diff --git a/database/migrations/0003_users.sql b/database/migrations/0003_users.sql new file mode 100644 index 00000000..2cb107fc --- /dev/null +++ b/database/migrations/0003_users.sql @@ -0,0 +1,10 @@ +CREATE TABLE users( + user_id TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT, + passkeys TEXT, + creation_timestamp TIMESTAMPTZ NOT NULL +); + +CREATE INDEX users_name_idx ON users(user_id); +CREATE INDEX users_email_idx ON users(email); \ No newline at end of file diff --git a/database/migrations/0005_user_app_privilages.sql b/database/migrations/0005_user_app_privilages.sql index 8856530e..240384bd 100644 --- a/database/migrations/0005_user_app_privilages.sql +++ b/database/migrations/0005_user_app_privilages.sql @@ -4,6 +4,6 @@ CREATE TABLE user_app_privileges ( creation_timestamp TIMESTAMPTZ NOT NULL, privilege_level privilege_level_enum NOT NULL, PRIMARY KEY (user_id, app_id), - FOREIGN KEY (user_id) REFERENCES grafana_users (user_id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE, FOREIGN KEY (app_id) REFERENCES registered_apps (app_id) ON DELETE CASCADE ); diff --git a/database/migrations/0014_domain_verifications.sql b/database/migrations/0014_domain_verifications.sql new file mode 100644 index 00000000..61d87ea4 --- /dev/null +++ b/database/migrations/0014_domain_verifications.sql @@ -0,0 +1,9 @@ +CREATE TABLE domain_verifications( + domain_name TEXT PRIMARY KEY, + app_id TEXT NOT NULL, + code TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + finished_at TIMESTAMPTZ +); + +CREATE INDEX domain_verifications_app_id_idx ON domain_verifications(app_id); diff --git a/database/migrations/0014_create_hypertables.sql b/database/migrations/0015_create_hypertables.sql similarity index 100% rename from database/migrations/0014_create_hypertables.sql rename to database/migrations/0015_create_hypertables.sql diff --git a/database/migrations/0015_requests_stats.sql b/database/migrations/0016_requests_stats.sql similarity index 100% rename from database/migrations/0015_requests_stats.sql rename to database/migrations/0016_requests_stats.sql diff --git a/database/migrations/0016_connection_stats.sql b/database/migrations/0017_connection_stats.sql similarity index 100% rename from database/migrations/0016_connection_stats.sql rename to database/migrations/0017_connection_stats.sql diff --git a/database/migrations/0017_session_stats.sql b/database/migrations/0018_session_stats.sql similarity index 100% rename from database/migrations/0017_session_stats.sql rename to database/migrations/0018_session_stats.sql diff --git a/database/src/tables/grafana_users/mod.rs b/database/src/tables/domain_verifications/mod.rs similarity index 100% rename from database/src/tables/grafana_users/mod.rs rename to database/src/tables/domain_verifications/mod.rs diff --git a/database/src/tables/domain_verifications/select.rs b/database/src/tables/domain_verifications/select.rs new file mode 100644 index 00000000..b4792409 --- /dev/null +++ b/database/src/tables/domain_verifications/select.rs @@ -0,0 +1,39 @@ +use crate::{ + db::Db, + structs::db_error::DbError, + tables::domain_verifications::table_struct::{ + DomainVerification, DOMAIN_VERIFICATIONS_TABLE_NAME, + }, +}; +use sqlx::query_as; + +impl Db { + pub async fn get_domain_verifications_by_app_id( + &self, + app_id: &String, + ) -> Result, DbError> { + let query = format!("SELECT * FROM {DOMAIN_VERIFICATIONS_TABLE_NAME} WHERE app_id = $1 ORDER BY created_at DESC"); + let typed_query = query_as::<_, DomainVerification>(&query); + + return typed_query + .bind(&app_id) + .fetch_all(&self.connection_pool) + .await + .map_err(|e| e.into()); + } + + pub async fn get_domain_verification_by_domain_name( + &self, + domain_name: &String, + ) -> Result, DbError> { + let query = + format!("SELECT * FROM {DOMAIN_VERIFICATIONS_TABLE_NAME} WHERE domain_name = $1"); + let typed_query = query_as::<_, DomainVerification>(&query); + + return typed_query + .bind(&domain_name) + .fetch_optional(&self.connection_pool) + .await + .map_err(|e| e.into()); + } +} diff --git a/database/src/tables/domain_verifications/table_struct.rs b/database/src/tables/domain_verifications/table_struct.rs new file mode 100644 index 00000000..f5789092 --- /dev/null +++ b/database/src/tables/domain_verifications/table_struct.rs @@ -0,0 +1,29 @@ +use sqlx::{ + postgres::PgRow, + types::chrono::{DateTime, Utc}, + FromRow, Row, +}; + +pub const DOMAIN_VERIFICATIONS_TABLE_NAME: &str = "domain_verifications"; +pub const DOMAIN_VERIFICATIONS_KEYS: &str = "domain_name, app_id, code, created_at, finished_at"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DomainVerification { + pub domain_name: String, + pub app_id: String, + pub code: String, + pub created_at: DateTime, + pub finished_at: Option>, +} + +impl FromRow<'_, PgRow> for DomainVerification { + fn from_row(row: &sqlx::postgres::PgRow) -> std::result::Result { + Ok(DomainVerification { + domain_name: row.get("domain_name"), + app_id: row.get("app_id"), + code: row.get("code"), + created_at: row.get("created_at"), + finished_at: row.get("finished_at"), + }) + } +} diff --git a/database/src/tables/domain_verifications/update.rs b/database/src/tables/domain_verifications/update.rs new file mode 100644 index 00000000..d39df252 --- /dev/null +++ b/database/src/tables/domain_verifications/update.rs @@ -0,0 +1,52 @@ +use super::table_struct::{DOMAIN_VERIFICATIONS_KEYS, DOMAIN_VERIFICATIONS_TABLE_NAME}; +use crate::db::Db; +use crate::structs::db_error::DbError; +use crate::tables::utils::get_current_datetime; +use sqlx::query; + +impl Db { + pub async fn create_new_domain_verification_entry( + &self, + domain_name: &String, + app_id: &String, + code: &String, + ) -> Result<(), DbError> { + let query_body = format!( + "INSERT INTO {DOMAIN_VERIFICATIONS_TABLE_NAME} ({DOMAIN_VERIFICATIONS_KEYS}) VALUES ($1, $2, $3, $4, NULL)" + ); + + let query_result = query(&query_body) + .bind(&domain_name) + .bind(&app_id) + .bind(&code) + .bind(&get_current_datetime()) + .execute(&self.connection_pool) + .await; + + match query_result { + Ok(_) => Ok(()), + Err(e) => Err(e).map_err(|e| e.into()), + } + } + + pub async fn finish_domain_verification( + &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + domain_name: &String, + ) -> Result<(), DbError> { + let query_body = format!( + "UPDATE {DOMAIN_VERIFICATIONS_TABLE_NAME} SET finished_at = $1 WHERE domain_name = $2" + ); + + let query_result = query(&query_body) + .bind(&get_current_datetime()) + .bind(&domain_name) + .execute(&mut **tx) + .await; + + match query_result { + Ok(_) => Ok(()), + Err(e) => Err(e).map_err(|e| e.into()), + } + } +} diff --git a/database/src/tables/grafana_users/select.rs b/database/src/tables/grafana_users/select.rs deleted file mode 100644 index ecfc5548..00000000 --- a/database/src/tables/grafana_users/select.rs +++ /dev/null @@ -1,32 +0,0 @@ -use super::table_struct::GrafanaUser; -use crate::db::Db; -use crate::structs::db_error::DbError; -use crate::tables::grafana_users::table_struct::GRAFANA_USERS_TABLE_NAME; -use sqlx::query_as; - -impl Db { - pub async fn get_user_by_user_id( - &self, - user_id: &String, - ) -> Result, DbError> { - let query = format!("SELECT * FROM {GRAFANA_USERS_TABLE_NAME} WHERE user_id = $1"); - let typed_query = query_as::<_, GrafanaUser>(&query); - - return typed_query - .bind(&user_id) - .fetch_optional(&self.connection_pool) - .await - .map_err(|e| e.into()); - } - - pub async fn get_user_by_email(&self, email: &String) -> Result, DbError> { - let query = format!("SELECT * FROM {GRAFANA_USERS_TABLE_NAME} WHERE email = $1"); - let typed_query = query_as::<_, GrafanaUser>(&query); - - return typed_query - .bind(&email) - .fetch_optional(&self.connection_pool) - .await - .map_err(|e| e.into()); - } -} diff --git a/database/src/tables/grafana_users/table_struct.rs b/database/src/tables/grafana_users/table_struct.rs deleted file mode 100644 index 5b07c513..00000000 --- a/database/src/tables/grafana_users/table_struct.rs +++ /dev/null @@ -1,27 +0,0 @@ -use sqlx::{ - postgres::PgRow, - types::chrono::{DateTime, Utc}, - FromRow, Row, -}; - -pub const GRAFANA_USERS_TABLE_NAME: &str = "grafana_users"; -pub const GRAFANA_USERS_KEYS: &str = "user_id, email, password_hash, creation_timestamp"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct GrafanaUser { - pub user_id: String, - pub email: String, - pub password_hash: String, - pub creation_timestamp: DateTime, -} - -impl FromRow<'_, PgRow> for GrafanaUser { - fn from_row(row: &sqlx::postgres::PgRow) -> std::result::Result { - Ok(GrafanaUser { - email: row.get("email"), - password_hash: row.get("password_hash"), - user_id: row.get("user_id"), - creation_timestamp: row.get("creation_timestamp"), - }) - } -} diff --git a/database/src/tables/grafana_users/update.rs b/database/src/tables/grafana_users/update.rs deleted file mode 100644 index fb61624f..00000000 --- a/database/src/tables/grafana_users/update.rs +++ /dev/null @@ -1,104 +0,0 @@ -use super::table_struct::{GrafanaUser, GRAFANA_USERS_KEYS, GRAFANA_USERS_TABLE_NAME}; -use crate::db::Db; -use crate::structs::db_error::DbError; -use sqlx::query; -use sqlx::Transaction; - -impl Db { - pub async fn create_new_user_within_tx( - &self, - tx: &mut Transaction<'_, sqlx::Postgres>, - user: &GrafanaUser, - ) -> Result<(), DbError> { - let query_body = format!( - "INSERT INTO {GRAFANA_USERS_TABLE_NAME} ({GRAFANA_USERS_KEYS}) VALUES ($1, $2, $3, $4)" - ); - - let query_result = query(&query_body) - .bind(&user.user_id) - .bind(&user.email) - .bind(&user.password_hash) - .bind(&user.creation_timestamp) - .execute(&mut **tx) - .await; - - match query_result { - Ok(_) => Ok(()), - Err(e) => Err(e).map_err(|e| e.into()), - } - } - - pub async fn add_new_user(&self, user: &GrafanaUser) -> Result<(), DbError> { - let query_body = format!( - "INSERT INTO {GRAFANA_USERS_TABLE_NAME} ({GRAFANA_USERS_KEYS}) VALUES ($1, $2, $3, $4)" - ); - - let query_result = query(&query_body) - .bind(&user.user_id) - .bind(&user.email) - .bind(&user.password_hash) - .bind(&user.creation_timestamp) - .execute(&self.connection_pool) - .await; - - match query_result { - Ok(_) => Ok(()), - Err(e) => Err(e).map_err(|e| e.into()), - } - } - - pub async fn set_new_password( - &self, - user_email: &String, - new_password: &String, - ) -> Result<(), DbError> { - let query_body = - format!("UPDATE {GRAFANA_USERS_TABLE_NAME} SET password_hash = $1 WHERE email = $2"); - - let query_result = query(&query_body) - .bind(new_password) - .bind(user_email) - .execute(&self.connection_pool) - .await; - - match query_result { - Ok(_) => Ok(()), - Err(e) => Err(e).map_err(|e| e.into()), - } - } -} - -#[cfg(feature = "cloud_db_tests")] -#[cfg(test)] -mod tests { - use crate::tables::{ - grafana_users::table_struct::GrafanaUser, utils::to_microsecond_precision, - }; - use sqlx::types::chrono::Utc; - - #[tokio::test] - async fn test_create_user() { - let db = super::Db::connect_to_the_pool().await; - db.truncate_all_tables().await.unwrap(); - - // Create test team instance - let team_id = "test_team_id".to_string(); - let app_id = "test_app_id".to_string(); - - db.setup_test_team(&team_id, &app_id, Utc::now()) - .await - .unwrap(); - - let user = GrafanaUser { - email: "test_user_email".to_string(), - password_hash: "test_password_hash".to_string(), - user_id: "test_user_id".to_string(), - creation_timestamp: to_microsecond_precision(&Utc::now()), - }; - - db.add_new_user(&user).await.unwrap(); - - let user_result = db.get_user_by_user_id(&user.user_id).await.unwrap(); - assert_eq!(user_result, Some(user)); - } -} diff --git a/database/src/tables/mod.rs b/database/src/tables/mod.rs index 9c5ec739..ffb411ba 100644 --- a/database/src/tables/mod.rs +++ b/database/src/tables/mod.rs @@ -1,11 +1,12 @@ pub mod client_profiles; pub mod connection_events; pub mod events; -pub mod grafana_users; pub mod ip_addresses; pub mod public_keys; pub mod registered_app; +pub mod users; // pub mod requests; +pub mod domain_verifications; pub mod session_public_keys; pub mod sessions; pub mod team; diff --git a/database/src/tables/registered_app/update.rs b/database/src/tables/registered_app/update.rs index c66253a4..d86db831 100644 --- a/database/src/tables/registered_app/update.rs +++ b/database/src/tables/registered_app/update.rs @@ -52,6 +52,7 @@ impl Db { pub async fn add_new_whitelisted_domain( &self, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, app_id: &str, domain: &str, ) -> Result<(), DbError> { @@ -62,7 +63,7 @@ impl Db { let query_result = query(&query_body) .bind(domain) .bind(app_id) - .execute(&self.connection_pool) + .execute(&mut **tx) .await; match query_result { diff --git a/database/src/tables/team/update.rs b/database/src/tables/team/update.rs index 1dbdbacb..dd8da81e 100644 --- a/database/src/tables/team/update.rs +++ b/database/src/tables/team/update.rs @@ -74,10 +74,7 @@ impl Db { #[cfg(feature = "cloud_db_tests")] #[cfg(test)] mod tests { - use crate::tables::{ - grafana_users::table_struct::GrafanaUser, team::table_struct::Team, - utils::to_microsecond_precision, - }; + use crate::tables::{team::table_struct::Team, utils::to_microsecond_precision}; use sqlx::types::chrono::Utc; #[tokio::test] @@ -86,14 +83,13 @@ mod tests { db.truncate_all_tables().await.unwrap(); // First create a user - let admin = GrafanaUser { - email: "test_email".to_string(), - password_hash: "test_password_hash".to_string(), - user_id: "test_user_id".to_string(), - creation_timestamp: to_microsecond_precision(&Utc::now()), - }; + let user_id = "test_user_id".to_string(); + let email = "test_user_email".to_string(); + let password_hash = "test_password_hash".to_string(); - db.add_new_user(&admin).await.unwrap(); + db.add_new_user(&user_id, &email, Some(&password_hash), None) + .await + .unwrap(); // Create team and register app let team = Team { diff --git a/database/src/tables/team_invites/select.rs b/database/src/tables/team_invites/select.rs index bcfbe83d..401feb3f 100644 --- a/database/src/tables/team_invites/select.rs +++ b/database/src/tables/team_invites/select.rs @@ -20,7 +20,7 @@ impl Db { ti.accepted_at, ti.cancelled_at, t.team_name, gu.email AS admin_email FROM {TEAM_INVITES_TABLE_NAME} ti INNER JOIN team t ON ti.team_id = t.team_id - INNER JOIN grafana_users gu ON t.team_admin_id = gu.user_id + INNER JOIN users gu ON t.team_admin_id = gu.user_id WHERE ti.team_id = $1 {additional_filter} ORDER BY ti.created_at DESC", ); @@ -49,7 +49,7 @@ impl Db { ti.accepted_at, ti.cancelled_at, t.team_name, gu.email AS admin_email \ FROM {TEAM_INVITES_TABLE_NAME} ti \ INNER JOIN team t ON ti.team_id = t.team_id \ - INNER JOIN grafana_users gu ON t.team_admin_id = gu.user_id \ + INNER JOIN users gu ON t.team_admin_id = gu.user_id \ WHERE ti.user_email = $1 {additional_filter} \ ORDER BY ti.created_at DESC", TEAM_INVITES_TABLE_NAME = TEAM_INVITES_TABLE_NAME diff --git a/database/src/tables/test_utils.rs b/database/src/tables/test_utils.rs index be46dfbb..5d56752b 100644 --- a/database/src/tables/test_utils.rs +++ b/database/src/tables/test_utils.rs @@ -5,7 +5,6 @@ pub mod test_utils { db::Db, structs::{db_error::DbError, privilege_level::PrivilegeLevel}, tables::{ - grafana_users::table_struct::GrafanaUser, registered_app::table_struct::DbRegisteredApp, team::table_struct::Team, user_app_privileges::table_struct::UserAppPrivilege, }, @@ -82,21 +81,19 @@ pub mod test_utils { app_id: &String, registration_timestamp: DateTime, ) -> Result<(), DbError> { - let admin = GrafanaUser { - creation_timestamp: registration_timestamp, - email: "email".to_string(), - password_hash: "pass_hash".to_string(), - user_id: "test_admin".to_string(), - }; + let user_id = "test_admin".to_string(); + let email = "email".to_string(); + let password_hash = "pass_hash".to_string(); - self.add_new_user(&admin).await?; + self.add_new_user(&user_id, &email, Some(&password_hash), None) + .await?; let team = Team { team_id: team_id.clone(), team_name: "test_team_name".to_string(), personal: false, subscription: None, - team_admin_id: admin.user_id.clone(), + team_admin_id: user_id.clone(), registration_timestamp: registration_timestamp, }; @@ -113,7 +110,7 @@ pub mod test_utils { app_id: app_id.clone(), creation_timestamp: registration_timestamp, privilege_level: PrivilegeLevel::Admin, - user_id: admin.user_id.clone(), + user_id: user_id.clone(), }; // Start a transaction diff --git a/database/src/tables/user_app_privileges/select.rs b/database/src/tables/user_app_privileges/select.rs index 433fc09a..2d539759 100644 --- a/database/src/tables/user_app_privileges/select.rs +++ b/database/src/tables/user_app_privileges/select.rs @@ -3,10 +3,10 @@ use crate::{ db::Db, structs::db_error::DbError, tables::{ - grafana_users::table_struct::GRAFANA_USERS_TABLE_NAME, registered_app::table_struct::{DbRegisteredApp, REGISTERED_APPS_TABLE_NAME}, team::table_struct::{Team, TEAM_TABLE_NAME}, user_app_privileges::table_struct::USER_APP_PRIVILEGES_TABLE_NAME, + users::table_struct::USERS_TABLE_NAME, }, }; use sqlx::{query_as, types::chrono::DateTime}; @@ -122,7 +122,7 @@ impl Db { FROM {TEAM_TABLE_NAME} t LEFT JOIN {REGISTERED_APPS_TABLE_NAME} ra ON t.team_id = ra.team_id LEFT JOIN {USER_APP_PRIVILEGES_TABLE_NAME} uap ON ra.app_id = uap.app_id AND uap.user_id = $1 - JOIN {GRAFANA_USERS_TABLE_NAME} gu ON t.team_admin_id = gu.user_id + JOIN {USERS_TABLE_NAME} gu ON t.team_admin_id = gu.user_id WHERE t.team_admin_id = $1 OR uap.user_id = $1 ) SELECT rt.team_id, rt.team_name, rt.personal, rt.subscription, rt.registration_timestamp, diff --git a/database/src/tables/user_app_privileges/update.rs b/database/src/tables/user_app_privileges/update.rs index 4419d293..105f5c46 100644 --- a/database/src/tables/user_app_privileges/update.rs +++ b/database/src/tables/user_app_privileges/update.rs @@ -207,7 +207,6 @@ mod tests { use crate::{ structs::privilege_level::PrivilegeLevel, tables::{ - grafana_users::table_struct::GrafanaUser, registered_app::table_struct::DbRegisteredApp, team::table_struct::Team, user_app_privileges::table_struct::UserAppPrivilege, utils::to_microsecond_precision, }, @@ -227,16 +226,16 @@ mod tests { .await .unwrap(); - let user = GrafanaUser { - email: "test_user_email".to_string(), - password_hash: "test_password_hash".to_string(), - user_id: "test_user_id".to_string(), - creation_timestamp: to_microsecond_precision(&Utc::now()), - }; - db.add_new_user(&user).await.unwrap(); + let user_id = "test_user_id".to_string(); + let email = "test_user_email".to_string(); + let password_hash = "test_password_hash".to_string(); + + db.add_new_user(&user_id, &email, Some(&password_hash), None) + .await + .unwrap(); let privilege = UserAppPrivilege { - user_id: user.user_id.clone(), + user_id: user_id.clone(), app_id: app_id.clone(), privilege_level: PrivilegeLevel::Edit, creation_timestamp: to_microsecond_precision(&Utc::now()), @@ -244,12 +243,12 @@ mod tests { db.add_new_privilege(&privilege).await.unwrap(); let get_by_user_id_and_app_id = db - .get_privilege_by_user_id_and_app_id(&user.user_id, &app_id) + .get_privilege_by_user_id_and_app_id(&user_id, &app_id) .await .unwrap(); assert_eq!(privilege, get_by_user_id_and_app_id.unwrap()); - let get_by_user_id = db.get_privileges_by_user_id(&user.user_id).await.unwrap(); + let get_by_user_id = db.get_privileges_by_user_id(&user_id).await.unwrap(); assert_eq!(vec![privilege.clone()], get_by_user_id); let get_by_app_id = db.get_privileges_by_app_id(&app_id).await.unwrap(); @@ -270,13 +269,13 @@ mod tests { .await .unwrap(); - let user = GrafanaUser { - email: "test_user_email".to_string(), - password_hash: "test_password_hash".to_string(), - user_id: "test_user_id".to_string(), - creation_timestamp: to_microsecond_precision(&Utc::now()), - }; - db.add_new_user(&user).await.unwrap(); + let user_id = "test_user_id".to_string(); + let email = "test_user_email".to_string(); + let password_hash = "test_password_hash".to_string(); + + db.add_new_user(&user_id, &email, Some(&password_hash), None) + .await + .unwrap(); // Create 7 more apps under the same team for i in 0..7 { @@ -294,12 +293,12 @@ mod tests { } let mut tx = db.connection_pool.begin().await.unwrap(); - db.add_user_to_the_team(&mut tx, &user.user_id, &team_id) + db.add_user_to_the_team(&mut tx, &user_id, &team_id) .await .unwrap(); tx.commit().await.unwrap(); - let get_by_user_id = db.get_privileges_by_user_id(&user.user_id).await.unwrap(); + let get_by_user_id = db.get_privileges_by_user_id(&user_id).await.unwrap(); assert!(get_by_user_id.len() == 8); // Create new team @@ -333,12 +332,12 @@ mod tests { // Add user to the new team let mut tx = db.connection_pool.begin().await.unwrap(); - db.add_user_to_the_team(&mut tx, &user.user_id, &team_id) + db.add_user_to_the_team(&mut tx, &user_id, &team_id) .await .unwrap(); tx.commit().await.unwrap(); - let get_by_user_id = db.get_privileges_by_user_id(&user.user_id).await.unwrap(); + let get_by_user_id = db.get_privileges_by_user_id(&user_id).await.unwrap(); assert!(get_by_user_id.len() == 10); } } diff --git a/database/src/tables/users/mod.rs b/database/src/tables/users/mod.rs new file mode 100644 index 00000000..4b2d4aa3 --- /dev/null +++ b/database/src/tables/users/mod.rs @@ -0,0 +1,3 @@ +pub mod select; +pub mod table_struct; +pub mod update; diff --git a/database/src/tables/users/select.rs b/database/src/tables/users/select.rs new file mode 100644 index 00000000..7b2a9c76 --- /dev/null +++ b/database/src/tables/users/select.rs @@ -0,0 +1,29 @@ +use super::table_struct::User; +use crate::db::Db; +use crate::structs::db_error::DbError; +use crate::tables::users::table_struct::USERS_TABLE_NAME; +use sqlx::query_as; + +impl Db { + pub async fn get_user_by_user_id(&self, user_id: &String) -> Result, DbError> { + let query = format!("SELECT * FROM {USERS_TABLE_NAME} WHERE user_id = $1"); + let typed_query = query_as::<_, User>(&query); + + return typed_query + .bind(&user_id) + .fetch_optional(&self.connection_pool) + .await + .map_err(|e| e.into()); + } + + pub async fn get_user_by_email(&self, email: &String) -> Result, DbError> { + let query = format!("SELECT * FROM {USERS_TABLE_NAME} WHERE email = $1"); + let typed_query = query_as::<_, User>(&query); + + return typed_query + .bind(&email) + .fetch_optional(&self.connection_pool) + .await + .map_err(|e| e.into()); + } +} diff --git a/database/src/tables/users/table_struct.rs b/database/src/tables/users/table_struct.rs new file mode 100644 index 00000000..6ccd4042 --- /dev/null +++ b/database/src/tables/users/table_struct.rs @@ -0,0 +1,36 @@ +use sqlx::{ + postgres::PgRow, + types::chrono::{DateTime, Utc}, + FromRow, Row, +}; +use webauthn_rs::prelude::Passkey; + +pub const USERS_TABLE_NAME: &str = "users"; +pub const USERS_KEYS: &str = "user_id, email, password_hash, passkeys, creation_timestamp"; + +#[derive(Clone, Debug, PartialEq)] +pub struct User { + pub user_id: String, + pub email: String, + pub password_hash: Option, + pub passkeys: Option>, + pub creation_timestamp: DateTime, +} + +impl FromRow<'_, PgRow> for User { + fn from_row(row: &sqlx::postgres::PgRow) -> std::result::Result { + let passkeys: Option = row.get("passkeys"); + Ok(User { + email: row.get("email"), + password_hash: row.get("password_hash"), + passkeys: match passkeys { + Some(passkeys) => { + serde_json::from_str(&passkeys).map_err(|e| sqlx::Error::Decode(Box::new(e)))? + } + None => None, + }, + user_id: row.get("user_id"), + creation_timestamp: row.get("creation_timestamp"), + }) + } +} diff --git a/database/src/tables/users/update.rs b/database/src/tables/users/update.rs new file mode 100644 index 00000000..e4dcf3ef --- /dev/null +++ b/database/src/tables/users/update.rs @@ -0,0 +1,111 @@ +use super::table_struct::{USERS_KEYS, USERS_TABLE_NAME}; +use crate::db::Db; +use crate::structs::db_error::DbError; +use crate::tables::utils::get_current_datetime; +use sqlx::query; +use webauthn_rs::prelude::Passkey; + +impl Db { + pub async fn add_new_user( + &self, + user_id: &String, + email: &String, + password_hash: Option<&String>, + passkey: Option<&Passkey>, + ) -> Result<(), DbError> { + let query_body = + format!("INSERT INTO {USERS_TABLE_NAME} ({USERS_KEYS}) VALUES ($1, $2, $3, $4, $5)"); + + let passkey = match passkey { + Some(passkey) => { + let serialized_passkey = serde_json::to_string(passkey).map_err(|e| { + DbError::DatabaseError(format!( + "Failed to serialize passkey: {}", + e.to_string() + )) + })?; + + Some(serialized_passkey) + } + None => None, + }; + let query_result = query(&query_body) + .bind(&user_id) + .bind(&email) + .bind(&password_hash) + .bind(&passkey) + .bind(&get_current_datetime()) + .execute(&self.connection_pool) + .await; + + match query_result { + Ok(_) => Ok(()), + Err(e) => Err(e).map_err(|e| e.into()), + } + } + + pub async fn set_new_password( + &self, + user_email: &String, + new_password: &String, + ) -> Result<(), DbError> { + let query_body = + format!("UPDATE {USERS_TABLE_NAME} SET password_hash = $1 WHERE email = $2"); + + let query_result = query(&query_body) + .bind(new_password) + .bind(user_email) + .execute(&self.connection_pool) + .await; + + match query_result { + Ok(_) => Ok(()), + Err(e) => Err(e).map_err(|e| e.into()), + } + } +} + +#[cfg(feature = "cloud_db_tests")] +#[cfg(test)] +mod tests { + use crate::tables::{users::table_struct::User, utils::to_microsecond_precision}; + use sqlx::types::chrono::Utc; + + #[tokio::test] + async fn test_create_user() { + let db = super::Db::connect_to_the_pool().await; + db.truncate_all_tables().await.unwrap(); + + // Create test team instance + let team_id = "test_team_id".to_string(); + let app_id = "test_app_id".to_string(); + + db.setup_test_team(&team_id, &app_id, Utc::now()) + .await + .unwrap(); + + let password = "test_password_hash".to_string(); + let user = User { + email: "test_user_email".to_string(), + password_hash: Some(password.clone()), + user_id: "test_user_id".to_string(), + passkeys: None, + creation_timestamp: to_microsecond_precision(&Utc::now()), + }; + + db.add_new_user(&user.user_id, &user.email, Some(&password), None) + .await + .unwrap(); + + let user_result = db + .get_user_by_user_id(&user.user_id) + .await + .unwrap() + .unwrap(); + + assert_eq!(user.email, user_result.email); + assert_eq!(user.password_hash, user_result.password_hash); + assert_eq!(user.user_id, user_result.user_id); + assert_eq!(user.passkeys, user_result.passkeys); + } +} diff --git a/server/Cargo.toml b/server/Cargo.toml index d89a8eb9..81d41ad1 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -36,6 +36,7 @@ r-cache = { workspace = true } lettre = { workspace = true } addr = { workspace = true } hickory-resolver = { workspace = true } +webauthn-rs = { workspace = true } [features] cloud_db_tests = [] \ No newline at end of file diff --git a/server/bindings/CloudApiErrors.ts b/server/bindings/CloudApiErrors.ts index f0240c6e..6937c645 100644 --- a/server/bindings/CloudApiErrors.ts +++ b/server/bindings/CloudApiErrors.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CloudApiErrors = "TeamDoesNotExist" | "UserDoesNotExist" | "CloudFeatureDisabled" | "InsufficientPermissions" | "TeamHasNoRegisteredApps" | "DatabaseError" | "MaximumUsersPerTeamReached" | "UserAlreadyBelongsToTheTeam" | "IncorrectPassword" | "AccessTokenFailure" | "RefreshTokenFailure" | "AppAlreadyExists" | "MaximumAppsPerTeamReached" | "TeamAlreadyExists" | "PersonalTeamAlreadyExists" | "EmailAlreadyExists" | "InternalServerError" | "UserDoesNotBelongsToTheTeam" | "InvalidName" | "UnauthorizedOriginError" | "AppDoesNotExist" | "UserAlreadyInvitedToTheTeam" | "MaximumInvitesPerTeamReached" | "InviteNotFound" | "ActionForbiddenForPersonalTeam" | "InviteDoesNotExist" | "InvalidPaginationCursor" | "InvalidVerificationCode" | "InvalidDomainName" | "DomainAlreadyVerified" | "DomainVerificationFailure" | "DomainNotFound"; \ No newline at end of file +export type CloudApiErrors = "TeamDoesNotExist" | "UserDoesNotExist" | "CloudFeatureDisabled" | "InsufficientPermissions" | "TeamHasNoRegisteredApps" | "DatabaseError" | "MaximumUsersPerTeamReached" | "UserAlreadyBelongsToTheTeam" | "IncorrectPassword" | "AccessTokenFailure" | "RefreshTokenFailure" | "AppAlreadyExists" | "MaximumAppsPerTeamReached" | "TeamAlreadyExists" | "PersonalTeamAlreadyExists" | "EmailAlreadyExists" | "InternalServerError" | "UserDoesNotBelongsToTheTeam" | "InvalidName" | "UnauthorizedOriginError" | "AppDoesNotExist" | "UserAlreadyInvitedToTheTeam" | "MaximumInvitesPerTeamReached" | "InviteNotFound" | "ActionForbiddenForPersonalTeam" | "InviteDoesNotExist" | "InvalidPaginationCursor" | "InvalidVerificationCode" | "InvalidDomainName" | "DomainAlreadyVerified" | "DomainVerificationFailure" | "DomainNotFound" | "DomainVerificationNotStarted" | "WebAuthnError" | "PasswordNotSet"; \ No newline at end of file diff --git a/server/bindings/HttpCloudEndpoint.ts b/server/bindings/HttpCloudEndpoint.ts index 5264d5fb..4086edb1 100644 --- a/server/bindings/HttpCloudEndpoint.ts +++ b/server/bindings/HttpCloudEndpoint.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HttpCloudEndpoint = "/register_new_app" | "/register_with_password" | "/login_with_password_start" | "/login_with_password_finish" | "/login_with_google" | "/register_new_team" | "/remove_user_from_team" | "/get_user_joined_teams" | "/events" | "/invite_user_to_team" | "/accept_team_invite" | "/get_team_user_invites" | "/get_user_team_invites" | "/cancel_team_user_invite" | "/cancel_user_team_invite" | "/get_app_events" | "/reset_password_start" | "/reset_password_finish" | "/verify_domain_start" | "/verify_domain_finish" | "/remove_whitelisted_domain"; \ No newline at end of file +export type HttpCloudEndpoint = "/register_new_app" | "/register_with_password" | "/login_with_password_start" | "/login_with_password_finish" | "/login_with_google" | "/register_new_team" | "/remove_user_from_team" | "/get_user_joined_teams" | "/events" | "/invite_user_to_team" | "/accept_team_invite" | "/get_team_user_invites" | "/get_user_team_invites" | "/cancel_team_user_invite" | "/cancel_user_team_invite" | "/get_app_events" | "/reset_password_start" | "/reset_password_finish" | "/verify_domain_start" | "/verify_domain_finish" | "/remove_whitelisted_domain" | "/register_with_passkey_start" | "/register_with_passkey_finish"; \ No newline at end of file diff --git a/server/bindings/HttpRegisterWithPasskeyFinishResponse.ts b/server/bindings/HttpRegisterWithPasskeyFinishResponse.ts new file mode 100644 index 00000000..3e3a4503 --- /dev/null +++ b/server/bindings/HttpRegisterWithPasskeyFinishResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type HttpRegisterWithPasskeyFinishResponse = null; \ No newline at end of file diff --git a/server/bindings/HttpRegisterWithPasskeyStartRequest.ts b/server/bindings/HttpRegisterWithPasskeyStartRequest.ts new file mode 100644 index 00000000..2be9179c --- /dev/null +++ b/server/bindings/HttpRegisterWithPasskeyStartRequest.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface HttpRegisterWithPasskeyStartRequest { email: string, } \ No newline at end of file diff --git a/server/src/cloud_state.rs b/server/src/cloud_state.rs index d7972599..3d1c4bc5 100644 --- a/server/src/cloud_state.rs +++ b/server/src/cloud_state.rs @@ -1,4 +1,5 @@ use crate::{ + env::ENVIRONMENT, ip_geolocation::GeolocationRequester, mailer::{entry::run_mailer, mailer::Mailer}, structs::session_cache::ApiSessionsCache, @@ -10,8 +11,10 @@ use hickory_resolver::{ AsyncResolver, TokioAsyncResolver, }; use r_cache::cache::Cache; +use reqwest::Url; use std::{sync::Arc, time::Duration}; use tokio::task; +use webauthn_rs::{Webauthn, WebauthnBuilder}; pub type DnsResolver = AsyncResolver>; @@ -22,6 +25,7 @@ pub struct CloudState { pub sessions_cache: Arc, pub mailer: Arc, pub dns_resolver: Arc, + pub webauthn: Arc, } impl CloudState { @@ -32,12 +36,31 @@ impl CloudState { let mailer = Arc::new(run_mailer().await.unwrap()); let dns_resolver = Arc::new(TokioAsyncResolver::tokio_from_system_conf().unwrap()); + // Passkey + let rp_id = match ENVIRONMENT() { + "DEV" => "localhost", + _ => panic!("Invalid ENVIRONMENT env"), + }; + // Url containing the effective domain name + let rp_origin = Url::parse(match ENVIRONMENT() { + "DEV" => "http://localhost:3000", + _ => panic!("Invalid ENVIRONMENT env"), + }) + .expect("Cant parse rp_origin"); + let builder = WebauthnBuilder::new(rp_id, &rp_origin) + .expect("Invalid configuration") + .rp_name("Nightly Connect Cloud"); + + // Consume the builder and create our webauthn instance. + let webauthn = Arc::new(builder.build().expect("Invalid configuration")); + Self { db: db_arc, geo_location: geo_loc_requester, sessions_cache, mailer, dns_resolver, + webauthn, } } } diff --git a/server/src/http/cloud/domains/verify_domain_finish.rs b/server/src/http/cloud/domains/verify_domain_finish.rs index ee4553e5..46a7a63d 100644 --- a/server/src/http/cloud/domains/verify_domain_finish.rs +++ b/server/src/http/cloud/domains/verify_domain_finish.rs @@ -3,10 +3,7 @@ use crate::{ env::is_env_production, http::cloud::utils::{custom_validate_domain_name, custom_validate_uuid}, middlewares::auth_middleware::UserId, - structs::{ - cloud::api_cloud_errors::CloudApiErrors, - session_cache::{ApiSessionsCache, SessionCache, SessionsCacheKey}, - }, + structs::cloud::api_cloud_errors::CloudApiErrors, }; use anyhow::bail; use axum::{extract::State, http::StatusCode, Extension, Json}; @@ -33,7 +30,6 @@ pub struct HttpVerifyDomainFinishResponse {} pub async fn verify_domain_finish( State(db): State>, - State(sessions_cache): State>, State(dns_resolver): State>, Extension(user_id): Extension, Json(request): Json, @@ -105,26 +101,36 @@ pub async fn verify_domain_finish( )); } - // Get session data - let sessions_key = SessionsCacheKey::DomainVerification(domain_name.clone()).to_string(); - let session_data = match sessions_cache.get(&sessions_key) { - Some(SessionCache::VerifyDomain(session)) => session, - _ => { + // Get challenge data + let domain_verification_challenge = match db + .get_domain_verification_by_domain_name(&domain_name) + .await + { + Ok(Some(challenge)) => challenge, + Ok(None) => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::DomainVerificationNotStarted.to_string(), + )) + } + Err(err) => { + error!("Failed to get domain verification challenge: {:?}", err); return Err(( StatusCode::INTERNAL_SERVER_ERROR, - CloudApiErrors::InternalServerError.to_string(), + CloudApiErrors::DatabaseError.to_string(), )); } }; - // Remove leftover session data - sessions_cache.remove(&sessions_key); - // Validate the code // Attempt to resolve the TXT records for the given domain, only on PROD if is_env_production() { - if let Err(err) = - check_verification_code(&dns_resolver, &domain_name, &session_data.code).await + if let Err(err) = check_verification_code( + &dns_resolver, + &domain_name, + &domain_verification_challenge.code, + ) + .await { error!("Failed to verify domain: {:?}, err: {:?}", domain_name, err); return Err(( @@ -135,10 +141,17 @@ pub async fn verify_domain_finish( } // Add domain to whitelist + let mut tx = db.connection_pool.begin().await.unwrap(); + if let Err(err) = db - .add_new_whitelisted_domain(&request.app_id, &domain_name) + .add_new_whitelisted_domain(&mut tx, &request.app_id, &domain_name) .await { + let _ = tx + .rollback() + .await + .map_err(|err| error!("Failed to rollback transaction: {:?}", err)); + error!("Failed to add domain to whitelist: {:?}", err); return Err(( StatusCode::INTERNAL_SERVER_ERROR, @@ -146,6 +159,30 @@ pub async fn verify_domain_finish( )); } + // Update domain verification entry + if let Err(err) = db.finish_domain_verification(&mut tx, &domain_name).await { + let _ = tx + .rollback() + .await + .map_err(|err| error!("Failed to rollback transaction: {:?}", err)); + + error!("Failed to finish domain verification: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + + // Commit transaction + if let Err(err) = tx.commit().await { + error!("Failed to commit transaction: {:?}", err); + + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + Ok(Json(HttpVerifyDomainFinishResponse {})) } diff --git a/server/src/http/cloud/domains/verify_domain_start.rs b/server/src/http/cloud/domains/verify_domain_start.rs index 7e9ffa8b..1920b5de 100644 --- a/server/src/http/cloud/domains/verify_domain_start.rs +++ b/server/src/http/cloud/domains/verify_domain_start.rs @@ -1,11 +1,7 @@ use crate::{ http::cloud::utils::{custom_validate_domain_name, custom_validate_uuid}, middlewares::auth_middleware::UserId, - structs::{ - cloud::api_cloud_errors::CloudApiErrors, - session_cache::{ApiSessionsCache, DomainVerification, SessionCache, SessionsCacheKey}, - }, - utils::get_timestamp_in_milliseconds, + structs::cloud::api_cloud_errors::CloudApiErrors, }; use axum::{extract::State, http::StatusCode, Extension, Json}; use database::{db::Db, structs::privilege_level::PrivilegeLevel}; @@ -33,7 +29,6 @@ pub struct HttpVerifyDomainStartResponse { pub async fn verify_domain_start( State(db): State>, - State(sessions_cache): State>, Extension(user_id): Extension, Json(request): Json, ) -> Result, (StatusCode, String)> { @@ -104,24 +99,39 @@ pub async fn verify_domain_start( )); } - // Generate verification code - let verification_code = - format!("TXT Nc verification code {}", uuid7::uuid7().to_string()).to_string(); + // Check if challenge already exists + let verification_code = match db + .get_domain_verification_by_domain_name(&domain_name) + .await + { + Ok(Some(challenge)) => challenge.code, + Ok(None) => { + // Challenge does not exist, generate new code + let code = + format!("TXT NCC verification code {}", uuid7::uuid7().to_string()).to_string(); - // Save to cache - let sessions_key = SessionsCacheKey::DomainVerification(domain_name.clone()).to_string(); - // Remove leftover session data - sessions_cache.remove(&sessions_key); + // Save challenge to the database + if let Err(err) = db + .create_new_domain_verification_entry(&domain_name, &request.app_id, &code) + .await + { + error!("Failed to save challenge to the database: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } - sessions_cache.set( - sessions_key, - SessionCache::VerifyDomain(DomainVerification { - domain_name: domain_name.clone(), - code: verification_code.clone(), - created_at: get_timestamp_in_milliseconds(), - }), - None, - ); + code + } + Err(err) => { + error!("Failed to check if challenge exists: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + }; Ok(Json(HttpVerifyDomainStartResponse { code: verification_code, diff --git a/server/src/http/cloud/login/login_with_google.rs b/server/src/http/cloud/login/login_with_google.rs index 6beaa961..560a7837 100644 --- a/server/src/http/cloud/login/login_with_google.rs +++ b/server/src/http/cloud/login/login_with_google.rs @@ -8,10 +8,7 @@ use axum::{ http::StatusCode, Json, }; -use database::{ - db::Db, - tables::{grafana_users::table_struct::GrafanaUser, utils::get_current_datetime}, -}; +use database::db::Db; use garde::Validate; use log::error; use pwhash::bcrypt; @@ -98,13 +95,17 @@ pub async fn login_with_google( // Register user let user_id = uuid7().to_string(); - let grafana_user = GrafanaUser { - user_id: user_id.clone(), - email: request.email.clone(), - password_hash: hashed_password, - creation_timestamp: get_current_datetime(), - }; - if let Err(err) = db.add_new_user(&grafana_user).await { + + if let Err(err) = db + .add_new_user( + &user_id, + &request.email, + Some(&hashed_password), + // None for passkeys + None, + ) + .await + { error!("Failed to create user: {:?}", err); return Err(( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/server/src/http/cloud/login/login_with_password.rs b/server/src/http/cloud/login/login_with_password.rs index fcd7042d..652fa4a7 100644 --- a/server/src/http/cloud/login/login_with_password.rs +++ b/server/src/http/cloud/login/login_with_password.rs @@ -63,12 +63,19 @@ pub async fn login_with_password( } }; + // Check if user has password + let password_hash = match user.password_hash { + Some(password_hash) => password_hash, + None => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::PasswordNotSet.to_string(), + )); + } + }; + // Verify password - if bcrypt::verify( - format!("{}_{}", NONCE(), request.password), - &user.password_hash, - ) == false - { + if bcrypt::verify(format!("{}_{}", NONCE(), request.password), &password_hash) == false { return Err(( StatusCode::BAD_REQUEST, CloudApiErrors::IncorrectPassword.to_string(), diff --git a/server/src/http/cloud/register/mod.rs b/server/src/http/cloud/register/mod.rs index 7eeb39bf..bee81985 100644 --- a/server/src/http/cloud/register/mod.rs +++ b/server/src/http/cloud/register/mod.rs @@ -1,2 +1,4 @@ +pub mod register_with_passkey_finish; +pub mod register_with_passkey_start; pub mod register_with_password_finish; pub mod register_with_password_start; diff --git a/server/src/http/cloud/register/register_with_passkey_finish.rs b/server/src/http/cloud/register/register_with_passkey_finish.rs new file mode 100644 index 00000000..e5505098 --- /dev/null +++ b/server/src/http/cloud/register/register_with_passkey_finish.rs @@ -0,0 +1,109 @@ +use crate::{ + env::is_env_production, + http::cloud::utils::{custom_validate_verification_code, validate_request}, + structs::{ + cloud::api_cloud_errors::CloudApiErrors, + session_cache::{ApiSessionsCache, SessionCache, SessionsCacheKey}, + }, +}; +use axum::{extract::State, http::StatusCode, Json}; +use database::db::Db; +use garde::Validate; +use log::error; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use ts_rs::TS; +use uuid7::uuid7; +use webauthn_rs::prelude::RegisterPublicKeyCredential; +use webauthn_rs::Webauthn; + +#[derive(Validate, Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpRegisterWithPasskeyFinishRequest { + #[garde(email)] + pub email: String, + #[garde(skip)] + pub credential: RegisterPublicKeyCredential, + #[garde(custom(custom_validate_verification_code))] + pub code: String, +} + +#[derive(Validate, Clone, Debug, Deserialize, Serialize, TS)] +#[ts(export)] +pub struct HttpRegisterWithPasskeyFinishResponse {} + +pub async fn register_with_passkey_finish( + State(db): State>, + State(web_auth): State>, + State(sessions_cache): State>, + Json(request): Json, +) -> Result, (StatusCode, String)> { + // Validate request + validate_request(&request, &())?; + + // Get cache data + let sessions_key = SessionsCacheKey::PasskeyVerification(request.email.clone()).to_string(); + let session_data = match sessions_cache.get(&sessions_key) { + Some(SessionCache::VerifyPasskeyRegister(session)) => session, + _ => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::InternalServerError.to_string(), + )); + } + }; + + // Remove leftover session data + sessions_cache.remove(&sessions_key); + + // validate code only on production + if is_env_production() { + // Validate the code + if session_data.code != request.code { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::InvalidVerificationCode.to_string(), + )); + } + } + + // Validate passkey register + let passkey = match web_auth.finish_passkey_registration( + &request.credential, + &session_data.passkey_registration_state, + ) { + Ok(sk) => sk, + Err(err) => { + error!("Failed to finish passkey registration: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::WebAuthnError.to_string(), + )); + } + }; + + // Save user to database + let user_id = uuid7().to_string(); + + match db + .add_new_user( + &user_id, + &request.email, + // None for password + None, + Some(&passkey), + ) + .await + { + Ok(_) => { + return Ok(Json(HttpRegisterWithPasskeyFinishResponse {})); + } + Err(err) => { + error!("Failed to create user: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } +} diff --git a/server/src/http/cloud/register/register_with_passkey_start.rs b/server/src/http/cloud/register/register_with_passkey_start.rs new file mode 100644 index 00000000..a1721f23 --- /dev/null +++ b/server/src/http/cloud/register/register_with_passkey_start.rs @@ -0,0 +1,117 @@ +use crate::{ + env::is_env_production, + http::cloud::utils::{generate_verification_code, validate_request}, + mailer::{ + mail_requests::{EmailConfirmationRequest, SendEmailRequest}, + mailer::Mailer, + }, + structs::{ + cloud::api_cloud_errors::CloudApiErrors, + session_cache::{ApiSessionsCache, PasskeyVerification, SessionCache, SessionsCacheKey}, + }, + utils::get_timestamp_in_milliseconds, +}; +use axum::{extract::State, http::StatusCode, Json}; +use database::db::Db; +use garde::Validate; +use log::error; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use ts_rs::TS; +use webauthn_rs::prelude::{CreationChallengeResponse, Uuid}; +use webauthn_rs::Webauthn; + +#[derive(Validate, Clone, Debug, Deserialize, Serialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct HttpRegisterWithPasskeyStartRequest { + #[garde(email)] + pub email: String, +} + +pub type HttpRegisterWithPasskeyStartResponse = CreationChallengeResponse; + +pub async fn register_with_passkey_start( + State(db): State>, + State(web_auth): State>, + State(mailer): State>, + State(sessions_cache): State>, + Json(request): Json, +) -> Result, (StatusCode, String)> { + // Validate request + validate_request(&request, &())?; + + // Check if user already exists + match db.get_user_by_email(&request.email).await { + Ok(Some(_)) => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::EmailAlreadyExists.to_string(), + )) + } + Ok(None) => { + // Continue + } + Err(err) => { + error!("Failed to check if user exists: {:?}", err); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + } + + // Save to cache register request + let sessions_key = SessionsCacheKey::PasskeyVerification(request.email.clone()).to_string(); + + // Remove leftover session data + sessions_cache.remove(&sessions_key); + + // Generate challenge + let temp_user_id = Uuid::new_v4(); + let res = + web_auth.start_passkey_registration(temp_user_id, &request.email, &request.email, None); + + let (ccr, reg_state) = match res { + Ok((ccr, reg_state)) => (ccr, reg_state), + Err(_) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::WebAuthnError.to_string(), + )) + } + }; + + // Generate email verification code + let code = generate_verification_code(); + + // Send email with code, only for PROD + if is_env_production() { + let request = SendEmailRequest::EmailConfirmation(EmailConfirmationRequest { + email: request.email.clone(), + code: code.clone(), + }); + + if let Some(err) = mailer.handle_email_request(&request).error_message { + error!("Failed to send email: {:?}, request: {:?}", err, request); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::InternalServerError.to_string(), + )); + } + } + + // Save the challenge to the cache + sessions_cache.set( + sessions_key, + SessionCache::VerifyPasskeyRegister(PasskeyVerification { + email: request.email.clone(), + passkey_registration_state: reg_state, + code, + created_at: get_timestamp_in_milliseconds(), + }), + None, + ); + + return Ok(Json(ccr)); +} diff --git a/server/src/http/cloud/register/register_with_password_finish.rs b/server/src/http/cloud/register/register_with_password_finish.rs index 471977fd..38f9e22e 100644 --- a/server/src/http/cloud/register/register_with_password_finish.rs +++ b/server/src/http/cloud/register/register_with_password_finish.rs @@ -7,10 +7,7 @@ use crate::{ }, }; use axum::{extract::State, http::StatusCode, Json}; -use database::{ - db::Db, - tables::{grafana_users::table_struct::GrafanaUser, utils::get_current_datetime}, -}; +use database::db::Db; use garde::Validate; use log::error; use serde::{Deserialize, Serialize}; @@ -68,13 +65,17 @@ pub async fn register_with_password_finish( // Save the user to the database let user_id = uuid7().to_string(); - let grafana_user = GrafanaUser { - user_id: user_id.clone(), - email: request.email.clone(), - password_hash: session_data.hashed_password, - creation_timestamp: get_current_datetime(), - }; - if let Err(err) = db.add_new_user(&grafana_user).await { + + if let Err(err) = db + .add_new_user( + &user_id, + &request.email, + Some(&session_data.hashed_password), + // None for passkeys + None, + ) + .await + { error!("Failed to create user: {:?}", err); return Err(( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/server/src/routes/cloud_router.rs b/server/src/routes/cloud_router.rs index 697106d2..bfec1457 100644 --- a/server/src/routes/cloud_router.rs +++ b/server/src/routes/cloud_router.rs @@ -15,6 +15,8 @@ use crate::{ invite_user_to_team::invite_user_to_team, login::{login_with_google::login_with_google, login_with_password::login_with_password}, register::{ + register_with_passkey_finish::register_with_passkey_finish, + register_with_passkey_start::register_with_passkey_start, register_with_password_finish::register_with_password_finish, register_with_password_start::register_with_password_start, }, @@ -75,6 +77,14 @@ pub fn public_router(state: ServerState) -> Router { &HttpCloudEndpoint::ResetPasswordFinish.to_string(), post(reset_password_finish), ) + .route( + &HttpCloudEndpoint::RegisterWithPasskeyStart.to_string(), + post(register_with_passkey_start), + ) + .route( + &HttpCloudEndpoint::RegisterWithPasskeyFinish.to_string(), + post(register_with_passkey_finish), + ) .route(&HttpCloudEndpoint::Events.to_string(), post(events)) .with_state(state) } diff --git a/server/src/state.rs b/server/src/state.rs index 30d084fc..b7fe77e7 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -21,6 +21,7 @@ use std::{ sync::Arc, }; use tokio::sync::RwLock; +use webauthn_rs::Webauthn; pub type SessionId = String; pub type ClientId = String; @@ -45,33 +46,35 @@ impl FromRef for Arc { state.cloud_state.as_ref().unwrap().db.clone() } } - impl FromRef for Arc { fn from_ref(state: &ServerState) -> Self { // Safe as middleware will prevent this from being None state.cloud_state.as_ref().unwrap().geo_location.clone() } } - impl FromRef for Arc { fn from_ref(state: &ServerState) -> Self { // Safe as middleware will prevent this from being None state.cloud_state.as_ref().unwrap().sessions_cache.clone() } } - impl FromRef for Arc { fn from_ref(state: &ServerState) -> Self { // Safe as middleware will prevent this from being None state.cloud_state.as_ref().unwrap().mailer.clone() } } - impl FromRef for Arc { fn from_ref(state: &ServerState) -> Self { state.cloud_state.as_ref().unwrap().dns_resolver.clone() } } +impl FromRef for Arc { + fn from_ref(state: &ServerState) -> Self { + // Safe as middleware will prevent this from being None + state.cloud_state.as_ref().unwrap().webauthn.clone() + } +} #[async_trait] pub trait DisconnectUser { diff --git a/server/src/structs/cloud/api_cloud_errors.rs b/server/src/structs/cloud/api_cloud_errors.rs index e21628e1..13fa3194 100644 --- a/server/src/structs/cloud/api_cloud_errors.rs +++ b/server/src/structs/cloud/api_cloud_errors.rs @@ -37,4 +37,7 @@ pub enum CloudApiErrors { DomainAlreadyVerified, DomainVerificationFailure, DomainNotFound, + DomainVerificationNotStarted, + WebAuthnError, + PasswordNotSet, } diff --git a/server/src/structs/cloud/cloud_http_endpoints.rs b/server/src/structs/cloud/cloud_http_endpoints.rs index aaaf1d20..aa68a3b6 100644 --- a/server/src/structs/cloud/cloud_http_endpoints.rs +++ b/server/src/structs/cloud/cloud_http_endpoints.rs @@ -46,6 +46,10 @@ pub enum HttpCloudEndpoint { VerifyDomainFinish, #[serde(rename = "/remove_whitelisted_domain")] RemoveWhitelistedDomain, + #[serde(rename = "/register_with_passkey_start")] + RegisterWithPasskeyStart, + #[serde(rename = "/register_with_passkey_finish")] + RegisterWithPasskeyFinish, } impl HttpCloudEndpoint { @@ -76,6 +80,12 @@ impl HttpCloudEndpoint { HttpCloudEndpoint::VerifyDomainStart => "/verify_domain_start".to_string(), HttpCloudEndpoint::VerifyDomainFinish => "/verify_domain_finish".to_string(), HttpCloudEndpoint::RemoveWhitelistedDomain => "/remove_whitelisted_domain".to_string(), + HttpCloudEndpoint::RegisterWithPasskeyStart => { + "/register_with_passkey_start".to_string() + } + HttpCloudEndpoint::RegisterWithPasskeyFinish => { + "/register_with_passkey_finish".to_string() + } } } } diff --git a/server/src/structs/session_cache.rs b/server/src/structs/session_cache.rs index 9018a2c9..262b8e53 100644 --- a/server/src/structs/session_cache.rs +++ b/server/src/structs/session_cache.rs @@ -6,13 +6,13 @@ pub type ApiSessionsCache = Cache; pub enum SessionCache { VerifyRegister(RegisterVerification), ResetPassword(ResetPasswordVerification), - VerifyDomain(DomainVerification), + VerifyPasskeyRegister(PasskeyVerification), } pub enum SessionsCacheKey { RegisterVerification(String), // user email ResetPasswordVerification(String), // user email - DomainVerification(String), // domain name + PasskeyVerification(String), // user email } impl SessionsCacheKey { @@ -20,7 +20,7 @@ impl SessionsCacheKey { match self { SessionsCacheKey::RegisterVerification(email) => format!("reg_ver_{}", email), SessionsCacheKey::ResetPasswordVerification(email) => format!("pass_res_{}", email), - SessionsCacheKey::DomainVerification(domain_name) => format!("dom_ver_{}", domain_name), + SessionsCacheKey::PasskeyVerification(email) => format!("pass_reg_{}", email), } } } @@ -42,8 +42,9 @@ pub struct ResetPasswordVerification { } #[derive(Debug, Clone)] -pub struct DomainVerification { - pub domain_name: String, +pub struct PasskeyVerification { + pub email: String, pub code: String, + pub passkey_registration_state: webauthn_rs::prelude::PasskeyRegistration, pub created_at: u64, }