forked from didirus/AstralRinth
* 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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user