mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat(auth): 实现 OAuth PKCE 安全流程并优化错误处理
添加 PKCE code_verifier 和 code_challenge 支持以增强 OAuth 安全性 重构 OAuth 回调处理逻辑,添加全局锁防止重复处理 优化错误处理和信息打印,增加调试日志 移除不必要的 setTimeout 包装导出功能 格式化代码以提高可读性
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
@@ -81,6 +81,7 @@ pub struct OAuthIntrospectResponse {
|
|||||||
pub struct OAuthAuthorizationUrlResponse {
|
pub struct OAuthAuthorizationUrlResponse {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub state: String,
|
pub state: String,
|
||||||
|
pub code_verifier: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_iv_hex() -> String {
|
fn get_iv_hex() -> String {
|
||||||
@@ -325,6 +326,29 @@ pub async fn auth_clear_all(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 生成 PKCE code_verifier
|
||||||
|
fn generate_code_verifier() -> String {
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 生成 PKCE code_challenge (S256)
|
||||||
|
fn generate_code_challenge(verifier: &str) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(verifier.as_bytes());
|
||||||
|
let result = hasher.finalize();
|
||||||
|
base64::Engine::encode(
|
||||||
|
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
||||||
|
&result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn oauth_get_authorization_url(
|
pub async fn oauth_get_authorization_url(
|
||||||
platform_id: String,
|
platform_id: String,
|
||||||
@@ -341,16 +365,22 @@ pub async fn oauth_get_authorization_url(
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 生成 PKCE 参数
|
||||||
|
let code_verifier = generate_code_verifier();
|
||||||
|
let code_challenge = generate_code_challenge(&code_verifier);
|
||||||
|
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"https://sectl.top/oauth/authorize?client_id={}&redirect_uri={}&response_type=code&state={}",
|
"https://sectl.top/oauth/authorize?client_id={}&redirect_uri={}&response_type=code&state={}&code_challenge={}&code_challenge_method=S256",
|
||||||
platform_id,
|
platform_id,
|
||||||
urlencoding::encode(&callback_url),
|
urlencoding::encode(&callback_url),
|
||||||
urlencoding::encode(&state)
|
urlencoding::encode(&state),
|
||||||
|
urlencoding::encode(&code_challenge)
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(IpcResponse::success(OAuthAuthorizationUrlResponse {
|
Ok(IpcResponse::success(OAuthAuthorizationUrlResponse {
|
||||||
url,
|
url,
|
||||||
state,
|
state,
|
||||||
|
code_verifier,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,6 +390,7 @@ pub async fn oauth_exchange_code(
|
|||||||
platform_id: String,
|
platform_id: String,
|
||||||
platform_secret: String,
|
platform_secret: String,
|
||||||
callback_url: String,
|
callback_url: String,
|
||||||
|
code_verifier: String,
|
||||||
state: State<'_, Arc<RwLock<AppState>>>,
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
) -> Result<IpcResponse<OAuthTokenResponse>, String> {
|
) -> Result<IpcResponse<OAuthTokenResponse>, String> {
|
||||||
println!(
|
println!(
|
||||||
@@ -375,6 +406,7 @@ pub async fn oauth_exchange_code(
|
|||||||
"client_id": &platform_id,
|
"client_id": &platform_id,
|
||||||
"client_secret": &platform_secret,
|
"client_secret": &platform_secret,
|
||||||
"redirect_uri": &callback_url,
|
"redirect_uri": &callback_url,
|
||||||
|
"code_verifier": &code_verifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
println!("[OAuth] 请求参数:{:?}", payload);
|
println!("[OAuth] 请求参数:{:?}", payload);
|
||||||
@@ -485,6 +517,11 @@ pub async fn oauth_get_user_info(
|
|||||||
access_token: String,
|
access_token: String,
|
||||||
state: State<'_, Arc<RwLock<AppState>>>,
|
state: State<'_, Arc<RwLock<AppState>>>,
|
||||||
) -> Result<IpcResponse<OAuthUserInfo>, String> {
|
) -> Result<IpcResponse<OAuthUserInfo>, String> {
|
||||||
|
println!(
|
||||||
|
"[OAuth] 获取用户信息 - access_token: {}...",
|
||||||
|
&access_token[..20.min(access_token.len())]
|
||||||
|
);
|
||||||
|
|
||||||
let state_guard = state.read();
|
let state_guard = state.read();
|
||||||
let client = &state_guard.http_client;
|
let client = &state_guard.http_client;
|
||||||
|
|
||||||
@@ -495,17 +532,36 @@ pub async fn oauth_get_user_info(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Request failed: {}", e))?;
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
println!("[OAuth] 用户信息响应状态: {}", response.status());
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let error_text = response
|
let error_text = response
|
||||||
.text()
|
.text()
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||||
return Ok(IpcResponse::error(&error_text));
|
println!("[OAuth] 用户信息响应错误: {}", error_text);
|
||||||
|
// 尝试解析 JSON 错误,提取 error_description 或 error
|
||||||
|
let error_message =
|
||||||
|
if let Ok(json_err) = serde_json::from_str::<serde_json::Value>(&error_text) {
|
||||||
|
json_err
|
||||||
|
.get("error_description")
|
||||||
|
.or_else(|| json_err.get("error"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or(&error_text)
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
error_text
|
||||||
|
};
|
||||||
|
return Ok(IpcResponse::error(&error_message));
|
||||||
}
|
}
|
||||||
|
|
||||||
let user_info: OAuthUserInfo = response
|
let response_text = response
|
||||||
.json()
|
.text()
|
||||||
.await
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||||
|
println!("[OAuth] 用户信息响应内容: {}", response_text);
|
||||||
|
|
||||||
|
let user_info: OAuthUserInfo = serde_json::from_str(&response_text)
|
||||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
Ok(IpcResponse::success(user_info))
|
Ok(IpcResponse::success(user_info))
|
||||||
|
|||||||
@@ -1567,7 +1567,9 @@ pub async fn apply_offline_backfill(
|
|||||||
}
|
}
|
||||||
requested_runs_by_rule
|
requested_runs_by_rule
|
||||||
.entry(item.rule_id)
|
.entry(item.rule_id)
|
||||||
.and_modify(|value| *value = (*value + capped).min(AUTO_SCORE_BACKFILL_MAX_RUNS_PER_RULE))
|
.and_modify(|value| {
|
||||||
|
*value = (*value + capped).min(AUTO_SCORE_BACKFILL_MAX_RUNS_PER_RULE)
|
||||||
|
})
|
||||||
.or_insert(capped);
|
.or_insert(capped);
|
||||||
}
|
}
|
||||||
if requested_runs_by_rule.is_empty() {
|
if requested_runs_by_rule.is_empty() {
|
||||||
@@ -1610,7 +1612,8 @@ pub async fn apply_offline_backfill(
|
|||||||
changed = true;
|
changed = true;
|
||||||
|
|
||||||
for _ in 0..replay_runs {
|
for _ in 0..replay_runs {
|
||||||
let stats = execute_rule(&conn, rule, &execution_batches, ExecutionMode::Backfill).await?;
|
let stats =
|
||||||
|
execute_rule(&conn, rule, &execution_batches, ExecutionMode::Backfill).await?;
|
||||||
result.applied_runs += 1;
|
result.applied_runs += 1;
|
||||||
result.affected_students += stats.affected_students;
|
result.affected_students += stats.affected_students;
|
||||||
result.created_events += stats.created_events;
|
result.created_events += stats.created_events;
|
||||||
@@ -1682,7 +1685,9 @@ async fn execute_rule(
|
|||||||
if let Some(max_runs) = rule.execution.max_runs_per_day {
|
if let Some(max_runs) = rule.execution.max_runs_per_day {
|
||||||
let today_runs = execution_batches
|
let today_runs = execution_batches
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at))
|
.filter(|batch| {
|
||||||
|
batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at)
|
||||||
|
})
|
||||||
.count() as i64;
|
.count() as i64;
|
||||||
if today_runs >= max_runs {
|
if today_runs >= max_runs {
|
||||||
return Ok(RuleExecutionStats::default());
|
return Ok(RuleExecutionStats::default());
|
||||||
@@ -1700,7 +1705,9 @@ async fn execute_rule(
|
|||||||
|
|
||||||
let mut daily_score_delta_used: i64 = execution_batches
|
let mut daily_score_delta_used: i64 = execution_batches
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|batch| batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at))
|
.filter(|batch| {
|
||||||
|
batch.rule_id == rule.id && !batch.rolled_back && is_same_utc_day(&batch.run_at)
|
||||||
|
})
|
||||||
.map(|batch| batch.score_delta_total.abs())
|
.map(|batch| batch.score_delta_total.abs())
|
||||||
.sum();
|
.sum();
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ interface BackfillPlanItem {
|
|||||||
ruleName: string
|
ruleName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildOfflineBackfillPlan = (rules: AutoScoreRule[]): {
|
const buildOfflineBackfillPlan = (
|
||||||
|
rules: AutoScoreRule[]
|
||||||
|
): {
|
||||||
items: BackfillPlanItem[]
|
items: BackfillPlanItem[]
|
||||||
totalRuns: number
|
totalRuns: number
|
||||||
from: dayjs.Dayjs | null
|
from: dayjs.Dayjs | null
|
||||||
@@ -87,12 +89,15 @@ const buildOfflineBackfillPlan = (rules: AutoScoreRule[]): {
|
|||||||
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
if (!rule.enabled) continue
|
if (!rule.enabled) continue
|
||||||
const intervalTrigger = rule.triggers.find((trigger) => trigger.event === "interval_time_passed")
|
const intervalTrigger = rule.triggers.find(
|
||||||
|
(trigger) => trigger.event === "interval_time_passed"
|
||||||
|
)
|
||||||
if (!intervalTrigger) continue
|
if (!intervalTrigger) continue
|
||||||
|
|
||||||
const intervalValue = parseIntervalTriggerValue(intervalTrigger.value)
|
const intervalValue = parseIntervalTriggerValue(intervalTrigger.value)
|
||||||
if (!intervalValue) continue
|
if (!intervalValue) continue
|
||||||
const intervalMinutes = intervalValue.days * 24 * 60 + intervalValue.hours * 60 + intervalValue.minutes
|
const intervalMinutes =
|
||||||
|
intervalValue.days * 24 * 60 + intervalValue.hours * 60 + intervalValue.minutes
|
||||||
if (intervalMinutes <= 0) continue
|
if (intervalMinutes <= 0) continue
|
||||||
|
|
||||||
const startAt = rule.execution?.startAt ? dayjs(rule.execution.startAt) : null
|
const startAt = rule.execution?.startAt ? dayjs(rule.execution.startAt) : null
|
||||||
@@ -218,7 +223,8 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
|||||||
return t("autoScore.startAtHint")
|
return t("autoScore.startAtHint")
|
||||||
}
|
}
|
||||||
|
|
||||||
const intervalMinutes = intervalValue.days * 24 * 60 + intervalValue.hours * 60 + intervalValue.minutes
|
const intervalMinutes =
|
||||||
|
intervalValue.days * 24 * 60 + intervalValue.hours * 60 + intervalValue.minutes
|
||||||
if (intervalMinutes <= 0) {
|
if (intervalMinutes <= 0) {
|
||||||
return t("autoScore.startAtHint")
|
return t("autoScore.startAtHint")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,60 +76,58 @@ export const Leaderboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
setTimeout(() => {
|
const title =
|
||||||
const title =
|
timeRange === "today"
|
||||||
timeRange === "today"
|
? t("leaderboard.today")
|
||||||
? t("leaderboard.today")
|
: timeRange === "week"
|
||||||
: timeRange === "week"
|
? t("leaderboard.week")
|
||||||
? t("leaderboard.week")
|
: t("leaderboard.month")
|
||||||
: t("leaderboard.month")
|
|
||||||
|
|
||||||
const sanitizeCell = (v: unknown) => {
|
const sanitizeCell = (v: unknown) => {
|
||||||
if (typeof v !== "string") return v
|
if (typeof v !== "string") return v
|
||||||
if (/^[=+\-@]/.test(v)) return `'${v}`
|
if (/^[=+\-@]/.test(v)) return `'${v}`
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
const sheetData = [
|
const sheetData = [
|
||||||
[
|
[
|
||||||
t("leaderboard.rank"),
|
t("leaderboard.rank"),
|
||||||
t("leaderboard.name"),
|
t("leaderboard.name"),
|
||||||
t("leaderboard.totalScore"),
|
t("leaderboard.totalScore"),
|
||||||
`${title}${t("leaderboard.change")}`,
|
`${title}${t("leaderboard.change")}`,
|
||||||
],
|
],
|
||||||
...data.map((item, index) => [
|
...data.map((item, index) => [
|
||||||
index + 1,
|
index + 1,
|
||||||
sanitizeCell(item.name),
|
sanitizeCell(item.name),
|
||||||
item.score,
|
item.score,
|
||||||
item.range_change,
|
item.range_change,
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
|
|
||||||
const ws = XLSX.utils.aoa_to_sheet(sheetData)
|
const ws = XLSX.utils.aoa_to_sheet(sheetData)
|
||||||
ws["!cols"] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }]
|
ws["!cols"] = [{ wch: 6 }, { wch: 14 }, { wch: 10 }, { wch: 10 }]
|
||||||
|
|
||||||
const wb = XLSX.utils.book_new()
|
const wb = XLSX.utils.book_new()
|
||||||
XLSX.utils.book_append_sheet(wb, ws, t("leaderboard.title"))
|
XLSX.utils.book_append_sheet(wb, ws, t("leaderboard.title"))
|
||||||
|
|
||||||
const xlsxBytes = XLSX.write(wb, { bookType: "xlsx", type: "array" })
|
const xlsxBytes = XLSX.write(wb, { bookType: "xlsx", type: "array" })
|
||||||
const blob = new Blob([xlsxBytes], {
|
const blob = new Blob([xlsxBytes], {
|
||||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
})
|
})
|
||||||
|
|
||||||
const link = document.createElement("a")
|
const link = document.createElement("a")
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
link.setAttribute("href", url)
|
link.setAttribute("href", url)
|
||||||
link.setAttribute(
|
link.setAttribute(
|
||||||
"download",
|
"download",
|
||||||
`${t("leaderboard.title")}_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
|
`${t("leaderboard.title")}_${timeRange}_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||||
)
|
)
|
||||||
link.style.visibility = "hidden"
|
link.style.visibility = "hidden"
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
messageApi.success(t("leaderboard.exportSuccess"))
|
messageApi.success(t("leaderboard.exportSuccess"))
|
||||||
}, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<studentRank> = [
|
const columns: ColumnsType<studentRank> = [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Button, message, Modal, Space, Spin } from "antd"
|
import { Button, message, Modal, Space, Spin } from "antd"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, 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 { listen, UnlistenFn } from "@tauri-apps/api/event"
|
import { listen, UnlistenFn } from "@tauri-apps/api/event"
|
||||||
@@ -28,10 +28,14 @@ interface OAuthCallbackResult {
|
|||||||
error_description?: string
|
error_description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 全局锁,确保同一时间只有一个回调在处理
|
||||||
|
let isProcessingCallback = false
|
||||||
|
// 全局监听器引用,确保只有一个监听器
|
||||||
|
let globalUnlisten: UnlistenFn | null = null
|
||||||
|
|
||||||
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 callbackUnlistenRef = useRef<UnlistenFn | null>(null)
|
|
||||||
// 使用 sessionStorage 存储 state,防止组件重新渲染导致丢失
|
// 使用 sessionStorage 存储 state,防止组件重新渲染导致丢失
|
||||||
const getExpectedState = () => sessionStorage.getItem("oauth_expected_state")
|
const getExpectedState = () => sessionStorage.getItem("oauth_expected_state")
|
||||||
const setExpectedState = (state: string) => sessionStorage.setItem("oauth_expected_state", state)
|
const setExpectedState = (state: string) => sessionStorage.setItem("oauth_expected_state", state)
|
||||||
@@ -55,6 +59,12 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOAuthCallback = async (result: OAuthCallbackResult) => {
|
const handleOAuthCallback = async (result: OAuthCallbackResult) => {
|
||||||
|
// 全局锁检查,防止重复处理
|
||||||
|
if (isProcessingCallback) {
|
||||||
|
console.log("[OAuth] 回调正在处理中,跳过")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[OAuth] 收到回调:", result)
|
console.log("[OAuth] 收到回调:", result)
|
||||||
const config = getOAuthConfig()
|
const config = getOAuthConfig()
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -63,6 +73,8 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
isProcessingCallback = true
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
console.error("[OAuth] 错误:", result.error, result.error_description)
|
console.error("[OAuth] 错误:", result.error, result.error_description)
|
||||||
message.error(result.error_description || result.error || "授权失败")
|
message.error(result.error_description || result.error || "授权失败")
|
||||||
@@ -85,12 +97,23 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
const api = (window as any).api
|
const api = (window as any).api
|
||||||
const callbackUrl = "http://127.0.0.1:16888/oauth/callback"
|
const callbackUrl = "http://127.0.0.1:16888/oauth/callback"
|
||||||
|
|
||||||
|
// 获取存储的 code_verifier
|
||||||
|
const codeVerifier = sessionStorage.getItem("oauth_code_verifier")
|
||||||
|
if (!codeVerifier) {
|
||||||
|
message.error("安全验证失败:缺少 code_verifier")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[OAuth] 开始换取 token...")
|
||||||
const tokenRes = await api.oauthExchangeCode(
|
const tokenRes = await api.oauthExchangeCode(
|
||||||
result.code,
|
result.code,
|
||||||
config.platform_id,
|
config.platform_id,
|
||||||
config.platform_secret,
|
config.platform_secret,
|
||||||
callbackUrl
|
callbackUrl,
|
||||||
|
codeVerifier
|
||||||
)
|
)
|
||||||
|
console.log("[OAuth] token 响应:", tokenRes)
|
||||||
|
|
||||||
if (!tokenRes.success) {
|
if (!tokenRes.success) {
|
||||||
message.error(tokenRes.message || "获取访问令牌失败")
|
message.error(tokenRes.message || "获取访问令牌失败")
|
||||||
@@ -98,7 +121,9 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("[OAuth] 开始获取用户信息...")
|
||||||
const userRes = await api.oauthGetUserInfo(tokenRes.data.access_token)
|
const userRes = await api.oauthGetUserInfo(tokenRes.data.access_token)
|
||||||
|
console.log("[OAuth] 用户信息响应:", userRes)
|
||||||
|
|
||||||
if (!userRes.success) {
|
if (!userRes.success) {
|
||||||
message.error(userRes.message || "获取用户信息失败")
|
message.error(userRes.message || "获取用户信息失败")
|
||||||
@@ -106,33 +131,46 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("[OAuth] 登录成功,清理资源...")
|
||||||
await api.oauthStopCallbackServer()
|
await api.oauthStopCallbackServer()
|
||||||
clearExpectedState()
|
clearExpectedState()
|
||||||
|
sessionStorage.removeItem("oauth_code_verifier")
|
||||||
|
console.log("[OAuth] 调用 onSuccess...")
|
||||||
onSuccess(userRes.data)
|
onSuccess(userRes.data)
|
||||||
|
console.log("[OAuth] 调用 onClose...")
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
console.error("[OAuth] 处理回调时发生错误:", error)
|
||||||
message.error(error.message || "登录失败")
|
message.error(error.message || "登录失败")
|
||||||
} finally {
|
} finally {
|
||||||
|
console.log("[OAuth] 回调处理完成,重置状态")
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
// 延迟释放锁,确保其他可能的重复事件被忽略
|
||||||
|
setTimeout(() => {
|
||||||
|
isProcessingCallback = false
|
||||||
|
console.log("[OAuth] 全局锁已释放")
|
||||||
|
}, 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const setupListener = async () => {
|
const setupListener = async () => {
|
||||||
|
// 如果已经有全局监听器,不再创建新的
|
||||||
|
if (globalUnlisten) {
|
||||||
|
console.log("[OAuth] 全局监听器已存在,跳过创建")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const unlisten = await listen<OAuthCallbackResult>("oauth-callback", async (event) => {
|
const unlisten = await listen<OAuthCallbackResult>("oauth-callback", async (event) => {
|
||||||
console.log("[OAuth] Event listener 收到 payload:", event.payload)
|
console.log("[OAuth] Event listener 收到 payload:", event.payload)
|
||||||
if (event.payload) {
|
if (event.payload) {
|
||||||
// 立即取消监听,防止重复触发
|
|
||||||
if (callbackUnlistenRef.current) {
|
|
||||||
callbackUnlistenRef.current()
|
|
||||||
callbackUnlistenRef.current = null
|
|
||||||
}
|
|
||||||
await handleOAuthCallback(event.payload)
|
await handleOAuthCallback(event.payload)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
callbackUnlistenRef.current = unlisten
|
globalUnlisten = unlisten
|
||||||
|
console.log("[OAuth] 全局监听器已创建")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to setup OAuth callback listener:", error)
|
console.error("Failed to setup OAuth callback listener:", error)
|
||||||
}
|
}
|
||||||
@@ -140,12 +178,9 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
|
|
||||||
setupListener()
|
setupListener()
|
||||||
|
|
||||||
return () => {
|
// 组件卸载时不取消监听,保持全局监听
|
||||||
if (callbackUnlistenRef.current) {
|
// 只在应用完全关闭时才清理
|
||||||
callbackUnlistenRef.current()
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
callbackUnlistenRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleOAuthLogin = async () => {
|
const handleOAuthLogin = async () => {
|
||||||
@@ -183,6 +218,10 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 存储 code_verifier 用于后续换取 token
|
||||||
|
sessionStorage.setItem("oauth_code_verifier", urlRes.data.code_verifier)
|
||||||
|
console.log("[OAuth] code_verifier 已存储")
|
||||||
|
|
||||||
await open(urlRes.data.url)
|
await open(urlRes.data.url)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.message || "登录失败")
|
message.error(error.message || "登录失败")
|
||||||
|
|||||||
@@ -437,6 +437,7 @@ const api = {
|
|||||||
data: {
|
data: {
|
||||||
url: string
|
url: string
|
||||||
state: string
|
state: string
|
||||||
|
code_verifier: string
|
||||||
}
|
}
|
||||||
message?: string
|
message?: string
|
||||||
}> => invoke("oauth_get_authorization_url", { platformId, callbackUrl, state }),
|
}> => invoke("oauth_get_authorization_url", { platformId, callbackUrl, state }),
|
||||||
@@ -444,7 +445,8 @@ const api = {
|
|||||||
code: string,
|
code: string,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
platformSecret: string,
|
platformSecret: string,
|
||||||
callbackUrl: string
|
callbackUrl: string,
|
||||||
|
codeVerifier: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data: {
|
data: {
|
||||||
@@ -454,7 +456,8 @@ const api = {
|
|||||||
expires_in: number
|
expires_in: number
|
||||||
}
|
}
|
||||||
message?: string
|
message?: string
|
||||||
}> => invoke("oauth_exchange_code", { code, platformId, platformSecret, callbackUrl }),
|
}> =>
|
||||||
|
invoke("oauth_exchange_code", { code, platformId, platformSecret, callbackUrl, codeVerifier }),
|
||||||
oauthGetUserInfo: (
|
oauthGetUserInfo: (
|
||||||
accessToken: string
|
accessToken: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
|
|||||||
Reference in New Issue
Block a user