You've already forked AstralRinth
forked from didirus/AstralRinth
OAuth 2.0 Authorization Server [MOD-559] (#733)
* WIP end-of-day push * Authorize endpoint, accept endpoints, DB stuff for oauth clients, their redirects, and client authorizations * OAuth Client create route * Get user clients * Client delete * Edit oauth client * Include redirects in edit client route * Database stuff for tokens * Reorg oauth stuff out of auth/flows and into its own module * Impl OAuth get access token endpoint * Accept oauth access tokens as auth and update through AuthQueue * User OAuth authorization management routes * Forgot to actually add the routes lol * Bit o cleanup * Happy path test for OAuth and minor fixes for things it found * Add dummy data oauth client (and detect/handle dummy data version changes) * More tests * Another test * More tests and reject endpoint * Test oauth client and authorization management routes * cargo sqlx prepare * dead code warning * Auto clippy fixes * Uri refactoring * minor name improvement * Don't compile-time check the test sqlx queries * Trying to fix db concurrency problem to get tests to pass * Try fix from test PR * Fixes for updated sqlx * Prevent restricted scopes from being requested or issued * Get OAuth client(s) * Remove joined oauth client info from authorization returns * Add default conversion to OAuthError::error so we can use ? * Rework routes * Consolidate scopes into SESSION_ACCESS * Cargo sqlx prepare * Parse to OAuthClientId automatically through serde and actix * Cargo clippy * Remove validation requiring 1 redirect URI on oauth client creation * Use serde(flatten) on OAuthClientCreationResult
This commit is contained in:
@@ -8,6 +8,25 @@ use crate::routes::ApiError;
|
||||
use actix_web::web;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub trait ValidateAuthorized {
|
||||
fn validate_authorized(&self, user_option: Option<&User>) -> Result<(), ApiError>;
|
||||
}
|
||||
|
||||
pub trait ValidateAllAuthorized {
|
||||
fn validate_all_authorized(self, user_option: Option<&User>) -> Result<(), ApiError>;
|
||||
}
|
||||
|
||||
impl<'a, T, A> ValidateAllAuthorized for T
|
||||
where
|
||||
T: IntoIterator<Item = &'a A>,
|
||||
A: ValidateAuthorized + 'a,
|
||||
{
|
||||
fn validate_all_authorized(self, user_option: Option<&User>) -> Result<(), ApiError> {
|
||||
self.into_iter()
|
||||
.try_for_each(|c| c.validate_authorized(user_option))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_authorized(
|
||||
project_data: &Project,
|
||||
user_option: &Option<User>,
|
||||
@@ -156,6 +175,23 @@ pub async fn is_authorized_version(
|
||||
Ok(authorized)
|
||||
}
|
||||
|
||||
impl ValidateAuthorized for crate::database::models::OAuthClient {
|
||||
fn validate_authorized(&self, user_option: Option<&User>) -> Result<(), ApiError> {
|
||||
if let Some(user) = user_option {
|
||||
if user.role.is_mod() || user.id == self.created_by.into() {
|
||||
return Ok(());
|
||||
} else {
|
||||
return Err(crate::routes::ApiError::CustomAuthentication(
|
||||
"You don't have sufficient permissions to interact with this OAuth application"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn filter_authorized_versions(
|
||||
versions: Vec<QueryVersion>,
|
||||
user_option: &Option<User>,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
pub mod checks;
|
||||
pub mod email;
|
||||
pub mod flows;
|
||||
pub mod oauth;
|
||||
pub mod pats;
|
||||
pub mod session;
|
||||
mod templates;
|
||||
pub mod validate;
|
||||
|
||||
pub use checks::{
|
||||
filter_authorized_projects, filter_authorized_versions, is_authorized, is_authorized_version,
|
||||
};
|
||||
|
||||
176
src/auth/oauth/errors.rs
Normal file
176
src/auth/oauth/errors.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use super::ValidatedRedirectUri;
|
||||
use crate::auth::AuthenticationError;
|
||||
use crate::models::error::ApiError;
|
||||
use crate::models::ids::DecodingError;
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::HttpResponse;
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
#[error("{}", .error_type)]
|
||||
pub struct OAuthError {
|
||||
#[source]
|
||||
pub error_type: OAuthErrorType,
|
||||
|
||||
pub state: Option<String>,
|
||||
pub valid_redirect_uri: Option<ValidatedRedirectUri>,
|
||||
}
|
||||
|
||||
impl<T> From<T> for OAuthError
|
||||
where
|
||||
T: Into<OAuthErrorType>,
|
||||
{
|
||||
fn from(value: T) -> Self {
|
||||
OAuthError::error(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl OAuthError {
|
||||
/// The OAuth request failed either because of an invalid redirection URI
|
||||
/// or before we could validate the one we were given, so return an error
|
||||
/// directly to the caller
|
||||
///
|
||||
/// See: IETF RFC 6749 4.1.2.1 (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1)
|
||||
pub fn error(error_type: impl Into<OAuthErrorType>) -> Self {
|
||||
Self {
|
||||
error_type: error_type.into(),
|
||||
valid_redirect_uri: None,
|
||||
state: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The OAuth request failed for a reason other than an invalid redirection URI
|
||||
/// So send the error in url-encoded form to the redirect URI
|
||||
///
|
||||
/// See: IETF RFC 6749 4.1.2.1 (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1)
|
||||
pub fn redirect(
|
||||
err: impl Into<OAuthErrorType>,
|
||||
state: &Option<String>,
|
||||
valid_redirect_uri: &ValidatedRedirectUri,
|
||||
) -> Self {
|
||||
Self {
|
||||
error_type: err.into(),
|
||||
state: state.clone(),
|
||||
valid_redirect_uri: Some(valid_redirect_uri.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl actix_web::ResponseError for OAuthError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self.error_type {
|
||||
OAuthErrorType::AuthenticationError(_)
|
||||
| OAuthErrorType::FailedScopeParse(_)
|
||||
| OAuthErrorType::ScopesTooBroad
|
||||
| OAuthErrorType::AccessDenied => {
|
||||
if self.valid_redirect_uri.is_some() {
|
||||
StatusCode::FOUND
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
OAuthErrorType::RedirectUriNotConfigured(_)
|
||||
| OAuthErrorType::ClientMissingRedirectURI { client_id: _ }
|
||||
| OAuthErrorType::InvalidAcceptFlowId
|
||||
| OAuthErrorType::MalformedId(_)
|
||||
| OAuthErrorType::InvalidClientId(_)
|
||||
| OAuthErrorType::InvalidAuthCode
|
||||
| OAuthErrorType::OnlySupportsAuthorizationCodeGrant(_)
|
||||
| OAuthErrorType::RedirectUriChanged(_)
|
||||
| OAuthErrorType::UnauthorizedClient => StatusCode::BAD_REQUEST,
|
||||
OAuthErrorType::ClientAuthenticationFailed => StatusCode::UNAUTHORIZED,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
if let Some(ValidatedRedirectUri(mut redirect_uri)) = self.valid_redirect_uri.clone() {
|
||||
redirect_uri = format!(
|
||||
"{}?error={}&error_description={}",
|
||||
redirect_uri,
|
||||
self.error_type.error_name(),
|
||||
self.error_type,
|
||||
);
|
||||
|
||||
if let Some(state) = self.state.as_ref() {
|
||||
redirect_uri = format!("{}&state={}", redirect_uri, state);
|
||||
}
|
||||
|
||||
redirect_uri = urlencoding::encode(&redirect_uri).to_string();
|
||||
HttpResponse::Found()
|
||||
.append_header(("Location".to_string(), redirect_uri))
|
||||
.finish()
|
||||
} else {
|
||||
HttpResponse::build(self.status_code()).json(ApiError {
|
||||
error: &self.error_type.error_name(),
|
||||
description: &self.error_type.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum OAuthErrorType {
|
||||
#[error(transparent)]
|
||||
AuthenticationError(#[from] AuthenticationError),
|
||||
#[error("Client {} has no redirect URIs specified", .client_id.0)]
|
||||
ClientMissingRedirectURI {
|
||||
client_id: crate::database::models::OAuthClientId,
|
||||
},
|
||||
#[error("The provided redirect URI did not match any configured in the client")]
|
||||
RedirectUriNotConfigured(String),
|
||||
#[error("The provided scope was malformed or did not correspond to known scopes ({0})")]
|
||||
FailedScopeParse(bitflags::parser::ParseError),
|
||||
#[error(
|
||||
"The provided scope requested scopes broader than the developer app is configured with"
|
||||
)]
|
||||
ScopesTooBroad,
|
||||
#[error("The provided flow id was invalid")]
|
||||
InvalidAcceptFlowId,
|
||||
#[error("The provided client id was invalid")]
|
||||
InvalidClientId(crate::database::models::OAuthClientId),
|
||||
#[error("The provided ID could not be decoded: {0}")]
|
||||
MalformedId(#[from] DecodingError),
|
||||
#[error("Failed to authenticate client")]
|
||||
ClientAuthenticationFailed,
|
||||
#[error("The provided authorization grant code was invalid")]
|
||||
InvalidAuthCode,
|
||||
#[error("The provided client id did not match the id this authorization code was granted to")]
|
||||
UnauthorizedClient,
|
||||
#[error("The provided redirect URI did not exactly match the uri originally provided when this flow began")]
|
||||
RedirectUriChanged(Option<String>),
|
||||
#[error("The provided grant type ({0}) must be \"authorization_code\"")]
|
||||
OnlySupportsAuthorizationCodeGrant(String),
|
||||
#[error("The resource owner denied the request")]
|
||||
AccessDenied,
|
||||
}
|
||||
|
||||
impl From<crate::database::models::DatabaseError> for OAuthErrorType {
|
||||
fn from(value: crate::database::models::DatabaseError) -> Self {
|
||||
OAuthErrorType::AuthenticationError(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sqlx::Error> for OAuthErrorType {
|
||||
fn from(value: sqlx::Error) -> Self {
|
||||
OAuthErrorType::AuthenticationError(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl OAuthErrorType {
|
||||
pub fn error_name(&self) -> String {
|
||||
// IETF RFC 6749 4.1.2.1 (https://datatracker.ietf.org/doc/html/rfc6749#autoid-38)
|
||||
// And 5.2 (https://datatracker.ietf.org/doc/html/rfc6749#section-5.2)
|
||||
match self {
|
||||
Self::RedirectUriNotConfigured(_) | Self::ClientMissingRedirectURI { client_id: _ } => {
|
||||
"invalid_uri"
|
||||
}
|
||||
Self::AuthenticationError(_) | Self::InvalidAcceptFlowId => "server_error",
|
||||
Self::RedirectUriChanged(_) | Self::MalformedId(_) => "invalid_request",
|
||||
Self::FailedScopeParse(_) | Self::ScopesTooBroad => "invalid_scope",
|
||||
Self::InvalidClientId(_) | Self::ClientAuthenticationFailed => "invalid_client",
|
||||
Self::InvalidAuthCode | Self::OnlySupportsAuthorizationCodeGrant(_) => "invalid_grant",
|
||||
Self::UnauthorizedClient => "unauthorized_client",
|
||||
Self::AccessDenied => "access_denied",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
430
src/auth/oauth/mod.rs
Normal file
430
src/auth/oauth/mod.rs
Normal file
@@ -0,0 +1,430 @@
|
||||
use crate::auth::get_user_from_headers;
|
||||
use crate::auth::oauth::uris::{OAuthRedirectUris, ValidatedRedirectUri};
|
||||
use crate::auth::validate::extract_authorization_header;
|
||||
use crate::database::models::flow_item::Flow;
|
||||
use crate::database::models::oauth_client_authorization_item::OAuthClientAuthorization;
|
||||
use crate::database::models::oauth_client_item::OAuthClient as DBOAuthClient;
|
||||
use crate::database::models::oauth_token_item::OAuthAccessToken;
|
||||
use crate::database::models::{
|
||||
generate_oauth_access_token_id, generate_oauth_client_authorization_id,
|
||||
OAuthClientAuthorizationId, OAuthClientId,
|
||||
};
|
||||
use crate::database::redis::RedisPool;
|
||||
use crate::models;
|
||||
use crate::models::pats::Scopes;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use actix_web::web::{scope, Data, Query, ServiceConfig};
|
||||
use actix_web::{get, post, web, HttpRequest, HttpResponse};
|
||||
use chrono::Duration;
|
||||
use rand::distributions::Alphanumeric;
|
||||
use rand::{Rng, SeedableRng};
|
||||
use rand_chacha::ChaCha20Rng;
|
||||
use reqwest::header::{CACHE_CONTROL, LOCATION, PRAGMA};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPool;
|
||||
|
||||
use self::errors::{OAuthError, OAuthErrorType};
|
||||
|
||||
use super::AuthenticationError;
|
||||
|
||||
pub mod errors;
|
||||
pub mod uris;
|
||||
|
||||
pub fn config(cfg: &mut ServiceConfig) {
|
||||
cfg.service(
|
||||
scope("auth/oauth")
|
||||
.service(init_oauth)
|
||||
.service(accept_client_scopes)
|
||||
.service(reject_client_scopes)
|
||||
.service(request_token),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct OAuthInit {
|
||||
pub client_id: OAuthClientId,
|
||||
pub redirect_uri: Option<String>,
|
||||
pub scope: Option<String>,
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct OAuthClientAccessRequest {
|
||||
pub flow_id: String,
|
||||
pub client_id: OAuthClientId,
|
||||
pub client_name: String,
|
||||
pub client_icon: Option<String>,
|
||||
pub requested_scopes: Scopes,
|
||||
}
|
||||
|
||||
#[get("authorize")]
|
||||
pub async fn init_oauth(
|
||||
req: HttpRequest,
|
||||
Query(oauth_info): Query<OAuthInit>,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, OAuthError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::USER_AUTH_WRITE]),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let client_id = oauth_info.client_id;
|
||||
let client = DBOAuthClient::get(client_id, &**pool).await?;
|
||||
|
||||
if let Some(client) = client {
|
||||
let redirect_uri = ValidatedRedirectUri::validate(
|
||||
&oauth_info.redirect_uri,
|
||||
client.redirect_uris.iter().map(|r| r.uri.as_ref()),
|
||||
client.id,
|
||||
)?;
|
||||
|
||||
let requested_scopes = oauth_info
|
||||
.scope
|
||||
.as_ref()
|
||||
.map_or(Ok(client.max_scopes), |s| {
|
||||
Scopes::parse_from_oauth_scopes(s).map_err(|e| {
|
||||
OAuthError::redirect(
|
||||
OAuthErrorType::FailedScopeParse(e),
|
||||
&oauth_info.state,
|
||||
&redirect_uri,
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
if !client.max_scopes.contains(requested_scopes) {
|
||||
return Err(OAuthError::redirect(
|
||||
OAuthErrorType::ScopesTooBroad,
|
||||
&oauth_info.state,
|
||||
&redirect_uri,
|
||||
));
|
||||
}
|
||||
|
||||
let existing_authorization =
|
||||
OAuthClientAuthorization::get(client.id, user.id.into(), &**pool)
|
||||
.await
|
||||
.map_err(|e| OAuthError::redirect(e, &oauth_info.state, &redirect_uri))?;
|
||||
let redirect_uris =
|
||||
OAuthRedirectUris::new(oauth_info.redirect_uri.clone(), redirect_uri.clone());
|
||||
match existing_authorization {
|
||||
Some(existing_authorization)
|
||||
if existing_authorization.scopes.contains(requested_scopes) =>
|
||||
{
|
||||
init_oauth_code_flow(
|
||||
user.id.into(),
|
||||
client.id,
|
||||
existing_authorization.id,
|
||||
requested_scopes,
|
||||
redirect_uris,
|
||||
oauth_info.state,
|
||||
&redis,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => {
|
||||
let flow_id = Flow::InitOAuthAppApproval {
|
||||
user_id: user.id.into(),
|
||||
client_id: client.id,
|
||||
existing_authorization_id: existing_authorization.map(|a| a.id),
|
||||
scopes: requested_scopes,
|
||||
redirect_uris,
|
||||
state: oauth_info.state.clone(),
|
||||
}
|
||||
.insert(Duration::minutes(30), &redis)
|
||||
.await
|
||||
.map_err(|e| OAuthError::redirect(e, &oauth_info.state, &redirect_uri))?;
|
||||
|
||||
let access_request = OAuthClientAccessRequest {
|
||||
client_id: client.id,
|
||||
client_name: client.name,
|
||||
client_icon: client.icon_url,
|
||||
flow_id,
|
||||
requested_scopes,
|
||||
};
|
||||
Ok(HttpResponse::Ok().json(access_request))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(OAuthError::error(OAuthErrorType::InvalidClientId(
|
||||
client_id,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct RespondToOAuthClientScopes {
|
||||
pub flow: String,
|
||||
}
|
||||
|
||||
#[post("accept")]
|
||||
pub async fn accept_client_scopes(
|
||||
req: HttpRequest,
|
||||
accept_body: web::Json<RespondToOAuthClientScopes>,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, OAuthError> {
|
||||
accept_or_reject_client_scopes(true, req, accept_body, pool, redis, session_queue).await
|
||||
}
|
||||
|
||||
#[post("reject")]
|
||||
pub async fn reject_client_scopes(
|
||||
req: HttpRequest,
|
||||
body: web::Json<RespondToOAuthClientScopes>,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, OAuthError> {
|
||||
accept_or_reject_client_scopes(false, req, body, pool, redis, session_queue).await
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TokenRequest {
|
||||
pub grant_type: String,
|
||||
pub code: String,
|
||||
pub redirect_uri: Option<String>,
|
||||
pub client_id: models::ids::OAuthClientId,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TokenResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: i64,
|
||||
}
|
||||
|
||||
#[post("token")]
|
||||
/// Params should be in the urlencoded request body
|
||||
/// And client secret should be in the HTTP basic authorization header
|
||||
/// Per IETF RFC6749 Section 4.1.3 (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3)
|
||||
pub async fn request_token(
|
||||
req: HttpRequest,
|
||||
req_params: web::Form<TokenRequest>,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
) -> Result<HttpResponse, OAuthError> {
|
||||
let req_client_id = req_params.client_id;
|
||||
let client = DBOAuthClient::get(req_client_id.into(), &**pool).await?;
|
||||
if let Some(client) = client {
|
||||
authenticate_client_token_request(&req, &client)?;
|
||||
|
||||
// Ensure auth code is single use
|
||||
// per IETF RFC6749 Section 10.5 (https://datatracker.ietf.org/doc/html/rfc6749#section-10.5)
|
||||
let flow = Flow::take_if(
|
||||
&req_params.code,
|
||||
|f| matches!(f, Flow::OAuthAuthorizationCodeSupplied { .. }),
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
if let Some(Flow::OAuthAuthorizationCodeSupplied {
|
||||
user_id,
|
||||
client_id,
|
||||
authorization_id,
|
||||
scopes,
|
||||
original_redirect_uri,
|
||||
}) = flow
|
||||
{
|
||||
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
|
||||
if req_client_id != client_id.into() {
|
||||
return Err(OAuthError::error(OAuthErrorType::UnauthorizedClient));
|
||||
}
|
||||
|
||||
if original_redirect_uri != req_params.redirect_uri {
|
||||
return Err(OAuthError::error(OAuthErrorType::RedirectUriChanged(
|
||||
req_params.redirect_uri.clone(),
|
||||
)));
|
||||
}
|
||||
|
||||
if req_params.grant_type != "authorization_code" {
|
||||
return Err(OAuthError::error(
|
||||
OAuthErrorType::OnlySupportsAuthorizationCodeGrant(
|
||||
req_params.grant_type.clone(),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let scopes = scopes - Scopes::restricted();
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
let token_id = generate_oauth_access_token_id(&mut transaction).await?;
|
||||
let token = generate_access_token();
|
||||
let token_hash = OAuthAccessToken::hash_token(&token);
|
||||
let time_until_expiration = OAuthAccessToken {
|
||||
id: token_id,
|
||||
authorization_id,
|
||||
token_hash,
|
||||
scopes,
|
||||
created: Default::default(),
|
||||
expires: Default::default(),
|
||||
last_used: None,
|
||||
client_id,
|
||||
user_id,
|
||||
}
|
||||
.insert(&mut *transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
// IETF RFC6749 Section 5.1 (https://datatracker.ietf.org/doc/html/rfc6749#section-5.1)
|
||||
Ok(HttpResponse::Ok()
|
||||
.append_header((CACHE_CONTROL, "no-store"))
|
||||
.append_header((PRAGMA, "no-cache"))
|
||||
.json(TokenResponse {
|
||||
access_token: token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: time_until_expiration.num_seconds(),
|
||||
}))
|
||||
} else {
|
||||
Err(OAuthError::error(OAuthErrorType::InvalidAuthCode))
|
||||
}
|
||||
} else {
|
||||
Err(OAuthError::error(OAuthErrorType::InvalidClientId(
|
||||
req_client_id.into(),
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn accept_or_reject_client_scopes(
|
||||
accept: bool,
|
||||
req: HttpRequest,
|
||||
body: web::Json<RespondToOAuthClientScopes>,
|
||||
pool: Data<PgPool>,
|
||||
redis: Data<RedisPool>,
|
||||
session_queue: Data<AuthQueue>,
|
||||
) -> Result<HttpResponse, OAuthError> {
|
||||
let current_user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::SESSION_ACCESS]),
|
||||
)
|
||||
.await?
|
||||
.1;
|
||||
|
||||
let flow = Flow::take_if(
|
||||
&body.flow,
|
||||
|f| matches!(f, Flow::InitOAuthAppApproval { .. }),
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
if let Some(Flow::InitOAuthAppApproval {
|
||||
user_id,
|
||||
client_id,
|
||||
existing_authorization_id,
|
||||
scopes,
|
||||
redirect_uris,
|
||||
state,
|
||||
}) = flow
|
||||
{
|
||||
if current_user.id != user_id.into() {
|
||||
return Err(OAuthError::error(AuthenticationError::InvalidCredentials));
|
||||
}
|
||||
|
||||
if accept {
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
let auth_id = match existing_authorization_id {
|
||||
Some(id) => id,
|
||||
None => generate_oauth_client_authorization_id(&mut transaction).await?,
|
||||
};
|
||||
OAuthClientAuthorization::upsert(auth_id, client_id, user_id, scopes, &mut transaction)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
init_oauth_code_flow(
|
||||
user_id,
|
||||
client_id,
|
||||
auth_id,
|
||||
scopes,
|
||||
redirect_uris,
|
||||
state,
|
||||
&redis,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
Err(OAuthError::redirect(
|
||||
OAuthErrorType::AccessDenied,
|
||||
&state,
|
||||
&redirect_uris.validated,
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Err(OAuthError::error(OAuthErrorType::InvalidAcceptFlowId))
|
||||
}
|
||||
}
|
||||
|
||||
fn authenticate_client_token_request(
|
||||
req: &HttpRequest,
|
||||
client: &DBOAuthClient,
|
||||
) -> Result<(), OAuthError> {
|
||||
let client_secret = extract_authorization_header(req)?;
|
||||
let hashed_client_secret = DBOAuthClient::hash_secret(client_secret);
|
||||
if client.secret_hash != hashed_client_secret {
|
||||
Err(OAuthError::error(
|
||||
OAuthErrorType::ClientAuthenticationFailed,
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_access_token() -> String {
|
||||
let random = ChaCha20Rng::from_entropy()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(60)
|
||||
.map(char::from)
|
||||
.collect::<String>();
|
||||
format!("mro_{}", random)
|
||||
}
|
||||
|
||||
async fn init_oauth_code_flow(
|
||||
user_id: crate::database::models::UserId,
|
||||
client_id: OAuthClientId,
|
||||
authorization_id: OAuthClientAuthorizationId,
|
||||
scopes: Scopes,
|
||||
redirect_uris: OAuthRedirectUris,
|
||||
state: Option<String>,
|
||||
redis: &RedisPool,
|
||||
) -> Result<HttpResponse, OAuthError> {
|
||||
let code = Flow::OAuthAuthorizationCodeSupplied {
|
||||
user_id,
|
||||
client_id,
|
||||
authorization_id,
|
||||
scopes,
|
||||
original_redirect_uri: redirect_uris.original.clone(),
|
||||
}
|
||||
.insert(Duration::minutes(10), redis)
|
||||
.await
|
||||
.map_err(|e| OAuthError::redirect(e, &state, &redirect_uris.validated.clone()))?;
|
||||
|
||||
let mut redirect_params = vec![format!("code={code}")];
|
||||
if let Some(state) = state {
|
||||
redirect_params.push(format!("state={state}"));
|
||||
}
|
||||
|
||||
let redirect_uri = append_params_to_uri(&redirect_uris.validated.0, &redirect_params);
|
||||
|
||||
// IETF RFC 6749 Section 4.1.2 (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2)
|
||||
Ok(HttpResponse::Found()
|
||||
.append_header((LOCATION, redirect_uri))
|
||||
.finish())
|
||||
}
|
||||
|
||||
fn append_params_to_uri(uri: &str, params: &[impl AsRef<str>]) -> String {
|
||||
let mut uri = uri.to_string();
|
||||
let mut connector = if uri.contains('?') { "&" } else { "?" };
|
||||
for param in params {
|
||||
uri.push_str(&format!("{}{}", connector, param.as_ref()));
|
||||
connector = "&";
|
||||
}
|
||||
|
||||
uri
|
||||
}
|
||||
94
src/auth/oauth/uris.rs
Normal file
94
src/auth/oauth/uris.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use super::errors::OAuthError;
|
||||
use crate::auth::oauth::OAuthErrorType;
|
||||
use crate::database::models::OAuthClientId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(derive_new::new, Serialize, Deserialize)]
|
||||
pub struct OAuthRedirectUris {
|
||||
pub original: Option<String>,
|
||||
pub validated: ValidatedRedirectUri,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ValidatedRedirectUri(pub String);
|
||||
|
||||
impl ValidatedRedirectUri {
|
||||
pub fn validate<'a>(
|
||||
to_validate: &Option<String>,
|
||||
validate_against: impl IntoIterator<Item = &'a str> + Clone,
|
||||
client_id: OAuthClientId,
|
||||
) -> Result<Self, OAuthError> {
|
||||
if let Some(first_client_redirect_uri) = validate_against.clone().into_iter().next() {
|
||||
if let Some(to_validate) = to_validate {
|
||||
if validate_against
|
||||
.into_iter()
|
||||
.any(|uri| same_uri_except_query_components(uri, to_validate))
|
||||
{
|
||||
Ok(ValidatedRedirectUri(to_validate.clone()))
|
||||
} else {
|
||||
Err(OAuthError::error(OAuthErrorType::RedirectUriNotConfigured(
|
||||
to_validate.clone(),
|
||||
)))
|
||||
}
|
||||
} else {
|
||||
Ok(ValidatedRedirectUri(first_client_redirect_uri.to_string()))
|
||||
}
|
||||
} else {
|
||||
Err(OAuthError::error(
|
||||
OAuthErrorType::ClientMissingRedirectURI { client_id },
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_uri_except_query_components(a: &str, b: &str) -> bool {
|
||||
let mut a_components = a.split('?');
|
||||
let mut b_components = b.split('?');
|
||||
a_components.next() == b_components.next()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validate_for_none_returns_first_valid_uri() {
|
||||
let validate_against = vec!["https://modrinth.com/a"];
|
||||
|
||||
let validated =
|
||||
ValidatedRedirectUri::validate(&None, validate_against.clone(), OAuthClientId(0))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(validate_against[0], validated.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_for_valid_uri_returns_first_matching_uri_ignoring_query_params() {
|
||||
let validate_against = vec![
|
||||
"https://modrinth.com/a?q3=p3&q4=p4",
|
||||
"https://modrinth.com/a/b/c?q1=p1&q2=p2",
|
||||
];
|
||||
let to_validate = "https://modrinth.com/a/b/c?query0=param0&query1=param1".to_string();
|
||||
|
||||
let validated = ValidatedRedirectUri::validate(
|
||||
&Some(to_validate.clone()),
|
||||
validate_against,
|
||||
OAuthClientId(0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(to_validate, validated.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_for_invalid_uri_returns_err() {
|
||||
let validate_against = vec!["https://modrinth.com/a"];
|
||||
let to_validate = "https://modrinth.com/a/b".to_string();
|
||||
|
||||
let validated =
|
||||
ValidatedRedirectUri::validate(&Some(to_validate), validate_against, OAuthClientId(0));
|
||||
|
||||
assert!(validated
|
||||
.is_err_and(|e| matches!(e.error_type, OAuthErrorType::RedirectUriNotConfigured(_))));
|
||||
}
|
||||
}
|
||||
@@ -91,12 +91,7 @@ where
|
||||
let token = if let Some(token) = token {
|
||||
token
|
||||
} else {
|
||||
let headers = req.headers();
|
||||
let token_val: Option<&HeaderValue> = headers.get(AUTHORIZATION);
|
||||
token_val
|
||||
.ok_or_else(|| AuthenticationError::InvalidAuthMethod)?
|
||||
.to_str()
|
||||
.map_err(|_| AuthenticationError::InvalidCredentials)?
|
||||
extract_authorization_header(req)?
|
||||
};
|
||||
|
||||
let possible_user = match token.split_once('_') {
|
||||
@@ -142,6 +137,25 @@ where
|
||||
|
||||
user.map(|x| (Scopes::all(), x))
|
||||
}
|
||||
Some(("mro", _)) => {
|
||||
use crate::database::models::oauth_token_item::OAuthAccessToken;
|
||||
|
||||
let hash = OAuthAccessToken::hash_token(token);
|
||||
let access_token =
|
||||
crate::database::models::oauth_token_item::OAuthAccessToken::get(hash, executor)
|
||||
.await?
|
||||
.ok_or(AuthenticationError::InvalidCredentials)?;
|
||||
|
||||
if access_token.expires < Utc::now() {
|
||||
return Err(AuthenticationError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let user = user_item::User::get_id(access_token.user_id, executor, redis).await?;
|
||||
|
||||
session_queue.add_oauth_access_token(access_token.id).await;
|
||||
|
||||
user.map(|u| (access_token.scopes, u))
|
||||
}
|
||||
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?;
|
||||
@@ -160,6 +174,15 @@ where
|
||||
Ok(possible_user)
|
||||
}
|
||||
|
||||
pub fn extract_authorization_header(req: &HttpRequest) -> Result<&str, AuthenticationError> {
|
||||
let headers = req.headers();
|
||||
let token_val: Option<&HeaderValue> = headers.get(AUTHORIZATION);
|
||||
token_val
|
||||
.ok_or_else(|| AuthenticationError::InvalidAuthMethod)?
|
||||
.to_str()
|
||||
.map_err(|_| AuthenticationError::InvalidCredentials)
|
||||
}
|
||||
|
||||
pub async fn check_is_moderator_from_headers<'a, 'b, E>(
|
||||
req: &HttpRequest,
|
||||
executor: E,
|
||||
|
||||
Reference in New Issue
Block a user