mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 13:59:03 +08:00
feat(oauth): 实现 OAuth 回调服务器与完整令牌管理功能
- 新增 OAuth 回调服务器模块,支持本地 HTTP 服务器处理授权回调 - 添加令牌撤销和令牌内省 API 端点 - 重构 OAuth 授权流程,增加 state 参数防止 CSRF 攻击 - 改进前端 OAuth 登录组件,适配新的回调机制
This commit is contained in:
@@ -107,7 +107,9 @@ export const ActionEditor: React.FC<ActionEditorProps> = ({
|
||||
allowClear
|
||||
style={{ minWidth: 260 }}
|
||||
placeholder={t("autoScore.tagNamePlaceholder")}
|
||||
value={Array.isArray(action.value) ? action.value : action.value ? [action.value] : []}
|
||||
value={
|
||||
Array.isArray(action.value) ? action.value : action.value ? [action.value] : []
|
||||
}
|
||||
disabled={!canEdit}
|
||||
options={mergedTagOptions}
|
||||
onChange={(nextValue) => updateAction(action.id, { value: nextValue })}
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
type JsonRule,
|
||||
} from "@react-awesome-query-builder/antd"
|
||||
import type { TFunction } from "i18next"
|
||||
import { IntervalValueWidget, parseIntervalTriggerValue, stringifyIntervalTriggerValue } from "./IntervalValueWidget"
|
||||
import {
|
||||
IntervalValueWidget,
|
||||
parseIntervalTriggerValue,
|
||||
stringifyIntervalTriggerValue,
|
||||
} from "./IntervalValueWidget"
|
||||
|
||||
export interface AutoScoreTrigger {
|
||||
event: string
|
||||
@@ -76,9 +80,7 @@ const toStringValue = (value: unknown): string => {
|
||||
}
|
||||
|
||||
const normalizeTagValues = (values: unknown[]): string[] => {
|
||||
const normalized = values
|
||||
.map((value) => toStringValue(value).trim())
|
||||
.filter(Boolean)
|
||||
const normalized = values.map((value) => toStringValue(value).trim()).filter(Boolean)
|
||||
|
||||
return Array.from(new Set(normalized))
|
||||
}
|
||||
@@ -265,7 +267,10 @@ export const createTriggerQueryConfig = (t: TFunction, tagOptions: AutoScoreTagO
|
||||
export const createEmptyTriggerTree = (config: Config): ImmutableTree =>
|
||||
QbUtils.checkTree(QbUtils.loadTree(buildEmptyGroup()), config)
|
||||
|
||||
export const triggersToQueryTree = (config: Config, triggers: AutoScoreTrigger[]): ImmutableTree => {
|
||||
export const triggersToQueryTree = (
|
||||
config: Config,
|
||||
triggers: AutoScoreTrigger[]
|
||||
): ImmutableTree => {
|
||||
const children = triggers.map(ruleFromTrigger).filter((item): item is JsonRule => Boolean(item))
|
||||
const group: JsonGroup = {
|
||||
...buildEmptyGroup(),
|
||||
@@ -282,7 +287,8 @@ export const queryTreeToTriggers = (tree: ImmutableTree, config: Config): AutoSc
|
||||
}
|
||||
|
||||
const hasUnsupportedLogicInGroup = (group: JsonGroup): boolean => {
|
||||
const conjunction = typeof group.properties?.conjunction === "string" ? group.properties.conjunction : "AND"
|
||||
const conjunction =
|
||||
typeof group.properties?.conjunction === "string" ? group.properties.conjunction : "AND"
|
||||
const not = Boolean(group.properties?.not)
|
||||
|
||||
if (conjunction !== "AND" || not) {
|
||||
|
||||
@@ -48,7 +48,11 @@ export const TriggerRuleBuilder: React.FC<TriggerRuleBuilderProps> = ({
|
||||
>
|
||||
<div
|
||||
className="query-builder-container"
|
||||
style={{ overflowX: "auto", pointerEvents: canEdit ? "auto" : "none", opacity: canEdit ? 1 : 0.7 }}
|
||||
style={{
|
||||
overflowX: "auto",
|
||||
pointerEvents: canEdit ? "auto" : "none",
|
||||
opacity: canEdit ? 1 : 0.7,
|
||||
}}
|
||||
>
|
||||
<Query
|
||||
{...queryConfig}
|
||||
|
||||
@@ -436,7 +436,12 @@ function AutoScoreManager({ canEdit }: AutoScoreManagerProps): React.JSX.Element
|
||||
/>
|
||||
|
||||
<div style={{ marginBottom: "24px", display: "flex", gap: "12px" }}>
|
||||
<Button type="primary" loading={saving} disabled={!canEdit} onClick={() => handleSubmit().catch(() => void 0)}>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={saving}
|
||||
disabled={!canEdit}
|
||||
onClick={() => handleSubmit().catch(() => void 0)}
|
||||
>
|
||||
{editingRuleId === null ? t("autoScore.addAutomation") : t("autoScore.updateAutomation")}
|
||||
</Button>
|
||||
<Button disabled={!canEdit || saving} onClick={resetEditor}>
|
||||
|
||||
@@ -19,13 +19,20 @@ interface OAuthLoginProps {
|
||||
interface OAuthConfig {
|
||||
platform_id: string
|
||||
platform_secret: string
|
||||
callback_url: string
|
||||
}
|
||||
|
||||
interface OAuthCallbackResult {
|
||||
code?: string
|
||||
state?: string
|
||||
error?: string
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const deepLinkUnlistenRef = useRef<UnlistenFn | null>(null)
|
||||
const callbackUnlistenRef = useRef<UnlistenFn | null>(null)
|
||||
const expectedStateRef = useRef<string | null>(null)
|
||||
|
||||
const getOAuthConfig = (): OAuthConfig | null => {
|
||||
const api = (window as any).api
|
||||
@@ -33,7 +40,6 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
|
||||
const platformId = import.meta.env.VITE_OAUTH_PLATFORM_ID
|
||||
const platformSecret = import.meta.env.VITE_OAUTH_PLATFORM_SECRET
|
||||
const callbackUrl = import.meta.env.VITE_OAUTH_CALLBACK_URL || "secscore://oauth/callback"
|
||||
|
||||
if (!platformId || !platformSecret) {
|
||||
return null
|
||||
@@ -42,32 +48,44 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
return {
|
||||
platform_id: platformId,
|
||||
platform_secret: platformSecret,
|
||||
callback_url: callbackUrl,
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeepLink = async (url: string) => {
|
||||
const handleOAuthCallback = async (result: OAuthCallbackResult) => {
|
||||
console.log("[OAuth] 收到回调:", result)
|
||||
const config = getOAuthConfig()
|
||||
if (!config) return
|
||||
if (!config) {
|
||||
console.error("[OAuth] 配置不存在")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const code = urlObj.searchParams.get("code")
|
||||
const error = urlObj.searchParams.get("error")
|
||||
|
||||
if (error) {
|
||||
message.error(decodeURIComponent(error))
|
||||
if (result.error) {
|
||||
console.error("[OAuth] 错误:", result.error, result.error_description)
|
||||
message.error(result.error_description || result.error || "授权失败")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (code) {
|
||||
if (result.code) {
|
||||
console.log("[OAuth] 授权码:", result.code)
|
||||
console.log("[OAuth] State:", result.state, "期望:", expectedStateRef.current)
|
||||
|
||||
// 验证 state 防止 CSRF
|
||||
if (expectedStateRef.current && result.state !== expectedStateRef.current) {
|
||||
message.error("安全验证失败:state 不匹配")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const api = (window as any).api
|
||||
const callbackUrl = "http://127.0.0.1:16888/oauth/callback"
|
||||
|
||||
const tokenRes = await api.oauthExchangeCode(
|
||||
code,
|
||||
result.code,
|
||||
config.platform_id,
|
||||
config.platform_secret,
|
||||
config.callback_url
|
||||
callbackUrl
|
||||
)
|
||||
|
||||
if (!tokenRes.success) {
|
||||
@@ -84,6 +102,8 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
return
|
||||
}
|
||||
|
||||
await api.oauthStopCallbackServer()
|
||||
expectedStateRef.current = null
|
||||
onSuccess(userRes.data)
|
||||
onClose()
|
||||
}
|
||||
@@ -95,28 +115,29 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const setupDeepLink = async () => {
|
||||
const setupListener = async () => {
|
||||
try {
|
||||
const unlisten = await listen<string>("deep-link://new-url", (event) => {
|
||||
const unlisten = await listen<OAuthCallbackResult>("oauth-callback", async (event) => {
|
||||
console.log("[OAuth] Event listener 收到 payload:", event.payload)
|
||||
if (event.payload) {
|
||||
handleDeepLink(event.payload)
|
||||
await handleOAuthCallback(event.payload)
|
||||
}
|
||||
})
|
||||
deepLinkUnlistenRef.current = unlisten
|
||||
callbackUnlistenRef.current = unlisten
|
||||
} catch (error) {
|
||||
console.error("Failed to setup deep link listener:", error)
|
||||
console.error("Failed to setup OAuth callback listener:", error)
|
||||
}
|
||||
}
|
||||
|
||||
setupDeepLink()
|
||||
setupListener()
|
||||
|
||||
return () => {
|
||||
if (deepLinkUnlistenRef.current) {
|
||||
deepLinkUnlistenRef.current()
|
||||
deepLinkUnlistenRef.current = null
|
||||
if (callbackUnlistenRef.current) {
|
||||
callbackUnlistenRef.current()
|
||||
callbackUnlistenRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, []) // handleOAuthCallback 在依赖数组外,使用 ref 存储
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
const config = getOAuthConfig()
|
||||
@@ -129,7 +150,23 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
|
||||
try {
|
||||
const api = (window as any).api
|
||||
const urlRes = await api.oauthGetAuthorizationUrl(config.platform_id, config.callback_url)
|
||||
|
||||
const serverRes = await api.oauthStartCallbackServer()
|
||||
if (!serverRes.success) {
|
||||
message.error(serverRes.message || "启动回调服务器失败")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const callbackUrl = serverRes.data.url
|
||||
|
||||
// 生成随机 state 防止 CSRF
|
||||
const state = generateRandomState()
|
||||
console.log("[OAuth] 生成 state:", state)
|
||||
expectedStateRef.current = state
|
||||
console.log("[OAuth] state 已设置:", expectedStateRef.current)
|
||||
|
||||
const urlRes = await api.oauthGetAuthorizationUrl(config.platform_id, callbackUrl, state)
|
||||
|
||||
if (!urlRes.success) {
|
||||
message.error(urlRes.message || "获取授权链接失败")
|
||||
@@ -137,13 +174,23 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
return
|
||||
}
|
||||
|
||||
await open(urlRes.data)
|
||||
await open(urlRes.data.url)
|
||||
} catch (error: any) {
|
||||
message.error(error.message || "登录失败")
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成随机 state 字符串
|
||||
const generateRandomState = (): string => {
|
||||
const array = new Uint8Array(32)
|
||||
window.crypto.getRandomValues(array)
|
||||
return btoa(String.fromCharCode(...array))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "")
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("auth.oauthLogin", "SECTL Auth 登录")}
|
||||
@@ -159,13 +206,13 @@ export function OAuthLogin({ visible, onClose, onSuccess }: OAuthLoginProps) {
|
||||
<Spin size="large" />
|
||||
<div>{t("auth.oauthLoggingIn", "正在登录...")}</div>
|
||||
<div style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>
|
||||
请在浏览器中完成授权
|
||||
请在浏览器中完成授权,授权后会自动返回
|
||||
</div>
|
||||
</Space>
|
||||
) : (
|
||||
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||
<div style={{ color: "var(--ss-text-secondary)" }}>
|
||||
{t("auth.oauthHint", "使用 SECTL Auth 账号登录,享受统一认证和远程退登功能")}
|
||||
{t("auth.oauthHint", "使用 SECTL Auth 账号登录,享受统一认证和远程退登功能")}
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
|
||||
@@ -92,7 +92,9 @@ const mergeFontOptions = (options: FontOption[]): FontOption[] => {
|
||||
|
||||
const findFontOption = (options: FontOption[], value?: string): FontOption | undefined => {
|
||||
if (!value) return options.find((item) => item.value === "system") || options[0]
|
||||
return options.find((item) => item.value === value) || options.find((item) => item.value === "system")
|
||||
return (
|
||||
options.find((item) => item.value === value) || options.find((item) => item.value === "system")
|
||||
)
|
||||
}
|
||||
|
||||
const applyFontFamily = (fontFamily: string) => {
|
||||
|
||||
+68
-5
@@ -286,7 +286,9 @@ const api = {
|
||||
actions: autoScoreAction[]
|
||||
}): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||
invoke("auto_score_update_rule", { rule }),
|
||||
autoScoreDeleteRule: (ruleId: number): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||
autoScoreDeleteRule: (
|
||||
ruleId: number
|
||||
): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||
invoke("auto_score_delete_rule", { ruleId }),
|
||||
autoScoreToggleRule: (params: {
|
||||
ruleId: number
|
||||
@@ -300,7 +302,9 @@ const api = {
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("auto_score_get_status"),
|
||||
autoScoreSortRules: (ruleIds: number[]): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||
autoScoreSortRules: (
|
||||
ruleIds: number[]
|
||||
): Promise<{ success: boolean; data?: boolean; message?: string }> =>
|
||||
invoke("auto_score_sort_rules", { ruleIds }),
|
||||
|
||||
// Settings & Sync
|
||||
@@ -353,9 +357,16 @@ const api = {
|
||||
// OAuth
|
||||
oauthGetAuthorizationUrl: (
|
||||
platformId: string,
|
||||
callbackUrl: string
|
||||
): Promise<{ success: boolean; data: string; message?: string }> =>
|
||||
invoke("oauth_get_authorization_url", { platformId, callbackUrl }),
|
||||
callbackUrl: string,
|
||||
state?: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
url: string
|
||||
state: string
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("oauth_get_authorization_url", { platformId, callbackUrl, state }),
|
||||
oauthExchangeCode: (
|
||||
code: string,
|
||||
platformId: string,
|
||||
@@ -398,6 +409,58 @@ const api = {
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("oauth_refresh_token", { refreshToken, platformId, platformSecret }),
|
||||
oauthRevokeToken: (
|
||||
token: string,
|
||||
tokenTypeHint: string | null,
|
||||
platformId: string,
|
||||
platformSecret: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}> =>
|
||||
invoke("oauth_revoke_token", {
|
||||
token,
|
||||
tokenTypeHint,
|
||||
platformId,
|
||||
platformSecret,
|
||||
}),
|
||||
oauthIntrospectToken: (
|
||||
token: string,
|
||||
platformId: string,
|
||||
platformSecret: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
active: boolean
|
||||
scope?: string
|
||||
client_id?: string
|
||||
username?: string
|
||||
token_type?: string
|
||||
exp?: number
|
||||
iat?: number
|
||||
sub?: string
|
||||
aud?: string
|
||||
iss?: string
|
||||
}
|
||||
message?: string
|
||||
}> =>
|
||||
invoke("oauth_introspect_token", {
|
||||
token,
|
||||
platformId,
|
||||
platformSecret,
|
||||
}),
|
||||
oauthStartCallbackServer: (): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
url: string
|
||||
port: number
|
||||
}
|
||||
message?: string
|
||||
}> => invoke("oauth_start_callback_server"),
|
||||
oauthStopCallbackServer: (): Promise<{
|
||||
success: boolean
|
||||
message?: string
|
||||
}> => invoke("oauth_stop_callback_server"),
|
||||
|
||||
// Data import/export
|
||||
exportDataJson: (): Promise<{ success: boolean; data: string }> => invoke("data_export_json"),
|
||||
|
||||
Reference in New Issue
Block a user