feat(auth): 实现 OAuth PKCE 安全流程并优化错误处理

添加 PKCE code_verifier 和 code_challenge 支持以增强 OAuth 安全性
重构 OAuth 回调处理逻辑,添加全局锁防止重复处理
优化错误处理和信息打印,增加调试日志
移除不必要的 setTimeout 包装导出功能
格式化代码以提高可读性
This commit is contained in:
Yukino_fox
2026-04-12 17:01:32 +08:00
parent a2f82cb869
commit 98afd446c9
7 changed files with 188 additions and 79 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+61 -5
View File
@@ -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))
+11 -4
View File
@@ -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();
+10 -4
View File
@@ -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")
} }
+46 -48
View File
@@ -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> = [
+54 -15
View File
@@ -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 || "登录失败")
+5 -2
View File
@@ -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<{