fix: P1/P2 优化 - OAuth验证 + API响应 + 缓存击穿 + Webhook关闭

P1 - OAuth auth_url origin 验证:
- 添加 validateOAuthUrl() 函数验证 OAuth URL origin
- 仅允许同源或可信 OAuth 提供商
- LoginPage 和 ProfileSecurityPage 调用前验证

P2 - API 响应运行时类型验证:
- 添加 isApiResponse() 运行时验证函数
- parseJsonResponse 验证响应结构完整性

P2 - 缓存击穿防护 (singleflight):
- AuthMiddleware.isJTIBlacklisted 使用 singleflight.Group
- 防止 L1 miss 时并发请求同时打 L2

P2 - Webhook 服务优雅关闭:
- WebhookService 添加 Shutdown() 方法
- 服务器关闭时等待 worker 完成
- main.go 集成 shutdown 调用
This commit is contained in:
2026-04-03 21:50:51 +08:00
parent 10d126ee12
commit 3ae11237ab
8 changed files with 176 additions and 42 deletions

View File

@@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest'
import { afterAll, describe, expect, it } from 'vitest'
import {
buildOAuthCallbackReturnTo,
parseOAuthCallbackHash,
sanitizeAuthRedirect,
validateOAuthUrl,
} from './oauth'
describe('oauth auth helpers', () => {
@@ -26,4 +27,40 @@ describe('oauth auth helpers', () => {
message: '',
})
})
describe('validateOAuthUrl', () => {
const originalOrigin = window.location.origin
afterAll(() => {
// 恢复原始 origin
Object.defineProperty(window, 'location', {
value: { origin: originalOrigin },
writable: true,
})
})
it('allows same-origin URLs', () => {
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost:3000' },
writable: true,
})
expect(validateOAuthUrl('http://localhost:3000/api/v1/auth/oauth/authorize')).toBe(true)
})
it('allows trusted OAuth provider origins', () => {
expect(validateOAuthUrl('https://github.com/login/oauth/authorize')).toBe(true)
expect(validateOAuthUrl('https://google.com/oauth/authorize')).toBe(true)
expect(validateOAuthUrl('https://facebook.com/v1.0/oauth/authorize')).toBe(true)
})
it('rejects untrusted origins', () => {
expect(validateOAuthUrl('https://evil.example.com/oauth/authorize')).toBe(false)
expect(validateOAuthUrl('https://attacker.com/callback')).toBe(false)
})
it('rejects invalid URLs', () => {
expect(validateOAuthUrl('not-a-url')).toBe(false)
expect(validateOAuthUrl('')).toBe(false)
})
})
})

View File

