feat: update auth flow (#5790)

* Backend routes for choosing username in OAuth flow

* fix up oauth flow routes

* improve URL-related OAuth code

* Use user-provided callback addr instead of SELF_ADDR

* Revert "Use user-provided callback addr instead of SELF_ADDR"

This reverts commit 7ea0635d86.

* fix flow

* fix: backend response for create oauth account

* feat: new auth flow (#5840)

* update auth with new designs

* refactor: auth.js to auth.ts

* refactor: componentize auth pages

* fix: auth pages height

* feat: initial implementation of new sign-in oauth

* fix create account flow

* fix checkbox

* remove hard coded username

* implement create user validation endpoint and add more specific error responses

* feat: implement under 13 DOB guard and email/password validation route

* fix: TOCTOU issue

* refactor: pnpm prepr

* fix: make sure staging uses staging

* fix: hcaptcha styles

* fix: copy

* remove: auth/welcome page as its no longer used

* refactor: bring root page card styles into individual components and use tailwind

* fix: account settings modals to use new modal and fix lots of bad styles

* refactor: pnpm prepr

* feat: implement last signed in indicator

* fix: append number when generated name from email is taken

* refactor: pnpm prepr

* fix: last sign in badge color

* fix: qa issues

* refactor: pnpm prepr

* fix: hover effect on native date picker

* chore: temp staging undo

* Revert "chore: temp staging undo"

This reverts commit cad6bd4f92.

* feat: handle app create account

* fix: last signed in style

* fix: add initOnMounted for SSR race

* refactor: use typescript

* refactor: pnpm prepr

* refactor: use typescript for reset-password

* refactor: convert verify-email to use typescript

* refactor: convert authorize.vue to use typescript

* fix: authorize.vue error states

* feat: small style updates

* feat: implement date picker component

* feat: improve UX and styles for range select

* refactor: pnpm prepr

* fix: range select border styles

* feat: implement date picker component in create account

* feat: implement preserve date for date picker

* update rust toolchain

* increase recursion limit

* fix: date picker can be null

* fix: calculate age based on user's timezone

* fix: number input icons color

* fix: date picker icons

* feat: improve styles

* fix: add width on date

* fix: hover color bad on number input

* fix lints

* feat: add default date open view

* fmt

* fix: account.vue

* fix: remove default date to open 13 years ago

* fix: edit copy on info banner

* fix: cannot hover over project card tooltip items (#6071)

fix: cannot hover over project cards

* feat: improve add dependency flow (#6075)

* fix: shadow on nav

* feat: improve add dependency flow

* feat: update suggested dependency style

* feat: update dependency rows to use version number and update styles

* feat: implement combobox select searched text on focus

* feat: add Tabs.vue

* feat: update nav tabs to use tabs

* feat: improve project search dropdown

* fix: dependency search not clearing inbound query

* fix: combobox no options open state bug

* feat: improve dependency project and version search

* fix: open modrinth project links in the app (#6072)

* pin tanstack versions + set pnpm min age to 7 days

* squash commits

* fix: 2 factor auth enter code screen styles

* update copy

* update copy

* improve reset password

* feat: update sign in screen

* fix: unused import

* Merge branch 'main' into boris/dev-908-backend-changes

* Revert "Merge branch 'main' into boris/dev-908-backend-changes"

This reverts commit b9b03796e3.

* fix: add stroke

* feat: add passkey support (#6375)

* feat: add passkey backend

* feat: passkey frontend

* invalidate sessions on compromised passkey

* chore: run sqlx prepare

* fix: make passkey button use both collumns to prevent empty space

* fix: correctly verify max passkeys in finish route

* fix: use structs for response

* fix: add rp name default

* style: use web::Json

* fmt

* feat: improve manage passkeys UI

* fix copy

* pnpm prepr

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Michael H. <michael@iptables.sh>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com>
This commit is contained in:
aecsocket
2026-06-25 20:36:19 +00:00
committed by GitHub
co-authored by tdgao Truman Gao Michael H. Calum H. Calum H. Prospector DeDiamondPro
parent 6fc741f7c0
commit ef4044534f
125 changed files with 5170 additions and 2828 deletions
+17 -3
View File
@@ -46,7 +46,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")]
@@ -76,7 +82,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,
}
}
@@ -105,7 +115,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",
}
}
+38 -14
View File
@@ -1,15 +1,17 @@
use super::ids::*;
use crate::auth::AuthProvider;
use crate::auth::oauth::uris::OAuthRedirectUris;
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::models::pats::Scopes;
use crate::{auth::AuthProvider, routes::internal::flows::TempUser};
use chrono::Duration;
use rand::Rng;
use rand::distributions::Alphanumeric;
use rand_chacha::ChaCha20Rng;
use rand_chacha::rand_core::SeedableRng;
use serde::{Deserialize, Serialize};
use url::Url;
use webauthn_rs::prelude::{DiscoverableAuthentication, PasskeyRegistration};
const FLOWS_NAMESPACE: &str = "flows";
@@ -18,10 +20,15 @@ const FLOWS_NAMESPACE: &str = "flows";
pub enum DBFlow {
OAuth {
user_id: Option<DBUserId>,
url: String,
url: Url,
provider: AuthProvider,
existing_user_id: Option<DBUserId>,
},
OAuthPending {
url: Url,
provider: AuthProvider,
user: TempUser,
},
Login2FA {
user_id: DBUserId,
},
@@ -52,31 +59,48 @@ pub enum DBFlow {
scopes: Scopes,
original_redirect_uri: Option<String>, // Needed for https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
},
RegisterPasskey {
user_id: DBUserId,
state: PasskeyRegistration,
},
AuthenticatePasskey {
state: DiscoverableAuthentication,
},
}
impl DBFlow {
pub async fn insert_with_state(
&self,
expires: Duration,
redis: &RedisPool,
state: &str,
) -> Result<(), DatabaseError> {
let mut redis = redis.connect().await?;
redis
.set_serialized_to_json(
FLOWS_NAMESPACE,
&state,
&self,
Some(expires.num_seconds()),
)
.await?;
Ok(())
}
pub async fn insert(
&self,
expires: Duration,
redis: &RedisPool,
) -> Result<String, DatabaseError> {
let mut redis = redis.connect().await?;
let flow = ChaCha20Rng::from_entropy()
let state = ChaCha20Rng::from_entropy()
.sample_iter(&Alphanumeric)
.take(32)
.map(char::from)
.collect::<String>();
redis
.set_serialized_to_json(
FLOWS_NAMESPACE,
&flow,
&self,
Some(expires.num_seconds()),
)
.await?;
Ok(flow)
self.insert_with_state(expires, redis, &state).await?;
Ok(state)
}
pub async fn get(
+5 -1
View File
@@ -4,7 +4,7 @@ use crate::models::ids::{
AffiliateCodeId, AnalyticsEventId, AttributionGroupId, CampaignDonationId,
ChargeId, CollectionId, FileId, ImageId, NotificationId,
OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId,
OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId,
OAuthRedirectUriId, OrganizationId, PasskeyId, PatId, PayoutId, ProductId,
ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId,
SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId, ThreadMessageId,
UserSubscriptionId, VersionId,
@@ -282,6 +282,10 @@ db_id_interface!(
AnalyticsEventId,
generator: generate_analytics_event_id @ "analytics_events",
);
db_id_interface!(
PasskeyId,
generator: generate_passkey_id @ "user_passkeys",
);
id_type!(CategoryId as i32);
id_type!(GameId as i32);
+2
View File
@@ -23,6 +23,7 @@ pub mod oauth_client_authorization_item;
pub mod oauth_client_item;
pub mod oauth_token_item;
pub mod organization_item;
pub mod passkey_item;
pub mod pat_item;
pub mod payout_item;
pub mod payouts_values_notifications;
@@ -51,6 +52,7 @@ pub use ids::*;
pub use image_item::DBImage;
pub use oauth_client_item::DBOAuthClient;
pub use organization_item::DBOrganization;
pub use passkey_item::DBPasskey;
pub use project_item::DBProject;
pub use team_item::DBTeam;
pub use team_item::DBTeamMember;
@@ -0,0 +1,191 @@
use super::ids::*;
use crate::database::PgTransaction;
use crate::database::models::DatabaseError;
use chrono::{DateTime, Utc};
use futures::TryStreamExt;
use serde::{Deserialize, Serialize};
use sqlx::types::Json;
use webauthn_rs::prelude::Passkey;
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct DBPasskey {
pub id: DBPasskeyId,
pub user_id: DBUserId,
pub name: String,
pub credential_id: Vec<u8>,
pub passkey: Passkey,
pub created_at: DateTime<Utc>,
pub last_used: Option<DateTime<Utc>>,
}
impl DBPasskey {
pub async fn insert(
&self,
transaction: &mut PgTransaction<'_>,
) -> Result<(), DatabaseError> {
sqlx::query!(
"
INSERT INTO user_passkeys (
id, user_id, name, credential_id, passkey, created_at, last_used
)
VALUES (
$1, $2 ,$3, $4, $5, $6, $7
)
",
self.id as DBPasskeyId,
self.user_id as DBUserId,
self.name,
self.credential_id,
Json(&self.passkey) as _,
self.created_at,
self.last_used,
)
.execute(&mut *transaction)
.await?;
Ok(())
}
pub async fn get_by_credential_id<'a, E>(
credential_id: &[u8],
exec: E,
) -> Result<Option<DBPasskey>, DatabaseError>
where
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
let row = sqlx::query!(
r#"
SELECT id, user_id, name, credential_id,
passkey AS "passkey: sqlx::types::Json<Passkey>",
last_used, created_at
FROM user_passkeys
WHERE credential_id = $1
"#,
credential_id,
)
.fetch_optional(exec)
.await?
.map(|x| DBPasskey {
id: DBPasskeyId(x.id),
user_id: DBUserId(x.user_id),
name: x.name,
credential_id: x.credential_id,
passkey: x.passkey.0,
created_at: x.created_at,
last_used: x.last_used,
});
Ok(row)
}
pub async fn get_for_user<'a, E>(
user_id: DBUserId,
exec: E,
) -> Result<Vec<DBPasskey>, DatabaseError>
where
E: crate::database::Executor<'a, Database = sqlx::Postgres>,
{
let passkeys = sqlx::query!(
r#"
SELECT id, user_id, name, credential_id,
passkey AS "passkey: sqlx::types::Json<Passkey>",
last_used, created_at
FROM user_passkeys
WHERE user_id = $1
ORDER BY created_at DESC
"#,
user_id.0,
)
.fetch(exec)
.map_ok(|x| DBPasskey {
id: DBPasskeyId(x.id),
user_id: DBUserId(x.user_id),
name: x.name,
credential_id: x.credential_id,
passkey: x.passkey.0,
created_at: x.created_at,
last_used: x.last_used,
})
.try_collect::<Vec<DBPasskey>>()
.await?;
Ok(passkeys)
}
pub async fn rename(
id: DBPasskeyId,
user_id: DBUserId,
name: &str,
transaction: &mut PgTransaction<'_>,
) -> Result<bool, DatabaseError> {
let result = sqlx::query!(
"
UPDATE user_passkeys SET name = $1
WHERE id = $2 AND user_id = $3
",
name,
id as DBPasskeyId,
user_id as DBUserId,
)
.execute(&mut *transaction)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn update_after_auth(
id: DBPasskeyId,
passkey: Passkey,
transaction: &mut PgTransaction<'_>,
) -> Result<bool, DatabaseError> {
let result = sqlx::query!(
"
UPDATE user_passkeys
SET passkey = $1, last_used = NOW()
WHERE id = $2
",
Json(&passkey) as _,
id as DBPasskeyId,
)
.execute(&mut *transaction)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn remove(
id: DBPasskeyId,
transaction: &mut PgTransaction<'_>,
) -> Result<bool, DatabaseError> {
let result = sqlx::query!(
"
DELETE FROM user_passkeys
WHERE id = $1
",
id as DBPasskeyId,
)
.execute(&mut *transaction)
.await?;
Ok(result.rows_affected() > 0)
}
pub async fn remove_for_user(
id: DBPasskeyId,
user_id: DBUserId,
transaction: &mut PgTransaction<'_>,
) -> Result<bool, DatabaseError> {
let result = sqlx::query!(
"
DELETE FROM user_passkeys
WHERE id = $1 AND user_id = $2
",
id as DBPasskeyId,
user_id as DBUserId,
)
.execute(&mut *transaction)
.await?;
Ok(result.rows_affected() > 0)
}
}
@@ -5,6 +5,7 @@ use crate::database::redis::RedisPool;
use ariadne::ids::base62_impl::parse_base62;
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use futures_util::TryStreamExt;
use serde::{Deserialize, Serialize};
use std::fmt::{Debug, Display};
use std::hash::Hash;
@@ -311,4 +312,22 @@ impl DBSession {
Ok(Some(()))
}
pub async fn remove_all_for_user(
user_id: DBUserId,
transaction: &mut PgTransaction<'_>,
) -> Result<Vec<(DBSessionId, String)>, sqlx::Error> {
let sessions = sqlx::query!(
"
DELETE FROM sessions WHERE user_id = $1 RETURNING id, session
",
user_id.0
)
.fetch(&mut *transaction)
.map_ok(|x| (DBSessionId(x.id), x.session))
.try_collect()
.await?;
Ok(sessions)
}
}
+10 -3
View File
@@ -55,6 +55,8 @@ pub struct DBUser {
pub allow_friend_requests: bool,
pub is_subscribed_to_newsletter: bool,
pub eligibility_verified_at: Option<DateTime<Utc>>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
@@ -83,13 +85,15 @@ impl DBUser {
avatar_url, raw_avatar_url, bio, created,
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
email_verified, password, paypal_id, paypal_country, paypal_email,
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,
eligibility_verified_at
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7,
$8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21, $22
$14, $15, $16, $17, $18, $19, $20, $21, $22,
$23
)
",
self.id as DBUserId,
@@ -114,6 +118,7 @@ impl DBUser {
self.stripe_customer_id,
self.allow_friend_requests,
self.is_subscribed_to_newsletter,
self.eligibility_verified_at,
)
.execute(&mut *transaction)
.await?;
@@ -209,7 +214,8 @@ impl DBUser {
) AS campaign_pride_26_total_amount_donated_usd,
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter,
eligibility_verified_at
FROM users
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
",
@@ -259,6 +265,7 @@ impl DBUser {
totp_secret: u.totp_secret,
allow_friend_requests: u.allow_friend_requests,
is_subscribed_to_newsletter: u.is_subscribed_to_newsletter,
eligibility_verified_at: u.eligibility_verified_at,
};
acc.insert(u.id, (Some(u.username), user));
+2
View File
@@ -311,4 +311,6 @@ vars! {
SERVER_PING_MIN_INTERVAL_SEC: u64 = 30u64 * 60;
SERVER_PING_TIMEOUT_MS: u64 = 3u64 * 1000;
SERVER_PING_MAX_FAIL_COUNT: u64 = 3u64;
WEBAUTHN_RP_NAME: String = "Modrinth";
}
+18
View File
@@ -28,6 +28,8 @@ use crate::util::http::HttpClient;
use crate::util::ratelimit::{AsyncRateLimiter, GCRAParameters};
use crate::util::tiltify::TiltifyClient;
use sync::friends::handle_pubsub;
use url::Url;
use webauthn_rs::{Webauthn, WebauthnBuilder};
pub mod auth;
pub mod background_task;
@@ -76,6 +78,7 @@ pub struct LabrinthConfig {
pub http_client: web::Data<HttpClient>,
pub tiltify_client: web::Data<TiltifyClient>,
pub kafka_client: web::Data<util::kafka::KafkaClientState>,
pub webauthn: web::Data<Webauthn>,
}
#[allow(clippy::too_many_arguments)]
@@ -312,6 +315,19 @@ pub fn app_setup(
});
}
let webauthn_origin = Url::parse(&ENV.SITE_URL).expect("invalid SITE_URL");
let webauthn_rp_id = webauthn_origin
.host_str()
.expect("SITE_URL has no host")
.to_string();
let webauthn = web::Data::new(
WebauthnBuilder::new(&webauthn_rp_id, &webauthn_origin)
.expect("invalid webauthn configuration")
.rp_name(&ENV.WEBAUTHN_RP_NAME)
.build()
.expect("failed to build webauthn"),
);
LabrinthConfig {
pool,
ro_pool,
@@ -338,6 +354,7 @@ pub fn app_setup(
.expect("ARCHON_URL and PYRO_API_KEY must be set"),
),
email_queue: web::Data::new(email_queue),
webauthn,
}
}
@@ -381,6 +398,7 @@ pub fn app_config(
.app_data(labrinth_config.rate_limiter.clone())
.app_data(labrinth_config.kafka_client.clone())
.app_data(labrinth_config.search_state.clone())
.app_data(labrinth_config.webauthn.clone())
.configure(routes::v3::config)
.configure(routes::internal::config)
.configure(routes::root_config)
+1
View File
@@ -29,3 +29,4 @@ base62_id!(UserSubscriptionId);
base62_id!(VersionId);
base62_id!(AffiliateCodeId);
base62_id!(AnalyticsEventId);
base62_id!(PasskeyId);
+3
View File
@@ -66,6 +66,7 @@ pub struct User {
pub payout_data: Option<UserPayoutData>,
pub stripe_customer_id: Option<String>,
pub allow_friend_requests: Option<bool>,
pub eligibility_verified_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub moderation_notes: Option<Option<ModerationNote>>,
@@ -120,6 +121,7 @@ impl From<DBUser> for User {
github_id: None,
stripe_customer_id: None,
allow_friend_requests: None,
eligibility_verified_at: None,
moderation_notes: None,
}
}
@@ -186,6 +188,7 @@ impl User {
}),
stripe_customer_id: db_user.stripe_customer_id,
allow_friend_requests: Some(db_user.allow_friend_requests),
eligibility_verified_at: db_user.eligibility_verified_at,
moderation_notes: None,
}
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,3 +1,4 @@
use std::cmp::Reverse;
use std::collections::HashMap;
use crate::database::{PgPool, ReadOnlyPgPool};
@@ -88,7 +89,7 @@ pub async fn forge_updates(
)
.await?;
versions.sort_by_key(|b| std::cmp::Reverse(b.date_published));
versions.sort_by_key(|b| Reverse(b.date_published));
#[derive(Serialize)]
struct ForgeUpdates {
+3 -2
View File
@@ -1,4 +1,5 @@
use std::any::type_name;
use std::cmp::Reverse;
use std::collections::HashMap;
use crate::auth::checks::{filter_visible_versions, is_visible_project};
@@ -1389,10 +1390,10 @@ pub async fn dependency_list_internal(
)
.await?;
projects.sort_by_key(|b| std::cmp::Reverse(b.published));
projects.sort_by_key(|b| Reverse(b.published));
projects.dedup_by(|a, b| a.id == b.id);
versions.sort_by_key(|b| std::cmp::Reverse(b.date_published));
versions.sort_by_key(|b| Reverse(b.date_published));
versions.dedup_by(|a, b| a.id == b.id);
Ok(HttpResponse::Ok().json(DependencyInfo { projects, versions }))
+5 -2
View File
@@ -1,4 +1,7 @@
use std::collections::{HashMap, HashSet};
use std::{
cmp::Reverse,
collections::{HashMap, HashSet},
};
use super::{ApiError, oauth_clients::get_user_clients};
use crate::database::PgPool;
@@ -1078,7 +1081,7 @@ pub async fn user_notifications(
.map(Into::into)
.collect();
notifications.sort_by_key(|b| std::cmp::Reverse(b.created));
notifications.sort_by_key(|b| Reverse(b.created));
Ok(HttpResponse::Ok().json(notifications))
} else {
Err(ApiError::NotFound)