Scoped PATs (#651)

* Scoped PATs

* fix threads issues

* fix migration
This commit is contained in:
Geometrically
2023-07-10 16:44:40 -07:00
committed by GitHub
parent 366ea63209
commit 7fbb8838e7
42 changed files with 2560 additions and 1402 deletions

View File

@@ -1,6 +1,6 @@
pub mod checks;
pub mod flows;
pub mod pat;
pub mod pats;
pub mod session;
pub mod validate;

View File

@@ -1,115 +0,0 @@
// use crate::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.email,
// 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,
// github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id
// 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),
// name: row.name,
// github_id: row.github_id,
// discord_id: row.discord_id,
// gitlab_id: row.gitlab_id,
// google_id: row.google_id,
// steam_id: row.steam_id,
// microsoft_id: row.microsoft_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 'modrinth_pat_'
// 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("modrinth_pat_");
// for _ in 0..51 {
// let c = rng.gen_range(0..62);
// 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);
// }
// }
// }

269
src/auth/pats.rs Normal file
View File

@@ -0,0 +1,269 @@
use crate::database;
use crate::database::models::generate_pat_id;
use crate::auth::get_user_from_headers;
use crate::routes::ApiError;
use actix_web::web::{self, Data};
use actix_web::{delete, get, patch, post, HttpRequest, HttpResponse};
use chrono::{DateTime, Utc};
use rand::distributions::Alphanumeric;
use rand::Rng;
use rand_chacha::rand_core::SeedableRng;
use rand_chacha::ChaCha20Rng;
use crate::models::pats::{PersonalAccessToken, Scopes};
use crate::queue::session::AuthQueue;
use crate::util::validate::validation_errors_to_string;
use serde::Deserialize;
use sqlx::postgres::PgPool;
use validator::Validate;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get_pats);
cfg.service(create_pat);
cfg.service(edit_pat);
cfg.service(delete_pat);
}
#[get("pat")]
pub async fn get_pats(
req: HttpRequest,
pool: Data<PgPool>,
redis: Data<deadpool_redis::Pool>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAT_READ]),
)
.await?
.1;
let pat_ids = database::models::pat_item::PersonalAccessToken::get_user_pats(
user.id.into(),
&**pool,
&redis,
)
.await?;
let pats =
database::models::pat_item::PersonalAccessToken::get_many_ids(&pat_ids, &**pool, &redis)
.await?;
Ok(HttpResponse::Ok().json(
pats.into_iter()
.map(|x| PersonalAccessToken::from(x, false))
.collect::<Vec<_>>(),
))
}
#[derive(Deserialize, Validate)]
pub struct NewPersonalAccessToken {
pub scopes: Scopes,
#[validate(length(min = 3, max = 255))]
pub name: String,
pub expires: DateTime<Utc>,
}
#[post("pat")]
pub async fn create_pat(
req: HttpRequest,
info: web::Json<NewPersonalAccessToken>,
pool: Data<PgPool>,
redis: Data<deadpool_redis::Pool>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
info.0
.validate()
.map_err(|err| ApiError::InvalidInput(validation_errors_to_string(err, None)))?;
if info.scopes.restricted() {
return Err(ApiError::InvalidInput(
"Invalid scopes requested!".to_string(),
));
}
if info.expires < Utc::now() {
return Err(ApiError::InvalidInput(
"Expire date must be in the future!".to_string(),
));
}
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAT_CREATE]),
)
.await?
.1;
let mut transaction = pool.begin().await?;
let id = generate_pat_id(&mut transaction).await?;
let token = ChaCha20Rng::from_entropy()
.sample_iter(&Alphanumeric)
.take(60)
.map(char::from)
.collect::<String>();
let token = format!("mrp_{}", token);
let name = info.name.clone();
database::models::pat_item::PersonalAccessToken {
id,
name: name.clone(),
access_token: token.clone(),
scopes: info.scopes,
user_id: user.id.into(),
created: Utc::now(),
expires: info.expires,
last_used: None,
}
.insert(&mut transaction)
.await?;
database::models::pat_item::PersonalAccessToken::clear_cache(
vec![(None, None, Some(user.id.into()))],
&redis,
)
.await?;
transaction.commit().await?;
Ok(HttpResponse::Ok().json(PersonalAccessToken {
id: id.into(),
name,
access_token: Some(token),
scopes: info.scopes,
user_id: user.id,
created: Utc::now(),
expires: info.expires,
last_used: None,
}))
}
#[derive(Deserialize, Validate)]
pub struct ModifyPersonalAccessToken {
pub scopes: Option<Scopes>,
#[validate(length(min = 3, max = 255))]
pub name: Option<String>,
pub expires: Option<DateTime<Utc>>,
}
#[patch("pat/{id}")]
pub async fn edit_pat(
req: HttpRequest,
id: web::Path<(String,)>,
info: web::Json<ModifyPersonalAccessToken>,
pool: Data<PgPool>,
redis: Data<deadpool_redis::Pool>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAT_WRITE]),
)
.await?
.1;
let id = id.into_inner().0;
let pat = database::models::pat_item::PersonalAccessToken::get(&id, &**pool, &redis).await?;
if let Some(pat) = pat {
if pat.user_id == user.id.into() {
let mut transaction = pool.begin().await?;
if let Some(scopes) = &info.scopes {
sqlx::query!(
"
UPDATE pats
SET scopes = $1
WHERE id = $2
",
scopes.bits() as i64,
pat.id.0
)
.execute(&mut *transaction)
.await?;
}
if let Some(name) = &info.name {
sqlx::query!(
"
UPDATE pats
SET name = $1
WHERE id = $2
",
name,
pat.id.0
)
.execute(&mut *transaction)
.await?;
}
if let Some(expires) = &info.expires {
sqlx::query!(
"
UPDATE pats
SET expires = $1
WHERE id = $2
",
expires,
pat.id.0
)
.execute(&mut *transaction)
.await?;
}
database::models::pat_item::PersonalAccessToken::clear_cache(
vec![(Some(pat.id), Some(pat.access_token), Some(pat.user_id))],
&redis,
)
.await?;
transaction.commit().await?;
}
}
Ok(HttpResponse::NoContent().finish())
}
#[delete("pat/{id}")]
pub async fn delete_pat(
req: HttpRequest,
id: web::Path<(String,)>,
pool: Data<PgPool>,
redis: Data<deadpool_redis::Pool>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::PAT_DELETE]),
)
.await?
.1;
let id = id.into_inner().0;
let pat = database::models::pat_item::PersonalAccessToken::get(&id, &**pool, &redis).await?;
if let Some(pat) = pat {
if pat.user_id == user.id.into() {
let mut transaction = pool.begin().await?;
database::models::pat_item::PersonalAccessToken::remove(pat.id, &mut transaction)
.await?;
database::models::pat_item::PersonalAccessToken::clear_cache(
vec![(Some(pat.id), Some(pat.access_token), Some(pat.user_id))],
&redis,
)
.await?;
transaction.commit().await?;
}
}
Ok(HttpResponse::NoContent().finish())
}

