diff --git a/.env.example b/.env.example index 9834190..eb8f6d8 100644 --- a/.env.example +++ b/.env.example @@ -19,4 +19,7 @@ CORS_ORIGINS=https://plus-admin.polyfrost.org,http://localhost:3000,https://stor OIDC_ISSUER=https://plus-local.lun.re SPECIAL_CHAT_TARGETS= -SPECIAL_CHAT_AUTO_REPLY= \ No newline at end of file +SPECIAL_CHAT_AUTO_REPLY= +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +DISCORD_REDIRECT_URI=http://localhost:8080/account/links/discord/callback diff --git a/Cargo.toml b/Cargo.toml index bbd9c37..26d9412 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ sea-orm-migration = { version = "1.1.14", features = ["sqlx-postgres", "runtime- [workspace.dependencies.reqwest] version = "0.13.1" default-features = false -features = ["charset", "gzip", "deflate", "brotli", "zstd", "json", "query", "rustls-no-provider"] +features = ["charset", "gzip", "deflate", "brotli", "zstd", "json", "query", "form", "rustls-no-provider"] [workspace.dependencies.rustls] version = "0.23.35" diff --git a/database/entities/src/entities/linked_account.rs b/database/entities/src/entities/linked_account.rs new file mode 100644 index 0000000..f198b16 --- /dev/null +++ b/database/entities/src/entities/linked_account.rs @@ -0,0 +1,38 @@ +//! `SeaORM` Entity + +use super::sea_orm_active_enums::LinkProvider; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "linked_account")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub provider: LinkProvider, + #[sea_orm(column_type = "Text")] + pub provider_user_id: String, + #[sea_orm(column_type = "Text", nullable)] + pub username: Option, + pub linked_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/database/entities/src/entities/mod.rs b/database/entities/src/entities/mod.rs index 3369805..d69e7bf 100644 --- a/database/entities/src/entities/mod.rs +++ b/database/entities/src/entities/mod.rs @@ -34,6 +34,7 @@ pub mod group_members; pub mod group_message_reads; pub mod group_messages; pub mod groups; +pub mod linked_account; pub mod monthly_active_login; pub mod oidc_signing_keys; pub mod ownership_grant; diff --git a/database/entities/src/entities/prelude.rs b/database/entities/src/entities/prelude.rs index 9866af2..429d382 100644 --- a/database/entities/src/entities/prelude.rs +++ b/database/entities/src/entities/prelude.rs @@ -32,6 +32,7 @@ pub use super::group_members::Entity as GroupMembers; pub use super::group_message_reads::Entity as GroupMessageReads; pub use super::group_messages::Entity as GroupMessages; pub use super::groups::Entity as Groups; +pub use super::linked_account::Entity as LinkedAccount; pub use super::monthly_active_login::Entity as MonthlyActiveLogin; pub use super::oidc_signing_keys::Entity as OidcSigningKeys; pub use super::ownership_grant::Entity as OwnershipGrant; diff --git a/database/entities/src/entities/sea_orm_active_enums.rs b/database/entities/src/entities/sea_orm_active_enums.rs index edecac3..a03303e 100644 --- a/database/entities/src/entities/sea_orm_active_enums.rs +++ b/database/entities/src/entities/sea_orm_active_enums.rs @@ -362,3 +362,21 @@ pub enum OwnershipEventKind { #[sea_orm(string_value = "expired")] Expired, } +#[derive( + Debug, + Clone, + PartialEq, + Eq, + EnumIter, + DeriveActiveEnum, + schemars :: JsonSchema, + serde :: Deserialize, + serde :: Serialize, + Hash, +)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "link_provider")] +#[serde(rename_all = "snake_case")] +pub enum LinkProvider { + #[sea_orm(string_value = "discord")] + Discord, +} diff --git a/database/migrations/src/lib.rs b/database/migrations/src/lib.rs index 479c924..4a75343 100644 --- a/database/migrations/src/lib.rs +++ b/database/migrations/src/lib.rs @@ -72,6 +72,7 @@ mod m20260928_000000_create_player_mods; mod m20260929_000000_product_for_sale; mod m20261014_000000_create_dm_delegates; mod m20261015_000000_create_outfits_and_skins; +mod m20261017_000000_create_linked_accounts; pub struct Migrator; @@ -151,6 +152,7 @@ impl MigratorTrait for Migrator { Box::new(m20260929_000000_product_for_sale::Migration), Box::new(m20261014_000000_create_dm_delegates::Migration), Box::new(m20261015_000000_create_outfits_and_skins::Migration), + Box::new(m20261017_000000_create_linked_accounts::Migration), ] } } diff --git a/database/migrations/src/m20261017_000000_create_linked_accounts.rs b/database/migrations/src/m20261017_000000_create_linked_accounts.rs new file mode 100644 index 0000000..efbae41 --- /dev/null +++ b/database/migrations/src/m20261017_000000_create_linked_accounts.rs @@ -0,0 +1,100 @@ +use sea_orm_migration::{ + prelude::{extension::postgres::Type, *}, + sea_orm::{EnumIter, Iterable as _}, +}; + +use crate::m20250917_163702_create_users_table::User; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[derive(DeriveIden)] +pub struct LinkProvider; + +#[derive(DeriveIden, EnumIter)] +pub enum LinkProviderVariants { + Discord, +} + +/// An external account linked to a player, at most one per provider. +#[derive(DeriveIden)] +pub enum LinkedAccount { + Table, + UserId, + Provider, + ProviderUserId, + /// The provider-side display name as of linking. + Username, + LinkedAt, +} + +const PROVIDER_USER_IDX: &str = "linked_account_provider_user_idx"; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_type( + Type::create() + .as_enum(LinkProvider) + .values(LinkProviderVariants::iter()) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(LinkedAccount::Table) + .if_not_exists() + .col(ColumnDef::new(LinkedAccount::UserId).integer().not_null()) + .col( + ColumnDef::new(LinkedAccount::Provider) + .custom(LinkProvider) + .not_null(), + ) + .col(ColumnDef::new(LinkedAccount::ProviderUserId).text().not_null()) + .col(ColumnDef::new(LinkedAccount::Username).text()) + .col( + ColumnDef::new(LinkedAccount::LinkedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .primary_key( + Index::create() + .col(LinkedAccount::UserId) + .col(LinkedAccount::Provider), + ) + .foreign_key( + ForeignKey::create() + .from(LinkedAccount::Table, LinkedAccount::UserId) + .to(User::Table, User::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name(PROVIDER_USER_IDX) + .table(LinkedAccount::Table) + .col(LinkedAccount::Provider) + .col(LinkedAccount::ProviderUserId) + .unique() + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(LinkedAccount::Table).to_owned()) + .await?; + manager + .drop_type(Type::drop().name(LinkProvider).to_owned()) + .await + } +} diff --git a/src/api/state/mod.rs b/src/api/state/mod.rs index 3f43cf3..4e69394 100644 --- a/src/api/state/mod.rs +++ b/src/api/state/mod.rs @@ -35,6 +35,7 @@ use crate::{ api_tokens::ApiTokens, rate_limit::{RateLimiter, RequestLimits}, v0::{ + account::links::{self, DiscordOAuth}, cosmetics::CachedAssetInfo, oidc::{self, AuthorizationCode, OidcSigningKey}, }, @@ -88,6 +89,8 @@ pub(super) struct ApiState { pub(super) oidc_codes: Cache, pub(super) special_chat_targets: Vec, pub(super) special_chat_auto_reply: Option, + pub(super) discord: Option, + pub(super) link_states: Cache, pub(super) instrumentation: Instrumentation, } @@ -156,6 +159,8 @@ impl ApiState { oidc_codes: oidc::new_authorization_code_cache(), special_chat_targets: args.special_chat_targets.clone(), special_chat_auto_reply: args.special_chat_auto_reply.clone(), + discord: DiscordOAuth::from_args(args), + link_states: links::new_link_state_cache(), instrumentation, s3_public_url: args.s3_public_url.trim_end_matches('/').into(), s3_bucket, diff --git a/src/api/v0/account/links.rs b/src/api/v0/account/links.rs new file mode 100644 index 0000000..21a1c36 --- /dev/null +++ b/src/api/v0/account/links.rs @@ -0,0 +1,326 @@ +use std::{fmt, time::Duration}; + +use aide::{ + OperationIo, + axum::{ + ApiRouter, + routing::{delete_with, get_with, post_with}, + }, + transform::TransformOperation, +}; +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::{DateTime, FixedOffset}; +use moka::future::Cache; +use rand::RngCore; +use schemars::JsonSchema; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set, SqlErr, sea_query::OnConflict}; +use serde::{Deserialize, Serialize}; + +use crate::{ + api::{ApiState, v0::account::AuthenticatedPlayer}, + commands::ServeArgs, +}; +use entities::{linked_account, prelude::*, sea_orm_active_enums::LinkProvider}; + +const LINK_STATE_TTL: Duration = Duration::from_secs(10 * 60); +const DISCORD_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone)] +pub(crate) struct DiscordOAuth { + client_id: String, + client_secret: String, + redirect_uri: String, +} + +impl fmt::Debug for DiscordOAuth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DiscordOAuth") + .field("client_id", &self.client_id) + .finish_non_exhaustive() + } +} + +impl DiscordOAuth { + pub(crate) fn from_args(args: &ServeArgs) -> Option { + (!args.discord_client_id.is_empty()).then(|| Self { + client_id: args.discord_client_id.clone(), + client_secret: args.discord_client_secret.clone(), + redirect_uri: args.discord_redirect_uri.clone(), + }) + } +} + +/// Pending OAuth `state` values, each mapped to the user who started the link. +pub(crate) fn new_link_state_cache() -> Cache { + Cache::builder().time_to_live(LINK_STATE_TTL).build() +} + +#[derive(thiserror::Error, Debug, OperationIo)] +pub enum LinkError { + #[error("Linking this provider is not configured")] + NotConfigured, + #[error("This link request has expired, start linking again")] + InvalidState, + #[error("Linking was cancelled")] + Cancelled, + #[error("That account is already linked to another player")] + AlreadyLinked, + #[error("Unable to reach the provider: {0}")] + Provider(#[from] reqwest::Error), + #[error("Unable to query database: {0}")] + Database(#[from] sea_orm::error::DbErr), +} + +impl IntoResponse for LinkError { + fn into_response(self) -> Response { + crate::api::error_response( + match self { + Self::NotConfigured => StatusCode::SERVICE_UNAVAILABLE, + Self::InvalidState | Self::Cancelled => StatusCode::BAD_REQUEST, + Self::AlreadyLinked => StatusCode::CONFLICT, + Self::Provider(_) => StatusCode::BAD_GATEWAY, + Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, + }, + self, + ) + } +} + +#[derive(Debug, Serialize, JsonSchema)] +struct LinkedAccountInfo { + provider: LinkProvider, + provider_user_id: String, + username: Option, + linked_at: DateTime, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct StartLinkResponse { + /// Open in the player's browser to authorize the link. + url: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct CallbackQuery { + /// Absent when the player declined on Discord's consent screen. + code: Option, + state: String, +} + +#[derive(Deserialize)] +struct DiscordToken { + access_token: String, +} + +#[derive(Deserialize)] +struct DiscordUser { + id: String, + username: String, +} + +fn list_doc(op: TransformOperation) -> TransformOperation { + op.id("listLinkedAccounts") + .summary("List the caller's linked external accounts") + .tag("account") +} + +fn start_discord_doc(op: TransformOperation) -> TransformOperation { + op.id("startDiscordLink") + .summary("Start linking a Discord account") + .description( + "Returns a Discord authorization url to open in the player's \ + browser. Discord redirects back to the callback, which completes \ + the link. Linking again replaces the existing Discord account.", + ) + .tag("account") +} + +fn discord_callback_doc(op: TransformOperation) -> TransformOperation { + op.id("discordLinkCallback") + .summary("Discord OAuth2 redirect target") + .description("Visited by the player's browser, not called directly.") + .tag("account") +} + +fn unlink_doc(op: TransformOperation) -> TransformOperation { + op.id("unlinkAccount") + .summary("Unlink the caller's account for a provider") + .tag("account") +} + +pub(super) fn router() -> ApiRouter { + ApiRouter::new() + .api_route("/links", get_with(self::list, self::list_doc)) + .api_route( + "/links/discord", + post_with(self::start_discord, self::start_discord_doc), + ) + .api_route( + "/links/discord/callback", + get_with(self::discord_callback, self::discord_callback_doc), + ) + .api_route("/links/{provider}", delete_with(self::unlink, self::unlink_doc)) +} + +#[tracing::instrument(level = "debug", skip(state))] +async fn list( + State(state): State, + AuthenticatedPlayer(player): AuthenticatedPlayer, +) -> Result>, LinkError> { + let links = LinkedAccount::find() + .filter(linked_account::Column::UserId.eq(player.id)) + .all(&state.database) + .await?; + + Ok(Json( + links + .into_iter() + .map(|link| LinkedAccountInfo { + provider: link.provider, + provider_user_id: link.provider_user_id, + username: link.username, + linked_at: link.linked_at, + }) + .collect(), + )) +} + +#[tracing::instrument(level = "debug", skip(state))] +async fn start_discord( + State(state): State, + AuthenticatedPlayer(player): AuthenticatedPlayer, +) -> Result, LinkError> { + let discord = state.discord.as_ref().ok_or(LinkError::NotConfigured)?; + + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + let link_state = URL_SAFE_NO_PAD.encode(bytes); + state.link_states.insert(link_state.clone(), player.id).await; + + // No `prompt=none`: the consent screen is what stops someone completing a + // link they were sent, which would tie their Discord to the sender. + let url = reqwest::Url::parse_with_params( + "https://discord.com/oauth2/authorize", + [ + ("client_id", discord.client_id.as_str()), + ("response_type", "code"), + ("scope", "identify"), + ("redirect_uri", discord.redirect_uri.as_str()), + ("state", link_state.as_str()), + ], + ) + .expect("static Discord authorize url is valid"); + + Ok(Json(StartLinkResponse { url: url.into() })) +} + +#[tracing::instrument(level = "debug", skip(state, query))] +async fn discord_callback( + State(state): State, + Query(query): Query, +) -> Result<&'static str, LinkError> { + let discord = state.discord.as_ref().ok_or(LinkError::NotConfigured)?; + let user_id = state + .link_states + .remove(&query.state) + .await + .ok_or(LinkError::InvalidState)?; + let code = query.code.ok_or(LinkError::Cancelled)?; + + let token: DiscordToken = state + .client + .post("https://discord.com/api/oauth2/token") + .timeout(DISCORD_TIMEOUT) + .basic_auth(&discord.client_id, Some(&discord.client_secret)) + .form(&[ + ("grant_type", "authorization_code"), + ("code", code.as_str()), + ("redirect_uri", discord.redirect_uri.as_str()), + ]) + .send() + .await? + .error_for_status()? + .json() + .await?; + + let discord_user: DiscordUser = state + .client + .get("https://discord.com/api/users/@me") + .timeout(DISCORD_TIMEOUT) + .bearer_auth(&token.access_token) + .send() + .await? + .error_for_status()? + .json() + .await?; + + link( + &state, + user_id, + LinkProvider::Discord, + discord_user.id, + Some(discord_user.username), + ) + .await?; + + Ok("Your Discord account is now linked. You can close this tab.") +} + +#[tracing::instrument(level = "debug", skip(state))] +async fn unlink( + State(state): State, + AuthenticatedPlayer(player): AuthenticatedPlayer, + Path(provider): Path, +) -> Result { + LinkedAccount::delete_by_id((player.id, provider)) + .exec(&state.database) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +/// Links the provider account to the user, replacing any account they already +/// linked for that provider. +async fn link( + state: &ApiState, + user_id: i32, + provider: LinkProvider, + provider_user_id: String, + username: Option, +) -> Result<(), LinkError> { + let result = LinkedAccount::insert(linked_account::ActiveModel { + user_id: Set(user_id), + provider: Set(provider), + provider_user_id: Set(provider_user_id), + username: Set(username), + linked_at: Set(chrono::Utc::now().fixed_offset()), + }) + .on_conflict( + OnConflict::columns([ + linked_account::Column::UserId, + linked_account::Column::Provider, + ]) + .update_columns([ + linked_account::Column::ProviderUserId, + linked_account::Column::Username, + linked_account::Column::LinkedAt, + ]) + .to_owned(), + ) + .exec_without_returning(&state.database) + .await; + + match result { + Ok(_) => Ok(()), + Err(error) if matches!(error.sql_err(), Some(SqlErr::UniqueConstraintViolation(_))) => { + Err(LinkError::AlreadyLinked) + } + Err(error) => Err(error.into()), + } +} diff --git a/src/api/v0/account/mod.rs b/src/api/v0/account/mod.rs index 9701ec0..659bc37 100644 --- a/src/api/v0/account/mod.rs +++ b/src/api/v0/account/mod.rs @@ -1,4 +1,5 @@ mod link_puid; +pub(crate) mod links; mod login; mod mods; @@ -31,6 +32,7 @@ pub(super) async fn setup_router() -> ApiRouter { ApiRouter::new() .merge(login::router()) .merge(link_puid::router()) + .merge(links::router()) .merge(mods::router()) } diff --git a/src/api/v0/players/lookup.rs b/src/api/v0/players/lookup.rs index 849b901..6fb5792 100644 --- a/src/api/v0/players/lookup.rs +++ b/src/api/v0/players/lookup.rs @@ -5,9 +5,14 @@ use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, sea_query::Expr}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use entities::sea_orm_active_enums::LinkProvider; + use super::PlayerError; use crate::{ - api::{ApiState, v0::account::AuthenticatedPlayer}, + api::{ + ApiState, admin_auth::AdminAuthenticationExtractor, + v0::account::AuthenticatedPlayer, + }, database::DatabaseUserExt, }; @@ -24,6 +29,22 @@ fn endpoint_doc(op: TransformOperation) -> TransformOperation { .tag("players") } +fn by_link_doc(op: TransformOperation) -> TransformOperation { + op.id("lookupPlayerByLinkedAccount") + .summary("Look up a player by a linked account") + .description( + "Resolves a provider user id (e.g. a Discord user id) to the \ + player who linked it. 404 if nobody has. Admin password required.", + ) + .tag("players") +} + +#[derive(Debug, Serialize, JsonSchema)] +struct LinkedPlayer { + id: Uuid, + username: Option, +} + #[derive(Debug, Serialize, JsonSchema)] pub(in crate::api) struct LookupResponse { pub id: Uuid, @@ -31,10 +52,38 @@ pub(in crate::api) struct LookupResponse { } pub(super) fn router() -> aide::axum::ApiRouter { - aide::axum::ApiRouter::new().api_route( - "/by-username/{username}", - get_with(self::endpoint, self::endpoint_doc), - ) + aide::axum::ApiRouter::new() + .api_route( + "/by-username/{username}", + get_with(self::endpoint, self::endpoint_doc), + ) + .api_route( + "/by-link/{provider}/{provider_user_id}", + get_with(self::by_link, self::by_link_doc), + ) +} + +#[tracing::instrument(level = "debug", skip(state, _auth))] +async fn by_link( + State(state): State, + _auth: AdminAuthenticationExtractor, + Path((provider, provider_user_id)): Path<(LinkProvider, String)>, +) -> Result, PlayerError> { + use entities::{linked_account, prelude::*}; + + let (_, player) = LinkedAccount::find() + .filter(linked_account::Column::Provider.eq(provider)) + .filter(linked_account::Column::ProviderUserId.eq(provider_user_id)) + .find_also_related(User) + .one(&state.database) + .await? + .ok_or(PlayerError::PlayerMissing)?; + let player = player.ok_or(PlayerError::PlayerMissing)?; + + Ok(Json(LinkedPlayer { + id: player.minecraft_uuid, + username: player.username, + })) } #[derive(Deserialize)] diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8f4139d..1d532bf 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -162,6 +162,22 @@ pub(crate) struct ServeArgs { optional )] pub(crate) special_chat_auto_reply: Option, + /// OAuth2 application used to link Discord accounts; linking is disabled when unset + #[bpaf(long("discord-client-id"), env("DISCORD_CLIENT_ID"), fallback(String::new()))] + pub(crate) discord_client_id: String, + #[bpaf( + long("discord-client-secret"), + env("DISCORD_CLIENT_SECRET"), + fallback(String::new()) + )] + pub(crate) discord_client_secret: String, + /// Must point at `/account/links/discord/callback` and be registered with Discord + #[bpaf( + long("discord-redirect-uri"), + env("DISCORD_REDIRECT_URI"), + fallback(String::new()) + )] + pub(crate) discord_redirect_uri: String, } fn parse_bind_addrs(value: String) -> Result, std::net::AddrParseError> {