You've already forked AstralRinth
forked from didirus/AstralRinth
Add launcher analytics (#661)
* Add more analytics * finish hydra move * Finish websocket flow * add minecraft account flow * Finish playtime vals + payout automation
This commit is contained in:
@@ -10,12 +10,14 @@ use crate::models::pats::Scopes;
|
||||
use crate::models::users::{Badges, Role};
|
||||
use crate::parse_strings_from_var;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::queue::socket::ActiveSockets;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::captcha::check_turnstile_captcha;
|
||||
use crate::util::ext::{get_image_content_type, get_image_ext};
|
||||
use crate::util::validate::{validation_errors_to_string, RE_URL_SAFE};
|
||||
use actix_web::web::{scope, Data, Query, ServiceConfig};
|
||||
use actix_web::web::{scope, Data, Payload, Query, ServiceConfig};
|
||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
||||
use actix_ws::Closed;
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use chrono::{Duration, Utc};
|
||||
@@ -27,11 +29,13 @@ use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use validator::Validate;
|
||||
|
||||
pub fn config(cfg: &mut ServiceConfig) {
|
||||
cfg.service(
|
||||
scope("auth")
|
||||
.service(ws_init)
|
||||
.service(init)
|
||||
.service(auth_callback)
|
||||
.service(delete_auth_provider)
|
||||
@@ -46,7 +50,9 @@ pub fn config(cfg: &mut ServiceConfig) {
|
||||
.service(resend_verify_email)
|
||||
.service(set_email)
|
||||
.service(verify_email)
|
||||
.service(subscribe_newsletter),
|
||||
.service(subscribe_newsletter)
|
||||
.service(login_from_minecraft)
|
||||
.configure(super::minecraft::config),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,6 +79,167 @@ pub struct TempUser {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl TempUser {
|
||||
async fn create_account(
|
||||
self,
|
||||
provider: AuthProvider,
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
client: &PgPool,
|
||||
file_host: &Arc<dyn FileHost + Send + Sync>,
|
||||
redis: &deadpool_redis::Pool,
|
||||
) -> Result<crate::database::models::UserId, AuthenticationError> {
|
||||
if let Some(email) = &self.email {
|
||||
if crate::database::models::User::get_email(email, client)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AuthenticationError::DuplicateUser);
|
||||
}
|
||||
}
|
||||
|
||||
let user_id = crate::database::models::generate_user_id(transaction).await?;
|
||||
|
||||
let mut username_increment: i32 = 0;
|
||||
let mut username = None;
|
||||
|
||||
while username.is_none() {
|
||||
let test_username = format!(
|
||||
"{}{}",
|
||||
self.username,
|
||||
if username_increment > 0 {
|
||||
username_increment.to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
);
|
||||
|
||||
let new_id = crate::database::models::User::get(&test_username, client, redis).await?;
|
||||
|
||||
if new_id.is_none() {
|
||||
username = Some(test_username);
|
||||
} else {
|
||||
username_increment += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let avatar_url = if let Some(avatar_url) = self.avatar_url {
|
||||
let cdn_url = dotenvy::var("CDN_URL")?;
|
||||
|
||||
let res = reqwest::get(&avatar_url).await?;
|
||||
let headers = res.headers().clone();
|
||||
|
||||
let img_data = if let Some(content_type) = headers
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|ct| ct.to_str().ok())
|
||||
{
|
||||
get_image_ext(content_type).map(|ext| (ext, content_type))
|
||||
} else if let Some(ext) = avatar_url.rsplit('.').next() {
|
||||
get_image_content_type(ext).map(|content_type| (ext, content_type))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some((ext, content_type)) = img_data {
|
||||
let bytes = res.bytes().await?;
|
||||
let hash = sha1::Sha1::from(&bytes).hexdigest();
|
||||
|
||||
let upload_data = file_host
|
||||
.upload_file(
|
||||
content_type,
|
||||
&format!(
|
||||
"user/{}/{}.{}",
|
||||
crate::models::users::UserId::from(user_id),
|
||||
hash,
|
||||
ext
|
||||
),
|
||||
bytes,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Some(format!("{}/{}", cdn_url, upload_data.file_name))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(username) = username {
|
||||
crate::database::models::User {
|
||||
id: user_id,
|
||||
github_id: if provider == AuthProvider::GitHub {
|
||||
Some(
|
||||
self.id
|
||||
.clone()
|
||||
.parse()
|
||||
.map_err(|_| AuthenticationError::InvalidCredentials)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
discord_id: if provider == AuthProvider::Discord {
|
||||
Some(
|
||||
self.id
|
||||
.parse()
|
||||
.map_err(|_| AuthenticationError::InvalidCredentials)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
gitlab_id: if provider == AuthProvider::GitLab {
|
||||
Some(
|
||||
self.id
|
||||
.parse()
|
||||
.map_err(|_| AuthenticationError::InvalidCredentials)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
google_id: if provider == AuthProvider::Google {
|
||||
Some(self.id.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
steam_id: if provider == AuthProvider::Steam {
|
||||
Some(
|
||||
self.id
|
||||
.parse()
|
||||
.map_err(|_| AuthenticationError::InvalidCredentials)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
microsoft_id: if provider == AuthProvider::Microsoft {
|
||||
Some(self.id)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
password: None,
|
||||
totp_secret: None,
|
||||
username,
|
||||
name: self.name,
|
||||
email: self.email,
|
||||
email_verified: true,
|
||||
avatar_url,
|
||||
bio: self.bio,
|
||||
created: Utc::now(),
|
||||
role: Role::Developer.to_string(),
|
||||
badges: Badges::default(),
|
||||
balance: Decimal::ZERO,
|
||||
payout_wallet: None,
|
||||
payout_wallet_type: None,
|
||||
payout_address: None,
|
||||
}
|
||||
.insert(transaction)
|
||||
.await?;
|
||||
|
||||
Ok(user_id)
|
||||
} else {
|
||||
Err(AuthenticationError::InvalidCredentials)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthProvider {
|
||||
pub fn get_redirect_url(&self, state: String) -> Result<String, AuthenticationError> {
|
||||
let self_addr = dotenvy::var("SELF_ADDR")?;
|
||||
@@ -771,7 +938,7 @@ pub async fn init(
|
||||
|
||||
let state = Flow::OAuth {
|
||||
user_id,
|
||||
url: info.url,
|
||||
url: Some(info.url),
|
||||
provider: info.provider,
|
||||
}
|
||||
.insert(Duration::minutes(30), &redis)
|
||||
@@ -783,262 +950,286 @@ pub async fn init(
|
||||
.json(serde_json::json!({ "url": url })))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct WsInit {
|
||||
pub provider: AuthProvider,
|
||||
}
|
||||
|
||||
#[get("ws")]
|
||||
pub async fn ws_init(
|
||||
req: HttpRequest,
|
||||
Query(info): Query<WsInit>,
|
||||
body: Payload,
|
||||
db: Data<RwLock<ActiveSockets>>,
|
||||
redis: Data<deadpool_redis::Pool>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let (res, session, _msg_stream) = actix_ws::handle(&req, body)?;
|
||||
|
||||
async fn sock(
|
||||
mut ws_stream: actix_ws::Session,
|
||||
info: WsInit,
|
||||
db: Data<RwLock<ActiveSockets>>,
|
||||
redis: Data<deadpool_redis::Pool>,
|
||||
) -> Result<(), Closed> {
|
||||
let flow = Flow::OAuth {
|
||||
user_id: None,
|
||||
url: None,
|
||||
provider: info.provider,
|
||||
}
|
||||
.insert(Duration::minutes(30), &redis)
|
||||
.await;
|
||||
|
||||
if let Ok(state) = flow {
|
||||
if let Ok(url) = info.provider.get_redirect_url(state.clone()) {
|
||||
ws_stream
|
||||
.text(serde_json::json!({ "url": url }).to_string())
|
||||
.await?;
|
||||
|
||||
let db = db.write().await;
|
||||
db.auth_sockets.insert(state, ws_stream);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let _ = sock(session, info, db, redis).await;
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
#[get("callback")]
|
||||
pub async fn auth_callback(
|
||||
req: HttpRequest,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
sockets: Data<RwLock<ActiveSockets>>,
|
||||
client: Data<PgPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
redis: Data<deadpool_redis::Pool>,
|
||||
) -> Result<HttpResponse, AuthenticationError> {
|
||||
let state = query
|
||||
.get("state")
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
) -> Result<HttpResponse, super::templates::ErrorPage> {
|
||||
let res = async move {
|
||||
let state = query
|
||||
.get("state")
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?.clone();
|
||||
|
||||
let flow = Flow::get(state, &redis).await?;
|
||||
let flow = Flow::get(&state, &redis).await?;
|
||||
|
||||
// Extract cookie header from request
|
||||
if let Some(Flow::OAuth {
|
||||
user_id,
|
||||
provider,
|
||||
url,
|
||||
}) = flow
|
||||
{
|
||||
Flow::remove(state, &redis).await?;
|
||||
|
||||
let token = provider.get_token(query).await?;
|
||||
let oauth_user = provider.get_user(&token).await?;
|
||||
|
||||
let user_id_opt = provider.get_user_id(&oauth_user.id, &**client).await?;
|
||||
|
||||
let mut transaction = client.begin().await?;
|
||||
if let Some(id) = user_id {
|
||||
if user_id_opt.is_some() {
|
||||
return Err(AuthenticationError::DuplicateUser);
|
||||
}
|
||||
|
||||
provider
|
||||
.update_user_id(id, Some(&oauth_user.id), &mut transaction)
|
||||
.await?;
|
||||
|
||||
let user = crate::database::models::User::get_id(id, &**client, &redis).await?;
|
||||
if let Some(email) = user.and_then(|x| x.email) {
|
||||
send_email(
|
||||
email,
|
||||
"Authentication method added",
|
||||
&format!("When logging into Modrinth, you can now log in using the {} authentication provider.", provider.as_str()),
|
||||
"If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).",
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
|
||||
crate::database::models::User::clear_caches(&[(id, None)], &redis).await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", &*url))
|
||||
.json(serde_json::json!({ "url": url })))
|
||||
} else {
|
||||
let user_id = if let Some(user_id) = user_id_opt {
|
||||
let user = crate::database::models::User::get_id(user_id, &**client, &redis)
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
if user.totp_secret.is_some() {
|
||||
let flow = Flow::Login2FA { user_id: user.id }
|
||||
.insert(Duration::minutes(30), &redis)
|
||||
.await?;
|
||||
|
||||
let redirect_url = format!(
|
||||
"{}{}error=2fa_required&flow={}",
|
||||
// Extract cookie header from request
|
||||
if let Some(Flow::OAuth {
|
||||
user_id,
|
||||
provider,
|
||||
url,
|
||||
if url.contains('?') { "&" } else { "?" },
|
||||
flow
|
||||
);
|
||||
}) = flow
|
||||
{
|
||||
Flow::remove(&state, &redis).await?;
|
||||
|
||||
return Ok(HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", &*redirect_url))
|
||||
.json(serde_json::json!({ "url": redirect_url })));
|
||||
let token = provider.get_token(query).await?;
|
||||
let oauth_user = provider.get_user(&token).await?;
|
||||
|
||||
let user_id_opt = provider.get_user_id(&oauth_user.id, &**client).await?;
|
||||
|
||||
let mut transaction = client.begin().await?;
|
||||
if let Some(id) = user_id {
|
||||
if user_id_opt.is_some() {
|
||||
return Err(AuthenticationError::DuplicateUser);
|
||||
}
|
||||
|
||||
user_id
|
||||
} else {
|
||||
if let Some(email) = &oauth_user.email {
|
||||
if crate::database::models::User::get_email(email, &**client)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(AuthenticationError::DuplicateUser);
|
||||
}
|
||||
}
|
||||
|
||||
let user_id = crate::database::models::generate_user_id(&mut transaction).await?;
|
||||
|
||||
let mut username_increment: i32 = 0;
|
||||
let mut username = None;
|
||||
|
||||
while username.is_none() {
|
||||
let test_username = format!(
|
||||
"{}{}",
|
||||
oauth_user.username,
|
||||
if username_increment > 0 {
|
||||
username_increment.to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
);
|
||||
|
||||
let new_id =
|
||||
crate::database::models::User::get(&test_username, &**client, &redis)
|
||||
.await?;
|
||||
|
||||
if new_id.is_none() {
|
||||
username = Some(test_username);
|
||||
} else {
|
||||
username_increment += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let avatar_url = if let Some(avatar_url) = oauth_user.avatar_url {
|
||||
let cdn_url = dotenvy::var("CDN_URL")?;
|
||||
|
||||
let res = reqwest::get(&avatar_url).await?;
|
||||
let headers = res.headers().clone();
|
||||
|
||||
let img_data = if let Some(content_type) = headers
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|ct| ct.to_str().ok())
|
||||
{
|
||||
get_image_ext(content_type).map(|ext| (ext, content_type))
|
||||
} else if let Some(ext) = avatar_url.rsplit('.').next() {
|
||||
get_image_content_type(ext).map(|content_type| (ext, content_type))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some((ext, content_type)) = img_data {
|
||||
let bytes = res.bytes().await?;
|
||||
let hash = sha1::Sha1::from(&bytes).hexdigest();
|
||||
|
||||
let upload_data = file_host
|
||||
.upload_file(
|
||||
content_type,
|
||||
&format!(
|
||||
"user/{}/{}.{}",
|
||||
crate::models::users::UserId::from(user_id),
|
||||
hash,
|
||||
ext
|
||||
),
|
||||
bytes,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Some(format!("{}/{}", cdn_url, upload_data.file_name))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(username) = username {
|
||||
crate::database::models::User {
|
||||
id: user_id,
|
||||
github_id: if provider == AuthProvider::GitHub {
|
||||
Some(
|
||||
oauth_user
|
||||
.id
|
||||
.clone()
|
||||
.parse()
|
||||
.map_err(|_| AuthenticationError::InvalidCredentials)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
discord_id: if provider == AuthProvider::Discord {
|
||||
Some(
|
||||
oauth_user
|
||||
.id
|
||||
.parse()
|
||||
.map_err(|_| AuthenticationError::InvalidCredentials)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
gitlab_id: if provider == AuthProvider::GitLab {
|
||||
Some(
|
||||
oauth_user
|
||||
.id
|
||||
.parse()
|
||||
.map_err(|_| AuthenticationError::InvalidCredentials)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
google_id: if provider == AuthProvider::Google {
|
||||
Some(oauth_user.id.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
steam_id: if provider == AuthProvider::Steam {
|
||||
Some(
|
||||
oauth_user
|
||||
.id
|
||||
.parse()
|
||||
.map_err(|_| AuthenticationError::InvalidCredentials)?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
microsoft_id: if provider == AuthProvider::Microsoft {
|
||||
Some(oauth_user.id)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
password: None,
|
||||
totp_secret: None,
|
||||
username,
|
||||
name: oauth_user.name,
|
||||
email: oauth_user.email,
|
||||
email_verified: true,
|
||||
avatar_url,
|
||||
bio: oauth_user.bio,
|
||||
created: Utc::now(),
|
||||
role: Role::Developer.to_string(),
|
||||
badges: Badges::default(),
|
||||
balance: Decimal::ZERO,
|
||||
payout_wallet: None,
|
||||
payout_wallet_type: None,
|
||||
payout_address: None,
|
||||
}
|
||||
.insert(&mut transaction)
|
||||
provider
|
||||
.update_user_id(id, Some(&oauth_user.id), &mut transaction)
|
||||
.await?;
|
||||
|
||||
let user = crate::database::models::User::get_id(id, &**client, &redis).await?;
|
||||
if let Some(email) = user.and_then(|x| x.email) {
|
||||
send_email(
|
||||
email,
|
||||
"Authentication method added",
|
||||
&format!("When logging into Modrinth, you can now log in using the {} authentication provider.", provider.as_str()),
|
||||
"If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).",
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
|
||||
crate::database::models::User::clear_caches(&[(id, None)], &redis).await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
if let Some(url) = url {
|
||||
Ok(HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", &*url))
|
||||
.json(serde_json::json!({ "url": url })))
|
||||
} else {
|
||||
Err(AuthenticationError::InvalidCredentials)
|
||||
}
|
||||
} else {
|
||||
let user_id = if let Some(user_id) = user_id_opt {
|
||||
let user = crate::database::models::User::get_id(user_id, &**client, &redis)
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
if user.totp_secret.is_some() {
|
||||
let flow = Flow::Login2FA { user_id: user.id }
|
||||
.insert(Duration::minutes(30), &redis)
|
||||
.await?;
|
||||
|
||||
if let Some(url) = url {
|
||||
let redirect_url = format!(
|
||||
"{}{}error=2fa_required&flow={}",
|
||||
url,
|
||||
if url.contains('?') { "&" } else { "?" },
|
||||
flow
|
||||
);
|
||||
|
||||
return Ok(HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", &*redirect_url))
|
||||
.json(serde_json::json!({ "url": redirect_url })));
|
||||
} else {
|
||||
let mut ws_conn = {
|
||||
let db = sockets.read().await;
|
||||
|
||||
let mut x = db
|
||||
.auth_sockets
|
||||
.get_mut(&state)
|
||||
.ok_or_else(|| AuthenticationError::SocketError)?;
|
||||
|
||||
x.value_mut().clone()
|
||||
};
|
||||
|
||||
ws_conn
|
||||
.text(
|
||||
serde_json::json!({
|
||||
"error": "2fa_required",
|
||||
"flow": flow,
|
||||
}).to_string()
|
||||
)
|
||||
.await.map_err(|_| AuthenticationError::SocketError)?;
|
||||
|
||||
let _ = ws_conn.close(None).await;
|
||||
|
||||
return Ok(super::templates::Success {
|
||||
icon: user.avatar_url.as_deref().unwrap_or("https://cdn-raw.modrinth.com/placeholder.svg"),
|
||||
name: &user.username,
|
||||
}.render());
|
||||
}
|
||||
}
|
||||
|
||||
user_id
|
||||
} else {
|
||||
return Err(AuthenticationError::InvalidCredentials);
|
||||
}
|
||||
};
|
||||
oauth_user.create_account(provider, &mut transaction, &client, &file_host, &redis).await?
|
||||
};
|
||||
|
||||
let session = issue_session(req, user_id, &mut transaction, &redis).await?;
|
||||
transaction.commit().await?;
|
||||
let session = issue_session(req, user_id, &mut transaction, &redis).await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
let redirect_url = format!(
|
||||
"{}{}code={}{}",
|
||||
url,
|
||||
if url.contains('?') { '&' } else { '?' },
|
||||
session.session,
|
||||
if user_id_opt.is_none() {
|
||||
"&new_account=true"
|
||||
if let Some(url) = url {
|
||||
let redirect_url = format!(
|
||||
"{}{}code={}{}",
|
||||
url,
|
||||
if url.contains('?') { '&' } else { '?' },
|
||||
session.session,
|
||||
if user_id_opt.is_none() {
|
||||
"&new_account=true"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
|
||||
Ok(HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", &*redirect_url))
|
||||
.json(serde_json::json!({ "url": redirect_url })))
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
let user = crate::database::models::user_item::User::get_id(
|
||||
user_id,
|
||||
&**client,
|
||||
&redis,
|
||||
)
|
||||
.await?.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
Ok(HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", &*redirect_url))
|
||||
.json(serde_json::json!({ "url": redirect_url })))
|
||||
let mut ws_conn = {
|
||||
let db = sockets.read().await;
|
||||
|
||||
let mut x = db
|
||||
.auth_sockets
|
||||
.get_mut(&state)
|
||||
.ok_or_else(|| AuthenticationError::SocketError)?;
|
||||
|
||||
x.value_mut().clone()
|
||||
};
|
||||
|
||||
ws_conn
|
||||
.text(
|
||||
serde_json::json!({
|
||||
"code": session.session,
|
||||
}).to_string()
|
||||
)
|
||||
.await.map_err(|_| AuthenticationError::SocketError)?;
|
||||
let _ = ws_conn.close(None).await;
|
||||
|
||||
return Ok(super::templates::Success {
|
||||
icon: user.avatar_url.as_deref().unwrap_or("https://cdn-raw.modrinth.com/placeholder.svg"),
|
||||
name: &user.username,
|
||||
}.render());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err::<HttpResponse, AuthenticationError>(AuthenticationError::InvalidCredentials)
|
||||
}
|
||||
}.await;
|
||||
|
||||
Ok(res?)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MinecraftLogin {
|
||||
pub flow: String,
|
||||
}
|
||||
|
||||
#[post("login/minecraft")]
|
||||
pub async fn login_from_minecraft(
|
||||
req: HttpRequest,
|
||||
client: Data<PgPool>,
|
||||
file_host: Data<Arc<dyn FileHost + Send + Sync>>,
|
||||
redis: Data<deadpool_redis::Pool>,
|
||||
login: web::Json<MinecraftLogin>,
|
||||
) -> Result<HttpResponse, AuthenticationError> {
|
||||
let flow = Flow::get(&login.flow, &redis).await?;
|
||||
|
||||
// Extract cookie header from request
|
||||
if let Some(Flow::MicrosoftLogin {
|
||||
access_token: token,
|
||||
}) = flow
|
||||
{
|
||||
let provider = AuthProvider::Microsoft;
|
||||
let oauth_user = provider.get_user(&token).await?;
|
||||
let user_id_opt = provider.get_user_id(&oauth_user.id, &**client).await?;
|
||||
|
||||
let mut transaction = client.begin().await?;
|
||||
|
||||
let user_id = if let Some(user_id) = user_id_opt {
|
||||
let user = crate::database::models::User::get_id(user_id, &**client, &redis)
|
||||
.await?
|
||||
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
if user.totp_secret.is_some() {
|
||||
let flow = Flow::Login2FA { user_id: user.id }
|
||||
.insert(Duration::minutes(30), &redis)
|
||||
.await?;
|
||||
|
||||
return Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"error": "2fa_required",
|
||||
"flow": flow
|
||||
})));
|
||||
}
|
||||
|
||||
user_id
|
||||
} else {
|
||||
oauth_user
|
||||
.create_account(provider, &mut transaction, &client, &file_host, &redis)
|
||||
.await?
|
||||
};
|
||||
|
||||
let session = issue_session(req, user_id, &mut transaction, &redis).await?;
|
||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||
"code": session.session
|
||||
})))
|
||||
} else {
|
||||
Err(AuthenticationError::InvalidCredentials)
|
||||
}
|
||||
|
||||
154
src/auth/minecraft/auth.rs
Normal file
154
src/auth/minecraft/auth.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
//! Main authentication flow for Hydra
|
||||
use crate::{auth::minecraft::stages, auth::templates, parse_var};
|
||||
|
||||
// use crate::db::RuntimeState;
|
||||
use crate::database::models::flow_item::Flow;
|
||||
use crate::queue::socket::ActiveSockets;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{get, web, HttpResponse};
|
||||
use chrono::Duration;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
macro_rules! ws_conn_try {
|
||||
($ctx:literal $status:path, $res:expr => $ws_conn:expr) => {
|
||||
match $res {
|
||||
Ok(res) => res,
|
||||
Err(err) => {
|
||||
let error = format!("In {}: {err}", $ctx);
|
||||
let render = super::Error::render_string(&error);
|
||||
let _ = $ws_conn.text(render.clone()).await;
|
||||
let _ = $ws_conn.close(None).await;
|
||||
return Err(templates::ErrorPage {
|
||||
code: $status,
|
||||
message: render,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Query {
|
||||
pub code: String,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[get("callback")]
|
||||
pub async fn route(
|
||||
db: web::Data<RwLock<ActiveSockets>>,
|
||||
info: web::Query<Query>,
|
||||
redis: web::Data<deadpool_redis::Pool>,
|
||||
) -> Result<HttpResponse, templates::ErrorPage> {
|
||||
let public_url = parse_var::<String>("SELF_ADDR").unwrap_or(format!(
|
||||
"http://{}",
|
||||
parse_var::<String>("BIND_ADDR").unwrap()
|
||||
));
|
||||
let client_id = parse_var::<String>("MICROSOFT_CLIENT_ID").unwrap();
|
||||
let client_secret = parse_var::<String>("MICROSOFT_CLIENT_SECRET").unwrap();
|
||||
|
||||
let code = &info.code;
|
||||
|
||||
let mut ws_conn = {
|
||||
let db = db.read().await;
|
||||
|
||||
let mut x = db
|
||||
.auth_sockets
|
||||
.get_mut(&info.state)
|
||||
.ok_or_else(|| templates::ErrorPage {
|
||||
code: StatusCode::BAD_REQUEST,
|
||||
message: "Invalid state sent, you probably need to get a new websocket".to_string(),
|
||||
})?;
|
||||
|
||||
x.value_mut().clone()
|
||||
};
|
||||
|
||||
let access_token = ws_conn_try!(
|
||||
"OAuth token exchange" StatusCode::INTERNAL_SERVER_ERROR,
|
||||
stages::access_token::fetch_token(
|
||||
public_url,
|
||||
code,
|
||||
&client_id,
|
||||
&client_secret,
|
||||
).await
|
||||
=> ws_conn
|
||||
);
|
||||
|
||||
let stages::xbl_signin::XBLLogin {
|
||||
token: xbl_token,
|
||||
uhs,
|
||||
} = ws_conn_try!(
|
||||
"XBox Live token exchange" StatusCode::INTERNAL_SERVER_ERROR,
|
||||
stages::xbl_signin::login_xbl(&access_token.access_token).await
|
||||
=> ws_conn
|
||||
);
|
||||
|
||||
let xsts_response = ws_conn_try!(
|
||||
"XSTS token exchange" StatusCode::INTERNAL_SERVER_ERROR,
|
||||
stages::xsts_token::fetch_token(&xbl_token).await
|
||||
=> ws_conn
|
||||
);
|
||||
|
||||
match xsts_response {
|
||||
stages::xsts_token::XSTSResponse::Unauthorized(err) => {
|
||||
let _ = ws_conn
|
||||
.text(super::Error::render_string(&format!(
|
||||
"Error getting XBox Live token: {err}"
|
||||
)))
|
||||
.await;
|
||||
let _ = ws_conn.close(None).await;
|
||||
|
||||
Err(templates::ErrorPage {
|
||||
code: StatusCode::FORBIDDEN,
|
||||
message: err,
|
||||
})
|
||||
}
|
||||
stages::xsts_token::XSTSResponse::Success { token: xsts_token } => {
|
||||
let bearer_token = &ws_conn_try!(
|
||||
"Bearer token flow" StatusCode::INTERNAL_SERVER_ERROR,
|
||||
stages::bearer_token::fetch_bearer(&xsts_token, &uhs)
|
||||
.await
|
||||
=> ws_conn
|
||||
);
|
||||
|
||||
let player_info = &ws_conn_try!(
|
||||
"No Minecraft account for profile. Make sure you own the game and have set a username through the official Minecraft launcher." StatusCode::BAD_REQUEST,
|
||||
stages::player_info::fetch_info(bearer_token)
|
||||
.await
|
||||
=> ws_conn
|
||||
);
|
||||
|
||||
let flow = &ws_conn_try!(
|
||||
"Error creating microsoft login request flow." StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Flow::MicrosoftLogin {
|
||||
access_token: bearer_token.clone(),
|
||||
}
|
||||
.insert(Duration::hours(1), &redis)
|
||||
.await
|
||||
=> ws_conn
|
||||
);
|
||||
|
||||
ws_conn
|
||||
.text(
|
||||
json!({
|
||||
"token": bearer_token,
|
||||
"refresh_token": &access_token.refresh_token,
|
||||
"expires_after": 86400,
|
||||
"flow": flow,
|
||||
}).to_string()
|
||||
)
|
||||
.await.map_err(|_| templates::ErrorPage {
|
||||
code: StatusCode::BAD_REQUEST,
|
||||
message: "Failed to send login details to launcher. Try restarting the login process!".to_string(),
|
||||
})?;
|
||||
let _ = ws_conn.close(None).await;
|
||||
|
||||
Ok(templates::Success {
|
||||
name: &player_info.name,
|
||||
icon: &format!("https://mc-heads.net/avatar/{}/128", &player_info.id),
|
||||
}
|
||||
.render())
|
||||
}
|
||||
}
|
||||
}
|
||||
35
src/auth/minecraft/login.rs
Normal file
35
src/auth/minecraft/login.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
//! Login route for Hydra, redirects to the Microsoft login page before going to the redirect route
|
||||
use crate::{auth::minecraft::stages::login_redirect, auth::templates, parse_var};
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{get, web, HttpResponse};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Query {
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AuthorizationInit {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[get("init")]
|
||||
pub async fn route(info: web::Query<Query>) -> Result<HttpResponse, templates::ErrorPage> {
|
||||
let conn_id = info.0.id.ok_or_else(|| templates::ErrorPage {
|
||||
code: StatusCode::BAD_REQUEST,
|
||||
message: "No socket ID provided (open a web socket at the / route for one)".to_string(),
|
||||
})?;
|
||||
|
||||
let public_url = parse_var::<String>("SELF_ADDR").unwrap_or(format!(
|
||||
"http://{}",
|
||||
parse_var::<String>("BIND_ADDR").unwrap()
|
||||
));
|
||||
let client_id = parse_var::<String>("MICROSOFT_CLIENT_ID").unwrap();
|
||||
|
||||
let url = login_redirect::get_url(&public_url, &conn_id, &client_id);
|
||||
|
||||
Ok(HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", &*url))
|
||||
.json(AuthorizationInit { url }))
|
||||
}
|
||||
60
src/auth/minecraft/mod.rs
Normal file
60
src/auth/minecraft/mod.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
mod auth;
|
||||
mod login;
|
||||
mod refresh;
|
||||
mod socket;
|
||||
mod stages;
|
||||
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::{scope, ServiceConfig};
|
||||
use actix_web::HttpResponse;
|
||||
use serde_json::json;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// Error message
|
||||
#[derive(Debug)]
|
||||
pub struct Error {
|
||||
pub code: StatusCode,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn render_string(reason: &str) -> String {
|
||||
json!({ "error": reason }).to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
json!({
|
||||
"error": self.reason
|
||||
})
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl actix_web::ResponseError for Error {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
self.code
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
HttpResponse::build(self.code).json(json!({
|
||||
"error": self.reason
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(cfg: &mut ServiceConfig) {
|
||||
cfg.service(
|
||||
scope("minecraft")
|
||||
.service(auth::route)
|
||||
.service(login::route)
|
||||
.service(refresh::route)
|
||||
.service(socket::route),
|
||||
);
|
||||
}
|
||||
72
src/auth/minecraft/refresh.rs
Normal file
72
src/auth/minecraft/refresh.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! Refresh token route
|
||||
use super::stages;
|
||||
use crate::parse_var;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{post, web, HttpResponse};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Body {
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
#[post("refresh")]
|
||||
pub async fn route(body: web::Json<Body>) -> Result<HttpResponse, super::Error> {
|
||||
let public_url = parse_var::<String>("SELF_ADDR").unwrap_or(format!(
|
||||
"http://{}",
|
||||
parse_var::<String>("BIND_ADDR").unwrap()
|
||||
));
|
||||
let client_id = parse_var::<String>("MICROSOFT_CLIENT_ID").unwrap();
|
||||
let client_secret = parse_var::<String>("MICROSOFT_CLIENT_SECRET").unwrap();
|
||||
|
||||
let access_token = stages::access_token::refresh_token(
|
||||
&public_url,
|
||||
&body.refresh_token,
|
||||
&client_id,
|
||||
&client_secret,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| super::Error {
|
||||
code: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
reason: "Error with OAuth token exchange".to_string(),
|
||||
})?;
|
||||
|
||||
let stages::xbl_signin::XBLLogin {
|
||||
token: xbl_token,
|
||||
uhs,
|
||||
} = stages::xbl_signin::login_xbl(&access_token.access_token)
|
||||
.await
|
||||
.map_err(|_| super::Error {
|
||||
code: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
reason: "Error with XBox Live token exchange".to_string(),
|
||||
})?;
|
||||
|
||||
let xsts_response = stages::xsts_token::fetch_token(&xbl_token)
|
||||
.await
|
||||
.map_err(|_| super::Error {
|
||||
code: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
reason: "Error with XSTS token exchange".to_string(),
|
||||
})?;
|
||||
|
||||
match xsts_response {
|
||||
stages::xsts_token::XSTSResponse::Unauthorized(err) => Err(super::Error {
|
||||
code: StatusCode::UNAUTHORIZED,
|
||||
reason: format!("Error getting XBox Live token: {err}"),
|
||||
}),
|
||||
stages::xsts_token::XSTSResponse::Success { token: xsts_token } => {
|
||||
let bearer_token = stages::bearer_token::fetch_bearer(&xsts_token, &uhs)
|
||||
.await
|
||||
.map_err(|_| super::Error {
|
||||
code: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
reason: "Error with Bearer token flow".to_string(),
|
||||
})?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(&json!({
|
||||
"token": bearer_token,
|
||||
"refresh_token": &access_token.refresh_token,
|
||||
"expires_after": 86400
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/auth/minecraft/socket.rs
Normal file
40
src/auth/minecraft/socket.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use crate::database::models::flow_item::Flow;
|
||||
use crate::queue::socket::ActiveSockets;
|
||||
use actix_web::web::Payload;
|
||||
use actix_web::{get, web, HttpRequest, HttpResponse};
|
||||
use actix_ws::{Closed, Session};
|
||||
use chrono::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[get("ws")]
|
||||
pub async fn route(
|
||||
req: HttpRequest,
|
||||
body: Payload,
|
||||
db: web::Data<RwLock<ActiveSockets>>,
|
||||
redis: web::Data<deadpool_redis::Pool>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
let (res, session, _msg_stream) = actix_ws::handle(&req, body)?;
|
||||
let _ = sock(session, db, redis).await;
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
async fn sock(
|
||||
mut ws_stream: Session,
|
||||
db: web::Data<RwLock<ActiveSockets>>,
|
||||
redis: web::Data<deadpool_redis::Pool>,
|
||||
) -> Result<(), Closed> {
|
||||
if let Ok(state) = Flow::MinecraftAuth
|
||||
.insert(Duration::minutes(30), &redis)
|
||||
.await
|
||||
{
|
||||
ws_stream
|
||||
.text(serde_json::json!({ "login_code": state }).to_string())
|
||||
.await?;
|
||||
|
||||
let db = db.write().await;
|
||||
db.auth_sockets.insert(state, ws_stream);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
65
src/auth/minecraft/stages/access_token.rs
Normal file
65
src/auth/minecraft/stages/access_token.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
//! Get access token from code
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const OAUTH_TOKEN_URL: &str = "https://login.live.com/oauth20_token.srf";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Tokens {
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
pub async fn fetch_token(
|
||||
public_uri: String,
|
||||
code: &str,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
) -> Result<Tokens, reqwest::Error> {
|
||||
let redirect_uri = format!("{}/v2/auth/minecraft/callback", public_uri);
|
||||
|
||||
let mut params = HashMap::new();
|
||||
params.insert("client_id", client_id);
|
||||
params.insert("client_secret", client_secret);
|
||||
params.insert("code", code);
|
||||
params.insert("grant_type", "authorization_code");
|
||||
params.insert("redirect_uri", redirect_uri.as_str());
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let result = client
|
||||
.post(OAUTH_TOKEN_URL)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?
|
||||
.json::<Tokens>()
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn refresh_token(
|
||||
public_uri: &str,
|
||||
refresh_token: &str,
|
||||
client_id: &str,
|
||||
client_secret: &str,
|
||||
) -> Result<Tokens, reqwest::Error> {
|
||||
let redirect_uri = format!("{}/v2/auth/minecraft/callback", public_uri);
|
||||
|
||||
let mut params = HashMap::new();
|
||||
params.insert("client_id", client_id);
|
||||
params.insert("client_secret", client_secret);
|
||||
params.insert("refresh_token", refresh_token);
|
||||
params.insert("grant_type", "refresh_token");
|
||||
params.insert("redirect_uri", &redirect_uri);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let result = client
|
||||
.post(OAUTH_TOKEN_URL)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?
|
||||
.json::<Tokens>()
|
||||
.await?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
27
src/auth/minecraft/stages/bearer_token.rs
Normal file
27
src/auth/minecraft/stages/bearer_token.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! Minecraft bearer token
|
||||
use crate::auth::AuthenticationError;
|
||||
use serde_json::json;
|
||||
|
||||
const MCSERVICES_AUTH_URL: &str = "https://api.minecraftservices.com/launcher/login";
|
||||
|
||||
pub async fn fetch_bearer(token: &str, uhs: &str) -> Result<String, AuthenticationError> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = client
|
||||
.post(MCSERVICES_AUTH_URL)
|
||||
.json(&json!({
|
||||
"xtoken": format!("XBL3.0 x={};{}", uhs, token),
|
||||
"platform": "PC_LAUNCHER"
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
serde_json::from_str::<serde_json::Value>(&body)?
|
||||
.get("access_token")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(String::from)
|
||||
.ok_or(AuthenticationError::Custom(
|
||||
"Response didn't contain valid bearer token".to_string(),
|
||||
))
|
||||
}
|
||||
8
src/auth/minecraft/stages/login_redirect.rs
Normal file
8
src/auth/minecraft/stages/login_redirect.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
//! Login redirect step
|
||||
pub fn get_url(public_uri: &str, conn_id: &str, client_id: &str) -> String {
|
||||
format!(
|
||||
"https://login.live.com/oauth20_authorize.srf?client_id={client_id}&response_type=code&redirect_uri={}&scope={}&state={conn_id}&prompt=select_account&cobrandid=8058f65d-ce06-4c30-9559-473c9275a65d",
|
||||
urlencoding::encode(&format!("{}/v2/auth/minecraft/callback", public_uri)),
|
||||
urlencoding::encode("XboxLive.signin offline_access")
|
||||
)
|
||||
}
|
||||
27
src/auth/minecraft/stages/mod.rs
Normal file
27
src/auth/minecraft/stages/mod.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
//! MSA authentication stages
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
pub mod access_token;
|
||||
pub mod bearer_token;
|
||||
pub mod login_redirect;
|
||||
pub mod player_info;
|
||||
pub mod xbl_signin;
|
||||
pub mod xsts_token;
|
||||
|
||||
lazy_static! {
|
||||
static ref REQWEST_CLIENT: reqwest::Client = {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
let header = reqwest::header::HeaderValue::from_str(&format!(
|
||||
"modrinth/labrinth/{} (support@modrinth.com)",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.unwrap();
|
||||
headers.insert(reqwest::header::USER_AGENT, header);
|
||||
reqwest::Client::builder()
|
||||
.tcp_keepalive(Some(std::time::Duration::from_secs(10)))
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.expect("Reqwest Client Building Failed")
|
||||
};
|
||||
}
|
||||
33
src/auth/minecraft/stages/player_info.rs
Normal file
33
src/auth/minecraft/stages/player_info.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
//! Fetch player info for display
|
||||
use serde::Deserialize;
|
||||
|
||||
const PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PlayerInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Default for PlayerInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: "606e2ff0ed7748429d6ce1d3321c7838".to_string(),
|
||||
name: String::from("???"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_info(token: &str) -> Result<PlayerInfo, reqwest::Error> {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(PROFILE_URL)
|
||||
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
55
src/auth/minecraft/stages/xbl_signin.rs
Normal file
55
src/auth/minecraft/stages/xbl_signin.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
//! Signin for XBox Live
|
||||
|
||||
use crate::auth::AuthenticationError;
|
||||
use serde_json::json;
|
||||
|
||||
const XBL_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
|
||||
|
||||
// Deserialization
|
||||
pub struct XBLLogin {
|
||||
pub token: String,
|
||||
pub uhs: String,
|
||||
}
|
||||
|
||||
// Impl
|
||||
pub async fn login_xbl(token: &str) -> Result<XBLLogin, AuthenticationError> {
|
||||
let client = reqwest::Client::new();
|
||||
let body = client
|
||||
.post(XBL_AUTH_URL)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.header("x-xbl-contract-version", "1")
|
||||
.json(&json!({
|
||||
"Properties": {
|
||||
"AuthMethod": "RPS",
|
||||
"SiteName": "user.auth.xboxlive.com",
|
||||
"RpsTicket": format!("d={token}")
|
||||
},
|
||||
"RelyingParty": "http://auth.xboxlive.com",
|
||||
"TokenType": "JWT"
|
||||
}))
|
||||
.send()
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
let json = serde_json::from_str::<serde_json::Value>(&body)?;
|
||||
let token = Some(&json)
|
||||
.and_then(|it| it.get("Token")?.as_str().map(String::from))
|
||||
.ok_or(AuthenticationError::Custom(
|
||||
"XBL response didn't contain valid token".to_string(),
|
||||
))?;
|
||||
let uhs = Some(&json)
|
||||
.and_then(|it| {
|
||||
it.get("DisplayClaims")?
|
||||
.get("xui")?
|
||||
.get(0)?
|
||||
.get("uhs")?
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
})
|
||||
.ok_or(AuthenticationError::Custom(
|
||||
"XBL response didn't contain valid user hash".to_string(),
|
||||
))?;
|
||||
|
||||
Ok(XBLLogin { token, uhs })
|
||||
}
|
||||
57
src/auth/minecraft/stages/xsts_token.rs
Normal file
57
src/auth/minecraft/stages/xsts_token.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use crate::auth::AuthenticationError;
|
||||
use serde_json::json;
|
||||
|
||||
const XSTS_AUTH_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
|
||||
|
||||
pub enum XSTSResponse {
|
||||
Unauthorized(String),
|
||||
Success { token: String },
|
||||
}
|
||||
|
||||
pub async fn fetch_token(token: &str) -> Result<XSTSResponse, AuthenticationError> {
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.post(XSTS_AUTH_URL)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.json(&json!({
|
||||
"Properties": {
|
||||
"SandboxId": "RETAIL",
|
||||
"UserTokens": [
|
||||
token
|
||||
]
|
||||
},
|
||||
"RelyingParty": "rp://api.minecraftservices.com/",
|
||||
"TokenType": "JWT"
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
|
||||
let body = resp.text().await?;
|
||||
let json = serde_json::from_str::<serde_json::Value>(&body)?;
|
||||
|
||||
if status.is_success() {
|
||||
Ok(json
|
||||
.get("Token")
|
||||
.and_then(|x| x.as_str().map(String::from))
|
||||
.map(|it| XSTSResponse::Success { token: it })
|
||||
.unwrap_or(XSTSResponse::Unauthorized(
|
||||
"XSTS response didn't contain valid token!".to_string(),
|
||||
)))
|
||||
} else {
|
||||
Ok(XSTSResponse::Unauthorized(
|
||||
#[allow(clippy::unreadable_literal)]
|
||||
match json.get("XErr").and_then(|x| x.as_i64()) {
|
||||
Some(2148916238) => {
|
||||
String::from("This Microsoft account is underage and is not linked to a family.")
|
||||
},
|
||||
Some(2148916235) => {
|
||||
String::from("XBOX Live/Minecraft is not available in your country.")
|
||||
},
|
||||
Some(2148916233) => String::from("This account does not have a valid XBOX Live profile. Please buy Minecraft and try again!"),
|
||||
Some(2148916236) | Some(2148916237) => String::from("This account needs adult verification on Xbox page."),
|
||||
_ => String::from("Unknown error code"),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
pub mod checks;
|
||||
pub mod email;
|
||||
pub mod flows;
|
||||
pub mod minecraft;
|
||||
pub mod pats;
|
||||
pub mod session;
|
||||
mod templates;
|
||||
pub mod validate;
|
||||
|
||||
pub use checks::{
|
||||
@@ -43,8 +45,12 @@ pub enum AuthenticationError {
|
||||
InvalidClientId,
|
||||
#[error("User email/account is already registered on Modrinth")]
|
||||
DuplicateUser,
|
||||
#[error("Invalid state sent, you probably need to get a new websocket")]
|
||||
SocketError,
|
||||
#[error("Invalid callback URL specified")]
|
||||
Url,
|
||||
#[error("{0}")]
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl actix_web::ResponseError for AuthenticationError {
|
||||
@@ -63,6 +69,8 @@ impl actix_web::ResponseError for AuthenticationError {
|
||||
AuthenticationError::Url => StatusCode::BAD_REQUEST,
|
||||
AuthenticationError::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AuthenticationError::DuplicateUser => StatusCode::BAD_REQUEST,
|
||||
AuthenticationError::Custom(..) => StatusCode::BAD_REQUEST,
|
||||
AuthenticationError::SocketError => StatusCode::BAD_REQUEST,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +90,8 @@ impl actix_web::ResponseError for AuthenticationError {
|
||||
AuthenticationError::Url => "url_error",
|
||||
AuthenticationError::FileHosting(..) => "file_hosting",
|
||||
AuthenticationError::DuplicateUser => "duplicate_user",
|
||||
AuthenticationError::Custom(..) => "custom",
|
||||
AuthenticationError::SocketError => "socket",
|
||||
},
|
||||
description: &self.to_string(),
|
||||
})
|
||||
|
||||
@@ -180,6 +180,12 @@ pub async fn edit_pat(
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
if let Some(scopes) = &info.scopes {
|
||||
if scopes.restricted() {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Invalid scopes requested!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE pats
|
||||
@@ -206,6 +212,12 @@ pub async fn edit_pat(
|
||||
.await?;
|
||||
}
|
||||
if let Some(expires) = &info.expires {
|
||||
if expires < &Utc::now() {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Expire date must be in the future!".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE pats
|
||||
|
||||
24
src/auth/templates/error.html
Normal file
24
src/auth/templates/error.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="/auth/style.css"/>
|
||||
<link rel="icon" type="image/png" href="/favicon.ico"/>
|
||||
<title>Error - Modrinth</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="content">
|
||||
<img src="/logo.svg" alt="Modrinth Logo" class="logo"/>
|
||||
<h2>{{ code }}</h2>
|
||||
<p>An error has occurred during the authentication process.</p>
|
||||
<p>
|
||||
Try restarting the authentication flow within the launcher. If you are still facing issues,
|
||||
join our <a href="https://discord.gg/EUHuJHt">Discord</a> for support!
|
||||
</p>
|
||||
<details>
|
||||
<summary>Debug info</summary>
|
||||
<p>{{ message }}</p>
|
||||
</details>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
66
src/auth/templates/mod.rs
Normal file
66
src/auth/templates/mod.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::auth::AuthenticationError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{HttpResponse, ResponseError};
|
||||
use std::fmt::{Debug, Display, Formatter};
|
||||
|
||||
pub struct Success<'a> {
|
||||
pub icon: &'a str,
|
||||
pub name: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> Success<'a> {
|
||||
pub fn render(self) -> HttpResponse {
|
||||
let html = include_str!("success.html");
|
||||
|
||||
HttpResponse::Ok()
|
||||
.append_header(("Content-Type", "text/html; charset=utf-8"))
|
||||
.body(
|
||||
html.replace("{{ icon }}", self.icon)
|
||||
.replace("{{ name }}", self.name),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ErrorPage {
|
||||
pub code: StatusCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl Display for ErrorPage {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
let html = include_str!("error.html")
|
||||
.replace("{{ code }}", &self.code.to_string())
|
||||
.replace("{{ message }}", &self.message);
|
||||
write!(f, "{}", html)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ErrorPage {
|
||||
pub fn render(&self) -> HttpResponse {
|
||||
HttpResponse::Ok()
|
||||
.append_header(("Content-Type", "text/html; charset=utf-8"))
|
||||
.body(self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl actix_web::ResponseError for ErrorPage {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
self.code
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
self.render()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthenticationError> for ErrorPage {
|
||||
fn from(item: AuthenticationError) -> Self {
|
||||
ErrorPage {
|
||||
code: item.status_code(),
|
||||
message: item.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/auth/templates/success.html
Normal file
16
src/auth/templates/success.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="stylesheet" href="/auth/style.css"/>
|
||||
<link rel="icon" type="image/png" href="/favicon.ico"/>
|
||||
<title>Login - Modrinth</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="content">
|
||||
<img src="{{ icon }}" alt="{{ name }}" class="logo"/>
|
||||
<h2>Login Successful</h2>
|
||||
<p>Hey, {{ name }}! You can now safely close this tab.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user