feat: use discord_id from discord sso for role grant (#6326)

* feat: properly use labrinth's linked discord accts sso for discord bot

* Update email for fixture user in SQL insert

Signed-off-by: Calum H. <calum@modrinth.com>

* fix: rev changes

* fix: copy on email

* fix: lint

* fix: rev

* fix: lint

---------

Signed-off-by: Calum H. <calum@modrinth.com>
This commit is contained in:
Calum H.
2026-06-09 17:33:07 +00:00
committed by GitHub
parent bc5a761312
commit 543d25e2d6
20 changed files with 726 additions and 59 deletions
@@ -1,6 +1,6 @@
use crate::auth::get_user_from_headers;
use crate::database::PgPool;
use crate::database::models::ids::DBUserId;
use crate::database::models::ids::{DBNotificationId, DBUserId};
use crate::database::models::notification_item::DBNotification;
use crate::database::models::notification_item::NotificationBuilder;
use crate::database::models::user_item::DBUser;
@@ -64,34 +64,12 @@ pub async fn create(
.insert_many(user_ids, &mut txn, &redis)
.await?;
let notifications = DBNotification::get_many(&notification_ids, &mut txn)
.await?
.into_iter()
.map(Notification::from)
.collect::<Vec<_>>();
let notifications =
get_site_exposed_notifications(&notification_ids, &mut txn).await?;
txn.commit().await?;
for notification in notifications {
let notification_id = notification.id;
let to_user = notification.user_id;
if let Err(error) = broadcast_friends_message(
&redis,
RedisFriendsMessage::Notification {
to_user,
notification,
},
)
.await
{
tracing::warn!(
?error,
?notification_id,
?to_user,
"failed to broadcast realtime notification"
);
}
}
broadcast_notifications(&redis, notifications).await;
Ok(HttpResponse::Accepted().finish())
}
@@ -155,37 +133,12 @@ pub async fn create_email_sync(
.insert_many_without_delivery(notification_user_ids, &mut txn, &redis)
.await?;
let notifications = DBNotification::get_many(&notification_ids, &mut txn)
.await?
.into_iter()
.map(Notification::from)
.collect::<Vec<_>>();
let notifications =
get_site_exposed_notifications(&notification_ids, &mut txn).await?;
txn.commit().await?;
for notification in notifications {
let Notification {
user_id: to_user,
id: notification_id,
..
} = notification;
if let Err(error) = broadcast_friends_message(
&redis,
RedisFriendsMessage::Notification {
to_user,
notification,
},
)
.await
{
tracing::warn!(
?error,
?notification_id,
?to_user,
"failed to broadcast realtime notification"
);
}
}
broadcast_notifications(&redis, notifications).await;
let mut email_txn = pool.begin().await?;
@@ -332,3 +285,57 @@ pub async fn send_custom_email(
Ok(HttpResponse::Accepted().finish())
}
async fn get_site_exposed_notifications(
notification_ids: &[DBNotificationId],
txn: &mut crate::database::PgTransaction<'_>,
) -> Result<Vec<Notification>, ApiError> {
let raw_ids = notification_ids.iter().map(|x| x.0).collect::<Vec<_>>();
let exposed_ids = sqlx::query_scalar!(
r#"
SELECT n.id AS "id!"
FROM notifications n
INNER JOIN notifications_types nt ON nt.name = n.body ->> 'type'
WHERE n.id = ANY($1::BIGINT[])
AND nt.expose_in_site_notifications = TRUE
"#,
&raw_ids[..],
)
.fetch_all(&mut *txn)
.await?
.into_iter()
.map(DBNotificationId)
.collect::<Vec<_>>();
Ok(DBNotification::get_many(&exposed_ids, txn)
.await?
.into_iter()
.map(Notification::from)
.collect())
}
async fn broadcast_notifications(
redis: &RedisPool,
notifications: Vec<Notification>,
) {
for notification in notifications {
let notification_id = notification.id;
let to_user = notification.user_id;
if let Err(error) = broadcast_friends_message(
redis,
RedisFriendsMessage::Notification {
to_user,
notification,
},
)
.await
{
tracing::warn!(
?error,
?notification_id,
?to_user,
"failed to broadcast realtime notification"
);
}
}
}
+98 -1
View File
@@ -29,13 +29,18 @@ use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use ariadne::ids::base62_impl::{parse_base62, to_base62};
use ariadne::ids::random_base62_rng;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{Duration, Utc};
use eyre::eyre;
use hmac::{Hmac, Mac};
use lettre::message::Mailbox;
use rand::Rng;
use rand::distributions::Alphanumeric;
use rand_chacha::ChaCha20Rng;
use rand_chacha::rand_core::SeedableRng;
use reqwest::header::AUTHORIZATION;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashMap;
use std::str::FromStr;
use std::sync::Arc;
@@ -61,7 +66,8 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) {
.service(set_email)
.service(verify_email)
.service(subscribe_newsletter)
.service(get_newsletter_subscription_status),
.service(get_newsletter_subscription_status)
.service(discord_community_link),
);
}
@@ -1369,6 +1375,97 @@ pub struct DeleteAuthProvider {
pub provider: AuthProvider,
}
type HmacSha256 = Hmac<Sha256>;
#[derive(Serialize, utoipa::ToSchema)]
pub struct DiscordCommunityLinkResponse {
pub url: String,
}
#[derive(Serialize)]
struct DiscordCommunityHandoffPayload {
v: u8,
modrinth_user_id: String,
discord_user_id: String,
iat: i64,
exp: i64,
nonce: String,
}
#[utoipa::path(
operation_id = "discordCommunityLink",
responses(
(status = 200, description = "Discord community bot handoff URL", body = DiscordCommunityLinkResponse),
(status = 400, description = "Discord provider not linked"),
(status = 401, description = "Unauthorized")
),
security(("bearer_auth" = ["SESSION_ACCESS"]))
)]
#[post("/discord-community-link")]
pub async fn discord_community_link(
req: HttpRequest,
client: Data<PgPool>,
redis: Data<RedisPool>,
session_queue: Data<AuthQueue>,
) -> Result<web::Json<DiscordCommunityLinkResponse>, ApiError> {
if ENV.DISCORD_COMMUNITY_LINK_SECRET.is_empty()
|| ENV.DISCORD_COMMUNITY_BOT_HANDOFF_URL.is_empty()
{
return Err(ApiError::Internal(eyre!(
"discord community linking is not configured"
)));
}
let db_user = get_full_user_from_headers(
&req,
&**client,
&redis,
&session_queue,
Scopes::SESSION_ACCESS,
)
.await?
.1;
let Some(discord_id) = db_user.discord_id else {
return Err(ApiError::Request(eyre!("discord account is not linked")));
};
let now = Utc::now().timestamp();
let nonce = ChaCha20Rng::from_entropy()
.sample_iter(&Alphanumeric)
.take(32)
.map(char::from)
.collect::<String>();
let payload = DiscordCommunityHandoffPayload {
v: 1,
modrinth_user_id: ariadne::ids::UserId::from(db_user.id).to_string(),
discord_user_id: discord_id.to_string(),
iat: now,
exp: now + 600,
nonce,
};
let payload_json = serde_json::to_vec(&payload).wrap_internal_err(
"failed to serialize discord community handoff payload",
)?;
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json);
let mut mac = HmacSha256::new_from_slice(
ENV.DISCORD_COMMUNITY_LINK_SECRET.as_bytes(),
)
.wrap_internal_err("failed to initialize discord community link hmac")?;
mac.update(payload_b64.as_bytes());
let sig = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
let url = format!(
"{}?payload={}&sig={}",
ENV.DISCORD_COMMUNITY_BOT_HANDOFF_URL, payload_b64, sig,
);
Ok(web::Json(DiscordCommunityLinkResponse { url }))
}
#[utoipa::path(
delete,
operation_id = "deleteAuthProvider",
+2 -1
View File
@@ -54,7 +54,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
.service(flows::set_email)
.service(flows::verify_email)
.service(flows::subscribe_newsletter)
.service(flows::get_newsletter_subscription_status),
.service(flows::get_newsletter_subscription_status)
.service(flows::discord_community_link),
);
cfg.service(pats::get_pats);
cfg.service(pats::create_pat);