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 url: String,
pub state: String,
pub code_verifier: 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]
pub async fn oauth_get_authorization_url(
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!(
"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,
urlencoding::encode(&callback_url),
urlencoding::encode(&state)
urlencoding::encode(&state),
urlencoding::encode(&code_challenge)
);
Ok(IpcResponse::success(OAuthAuthorizationUrlResponse {
url,
state,
code_verifier,
}))
}
@@ -360,6 +390,7 @@ pub async fn oauth_exchange_code(
platform_id: String,
platform_secret: String,
callback_url: String,
code_verifier: String,
state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<IpcResponse<OAuthTokenResponse>, String> {
println!(
@@ -375,6 +406,7 @@ pub async fn oauth_exchange_code(
"client_id": &platform_id,
"client_secret": &platform_secret,
"redirect_uri": &callback_url,
"code_verifier": &code_verifier,
});
println!("[OAuth] 请求参数:{:?}", payload);
@@ -485,6 +517,11 @@ pub async fn oauth_get_user_info(
access_token: String,
state: State<'_, Arc<RwLock<AppState>>>,
) -> Result<IpcResponse<OAuthUserInfo>, String> {
println!(
"[OAuth] 获取用户信息 - access_token: {}...",
&access_token[..20.min(access_token.len())]
);
let state_guard = state.read();
let client = &state_guard.http_client;
@@ -495,17 +532,36 @@ pub async fn oauth_get_user_info(
.await
.map_err(|e| format!("Request failed: {}", e))?;
println!("[OAuth] 用户信息响应状态: {}", response.status());
if !response.status().is_success() {
let error_text = response
.text()
.await
.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
.json()
let response_text = response
.text()
.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))?;
Ok(IpcResponse::success(user_info))
+11 -4
View File
@@ -1567,7 +1567,9 @@ pub async fn apply_offline_backfill(
}
requested_runs_by_rule
.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);
}
if requested_runs_by_rule.is_empty() {
@@ -1610,7 +1612,8 @@ pub async fn apply_offline_backfill(
changed = true;
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.affected_students += stats.affected_students;
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 {
let today_runs = execution_batches
.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;
if today_runs >= max_runs {
return Ok(RuleExecutionStats::default());
@@ -1700,7 +1705,9 @@ async fn execute_rule(
let mut daily_score_delta_used: i64 = execution_batches
.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())
.sum();
+10 -4
View File
@@ -73,7 +73,9 @@ interface BackfillPlanItem {
ruleName: string
}
const buildOfflineBackfillPlan = (rules: AutoScoreRule[]): {
const buildOfflineBackfillPlan = (
rules: AutoScoreRule[]
): {
items: BackfillPlanItem[]
totalRuns: number
from: dayjs.Dayjs | null
@@ -87,12 +89,15 @@ const buildOfflineBackfillPlan = (rules: AutoScoreRule[]): {
for (const rule of rules) {
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
const intervalValue = parseIntervalTriggerValue(intervalTrigger.value)
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
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")
}
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) {
return t("autoScore.startAtHint")
}
-2
View File
@@ -76,7 +76,6 @@ export const Leaderboard: React.FC = () => {
}
const handleExport = () => {
setTimeout(() => {
const title =
timeRange === "today"
? t("leaderboard.today")
@@ -129,7 +128,6 @@ export const Leaderboard: React.FC = () => {
document.body.removeChild(link)
URL.revokeObjectURL(url)
messageApi.success(t("leaderboard.exportSuccess"))
}, 0)
}
const columns: ColumnsType<studentRank> = [
+54 -15
View File
@@ -1,5 +1,5 @@
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 { open } from "@tauri-apps/plugin-shell"
import { listen, UnlistenFn } from "@tauri-apps/api/event"
@@ -28,10 +28,14 @@ interface OAuthCallbackResult {
error_description?: string
}
// 全局锁,确保同一时间只有一个回调在处理
let isProcessingCallback = false
// 全局监听器引用,确保只有一个监听器
let globalUnlisten: UnlistenFn | null = null
export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const callbackUnlistenRef = useRef<UnlistenFn | null>(null)
// 使用 sessionStorage 存储 state,防止组件重新渲染导致丢失
const getExpectedState = () => sessionStorage.getItem("oauth_expected_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) => {
// 全局锁检查,防止重复处理
if (isProcessingCallback) {
console.log("[OAuth] 回调正在处理中,跳过")
return
}
console.log("[OAuth] 收到回调:", result)
const config = getOAuthConfig()
if (!config) {
@@ -63,6 +73,8 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
}
try {
isProcessingCallback = true
if (result.error) {
console.error("[OAuth] 错误:", result.error, result.error_description)
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 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(
result.code,
config.platform_id,
config.platform_secret,
callbackUrl
callbackUrl,
codeVerifier
)
console.log("[OAuth] token 响应:", tokenRes)
if (!tokenRes.success) {
message.error(tokenRes.message || "获取访问令牌失败")
@@ -98,7 +121,9 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
return
}
console.log("[OAuth] 开始获取用户信息...")
const userRes = await api.oauthGetUserInfo(tokenRes.data.access_token)
console.log("[OAuth] 用户信息响应:", userRes)
if (!userRes.success) {
message.error(userRes.message || "获取用户信息失败")
@@ -106,33 +131,46 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
return
}
console.log("[OAuth] 登录成功,清理资源...")
await api.oauthStopCallbackServer()
clearExpectedState()
sessionStorage.removeItem("oauth_code_verifier")
console.log("[OAuth] 调用 onSuccess...")
onSuccess(userRes.data)
console.log("[OAuth] 调用 onClose...")
onClose()
}
} catch (error: any) {
console.error("[OAuth] 处理回调时发生错误:", error)
message.error(error.message || "登录失败")
} finally {
console.log("[OAuth] 回调处理完成,重置状态")
setLoading(false)
// 延迟释放锁,确保其他可能的重复事件被忽略
setTimeout(() => {
isProcessingCallback = false
console.log("[OAuth] 全局锁已释放")
}, 1000)
}
}
useEffect(() => {
const setupListener = async () => {
// 如果已经有全局监听器,不再创建新的
if (globalUnlisten) {
console.log("[OAuth] 全局监听器已存在,跳过创建")
return
}
try {
const unlisten = await listen<OAuthCallbackResult>("oauth-callback", async (event) => {
console.log("[OAuth] Event listener 收到 payload:", event.payload)
if (event.payload) {
// 立即取消监听,防止重复触发
if (callbackUnlistenRef.current) {
callbackUnlistenRef.current()
callbackUnlistenRef.current = null
}
await handleOAuthCallback(event.payload)
}
})
callbackUnlistenRef.current = unlisten
globalUnlisten = unlisten
console.log("[OAuth] 全局监听器已创建")
} catch (error) {
console.error("Failed to setup OAuth callback listener:", error)
}
@@ -140,12 +178,9 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
setupListener()
return () => {
if (callbackUnlistenRef.current) {
callbackUnlistenRef.current()
callbackUnlistenRef.current = null
}
}
// 组件卸载时不取消监听,保持全局监听
// 只在应用完全关闭时才清理
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleOAuthLogin = async () => {
@@ -183,6 +218,10 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
return
}
// 存储 code_verifier 用于后续换取 token
sessionStorage.setItem("oauth_code_verifier", urlRes.data.code_verifier)
console.log("[OAuth] code_verifier 已存储")
await open(urlRes.data.url)
} catch (error: any) {
message.error(error.message || "登录失败")
+5 -2
View File
@@ -437,6 +437,7 @@ const api = {
data: {
url: string
state: string
code_verifier: string
}
message?: string
}> => invoke("oauth_get_authorization_url", { platformId, callbackUrl, state }),
@@ -444,7 +445,8 @@ const api = {
code: string,
platformId: string,
platformSecret: string,
callbackUrl: string
callbackUrl: string,
codeVerifier: string
): Promise<{
success: boolean
data: {
@@ -454,7 +456,8 @@ const api = {
expires_in: number
}
message?: string
}> => invoke("oauth_exchange_code", { code, platformId, platformSecret, callbackUrl }),
}> =>
invoke("oauth_exchange_code", { code, platformId, platformSecret, callbackUrl, codeVerifier }),
oauthGetUserInfo: (
accessToken: string
): Promise<{