fix: add basic auth and oidc auth
This commit is contained in:
parent
4c6cc1116b
commit
abd399aacd
@ -1,6 +1,6 @@
|
||||
[alias]
|
||||
recorder = "run -p recorder --bin recorder_cli -- --environment recorder/development"
|
||||
recorder-playground = "run -p recorder --example playground -- --environment recorder/development"
|
||||
recorder = "run -p recorder --bin recorder_cli -- --environment development"
|
||||
recorder-playground = "run -p recorder --example playground -- --environment development"
|
||||
|
||||
[build]
|
||||
rustflags = ["-Zthreads=8"]
|
||||
|
106
Cargo.lock
generated
106
Cargo.lock
generated
@ -299,6 +299,18 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-auth"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8169113a185f54f68614fcfc3581df585d30bf8542bcb99496990e1025e4120a"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"base64 0.21.7",
|
||||
"http 1.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
@ -1864,6 +1876,30 @@ dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "headers"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"bytes",
|
||||
"headers-core",
|
||||
"http 1.2.0",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"sha1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "headers-core"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
|
||||
dependencies = [
|
||||
"http 1.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@ -2528,6 +2564,33 @@ dependencies = [
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jwt-authorizer"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87d72c47f96cdd849689e1a417a2b7206399b238dd8e2b1dfcea16f27185d00e"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"headers",
|
||||
"http 1.2.0",
|
||||
"http-body-util",
|
||||
"jsonwebtoken",
|
||||
"pin-project",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tower-http 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jxl-bitstream"
|
||||
version = "0.2.3"
|
||||
@ -2946,7 +3009,7 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"tower 0.4.13",
|
||||
"tower-http",
|
||||
"tower-http 0.6.2",
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
@ -3714,6 +3777,26 @@ dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be57f64e946e500c8ee36ef6331845d40a93055567ec57e8fae13efd33759b95"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.92",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.15"
|
||||
@ -4094,6 +4177,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-auth",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"eyre",
|
||||
@ -4101,6 +4185,7 @@ dependencies = [
|
||||
"html-escape",
|
||||
"insta",
|
||||
"itertools 0.13.0",
|
||||
"jwt-authorizer",
|
||||
"lazy_static",
|
||||
"leaky-bucket",
|
||||
"lightningcss",
|
||||
@ -6091,6 +6176,25 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"bitflags 2.6.0",
|
||||
"bytes",
|
||||
"http 1.2.0",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.2"
|
||||
|
@ -1,5 +1,4 @@
|
||||
# Server
|
||||
BETTER_AUTH_SECRET="konobangu"
|
||||
DATABASE_URL="postgres://konobangu:konobangu@127.0.0.1:5432/konobangu"
|
||||
BETTERSTACK_API_KEY=""
|
||||
BETTERSTACK_URL=""
|
||||
|
@ -1,5 +1,19 @@
|
||||
# Server
|
||||
BETTER_AUTH_SECRET="konobangu"
|
||||
AUTH_TYPE="basic" #
|
||||
|
||||
BASIC_USER="konobangu"
|
||||
BASIC_PASSWORD="konobangu"
|
||||
|
||||
OIDC_PROVIDER_ENDPOINT="https://some-oidc-auth.com/oidc/.well-known/openid-configuration"
|
||||
OIDC_CLIENT_ID=""
|
||||
OIDC_CLIENT_SECRET=""
|
||||
OIDC_API_ISSUER="https://some-oidc-auth.com/oidc"
|
||||
OIDC_API_AUDIENCE="https://konobangu.com/api"
|
||||
OIDC_ICON_URL=""
|
||||
OIDC_EXTRA_SCOPE_REGEX=""
|
||||
OIDC_EXTRA_CLAIM_KEY=""
|
||||
OIDC_EXTRA_CLAIM_VALUE=""
|
||||
|
||||
DATABASE_URL="postgres://konobangu:konobangu@127.0.0.1:5432/konobangu"
|
||||
BETTERSTACK_API_KEY=""
|
||||
BETTERSTACK_URL=""
|
||||
|
@ -1,6 +1,20 @@
|
||||
# Server
|
||||
BETTER_AUTH_SECRET=""
|
||||
DATABASE_URL=""
|
||||
# AUTH
|
||||
AUTH_TYPE="basic"
|
||||
|
||||
NEXT_PUBLIC_OIDC_PROVIDER_ENDPOINT="https://some-oidc-auth.com/oidc/.well-known/openid-configuration"
|
||||
NEXT_PUBLIC_OIDC_CLIENT_ID=""
|
||||
NEXT_PUBLIC_OIDC_CLIENT_SECRET=""
|
||||
NEXT_PUBLIC_OIDC_ICON_URL=""
|
||||
OIDC_API_ISSUER="https://some-oidc-auth.com/oidc"
|
||||
OIDC_API_AUDIENCE="https://konobangu.com/api"
|
||||
OIDC_EXTRA_SCOPES="" # 如 "read:konobangu,write:konobangu"
|
||||
OIDC_EXTRA_CLAIM_KEY=""
|
||||
OIDC_EXTRA_CLAIM_VALUE=""
|
||||
|
||||
# DATABASE
|
||||
DATABASE_URL="postgres://konobangu:konobangu@127.0.0.1:5432/konobangu"
|
||||
|
||||
# SERVER MISC
|
||||
BETTERSTACK_API_KEY=""
|
||||
BETTERSTACK_URL=""
|
||||
FLAGS_SECRET=""
|
||||
@ -8,8 +22,8 @@ ARCJET_KEY=""
|
||||
SVIX_TOKEN=""
|
||||
LIVEBLOCKS_SECRET=""
|
||||
|
||||
# Client
|
||||
# WEBUI
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_WEB_URL="http://localhost:3001"
|
||||
NEXT_PUBLIC_DOCS_URL="http://localhost:3004"
|
||||
NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL="http://localhost:3000"
|
||||
NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL="https://konobangu.com"
|
@ -57,6 +57,8 @@ reqwest-tracing = "0.5.5"
|
||||
scraper = "0.22.0"
|
||||
leaky-bucket = "1.1.2"
|
||||
serde_with = "3"
|
||||
jwt-authorizer = "0.15.0"
|
||||
axum-auth = "0.7.0"
|
||||
|
||||
[dev-dependencies]
|
||||
serial_test = "3"
|
||||
|
@ -12,7 +12,7 @@ use recorder::{
|
||||
extract::mikan::parse_mikan_rss_items_from_rss_link,
|
||||
migrations::Migrator,
|
||||
models::{
|
||||
subscribers::ROOT_SUBSCRIBER,
|
||||
subscribers::SEED_SUBSCRIBER,
|
||||
subscriptions::{self, SubscriptionCreateFromRssDto},
|
||||
},
|
||||
};
|
||||
|
@ -15,6 +15,7 @@ use loco_rs::{
|
||||
use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::{
|
||||
auth::service::AppAuthService,
|
||||
controllers,
|
||||
dal::{AppDalClient, AppDalInitalizer},
|
||||
extract::mikan::{client::AppMikanClientInitializer, AppMikanClient},
|
||||
@ -25,11 +26,15 @@ use crate::{
|
||||
|
||||
pub trait AppContextExt {
|
||||
fn get_dal_client(&self) -> &AppDalClient {
|
||||
AppDalClient::global()
|
||||
AppDalClient::app_instance()
|
||||
}
|
||||
|
||||
fn get_mikan_client(&self) -> &AppMikanClient {
|
||||
AppMikanClient::global()
|
||||
AppMikanClient::app_instance()
|
||||
}
|
||||
|
||||
fn get_auth_service(&self) -> &AppAuthService {
|
||||
&AppAuthService::app_instance()
|
||||
}
|
||||
}
|
||||
|
||||
|
31
apps/recorder/src/auth/basic.rs
Normal file
31
apps/recorder/src/auth/basic.rs
Normal file
@ -0,0 +1,31 @@
|
||||
use axum::{http::request::Parts, RequestPartsExt};
|
||||
use axum_auth::AuthBasic;
|
||||
|
||||
use super::{
|
||||
config::BasicAuthConfig,
|
||||
errors::AuthError,
|
||||
service::{AuthService, AuthUserInfo},
|
||||
};
|
||||
use crate::models::{auth::AuthType, subscribers::SEED_SUBSCRIBER};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct BasicAuthService {
|
||||
pub config: BasicAuthConfig,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AuthService for BasicAuthService {
|
||||
async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError> {
|
||||
if let Ok(AuthBasic((found_user, found_password))) = request.extract().await {
|
||||
if self.config.user == found_user
|
||||
&& self.config.password == found_password.unwrap_or_default()
|
||||
{
|
||||
return Ok(AuthUserInfo {
|
||||
user_pid: SEED_SUBSCRIBER.to_string(),
|
||||
auth_type: AuthType::Basic,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(AuthError::BasicInvalidCredentials)
|
||||
}
|
||||
}
|
31
apps/recorder/src/auth/config.rs
Normal file
31
apps/recorder/src/auth/config.rs
Normal file
@ -0,0 +1,31 @@
|
||||
use jwt_authorizer::OneOrArray;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BasicAuthConfig {
|
||||
#[serde(rename = "basic_user")]
|
||||
pub user: String,
|
||||
#[serde(rename = "basic_password")]
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OidcAuthConfig {
|
||||
#[serde(rename = "oidc_api_issuer")]
|
||||
pub issuer: String,
|
||||
#[serde(rename = "oidc_api_audience")]
|
||||
pub audience: String,
|
||||
#[serde(rename = "oidc_extra_scopes")]
|
||||
pub extra_scopes: Option<OneOrArray<String>>,
|
||||
#[serde(rename = "oidc_extra_claim_key")]
|
||||
pub extra_claim_key: Option<String>,
|
||||
#[serde(rename = "oidc_extra_claim_value")]
|
||||
pub extra_claim_value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AppAuthConfig {
|
||||
Basic(BasicAuthConfig),
|
||||
Oidc(OidcAuthConfig),
|
||||
}
|
35
apps/recorder/src/auth/errors.rs
Normal file
35
apps/recorder/src/auth/errors.rs
Normal file
@ -0,0 +1,35 @@
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AuthError {
|
||||
#[error(transparent)]
|
||||
OidcInitError(#[from] jwt_authorizer::error::InitError),
|
||||
#[error("Invalid credentials")]
|
||||
BasicInvalidCredentials,
|
||||
#[error(transparent)]
|
||||
OidcJwtAuthError(#[from] jwt_authorizer::AuthError),
|
||||
#[error("Extra scopes {expected} do not match found scopes {found}")]
|
||||
OidcExtraScopesMatchError { expected: String, found: String },
|
||||
#[error("Extra claim {key} does not match expected value {expected}, found {found}")]
|
||||
OidcExtraClaimMatchError {
|
||||
key: String,
|
||||
expected: String,
|
||||
found: String,
|
||||
},
|
||||
#[error("Extra claim {0} missing")]
|
||||
OidcExtraClaimMissingError(String),
|
||||
#[error("Audience {0} missing")]
|
||||
OidcAudMissingError(String),
|
||||
#[error("Subject missing")]
|
||||
OidcSubMissingError,
|
||||
}
|
||||
|
||||
impl IntoResponse for AuthError {
|
||||
fn into_response(self) -> Response {
|
||||
(StatusCode::UNAUTHORIZED, self.to_string()).into_response()
|
||||
}
|
||||
}
|
9
apps/recorder/src/auth/mod.rs
Normal file
9
apps/recorder/src/auth/mod.rs
Normal file
@ -0,0 +1,9 @@
|
||||
pub mod basic;
|
||||
pub mod config;
|
||||
pub mod errors;
|
||||
pub mod oidc;
|
||||
pub mod service;
|
||||
|
||||
pub use config::{AppAuthConfig, BasicAuthConfig, OidcAuthConfig};
|
||||
pub use errors::AuthError;
|
||||
pub use service::{AppAuthService, AuthService, AuthUserInfo};
|
137
apps/recorder/src/auth/oidc.rs
Normal file
137
apps/recorder/src/auth/oidc.rs
Normal file
@ -0,0 +1,137 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use axum::http::request::Parts;
|
||||
use itertools::Itertools;
|
||||
use jwt_authorizer::{authorizer::Authorizer, NumericDate, OneOrArray};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
config::OidcAuthConfig,
|
||||
errors::AuthError,
|
||||
service::{AuthService, AuthUserInfo},
|
||||
};
|
||||
use crate::models::auth::AuthType;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug)]
|
||||
pub struct OidcAuthClaims {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub iss: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sub: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aud: Option<OneOrArray<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub exp: Option<NumericDate>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub nbf: Option<NumericDate>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub iat: Option<NumericDate>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jti: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub custom: HashMap<String, Value>,
|
||||
}
|
||||
|
||||
impl OidcAuthClaims {
|
||||
pub fn scopes(&self) -> std::str::Split<'_, char> {
|
||||
self.scope.as_deref().unwrap_or_default().split(',')
|
||||
}
|
||||
|
||||
pub fn get_claim(&self, key: &str) -> Option<String> {
|
||||
match key {
|
||||
"iss" => self.iss.clone(),
|
||||
"sub" => self.sub.clone(),
|
||||
"aud" => self.aud.as_ref().map(|s| s.iter().join(",")),
|
||||
"exp" => self.exp.clone().map(|s| s.0.to_string()),
|
||||
"nbf" => self.nbf.clone().map(|s| s.0.to_string()),
|
||||
"iat" => self.iat.clone().map(|s| s.0.to_string()),
|
||||
"jti" => self.jti.clone(),
|
||||
"scope" => self.scope.clone(),
|
||||
key => self.custom.get(key).map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_claim(&self, key: &str) -> bool {
|
||||
match key {
|
||||
"iss" => self.iss.is_some(),
|
||||
"sub" => self.sub.is_some(),
|
||||
"aud" => self.aud.is_some(),
|
||||
"exp" => self.exp.is_some(),
|
||||
"nbf" => self.nbf.is_some(),
|
||||
"iat" => self.iat.is_some(),
|
||||
"jti" => self.jti.is_some(),
|
||||
"scope" => self.scope.is_some(),
|
||||
key => self.custom.contains_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains_audience(&self, aud: &str) -> bool {
|
||||
self.aud
|
||||
.as_ref()
|
||||
.is_some_and(|arr| arr.iter().any(|s| s == aud))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OidcAuthService {
|
||||
pub config: OidcAuthConfig,
|
||||
pub authorizer: Authorizer<OidcAuthClaims>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AuthService for OidcAuthService {
|
||||
async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError> {
|
||||
let config = &self.config;
|
||||
let token =
|
||||
self.authorizer
|
||||
.extract_token(&request.headers)
|
||||
.ok_or(AuthError::OidcJwtAuthError(
|
||||
jwt_authorizer::AuthError::MissingToken(),
|
||||
))?;
|
||||
|
||||
let token_data = self.authorizer.check_auth(&token).await?;
|
||||
let claims = token_data.claims;
|
||||
if !claims.sub.as_deref().is_some_and(|s| !s.trim().is_empty()) {
|
||||
return Err(AuthError::OidcSubMissingError);
|
||||
}
|
||||
if !claims.contains_audience(&config.audience) {
|
||||
return Err(AuthError::OidcAudMissingError(config.audience.clone()));
|
||||
}
|
||||
if let Some(expected_scopes) = config.extra_scopes.as_ref() {
|
||||
let found_scopes = claims.scopes().collect::<HashSet<_>>();
|
||||
if !expected_scopes
|
||||
.iter()
|
||||
.all(|es| found_scopes.contains(&es as &str))
|
||||
{
|
||||
return Err(AuthError::OidcExtraScopesMatchError {
|
||||
expected: expected_scopes.iter().join(","),
|
||||
found: claims.scope.unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(key) = config.extra_claim_key.as_ref() {
|
||||
if !claims.has_claim(key) {
|
||||
return Err(AuthError::OidcExtraClaimMissingError(key.clone()));
|
||||
}
|
||||
if let Some(value) = config.extra_claim_value.as_ref() {
|
||||
if claims.get_claim(key).is_none_or(|v| &v != value) {
|
||||
return Err(AuthError::OidcExtraClaimMatchError {
|
||||
expected: value.clone(),
|
||||
found: claims.get_claim(key).unwrap_or_default().to_string(),
|
||||
key: key.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(AuthUserInfo {
|
||||
user_pid: claims
|
||||
.sub
|
||||
.as_deref()
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| unreachable!("sub should be present and validated")),
|
||||
auth_type: AuthType::Oidc,
|
||||
})
|
||||
}
|
||||
}
|
116
apps/recorder/src/auth/service.rs
Normal file
116
apps/recorder/src/auth/service.rs
Normal file
@ -0,0 +1,116 @@
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::request::Parts,
|
||||
response::{IntoResponse as _, Response},
|
||||
Extension,
|
||||
};
|
||||
use jwt_authorizer::{JwtAuthorizer, Validation};
|
||||
use loco_rs::app::{AppContext, Initializer};
|
||||
use once_cell::sync::OnceCell;
|
||||
|
||||
use super::{
|
||||
basic::BasicAuthService,
|
||||
errors::AuthError,
|
||||
oidc::{OidcAuthClaims, OidcAuthService},
|
||||
AppAuthConfig,
|
||||
};
|
||||
use crate::{app::AppContextExt as _, config::AppConfigExt, models::auth::AuthType};
|
||||
|
||||
pub struct AuthUserInfo {
|
||||
pub user_pid: String,
|
||||
pub auth_type: AuthType,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<S> FromRequestParts<S> for AuthUserInfo
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request_parts(req: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let Extension(ctx) = Extension::<AppContext>::from_request_parts(req, state)
|
||||
.await
|
||||
.expect("AppContext should be present");
|
||||
|
||||
let auth_service = ctx.get_auth_service();
|
||||
|
||||
auth_service
|
||||
.extract_user_info(req)
|
||||
.await
|
||||
.map_err(|err| err.into_response())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait AuthService {
|
||||
async fn extract_user_info(&self, request: &mut Parts) -> Result<AuthUserInfo, AuthError>;
|
||||
}
|
||||
|
||||
pub enum AppAuthService {
|
||||
Basic(BasicAuthService),
|
||||
Oidc(OidcAuthService),
|
||||
}
|
||||
|
||||
static APP_AUTH_SERVICE: OnceCell<AppAuthService> = OnceCell::new();
|
||||
|
||||
impl AppAuthService {
|
||||
pub fn app_instance() -> &'static Self {
|
||||
APP_AUTH_SERVICE
|
||||
.get()
|
||||
.expect("AppAuthService is not initialized")
|
||||
}
|
||||
|
||||
pub async fn from_conf(config: AppAuthConfig) -> Result<Self, AuthError> {
|
||||
let result = match config {
|
||||
AppAuthConfig::Basic(config) => AppAuthService::Basic(BasicAuthService { config }),
|
||||
AppAuthConfig::Oidc(config) => {
|
||||
let validation = Validation::new()
|
||||
.iss(&[&config.issuer])
|
||||
.aud(&[&config.audience]);
|
||||
|
||||
let jwt_auth = JwtAuthorizer::<OidcAuthClaims>::from_oidc(&config.issuer)
|
||||
.validation(validation)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
AppAuthService::Oidc(OidcAuthService {
|
||||
config,
|
||||
authorizer: jwt_auth,
|
||||
})
|
||||
}
|
||||
};
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AuthService for AppAuthService {
|
||||
async fn extract_user_info(&self, 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppAuthServiceInitializer;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Initializer for AppAuthServiceInitializer {
|
||||
fn name(&self) -> String {
|
||||
String::from("AppAuthServiceInitializer")
|
||||
}
|
||||
|
||||
async fn before_run(&self, ctx: &AppContext) -> Result<(), loco_rs::Error> {
|
||||
let auth_conf = ctx.config.get_app_conf()?.auth;
|
||||
|
||||
let service = AppAuthService::from_conf(auth_conf)
|
||||
.await
|
||||
.map_err(|e| loco_rs::Error::wrap(e))?;
|
||||
|
||||
APP_AUTH_SERVICE.get_or_init(|| service);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1,9 +1,13 @@
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
dal::{config::AppDalConfig, DAL_CONF_KEY},
|
||||
extract::mikan::{AppMikanConfig, MIKAN_CONF_KEY},
|
||||
};
|
||||
use crate::{auth::AppAuthConfig, dal::config::AppDalConfig, extract::mikan::AppMikanConfig};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub auth: AppAuthConfig,
|
||||
pub dal: Option<AppDalConfig>,
|
||||
pub mikan: Option<AppMikanConfig>,
|
||||
}
|
||||
|
||||
pub fn deserialize_key_path_from_json_value<T: DeserializeOwned>(
|
||||
value: &serde_json::Value,
|
||||
@ -37,12 +41,11 @@ pub fn deserialize_key_path_from_app_config<T: DeserializeOwned>(
|
||||
pub trait AppConfigExt {
|
||||
fn get_root_conf(&self) -> &loco_rs::config::Config;
|
||||
|
||||
fn get_dal_conf(&self) -> loco_rs::Result<Option<AppDalConfig>> {
|
||||
deserialize_key_path_from_app_config(self.get_root_conf(), &[DAL_CONF_KEY])
|
||||
}
|
||||
|
||||
fn get_mikan_conf(&self) -> loco_rs::Result<Option<AppMikanConfig>> {
|
||||
deserialize_key_path_from_app_config(self.get_root_conf(), &[MIKAN_CONF_KEY])
|
||||
fn get_app_conf(&self) -> loco_rs::Result<AppConfig> {
|
||||
Ok(
|
||||
deserialize_key_path_from_app_config(self.get_root_conf(), &[])?
|
||||
.expect("app config must be present"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
0
apps/recorder/src/controllers/episodes.rs
Normal file
0
apps/recorder/src/controllers/episodes.rs
Normal file
0
apps/recorder/src/controllers/subscriptions.rs
Normal file
0
apps/recorder/src/controllers/subscriptions.rs
Normal file
@ -27,11 +27,6 @@ impl AsRef<str> for DalContentCategory {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppDalClient {
|
||||
pub config: AppDalConfig,
|
||||
}
|
||||
|
||||
static APP_DAL_CLIENT: OnceCell<AppDalClient> = OnceCell::new();
|
||||
|
||||
pub enum DalStoredUrl {
|
||||
@ -54,15 +49,20 @@ impl fmt::Display for DalStoredUrl {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppDalClient {
|
||||
pub config: AppDalConfig,
|
||||
}
|
||||
|
||||
impl AppDalClient {
|
||||
pub fn new(config: AppDalConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub fn global() -> &'static AppDalClient {
|
||||
pub fn app_instance() -> &'static AppDalClient {
|
||||
APP_DAL_CLIENT
|
||||
.get()
|
||||
.expect("Global app dal client is not initialized")
|
||||
.expect("AppDalClient is not initialized")
|
||||
}
|
||||
|
||||
pub fn get_fs(&self) -> Fs {
|
||||
@ -192,7 +192,7 @@ impl Initializer for AppDalInitalizer {
|
||||
|
||||
async fn before_run(&self, app_context: &AppContext) -> loco_rs::Result<()> {
|
||||
let config = &app_context.config;
|
||||
let app_dal_conf = config.get_dal_conf()?;
|
||||
let app_dal_conf = config.get_app_conf()?.dal;
|
||||
|
||||
APP_DAL_CLIENT.get_or_init(|| AppDalClient::new(app_dal_conf.unwrap_or_default()));
|
||||
|
||||
|
@ -1,7 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const DAL_CONF_KEY: &str = "dal";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct AppDalConfig {
|
||||
pub data_dir: Option<String>,
|
||||
|
@ -1,4 +1,4 @@
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub use client::{AppDalClient, AppDalInitalizer, DalContentCategory};
|
||||
pub use config::{AppDalConfig, DAL_CONF_KEY};
|
||||
pub use config::AppDalConfig;
|
||||
|
@ -26,10 +26,10 @@ impl AppMikanClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn global() -> &'static AppMikanClient {
|
||||
pub fn app_instance() -> &'static AppMikanClient {
|
||||
APP_MIKAN_CLIENT
|
||||
.get()
|
||||
.expect("Global mikan http client is not initialized")
|
||||
.expect("AppMikanClient is not initialized")
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
@ -55,7 +55,7 @@ impl Initializer for AppMikanClientInitializer {
|
||||
|
||||
async fn before_run(&self, app_context: &AppContext) -> loco_rs::Result<()> {
|
||||
let config = &app_context.config;
|
||||
let app_mikan_conf = config.get_mikan_conf()?.unwrap_or_default();
|
||||
let app_mikan_conf = config.get_app_conf()?.mikan.unwrap_or_default();
|
||||
|
||||
APP_MIKAN_CLIENT.get_or_try_init(|| AppMikanClient::new(app_mikan_conf))?;
|
||||
|
||||
|
@ -2,8 +2,6 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fetch::HttpClientConfig;
|
||||
|
||||
pub const MIKAN_CONF_KEY: &str = "mikan";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct AppMikanConfig {
|
||||
pub http_client: Option<HttpClientConfig>,
|
||||
|
@ -5,7 +5,7 @@ pub mod rss_parser;
|
||||
pub mod web_parser;
|
||||
|
||||
pub use client::{AppMikanClient, AppMikanClientInitializer};
|
||||
pub use config::{AppMikanConfig, MIKAN_CONF_KEY};
|
||||
pub use config::AppMikanConfig;
|
||||
pub use constants::{MIKAN_BASE_URL, MIKAN_BUCKET_KEY};
|
||||
pub use rss_parser::{
|
||||
build_mikan_bangumi_rss_link, build_mikan_subscriber_aggregation_rss_link,
|
||||
|
@ -11,3 +11,4 @@ pub mod models;
|
||||
pub mod tasks;
|
||||
pub mod views;
|
||||
pub mod workers;
|
||||
pub mod auth;
|
||||
|
@ -107,6 +107,16 @@ pub enum Downloaders {
|
||||
SavePath,
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
pub enum Auth {
|
||||
Table,
|
||||
Id,
|
||||
Pid,
|
||||
SubscriberId,
|
||||
AvatarUrl,
|
||||
AuthType,
|
||||
}
|
||||
|
||||
macro_rules! create_postgres_enum_for_active_enum {
|
||||
($manager: expr, $active_enum: expr, $($enum_value:expr),+) => {
|
||||
{
|
||||
|
@ -5,7 +5,7 @@ use super::defs::{
|
||||
Bangumi, CustomSchemaManagerExt, Episodes, GeneralIds, Subscribers, Subscriptions,
|
||||
};
|
||||
use crate::models::{
|
||||
subscribers::ROOT_SUBSCRIBER,
|
||||
subscribers::SEED_SUBSCRIBER,
|
||||
subscriptions::{self, SubscriptionCategoryEnum},
|
||||
};
|
||||
|
||||
@ -40,7 +40,7 @@ impl MigrationTrait for Migration {
|
||||
let insert = Query::insert()
|
||||
.into_table(Subscribers::Table)
|
||||
.columns([Subscribers::Pid, Subscribers::DisplayName])
|
||||
.values_panic([ROOT_SUBSCRIBER.into(), ROOT_SUBSCRIBER.into()])
|
||||
.values_panic([SEED_SUBSCRIBER.into(), SEED_SUBSCRIBER.into()])
|
||||
.to_owned();
|
||||
manager.exec_stmt(insert).await?;
|
||||
|
||||
|
83
apps/recorder/src/migrations/m20241231_000001_auth.rs
Normal file
83
apps/recorder/src/migrations/m20241231_000001_auth.rs
Normal file
@ -0,0 +1,83 @@
|
||||
use sea_orm_migration::{prelude::*, schema::*};
|
||||
|
||||
use super::defs::Auth;
|
||||
use crate::{
|
||||
migrations::defs::{CustomSchemaManagerExt, GeneralIds, Subscribers},
|
||||
models::auth::{AuthType, AuthTypeEnum},
|
||||
};
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
create_postgres_enum_for_active_enum!(
|
||||
manager,
|
||||
AuthTypeEnum,
|
||||
AuthType::Basic,
|
||||
AuthType::Oidc
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
table_auto(Auth::Table)
|
||||
.col(pk_auto(Auth::Id))
|
||||
.col(text(Auth::Pid))
|
||||
.col(enumeration(
|
||||
Auth::AuthType,
|
||||
AuthTypeEnum,
|
||||
AuthType::iden_values(),
|
||||
))
|
||||
.col(string_null(Auth::AvatarUrl))
|
||||
.col(integer(Auth::SubscriberId))
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_auth_subscriber_id")
|
||||
.from_tbl(Auth::Table)
|
||||
.from_col(Auth::SubscriberId)
|
||||
.to_tbl(Subscribers::Table)
|
||||
.to_col(Subscribers::Id)
|
||||
.on_delete(ForeignKeyAction::Cascade)
|
||||
.on_update(ForeignKeyAction::Restrict),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_auth_pid_auth_type")
|
||||
.unique()
|
||||
.table(Auth::Table)
|
||||
.col(Auth::Pid)
|
||||
.col(Auth::AuthType)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_postgres_auto_update_ts_trigger_for_col(Auth::Table, GeneralIds::UpdatedAt)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.drop_postgres_auto_update_ts_trigger_for_col(Auth::Table, GeneralIds::UpdatedAt)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_table(Table::drop().table(Auth::Table).to_owned())
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.drop_postgres_enum_for_active_enum(AuthTypeEnum)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ pub mod defs;
|
||||
pub mod m20220101_000001_init;
|
||||
pub mod m20240224_082543_add_downloads;
|
||||
pub mod m20240225_060853_subscriber_add_downloader;
|
||||
pub mod m20241231_000001_auth;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@ -15,6 +16,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20220101_000001_init::Migration),
|
||||
Box::new(m20240224_082543_add_downloads::Migration),
|
||||
Box::new(m20240225_060853_subscriber_add_downloader::Migration),
|
||||
Box::new(m20241231_000001_auth::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
6
apps/recorder/src/models/auth.rs
Normal file
6
apps/recorder/src/models/auth.rs
Normal file
@ -0,0 +1,6 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
pub use super::entities::auth::*;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
43
apps/recorder/src/models/entities/auth.rs
Normal file
43
apps/recorder/src/models/entities/auth.rs
Normal file
@ -0,0 +1,43 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(
|
||||
Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, DeriveDisplay, Serialize, Deserialize,
|
||||
)]
|
||||
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "auth_type")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthType {
|
||||
#[sea_orm(string_value = "basic")]
|
||||
Basic,
|
||||
#[sea_orm(string_value = "oidc")]
|
||||
Oidc,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "auth")]
|
||||
pub struct Model {
|
||||
pub created_at: DateTime,
|
||||
pub updated_at: DateTime,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub pid: String,
|
||||
pub subscriber_id: i32,
|
||||
pub auth_type: AuthType,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::subscribers::Entity",
|
||||
from = "Column::SubscriberId",
|
||||
to = "super::subscribers::Column::Id"
|
||||
)]
|
||||
SubscriberId,
|
||||
}
|
||||
|
||||
impl Related<super::subscribers::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::SubscriberId.def()
|
||||
}
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.4
|
||||
pub mod auth;
|
||||
pub mod bangumi;
|
||||
pub mod downloaders;
|
||||
pub mod downloads;
|
||||
pub mod episodes;
|
||||
pub mod subscribers;
|
||||
pub mod subscriptions;
|
||||
pub mod downloaders;
|
||||
|
@ -36,6 +36,8 @@ pub enum Relation {
|
||||
Bangumi,
|
||||
#[sea_orm(has_many = "super::episodes::Entity")]
|
||||
Episode,
|
||||
#[sea_orm(has_many = "super::auth::Entity")]
|
||||
Auth,
|
||||
}
|
||||
|
||||
impl Related<super::subscriptions::Entity> for Entity {
|
||||
@ -61,3 +63,9 @@ impl Related<super::episodes::Entity> for Entity {
|
||||
Relation::Episode.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::auth::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Auth.def()
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod bangumi;
|
||||
pub mod downloaders;
|
||||
pub mod downloads;
|
||||
|
@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use super::entities::subscribers::*;
|
||||
|
||||
pub const ROOT_SUBSCRIBER: &str = "konobangu";
|
||||
pub const SEED_SUBSCRIBER: &str = "konobangu";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct SubscriberIdParams {
|
||||
@ -69,7 +69,7 @@ impl Model {
|
||||
}
|
||||
|
||||
pub async fn find_root(ctx: &AppContext) -> ModelResult<Self> {
|
||||
Self::find_by_pid(ctx, ROOT_SUBSCRIBER).await
|
||||
Self::find_by_pid(ctx, SEED_SUBSCRIBER).await
|
||||
}
|
||||
|
||||
/// Asynchronously creates a user with a password and saves it to the
|
||||
@ -83,8 +83,8 @@ impl Model {
|
||||
let txn = db.begin().await?;
|
||||
|
||||
let user = ActiveModel {
|
||||
display_name: ActiveValue::set(ROOT_SUBSCRIBER.to_string()),
|
||||
pid: ActiveValue::set(ROOT_SUBSCRIBER.to_string()),
|
||||
display_name: ActiveValue::set(SEED_SUBSCRIBER.to_string()),
|
||||
pid: ActiveValue::set(SEED_SUBSCRIBER.to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
|
@ -112,8 +112,10 @@ redis:
|
||||
dangerously_flush: false
|
||||
|
||||
settings:
|
||||
|
||||
dal:
|
||||
data_dir: ./data
|
||||
|
||||
mikan:
|
||||
http_client:
|
||||
exponential_backoff_max_retries: 3
|
||||
@ -123,3 +125,13 @@ settings:
|
||||
leaky_bucket_refill_interval: 500
|
||||
user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0"
|
||||
base_url: "https://mikanani.me/"
|
||||
|
||||
auth:
|
||||
auth_type: "oidc" # or "basic"
|
||||
basic_user: "konobangu"
|
||||
basic_password: "konobangu"
|
||||
oidc_api_issuer: "https://some-oidc-auth.com/oidc"
|
||||
oidc_api_audience: "https://konobangu.com/api"
|
||||
oidc_extra_scopes: "read:konobangu,write:konobangu"
|
||||
oidc_extra_claim_key: ""
|
||||
oidc_extra_claim_value: ""
|
0
config/production.yaml
Normal file
0
config/production.yaml
Normal file
2
justfile
2
justfile
@ -13,7 +13,7 @@ dev-recorder:
|
||||
cargo watch -w apps/recorder -w config -x 'recorder start'
|
||||
|
||||
down-recorder:
|
||||
cargo run -p recorder --bin recorder_cli -- db down 999 --environment recorder/development
|
||||
cargo run -p recorder --bin recorder_cli -- db down 999 --environment development
|
||||
|
||||
play-recorder:
|
||||
cargo recorder-playground
|
Loading…
Reference in New Issue
Block a user