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
19 changed files with 1781 additions and 738 deletions

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