Minos push (#589) (#590)

* 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:
Wyatt Verchere 2023-05-31 16:03:08 -07:00 committed by GitHub
parent 2eb51edfb6
commit fe25cd3bec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1781 additions and 738 deletions

6
.env
View File

@ -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/

View 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
);

File diff suppressed because it is too large Load Diff

View File

@ -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);

View File

@ -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,

View File

@ -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(())
}
}

View File

@ -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");

View File

@ -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,
}
}
}

View File

@ -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,

View File

@ -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)
}

View File

@ -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)

View File

@ -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
View 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())
}

View File

@ -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();

View File

@ -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 {

View File

@ -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?;

View File

@ -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?;

View File

@ -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
View 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);
}
}
}