diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs index d996afb1d5..24c31d03ed 100644 --- a/apps/labrinth/src/auth/mod.rs +++ b/apps/labrinth/src/auth/mod.rs @@ -43,7 +43,13 @@ pub enum AuthenticationError { #[error( "User email is already registered on Modrinth. Try 'Forgot password' to access your account." )] - DuplicateUser, + DuplicateEmail, + #[error("Username is already taken on Modrinth.")] + UsernameTaken, + #[error( + "This authentication provider is already linked to another Modrinth account." + )] + ProviderAlreadyLinked, #[error("Invalid state sent, you probably need to get a new websocket")] SocketError, #[error("Invalid callback URL specified")] @@ -73,7 +79,11 @@ impl actix_web::ResponseError for AuthenticationError { AuthenticationError::FileHosting(..) => { StatusCode::INTERNAL_SERVER_ERROR } - AuthenticationError::DuplicateUser => StatusCode::BAD_REQUEST, + AuthenticationError::DuplicateEmail => StatusCode::BAD_REQUEST, + AuthenticationError::UsernameTaken => StatusCode::BAD_REQUEST, + AuthenticationError::ProviderAlreadyLinked => { + StatusCode::BAD_REQUEST + } AuthenticationError::SocketError => StatusCode::BAD_REQUEST, } } @@ -102,7 +112,11 @@ impl AuthenticationError { AuthenticationError::InvalidClientId => "invalid_client_id", AuthenticationError::Url => "url_error", AuthenticationError::FileHosting(..) => "file_hosting", - AuthenticationError::DuplicateUser => "duplicate_user", + AuthenticationError::DuplicateEmail => "duplicate_email", + AuthenticationError::UsernameTaken => "username_taken", + AuthenticationError::ProviderAlreadyLinked => { + "provider_already_linked" + } AuthenticationError::SocketError => "socket", } } diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index e531e824eb..a933bc55eb 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -10,6 +10,7 @@ use crate::database::models::{DBUser, DBUserId}; use crate::database::redis::RedisPool; use crate::env::ENV; use crate::file_hosting::{FileHost, FileHostPublicity}; +use crate::models::error::ApiError as ApiErrorResponse; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; use crate::models::users::{Badges, Role}; @@ -23,6 +24,7 @@ use crate::util::ext::get_image_ext; use crate::util::img::upload_image_optimized; use crate::util::validate::validation_errors_to_string; use actix_http::header::LOCATION; +use actix_web::http::StatusCode; use actix_web::web::{Data, Query, ServiceConfig, scope}; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use argon2::password_hash::SaltString; @@ -40,6 +42,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; +use thiserror::Error; use tracing::{error, info}; use url::Url; use validator::Validate; @@ -52,6 +55,7 @@ pub fn config(cfg: &mut ServiceConfig) { .service(auth_callback) .service(delete_auth_provider) .service(create_oauth_account) + .service(validate_create_account_with_password) .service(create_account_with_password) .service(login_password) .service(login_2fa) @@ -96,7 +100,7 @@ impl TempUser { .await? .is_some() { - return Err(AuthenticationError::DuplicateUser); + return Err(AuthenticationError::DuplicateEmail); } let user_id = @@ -107,7 +111,7 @@ impl TempUser { .wrap_err("failed to fetch existing user by id")?; if existing_id.is_some() { - return Err(AuthenticationError::DuplicateUser); + return Err(AuthenticationError::UsernameTaken); } let (avatar_url, raw_avatar_url) = if let Some(avatar_url) = @@ -1266,7 +1270,7 @@ pub async fn auth_callback( if let Some(id) = user_id { if user_id_opt.is_some() { - return Err(AuthenticationError::DuplicateUser); + return Err(AuthenticationError::ProviderAlreadyLinked); } provider @@ -1339,6 +1343,7 @@ pub async fn auth_callback( // for this, we redirect them to a frontend page which lets them set a username. // then frontend will call `/create/oauth` with the same state parameter and // chosen settings (username, subscribe to newsletter), and handle navigation. + let suggested_username = oauth_user.username.clone(); flow_guard .replace_with(DBFlow::OAuthPending { @@ -1356,7 +1361,8 @@ pub async fn auth_callback( .append_pair( "requires_dob", &requires_dob(provider).to_string(), - ); + ) + .append_pair("username", &suggested_username); let redirect_url = redirect_url.to_string(); Ok(HttpResponse::TemporaryRedirect() @@ -1394,6 +1400,10 @@ async fn create_oauth_account( redis: Data, web::Json(new_account): web::Json, ) -> Result { + new_account.validate().map_err(|err| { + ApiError::InvalidInput(validation_errors_to_string(err, None)) + })?; + if !check_hcaptcha(&req, &new_account.challenge).await? { return Err(ApiError::Turnstile); } @@ -1525,19 +1535,243 @@ pub async fn check_sendy_subscription( Ok(response.trim() == "Subscribed") } -#[derive(Deserialize, Validate)] +#[derive(Deserialize)] pub struct NewAccount { // keep in sync with NewOAuthAccount - #[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_URL_SAFE))] pub username: String, - #[validate(length(min = 8, max = 256))] pub password: String, - #[validate(email)] pub email: String, - pub challenge: String, + pub challenge: Option, pub sign_up_newsletter: Option, } +#[derive(Debug, Validate)] +struct AccountRegisterFlow { + #[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_URL_SAFE))] + username: String, + #[validate(length(min = 8, max = 256))] + password: String, + #[validate(email)] + email: String, + sign_up_newsletter: bool, +} + +#[derive(Debug)] +struct ReadyAccountRegisterFlow { + inner: AccountRegisterFlow, +} + +#[derive(Debug, Error)] +enum AccountRegisterValidateError { + #[error("Username is already taken on Modrinth.")] + UsernameTaken, + #[error( + "Email is already registered on Modrinth. Try 'Forgot password' to access your account." + )] + DuplicateEmail, + #[error("{}", match .0 { + Some(feedback) => format!("Password too weak: {feedback}"), + None => "Specified password is too weak! Please improve its strength.".to_string(), + })] + WeakPassword(Option), + #[error("{0}")] + InvalidInput(String), +} + +impl AccountRegisterValidateError { + fn error_code(&self) -> &'static str { + match self { + AccountRegisterValidateError::UsernameTaken => "username_taken", + AccountRegisterValidateError::DuplicateEmail => "duplicate_email", + AccountRegisterValidateError::WeakPassword(_) => "weak_password", + AccountRegisterValidateError::InvalidInput(_) => "invalid_input", + } + } +} + +impl actix_web::ResponseError for AccountRegisterValidateError { + fn status_code(&self) -> StatusCode { + StatusCode::BAD_REQUEST + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(ApiErrorResponse { + error: self.error_code(), + description: self.to_string(), + details: None, + }) + } +} + +impl From for ApiError { + fn from(value: AccountRegisterValidateError) -> Self { + match &value { + AccountRegisterValidateError::UsernameTaken => { + ApiError::Authentication(AuthenticationError::UsernameTaken) + } + AccountRegisterValidateError::DuplicateEmail => { + ApiError::Authentication(AuthenticationError::DuplicateEmail) + } + _ => ApiError::InvalidInput(value.to_string()), + } + } +} + +impl From for AccountRegisterFlow { + fn from(account: NewAccount) -> Self { + Self { + username: account.username, + password: account.password, + email: account.email, + sign_up_newsletter: account.sign_up_newsletter.unwrap_or(false), + } + } +} + +impl AccountRegisterFlow { + async fn validate( + self, + pool: &PgPool, + redis: &RedisPool, + ) -> Result { + validator::Validate::validate(&self).map_err(|err| { + AccountRegisterValidateError::InvalidInput( + validation_errors_to_string(err, None), + ) + })?; + + if crate::database::models::DBUser::get(&self.username, pool, redis) + .await + .map_err(|err| { + AccountRegisterValidateError::InvalidInput(err.to_string()) + })? + .is_some() + { + return Err(AccountRegisterValidateError::UsernameTaken); + } + + let score = + zxcvbn::zxcvbn(&self.password, &[&self.username, &self.email]); + + if score.score() < Score::Three { + let feedback = score + .feedback() + .and_then(|x| x.warning()) + .map(|w| w.to_string()); + return Err(AccountRegisterValidateError::WeakPassword(feedback)); + } + + if !crate::database::models::DBUser::get_by_case_insensitive_email( + &self.email, + pool, + ) + .await + .map_err(|err| { + AccountRegisterValidateError::InvalidInput(err.to_string()) + })? + .is_empty() + { + return Err(AccountRegisterValidateError::DuplicateEmail); + } + + Ok(ReadyAccountRegisterFlow { inner: self }) + } +} + +impl ReadyAccountRegisterFlow { + async fn execute( + self, + req: HttpRequest, + pool: &PgPool, + redis: &RedisPool, + email_queue: &EmailQueue, + ) -> Result { + let register_flow = self.inner; + + let mut transaction = pool.begin().await?; + let user_id = + crate::database::models::generate_user_id(&mut transaction).await?; + + let hasher = Argon2::default(); + let salt = SaltString::generate(&mut ChaCha20Rng::from_entropy()); + let password_hash = hasher + .hash_password(register_flow.password.as_bytes(), &salt)? + .to_string(); + + crate::database::models::DBUser { + id: user_id, + github_id: None, + discord_id: None, + gitlab_id: None, + google_id: None, + steam_id: None, + microsoft_id: None, + password: Some(password_hash), + paypal_id: None, + paypal_country: None, + paypal_email: None, + venmo_handle: None, + stripe_customer_id: None, + totp_secret: None, + username: register_flow.username.clone(), + email: Some(register_flow.email.clone()), + email_verified: false, + avatar_url: None, + raw_avatar_url: None, + bio: None, + created: Utc::now(), + role: Role::Developer.to_string(), + badges: Badges::default(), + allow_friend_requests: true, + is_subscribed_to_newsletter: register_flow.sign_up_newsletter, + } + .insert(&mut transaction) + .await?; + + let session = + issue_session(req, user_id, &mut transaction, redis, None).await?; + let res = crate::models::sessions::Session::from(session, true, None); + + let mailbox: Mailbox = register_flow.email.parse().map_err(|_| { + ApiError::InvalidInput("Invalid email address!".to_string()) + })?; + + let flow = DBFlow::ConfirmEmail { + user_id, + confirm_email: register_flow.email.clone(), + } + .insert(Duration::hours(24), redis) + .await?; + + email_queue + .send_one( + &mut transaction, + NotificationBody::VerifyEmail { flow }, + user_id, + mailbox, + ) + .await? + .as_user_error()?; + + transaction.commit().await?; + + Ok(res) + } +} + +#[post("create/validate")] +pub async fn validate_create_account_with_password( + pool: Data, + redis: Data, + new_account: web::Json, +) -> Result<(), AccountRegisterValidateError> { + AccountRegisterFlow::from(new_account.into_inner()) + .validate(&pool, &redis) + .await?; + + Ok(()) +} + #[post("create")] pub async fn create_account_with_password( req: HttpRequest, @@ -1546,123 +1780,19 @@ pub async fn create_account_with_password( new_account: web::Json, email: web::Data, ) -> Result { - new_account.0.validate().map_err(|err| { - ApiError::InvalidInput(validation_errors_to_string(err, None)) - })?; + let new_account = new_account.into_inner(); - if !check_hcaptcha(&req, &new_account.challenge).await? { + if !check_hcaptcha(&req, new_account.challenge.as_deref().unwrap_or("")) + .await? + { return Err(ApiError::Turnstile); } - if crate::database::models::DBUser::get( - &new_account.username, - &**pool, - &redis, - ) - .await? - .is_some() - { - return Err(ApiError::InvalidInput("Username is taken!".to_string())); - } + let ready_flow = AccountRegisterFlow::from(new_account) + .validate(&pool, &redis) + .await?; - let mut transaction = pool.begin().await?; - let user_id = - crate::database::models::generate_user_id(&mut transaction).await?; - - let new_account = new_account.0; - - let score = zxcvbn::zxcvbn( - &new_account.password, - &[&new_account.username, &new_account.email], - ); - - if score.score() < Score::Three { - return Err(ApiError::InvalidInput( - if let Some(feedback) = score.feedback().and_then(|x| x.warning()) { - format!("Password too weak: {feedback}") - } else { - "Specified password is too weak! Please improve its strength." - .to_string() - }, - )); - } - - let hasher = Argon2::default(); - let salt = SaltString::generate(&mut ChaCha20Rng::from_entropy()); - let password_hash = hasher - .hash_password(new_account.password.as_bytes(), &salt)? - .to_string(); - - if !crate::database::models::DBUser::get_by_case_insensitive_email( - &new_account.email, - &**pool, - ) - .await? - .is_empty() - { - return Err(ApiError::InvalidInput( - "Email is already registered on Modrinth! Try 'Forgot password' to access your account.".to_string(), - )); - } - - crate::database::models::DBUser { - id: user_id, - github_id: None, - discord_id: None, - gitlab_id: None, - google_id: None, - steam_id: None, - microsoft_id: None, - password: Some(password_hash), - paypal_id: None, - paypal_country: None, - paypal_email: None, - venmo_handle: None, - stripe_customer_id: None, - totp_secret: None, - username: new_account.username.clone(), - email: Some(new_account.email.clone()), - email_verified: false, - avatar_url: None, - raw_avatar_url: None, - bio: None, - created: Utc::now(), - role: Role::Developer.to_string(), - badges: Badges::default(), - allow_friend_requests: true, - is_subscribed_to_newsletter: new_account - .sign_up_newsletter - .unwrap_or(false), - } - .insert(&mut transaction) - .await?; - - let session = - issue_session(req, user_id, &mut transaction, &redis, None).await?; - let res = crate::models::sessions::Session::from(session, true, None); - - let mailbox: Mailbox = new_account.email.parse().map_err(|_| { - ApiError::InvalidInput("Invalid email address!".to_string()) - })?; - - let flow = DBFlow::ConfirmEmail { - user_id, - confirm_email: new_account.email.clone(), - } - .insert(Duration::hours(24), &redis) - .await?; - - email - .send_one( - &mut transaction, - NotificationBody::VerifyEmail { flow }, - user_id, - mailbox, - ) - .await? - .as_user_error()?; - - transaction.commit().await?; + let res = ready_flow.execute(req, &pool, &redis, &email).await?; Ok(HttpResponse::Ok().json(res)) }