diff --git a/services/identity/src/client_service.rs b/services/identity/src/client_service.rs --- a/services/identity/src/client_service.rs +++ b/services/identity/src/client_service.rs @@ -127,7 +127,7 @@ )); } - if RESERVED_USERNAME_SET.contains(&message.username) { + if RESERVED_USERNAME_SET.contains(&message.username.to_lowercase()) { return Err(tonic::Status::invalid_argument( tonic_status_messages::USERNAME_RESERVED, )); @@ -180,7 +180,7 @@ let message = request.into_inner(); self.check_username_taken(&message.username).await?; - if RESERVED_USERNAME_SET.contains(&message.username) { + if RESERVED_USERNAME_SET.contains(&message.username.to_lowercase()) { return Err(tonic::Status::invalid_argument( tonic_status_messages::USERNAME_RESERVED, )); diff --git a/services/identity/src/constants.rs b/services/identity/src/constants.rs --- a/services/identity/src/constants.rs +++ b/services/identity/src/constants.rs @@ -54,9 +54,11 @@ pub const USERS_TABLE_DEVICELIST_TIMESTAMP_ATTRIBUTE_NAME: &str = "deviceListTimestamp"; pub const USERS_TABLE_FARCASTER_ID_ATTRIBUTE_NAME: &str = "farcasterID"; +pub const USERS_TABLE_USERNAME_LOWER_ATTRIBUTE_NAME: &str = "usernameLower"; pub const USERS_TABLE_USERNAME_INDEX: &str = "username-index"; pub const USERS_TABLE_WALLET_ADDRESS_INDEX: &str = "walletAddress-index"; pub const USERS_TABLE_FARCASTER_ID_INDEX: &str = "farcasterID-index"; +pub const USERS_TABLE_USERNAME_LOWER_INDEX: &str = "usernameLower-index"; pub mod token_table { pub const NAME: &str = "identity-tokens"; @@ -85,6 +87,10 @@ pub const RESERVED_USERNAMES_TABLE: &str = "identity-reserved-usernames"; pub const RESERVED_USERNAMES_TABLE_PARTITION_KEY: &str = "username"; pub const RESERVED_USERNAMES_TABLE_USER_ID_ATTRIBUTE: &str = "userID"; +pub const RESERVED_USERNAMES_TABLE_USERNAME_LOWER_ATTRIBUTE: &str = + "usernameLower"; +pub const RESERVED_USERNAMES_TABLE_USERNAME_LOWER_INDEX: &str = + "usernameLower-index"; // Users table social proof attribute pub const SOCIAL_PROOF_MESSAGE_ATTRIBUTE: &str = "siweMessage"; diff --git a/services/identity/src/database.rs b/services/identity/src/database.rs --- a/services/identity/src/database.rs +++ b/services/identity/src/database.rs @@ -20,11 +20,8 @@ pub use crate::database::device_list::DeviceIDAttribute; pub use crate::database::one_time_keys::OTKRow; use crate::{ - constants::{error_types, USERS_TABLE_SOCIAL_PROOF_ATTRIBUTE_NAME}, - ddb_utils::EthereumIdentity, - device_list::SignedDeviceList, - grpc_services::shared::PlatformMetadata, - reserved_users::UserDetail, + ddb_utils::EthereumIdentity, device_list::SignedDeviceList, + grpc_services::shared::PlatformMetadata, reserved_users::UserDetail, siwe::SocialProof, }; use crate::{ @@ -39,15 +36,18 @@ use crate::client_service::{FlattenedDeviceKeyUpload, UserRegistrationInfo}; use crate::config::CONFIG; use crate::constants::{ - NONCE_TABLE, NONCE_TABLE_CREATED_ATTRIBUTE, + error_types, NONCE_TABLE, NONCE_TABLE_CREATED_ATTRIBUTE, NONCE_TABLE_EXPIRATION_TIME_ATTRIBUTE, NONCE_TABLE_EXPIRATION_TIME_UNIX_ATTRIBUTE, NONCE_TABLE_PARTITION_KEY, RESERVED_USERNAMES_TABLE, RESERVED_USERNAMES_TABLE_PARTITION_KEY, + RESERVED_USERNAMES_TABLE_USERNAME_LOWER_ATTRIBUTE, + RESERVED_USERNAMES_TABLE_USERNAME_LOWER_INDEX, RESERVED_USERNAMES_TABLE_USER_ID_ATTRIBUTE, USERS_TABLE, USERS_TABLE_DEVICES_MAP_DEVICE_TYPE_ATTRIBUTE_NAME, USERS_TABLE_FARCASTER_ID_ATTRIBUTE_NAME, USERS_TABLE_PARTITION_KEY, - USERS_TABLE_REGISTRATION_ATTRIBUTE, USERS_TABLE_USERNAME_ATTRIBUTE, - USERS_TABLE_USERNAME_INDEX, USERS_TABLE_WALLET_ADDRESS_ATTRIBUTE, + USERS_TABLE_REGISTRATION_ATTRIBUTE, USERS_TABLE_SOCIAL_PROOF_ATTRIBUTE_NAME, + USERS_TABLE_USERNAME_ATTRIBUTE, USERS_TABLE_USERNAME_LOWER_ATTRIBUTE_NAME, + USERS_TABLE_USERNAME_LOWER_INDEX, USERS_TABLE_WALLET_ADDRESS_ATTRIBUTE, USERS_TABLE_WALLET_ADDRESS_INDEX, }; use crate::id::generate_uuid; @@ -290,12 +290,16 @@ { user.insert( USERS_TABLE_USERNAME_ATTRIBUTE.to_string(), - AttributeValue::S(username), + AttributeValue::S(username.clone()), ); user.insert( USERS_TABLE_REGISTRATION_ATTRIBUTE.to_string(), AttributeValue::B(password_file), ); + user.insert( + USERS_TABLE_USERNAME_LOWER_ATTRIBUTE_NAME.to_string(), + AttributeValue::S(username.to_lowercase()), + ); } if let Some(eth_identity) = wallet_identity.clone() { @@ -326,13 +330,24 @@ let put_user_operation = TransactWriteItem::builder().put(put_user).build(); - let partition_key_value = + let username_or_wallet_address = match (username_and_password_file, wallet_identity) { (Some((username, _)), _) => username, (_, Some(ethereum_identity)) => ethereum_identity.wallet_address, _ => return Err(Error::MalformedItem), }; + let maybe_original_reserved_username = self + .get_original_username_from_reserved_usernames_table( + &username_or_wallet_address, + ) + .await?; + + let partition_key_value = match maybe_original_reserved_username { + Some(u) => u, + None => username_or_wallet_address, + }; + // We make sure to delete the user from the reserved usernames table when we // add them to the users table let delete_user_from_reserved_usernames = Delete::builder() @@ -573,11 +588,38 @@ } pub async fn username_taken(&self, username: String) -> Result { - let result = self - .get_user_id_from_user_info(username, &AuthType::Password) - .await?; + let username_lower = username.to_lowercase(); - Ok(result.is_some()) + let request = self + .client + .query() + .table_name(USERS_TABLE) + .index_name(USERS_TABLE_USERNAME_LOWER_INDEX) + .key_condition_expression("#username_lower = :username_lower") + .expression_attribute_names( + "#username_lower", + USERS_TABLE_USERNAME_LOWER_ATTRIBUTE_NAME, + ) + .expression_attribute_values( + ":username_lower", + AttributeValue::S(username_lower), + ); + + let response = request.send().await.map_err(|e| { + error!( + errorType = error_types::GENERIC_DB_LOG, + "Failed to query lowercase usernames by index: {:?}", e + ); + Error::AwsSdk(e.into()) + })?; + + if let Some(items) = response.items() { + if !items.is_empty() { + return Ok(true); + } + } + + Ok(false) } pub async fn filter_out_taken_usernames( @@ -586,11 +628,16 @@ ) -> Result, Error> { let db_usernames = self.get_all_usernames().await?; - let db_usernames_set: HashSet = db_usernames.into_iter().collect(); + let db_usernames_set: HashSet = db_usernames + .into_iter() + .map(|username| username.to_lowercase()) + .collect(); let available_user_details: Vec = user_details .into_iter() - .filter(|user_detail| !db_usernames_set.contains(&user_detail.username)) + .filter(|user_detail| { + !db_usernames_set.contains(&user_detail.username.to_lowercase()) + }) .collect(); Ok(available_user_details) @@ -602,13 +649,16 @@ user_info: String, auth_type: &AuthType, ) -> Result>, Error> { - let (index, attribute_name) = match auth_type { - AuthType::Password => { - (USERS_TABLE_USERNAME_INDEX, USERS_TABLE_USERNAME_ATTRIBUTE) - } + let (index, attribute_name, attribute_value) = match auth_type { + AuthType::Password => ( + USERS_TABLE_USERNAME_LOWER_INDEX, + USERS_TABLE_USERNAME_LOWER_ATTRIBUTE_NAME, + user_info.to_lowercase(), + ), AuthType::Wallet => ( USERS_TABLE_WALLET_ADDRESS_INDEX, USERS_TABLE_WALLET_ADDRESS_ATTRIBUTE, + user_info.clone(), ), }; match self @@ -617,7 +667,7 @@ .table_name(USERS_TABLE) .index_name(index) .key_condition_expression(format!("{} = :u", attribute_name)) - .expression_attribute_values(":u", AttributeValue::S(user_info.clone())) + .expression_attribute_values(":u", AttributeValue::S(attribute_value)) .send() .await { @@ -1063,6 +1113,10 @@ RESERVED_USERNAMES_TABLE_USER_ID_ATTRIBUTE, AttributeValue::S(user_detail.user_id.to_string()), ) + .item( + RESERVED_USERNAMES_TABLE_USERNAME_LOWER_ATTRIBUTE, + AttributeValue::S(user_detail.username.to_lowercase()), + ) .build(); WriteRequest::builder().put_request(put_request).build() @@ -1122,30 +1176,65 @@ &self, username: &str, ) -> Result, Error> { + self + .query_reserved_usernames_table( + username, + RESERVED_USERNAMES_TABLE_USER_ID_ATTRIBUTE, + ) + .await + } + + async fn get_original_username_from_reserved_usernames_table( + &self, + username: &str, + ) -> Result, Error> { + self + .query_reserved_usernames_table( + username, + RESERVED_USERNAMES_TABLE_PARTITION_KEY, + ) + .await + } + + async fn query_reserved_usernames_table( + &self, + username: &str, + attribute: &str, + ) -> Result, Error> { + let username_lower = username.to_lowercase(); + let response = self .client - .get_item() + .query() .table_name(RESERVED_USERNAMES_TABLE) - .key( - RESERVED_USERNAMES_TABLE_PARTITION_KEY.to_string(), - AttributeValue::S(username.to_string()), + .index_name(RESERVED_USERNAMES_TABLE_USERNAME_LOWER_INDEX) + .key_condition_expression("#username_lower = :username_lower") + .expression_attribute_names( + "#username_lower", + RESERVED_USERNAMES_TABLE_USERNAME_LOWER_ATTRIBUTE, + ) + .expression_attribute_values( + ":username_lower", + AttributeValue::S(username_lower), ) - .consistent_read(true) .send() .await .map_err(|e| Error::AwsSdk(e.into()))?; - let GetItemOutput { - item: Some(mut item), + let QueryOutput { + items: Some(mut results), .. } = response else { return Ok(None); }; - // We should not return `Ok(None)` if `userID` is missing from the item - let user_id = item.take_attr(RESERVED_USERNAMES_TABLE_USER_ID_ATTRIBUTE)?; - Ok(Some(user_id)) + let result = results + .pop() + .map(|mut attrs| attrs.take_attr::(attribute)) + .transpose()?; + + Ok(result) } } diff --git a/services/terraform/modules/shared/dynamodb.tf b/services/terraform/modules/shared/dynamodb.tf --- a/services/terraform/modules/shared/dynamodb.tf +++ b/services/terraform/modules/shared/dynamodb.tf @@ -145,6 +145,11 @@ type = "S" } + attribute { + name = "usernameLower" + type = "S" + } + global_secondary_index { name = "username-index" hash_key = "username" @@ -163,6 +168,12 @@ projection_type = "INCLUDE" non_key_attributes = ["walletAddress", "username"] } + + global_secondary_index { + name = "usernameLower-index" + hash_key = "usernameLower" + projection_type = "KEYS_ONLY" + } } resource "aws_dynamodb_table" "identity-devices" { @@ -270,6 +281,18 @@ name = "username" type = "S" } + + attribute { + name = "usernameLower" + type = "S" + } + + global_secondary_index { + name = "usernameLower-index" + hash_key = "usernameLower" + projection_type = "INCLUDE" + non_key_attributes = ["userID"] + } } resource "aws_dynamodb_table" "identity-one-time-keys" {