diff --git a/server/src/http/cloud/add_passkey_finish.rs b/server/src/http/cloud/add_passkey_finish.rs new file mode 100644 index 00000000..1a49743b --- /dev/null +++ b/server/src/http/cloud/add_passkey_finish.rs @@ -0,0 +1,121 @@ +use crate::{ + middlewares::auth_middleware::UserId, + structs::{ + cloud::api_cloud_errors::CloudApiErrors, + session_cache::{ApiSessionsCache, SessionCache, SessionsCacheKey}, + }, +}; +use axum::{extract::State, http::StatusCode, Extension, Json}; +use database::db::Db; +use garde::Validate; +use log::{error, warn}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use ts_rs::TS; +use webauthn_rs::{prelude::RegisterPublicKeyCredential, Webauthn}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct HttpAddPasskeyFinishRequest { + pub credential: RegisterPublicKeyCredential, +} + +#[derive(Validate, Clone, Debug, Deserialize, Serialize, TS)] +#[ts(export)] +pub struct HttpAddPasskeyFinishResponse {} + +pub async fn add_passkey_finish( + State(db): State>, + State(web_auth): State>, + State(sessions_cache): State>, + Extension(user_id): Extension, + Json(request): Json, +) -> Result, (StatusCode, String)> { + // Get cache data + let sessions_key = SessionsCacheKey::AddPasskey(user_id.clone()).to_string(); + let session_data = match sessions_cache.get(&sessions_key) { + Some(SessionCache::VerifyAddPasskey(session)) => session, + _ => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::InternalServerError.to_string(), + )); + } + }; + + // Remove leftover session data + sessions_cache.remove(&sessions_key); + + // Validate new passkey registration + let passkey = match web_auth.finish_passkey_registration( + &request.credential, + &session_data.passkey_registration_state, + ) { + Ok(sk) => sk, + Err(err) => { + warn!( + "Failed to finish adding new passkey: {:?}, user_id: {}", + err, &user_id + ); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::WebAuthnError.to_string(), + )); + } + }; + + // Validate new passkey + // Get user data + let user_data = match db.get_user_by_user_id(&user_id).await { + Ok(Some(user_data)) => user_data, + Ok(None) => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserDoesNotExist.to_string(), + )); + } + Err(err) => { + error!("Failed to get user data: {:?}, user_id: {}", err, &user_id); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + }; + + // Check if user has already added this passkey + let mut passkeys = match user_data.passkeys { + Some(passkeys) => { + if passkeys.contains(&passkey) { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::PasskeyAlreadyExists.to_string(), + )); + } + + passkeys + } + None => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserDoesNotHavePasskey.to_string(), + )); + } + }; + + // Add new passkey + passkeys.push(passkey); + + // Update passkeys in database + if let Err(err) = db.update_passkeys(&user_data.email, &passkeys).await { + error!( + "Failed to update user passkeys: {:?}, user_email: {}", + err, &user_data.email + ); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + + return Ok(Json(HttpAddPasskeyFinishResponse {})); +} diff --git a/server/src/http/cloud/add_passkey_start.rs b/server/src/http/cloud/add_passkey_start.rs new file mode 100644 index 00000000..3b67db47 --- /dev/null +++ b/server/src/http/cloud/add_passkey_start.rs @@ -0,0 +1,80 @@ +use crate::{ + middlewares::auth_middleware::UserId, + structs::{ + cloud::api_cloud_errors::CloudApiErrors, + session_cache::{AddPasskeyVerification, ApiSessionsCache, SessionCache, SessionsCacheKey}, + }, + utils::get_timestamp_in_milliseconds, +}; +use axum::{extract::State, http::StatusCode, Extension, Json}; +use database::db::Db; +use log::error; +use std::sync::Arc; +use webauthn_rs::{ + prelude::{CreationChallengeResponse, Uuid}, + Webauthn, +}; + +pub type HttpAddPasskeyStartResponse = CreationChallengeResponse; + +pub async fn add_passkey_start( + State(db): State>, + State(web_auth): State>, + State(sessions_cache): State>, + Extension(user_id): Extension, +) -> Result, (StatusCode, String)> { + // Get user data + let user_data = match db.get_user_by_user_id(&user_id).await { + Ok(Some(user_data)) => user_data, + Ok(None) => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserDoesNotExist.to_string(), + )) + } + Err(err) => { + error!( + "Failed to check if user exists: {:?}, user_id: {}", + err, user_id + ); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + }; + + // Save to cache passkey challenge request + let sessions_key = SessionsCacheKey::AddPasskey(user_id.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, &user_data.email, &user_data.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(), + )) + } + }; + + // Save the challenge to the cache + sessions_cache.set( + sessions_key, + SessionCache::VerifyAddPasskey(AddPasskeyVerification { + user_id, + passkey_registration_state: reg_state, + created_at: get_timestamp_in_milliseconds(), + }), + None, + ); + + return Ok(Json(ccr)); +} diff --git a/server/src/http/cloud/delete_passkey.rs b/server/src/http/cloud/delete_passkey.rs new file mode 100644 index 00000000..ed0ebcef --- /dev/null +++ b/server/src/http/cloud/delete_passkey.rs @@ -0,0 +1,125 @@ +use crate::{ + middlewares::auth_middleware::UserId, + structs::{ + cloud::api_cloud_errors::CloudApiErrors, + session_cache::{ApiSessionsCache, SessionCache, SessionsCacheKey}, + }, +}; +use axum::{extract::State, http::StatusCode, Extension, Json}; +use database::db::Db; +use garde::Validate; +use log::{error, warn}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use webauthn_rs::prelude::PublicKeyCredential; +use webauthn_rs::Webauthn; + +#[derive(Validate, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HttpDeletePasskeyRequest { + #[garde(skip)] + pub passkey_id: String, + #[garde(skip)] + pub passkey_credential: PublicKeyCredential, +} + +pub async fn delete_passkey( + State(db): State>, + State(web_auth): State>, + State(sessions_cache): State>, + Extension(user_id): Extension, + Json(payload): Json, +) -> Result<(), (StatusCode, String)> { + // Get user data + let user_data = match db.get_user_by_user_id(&user_id).await { + Ok(Some(user_data)) => user_data, + Ok(None) => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserDoesNotExist.to_string(), + )) + } + Err(err) => { + error!( + "Failed to check if user exists: {:?}, user_id: {}", + err, user_id + ); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + }; + + // Get user passkeys + let mut passkeys = match user_data.passkeys { + Some(passkey) => passkey, + None => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserDoesNotHavePasskey.to_string(), + )); + } + }; + + // Get cache data + let sessions_key = SessionsCacheKey::Passkey2FA(user_id.clone()).to_string(); + let session_data = match sessions_cache.get(&sessions_key) { + Some(SessionCache::Passkey2FA(session)) => session, + _ => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::InternalServerError.to_string(), + )); + } + }; + + // Remove leftover session data + sessions_cache.remove(&sessions_key); + + // Finish passkey authentication + if let Err(err) = + web_auth.finish_passkey_authentication(&payload.passkey_credential, &session_data) + { + warn!( + "Failed to finish passkey authentication: {:?}, user_id: {}", + err, user_id + ); + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::InvalidPasskeyCredential.to_string(), + )); + } + + // Remove passkey + match passkeys + .iter() + .position(|x| x.cred_id().to_string() == payload.passkey_id) + { + Some(index) => { + // Remove passkey + passkeys.remove(index); + } + None => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::PasskeyDoesNotExist.to_string(), + )) + } + } + + // Update user passkeys in database + if let Err(err) = db.update_passkeys(&user_id, &passkeys).await { + error!( + "Failed to update user passkeys: {:?}, user_id: {}", + err, user_id + ); + + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + + return Ok(()); +} diff --git a/server/src/http/cloud/get_passkey_challenge.rs b/server/src/http/cloud/get_passkey_challenge.rs new file mode 100644 index 00000000..f249d368 --- /dev/null +++ b/server/src/http/cloud/get_passkey_challenge.rs @@ -0,0 +1,73 @@ +use crate::{ + middlewares::auth_middleware::UserId, + structs::{ + cloud::api_cloud_errors::CloudApiErrors, + session_cache::{ApiSessionsCache, SessionCache, SessionsCacheKey}, + }, +}; +use axum::{extract::State, http::StatusCode}; +use axum::{Extension, Json}; +use database::db::Db; +use log::error; +use std::sync::Arc; +use webauthn_rs::prelude::RequestChallengeResponse; +use webauthn_rs::Webauthn; + +pub type HttpTwoFactorWithPasskeyStartResponse = RequestChallengeResponse; + +pub async fn get_passkey_challenge( + State(db): State>, + State(web_auth): State>, + State(sessions_cache): State>, + Extension(user_id): Extension, +) -> Result, (StatusCode, String)> { + // Get user data + let user_data = match db.get_user_by_user_id(&user_id).await { + Ok(Some(user_data)) => user_data, + Ok(None) => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserDoesNotExist.to_string(), + )) + } + Err(err) => { + error!( + "Failed to check if user exists: {:?}, user_id: {}", + err, user_id + ); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::DatabaseError.to_string(), + )); + } + }; + + let passkey = match user_data.passkeys { + Some(passkey) => passkey, + None => { + return Err(( + StatusCode::BAD_REQUEST, + CloudApiErrors::UserDoesNotHavePasskey.to_string(), + )); + } + }; + + // Save to cache passkey challenge request + let sessions_key = SessionsCacheKey::PasskeyVerification(user_id.clone()).to_string(); + + // Remove leftover session data + sessions_cache.remove(&sessions_key); + + match web_auth.start_passkey_authentication(&passkey) { + Ok((rcr, auth_state)) => { + sessions_cache.set(sessions_key, SessionCache::Passkey2FA(auth_state), None); + return Ok(Json(rcr)); + } + Err(_) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + CloudApiErrors::WebAuthnError.to_string(), + )); + } + }; +} diff --git a/server/src/http/cloud/mod.rs b/server/src/http/cloud/mod.rs index 374108c0..6c08e460 100644 --- a/server/src/http/cloud/mod.rs +++ b/server/src/http/cloud/mod.rs @@ -1,9 +1,13 @@ pub mod accept_team_invite; +pub mod add_passkey_finish; +pub mod add_passkey_start; pub mod cancel_team_user_invite; pub mod cancel_user_team_invite; +pub mod delete_passkey; pub mod domains; pub mod events; pub mod get_events; +pub mod get_passkey_challenge; pub mod get_team_user_invites; pub mod get_user_joined_teams; pub mod get_user_team_invites; diff --git a/server/src/routes/cloud_router.rs b/server/src/routes/cloud_router.rs index b3d5c922..3c0ae4bd 100644 --- a/server/src/routes/cloud_router.rs +++ b/server/src/routes/cloud_router.rs @@ -1,14 +1,18 @@ use crate::{ http::cloud::{ accept_team_invite::accept_team_invite, + add_passkey_finish::add_passkey_finish, + add_passkey_start::add_passkey_start, cancel_team_user_invite::cancel_team_user_invite, cancel_user_team_invite::cancel_user_team_invite, + delete_passkey::delete_passkey, domains::{ remove_whitelisted_domain::remove_whitelisted_domain, verify_domain_finish::verify_domain_finish, verify_domain_start::verify_domain_start, }, events::events::events, get_events::get_events, + get_passkey_challenge::get_passkey_challenge, get_team_user_invites::get_team_user_invites, get_user_joined_teams::get_user_joined_teams, get_user_team_invites::get_user_team_invites, @@ -153,5 +157,21 @@ pub fn private_router(state: ServerState) -> Router { &HttpCloudEndpoint::RemoveWhitelistedDomain.to_string(), post(remove_whitelisted_domain), ) + .route( + &HttpCloudEndpoint::GetPasskeyChallenge.to_string(), + get(get_passkey_challenge), + ) + .route( + &HttpCloudEndpoint::DeletePasskey.to_string(), + post(delete_passkey), + ) + .route( + &HttpCloudEndpoint::AddPasskeyStart.to_string(), + post(add_passkey_start), + ) + .route( + &HttpCloudEndpoint::AddPasskeyFinish.to_string(), + post(add_passkey_finish), + ) .with_state(state) } diff --git a/server/src/structs/cloud/api_cloud_errors.rs b/server/src/structs/cloud/api_cloud_errors.rs index 2c24d78d..7852a592 100644 --- a/server/src/structs/cloud/api_cloud_errors.rs +++ b/server/src/structs/cloud/api_cloud_errors.rs @@ -42,4 +42,6 @@ pub enum CloudApiErrors { PasswordNotSet, UserDoesNotHavePasskey, PasskeyAlreadyExists, + InvalidPasskeyCredential, + PasskeyDoesNotExist, } diff --git a/server/src/structs/cloud/cloud_http_endpoints.rs b/server/src/structs/cloud/cloud_http_endpoints.rs index 86738556..80984919 100644 --- a/server/src/structs/cloud/cloud_http_endpoints.rs +++ b/server/src/structs/cloud/cloud_http_endpoints.rs @@ -54,6 +54,14 @@ pub enum HttpCloudEndpoint { ResetPasskeyStart, #[serde(rename = "/reset_passkey_finish")] ResetPasskeyFinish, + #[serde(rename = "/get_passkey_challenge")] + GetPasskeyChallenge, + #[serde(rename = "/delete_passkey")] + DeletePasskey, + #[serde(rename = "/add_passkey_start")] + AddPasskeyStart, + #[serde(rename = "/add_passkey_finish")] + AddPasskeyFinish, } impl HttpCloudEndpoint { @@ -92,6 +100,10 @@ impl HttpCloudEndpoint { } HttpCloudEndpoint::ResetPasskeyStart => "/reset_passkey_start".to_string(), HttpCloudEndpoint::ResetPasskeyFinish => "/reset_passkey_finish".to_string(), + HttpCloudEndpoint::DeletePasskey => "/delete_passkey".to_string(), + HttpCloudEndpoint::GetPasskeyChallenge => "/get_passkey_challenge".to_string(), + HttpCloudEndpoint::AddPasskeyStart => "/add_passkey_start".to_string(), + HttpCloudEndpoint::AddPasskeyFinish => "/add_passkey_finish".to_string(), } } } diff --git a/server/src/structs/session_cache.rs b/server/src/structs/session_cache.rs index 6607f132..4136b388 100644 --- a/server/src/structs/session_cache.rs +++ b/server/src/structs/session_cache.rs @@ -8,6 +8,8 @@ pub enum SessionCache { ResetPassword(ResetPasswordVerification), VerifyPasskeyRegister(PasskeyVerification), ResetPasskey(ResetPasskeyVerification), + Passkey2FA(webauthn_rs::prelude::PasskeyAuthentication), + VerifyAddPasskey(AddPasskeyVerification), } pub enum SessionsCacheKey { @@ -15,6 +17,8 @@ pub enum SessionsCacheKey { ResetPasswordVerification(String), // user email PasskeyVerification(String), // user email ResetPasskeyVerification(String), // user email + Passkey2FA(String), // user id + AddPasskey(String), // user id } impl SessionsCacheKey { @@ -24,6 +28,8 @@ impl SessionsCacheKey { SessionsCacheKey::ResetPasswordVerification(email) => format!("pass_res_{}", email), SessionsCacheKey::PasskeyVerification(email) => format!("pass_reg_{}", email), SessionsCacheKey::ResetPasskeyVerification(email) => format!("pass_res_{}", email), + SessionsCacheKey::Passkey2FA(user_id) => format!("pass_chal_{}", user_id), + SessionsCacheKey::AddPasskey(email) => format!("add_pass_{}", email), } } } @@ -59,3 +65,10 @@ pub struct ResetPasskeyVerification { pub passkey_registration_state: webauthn_rs::prelude::PasskeyRegistration, pub created_at: u64, } + +#[derive(Debug, Clone)] +pub struct AddPasskeyVerification { + pub user_id: String, + pub passkey_registration_state: webauthn_rs::prelude::PasskeyRegistration, + pub created_at: u64, +}