From bf538b55d1648dab44b97fd5fffc5bc7595809da Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 20 Sep 2024 03:32:00 -0400 Subject: [PATCH] new create user account (WIP) --- Cargo.lock | 1 + .../src/decode_and_verify_token_claims.rs | 48 ++- .../lib/api_clients/google_sign_in/src/lib.rs | 2 + crates/service/web/storyteller_web/Cargo.toml | 1 + .../web/storyteller_web/src/docs/api_doc.rs | 5 + ...ate_account_from_google_sign_in_handler.rs | 336 ++++++++++++++++++ .../src/http_server/endpoints/users/mod.rs | 1 + .../src/http_server/routes/user_routes.rs | 6 + 8 files changed, 382 insertions(+), 18 deletions(-) create mode 100644 crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_from_google_sign_in_handler.rs diff --git a/Cargo.lock b/Cargo.lock index 2eebab2223..eb36766d38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7162,6 +7162,7 @@ dependencies = [ "futures 0.3.29", "futures-core", "futures-util", + "google_sign_in", "hashing", "hostname 0.4.0", "http_server_common", diff --git a/crates/lib/api_clients/google_sign_in/src/decode_and_verify_token_claims.rs b/crates/lib/api_clients/google_sign_in/src/decode_and_verify_token_claims.rs index 3580fef587..076f0ce13c 100644 --- a/crates/lib/api_clients/google_sign_in/src/decode_and_verify_token_claims.rs +++ b/crates/lib/api_clients/google_sign_in/src/decode_and_verify_token_claims.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use crate::certs::key_map::KeyMap; use crate::claims::claims::Claims; use crate::claims::google_custom_claims::GoogleCustomClaims; @@ -6,27 +7,10 @@ use errors::{anyhow, AnyhowResult}; use jwt_simple::algorithms::RS256PublicKey; use jwt_simple::algorithms::RSAPublicKeyLike; use jwt_simple::common::VerificationOptions; -/* -Example payload: - iss https://accounts.google.com - azp 788843034237-uqcg8tbgofrcf1to37e1bqphd924jaf6.apps.googleusercontent.com - aud 788843034237-uqcg8tbgofrcf1to37e1bqphd924jaf6.apps.googleusercontent.com - sub 113101967612396793777 - email vocodes2020@gmail.com - email_verified true - nbf 1726786100 - name Vocodes Vocodes - picture https://lh3.googleusercontent.com/a/ACg8ocLz2-2OaAm0MQxR6j8CNr-Po8_Xr-aryATiCn4c0i_TuDmL_g=s96-c - given_name Vocodes - family_name Vocodes - iat 1726786400 - exp 1726790000 - jti 4d44eeac06ce79fc0ab2270cfeea30d8acf77613 - */ /// Decode a Google Sign In JWT. /// Verification options can be supplied to increase clock skew tolerance, etc. -pub fn decode_and_verify_token_claims(keys: &KeyMap, token: &str, options: Option) -> AnyhowResult { +pub fn decode_and_verify_token_claims(keys: &KeyMap, token: &str, mut options: Option) -> AnyhowResult { let header = decode_jwt_header(token)?; let key_id = header.kid.as_deref() @@ -36,6 +20,16 @@ pub fn decode_and_verify_token_claims(keys: &KeyMap, token: &str, options: Optio let key = keys.get(key_id) .ok_or_else(|| anyhow!("Key not found"))?; + if options.is_none() { + options = Some(VerificationOptions { + allowed_issuers: Some(HashSet::from([ + "https://accounts.google.com".to_string(), + "accounts.google.com".to_string(), + ])), + ..Default::default() + }); + } + decode_and_verify_token_claims_with_key(key, token, options) } @@ -64,6 +58,24 @@ mod tests { use std::fs::read_to_string; use testing::test_file_path::test_file_path; + /* + Example payload: + iss https://accounts.google.com + azp 788843034237-uqcg8tbgofrcf1to37e1bqphd924jaf6.apps.googleusercontent.com + aud 788843034237-uqcg8tbgofrcf1to37e1bqphd924jaf6.apps.googleusercontent.com + sub 113101967612396793777 + email vocodes2020@gmail.com + email_verified true + nbf 1726786100 + name Vocodes Vocodes + picture https://lh3.googleusercontent.com/a/ACg8ocLz2-2OaAm0MQxR6j8CNr-Po8_Xr-aryATiCn4c0i_TuDmL_g=s96-c + given_name Vocodes + family_name Vocodes + iat 1726786400 + exp 1726790000 + jti 4d44eeac06ce79fc0ab2270cfeea30d8acf77613 + */ + #[test] fn test_decode() { let jwk_file = test_file_path("test_data/crypto/google_signin.jwk").unwrap(); diff --git a/crates/lib/api_clients/google_sign_in/src/lib.rs b/crates/lib/api_clients/google_sign_in/src/lib.rs index 0a6d1fe73f..b081611a79 100644 --- a/crates/lib/api_clients/google_sign_in/src/lib.rs +++ b/crates/lib/api_clients/google_sign_in/src/lib.rs @@ -25,3 +25,5 @@ pub mod download_certs; pub(crate) mod jwt; +// Export for external users +pub use jwt_simple::common::VerificationOptions; diff --git a/crates/service/web/storyteller_web/Cargo.toml b/crates/service/web/storyteller_web/Cargo.toml index be37e63770..76508c00fb 100644 --- a/crates/service/web/storyteller_web/Cargo.toml +++ b/crates/service/web/storyteller_web/Cargo.toml @@ -45,6 +45,7 @@ enums = { path = "../../../_types/enums" } enums_public = { path = "../../../_types/enums_public" } errors = { path = "../../../lib/errors" } filesys = { path = "../../../lib/files/filesys" } +google_sign_in = { path = "../../../lib/api_clients/google_sign_in" } hashing = { path = "../../../lib/files/hashing" } http_server_common = { path = "../../../lib/deprecated/http_server_common" } idempotency = { path = "../../../lib/idempotency" } diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index b30e0be485..3afc2a34a5 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -132,6 +132,7 @@ use crate::http_server::endpoints::users::get_profile_handler::*; use crate::http_server::endpoints::users::login_handler::*; use crate::http_server::endpoints::users::logout_handler::*; use crate::http_server::endpoints::users::session_info_handler::*; +use crate::http_server::endpoints::users::create_account_from_google_sign_in_handler::*; use crate::http_server::endpoints::users::session_token_info_handler::*; use crate::http_server::endpoints::voice_conversion::enqueue_voice_conversion_inference_handler::*; use crate::http_server::endpoints::voice_designer::inference::enqueue_tts_request::*; @@ -188,6 +189,7 @@ use crate::http_server::web_utils::response_success_helpers::*; crate::http_server::endpoints::media_files::get::get_media_file_handler::get_media_file_handler, crate::http_server::endpoints::media_files::list::list_featured_media_files_handler::list_featured_media_files_handler, crate::http_server::endpoints::media_files::list::list_media_files_by_batch_token_handler::list_media_files_by_batch_token_handler, + crate::http_server::endpoints::users::create_account_from_google_sign_in_handler::create_account_from_google_sign_in_handler, crate::http_server::endpoints::media_files::list::list_media_files_for_user_handler::list_media_files_for_user_handler, crate::http_server::endpoints::media_files::list::list_media_files_handler::list_media_files_handler, crate::http_server::endpoints::media_files::upload::upload_studio_shot::upload_studio_shot_media_file_handler::upload_studio_shot_media_file_handler, @@ -348,6 +350,9 @@ use crate::http_server::web_utils::response_success_helpers::*; ByQueueStats, ChangeMediaFileAnimationTypeError, ChangeMediaFileAnimationTypeRequest, + GoogleCreateAccountRequest, + GoogleCreateAccountErrorResponse, + GoogleCreateAccountSuccessResponse, ChangeMediaFileEngineCategoryError, ChangeMediaFileEngineCategoryRequest, ChangeMediaFileVisibilityError, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_from_google_sign_in_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_from_google_sign_in_handler.rs new file mode 100644 index 0000000000..4ebbc4886a --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/create_account_from_google_sign_in_handler.rs @@ -0,0 +1,336 @@ +// NB: Incrementally getting rid of build warnings... +//#![forbid(unused_imports)] +//#![forbid(unused_mut)] +//#![forbid(unused_variables)] + +use std::collections::HashMap; +use std::fmt; +use std::fmt::Formatter; + +use actix_web::error::ResponseError; +use actix_web::http::StatusCode; +use actix_web::{web, HttpRequest, HttpResponse}; +use log::{info, warn}; +use sqlx::MySqlPool; +use utoipa::ToSchema; +use actix_helpers::extractors::get_request_origin_uri::get_request_origin_uri; +use google_sign_in::decode_and_verify_token_claims::decode_and_verify_token_claims; +use google_sign_in::download_certs::download_cert_key_set; +use google_sign_in::VerificationOptions; +use http_server_common::request::get_request_ip::get_request_ip; +use http_server_common::response::serialize_as_json_error::serialize_as_json_error; +use mysql_queries::mediators::firehose_publisher::FirehosePublisher; +use mysql_queries::queries::users::user::create_account::{create_account, CreateAccountArgs, CreateAccountError}; +use mysql_queries::queries::users::user_sessions::create_user_session::create_user_session; +use password::bcrypt_hash_password::bcrypt_hash_password; +use tokens::tokens::user_sessions::UserSessionToken; +use user_input_common::check_for_slurs::contains_slurs; + +use crate::http_server::session::http::http_user_session_manager::HttpUserSessionManager; +use crate::http_server::validations::is_reserved_username::is_reserved_username; +use crate::http_server::validations::validate_passwords::validate_passwords; +use crate::http_server::validations::validate_username::validate_username; +use crate::util::email_to_gravatar::email_to_gravatar; +use crate::util::enroll_in_studio::enroll_in_studio; +use crate::util::generate_random_username::generate_random_username; + +#[derive(ToSchema, Deserialize)] +pub struct GoogleCreateAccountRequest { + pub google_credential: String, +} + +#[derive(ToSchema, Serialize)] +pub struct GoogleCreateAccountSuccessResponse { + pub success: bool, + + /// A signed session that can be sent as a header, bypassing cookies. + /// This is useful for API clients that don't support cookies or Google + /// browsers killing cross-domain cookies. + pub signed_session: String, + + /// Tell the user what the automatically created display name was in case + /// they want to rename their account. + pub automatic_user_display_name: String, +} + +#[derive(ToSchema, Serialize, Debug)] +pub struct GoogleCreateAccountErrorResponse { + pub success: bool, + pub error_type: GoogleCreateAccountErrorType, + pub error_fields: HashMap, +} + +#[derive(ToSchema, Copy, Clone, Debug, Serialize)] +pub enum GoogleCreateAccountErrorType { + BadRequest, // Other request malformed errors, eg. bad Origin header + BadInput, + EmailTaken, + ServerError, + UsernameReserved, + UsernameTaken, +} + +impl GoogleCreateAccountErrorResponse { + fn server_error() -> Self { + Self { + success: false, + error_type: GoogleCreateAccountErrorType::ServerError, + error_fields: HashMap::new(), + } + } + + fn bad_request() -> Self { + Self { + success: false, + error_type: GoogleCreateAccountErrorType::BadRequest, + error_fields: HashMap::new(), + } + } +} + +// NB: Not using DeriveMore since Clion doesn't understand it. +impl fmt::Display for GoogleCreateAccountErrorResponse { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self.error_type) + } +} + +impl ResponseError for GoogleCreateAccountErrorResponse { + fn status_code(&self) -> StatusCode { + match self.error_type { + GoogleCreateAccountErrorType::BadRequest => StatusCode::BAD_REQUEST, + GoogleCreateAccountErrorType::BadInput => StatusCode::BAD_REQUEST, + GoogleCreateAccountErrorType::EmailTaken => StatusCode::BAD_REQUEST, + GoogleCreateAccountErrorType::ServerError => StatusCode::INTERNAL_SERVER_ERROR, + GoogleCreateAccountErrorType::UsernameReserved => StatusCode::BAD_REQUEST, + GoogleCreateAccountErrorType::UsernameTaken => StatusCode::BAD_REQUEST, + } + } + + fn error_response(&self) -> HttpResponse { + serialize_as_json_error(self) + } +} + + +/// Create a new account from "Sign in with Google" credentials. +#[utoipa::path( + post, + tag = "Users", + path = "/v1/accounts/google_create", + responses( + (status = 200, description = "Success", body = GoogleCreateAccountSuccessResponse), + (status = 400, description = "Bad input", body = GoogleCreateAccountErrorResponse), + (status = 401, description = "Not authorized", body = GoogleCreateAccountErrorResponse), + (status = 500, description = "Server error", body = GoogleCreateAccountErrorResponse), + ), + params( + ("request" = GoogleCreateAccountRequest, description = "Payload for Request"), + ) +)] +pub async fn create_account_from_google_sign_in_handler( + http_request: HttpRequest, + request: web::Json, + mysql_pool: web::Data, + session_cookie_manager: web::Data, +) -> Result +{ + // TODO(bt,2024-09-20): Cache certs in shared server state + let keys = download_cert_key_set() + .await + .map_err(|e| { + warn!("error downloading google certs: {:?}", e); + GoogleCreateAccountErrorResponse::server_error() + })?; + + let claims = decode_and_verify_token_claims(&keys, &request.google_credential, None) + .map_err(|e| { + warn!("error decoding google token claims: {:?}", e); + GoogleCreateAccountErrorResponse::bad_request() + })?; + + info!("Google JWT credential claims: email {:?}, verified: {}", + claims.email(), + claims.email_verified()); + + let display_name = generate_random_username(); + let username = display_name.trim().to_lowercase(); + + info!("generated username: {}", username); + + /* + let mut error_fields = HashMap::new(); + + if let Err(reason) = validate_username(&request.username) { + error_fields.insert("username".to_string(), reason); + } + + if let Err(reason) = validate_passwords(&request.password, &request.password_confirmation) { + error_fields.insert("password".to_string(), reason); + } + + if contains_slurs(&request.username) { + error_fields.insert("username".to_string(), "username contains slurs".to_string()); + } + + if !request.email_address.contains("@") { + error_fields.insert("email_address".to_string(), "invalid email address".to_string()); + } + + if is_reserved_username(&request.username) { + error_fields.insert("username".to_string(), "username is reserved".to_string()); + + return Err(GoogleCreateAccountErrorResponse { + success: false, + error_type: GoogleCreateAccountErrorType::UsernameReserved, + error_fields + }); + } + + if !error_fields.is_empty() { + return Err(GoogleCreateAccountErrorResponse { + success: false, + error_type: GoogleCreateAccountErrorType::BadInput, + error_fields + }); + } + + let password_hash = match bcrypt_hash_password(request.password.clone()) { + Ok(hash) => hash, + Err(err) => { + warn!("Bcrypt error: {:?}", err); + return Err(GoogleCreateAccountErrorResponse::server_error()); + } + }; + + let username = request.username.trim().to_lowercase(); + + let display_name = request.username.trim().to_string(); + + let email_address = request.email_address.trim().to_lowercase(); + + let email_gravatar_hash = email_to_gravatar(&email_address); + + let ip_address = get_request_ip(&http_request); + + let maybe_origin = get_request_origin_uri(&http_request); + + let mut maybe_source = None; + + match maybe_origin { + Ok(Some(uri)) => { + if let Some(host) = uri.host() { + if host.contains("storyteller") { + maybe_source = Some("storyteller"); + } else if host.contains("fakeyou") { + maybe_source = Some("fakeyou"); + } + } + } + // Fail open for now. + Ok(None) => {} + Err(err) => { + warn!("Origin header error: {:?}", err); + } + } + + let create_account_result = create_account( + &mysql_pool, + CreateAccountArgs { + username: &username, + display_name: &display_name, + email_address: &email_address, + email_gravatar_hash: &email_gravatar_hash, + password_hash: &password_hash, + ip_address: &ip_address, + maybe_source, + maybe_user_token: None, // NB: This parameter is for internal testing only + } + ).await; + + let new_user_data = match create_account_result { + Ok(success) => success, + Err(err) => { + let mut error_fields = HashMap::new(); + let error_type; + + match err { + CreateAccountError::EmailIsTaken => { + error_type = GoogleCreateAccountErrorType::EmailTaken; + error_fields.insert("email_address".to_string(), "email is taken".to_string()); + } + CreateAccountError::UsernameIsTaken => { + error_type = GoogleCreateAccountErrorType::UsernameTaken; + error_fields.insert("username".to_string(), "username is taken".to_string()); + } + CreateAccountError::DatabaseError | CreateAccountError::OtherError => { + error_type = GoogleCreateAccountErrorType::ServerError; + } + } + + return Err(GoogleCreateAccountErrorResponse { + success: false, + error_type, + error_fields + }); + } + }; + + info!("new user id: {}", new_user_data.user_id); + + let create_session_result = create_user_session( + new_user_data.user_token.as_str(), + &ip_address, + &mysql_pool + ).await; + + let session_token = match create_session_result { + Ok(token) => token, + Err(e) => { + warn!("create account session creation error : {:?}", e); + return Err(GoogleCreateAccountErrorResponse::server_error()); + } + }; + + info!("new user session created"); + + firehose_publisher.publish_user_sign_up(new_user_data.user_token.as_str()) + .await + .map_err(|e| { + warn!("error publishing event: {:?}", e); + GoogleCreateAccountErrorResponse::server_error() + })?; + + // NB: Enroll new users in studio for a while. + enroll_in_studio(&new_user_data.user_token, &ip_address, &mysql_pool, None).await + .map_err(|e| { + warn!("error enrolling in studio: {:?}", e); + }).ok(); + + let session_token = UserSessionToken::new_from_str(&session_token); + + let session_cookie = match session_cookie_manager.create_cookie(&session_token, &new_user_data.user_token) { + Ok(cookie) => cookie, + Err(_) => return Err(GoogleCreateAccountErrorResponse::server_error()), + }; + + let signed_session = match session_cookie_manager.encode_session_payload(&session_token, &new_user_data.user_token) { + Ok(payload) => payload, + Err(_) => return Err(GoogleCreateAccountErrorResponse::server_error()), + }; + */ + + let response = GoogleCreateAccountSuccessResponse { + success: true, + signed_session: "todo".to_string(), + automatic_user_display_name: display_name, + }; + + let body = serde_json::to_string(&response) + .map_err(|_e| GoogleCreateAccountErrorResponse::server_error())?; + + Ok(HttpResponse::Ok() + // .cookie(session_cookie) // TODO / FIXME + .content_type("application/json") + .body(body)) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/users/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/users/mod.rs index 78f494a4c4..1c0e85dce3 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/users/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/users/mod.rs @@ -1,3 +1,4 @@ +pub mod create_account_from_google_sign_in_handler; pub mod create_account_handler; pub mod edit_profile_handler; pub mod get_profile_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/routes/user_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/user_routes.rs index 3e99a9befc..a274e45d00 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/user_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/user_routes.rs @@ -6,6 +6,7 @@ use actix_web::error::Error; use crate::http_server::endpoints::tts::list_user_tts_inference_results::list_user_tts_inference_results_handler; use crate::http_server::endpoints::tts::list_user_tts_models::list_user_tts_models_handler; +use crate::http_server::endpoints::users::create_account_from_google_sign_in_handler::create_account_from_google_sign_in_handler; use crate::http_server::endpoints::users::create_account_handler::create_account_handler; use crate::http_server::endpoints::users::edit_profile_handler::edit_profile_handler; use crate::http_server::endpoints::users::get_profile_handler::get_profile_handler; @@ -41,6 +42,11 @@ pub fn add_user_routes (app: App) -> App .route(web::post().to(create_account_handler)) .route(web::head().to(|| HttpResponse::Ok())) ) + .service( + web::resource("/v1/accounts/google_create") + .route(web::post().to(create_account_from_google_sign_in_handler)) + .route(web::head().to(|| HttpResponse::Ok())) + ) .service( // TODO(bt,2022-11-16): non-/v1/ endpoints are deprecated and subject for future removal web::resource("/login")