* Minos push (#589) * moving to other computer * working redirection * incomplete pat setup * no more errors * new migrations * fixed bugs; added user check * pats * resized pats * removed testing callback * lowered kratos_id size * metadata support * google not working * refactoring * restructured github_id * kratos-id optional, legacy accounts connect * default picture * merge mistake * clippy * sqlx-data.json * env vars, clippy * merge error * scopes into an i64, name * requested changes * removed banning * partial completion of github flow * revision --------- Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
This commit is contained in:
parent
2eb51edfb6
commit
fe25cd3bec
6
.env
6
.env
@ -21,6 +21,10 @@ MEILISEARCH_KEY=modrinth
|
||||
BIND_ADDR=127.0.0.1:8000
|
||||
MOCK_FILE_PATH=/tmp/modrinth
|
||||
|
||||
MINOS_URL=http://127.0.0.1:4000
|
||||
KRATOS_URL=http://127.0.0.1:4433
|
||||
ORY_AUTH_BEARER=none
|
||||
|
||||
STORAGE_BACKEND=local
|
||||
|
||||
BACKBLAZE_KEY_ID=none
|
||||
@ -45,7 +49,7 @@ RATE_LIMIT_IGNORE_IPS='["127.0.0.1"]'
|
||||
|
||||
WHITELISTED_MODPACK_DOMAINS='["cdn.modrinth.com", "edge.forgecdn.net", "github.com", "raw.githubusercontent.com"]'
|
||||
|
||||
ALLOWED_CALLBACK_URLS='["localhost", ".modrinth.com"]'
|
||||
ALLOWED_CALLBACK_URLS='["localhost", ".modrinth.com", "127.0.0.1"]'
|
||||
|
||||
ARIADNE_ADMIN_KEY=feedbeef
|
||||
ARIADNE_URL=https://staging-ariadne.modrinth.com/v1/
|
||||
|
||||
16
migrations/20230502141522_minos-support.sql
Normal file
16
migrations/20230502141522_minos-support.sql
Normal file
@ -0,0 +1,16 @@
|
||||
-- No longer have banned users in Labrinth
|
||||
DROP TABLE banned_users;
|
||||
|
||||
-- Initialize kratos_id
|
||||
ALTER TABLE users ADD COLUMN kratos_id varchar(40) UNIQUE;
|
||||
|
||||
-- Add pats table
|
||||
CREATE TABLE pats (
|
||||
id BIGINT PRIMARY KEY,
|
||||
name VARCHAR(255),
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
access_token VARCHAR(64) NOT NULL,
|
||||
scope BIGINT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
);
|
||||
1340
sqlx-data.json
1340
sqlx-data.json
File diff suppressed because it is too large
Load Diff
@ -83,6 +83,14 @@ generate_ids!(
|
||||
"SELECT EXISTS(SELECT 1 FROM states WHERE id=$1)",
|
||||
StateId
|
||||
);
|
||||
generate_ids!(
|
||||
pub generate_pat_id,
|
||||
PatId,
|
||||
8,
|
||||
"SELECT EXISTS(SELECT 1 FROM pats WHERE id=$1)",
|
||||
PatId
|
||||
);
|
||||
|
||||
generate_ids!(
|
||||
pub generate_user_id,
|
||||
UserId,
|
||||
@ -177,6 +185,10 @@ pub struct FileId(pub i64);
|
||||
#[sqlx(transparent)]
|
||||
pub struct StateId(pub i64);
|
||||
|
||||
#[derive(Copy, Clone, Debug, Type)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct PatId(pub i64);
|
||||
|
||||
#[derive(Copy, Clone, Debug, Type, Deserialize)]
|
||||
#[sqlx(transparent)]
|
||||
pub struct NotificationId(pub i64);
|
||||
|
||||
@ -128,7 +128,7 @@ impl TeamMember {
|
||||
let teams = sqlx::query!(
|
||||
"
|
||||
SELECT tm.id id, tm.team_id team_id, tm.role member_role, tm.permissions permissions, tm.accepted accepted, tm.payouts_split payouts_split, tm.ordering,
|
||||
u.id user_id, u.github_id github_id, u.name user_name, u.email email,
|
||||
u.id user_id, u.name user_name, u.email email, u.kratos_id kratos_id, u.github_id github_id,
|
||||
u.avatar_url avatar_url, u.username username, u.bio bio,
|
||||
u.created created, u.role user_role, u.badges badges, u.balance balance,
|
||||
u.payout_wallet payout_wallet, u.payout_wallet_type payout_wallet_type,
|
||||
@ -153,6 +153,7 @@ impl TeamMember {
|
||||
user: User {
|
||||
id: UserId(m.user_id),
|
||||
github_id: m.github_id,
|
||||
kratos_id: m.kratos_id,
|
||||
name: m.user_name,
|
||||
email: m.email,
|
||||
avatar_url: m.avatar_url,
|
||||
|
||||
@ -5,6 +5,7 @@ use rust_decimal::Decimal;
|
||||
|
||||
pub struct User {
|
||||
pub id: UserId,
|
||||
pub kratos_id: Option<String>, // None if legacy user unconnected to Minos/Kratos
|
||||
pub github_id: Option<i64>,
|
||||
pub username: String,
|
||||
pub name: Option<String>,
|
||||
@ -28,7 +29,7 @@ impl User {
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO users (
|
||||
id, github_id, username, name, email,
|
||||
id, kratos_id, username, name, email,
|
||||
avatar_url, bio, created
|
||||
)
|
||||
VALUES (
|
||||
@ -37,7 +38,7 @@ impl User {
|
||||
)
|
||||
",
|
||||
self.id as UserId,
|
||||
self.github_id,
|
||||
self.kratos_id,
|
||||
&self.username,
|
||||
self.name.as_ref(),
|
||||
self.email.as_ref(),
|
||||
@ -50,6 +51,7 @@ impl User {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get<'a, 'b, E>(id: UserId, executor: E) -> Result<Option<Self>, sqlx::error::Error>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
@ -68,7 +70,7 @@ impl User {
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
SELECT u.id, u.name, u.email,
|
||||
SELECT u.id, u.name, u.email, u.kratos_id,
|
||||
u.avatar_url, u.username, u.bio,
|
||||
u.created, u.role, u.badges,
|
||||
u.balance, u.payout_wallet, u.payout_wallet_type,
|
||||
@ -87,6 +89,54 @@ impl User {
|
||||
github_id: Some(github_id as i64),
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
kratos_id: row.kratos_id,
|
||||
avatar_url: row.avatar_url,
|
||||
username: row.username,
|
||||
bio: row.bio,
|
||||
created: row.created,
|
||||
role: row.role,
|
||||
badges: Badges::from_bits(row.badges as u64).unwrap_or_default(),
|
||||
balance: row.balance,
|
||||
payout_wallet: row.payout_wallet.map(|x| RecipientWallet::from_string(&x)),
|
||||
payout_wallet_type: row
|
||||
.payout_wallet_type
|
||||
.map(|x| RecipientType::from_string(&x)),
|
||||
payout_address: row.payout_address,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_from_minos_kratos_id<'a, 'b, E>(
|
||||
kratos_id: String,
|
||||
executor: E,
|
||||
) -> Result<Option<Self>, sqlx::error::Error>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
SELECT u.id, u.name, u.kratos_id, u.email, u.github_id,
|
||||
u.avatar_url, u.username, u.bio,
|
||||
u.created, u.role, u.badges,
|
||||
u.balance, u.payout_wallet, u.payout_wallet_type,
|
||||
u.payout_address
|
||||
FROM users u
|
||||
WHERE u.kratos_id = $1
|
||||
",
|
||||
kratos_id as String,
|
||||
)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
if let Some(row) = result {
|
||||
Ok(Some(User {
|
||||
id: UserId(row.id),
|
||||
kratos_id: row.kratos_id,
|
||||
github_id: row.github_id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
avatar_url: row.avatar_url,
|
||||
username: row.username,
|
||||
bio: row.bio,
|
||||
@ -114,7 +164,7 @@ impl User {
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
"
|
||||
SELECT u.id, u.github_id, u.name, u.email,
|
||||
SELECT u.id, u.kratos_id, u.name, u.email, u.github_id,
|
||||
u.avatar_url, u.username, u.bio,
|
||||
u.created, u.role, u.badges,
|
||||
u.balance, u.payout_wallet, u.payout_wallet_type,
|
||||
@ -130,6 +180,7 @@ impl User {
|
||||
if let Some(row) = result {
|
||||
Ok(Some(User {
|
||||
id: UserId(row.id),
|
||||
kratos_id: row.kratos_id,
|
||||
github_id: row.github_id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
@ -160,7 +211,7 @@ impl User {
|
||||
let user_ids_parsed: Vec<i64> = user_ids.iter().map(|x| x.0).collect();
|
||||
let users = sqlx::query!(
|
||||
"
|
||||
SELECT u.id, u.github_id, u.name, u.email,
|
||||
SELECT u.id, u.kratos_id, u.name, u.email, u.github_id,
|
||||
u.avatar_url, u.username, u.bio,
|
||||
u.created, u.role, u.badges,
|
||||
u.balance, u.payout_wallet, u.payout_wallet_type,
|
||||
@ -174,6 +225,7 @@ impl User {
|
||||
.try_filter_map(|e| async {
|
||||
Ok(e.right().map(|u| User {
|
||||
id: UserId(u.id),
|
||||
kratos_id: u.kratos_id,
|
||||
github_id: u.github_id,
|
||||
name: u.name,
|
||||
email: u.email,
|
||||
@ -514,4 +566,28 @@ impl User {
|
||||
Ok(id.map(|x| UserId(x.id)))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn merge_minos_user<'a, 'b, E>(
|
||||
&self,
|
||||
kratos_id: &str,
|
||||
executor: E,
|
||||
) -> Result<(), sqlx::error::Error>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
// If the user exists, link the Minos user into the existing user rather tham create a new one
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE users
|
||||
SET kratos_id = $1
|
||||
WHERE (id = $2)
|
||||
",
|
||||
kratos_id,
|
||||
self.id.0,
|
||||
)
|
||||
.execute(executor)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -387,6 +387,9 @@ fn check_env_vars() -> bool {
|
||||
|
||||
failed |= check_var::<String>("SITE_URL");
|
||||
failed |= check_var::<String>("CDN_URL");
|
||||
failed |= check_var::<String>("MINOS_URL");
|
||||
failed |= check_var::<String>("KRATOS_URL");
|
||||
failed |= check_var::<String>("ORY_AUTH_BEARER");
|
||||
failed |= check_var::<String>("LABRINTH_ADMIN_KEY");
|
||||
failed |= check_var::<String>("RATE_LIMIT_IGNORE_KEY");
|
||||
failed |= check_var::<String>("DATABASE_URL");
|
||||
|
||||
@ -37,7 +37,7 @@ impl Default for Badges {
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct User {
|
||||
pub id: UserId,
|
||||
pub github_id: Option<u64>,
|
||||
pub kratos_id: Option<String>, // None if legacy user unconnected to Minos/Kratos
|
||||
pub username: String,
|
||||
pub name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
@ -47,6 +47,12 @@ pub struct User {
|
||||
pub role: Role,
|
||||
pub badges: Badges,
|
||||
pub payout_data: Option<UserPayoutData>,
|
||||
pub github_id: Option<u64>,
|
||||
pub discord_id: Option<u64>,
|
||||
pub google_id: Option<u128>,
|
||||
pub microsoft_id: Option<u64>,
|
||||
pub apple_id: Option<u64>,
|
||||
pub gitlab_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
@ -130,7 +136,7 @@ impl From<DBUser> for User {
|
||||
fn from(data: DBUser) -> Self {
|
||||
Self {
|
||||
id: data.id.into(),
|
||||
github_id: data.github_id.map(|i| i as u64),
|
||||
kratos_id: data.kratos_id,
|
||||
username: data.username,
|
||||
name: data.name,
|
||||
email: None,
|
||||
@ -140,6 +146,12 @@ impl From<DBUser> for User {
|
||||
role: Role::from_string(&data.role),
|
||||
badges: data.badges,
|
||||
payout_data: None,
|
||||
github_id: None,
|
||||
discord_id: None,
|
||||
google_id: None,
|
||||
microsoft_id: None,
|
||||
apple_id: None,
|
||||
gitlab_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
use crate::database::models::user_item;
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::models::projects::MonetizationStatus;
|
||||
use crate::models::users::User;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::auth::{link_or_insert_new_user, MinosNewUser};
|
||||
use crate::util::guards::admin_key_guard;
|
||||
use crate::DownloadQueue;
|
||||
use actix_web::{patch, post, web, HttpResponse};
|
||||
use actix_web::{get, patch, post, web, HttpResponse};
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
@ -16,10 +19,38 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("admin")
|
||||
.service(count_download)
|
||||
.service(add_minos_user)
|
||||
.service(get_legacy_account)
|
||||
.service(process_payout),
|
||||
);
|
||||
}
|
||||
|
||||
// Adds a Minos user to the database
|
||||
// This is an internal endpoint, and should not be used by applications, only by the Minos backend
|
||||
#[post("_minos-user-callback", guard = "admin_key_guard")]
|
||||
pub async fn add_minos_user(
|
||||
minos_user: web::Json<MinosNewUser>, // getting directly from Kratos rather than Minos, so unparse
|
||||
client: web::Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let minos_new_user = minos_user.into_inner();
|
||||
let mut transaction = client.begin().await?;
|
||||
link_or_insert_new_user(&mut transaction, minos_new_user).await?;
|
||||
transaction.commit().await?;
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
#[get("_legacy_account/{github_id}", guard = "admin_key_guard")]
|
||||
|
||||
pub async fn get_legacy_account(
|
||||
pool: web::Data<PgPool>,
|
||||
github_id: web::Path<i32>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let github_id = github_id.into_inner();
|
||||
let user = user_item::User::get_from_github_id(github_id as u64, &**pool).await?;
|
||||
let user: Option<User> = user.map(|u| u.into());
|
||||
Ok(HttpResponse::Ok().json(user))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DownloadBody {
|
||||
pub url: String,
|
||||
|
||||
@ -1,28 +1,30 @@
|
||||
/*!
|
||||
This auth module is primarily for use within the main website. Applications interacting with the
|
||||
authenticated API (a very small portion - notifications, private projects, editing/creating projects
|
||||
and versions) should either retrieve the Modrinth GitHub token through the site, or create a personal
|
||||
app token for use with Modrinth.
|
||||
This auth module is how we allow for authentication within the Modrinth sphere.
|
||||
It uses a self-hosted Ory Kratos instance on the backend, powered by our Minos backend.
|
||||
|
||||
JUst as a summary: Don't implement this flow in your application! Instead, use a personal access token
|
||||
or create your own GitHub OAuth2 application.
|
||||
Applications interacting with the authenticated API (a very small portion - notifications, private projects, editing/creating projects
|
||||
and versions) should include the Ory authentication cookie in their requests. This cookie is set by the Ory Kratos instance and Minos provides function to access these.
|
||||
|
||||
This system will be revisited and allow easier interaction with the authenticated API once we roll
|
||||
out our own authentication system.
|
||||
In addition, you can use a logged-in-account to generate a PAT.
|
||||
This token can be passed in as a Bearer token in the Authorization header, as an alternative to a cookie.
|
||||
This is useful for applications that don't have a frontend, or for applications that need to access the authenticated API on behalf of a user.
|
||||
|
||||
Just as a summary: Don't implement this flow in your application!
|
||||
*/
|
||||
|
||||
use crate::database::models::{generate_state_id, User};
|
||||
use crate::database::models::{self, generate_state_id};
|
||||
use crate::models::error::ApiError;
|
||||
use crate::models::ids::base62_impl::{parse_base62, to_base62};
|
||||
use crate::models::ids::DecodingError;
|
||||
use crate::models::users::{Badges, Role};
|
||||
|
||||
use crate::parse_strings_from_var;
|
||||
use crate::util::auth::get_github_user_from_token;
|
||||
use crate::util::auth::{get_minos_user_from_cookies, AuthenticationError};
|
||||
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::web::{scope, Data, Query, ServiceConfig};
|
||||
use actix_web::{get, HttpResponse};
|
||||
use actix_web::{get, HttpRequest, HttpResponse};
|
||||
use chrono::Utc;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPool;
|
||||
use thiserror::Error;
|
||||
@ -41,8 +43,8 @@ pub enum AuthorizationError {
|
||||
Database(#[from] crate::database::models::DatabaseError),
|
||||
#[error("Error while parsing JSON: {0}")]
|
||||
SerDe(#[from] serde_json::Error),
|
||||
#[error("Error while communicating to GitHub OAuth2")]
|
||||
Github(#[from] reqwest::Error),
|
||||
#[error("Error with communicating to Minos")]
|
||||
Minos(#[from] reqwest::Error),
|
||||
#[error("Invalid Authentication credentials")]
|
||||
InvalidCredentials,
|
||||
#[error("Authentication Error: {0}")]
|
||||
@ -51,8 +53,8 @@ pub enum AuthorizationError {
|
||||
Decoding(#[from] DecodingError),
|
||||
#[error("Invalid callback URL specified")]
|
||||
Url,
|
||||
#[error("User is not allowed to access Modrinth services")]
|
||||
Banned,
|
||||
#[error("User exists in Minos but not in Labrinth")]
|
||||
DatabaseMismatch,
|
||||
}
|
||||
impl actix_web::ResponseError for AuthorizationError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
@ -61,12 +63,12 @@ impl actix_web::ResponseError for AuthorizationError {
|
||||
AuthorizationError::SqlxDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AuthorizationError::Database(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AuthorizationError::SerDe(..) => StatusCode::BAD_REQUEST,
|
||||
AuthorizationError::Github(..) => StatusCode::FAILED_DEPENDENCY,
|
||||
AuthorizationError::Minos(..) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AuthorizationError::InvalidCredentials => StatusCode::UNAUTHORIZED,
|
||||
AuthorizationError::Decoding(..) => StatusCode::BAD_REQUEST,
|
||||
AuthorizationError::Authentication(..) => StatusCode::UNAUTHORIZED,
|
||||
AuthorizationError::Url => StatusCode::BAD_REQUEST,
|
||||
AuthorizationError::Banned => StatusCode::FORBIDDEN,
|
||||
AuthorizationError::DatabaseMismatch => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,12 +79,12 @@ impl actix_web::ResponseError for AuthorizationError {
|
||||
AuthorizationError::SqlxDatabase(..) => "database_error",
|
||||
AuthorizationError::Database(..) => "database_error",
|
||||
AuthorizationError::SerDe(..) => "invalid_input",
|
||||
AuthorizationError::Github(..) => "github_error",
|
||||
AuthorizationError::Minos(..) => "network_error",
|
||||
AuthorizationError::InvalidCredentials => "invalid_credentials",
|
||||
AuthorizationError::Decoding(..) => "decoding_error",
|
||||
AuthorizationError::Authentication(..) => "authentication_error",
|
||||
AuthorizationError::Url => "url_error",
|
||||
AuthorizationError::Banned => "user_banned",
|
||||
AuthorizationError::DatabaseMismatch => "database_mismatch",
|
||||
},
|
||||
description: &self.to_string(),
|
||||
})
|
||||
@ -93,31 +95,22 @@ impl actix_web::ResponseError for AuthorizationError {
|
||||
pub struct AuthorizationInit {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Authorization {
|
||||
pub code: String,
|
||||
pub struct StateResponse {
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct AccessToken {
|
||||
pub access_token: String,
|
||||
pub scope: String,
|
||||
pub token_type: String,
|
||||
}
|
||||
|
||||
//http://localhost:8000/api/v1/auth/init?url=https%3A%2F%2Fmodrinth.com%2Fmods
|
||||
// Init link takes us to Minos API and calls back to callback endpoint with a code and state
|
||||
//http://<URL>:8000/api/v1/auth/init?url=https%3A%2F%2Fmodrinth.com%2Fmods
|
||||
#[get("init")]
|
||||
pub async fn init(
|
||||
Query(info): Query<AuthorizationInit>,
|
||||
Query(info): Query<AuthorizationInit>, // callback url
|
||||
client: Data<PgPool>,
|
||||
) -> Result<HttpResponse, AuthorizationError> {
|
||||
let url = url::Url::parse(&info.url).map_err(|_| AuthorizationError::Url)?;
|
||||
|
||||
let allowed_callback_urls = parse_strings_from_var("ALLOWED_CALLBACK_URLS").unwrap_or_default();
|
||||
|
||||
let domain = url.domain().ok_or(AuthorizationError::Url)?;
|
||||
let domain = url.host_str().ok_or(AuthorizationError::Url)?; // TODO: change back to .domain() (host_str is so we can use 127.0.0.1)
|
||||
if !allowed_callback_urls.iter().any(|x| domain.ends_with(x)) && domain != "modrinth.com" {
|
||||
return Err(AuthorizationError::Url);
|
||||
}
|
||||
@ -139,14 +132,13 @@ pub async fn init(
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
let client_id = dotenvy::var("GITHUB_CLIENT_ID")?;
|
||||
let kratos_url = dotenvy::var("KRATOS_URL")?;
|
||||
let labrinth_url = dotenvy::var("BIND_ADDR")?;
|
||||
let url = format!(
|
||||
"https://github.com/login/oauth/authorize?client_id={}&state={}&scope={}",
|
||||
client_id,
|
||||
to_base62(state.0 as u64),
|
||||
"read%3Auser"
|
||||
// Callback URL of initialization is /callback below.
|
||||
"{kratos_url}/self-service/login/browser?return_to=http://{labrinth_url}/v2/auth/callback?state={}",
|
||||
to_base62(state.0 as u64)
|
||||
);
|
||||
|
||||
Ok(HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", &*url))
|
||||
.json(AuthorizationInit { url }))
|
||||
@ -154,11 +146,12 @@ pub async fn init(
|
||||
|
||||
#[get("callback")]
|
||||
pub async fn auth_callback(
|
||||
Query(info): Query<Authorization>,
|
||||
req: HttpRequest,
|
||||
Query(state): Query<StateResponse>,
|
||||
client: Data<PgPool>,
|
||||
) -> Result<HttpResponse, AuthorizationError> {
|
||||
let mut transaction = client.begin().await?;
|
||||
let state_id = parse_base62(&info.state)?;
|
||||
let state_id: u64 = parse_base62(&state.state)?;
|
||||
|
||||
let result_option = sqlx::query!(
|
||||
"
|
||||
@ -170,119 +163,51 @@ pub async fn auth_callback(
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
// Extract cookie header from request
|
||||
let cookie_header = req.headers().get("Cookie");
|
||||
if let Some(result) = result_option {
|
||||
let duration: chrono::Duration = result.expires - Utc::now();
|
||||
|
||||
if duration.num_seconds() < 0 {
|
||||
return Err(AuthorizationError::InvalidCredentials);
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM states
|
||||
WHERE id = $1
|
||||
",
|
||||
state_id as i64
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let client_id = dotenvy::var("GITHUB_CLIENT_ID")?;
|
||||
let client_secret = dotenvy::var("GITHUB_CLIENT_SECRET")?;
|
||||
|
||||
let url = format!(
|
||||
"https://github.com/login/oauth/access_token?client_id={}&client_secret={}&code={}",
|
||||
client_id, client_secret, info.code
|
||||
);
|
||||
|
||||
let token: AccessToken = reqwest::Client::new()
|
||||
.post(&url)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
if let Some(cookie_header) = cookie_header {
|
||||
// Extract cookie header to get authenticated user from Minos
|
||||
let duration: chrono::Duration = result.expires - Utc::now();
|
||||
if duration.num_seconds() < 0 {
|
||||
return Err(AuthorizationError::InvalidCredentials);
|
||||
}
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM states
|
||||
WHERE id = $1
|
||||
",
|
||||
state_id as i64
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
let user = get_github_user_from_token(&token.access_token).await?;
|
||||
|
||||
let user_result = User::get_from_github_id(user.id, &mut *transaction).await?;
|
||||
match user_result {
|
||||
Some(_) => {}
|
||||
None => {
|
||||
let banned_user = sqlx::query!(
|
||||
"SELECT user FROM banned_users WHERE github_id = $1",
|
||||
user.id as i64
|
||||
)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
if banned_user.is_some() {
|
||||
return Err(AuthorizationError::Banned);
|
||||
}
|
||||
|
||||
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!(
|
||||
"{}{}",
|
||||
&user.login,
|
||||
if username_increment > 0 {
|
||||
username_increment.to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
);
|
||||
|
||||
let new_id = crate::database::models::User::get_id_from_username_or_id(
|
||||
&test_username,
|
||||
&**client,
|
||||
)
|
||||
// Attempt to create a minos user from the cookie header- if this fails, the user is invalid
|
||||
let minos_user = get_minos_user_from_cookies(
|
||||
cookie_header
|
||||
.to_str()
|
||||
.map_err(|_| AuthenticationError::InvalidCredentials)?,
|
||||
)
|
||||
.await?;
|
||||
let user_result =
|
||||
models::User::get_from_minos_kratos_id(minos_user.id.clone(), &mut transaction)
|
||||
.await?;
|
||||
|
||||
if new_id.is_none() {
|
||||
username = Some(test_username);
|
||||
} else {
|
||||
username_increment += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(username) = username {
|
||||
User {
|
||||
id: user_id,
|
||||
github_id: Some(user.id as i64),
|
||||
username,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
avatar_url: Some(user.avatar_url),
|
||||
bio: 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)
|
||||
.await?;
|
||||
}
|
||||
// Cookies exist, but user does not exist in database, meaning they are invalid
|
||||
if user_result.is_none() {
|
||||
return Err(AuthorizationError::DatabaseMismatch);
|
||||
}
|
||||
}
|
||||
transaction.commit().await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
let redirect_url = if result.url.contains('?') {
|
||||
format!("{}&code={}", result.url, token.access_token)
|
||||
// Cookie is attached now, so redirect to the original URL
|
||||
// Do not re-append cookie header, as it is not needed,
|
||||
// because all redirects are to various modrinth.com subdomains
|
||||
Ok(HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", &*result.url))
|
||||
.json(AuthorizationInit { url: result.url }))
|
||||
} else {
|
||||
format!("{}?code={}", result.url, token.access_token)
|
||||
};
|
||||
|
||||
Ok(HttpResponse::TemporaryRedirect()
|
||||
.append_header(("Location", &*redirect_url))
|
||||
.json(AuthorizationInit { url: redirect_url }))
|
||||
Err(AuthorizationError::InvalidCredentials)
|
||||
}
|
||||
} else {
|
||||
Err(AuthorizationError::InvalidCredentials)
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ mod auth;
|
||||
mod midas;
|
||||
mod moderation;
|
||||
mod notifications;
|
||||
mod pats;
|
||||
pub(crate) mod project_creation;
|
||||
mod projects;
|
||||
mod reports;
|
||||
@ -25,6 +26,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||
.configure(midas::config)
|
||||
.configure(moderation::config)
|
||||
.configure(notifications::config)
|
||||
.configure(pats::config)
|
||||
.configure(project_creation::config)
|
||||
.configure(projects::config)
|
||||
.configure(reports::config)
|
||||
|
||||
@ -2,17 +2,12 @@ use super::ApiError;
|
||||
use crate::database;
|
||||
use crate::models::projects::ProjectStatus;
|
||||
use crate::util::auth::check_is_moderator_from_headers;
|
||||
use actix_web::{delete, get, web, HttpRequest, HttpResponse};
|
||||
use actix_web::{get, web, HttpRequest, HttpResponse};
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("moderation")
|
||||
.service(get_projects)
|
||||
.service(ban_user)
|
||||
.service(unban_user),
|
||||
);
|
||||
cfg.service(web::scope("moderation").service(get_projects));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@ -58,38 +53,3 @@ pub async fn get_projects(
|
||||
|
||||
Ok(HttpResponse::Ok().json(projects))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BanUser {
|
||||
pub id: i64,
|
||||
}
|
||||
|
||||
#[get("ban")]
|
||||
pub async fn ban_user(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
id: web::Query<BanUser>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
check_is_moderator_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
sqlx::query!("INSERT INTO banned_users (github_id) VALUES ($1);", id.id)
|
||||
.execute(&**pool)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
#[delete("ban")]
|
||||
pub async fn unban_user(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
id: web::Query<BanUser>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
check_is_moderator_from_headers(req.headers(), &**pool).await?;
|
||||
|
||||
sqlx::query!("DELETE FROM banned_users WHERE github_id = $1;", id.id)
|
||||
.execute(&**pool)
|
||||
.await?;
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
}
|
||||
|
||||
233
src/routes/v2/pats.rs
Normal file
233
src/routes/v2/pats.rs
Normal file
@ -0,0 +1,233 @@
|
||||
/*!
|
||||
Current edition of Ory kratos does not support PAT access of data, so this module is how we allow for PAT authentication.
|
||||
|
||||
|
||||
Just as a summary: Don't implement this flow in your application!
|
||||
*/
|
||||
|
||||
use crate::database;
|
||||
use crate::database::models::generate_pat_id;
|
||||
use crate::models::ids::base62_impl::{parse_base62, to_base62};
|
||||
|
||||
use crate::models::users::UserId;
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::auth::get_user_from_headers;
|
||||
use crate::util::pat::{generate_pat, PersonalAccessToken};
|
||||
|
||||
use actix_web::web::{self, Data, Query};
|
||||
use actix_web::{delete, get, patch, post, HttpRequest, HttpResponse};
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
use serde::Deserialize;
|
||||
use sqlx::postgres::PgPool;
|
||||
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(get_pats);
|
||||
cfg.service(create_pat);
|
||||
cfg.service(edit_pat);
|
||||
cfg.service(delete_pat);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreatePersonalAccessToken {
|
||||
pub scope: i64, // todo: should be a vec of enum
|
||||
pub name: Option<String>,
|
||||
pub expire_in_days: i64, // resets expiry to expire_in_days days from now
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ModifyPersonalAccessToken {
|
||||
#[serde(default, with = "::serde_with::rust::double_option")]
|
||||
pub name: Option<Option<String>>,
|
||||
pub expire_in_days: Option<i64>, // resets expiry to expire_in_days days from now
|
||||
}
|
||||
|
||||
// GET /pat
|
||||
// Get all personal access tokens for the given user. Minos/Kratos cookie must be attached for it to work.
|
||||
// Does not return the actual access token, only the ID + metadata.
|
||||
#[get("pat")]
|
||||
pub async fn get_pats(req: HttpRequest, pool: Data<PgPool>) -> Result<HttpResponse, ApiError> {
|
||||
let user: crate::models::users::User = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
let db_user_id: database::models::UserId = database::models::UserId::from(user.id);
|
||||
|
||||
let pats = sqlx::query!(
|
||||
"
|
||||
SELECT id, name, user_id, scope, expires_at
|
||||
FROM pats
|
||||
WHERE user_id = $1
|
||||
",
|
||||
db_user_id.0
|
||||
)
|
||||
.fetch_all(&**pool)
|
||||
.await?;
|
||||
|
||||
let pats = pats
|
||||
.into_iter()
|
||||
.map(|pat| PersonalAccessToken {
|
||||
id: to_base62(pat.id as u64),
|
||||
scope: pat.scope,
|
||||
name: pat.name,
|
||||
expires_at: pat.expires_at,
|
||||
access_token: None,
|
||||
user_id: UserId(pat.user_id as u64),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(HttpResponse::Ok().json(pats))
|
||||
}
|
||||
|
||||
// POST /pat
|
||||
// Create a new personal access token for the given user. Minos/Kratos cookie must be attached for it to work.
|
||||
// All PAT tokens are base62 encoded, and are prefixed with "mod_"
|
||||
#[post("pat")]
|
||||
pub async fn create_pat(
|
||||
req: HttpRequest,
|
||||
Query(info): Query<CreatePersonalAccessToken>, // callback url
|
||||
pool: Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user: crate::models::users::User = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
let db_user_id: database::models::UserId = database::models::UserId::from(user.id);
|
||||
|
||||
let mut transaction: sqlx::Transaction<sqlx::Postgres> = pool.begin().await?;
|
||||
|
||||
let pat = generate_pat_id(&mut transaction).await?;
|
||||
let access_token = generate_pat(&mut transaction).await?;
|
||||
let expiry = Utc::now().naive_utc() + Duration::days(info.expire_in_days);
|
||||
if info.expire_in_days <= 0 {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"'expire_in_days' must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
INSERT INTO pats (id, name, access_token, user_id, scope, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
",
|
||||
pat.0,
|
||||
info.name,
|
||||
access_token,
|
||||
db_user_id.0,
|
||||
info.scope,
|
||||
expiry
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(PersonalAccessToken {
|
||||
id: to_base62(pat.0 as u64),
|
||||
access_token: Some(access_token),
|
||||
name: info.name,
|
||||
scope: info.scope,
|
||||
user_id: user.id,
|
||||
expires_at: expiry,
|
||||
}))
|
||||
}
|
||||
|
||||
// PATCH /pat/(id)
|
||||
// Edit an access token of id "id" for the given user.
|
||||
// 'None' will mean not edited. Minos/Kratos cookie or PAT must be attached for it to work.
|
||||
#[patch("pat/{id}")]
|
||||
pub async fn edit_pat(
|
||||
req: HttpRequest,
|
||||
id: web::Path<String>,
|
||||
Query(info): Query<ModifyPersonalAccessToken>, // callback url
|
||||
pool: Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user: crate::models::users::User = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
let pat_id = database::models::PatId(parse_base62(&id)? as i64);
|
||||
let db_user_id: database::models::UserId = database::models::UserId::from(user.id);
|
||||
|
||||
if let Some(expire_in_days) = info.expire_in_days {
|
||||
if expire_in_days <= 0 {
|
||||
return Err(ApiError::InvalidInput(
|
||||
"'expire_in_days' must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Get the singular PAT and user combination (failing immediately if it doesn't exist)
|
||||
let mut transaction = pool.begin().await?;
|
||||
let row = sqlx::query!(
|
||||
"
|
||||
SELECT id, name, scope, user_id, expires_at FROM pats
|
||||
WHERE id = $1 AND user_id = $2
|
||||
",
|
||||
pat_id.0,
|
||||
db_user_id.0 // included for safety
|
||||
)
|
||||
.fetch_one(&**pool)
|
||||
.await?;
|
||||
|
||||
let pat = PersonalAccessToken {
|
||||
id: to_base62(row.id as u64),
|
||||
access_token: None,
|
||||
user_id: UserId::from(db_user_id),
|
||||
name: info.name.unwrap_or(row.name),
|
||||
scope: row.scope,
|
||||
expires_at: info
|
||||
.expire_in_days
|
||||
.map(|d| Utc::now().naive_utc() + Duration::days(d))
|
||||
.unwrap_or(row.expires_at),
|
||||
};
|
||||
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE pats SET
|
||||
name = $1,
|
||||
expires_at = $2
|
||||
WHERE id = $3
|
||||
",
|
||||
pat.name,
|
||||
pat.expires_at,
|
||||
parse_base62(&pat.id)? as i64
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(pat))
|
||||
}
|
||||
|
||||
// DELETE /pat
|
||||
// Delete a personal access token for the given user. Minos/Kratos cookie must be attached for it to work.
|
||||
#[delete("pat/{id}")]
|
||||
pub async fn delete_pat(
|
||||
req: HttpRequest,
|
||||
id: web::Path<String>,
|
||||
pool: Data<PgPool>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user: crate::models::users::User = get_user_from_headers(req.headers(), &**pool).await?;
|
||||
let pat_id = database::models::PatId(parse_base62(&id)? as i64);
|
||||
let db_user_id: database::models::UserId = database::models::UserId::from(user.id);
|
||||
|
||||
// Get the singular PAT and user combination (failing immediately if it doesn't exist)
|
||||
// This is to prevent users from deleting other users' PATs
|
||||
let pat_id = sqlx::query!(
|
||||
"
|
||||
SELECT id FROM pats
|
||||
WHERE id = $1 AND user_id = $2
|
||||
",
|
||||
pat_id.0,
|
||||
db_user_id.0
|
||||
)
|
||||
.fetch_one(&**pool)
|
||||
.await?
|
||||
.id;
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM pats
|
||||
WHERE id = $1
|
||||
",
|
||||
pat_id,
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
@ -10,7 +10,7 @@ use crate::models::projects::{
|
||||
use crate::models::threads::ThreadType;
|
||||
use crate::models::users::UserId;
|
||||
use crate::search::indexing::IndexingError;
|
||||
use crate::util::auth::{get_user_from_headers, AuthenticationError};
|
||||
use crate::util::auth::{get_user_from_headers_transaction, AuthenticationError};
|
||||
use crate::util::routes::read_from_field;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use actix_multipart::{Field, Multipart};
|
||||
@ -341,7 +341,7 @@ async fn project_create_inner(
|
||||
let cdn_url = dotenvy::var("CDN_URL")?;
|
||||
|
||||
// The currently logged in user
|
||||
let current_user = get_user_from_headers(req.headers(), &mut *transaction).await?;
|
||||
let current_user = get_user_from_headers_transaction(req.headers(), &mut *transaction).await?;
|
||||
|
||||
let project_id: ProjectId = models::generate_project_id(transaction).await?.into();
|
||||
|
||||
|
||||
@ -3,7 +3,9 @@ use crate::models::ids::{base62_impl::parse_base62, ProjectId, UserId, VersionId
|
||||
use crate::models::reports::{ItemType, Report};
|
||||
use crate::models::threads::{MessageBody, ThreadType};
|
||||
use crate::routes::ApiError;
|
||||
use crate::util::auth::{check_is_moderator_from_headers, get_user_from_headers};
|
||||
use crate::util::auth::{
|
||||
check_is_moderator_from_headers, get_user_from_headers, get_user_from_headers_transaction,
|
||||
};
|
||||
use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse};
|
||||
use chrono::Utc;
|
||||
use futures::StreamExt;
|
||||
@ -36,7 +38,7 @@ pub async fn report_create(
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let current_user = get_user_from_headers(req.headers(), &mut *transaction).await?;
|
||||
let current_user = get_user_from_headers_transaction(req.headers(), &mut transaction).await?;
|
||||
|
||||
let mut bytes = web::BytesMut::new();
|
||||
while let Some(item) = body.next().await {
|
||||
|
||||
@ -12,7 +12,7 @@ use crate::models::projects::{
|
||||
VersionId, VersionStatus, VersionType,
|
||||
};
|
||||
use crate::models::teams::Permissions;
|
||||
use crate::util::auth::get_user_from_headers;
|
||||
use crate::util::auth::get_user_from_headers_transaction;
|
||||
use crate::util::routes::read_from_field;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use crate::validate::{validate_file, ValidationResult};
|
||||
@ -127,7 +127,7 @@ async fn version_create_inner(
|
||||
let all_game_versions = models::categories::GameVersion::list(&mut *transaction).await?;
|
||||
let all_loaders = models::categories::Loader::list(&mut *transaction).await?;
|
||||
|
||||
let user = get_user_from_headers(req.headers(), &mut *transaction).await?;
|
||||
let user = get_user_from_headers_transaction(req.headers(), &mut *transaction).await?;
|
||||
|
||||
let mut error = None;
|
||||
while let Some(item) = payload.next().await {
|
||||
@ -479,7 +479,7 @@ async fn upload_file_to_version_inner(
|
||||
let mut initial_file_data: Option<InitialFileData> = None;
|
||||
let mut file_builders: Vec<VersionFileBuilder> = Vec::new();
|
||||
|
||||
let user = get_user_from_headers(req.headers(), &mut *transaction).await?;
|
||||
let user = get_user_from_headers_transaction(req.headers(), &mut *transaction).await?;
|
||||
|
||||
let result = models::Version::get_full(version_id, &**client).await?;
|
||||
|
||||
|
||||
369
src/util/auth.rs
369
src/util/auth.rs
@ -1,15 +1,24 @@
|
||||
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::{Role, User, UserId, UserPayoutData};
|
||||
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")]
|
||||
@ -18,85 +27,323 @@ pub enum AuthenticationError {
|
||||
Database(#[from] models::DatabaseError),
|
||||
#[error("Error while parsing JSON: {0}")]
|
||||
SerDe(#[from] serde_json::Error),
|
||||
#[error("Error while communicating to GitHub OAuth2: {0}")]
|
||||
Github(#[from] reqwest::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 GitHubUser {
|
||||
pub login: String,
|
||||
pub id: u64,
|
||||
pub avatar_url: String,
|
||||
pub name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub struct MinosUser {
|
||||
pub id: String, // This is the unique generated Ory name
|
||||
pub username: String, // unique username
|
||||
pub email: String,
|
||||
pub name: Option<String>, // real name
|
||||
pub github_id: Option<u64>,
|
||||
pub discord_id: Option<u64>,
|
||||
pub google_id: Option<u128>,
|
||||
pub gitlab_id: Option<u64>,
|
||||
pub microsoft_id: Option<u64>,
|
||||
pub apple_id: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn get_github_user_from_token(
|
||||
access_token: &str,
|
||||
) -> Result<GitHubUser, AuthenticationError> {
|
||||
Ok(reqwest::Client::new()
|
||||
.get("https://api.github.com/user")
|
||||
.header(reqwest::header::USER_AGENT, "Modrinth")
|
||||
// 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<String>, // real name
|
||||
pub default_picture: Option<String>, // uri of default avatar
|
||||
#[serde_as(as = "Option<DisplayFromStr>")]
|
||||
pub github_id: Option<i64>, // 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<MinosUser, AuthenticationError> {
|
||||
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!("token {access_token}"),
|
||||
)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?)
|
||||
format!("Bearer {ory_auth_bearer}"),
|
||||
);
|
||||
let res = req.send().await?.error_for_status()?;
|
||||
let res = res.json().await?;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn get_user_from_token<'a, 'b, E>(
|
||||
access_token: &str,
|
||||
executor: E,
|
||||
) -> Result<User, AuthenticationError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let github_user = get_github_user_from_token(access_token).await?;
|
||||
// pass the cookies to Minos to get the user.
|
||||
pub async fn get_minos_user_from_cookies(cookies: &str) -> Result<MinosUser, AuthenticationError> {
|
||||
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 = models::User::get_from_github_id(github_user.id, executor).await?;
|
||||
|
||||
match res {
|
||||
Some(result) => Ok(User {
|
||||
id: UserId::from(result.id),
|
||||
github_id: result.github_id.map(|i| i as u64),
|
||||
username: result.username,
|
||||
name: result.name,
|
||||
email: result.email,
|
||||
avatar_url: result.avatar_url,
|
||||
bio: result.bio,
|
||||
created: result.created,
|
||||
role: Role::from_string(&result.role),
|
||||
badges: result.badges,
|
||||
payout_data: Some(UserPayoutData {
|
||||
balance: result.balance,
|
||||
payout_wallet: result.payout_wallet,
|
||||
payout_wallet_type: result.payout_wallet_type,
|
||||
payout_address: result.payout_address,
|
||||
}),
|
||||
}),
|
||||
None => Err(AuthenticationError::InvalidCredentials),
|
||||
}
|
||||
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, 'b, E>(
|
||||
|
||||
pub async fn get_user_from_headers<'a, E>(
|
||||
headers: &HeaderMap,
|
||||
executor: E,
|
||||
) -> Result<User, AuthenticationError>
|
||||
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<User, AuthenticationError> {
|
||||
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 token = headers
|
||||
.get("Authorization")
|
||||
.ok_or(AuthenticationError::InvalidCredentials)?
|
||||
.to_str()
|
||||
.map_err(|_| AuthenticationError::InvalidCredentials)?;
|
||||
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))
|
||||
}
|
||||
|
||||
get_user_from_token(token, executor).await
|
||||
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<Option<user_item::User>, 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_at(4) {
|
||||
("mod_", _) => get_user_from_pat(token, executor).await?,
|
||||
("ory_", _) => get_user_from_minos_session_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<Option<user_item::User>, 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)
|
||||
}
|
||||
|
||||
pub async fn check_is_moderator_from_headers<'a, 'b, E>(
|
||||
@ -104,7 +351,7 @@ pub async fn check_is_moderator_from_headers<'a, 'b, E>(
|
||||
executor: E,
|
||||
) -> Result<User, AuthenticationError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
|
||||
{
|
||||
let user = get_user_from_headers(headers, executor).await?;
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ pub mod env;
|
||||
pub mod ext;
|
||||
pub mod guards;
|
||||
pub mod img;
|
||||
pub mod pat;
|
||||
pub mod routes;
|
||||
pub mod validate;
|
||||
pub mod webhook;
|
||||
|
||||
118
src/util/pat.rs
Normal file
118
src/util/pat.rs
Normal file
@ -0,0 +1,118 @@
|
||||
/*!
|
||||
Current edition of Ory kratos does not support PAT access of data, so this module is how we allow for PAT authentication.
|
||||
|
||||
|
||||
Just as a summary: Don't implement this flow in your application!
|
||||
*/
|
||||
|
||||
use super::auth::AuthenticationError;
|
||||
use crate::database;
|
||||
use crate::database::models::{DatabaseError, UserId};
|
||||
use crate::models::users::{self, Badges, RecipientType, RecipientWallet};
|
||||
use censor::Censor;
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct PersonalAccessToken {
|
||||
pub id: String,
|
||||
pub name: Option<String>,
|
||||
pub access_token: Option<String>,
|
||||
pub scope: i64,
|
||||
pub user_id: users::UserId,
|
||||
pub expires_at: NaiveDateTime,
|
||||
}
|
||||
// Find database user from PAT token
|
||||
// Separate to user_items as it may yet include further behaviour.
|
||||
pub async fn get_user_from_pat<'a, E>(
|
||||
access_token: &str,
|
||||
executor: E,
|
||||
) -> Result<Option<database::models::User>, AuthenticationError>
|
||||
where
|
||||
E: sqlx::Executor<'a, Database = sqlx::Postgres>,
|
||||
{
|
||||
let row = sqlx::query!(
|
||||
"
|
||||
SELECT pats.expires_at,
|
||||
u.id, u.name, u.kratos_id, u.email, u.github_id,
|
||||
u.avatar_url, u.username, u.bio,
|
||||
u.created, u.role, u.badges,
|
||||
u.balance, u.payout_wallet, u.payout_wallet_type,
|
||||
u.payout_address
|
||||
FROM pats LEFT OUTER JOIN users u ON pats.user_id = u.id
|
||||
WHERE access_token = $1
|
||||
",
|
||||
access_token
|
||||
)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
if let Some(row) = row {
|
||||
if row.expires_at < Utc::now().naive_utc() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
return Ok(Some(database::models::User {
|
||||
id: UserId(row.id),
|
||||
kratos_id: row.kratos_id,
|
||||
name: row.name,
|
||||
github_id: row.github_id,
|
||||
email: row.email,
|
||||
avatar_url: row.avatar_url,
|
||||
username: row.username,
|
||||
bio: row.bio,
|
||||
created: row.created,
|
||||
role: row.role,
|
||||
badges: Badges::from_bits(row.badges as u64).unwrap_or_default(),
|
||||
balance: row.balance,
|
||||
payout_wallet: row.payout_wallet.map(|x| RecipientWallet::from_string(&x)),
|
||||
payout_wallet_type: row
|
||||
.payout_wallet_type
|
||||
.map(|x| RecipientType::from_string(&x)),
|
||||
payout_address: row.payout_address,
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// Generate a new 128 char PAT token starting with 'mod_'
|
||||
pub async fn generate_pat(
|
||||
con: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<String, DatabaseError> {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut retry_count = 0;
|
||||
let censor = Censor::Standard + Censor::Sex;
|
||||
|
||||
// First generate the PAT token as a random 128 char string. This may include uppercase and lowercase and numbers only.
|
||||
loop {
|
||||
let mut access_token = String::with_capacity(63);
|
||||
access_token.push_str("mod_");
|
||||
for _ in 0..60 {
|
||||
let c = rng.gen_range(0..60);
|
||||
if c < 10 {
|
||||
access_token.push(char::from_u32(c + 48).unwrap()); // 0-9
|
||||
} else if c < 36 {
|
||||
access_token.push(char::from_u32(c + 55).unwrap()); // A-Z
|
||||
} else {
|
||||
access_token.push(char::from_u32(c + 61).unwrap()); // a-z
|
||||
}
|
||||
}
|
||||
let results = sqlx::query!(
|
||||
"
|
||||
SELECT EXISTS(SELECT 1 FROM pats WHERE access_token=$1)
|
||||
",
|
||||
access_token
|
||||
)
|
||||
.fetch_one(&mut *con)
|
||||
.await?;
|
||||
|
||||
if !results.exists.unwrap_or(true) && !censor.check(&access_token) {
|
||||
break Ok(access_token);
|
||||
}
|
||||
|
||||
retry_count += 1;
|
||||
if retry_count > 15 {
|
||||
return Err(DatabaseError::RandomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user