feat: add permission control

This commit is contained in:
2025-02-22 20:26:14 +08:00
parent ae40a3a7f8
commit c2f74dc369
33 changed files with 707 additions and 226 deletions

View File

@@ -1,6 +1,7 @@
use async_trait::async_trait;
use axum::http::{request::Parts, HeaderValue};
use axum::http::{HeaderValue, request::Parts};
use base64::{self, Engine};
use loco_rs::app::AppContext;
use reqwest::header::AUTHORIZATION;
use super::{
@@ -59,7 +60,11 @@ pub struct BasicAuthService {
#[async_trait]
impl AuthService for BasicAuthService {
async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError> {
async fn extract_user_info(
&self,
ctx: &AppContext,
request: &mut Parts,
) -> Result<AuthUserInfo, AuthError> {
if let Ok(AuthBasic {
user: found_user,
password: found_password,
@@ -68,8 +73,11 @@ impl AuthService for BasicAuthService {
if self.config.user == found_user
&& self.config.password == found_password.unwrap_or_default()
{
let subscriber_auth = crate::models::auth::Model::find_by_pid(ctx, SEED_SUBSCRIBER)
.await
.map_err(AuthError::FindAuthRecordError)?;
return Ok(AuthUserInfo {
user_pid: SEED_SUBSCRIBER.to_string(),
subscriber_auth,
auth_type: AuthType::Basic,
});
}

View File

@@ -1,11 +1,14 @@
use std::fmt;
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use loco_rs::model::ModelError;
use openidconnect::{
core::CoreErrorResponseType, ConfigurationError, RequestTokenError, SignatureVerificationError,
SigningError, StandardErrorResponse,
ConfigurationError, RequestTokenError, SignatureVerificationError, SigningError,
StandardErrorResponse, core::CoreErrorResponseType,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
@@ -19,6 +22,8 @@ pub enum AuthError {
supported: Vec<AuthType>,
current: AuthType,
},
#[error("Failed to find auth record")]
FindAuthRecordError(ModelError),
#[error("Invalid credentials")]
BasicInvalidCredentials,
#[error(transparent)]
@@ -69,6 +74,15 @@ pub enum AuthError {
OidcAudMissingError(String),
#[error("Subject missing")]
OidcSubMissingError,
#[error(fmt = display_graphql_permission_error)]
GraphQLPermissionError(async_graphql::Error),
}
fn display_graphql_permission_error(
error: &async_graphql::Error,
formatter: &mut fmt::Formatter<'_>,
) -> fmt::Result {
write!(formatter, "GraphQL permission denied: {}", error.message)
}
#[derive(Clone, Debug, Serialize, Deserialize)]

View File

@@ -19,7 +19,7 @@ pub async fn api_auth_middleware(
let (mut parts, body) = request.into_parts();
let mut response = match auth_service.extract_user_info(&mut parts).await {
let mut response = match auth_service.extract_user_info(&ctx, &mut parts).await {
Ok(auth_user_info) => {
let mut request = Request::from_parts(parts, body);
request.extensions_mut().insert(auth_user_info);

View File

@@ -4,14 +4,15 @@ use std::{
};
use async_trait::async_trait;
use axum::http::{request::Parts, HeaderValue};
use axum::http::{HeaderValue, request::Parts};
use itertools::Itertools;
use jwt_authorizer::{authorizer::Authorizer, NumericDate, OneOrArray};
use jwt_authorizer::{NumericDate, OneOrArray, authorizer::Authorizer};
use loco_rs::{app::AppContext, model::ModelError};
use moka::future::Cache;
use openidconnect::{
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
AccessTokenHash, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, TokenResponse,
core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -258,7 +259,11 @@ impl OidcAuthService {
#[async_trait]
impl AuthService for OidcAuthService {
async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError> {
async fn extract_user_info(
&self,
ctx: &AppContext,
request: &mut Parts,
) -> Result<AuthUserInfo, AuthError> {
let config = &self.config;
let token = self.api_authorizer.extract_token(&request.headers).ok_or(
AuthError::OidcJwtAuthError(jwt_authorizer::AuthError::MissingToken()),
@@ -266,9 +271,11 @@ impl AuthService for OidcAuthService {
let token_data = self.api_authorizer.check_auth(&token).await?;
let claims = token_data.claims;
if claims.sub.as_deref().is_none_or(|s| s.trim().is_empty()) {
let sub = if let Some(sub) = claims.sub.as_deref() {
sub
} else {
return Err(AuthError::OidcSubMissingError);
}
};
if !claims.contains_audience(&config.audience) {
return Err(AuthError::OidcAudMissingError(config.audience.clone()));
}
@@ -298,12 +305,16 @@ impl AuthService for OidcAuthService {
}
}
}
let subscriber_auth = match crate::models::auth::Model::find_by_pid(ctx, sub).await {
Err(ModelError::EntityNotFound) => {
crate::models::auth::Model::create_from_oidc(ctx, sub.to_string()).await
}
r => r,
}
.map_err(AuthError::FindAuthRecordError)?;
Ok(AuthUserInfo {
user_pid: claims
.sub
.as_deref()
.map(|s| s.trim().to_string())
.unwrap_or_else(|| unreachable!("sub should be present and validated")),
subscriber_auth,
auth_type: AuthType::Oidc,
})
}

View File

@@ -13,24 +13,24 @@ use once_cell::sync::OnceCell;
use reqwest::header::HeaderValue;
use super::{
AppAuthConfig,
basic::BasicAuthService,
errors::AuthError,
oidc::{OidcAuthClaims, OidcAuthService},
AppAuthConfig,
};
use crate::{
app::AppContextExt as _,
config::AppConfigExt,
fetch::{
client::{HttpClientCacheBackendConfig, HttpClientCachePresetConfig},
HttpClient, HttpClientConfig,
client::{HttpClientCacheBackendConfig, HttpClientCachePresetConfig},
},
models::auth::AuthType,
};
#[derive(Clone, Debug)]
pub struct AuthUserInfo {
pub user_pid: String,
pub subscriber_auth: crate::models::auth::Model,
pub auth_type: AuthType,
}
@@ -44,7 +44,7 @@ impl FromRequestParts<AppContext> for AuthUserInfo {
let auth_service = state.get_auth_service();
auth_service
.extract_user_info(parts)
.extract_user_info(state, parts)
.await
.map_err(|err| err.into_response())
}
@@ -52,7 +52,11 @@ impl FromRequestParts<AppContext> for AuthUserInfo {
#[async_trait]
pub trait AuthService {
async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError>;
async fn extract_user_info(
&self,
ctx: &AppContext,
request: &mut Parts,
) -> Result<AuthUserInfo, AuthError>;
fn www_authenticate_header_value(&self) -> Option<HeaderValue>;
fn auth_type(&self) -> AuthType;
}
@@ -79,21 +83,23 @@ impl AppAuthService {
.iss(&[&config.issuer])
.aud(&[&config.audience]);
let jwt_auth = JwtAuthorizer::<OidcAuthClaims>::from_oidc(&config.issuer)
let oidc_provider_client = HttpClient::from_config(HttpClientConfig {
exponential_backoff_max_retries: Some(3),
cache_backend: Some(HttpClientCacheBackendConfig::Moka { cache_size: 1 }),
cache_preset: Some(HttpClientCachePresetConfig::RFC7234),
..Default::default()
})
.map_err(AuthError::OidcProviderHttpClientError)?;
let api_authorizer = JwtAuthorizer::<OidcAuthClaims>::from_oidc(&config.issuer)
.validation(validation)
.build()
.await?;
AppAuthService::Oidc(OidcAuthService {
config,
api_authorizer: jwt_auth,
oidc_provider_client: HttpClient::from_config(HttpClientConfig {
exponential_backoff_max_retries: Some(3),
cache_backend: Some(HttpClientCacheBackendConfig::Moka { cache_size: 1 }),
cache_preset: Some(HttpClientCachePresetConfig::RFC7234),
..Default::default()
})
.map_err(AuthError::OidcProviderHttpClientError)?,
api_authorizer,
oidc_provider_client,
oidc_request_cache: Cache::builder()
.time_to_live(Duration::from_mins(5))
.name("oidc_request_cache")
@@ -107,10 +113,14 @@ impl AppAuthService {
#[async_trait]
impl AuthService for AppAuthService {
async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError> {
async fn extract_user_info(
&self,
ctx: &AppContext,
request: &mut Parts,
) -> Result<AuthUserInfo, AuthError> {
match self {
AppAuthService::Basic(service) => service.extract_user_info(request).await,
AppAuthService::Oidc(service) => service.extract_user_info(request).await,
AppAuthService::Basic(service) => service.extract_user_info(ctx, request).await,
AppAuthService::Oidc(service) => service.extract_user_info(ctx, request).await,
}
}