feat(oauth): 实现 OAuth 回调服务器与完整令牌管理功能

- 新增 OAuth 回调服务器模块,支持本地 HTTP 服务器处理授权回调
- 添加令牌撤销和令牌内省 API 端点
- 重构 OAuth 授权流程,增加 state 参数防止 CSRF 攻击
- 改进前端 OAuth 登录组件,适配新的回调机制
This commit is contained in:
Yukino_fox
2026-04-04 13:46:42 +08:00
parent c015ff3768
commit c2fe7af7d0
18 changed files with 535 additions and 2247 deletions
Binary file not shown.
+126 -10
View File
@@ -54,6 +54,35 @@ pub struct OAuthUserInfo {
pub permission: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthIntrospectResponse {
pub active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iat: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iss: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthAuthorizationUrlResponse {
pub url: String,
pub state: String,
}
fn get_iv_hex() -> String {
SecurityService::generate_iv_hex()
}
@@ -300,13 +329,23 @@ pub async fn auth_clear_all(
pub async fn oauth_get_authorization_url(
platform_id: String,
callback_url: String,
) -> Result<IpcResponse<String>, String> {
state: Option<String>,
) -> Result<IpcResponse<OAuthAuthorizationUrlResponse>, String> {
let state = state.unwrap_or_else(|| {
use rand::Rng;
let mut rng = rand::thread_rng();
let random_bytes: Vec<u8> = (0..32).map(|_| rng.gen()).collect();
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &random_bytes)
});
let url = format!(
"https://sectl.top/oauth/authorize?client_id={}&redirect_uri={}&response_type=code",
"https://sectl.top/oauth/authorize?client_id={}&redirect_uri={}&response_type=code&state={}",
platform_id,
urlencoding::encode(&callback_url)
urlencoding::encode(&callback_url),
urlencoding::encode(&state)
);
Ok(IpcResponse::success(url))
Ok(IpcResponse::success(OAuthAuthorizationUrlResponse { url, state }))
}
#[tauri::command]
@@ -316,15 +355,92 @@ pub async fn oauth_exchange_code(
platform_secret: String,
callback_url: String,
) -> Result<IpcResponse<OAuthTokenResponse>, String> {
println!("[OAuth] 换取令牌 - code: {}, platform_id: {}, callback_url: {}", code, platform_id, callback_url);
let client = reqwest::Client::new();
let payload = serde_json::json!({
"grant_type": "authorization_code",
"code": code,
"client_id": platform_id,
"client_secret": platform_secret,
"redirect_uri": callback_url
});
println!("[OAuth] 请求体:{:?}", payload);
let response = client
.post("https://sectl.top/api/oauth/token")
.json(&payload)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
println!("[OAuth] 响应状态:{}", response.status());
let response_text = response.text().await.map_err(|e| format!("Failed to read response: {}", e))?;
println!("[OAuth] 响应内容:{}", response_text);
let status_success = response_text.is_empty() || !response_text.contains("error");
if !status_success {
return Ok(IpcResponse::error(&response_text));
}
let token_response: OAuthTokenResponse = serde_json::from_str(&response_text)
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(IpcResponse::success(token_response))
}
#[tauri::command]
pub async fn oauth_revoke_token(
token: String,
token_type_hint: Option<String>,
platform_id: String,
platform_secret: String,
) -> Result<IpcResponse<()>, String> {
let client = reqwest::Client::new();
let mut payload = serde_json::json!({
"token": token,
"client_id": platform_id,
"client_secret": platform_secret
});
if let Some(hint) = token_type_hint {
payload["token_type_hint"] = serde_json::json!(hint);
}
let response = client
.post("https://sectl.top/api/oauth/revoke")
.json(&payload)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Ok(IpcResponse::error(&error_text));
}
Ok(IpcResponse::success(()))
}
#[tauri::command]
pub async fn oauth_introspect_token(
token: String,
platform_id: String,
platform_secret: String,
) -> Result<IpcResponse<OAuthIntrospectResponse>, String> {
let client = reqwest::Client::new();
let response = client
.post("https://sectl.top/api/oauth/introspect")
.json(&serde_json::json!({
"grant_type": "authorization_code",
"code": code,
"token": token,
"client_id": platform_id,
"client_secret": platform_secret,
"redirect_uri": callback_url
"client_secret": platform_secret
}))
.send()
.await
@@ -338,12 +454,12 @@ pub async fn oauth_exchange_code(
return Ok(IpcResponse::error(&error_text));
}
let token_response: OAuthTokenResponse = response
let introspect_response: OAuthIntrospectResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
Ok(IpcResponse::success(token_response))
Ok(IpcResponse::success(introspect_response))
}
#[tauri::command]
+2
View File
@@ -9,6 +9,7 @@ pub mod filesystem;
pub mod http_server;
pub mod log;
pub mod mcp;
pub mod oauth_server;
pub mod reason;
pub mod response;
pub mod reward;
@@ -30,6 +31,7 @@ pub use filesystem::*;
pub use http_server::*;
pub use log::*;
pub use mcp::*;
pub use oauth_server::*;
pub use reason::*;
pub use response::*;
pub use reward::*;
+228
View File
@@ -0,0 +1,228 @@
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use std::sync::Arc;
use tauri::Emitter;
use tauri::State;
use tokio::sync::Mutex;
use tokio::sync::oneshot;
use crate::state::AppState;
use super::response::IpcResponse;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthServerStartResult {
pub url: String,
pub port: u16,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthCallbackResult {
pub code: Option<String>,
pub state: Option<String>,
pub error: Option<String>,
pub error_description: Option<String>,
}
static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy<Arc<Mutex<Option<oneshot::Sender<()>>>>> =
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None)));
#[tauri::command]
pub async fn oauth_start_callback_server(
app_handle: tauri::AppHandle,
_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<IpcResponse<OAuthServerStartResult>, String> {
let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await;
// 如果服务器已经在运行,直接返回 URL
if shutdown_tx.is_some() {
let port = 16888u16;
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
return Ok(IpcResponse::success(OAuthServerStartResult { url, port }));
}
let port = 16888u16;
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
let url_for_spawn = url.clone();
let (tx, rx) = oneshot::channel::<()>();
*shutdown_tx = Some(tx);
drop(shutdown_tx);
let app_handle_clone = app_handle.clone();
tokio::spawn(async move {
let app = axum::Router::new()
.route("/oauth/callback", axum::routing::get(handle_oauth_callback))
.layer(axum::extract::Extension(app_handle_clone));
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => {
eprintln!("Failed to bind OAuth callback server: {}", e);
return;
}
};
println!("OAuth callback server started at {}", url_for_spawn);
let server = axum::serve(listener, app);
tokio::select! {
_ = server => {},
_ = rx => {
println!("OAuth callback server shutting down");
}
}
});
Ok(IpcResponse::success(OAuthServerStartResult { url, port }))
}
#[tauri::command]
pub async fn oauth_stop_callback_server(
_state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<IpcResponse<()>, String> {
let mut shutdown_tx = OAUTH_SERVER_SHUTDOWN.lock().await;
if let Some(tx) = shutdown_tx.take() {
let _ = tx.send(());
}
Ok(IpcResponse::success(()))
}
async fn handle_oauth_callback(
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
axum::extract::Extension(app_handle): axum::extract::Extension<tauri::AppHandle>,
) -> impl axum::response::IntoResponse {
let code = params.get("code").cloned();
let state = params.get("state").cloned();
let error = params.get("error").cloned();
let error_description = params.get("error_description").cloned();
println!("[OAuth Callback] 收到回调 - code: {:?}, state: {:?}, error: {:?}", code, state, error);
let result = OAuthCallbackResult {
code: code.clone(),
state: state.clone(),
error: error.clone(),
error_description: error_description.clone(),
};
match app_handle.emit("oauth-callback", result) {
Ok(_) => println!("[OAuth Callback] Event 发送成功"),
Err(e) => println!("[OAuth Callback] Event 发送失败:{:?}", e),
}
let html = r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>OAuth 授权完成</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
padding: 40px;
background: rgba(255, 255, 255, 0.1);
border-radius: 16px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
h1 {
margin: 0 0 16px 0;
font-size: 24px;
}
p {
margin: 0;
opacity: 0.9;
font-size: 14px;
}
.success { color: #4ade80; }
.error { color: #f87171; }
</style>
</head>
<body>
<div class="container">
<h1 class="success">✓ 授权成功</h1>
<p>请返回 SecScore 应用查看登录结果</p>
<p style="margin-top: 16px; font-size: 12px; opacity: 0.7;">此窗口可以关闭</p>
</div>
<script>
setTimeout(() => {
window.close();
}, 3000);
</script>
</body>
</html>
"#;
let error_html = r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>OAuth 授权失败</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
color: white;
}
.container {
text-align: center;
padding: 40px;
background: rgba(255, 255, 255, 0.1);
border-radius: 16px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
h1 {
margin: 0 0 16px 0;
font-size: 24px;
}
p {
margin: 0;
opacity: 0.9;
font-size: 14px;
}
.success { color: #4ade80; }
.error { color: #f87171; }
</style>
</head>
<body>
<div class="container">
<h1 class="error">✗ 授权失败</h1>
<p>请返回 SecScore 应用查看错误信息</p>
<p style="margin-top: 16px; font-size: 12px; opacity: 0.7;">此窗口可以关闭</p>
</div>
</body>
</html>
"#;
let response_html = if error.is_some() { error_html } else { html };
(
axum::http::StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
response_html,
)
}
+4
View File
@@ -80,6 +80,10 @@ pub fn run() {
oauth_exchange_code,
oauth_get_user_info,
oauth_refresh_token,
oauth_revoke_token,
oauth_introspect_token,
oauth_start_callback_server,
oauth_stop_callback_server,
theme_list,
theme_current,
theme_set,