View File

@@ -2,8 +2,9 @@ use crate::auth::{get_user_from_headers, AuthenticationError};
use crate::database::models::session_item::Session as DBSession;
use crate::database::models::session_item::SessionBuilder;
use crate::database::models::UserId;
use crate::models::pats::Scopes;
use crate::models::sessions::Session;
use crate::queue::session::SessionQueue;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::util::env::parse_var;
use actix_web::http::header::AUTHORIZATION;
@@ -122,9 +123,17 @@ pub async fn list(
req: HttpRequest,
pool: Data<PgPool>,
redis: Data<deadpool_redis::Pool>,
session_queue: Data<SessionQueue>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_READ]),
)
.await?
.1;
let session_ids = DBSession::get_user_sessions(current_user.id.into(), &**pool, &redis).await?;
let sessions = DBSession::get_many_ids(&session_ids, &**pool, &redis)
@@ -143,9 +152,17 @@ pub async fn delete(
req: HttpRequest,
pool: Data<PgPool>,
redis: Data<deadpool_redis::Pool>,
session_queue: Data<SessionQueue>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?;
let current_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_DELETE]),
)
.await?
.1;
let session = DBSession::get(info.into_inner().0, &**pool, &redis).await?;
@@ -174,9 +191,11 @@ pub async fn refresh(
req: HttpRequest,
pool: Data<PgPool>,
redis: Data<deadpool_redis::Pool>,
session_queue: Data<SessionQueue>,
session_queue: Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue).await?;
let current_user = get_user_from_headers(&req, &**pool, &redis, &session_queue, None)
.await?
.1;
let session = req
.headers()
.get(AUTHORIZATION)

View File