@@ -6,6 +6,46 @@ export function sanitizeAuthRedirect(target: string | null | undefined, fallback
return value
}
// 可信的 OAuth 提供商 origin 白名单
const TRUSTED_OAUTH_ORIGINS = new Set([
// 社交登录提供商
'https://github.com',
'https://google.com',
'https://facebook.com',
'https://twitter.com',
'https://apple.com',
'https://weixin.qq.com',
'https://qq.com',
'https://alipay.com',
'https://douyin.com',
])
/**
* 验证 OAuth 授权 URL 的 origin 是否可信
* 防止开放重定向攻击
*/
export function validateOAuthUrl(authUrl: string): boolean {
try {
const url = new URL(authUrl)
// 允许同源(当前应用自身作为 OAuth 提供者的情况)
if (url.origin === window.location.origin) {
return true
}
// 检查是否在可信 origin 白名单中
if (TRUSTED_OAUTH_ORIGINS.has(url.origin)) {
return true
}
// 拒绝所有其他 origin
return false
} catch {
// 无效的 URL 格式
return false
}
}
export function buildOAuthCallbackReturnTo(redirectPath: string): string {
const callbackUrl = new URL('/login/oauth/callback', window.location.origin)
if (redirectPath && redirectPath !== '/dashboard') {

View File

@@ -85,7 +85,41 @@ function createTimeoutSignal(signal?: AbortSignal): { signal: AbortSignal; clean
}
async function parseJsonResponse<T>(response: Response): Promise<ApiResponse<T>> {
return response.json() as Promise<ApiResponse<T>>
const raw = await response.json()
// 运行时验证响应结构
if (!isApiResponse(raw)) {
throw new Error('Invalid API response structure: missing required fields')
}
return raw as ApiResponse<T>
}
/**
* 运行时验证 API 响应结构
* 防止后端返回异常格式时导致运行时错误
*/
function isApiResponse(obj: unknown): obj is ApiResponse<unknown> {
if (typeof obj !== 'object' || obj === null) {
return false
}
const r = obj as Record<string, unknown>
// 必须有 code 字段且为数字
if (typeof r.code !== 'number') {
return false
}
// 必须有 message 字段且为字符串
if (typeof r.message !== 'string') {
return false
}
// 如果有 data 字段,应该存在
// (data 可以是 undefined/null/任何类型,但我们允许这些值)
return true
}
async function refreshAccessToken(): Promise<TokenBundle> {

View File

@@ -31,7 +31,8 @@ import type { RcFile } from 'antd/es/upload'
import dayjs from 'dayjs'
import { useAuth } from '@/app/providers/auth-context'
import { getErrorMessage } from '@/lib/errors'
import { parseOAuthCallbackHash } from '@/lib/auth/oauth'
import { parseOAuthCallbackHash, validateOAuthUrl } from '@/lib/auth/oauth'
import { getDeviceFingerprint } from '@/lib/device-fingerprint'
import { PageLayout, ContentCard } from '@/components/layout'
import { PageHeader } from '@/components/common'
import { getAuthCapabilities } from '@/services/auth'
@@ -198,6 +199,11 @@ export function ProfileSecurityPage() {
totp_code: values.totp_code?.trim() || undefined,
})
// 验证 OAuth URL origin 防止开放重定向攻击
if (!validateOAuthUrl(result.auth_url)) {
throw new Error('Invalid OAuth authorization URL')
}
setBindVisible(false)
setActiveProvider(null)
bindSocialForm.resetFields()
@@ -306,11 +312,8 @@ export function ProfileSecurityPage() {
// If "remember device" is checked, trust the current device
if (totpRememberDevice) {
try {
const stored = localStorage.getItem('device_fingerprint')
if (stored) {
const deviceInfo = JSON.parse(stored)
await trustDeviceByDeviceId(deviceInfo.device_id, '30d')
}
const deviceInfo = getDeviceFingerprint()
await trustDeviceByDeviceId(deviceInfo.device_id, '30d')
} catch {
// Non-critical: device trust failed, but TOTP was enabled
}

View File

@@ -11,8 +11,9 @@ import {
import { useAuth } from '@/app/providers/auth-context'
import { AuthLayout } from '@/layouts'
import { buildOAuthCallbackReturnTo, sanitizeAuthRedirect } from '@/lib/auth/oauth'
import { buildOAuthCallbackReturnTo, sanitizeAuthRedirect, validateOAuthUrl } from '@/lib/auth/oauth'
import { getErrorMessage, isFormValidationError } from '@/lib/errors'
import { getDeviceFingerprint } from '@/lib/device-fingerprint'
import {
getAuthCapabilities,
getOAuthAuthorizationUrl,
@@ -52,34 +53,6 @@ type SmsCodeFormValues = {
code: string
}
// 构建设备指纹
function buildDeviceFingerprint(): { device_id: string; device_name: string; device_browser: string; device_os: string } {
const ua = navigator.userAgent
let browser = 'Unknown'
let os = 'Unknown'
if (ua.includes('Chrome')) browser = 'Chrome'
else if (ua.includes('Firefox')) browser = 'Firefox'
else if (ua.includes('Safari')) browser = 'Safari'
else if (ua.includes('Edge')) browser = 'Edge'
if (ua.includes('Windows')) os = 'Windows'
else if (ua.includes('Mac')) os = 'macOS'
else if (ua.includes('Linux')) os = 'Linux'
else if (ua.includes('Android')) os = 'Android'
else if (ua.includes('iOS')) os = 'iOS'
// 使用随机ID作为设备唯一标识
const deviceId = `${browser}-${os}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
return {
device_id: deviceId,
device_name: `${browser} on ${os}`,
device_browser: browser,
device_os: os,
}
}
export function LoginPage() {
const [activeTab, setActiveTab] = useState('password')
const [loading, setLoading] = useState(false)
@@ -165,6 +138,10 @@ export function LoginPage() {
provider,
buildOAuthCallbackReturnTo(redirect),
)
// 验证 OAuth URL origin 防止开放重定向攻击
if (!validateOAuthUrl(result.auth_url)) {
throw new Error('Invalid OAuth authorization URL')
}
window.location.assign(result.auth_url)
} catch (error) {
message.error(getErrorMessage(error, '启动第三方登录失败'))
@@ -175,9 +152,7 @@ export function LoginPage() {
const handlePasswordLogin = useCallback(async (values: LoginFormValues) => {
setLoading(true)
try {
const deviceInfo = buildDeviceFingerprint()
// Store device info for "remember device" feature on TOTP enable
localStorage.setItem('device_fingerprint', JSON.stringify(deviceInfo))
const deviceInfo = getDeviceFingerprint()
const tokenBundle = await loginByPassword({
username: values.username,
password: values.password,