use crate::database; use crate::database::models::project_item::QueryProject; use crate::database::models::user_item; use crate::database::models::version_item::QueryVersion; use crate::database::{models, Project, Version}; use crate::models::users::{Badges, Role, User, UserId, UserPayoutData}; use crate::routes::ApiError; use crate::Utc; use actix_web::http::header::HeaderMap; use actix_web::http::header::COOKIE; use actix_web::web; use reqwest::header::{HeaderValue, AUTHORIZATION}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use serde_with::serde_as; use serde_with::DisplayFromStr; use sqlx::PgPool; use thiserror::Error; use super::pat::get_user_from_pat; #[derive(Error, Debug)] pub enum AuthenticationError { #[error("An unknown database error occurred")] Sqlx(#[from] sqlx::Error), #[error("Database Error: {0}")] Database(#[from] models::DatabaseError), #[error("Error while parsing JSON: {0}")] SerDe(#[from] serde_json::Error), #[error("Error while communicating over the internet: {0}")] Reqwest(#[from] reqwest::Error), #[error("Error while decoding PAT: {0}")] Decoding(#[from] crate::models::ids::DecodingError), #[error("Invalid Authentication Credentials")] InvalidCredentials, #[error("Authentication method was not valid")] InvalidAuthMethod, } // A user as stored in the Minos database #[derive(Serialize, Deserialize, Debug)] pub struct MinosUser { pub id: String, // This is the unique generated Ory name pub username: String, // unique username pub email: String, pub name: Option, // real name pub github_id: Option, pub discord_id: Option, pub google_id: Option, pub gitlab_id: Option, pub microsoft_id: Option, pub apple_id: Option, } // A payload marking a new user in Minos, with data to be inserted into Labrinth #[serde_as] #[derive(Deserialize, Debug)] pub struct MinosNewUser { pub id: String, // This is the unique generated Ory name pub username: String, // unique username pub email: String, pub name: Option, // real name pub default_picture: Option, // uri of default avatar #[serde_as(as = "Option")] pub github_id: Option, // we allow Github to be submitted to connect to an existing account } // Attempt to append a Minos user to an existing user, if one exists // (combining the the legacy user with the Minos user) pub async fn link_or_insert_new_user( transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, minos_new_user: MinosNewUser, ) -> Result<(), AuthenticationError> { // If the user with this Github ID already exists, we can just merge the two accounts if let Some(github_id) = minos_new_user.github_id { if let Some(existing_user) = user_item::User::get_from_github_id(github_id as u64, &mut *transaction).await? { existing_user .merge_minos_user(&minos_new_user.id, &mut *transaction) .await?; return Ok(()); } } // No user exists, so we need to create a new user insert_new_user(transaction, minos_new_user).await?; Ok(()) } // Insert a new user into the database from a MinosUser pub async fn insert_new_user( transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, minos_new_user: MinosNewUser, ) -> Result<(), AuthenticationError> { let user_id = crate::database::models::generate_user_id(transaction).await?; database::models::User { id: user_id, kratos_id: Some(minos_new_user.id), username: minos_new_user.username, name: minos_new_user.name, email: Some(minos_new_user.email), avatar_url: minos_new_user.default_picture, bio: None, github_id: minos_new_user.github_id, 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(()) } // Gets MinosUser from Kratos ID // This uses an administrative bearer token to access the Minos API // Should NOT be directly accessible to users pub async fn get_minos_user(kratos_id: &str) -> Result { let ory_auth_bearer = dotenvy::var("ORY_AUTH_BEARER").unwrap(); let req = reqwest::Client::new() .get(format!( "{}/admin/user/{kratos_id}", dotenvy::var("MINOS_URL").unwrap() )) .header(reqwest::header::USER_AGENT, "Labrinth") .header( reqwest::header::AUTHORIZATION, format!("Bearer {ory_auth_bearer}"), ); let res = req.send().await?.error_for_status()?; let res = res.json().await?; Ok(res) } // pass the cookies to Minos to get the user. pub async fn get_minos_user_from_cookies(cookies: &str) -> Result { let req = reqwest::Client::new() .get(dotenvy::var("MINOS_URL").unwrap() + "/user") .header(reqwest::header::USER_AGENT, "Modrinth") .header(reqwest::header::COOKIE, cookies); let res = req.send().await?; let res = match res.status() { reqwest::StatusCode::OK => res, reqwest::StatusCode::UNAUTHORIZED => return Err(AuthenticationError::InvalidCredentials), _ => res.error_for_status()?, }; Ok(res.json().await?) } pub async fn get_user_from_headers<'a, E>( headers: &HeaderMap, executor: E, ) -> Result where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, { let token: Option<&reqwest::header::HeaderValue> = headers.get(AUTHORIZATION); let cookies_unparsed: Option<&reqwest::header::HeaderValue> = headers.get(COOKIE); // Fetch DB user record and minos user from headers let (db_user, minos_user) = match (token, cookies_unparsed) { // If both, favour the bearer token first- redirect to cookie on failure (Some(token), Some(cookies)) => { match get_db_and_minos_user_from_bearer_token(token, executor).await { Ok((db, minos)) => (db, minos), Err(_) => get_db_and_minos_user_from_cookies(cookies, executor).await?, } } (Some(token), _) => get_db_and_minos_user_from_bearer_token(token, executor).await?, (_, Some(cookies)) => get_db_and_minos_user_from_cookies(cookies, executor).await?, _ => return Err(AuthenticationError::InvalidAuthMethod), // No credentials passed }; let user = User { id: UserId::from(db_user.id), kratos_id: db_user.kratos_id, github_id: minos_user.github_id, discord_id: minos_user.discord_id, google_id: minos_user.google_id, microsoft_id: minos_user.microsoft_id, apple_id: minos_user.apple_id, gitlab_id: minos_user.gitlab_id, username: db_user.username, name: db_user.name, email: db_user.email, avatar_url: db_user.avatar_url, bio: db_user.bio, created: db_user.created, role: Role::from_string(&db_user.role), badges: db_user.badges, payout_data: Some(UserPayoutData { balance: db_user.balance, payout_wallet: db_user.payout_wallet, payout_wallet_type: db_user.payout_wallet_type, payout_address: db_user.payout_address, }), }; Ok(user) } pub async fn get_user_from_headers_transaction( headers: &HeaderMap, transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result { let token: Option<&reqwest::header::HeaderValue> = headers.get(AUTHORIZATION); let cookies_unparsed: Option<&reqwest::header::HeaderValue> = headers.get(COOKIE); // Fetch DB user record and minos user from headers let (db_user, minos_user) = match (token, cookies_unparsed) { // If both, favour the bearer token first- redirect to cookie on failure (Some(token), Some(cookies)) => { match get_db_and_minos_user_from_bearer_token(token, &mut *transaction).await { Ok((db, minos)) => (db, minos), Err(_) => get_db_and_minos_user_from_cookies(cookies, &mut *transaction).await?, } } (Some(token), _) => { get_db_and_minos_user_from_bearer_token(token, &mut *transaction).await? } (_, Some(cookies)) => { get_db_and_minos_user_from_cookies(cookies, &mut *transaction).await? } _ => return Err(AuthenticationError::InvalidAuthMethod), // No credentials passed }; let user = User { id: UserId::from(db_user.id), kratos_id: db_user.kratos_id, github_id: minos_user.github_id, discord_id: minos_user.discord_id, google_id: minos_user.google_id, microsoft_id: minos_user.microsoft_id, apple_id: minos_user.apple_id, gitlab_id: minos_user.gitlab_id, username: db_user.username, name: db_user.name, email: db_user.email, avatar_url: db_user.avatar_url, bio: db_user.bio, created: db_user.created, role: Role::from_string(&db_user.role), badges: db_user.badges, payout_data: Some(UserPayoutData { balance: db_user.balance, payout_wallet: db_user.payout_wallet, payout_wallet_type: db_user.payout_wallet_type, payout_address: db_user.payout_address, }), }; Ok(user) } pub async fn get_db_and_minos_user_from_bearer_token<'a, E>( token: &HeaderValue, executor: E, ) -> Result<(user_item::User, MinosUser), AuthenticationError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { let db_user = get_user_record_from_bearer_token( token .to_str() .map_err(|_| AuthenticationError::InvalidCredentials)?, executor, ) .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; let minos_user = get_minos_user( &db_user .kratos_id .clone() .ok_or_else(|| AuthenticationError::InvalidCredentials)?, ) .await?; Ok((db_user, minos_user)) } pub async fn get_db_and_minos_user_from_cookies<'a, E>( cookies: &HeaderValue, executor: E, ) -> Result<(user_item::User, MinosUser), AuthenticationError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { let minos_user = get_minos_user_from_cookies( cookies .to_str() .map_err(|_| AuthenticationError::InvalidCredentials)?, ) .await?; let db_user = models::User::get_from_minos_kratos_id(minos_user.id.clone(), executor) .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; Ok((db_user, minos_user)) } pub async fn get_user_record_from_bearer_token<'a, 'b, E>( token: &str, executor: E, ) -> Result, AuthenticationError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { if token.starts_with("Bearer ") { let token: &str = token.trim_start_matches("Bearer "); // Tokens beginning with Ory are considered to be Kratos tokens (in reality, extracted cookies) and can be forwarded to Minos let possible_user = match token.split_once('_') { Some(("modrinth", _)) => get_user_from_pat(token, executor).await?, Some(("ory", _)) => get_user_from_minos_session_token(token, executor).await?, Some(("github", _)) | Some(("gho", _)) | Some(("ghp", _)) => { get_user_from_github_token(token, executor).await? } _ => return Err(AuthenticationError::InvalidAuthMethod), }; Ok(possible_user) } else { Err(AuthenticationError::InvalidAuthMethod) } } pub async fn get_user_from_minos_session_token<'a, 'b, E>( token: &str, executor: E, ) -> Result, AuthenticationError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { let ory_auth_bearer = dotenvy::var("ORY_AUTH_BEARER").unwrap(); let req = reqwest::Client::new() .get(dotenvy::var("MINOS_URL").unwrap() + "/admin/user/token?token=" + token) .header(reqwest::header::USER_AGENT, "Labrinth") .header( reqwest::header::AUTHORIZATION, format!("Bearer {ory_auth_bearer}"), ); let res = req.send().await?.error_for_status()?; let minos_user: MinosUser = res.json().await?; let db_user = models::User::get_from_minos_kratos_id(minos_user.id.clone(), executor).await?; Ok(db_user) } #[derive(Serialize, Deserialize, Debug)] pub struct GitHubUser { pub id: u64, } // Get a database user from a GitHub PAT pub async fn get_user_from_github_token<'a, E>( access_token: &str, executor: E, ) -> Result, AuthenticationError> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { let github_user: GitHubUser = reqwest::Client::new() .get("https://api.github.com/user") .header(reqwest::header::USER_AGENT, "Modrinth") .header( reqwest::header::AUTHORIZATION, format!("token {access_token}"), ) .send() .await? .json() .await?; Ok(user_item::User::get_from_github_id(github_user.id, executor).await?) } pub async fn check_is_moderator_from_headers<'a, 'b, E>( headers: &HeaderMap, executor: E, ) -> Result where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, { let user = get_user_from_headers(headers, executor).await?; if user.role.is_mod() { Ok(user) } else { Err(AuthenticationError::InvalidCredentials) } } pub async fn is_authorized( project_data: &Project, user_option: &Option, pool: &web::Data, ) -> Result { let mut authorized = !project_data.status.is_hidden(); if let Some(user) = &user_option { if !authorized { if user.role.is_mod() { authorized = true; } else { let user_id: models::ids::UserId = user.id.into(); let project_exists = sqlx::query!( "SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)", project_data.team_id as database::models::ids::TeamId, user_id as database::models::ids::UserId, ) .fetch_one(&***pool) .await? .exists; authorized = project_exists.unwrap_or(false); } } } Ok(authorized) } pub async fn filter_authorized_projects( projects: Vec, user_option: &Option, pool: &web::Data, ) -> Result, ApiError> { let mut return_projects = Vec::new(); let mut check_projects = Vec::new(); for project in projects { if !project.inner.status.is_hidden() || user_option .as_ref() .map(|x| x.role.is_mod()) .unwrap_or(false) { return_projects.push(project.into()); } else if user_option.is_some() { check_projects.push(project); } } if !check_projects.is_empty() { if let Some(user) = user_option { let user_id: models::ids::UserId = user.id.into(); use futures::TryStreamExt; sqlx::query!( " SELECT m.id id, m.team_id team_id FROM team_members tm INNER JOIN mods m ON m.team_id = tm.team_id WHERE tm.team_id = ANY($1) AND tm.user_id = $2 ", &check_projects .iter() .map(|x| x.inner.team_id.0) .collect::>(), user_id as database::models::ids::UserId, ) .fetch_many(&***pool) .try_for_each(|e| { if let Some(row) = e.right() { check_projects.retain(|x| { let bool = x.inner.id.0 == row.id && x.inner.team_id.0 == row.team_id; if bool { return_projects.push(x.clone().into()); } !bool }); } futures::future::ready(Ok(())) }) .await?; } } Ok(return_projects) } pub async fn is_authorized_version( version_data: &Version, user_option: &Option, pool: &web::Data, ) -> Result { let mut authorized = !version_data.status.is_hidden(); if let Some(user) = &user_option { if !authorized { if user.role.is_mod() { authorized = true; } else { let user_id: models::ids::UserId = user.id.into(); let version_exists = sqlx::query!( "SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 WHERE m.id = $1)", version_data.project_id as database::models::ids::ProjectId, user_id as database::models::ids::UserId, ) .fetch_one(&***pool) .await? .exists; authorized = version_exists.unwrap_or(false); } } } Ok(authorized) } pub async fn filter_authorized_versions( versions: Vec, user_option: &Option, pool: &web::Data, ) -> Result, ApiError> { let mut return_versions = Vec::new(); let mut check_versions = Vec::new(); for version in versions { if !version.inner.status.is_hidden() || user_option .as_ref() .map(|x| x.role.is_mod()) .unwrap_or(false) { return_versions.push(version.into()); } else if user_option.is_some() { check_versions.push(version); } } if !check_versions.is_empty() { if let Some(user) = user_option { let user_id: models::ids::UserId = user.id.into(); use futures::TryStreamExt; sqlx::query!( " SELECT m.id FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 WHERE m.id = ANY($1) ", &check_versions .iter() .map(|x| x.inner.project_id.0) .collect::>(), user_id as database::models::ids::UserId, ) .fetch_many(&***pool) .try_for_each(|e| { if let Some(row) = e.right() { check_versions.retain(|x| { let bool = x.inner.project_id.0 == row.id; if bool { return_versions.push(x.clone().into()); } !bool }); } futures::future::ready(Ok(())) }) .await?; } } Ok(return_versions) }