diff --git a/config.example.yaml b/config.example.yaml index ac2c4924..eec32abc 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -109,6 +109,16 @@ worker: linuxDo: api_key: "" +# OpenAPI Risk +openapi_risk: + enabled: false + base_url: "https://audit.example.com" + username: "" + password: "" + cache_ttl_seconds: 3600 + prompt_risk_levels: [] + block_risk_levels: [] + # OpenTelemetry 配置 otel: sampling_rate: 0.1 # 采样率 0.0-1.0 diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 43757f1e..5e737868 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -3,6 +3,7 @@ import type {Metadata} from 'next'; import {Inter, Noto_Sans_SC} from 'next/font/google'; import {Toaster} from '@/components/ui/sonner'; import {ThemeProvider} from '@/components/common/layout/ThemeProvider'; +import {RiskBlockDialog} from '@/components/common/risk/risk-block-dialog'; import './globals.css'; // eslint-disable-next-line new-cap @@ -49,6 +50,7 @@ export default function RootLayout({ disableTransitionOnChange > {children} + diff --git a/frontend/components/common/risk/risk-block-dialog.tsx b/frontend/components/common/risk/risk-block-dialog.tsx new file mode 100644 index 00000000..b3f003b9 --- /dev/null +++ b/frontend/components/common/risk/risk-block-dialog.tsx @@ -0,0 +1,65 @@ +'use client'; + +import {useEffect, useState} from 'react'; +import {AlertTriangle} from 'lucide-react'; +import {RiskInfo, RiskInfoBox} from '@/components/common/risk/risk-info-box'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +const RISK_BLOCKED_EVENT = 'credit-risk-blocked'; + +function isRiskInfo(value: unknown): value is RiskInfo { + if (!value || typeof value !== 'object') return false; + const riskInfo = value as Partial; + return ( + typeof riskInfo.risk_level === 'string' && + Array.isArray(riskInfo.risk_labels) + ); +} + +export function RiskBlockDialog() { + const [riskInfo, setRiskInfo] = useState(null); + + useEffect(() => { + const handleRiskBlocked = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!isRiskInfo(detail)) return; + setRiskInfo(detail); + }; + + window.addEventListener(RISK_BLOCKED_EVENT, handleRiskBlocked); + return () => + window.removeEventListener(RISK_BLOCKED_EVENT, handleRiskBlocked); + }, []); + + return ( + + event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + className="gap-4 p-6 sm:max-w-md" + > + +
+ +
+ + 账号存在风险 + + + 因触发风控,账号暂时无法使用需登录的功能 + +
+ + +
+
+ ); +} diff --git a/frontend/components/common/risk/risk-info-box.tsx b/frontend/components/common/risk/risk-info-box.tsx new file mode 100644 index 00000000..f26393f5 --- /dev/null +++ b/frontend/components/common/risk/risk-info-box.tsx @@ -0,0 +1,47 @@ +import {Badge} from '@/components/ui/badge'; +import {cn} from '@/lib/utils'; + +export interface RiskInfo { + risk_level: string; + risk_labels: string[]; +} + +export function RiskInfoBox({ + riskInfo, + label = '风险等级', + labelClassName, +}: { + riskInfo: RiskInfo | null; + label?: string; + labelClassName?: string; +}) { + return ( +
+
+ + {label} + + {riskInfo?.risk_level || '未知'} +
+ +
+ {riskInfo?.risk_labels.length ? ( + riskInfo.risk_labels.map((label) => ( + + {label} + + )) + ) : ( + + 暂无风险标签详情 + + )} +
+
+ ); +} diff --git a/frontend/components/common/risk/risk-warning-toast.tsx b/frontend/components/common/risk/risk-warning-toast.tsx new file mode 100644 index 00000000..2237bda6 --- /dev/null +++ b/frontend/components/common/risk/risk-warning-toast.tsx @@ -0,0 +1,26 @@ +'use client'; + +import {toast} from 'sonner'; +import {RiskInfo, RiskInfoBox} from '@/components/common/risk/risk-info-box'; + +const RISK_TOAST_ID = 'credit-risk-warning'; + +export function showRiskWarningToast(riskInfo: RiskInfo) { + toast.custom( + () => ( +
+ +
+ ), + { + id: RISK_TOAST_ID, + duration: Infinity, + closeButton: false, + dismissible: false, + }, + ); +} diff --git a/frontend/lib/services/core/api-client.ts b/frontend/lib/services/core/api-client.ts index 822db3c3..876446fc 100644 --- a/frontend/lib/services/core/api-client.ts +++ b/frontend/lib/services/core/api-client.ts @@ -1,4 +1,5 @@ import axios, {AxiosError, AxiosResponse} from 'axios'; +import {showRiskWarningToast} from '@/components/common/risk/risk-warning-toast'; import {ApiError, ApiResponse} from './types'; /** @@ -13,6 +14,107 @@ const apiClient = axios.create({ }, }); +const RISK_LEVEL_HEADER = 'x-credit-risk-level'; +const RISK_LABELS_HEADER = 'x-credit-risk-labels'; +const RISK_BLOCKED_CODE = 'RISK_BLOCKED'; +const RISK_BLOCKED_EVENT = 'credit-risk-blocked'; + +interface RiskInfo { + risk_level: string; + risk_labels: string[]; +} + +class ApiClientError extends Error { + code?: string; + details?: unknown; + status?: number; + + constructor( + message: string, + code?: string, + details?: unknown, + status?: number, + ) { + super(message); + this.name = 'ApiError'; + this.code = code; + this.details = details; + this.status = status; + } +} + +function getHeader( + headers: AxiosResponse['headers'], + name: string, +): string | undefined { + const maybeAxiosHeaders = headers as { get?: (key: string) => unknown }; + const value = maybeAxiosHeaders.get?.(name); + if (typeof value === 'string') return value; + + const plainHeaders = headers as Record; + const lowerValue = plainHeaders[name.toLowerCase()]; + if (typeof lowerValue === 'string') return lowerValue; + + const directValue = plainHeaders[name]; + return typeof directValue === 'string' ? directValue : undefined; +} + +function decodeRiskLabels(value?: string): string[] { + if (!value || typeof window === 'undefined') return []; + + try { + const binary = window.atob(value); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + const json = new TextDecoder().decode(bytes); + const labels = JSON.parse(json); + return Array.isArray(labels) ? + labels.filter((label): label is string => typeof label === 'string') : + []; + } catch { + return []; + } +} + +function riskInfoFromHeaders( + headers: AxiosResponse['headers'], +): RiskInfo | null { + const riskLevel = getHeader(headers, RISK_LEVEL_HEADER); + if (!riskLevel) return null; + + return { + risk_level: riskLevel, + risk_labels: decodeRiskLabels(getHeader(headers, RISK_LABELS_HEADER)), + }; +} + +function riskInfoFromDetails(details: unknown): RiskInfo | null { + if (!details || typeof details !== 'object') return null; + + const riskLevel = + 'risk_level' in details ? + (details as { risk_level?: unknown }).risk_level : + undefined; + const riskLabels = + 'risk_labels' in details ? + (details as { risk_labels?: unknown }).risk_labels : + undefined; + if (typeof riskLevel !== 'string' || !riskLevel) return null; + + return { + risk_level: riskLevel, + risk_labels: Array.isArray(riskLabels) ? + riskLabels.filter((label): label is string => typeof label === 'string') : + [], + }; +} + +function showRiskBlockedDialog(riskInfo: RiskInfo): void { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(RISK_BLOCKED_EVENT, {detail: riskInfo}), + ); +} + /** * 请求拦截器 * 确保所有请求带上凭证 @@ -31,7 +133,10 @@ apiClient.interceptors.request.use( */ function initiateLogin(currentPath: string): Promise { // 防止循环重定向 - if (!currentPath.startsWith('/login') && !currentPath.startsWith('/callback')) { + if ( + !currentPath.startsWith('/login') && + !currentPath.startsWith('/callback') + ) { // 动态导入AuthService避免循环依赖 import('../auth/auth.service').then(({AuthService}) => { // 直接调用登录方法,传入当前路径作为重定向目标 @@ -48,17 +153,49 @@ function initiateLogin(currentPath: string): Promise { * 处理API响应和统一错误处理 */ apiClient.interceptors.response.use( - (response: AxiosResponse) => response, + (response: AxiosResponse) => { + const riskInfo = riskInfoFromHeaders(response.headers); + if (riskInfo) { + showRiskWarningToast(riskInfo); + } + return response; + }, (error: AxiosError) => { // 处理401未授权错误 if (error.response?.status === 401) { return initiateLogin(window.location.pathname); } + // 处理风控阻断 + if ( + error.response?.status === 403 && + error.response.data?.error_code === RISK_BLOCKED_CODE + ) { + const riskInfo = + riskInfoFromDetails(error.response.data.details) || + riskInfoFromHeaders(error.response.headers); + if (riskInfo) { + showRiskBlockedDialog(riskInfo); + } + + return Promise.reject( + new ApiClientError( + error.response.data?.error_msg || '账号存在风险', + RISK_BLOCKED_CODE, + error.response.data?.details, + 403, + ), + ); + } + // 处理后端返回的错误信息 if (error.response?.data?.error_msg) { - const apiError = new Error(error.response.data.error_msg); - apiError.name = 'ApiError'; + const apiError = new ApiClientError( + error.response.data.error_msg, + error.response.data.error_code, + error.response.data.details, + error.response.status, + ); return Promise.reject(apiError); } @@ -69,7 +206,9 @@ apiClient.interceptors.response.use( // 处理权限错误 if (error.response?.status === 403) { - return Promise.reject(new Error('权限不足')); + return Promise.reject( + new ApiClientError('权限不足', 'FORBIDDEN', undefined, 403), + ); } // 处理服务器错误 diff --git a/frontend/lib/services/core/types.ts b/frontend/lib/services/core/types.ts index a05c7b0f..67856cf8 100644 --- a/frontend/lib/services/core/types.ts +++ b/frontend/lib/services/core/types.ts @@ -26,6 +26,10 @@ export interface PaginatedResponse { export interface ApiError { /** 错误信息 */ error_msg: string; + /** 错误码 */ + error_code?: string; + /** 错误详情 */ + details?: unknown; } /** diff --git a/internal/apps/oauth/middlewares.go b/internal/apps/oauth/middlewares.go index 4a39f9a1..fa585185 100644 --- a/internal/apps/oauth/middlewares.go +++ b/internal/apps/oauth/middlewares.go @@ -87,6 +87,12 @@ func LoginRequired() gin.HandlerFunc { // set user info SetUserToContext(c, &user) + if risk, ok := checkOpenAPIUserRisk(ctx, user.ID); ok { + if blocked := applyOpenAPIUserRisk(c, risk); blocked { + return + } + } + // next c.Next() } diff --git a/internal/apps/oauth/risk.go b/internal/apps/oauth/risk.go new file mode 100644 index 00000000..1cc74376 --- /dev/null +++ b/internal/apps/oauth/risk.go @@ -0,0 +1,239 @@ +/* + * MIT License + * + * Copyright (c) 2025 linux.do + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package oauth + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/linux-do/cdk/internal/config" + "github.com/linux-do/cdk/internal/db" + "github.com/linux-do/cdk/internal/logger" + "github.com/linux-do/cdk/internal/utils" + "github.com/redis/go-redis/v9" +) + +const ( + openAPIRiskCacheKeyFormat = "openapi_risk:user:%d" + minOpenAPIRiskCacheTTL = time.Hour + + riskLevelHeader = "X-Credit-Risk-Level" + riskLabelsHeader = "X-Credit-Risk-Labels" + exposeHeader = "Access-Control-Expose-Headers" + + riskBlockedCode = "RISK_BLOCKED" + riskBlockedMsg = "账号存在风险" +) + +type openAPIUserRiskItem struct { + Label string `json:"label"` + Value string `json:"value"` +} + +type openAPIUserRiskResponse struct { + Risky bool `json:"risky"` + RiskLevel string `json:"risk_level"` + Risks []openAPIUserRiskItem `json:"risks"` +} + +type riskBlockDetails struct { + RiskLevel string `json:"risk_level"` + RiskLabels []string `json:"risk_labels"` +} + +func checkOpenAPIUserRisk(ctx context.Context, userID uint64) (*openAPIUserRiskResponse, bool) { + cfg := config.Config.OpenAPIRisk + if !cfg.Enabled || strings.TrimSpace(cfg.BaseURL) == "" { + return nil, false + } + if db.Redis == nil { + logger.ErrorF(ctx, "[OpenAPIRisk] redis is not initialized, skip risk check") + return nil, false + } + + cacheKey := fmt.Sprintf(openAPIRiskCacheKeyFormat, userID) + var cached openAPIUserRiskResponse + if value, err := db.Redis.Get(ctx, cacheKey).Result(); err == nil { + if err = json.Unmarshal([]byte(value), &cached); err == nil { + return &cached, true + } + logger.ErrorF(ctx, "[OpenAPIRisk] decode cache failed, skip risk check: %v", err) + return nil, false + } else if err != nil && !errors.Is(err, redis.Nil) { + logger.ErrorF(ctx, "[OpenAPIRisk] read cache failed, skip risk check: %v", err) + return nil, false + } + + risk, err := fetchOpenAPIUserRisk(ctx, userID) + if err != nil { + logger.ErrorF(ctx, "[OpenAPIRisk] fetch user risk failed, skip risk check: %v", err) + return nil, false + } + + payload, err := json.Marshal(risk) + if err != nil { + logger.ErrorF(ctx, "[OpenAPIRisk] encode cache failed, skip risk check: %v", err) + return nil, false + } + if err = db.Redis.Set(ctx, cacheKey, payload, openAPIRiskCacheTTL()).Err(); err != nil { + logger.ErrorF(ctx, "[OpenAPIRisk] write cache failed, skip risk check: %v", err) + return nil, false + } + + return risk, true +} + +func fetchOpenAPIUserRisk(ctx context.Context, userID uint64) (*openAPIUserRiskResponse, error) { + cfg := config.Config.OpenAPIRisk + endpoint := fmt.Sprintf( + "%s/api/open/v1/risk/users/%d", + strings.TrimRight(cfg.BaseURL, "/"), + userID, + ) + + headers := map[string]string{ + "Accept": "application/json", + } + if cfg.Username != "" || cfg.Password != "" { + token := base64.StdEncoding.EncodeToString([]byte(cfg.Username + ":" + cfg.Password)) + headers["Authorization"] = "Basic " + token + } + + resp, err := utils.Request(ctx, http.MethodGet, endpoint, nil, headers, nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var risk openAPIUserRiskResponse + if err = json.NewDecoder(resp.Body).Decode(&risk); err != nil { + return nil, fmt.Errorf("decode response failed: %w", err) + } + + return &risk, nil +} + +func openAPIRiskCacheTTL() time.Duration { + ttl := time.Duration(config.Config.OpenAPIRisk.CacheTTLSeconds) * time.Second + if ttl < minOpenAPIRiskCacheTTL { + return minOpenAPIRiskCacheTTL + } + return ttl +} + +func applyOpenAPIUserRisk(c *gin.Context, risk *openAPIUserRiskResponse) bool { + if risk == nil || !risk.Risky { + return false + } + + labels := riskLabels(risk) + cfg := config.Config.OpenAPIRisk + if containsString(cfg.BlockRiskLevels, risk.RiskLevel) { + setRiskHeaders(c, risk.RiskLevel, labels) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error_code": riskBlockedCode, + "error_msg": riskBlockedMsg, + "details": riskBlockDetails{ + RiskLevel: risk.RiskLevel, + RiskLabels: labels, + }, + }) + return true + } + + if containsString(cfg.PromptRiskLevels, risk.RiskLevel) { + setRiskHeaders(c, risk.RiskLevel, labels) + } + + return false +} + +func setRiskHeaders(c *gin.Context, riskLevel string, labels []string) { + labelsJSON, err := json.Marshal(labels) + if err != nil { + logger.ErrorF(c.Request.Context(), "[OpenAPIRisk] marshal risk labels failed: %v", err) + return + } + + c.Header(riskLevelHeader, riskLevel) + c.Header(riskLabelsHeader, base64.StdEncoding.EncodeToString(labelsJSON)) + appendExposeHeaders(c, riskLevelHeader, riskLabelsHeader) +} + +func appendExposeHeaders(c *gin.Context, names ...string) { + existing := c.Writer.Header().Get(exposeHeader) + exposed := make([]string, 0, len(names)+1) + if existing != "" { + exposed = append(exposed, strings.Split(existing, ",")...) + } + exposed = append(exposed, names...) + + seen := make(map[string]struct{}, len(exposed)) + normalized := make([]string, 0, len(exposed)) + for _, header := range exposed { + header = strings.TrimSpace(header) + if header == "" { + continue + } + key := strings.ToLower(header) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, header) + } + + c.Header(exposeHeader, strings.Join(normalized, ", ")) +} + +func riskLabels(risk *openAPIUserRiskResponse) []string { + labels := make([]string, 0, len(risk.Risks)) + for _, item := range risk.Risks { + label := strings.TrimSpace(item.Label) + if label == "" { + continue + } + labels = append(labels, label) + } + return labels +} + +func containsString(values []string, target string) bool { + target = strings.TrimSpace(target) + for _, value := range values { + if strings.TrimSpace(value) == target { + return true + } + } + return false +} diff --git a/internal/config/model.go b/internal/config/model.go index 191b35f8..81a5e43a 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -27,18 +27,19 @@ package config import "time" type configModel struct { - App appConfig `mapstructure:"app"` - ProjectApp projectAppConfig `mapstructure:"projectApp"` - OAuth2 OAuth2Config `mapstructure:"oauth2"` - Database databaseConfig `mapstructure:"database"` - Redis redisConfig `mapstructure:"redis"` - Log logConfig `mapstructure:"log"` - Schedule scheduleConfig `mapstructure:"schedule"` - Worker workerConfig `mapstructure:"worker"` - ClickHouse clickHouseConfig `mapstructure:"clickhouse"` - LinuxDo linuxDoConfig `mapstructure:"linuxdo"` - Otel otelConfig `mapstructure:"otel"` - Payment PaymentConfig `mapstructure:"payment"` + App appConfig `mapstructure:"app"` + ProjectApp projectAppConfig `mapstructure:"projectApp"` + OAuth2 OAuth2Config `mapstructure:"oauth2"` + Database databaseConfig `mapstructure:"database"` + Redis redisConfig `mapstructure:"redis"` + Log logConfig `mapstructure:"log"` + Schedule scheduleConfig `mapstructure:"schedule"` + Worker workerConfig `mapstructure:"worker"` + ClickHouse clickHouseConfig `mapstructure:"clickhouse"` + LinuxDo linuxDoConfig `mapstructure:"linuxdo"` + OpenAPIRisk openAPIRiskConfig `mapstructure:"openapi_risk"` + Otel otelConfig `mapstructure:"otel"` + Payment PaymentConfig `mapstructure:"payment"` } // appConfig 应用基本配置 @@ -149,6 +150,17 @@ type linuxDoConfig struct { ApiKey string `mapstructure:"api_key"` } +// openAPIRiskConfig OpenAPI 用户风险配置 +type openAPIRiskConfig struct { + Enabled bool `mapstructure:"enabled"` + BaseURL string `mapstructure:"base_url"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password" json:"-"` + CacheTTLSeconds int `mapstructure:"cache_ttl_seconds"` + PromptRiskLevels []string `mapstructure:"prompt_risk_levels"` + BlockRiskLevels []string `mapstructure:"block_risk_levels"` +} + // otelConfig OpenTelemetry 配置 type otelConfig struct { SamplingRate float64 `mapstructure:"sampling_rate"` diff --git a/internal/router/router.go b/internal/router/router.go index e6ebf2bf..51f100e8 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -101,7 +101,7 @@ func Serve() { // OAuth apiV1Router.GET("/oauth/login", oauth.GetLoginURL) - apiV1Router.GET("/oauth/logout", oauth.LoginRequired(), oauth.Logout) + apiV1Router.GET("/oauth/logout", oauth.Logout) apiV1Router.POST("/oauth/callback", oauth.Callback) apiV1Router.GET("/oauth/user-info", oauth.LoginRequired(), oauth.UserInfo)