mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 12:34:22 +08:00
feat: SECTL Auth 回调改用本地 HTTP 服务 (端口 51267)
将 OAuth 回调从 secscore:// URI 协议改为本地 HTTP 回调服务,点击登录时 启动 127.0.0.1:51267 上的回调服务器,端口被占用时强杀占用进程后重试, 登录成功/失败/超时/关闭弹窗时关闭服务。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,9 @@ pub struct OAuthCallbackResult {
|
|||||||
static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy<Arc<Mutex<Option<oneshot::Sender<()>>>>> =
|
static OAUTH_SERVER_SHUTDOWN: once_cell::sync::Lazy<Arc<Mutex<Option<oneshot::Sender<()>>>>> =
|
||||||
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None)));
|
once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(None)));
|
||||||
|
|
||||||
|
/// OAuth 本地回调服务器监听端口
|
||||||
|
const OAUTH_CALLBACK_PORT: u16 = 51267;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn oauth_start_callback_server(
|
pub async fn oauth_start_callback_server(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
@@ -37,16 +40,39 @@ pub async fn oauth_start_callback_server(
|
|||||||
|
|
||||||
// 如果服务器已经在运行,直接返回 URL
|
// 如果服务器已经在运行,直接返回 URL
|
||||||
if shutdown_tx.is_some() {
|
if shutdown_tx.is_some() {
|
||||||
let port = 16888u16;
|
let port = OAUTH_CALLBACK_PORT;
|
||||||
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
|
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
|
||||||
return Ok(IpcResponse::success(OAuthServerStartResult { url, port }));
|
return Ok(IpcResponse::success(OAuthServerStartResult { url, port }));
|
||||||
}
|
}
|
||||||
|
|
||||||
let port = 16888u16;
|
let port = OAUTH_CALLBACK_PORT;
|
||||||
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
let addr: SocketAddr = ([127, 0, 0, 1], port).into();
|
||||||
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
|
let url = format!("http://127.0.0.1:{}/oauth/callback", port);
|
||||||
let url_for_spawn = url.clone();
|
let url_for_spawn = url.clone();
|
||||||
|
|
||||||
|
// 尝试绑定;若端口被占用,强杀占用进程后重试一次
|
||||||
|
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
if e.kind() == std::io::ErrorKind::AddrInUse {
|
||||||
|
println!(
|
||||||
|
"[OAuth Callback] 端口 {} 被占用,尝试强杀占用进程",
|
||||||
|
port
|
||||||
|
);
|
||||||
|
if let Err(kill_err) = kill_processes_on_port(port) {
|
||||||
|
eprintln!("[OAuth Callback] 强杀端口占用进程失败: {}", kill_err);
|
||||||
|
}
|
||||||
|
// 给系统一点时间回收端口
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||||
|
tokio::net::TcpListener::bind(addr)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("绑定回调服务器端口 {} 失败: {}", port, e))?
|
||||||
|
} else {
|
||||||
|
return Err(format!("绑定回调服务器端口 {} 失败: {}", port, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let (tx, rx) = oneshot::channel::<()>();
|
let (tx, rx) = oneshot::channel::<()>();
|
||||||
*shutdown_tx = Some(tx);
|
*shutdown_tx = Some(tx);
|
||||||
drop(shutdown_tx);
|
drop(shutdown_tx);
|
||||||
@@ -58,14 +84,6 @@ pub async fn oauth_start_callback_server(
|
|||||||
.route("/oauth/callback", axum::routing::get(handle_oauth_callback))
|
.route("/oauth/callback", axum::routing::get(handle_oauth_callback))
|
||||||
.layer(axum::extract::Extension(app_handle_clone));
|
.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);
|
println!("OAuth callback server started at {}", url_for_spawn);
|
||||||
|
|
||||||
let server = axum::serve(listener, app);
|
let server = axum::serve(listener, app);
|
||||||
@@ -81,6 +99,62 @@ pub async fn oauth_start_callback_server(
|
|||||||
Ok(IpcResponse::success(OAuthServerStartResult { url, port }))
|
Ok(IpcResponse::success(OAuthServerStartResult { url, port }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 查找并强杀占用指定端口的进程。
|
||||||
|
/// macOS/Linux 使用 lsof,Windows 使用 netstat + taskkill。
|
||||||
|
fn kill_processes_on_port(port: u16) -> Result<(), String> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let output = std::process::Command::new("lsof")
|
||||||
|
.args(["-t", "-i", &format!(":{}", port)])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("执行 lsof 失败: {}", e))?;
|
||||||
|
let pids = String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| line.trim().parse::<i32>().ok())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for pid in pids {
|
||||||
|
let _ = std::process::Command::new("kill")
|
||||||
|
.args(["-9", &pid.to_string()])
|
||||||
|
.status();
|
||||||
|
println!("[OAuth Callback] 已强杀占用端口 {} 的进程 {}", port, pid);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let output = std::process::Command::new("netstat")
|
||||||
|
.args(["-ano"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("执行 netstat 失败: {}", e))?;
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let needle = format!(":{}", port);
|
||||||
|
let mut pids = std::collections::HashSet::<String>::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
// 只匹配监听/连接行且包含目标端口
|
||||||
|
if line.contains(&needle) {
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if let Some(pid) = parts.last() {
|
||||||
|
if pid.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
pids.push(pid.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pid in pids {
|
||||||
|
let _ = std::process::Command::new("taskkill")
|
||||||
|
.args(["/PID", &pid, "/F"])
|
||||||
|
.status();
|
||||||
|
println!("[OAuth Callback] 已强杀占用端口 {} 的进程 {}", port, pid);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(not(any(unix, windows)))]
|
||||||
|
{
|
||||||
|
let _ = port;
|
||||||
|
Err("当前平台不支持强杀端口占用进程".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn oauth_stop_callback_server(
|
pub async fn oauth_stop_callback_server(
|
||||||
_state: State<'_, Arc<RwLock<AppState>>>,
|
_state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Button, message, Modal, Space, Spin } from "antd"
|
import { Button, message, Modal, Space, Spin } from "antd"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { open } from "@tauri-apps/plugin-shell"
|
import { open } from "@tauri-apps/plugin-shell"
|
||||||
import { sectlAuth, SECTL_CONFIG } from "../../services/sectlAuth"
|
import { sectlAuth, SECTL_CONFIG } from "../../services/sectlAuth"
|
||||||
@@ -20,50 +20,106 @@ interface OAuthLoginProps {
|
|||||||
export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
// 标记本次登录流程是否已完成,避免回调重复触发
|
||||||
|
const completedRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return
|
if (!visible) return
|
||||||
|
|
||||||
const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID
|
const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID
|
||||||
if (platformId) {
|
if (platformId) {
|
||||||
// 使用 deep link 回调地址
|
// 使用本地 HTTP 回调地址 (http://127.0.0.1:51267/oauth/callback)
|
||||||
sectlAuth.initialize(platformId, "secscore://oauth")
|
sectlAuth.initialize(platformId, "http://127.0.0.1:51267/oauth/callback")
|
||||||
SECTL_CONFIG.platformId = platformId
|
SECTL_CONFIG.platformId = platformId
|
||||||
SECTL_CONFIG.callbackUrl = "secscore://oauth"
|
SECTL_CONFIG.callbackUrl = "http://127.0.0.1:51267/oauth/callback"
|
||||||
}
|
}
|
||||||
}, [visible])
|
}, [visible])
|
||||||
|
|
||||||
// 监听 Deep Link 回调
|
// 监听本地 HTTP 回调服务器转发回来的 OAuth 回调
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || !loading) return
|
if (!visible || !loading) return
|
||||||
|
completedRef.current = false
|
||||||
|
|
||||||
const handleDeepLink = async (event: Event) => {
|
const api = (window as any).api
|
||||||
const customEvent = event as CustomEvent<{ code: string; state: string }>
|
if (!api || typeof api.onOAuthCallback !== "function") return
|
||||||
const { code, state } = customEvent.detail
|
|
||||||
|
|
||||||
console.log("[OAuthLogin] Received deep link callback:", { code, state })
|
let disposed = false
|
||||||
|
let unlisten: (() => void) | null = null
|
||||||
|
|
||||||
|
const handleCallback = async (payload: {
|
||||||
|
code?: string | null
|
||||||
|
state?: string | null
|
||||||
|
error?: string | null
|
||||||
|
error_description?: string | null
|
||||||
|
}) => {
|
||||||
|
if (disposed || completedRef.current) return
|
||||||
|
console.log("[OAuthLogin] Received http callback:", payload)
|
||||||
|
|
||||||
|
if (payload.error) {
|
||||||
|
completedRef.current = true
|
||||||
|
message.error("登录失败: " + (payload.error_description || payload.error))
|
||||||
|
await stopCallbackServer()
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.code) {
|
||||||
|
completedRef.current = true
|
||||||
|
message.error("登录失败: 未收到授权码")
|
||||||
|
await stopCallbackServer()
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用 code 交换 token
|
const result = await sectlAuth.exchangeCode(
|
||||||
const result = await sectlAuth.exchangeCode(code, state)
|
payload.code,
|
||||||
|
payload.state || ""
|
||||||
|
)
|
||||||
if (result) {
|
if (result) {
|
||||||
|
completedRef.current = true
|
||||||
const userInfo = await sectlAuth.getUserInfo()
|
const userInfo = await sectlAuth.getUserInfo()
|
||||||
onSuccess(userInfo)
|
onSuccess(userInfo)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
completedRef.current = true
|
||||||
message.error(error.message || "登录失败")
|
message.error(error.message || "登录失败")
|
||||||
} finally {
|
} finally {
|
||||||
|
await stopCallbackServer()
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("ss:oauth-deep-link", handleDeepLink)
|
api
|
||||||
|
.onOAuthCallback(handleCallback)
|
||||||
|
.then((fn: () => void) => {
|
||||||
|
if (disposed) {
|
||||||
|
fn()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unlisten = fn
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
console.error("[OAuthLogin] Failed to listen oauth-callback:", err)
|
||||||
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("ss:oauth-deep-link", handleDeepLink)
|
disposed = true
|
||||||
|
if (unlisten) unlisten()
|
||||||
}
|
}
|
||||||
}, [visible, loading, onClose, onSuccess])
|
}, [visible, loading, onClose, onSuccess])
|
||||||
|
|
||||||
|
const stopCallbackServer = async () => {
|
||||||
|
const api = (window as any).api
|
||||||
|
if (!api || typeof api.oauthStopCallbackServer !== "function") return
|
||||||
|
try {
|
||||||
|
await api.oauthStopCallbackServer()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[OAuthLogin] stop callback server failed:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleOAuthLogin = async () => {
|
const handleOAuthLogin = async () => {
|
||||||
if (!SECTL_CONFIG.platformId) {
|
if (!SECTL_CONFIG.platformId) {
|
||||||
message.error("OAuth 配置未设置")
|
message.error("OAuth 配置未设置")
|
||||||
@@ -73,25 +129,41 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 启动本地 HTTP 回调服务器 (端口 51267,被占用则强杀已有进程)
|
||||||
|
const api = (window as any).api
|
||||||
|
if (api && typeof api.oauthStartCallbackServer === "function") {
|
||||||
|
const res = await api.oauthStartCallbackServer()
|
||||||
|
if (!res?.success) {
|
||||||
|
throw new Error(res?.message || "启动回调服务器失败")
|
||||||
|
}
|
||||||
|
// 以服务器实际返回的 URL 作为 redirect_uri
|
||||||
|
if (res.data?.url) {
|
||||||
|
SECTL_CONFIG.callbackUrl = res.data.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const authUrl = await sectlAuth.getAuthorizationUrl()
|
const authUrl = await sectlAuth.getAuthorizationUrl()
|
||||||
// 使用 Tauri shell 打开系统浏览器
|
// 使用 Tauri shell 打开系统浏览器
|
||||||
await open(authUrl)
|
await open(authUrl)
|
||||||
|
|
||||||
// 设置超时
|
// 设置超时
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (loading) {
|
if (loading && !completedRef.current) {
|
||||||
|
stopCallbackServer()
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
message.warning("登录超时,请重试")
|
message.warning("登录超时,请重试")
|
||||||
}
|
}
|
||||||
}, 300000)
|
}, 300000)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.message || "登录失败")
|
message.error(error.message || "登录失败")
|
||||||
|
await stopCallbackServer()
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleModalClose = () => {
|
const handleModalClose = () => {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
void stopCallbackServer()
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -542,6 +542,23 @@ const api = {
|
|||||||
success: boolean
|
success: boolean
|
||||||
message?: string
|
message?: string
|
||||||
}> => invoke("oauth_stop_callback_server"),
|
}> => invoke("oauth_stop_callback_server"),
|
||||||
|
onOAuthCallback: (
|
||||||
|
callback: (payload: {
|
||||||
|
code?: string | null
|
||||||
|
state?: string | null
|
||||||
|
error?: string | null
|
||||||
|
error_description?: string | null
|
||||||
|
}) => void
|
||||||
|
): Promise<UnlistenFn> => {
|
||||||
|
return listen<{
|
||||||
|
code?: string | null
|
||||||
|
state?: string | null
|
||||||
|
error?: string | null
|
||||||
|
error_description?: string | null
|
||||||
|
}>("oauth-callback", (event) => {
|
||||||
|
callback(event.payload || {})
|
||||||
|
})
|
||||||
|
},
|
||||||
oauthReportOnline: (
|
oauthReportOnline: (
|
||||||
platformId: string,
|
platformId: string,
|
||||||
deviceType: string,
|
deviceType: string,
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const SECTL_CONFIG = {
|
|||||||
baseUrl: "https://appwrite.sectl.cn",
|
baseUrl: "https://appwrite.sectl.cn",
|
||||||
authUrl: "https://sectl.cn",
|
authUrl: "https://sectl.cn",
|
||||||
platformId: "", // 需要在设置中配置
|
platformId: "", // 需要在设置中配置
|
||||||
callbackUrl: "secscore://oauth",
|
callbackUrl: "http://127.0.0.1:51267/oauth/callback",
|
||||||
callbackPort: 5173,
|
callbackPort: 51267,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token 数据类型 (与 SDK TokenData 对齐)
|
// Token 数据类型 (与 SDK TokenData 对齐)
|
||||||
|
|||||||
Reference in New Issue
Block a user