mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 11:36:05 +00:00
implement create user validation endpoint and add more specific error responses
This commit is contained in:
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RedisPool>,
|
||||
web::Json(new_account): web::Json<NewOAuthAccount>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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<String>,
|
||||
pub sign_up_newsletter: Option<bool>,
|
||||
}
|
||||
|
||||
#[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<String>),
|
||||
#[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<AccountRegisterValidateError> 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<NewAccount> 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<ReadyAccountRegisterFlow, AccountRegisterValidateError> {
|
||||
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<crate::models::sessions::Session, ApiError> {
|
||||
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<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
new_account: web::Json<NewAccount>,
|
||||
) -> 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<NewAccount>,
|
||||
email: web::Data<EmailQueue>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user