@@ -2,8 +2,9 @@ use crate::auth::flows::AuthProvider;
use crate::auth::session::get_session_metadata;
use crate::auth::AuthenticationError;
use crate::database::models::user_item;
use crate::models::pats::Scopes;
use crate::models::users::{Role, User, UserId, UserPayoutData};
use crate::queue::session::SessionQueue;
use crate::queue::session::AuthQueue;
use actix_web::HttpRequest;
use chrono::Utc;
use reqwest::header::{HeaderValue, AUTHORIZATION};
@@ -12,8 +13,9 @@ pub async fn get_user_from_headers<'a, E>(
req: &HttpRequest,
executor: E,
redis: &deadpool_redis::Pool,
session_queue: &SessionQueue,
) -> Result<User, AuthenticationError>
session_queue: &AuthQueue,
required_scopes: Option<&[Scopes]>,
) -> Result<(Scopes, User), AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
@@ -21,7 +23,7 @@ where
let token: Option<&HeaderValue> = headers.get(AUTHORIZATION);
// Fetch DB user record and minos user from headers
let db_user = get_user_record_from_bearer_token(
let (scopes, db_user) = get_user_record_from_bearer_token(
req,
token
.ok_or_else(|| AuthenticationError::InvalidAuthMethod)?
@@ -57,7 +59,16 @@ where
payout_address: db_user.payout_address,
}),
};
Ok(user)
if let Some(required_scopes) = required_scopes {
for scope in required_scopes {
if !scopes.contains(*scope) {
return Err(AuthenticationError::InvalidCredentials);
}
}
}
Ok((scopes, user))
}
pub async fn get_user_record_from_bearer_token<'a, 'b, E>(
@@ -65,13 +76,28 @@ pub async fn get_user_record_from_bearer_token<'a, 'b, E>(
token: &str,
executor: E,
redis: &deadpool_redis::Pool,
session_queue: &SessionQueue,
) -> Result<Option<user_item::User>, AuthenticationError>
session_queue: &AuthQueue,
) -> Result<Option<(Scopes, user_item::User)>, AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let possible_user = match token.split_once('_') {
//Some(("modrinth", _)) => get_user_from_pat(token, executor).await?,
Some(("mrp", _)) => {
let pat =
crate::database::models::pat_item::PersonalAccessToken::get(token, executor, redis)
.await?
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
if pat.expires < Utc::now() {
return Err(AuthenticationError::InvalidCredentials);
}
let user = user_item::User::get_id(pat.user_id, executor, redis).await?;
session_queue.add_pat(pat.id).await;
user.map(|x| (pat.scopes, x))
}
Some(("mra", _)) => {
let session =
crate::database::models::session_item::Session::get(token, executor, redis)
@@ -85,23 +111,31 @@ where
let user = user_item::User::get_id(session.user_id, executor, redis).await?;
let rate_limit_ignore = dotenvy::var("RATE_LIMIT_IGNORE_KEY")?;
if !req.headers().get("x-ratelimit-key").and_then(|x| x.to_str().ok()).map(|x| x == rate_limit_ignore).unwrap_or(false) {
if !req
.headers()
.get("x-ratelimit-key")
.and_then(|x| x.to_str().ok())
.map(|x| x == rate_limit_ignore)
.unwrap_or(false)
{
let metadata = get_session_metadata(req).await?;
session_queue.add(session.id, metadata).await;
session_queue.add_session(session.id, metadata).await;
}
user
user.map(|x| (Scopes::ALL, x))
}
Some(("github", _)) | Some(("gho", _)) | Some(("ghp", _)) => {
let user = AuthProvider::GitHub.get_user(token).await?;
let id = AuthProvider::GitHub.get_user_id(&user.id, executor).await?;
user_item::User::get_id(
let user = user_item::User::get_id(
id.ok_or_else(|| AuthenticationError::InvalidCredentials)?,
executor,
redis,
)
.await?
.await?;
user.map(|x| (Scopes::ALL, x))
}
_ => return Err(AuthenticationError::InvalidAuthMethod),
};
@@ -112,12 +146,14 @@ pub async fn check_is_moderator_from_headers<'a, 'b, E>(
req: &HttpRequest,
executor: E,
redis: &deadpool_redis::Pool,
session_queue: &SessionQueue,
session_queue: &AuthQueue,
) -> Result<User, AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
let user = get_user_from_headers(req, executor, redis, session_queue).await?;
let user = get_user_from_headers(req, executor, redis, session_queue, None)
.await?
.1;
if user.role.is_mod() {
Ok(user)