Files
Aether/apps/aether-gateway/src/handlers/admin/auth/oauth_routes.rs
T

692 lines
25 KiB
Rust

use super::oauth_config::{
admin_oauth_builtin_allowed_domains, admin_oauth_custom_allowed_domains,
admin_oauth_is_supported_provider, admin_oauth_provider_type_from_path,
admin_oauth_test_provider_type_from_path, build_admin_oauth_provider_payload,
build_admin_oauth_supported_types_payload, build_admin_oauth_upsert_record,
validate_admin_oauth_url_override, AdminOAuthProviderUpsertRequest,
};
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::{attach_admin_audit_response, build_proxy_error_response};
use crate::GatewayError;
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;
const ADMIN_OAUTH_TEST_TIMEOUT_SECS: u64 = 10;
const ADMIN_OAUTH_TEST_MAX_REDIRECTS: usize = 3;
const LINUXDO_AUTHORIZATION_URL: &str = "https://connect.linux.do/oauth2/authorize";
const LINUXDO_TOKEN_URL: &str = "https://connect.linux.do/oauth2/token";
fn admin_oauth_payload_string<'a>(payload: &'a serde_json::Value, field: &str) -> Option<&'a str> {
payload
.get(field)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn admin_oauth_secret_status(has_secret: bool) -> &'static str {
if has_secret {
"configured"
} else {
"not_provided"
}
}
async fn admin_oauth_endpoint_reachable(
url: &str,
allowed_domains: &[&str],
allow_benchmarking_ip: bool,
) -> bool {
let Ok(mut current) = reqwest::Url::parse(url) else {
return false;
};
for redirects in 0..=ADMIN_OAUTH_TEST_MAX_REDIRECTS {
if validate_admin_oauth_url_override(current.as_str(), allowed_domains).is_err() {
return false;
}
let Ok((host, addrs)) =
resolve_public_admin_oauth_endpoint_with_policy(&current, allow_benchmarking_ip).await
else {
return false;
};
let mut builder = reqwest::Client::builder()
.timeout(Duration::from_secs(ADMIN_OAUTH_TEST_TIMEOUT_SECS))
.redirect(reqwest::redirect::Policy::none())
.no_proxy();
if host.parse::<IpAddr>().is_err() {
builder = builder.resolve_to_addrs(host.as_str(), &addrs);
}
let Ok(client) = builder.build() else {
return false;
};
let Ok(response) = client
.get(current.clone())
.header(reqwest::header::ACCEPT, "*/*")
.header(
reqwest::header::USER_AGENT,
"Aether OAuth configuration tester",
)
.send()
.await
else {
return false;
};
if !response.status().is_redirection() {
return response.status().as_u16() < 500;
}
if redirects == ADMIN_OAUTH_TEST_MAX_REDIRECTS {
return false;
}
let Some(location) = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
else {
return false;
};
let Ok(next) = current.join(location) else {
return false;
};
current = next;
}
false
}
async fn resolve_public_admin_oauth_endpoint(
url: &reqwest::Url,
) -> Result<(String, Vec<SocketAddr>), ()> {
resolve_public_admin_oauth_endpoint_with_policy(url, false).await
}
async fn resolve_public_admin_oauth_endpoint_with_policy(
url: &reqwest::Url,
allow_benchmarking_ip: bool,
) -> Result<(String, Vec<SocketAddr>), ()> {
if url.scheme() != "https"
|| !url.username().is_empty()
|| url.password().is_some()
|| url.host_str().is_none()
{
return Err(());
}
let host = url.host_str().ok_or(())?;
let port = url.port_or_known_default().ok_or(())?;
let addrs = if let Ok(ip) = host.parse::<IpAddr>() {
vec![SocketAddr::new(ip, port)]
} else {
aether_http::lookup_host_with_limits(host, port, aether_http::DEFAULT_DNS_LOOKUP_TIMEOUT)
.await
.map_err(|_| ())?
};
if validate_public_admin_oauth_resolved_addrs(url, &addrs, allow_benchmarking_ip).is_err() {
return Err(());
}
Ok((host.to_string(), addrs))
}
fn validate_public_admin_oauth_resolved_addrs(
url: &reqwest::Url,
addrs: &[SocketAddr],
allow_benchmarking_ip: bool,
) -> Result<(), ()> {
if addrs.is_empty()
|| addrs.iter().any(|addr| {
aether_http::is_private_or_reserved_ip(addr.ip())
&& !(allow_benchmarking_ip
&& is_fixed_linuxdo_oauth_origin(url)
&& aether_http::is_ipv4_benchmarking_fake_ip(addr.ip()))
})
{
return Err(());
}
Ok(())
}
fn is_fixed_linuxdo_oauth_origin(url: &reqwest::Url) -> bool {
url.scheme() == "https"
&& url.host_str().is_some_and(|host| {
host.trim_end_matches('.')
.eq_ignore_ascii_case("connect.linux.do")
})
&& url.port_or_known_default() == Some(443)
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none()
}
fn admin_oauth_test_allowed_domains(
provider_type: &str,
payload: &serde_json::Value,
persisted_config: Option<&aether_data::repository::oauth_providers::StoredOAuthProviderConfig>,
) -> Vec<String> {
if let Some(domains) = admin_oauth_builtin_allowed_domains(provider_type) {
return domains.iter().map(|domain| (*domain).to_string()).collect();
}
let payload_extra = payload.get("extra_config");
let domains = admin_oauth_custom_allowed_domains(payload_extra);
if domains.is_empty() {
admin_oauth_custom_allowed_domains(
persisted_config.and_then(|provider| provider.extra_config.as_ref()),
)
} else {
domains
}
}
fn management_token_may_configure_frontend_callback(
request_context: &AdminRequestContext<'_>,
existing: Option<&aether_data::repository::oauth_providers::StoredOAuthProviderConfig>,
requested_callback: &str,
) -> bool {
let Some(principal) = request_context
.decision()
.and_then(|decision| decision.admin_principal.as_ref())
else {
return false;
};
if principal.management_token_id.is_none() {
return true;
}
let callback_changed =
existing.is_none_or(|provider| provider.frontend_callback_url != requested_callback.trim());
if !callback_changed {
return true;
}
// A missing permission list is the legacy full-access token representation.
principal
.management_token_permissions
.as_ref()
.is_none_or(|permissions| {
permissions
.iter()
.any(|permission| permission == "admin:oauth:admin")
})
}
fn oauth_frontend_callback_permission_denied_response(
request_context: &AdminRequestContext<'_>,
) -> Response<Body> {
let actor_id = request_context
.decision()
.and_then(|decision| decision.admin_principal.as_ref())
.and_then(|principal| principal.management_token_id.as_deref())
.unwrap_or("unknown");
attach_admin_audit_response(
(
http::StatusCode::FORBIDDEN,
Json(json!({
"detail": "management token permission denied",
"required_permission": "admin:oauth:admin",
"route_family": "oauth_manage",
"route_kind": "upsert_provider",
"request_path": request_context.path(),
})),
)
.into_response(),
"admin_oauth_frontend_callback_permission_denied",
"permission_denied",
"oauth_frontend_callback",
actor_id,
)
}
async fn build_admin_oauth_test_payload(
state: &AdminAppState<'_>,
provider_type: &str,
payload: &serde_json::Value,
) -> Result<serde_json::Value, GatewayError> {
if !admin_oauth_is_supported_provider(provider_type) {
return Ok(json!({
"authorization_url_reachable": false,
"token_url_reachable": false,
"secret_status": "unknown",
"details": "provider 未安装/不可用",
}));
}
let provided_secret = admin_oauth_payload_string(payload, "client_secret");
let persisted_config = state.get_oauth_provider_config(provider_type).await?;
let persisted_secret_configured = persisted_config
.as_ref()
.and_then(|provider| provider.client_secret_encrypted.as_ref())
.is_some();
let has_secret = provided_secret.is_some() || persisted_secret_configured;
let builtin_defaults = provider_type
.eq_ignore_ascii_case("linuxdo")
.then_some((LINUXDO_AUTHORIZATION_URL, LINUXDO_TOKEN_URL));
let authorization_url = admin_oauth_payload_string(payload, "authorization_url_override")
.map(ToOwned::to_owned)
.or_else(|| {
persisted_config
.as_ref()
.and_then(|provider| provider.authorization_url_override.clone())
})
.or_else(|| builtin_defaults.map(|defaults| defaults.0.to_string()));
let token_url = admin_oauth_payload_string(payload, "token_url_override")
.map(ToOwned::to_owned)
.or_else(|| {
persisted_config
.as_ref()
.and_then(|provider| provider.token_url_override.clone())
})
.or_else(|| builtin_defaults.map(|defaults| defaults.1.to_string()));
let Some(authorization_url) = authorization_url else {
return Ok(json!({
"authorization_url_reachable": false,
"token_url_reachable": false,
"secret_status": admin_oauth_secret_status(has_secret),
"details": "Authorization URL 未配置",
}));
};
let Some(token_url) = token_url else {
return Ok(json!({
"authorization_url_reachable": false,
"token_url_reachable": false,
"secret_status": admin_oauth_secret_status(has_secret),
"details": "Token URL 未配置",
}));
};
let allowed_domains =
admin_oauth_test_allowed_domains(provider_type, payload, persisted_config.as_ref());
let allowed_domain_refs = allowed_domains
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
if allowed_domain_refs.is_empty()
|| validate_admin_oauth_url_override(&authorization_url, &allowed_domain_refs).is_err()
|| validate_admin_oauth_url_override(&token_url, &allowed_domain_refs).is_err()
{
return Ok(json!({
"authorization_url_reachable": false,
"token_url_reachable": false,
"secret_status": admin_oauth_secret_status(has_secret),
"details": "OAuth 端点必须使用 https 且位于 provider 域名白名单中",
}));
}
let allow_benchmarking_ip = provider_type.eq_ignore_ascii_case("linuxdo");
let (authorization_url_reachable, token_url_reachable) = tokio::join!(
admin_oauth_endpoint_reachable(
&authorization_url,
&allowed_domain_refs,
allow_benchmarking_ip,
),
admin_oauth_endpoint_reachable(&token_url, &allowed_domain_refs, allow_benchmarking_ip),
);
let details = if authorization_url_reachable && token_url_reachable {
"OAuth 端点可达;client_secret 仅在授权回调兑换 code 时校验"
} else {
"OAuth 端点不可达或返回不可用状态;请检查端点 URL 和网络配置"
};
Ok(json!({
"authorization_url_reachable": authorization_url_reachable,
"token_url_reachable": token_url_reachable,
"secret_status": admin_oauth_secret_status(has_secret),
"details": details,
}))
}
#[cfg(test)]
mod tests {
use super::{
is_fixed_linuxdo_oauth_origin, resolve_public_admin_oauth_endpoint,
validate_public_admin_oauth_resolved_addrs,
};
use std::net::SocketAddr;
#[tokio::test]
async fn oauth_test_endpoint_rejects_loopback_https_targets_before_connecting() {
let url = reqwest::Url::parse("https://127.0.0.1/oauth/token").expect("URL");
assert!(resolve_public_admin_oauth_endpoint(&url).await.is_err());
}
#[test]
fn linuxdo_builtin_origin_allows_only_benchmarking_addresses() {
let fixed = reqwest::Url::parse("https://connect.linux.do/oauth2/token")
.expect("LinuxDo URL should parse");
let fake = SocketAddr::from(([198, 18, 75, 234], 443));
assert!(is_fixed_linuxdo_oauth_origin(&fixed));
assert!(validate_public_admin_oauth_resolved_addrs(&fixed, &[fake], true).is_ok());
assert!(validate_public_admin_oauth_resolved_addrs(&fixed, &[fake], false).is_err());
assert!(validate_public_admin_oauth_resolved_addrs(
&fixed,
&[fake, SocketAddr::from(([127, 0, 0, 1], 443))],
true,
)
.is_err());
}
#[test]
fn custom_or_non_default_oauth_origins_reject_benchmarking_addresses() {
let fake = SocketAddr::from(([198, 18, 75, 234], 443));
for raw_url in [
"https://oauth.example.test/token",
"https://connect.linux.do:8443/oauth2/token",
"https://connect.linuxdo.org/oauth2/token",
"https://connect.linux.do.evil.test/oauth2/token",
"https://connect.linux.do/oauth2/token?tenant=unexpected",
] {
let url = reqwest::Url::parse(raw_url).expect("test URL should parse");
assert!(
!is_fixed_linuxdo_oauth_origin(&url),
"must not trust {raw_url}"
);
assert!(validate_public_admin_oauth_resolved_addrs(&url, &[fake], true).is_err());
}
}
}
pub(crate) async fn maybe_build_local_admin_oauth_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.decision() else {
return Ok(None);
};
if decision.route_family.as_deref() != Some("oauth_manage") {
return Ok(None);
}
if decision.route_kind.as_deref() == Some("supported_types")
&& request_context.method() == http::Method::GET
&& request_context.path() == "/api/admin/oauth/supported-types"
{
return Ok(Some(
Json(build_admin_oauth_supported_types_payload()).into_response(),
));
}
if decision.route_kind.as_deref() == Some("list_providers")
&& request_context.method() == http::Method::GET
&& matches!(
request_context.path(),
"/api/admin/oauth/providers" | "/api/admin/oauth/providers/"
)
{
let providers = state.list_oauth_provider_configs().await?;
return Ok(Some(attach_admin_audit_response(
Json(
providers
.iter()
.map(build_admin_oauth_provider_payload)
.collect::<Vec<_>>(),
)
.into_response(),
"admin_oauth_provider_configs_viewed",
"list_oauth_provider_configs",
"oauth_provider",
"all",
)));
}
if decision.route_kind.as_deref() == Some("get_provider")
&& request_context.method() == http::Method::GET
{
let Some(provider_type) = admin_oauth_provider_type_from_path(request_context.path())
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 配置不存在" })),
)
.into_response(),
));
};
return Ok(Some(
match state.get_oauth_provider_config(&provider_type).await? {
Some(provider) => attach_admin_audit_response(
Json(build_admin_oauth_provider_payload(&provider)).into_response(),
"admin_oauth_provider_config_viewed",
"view_oauth_provider_config",
"oauth_provider",
&provider_type,
),
None => (
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 配置不存在" })),
)
.into_response(),
},
));
}
if decision.route_kind.as_deref() == Some("upsert_provider")
&& request_context.method() == http::Method::PUT
{
let Some(provider_type) = admin_oauth_provider_type_from_path(request_context.path())
else {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
"Provider 配置不存在",
None,
)));
};
let Some(request_body) = request_body else {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
"请求数据验证失败",
None,
)));
};
let payload = match serde_json::from_slice::<AdminOAuthProviderUpsertRequest>(request_body)
{
Ok(payload) => payload,
Err(_) => {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
"请求数据验证失败",
None,
)));
}
};
let existing = state.get_oauth_provider_config(&provider_type).await?;
if !management_token_may_configure_frontend_callback(
request_context,
existing.as_ref(),
&payload.frontend_callback_url,
) {
return Ok(Some(oauth_frontend_callback_permission_denied_response(
request_context,
)));
}
let force_disable = payload.force;
let record = match build_admin_oauth_upsert_record(state, &provider_type, payload) {
Ok(record) => record,
Err(message) => {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
message,
None,
)));
}
};
if let Some(existing) = existing.as_ref() {
if existing
.client_secret_encrypted
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
&& matches!(
record.client_secret_encrypted,
aether_data::repository::oauth_providers::EncryptedSecretUpdate::Preserve
)
{
match crate::handlers::shared::identity_oauth_provider_secret_binding_matches(
existing, &record,
) {
Ok(true) => {}
Ok(false) => {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
"修改 OAuth Provider 的 Client ID、端点或 redirect_uri 时必须重新提供 client_secret",
None,
)));
}
Err(_) => {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
"OAuth Provider 密钥绑定校验失败,请重新提供 client_secret",
None,
)));
}
}
}
}
let Some(outcome) = state
.upsert_oauth_provider_config_with_force_disable(&record, force_disable)
.await?
else {
return Ok(None);
};
let provider = match outcome {
aether_data::repository::oauth_providers::UpsertOAuthProviderConfigOutcome::Upserted(
provider,
) => provider,
aether_data::repository::oauth_providers::UpsertOAuthProviderConfigOutcome::DisableRequiresConfirmation {
affected_count,
} => {
return Ok(Some(build_proxy_error_response(
http::StatusCode::CONFLICT,
"confirmation_required",
format!("禁用该 Provider 会导致 {affected_count} 个用户无法登录"),
Some(json!({
"affected_count": affected_count,
"action": "disable_oauth_provider",
})),
)));
}
};
return Ok(Some(
Json(build_admin_oauth_provider_payload(&provider)).into_response(),
));
}
if decision.route_kind.as_deref() == Some("delete_provider")
&& request_context.method() == http::Method::DELETE
{
let Some(provider_type) = admin_oauth_provider_type_from_path(request_context.path())
else {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
"Provider 配置不存在",
None,
)));
};
let Some(_existing) = state.get_oauth_provider_config(&provider_type).await? else {
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
"Provider 配置不存在",
None,
)));
};
let _mutation_guard = crate::oauth::lock_identity_oauth_mutation().await;
if state.has_oauth_links_for_provider(&provider_type).await? {
return Ok(Some(build_proxy_error_response(
http::StatusCode::CONFLICT,
"provider_has_bindings",
"Provider 仍有用户绑定,必须先解除全部绑定",
None,
)));
}
let deleted = state
.delete_oauth_provider_config_if_unlinked(&provider_type)
.await?;
if !deleted {
if state.has_oauth_links_for_provider(&provider_type).await? {
return Ok(Some(build_proxy_error_response(
http::StatusCode::CONFLICT,
"provider_has_bindings",
"Provider 仍有用户绑定,必须先解除全部绑定",
None,
)));
}
return Ok(Some(build_proxy_error_response(
http::StatusCode::BAD_REQUEST,
"invalid_request",
"Provider 配置不存在",
None,
)));
}
return Ok(Some(Json(json!({ "message": "删除成功" })).into_response()));
}
if decision.route_kind.as_deref() == Some("test_provider")
&& request_context.method() == http::Method::POST
{
let Some(provider_type) = admin_oauth_test_provider_type_from_path(request_context.path())
else {
return Ok(Some(
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": "Provider 配置不存在" })),
)
.into_response(),
));
};
let Some(request_body) = request_body else {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求数据验证失败" })),
)
.into_response(),
));
};
let payload = match serde_json::from_slice::<serde_json::Value>(request_body) {
Ok(payload) => payload,
Err(_) => {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求数据验证失败" })),
)
.into_response(),
));
}
};
let client_id = admin_oauth_payload_string(&payload, "client_id");
let redirect_uri = admin_oauth_payload_string(&payload, "redirect_uri");
if client_id.is_none() || redirect_uri.is_none() {
return Ok(Some(
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": "请求数据验证失败" })),
)
.into_response(),
));
}
let test_payload = build_admin_oauth_test_payload(state, &provider_type, &payload).await?;
return Ok(Some(attach_admin_audit_response(
Json(test_payload).into_response(),
"admin_oauth_provider_tested",
"test_oauth_provider_config",
"oauth_provider",
&provider_type,
)));
}
Ok(None)
}