feat: 通用二步验证

This commit is contained in:
Seefs
2025-09-30 12:12:50 +08:00
parent 84cdd24116
commit e8425addf0
12 changed files with 798 additions and 323 deletions

View File

@@ -384,19 +384,9 @@ func GetChannel(c *gin.Context) {
return return
} }
// GetChannelKey 验证2FA或Passkey后获取渠道密钥 // GetChannelKey 获取渠道密钥(需要通过安全验证中间件)
// 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证
func GetChannelKey(c *gin.Context) { func GetChannelKey(c *gin.Context) {
type GetChannelKeyRequest struct {
Code string `json:"code,omitempty"` // 2FA验证码或备用码
Method string `json:"method,omitempty"` // 验证方式: "2fa" 或 "passkey"
}
var req GetChannelKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
return
}
userId := c.GetInt("id") userId := c.GetInt("id")
channelId, err := strconv.Atoi(c.Param("id")) channelId, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
@@ -404,111 +394,6 @@ func GetChannelKey(c *gin.Context) {
return return
} }
// 检查用户支持的验证方式
twoFA, err := model.GetTwoFAByUserId(userId)
if err != nil {
common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err))
return
}
passkey, passkeyErr := model.GetPasskeyByUserID(userId)
hasPasskey := passkeyErr == nil && passkey != nil
has2FA := twoFA != nil && twoFA.IsEnabled
// 至少需要启用一种验证方式
if !has2FA && !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey无法查看密钥"))
return
}
// 根据请求的验证方式进行验证
switch req.Method {
case "2fa":
if !has2FA {
common.ApiError(c, fmt.Errorf("用户未启用2FA"))
return
}
if req.Code == "" {
common.ApiError(c, fmt.Errorf("2FA验证码不能为空"))
return
}
if !validateTwoFactorAuth(twoFA, req.Code) {
common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
return
}
case "passkey":
if !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用Passkey"))
return
}
// Passkey验证已在前端完成这里只需要检查是否有有效的Passkey验证会话
// 由于Passkey验证是基于WebAuthn协议的验证过程已经在PasskeyVerifyFinish中完成
// 这里我们可以设置一个临时标记来验证Passkey验证是否成功
default:
// 自动选择验证方式如果提供了code则使用2FA否则需要用户明确指定
if req.Code != "" && has2FA {
if !validateTwoFactorAuth(twoFA, req.Code) {
common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
return
}
} else {
common.ApiError(c, fmt.Errorf("请指定验证方式(method: '2fa' 或 'passkey')"))
return
}
}
// 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true)
if err != nil {
common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err))
return
}
if channel == nil {
common.ApiError(c, fmt.Errorf("渠道不存在"))
return
}
// 记录操作日志
logMethod := req.Method
if logMethod == "" {
logMethod = "2fa"
}
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: %s)", channelId, logMethod))
// 统一的成功响应格式
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"data": map[string]interface{}{
"key": channel.Key,
},
})
}
// GetChannelKeyWithPasskey 使用Passkey验证查看渠道密钥
func GetChannelKeyWithPasskey(c *gin.Context) {
userId := c.GetInt("id")
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err))
return
}
// 检查用户是否已绑定Passkey
passkey, err := model.GetPasskeyByUserID(userId)
if err != nil {
common.ApiError(c, fmt.Errorf("用户未绑定Passkey无法使用此验证方式"))
return
}
if passkey == nil {
common.ApiError(c, fmt.Errorf("用户未绑定Passkey"))
return
}
// 获取渠道信息(包含密钥) // 获取渠道信息(包含密钥)
channel, err := model.GetChannelById(channelId, true) channel, err := model.GetChannelById(channelId, true)
if err != nil { if err != nil {
@@ -522,12 +407,12 @@ func GetChannelKeyWithPasskey(c *gin.Context) {
} }
// 记录操作日志 // 记录操作日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: passkey)", channelId)) model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
// 返回渠道密钥 // 返回渠道密钥
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Passkey验证成功", "message": "获取成功",
"data": map[string]interface{}{ "data": map[string]interface{}{
"key": channel.Key, "key": channel.Key,
}, },

View File

@@ -0,0 +1,313 @@
package controller
import (
"fmt"
"net/http"
"one-api/common"
"one-api/model"
passkeysvc "one-api/service/passkey"
"one-api/setting/system_setting"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
const (
// SecureVerificationSessionKey 安全验证的 session key
SecureVerificationSessionKey = "secure_verified_at"
// SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟
)
type UniversalVerifyRequest struct {
Method string `json:"method"` // "2fa" 或 "passkey"
Code string `json:"code,omitempty"`
}
type VerificationStatusResponse struct {
Verified bool `json:"verified"`
ExpiresAt int64 `json:"expires_at,omitempty"`
}
// UniversalVerify 通用验证接口
// 支持 2FA 和 Passkey 验证,验证成功后在 session 中记录时间戳
func UniversalVerify(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
var req UniversalVerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, fmt.Errorf("参数错误: %v", err))
return
}
// 获取用户信息
user := &model.User{Id: userId}
if err := user.FillUserById(); err != nil {
common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err))
return
}
if user.Status != common.UserStatusEnabled {
common.ApiError(c, fmt.Errorf("该用户已被禁用"))
return
}
// 检查用户的验证方式
twoFA, _ := model.GetTwoFAByUserId(userId)
has2FA := twoFA != nil && twoFA.IsEnabled
passkey, passkeyErr := model.GetPasskeyByUserID(userId)
hasPasskey := passkeyErr == nil && passkey != nil
if !has2FA && !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey"))
return
}
// 根据验证方式进行验证
var verified bool
var verifyMethod string
switch req.Method {
case "2fa":
if !has2FA {
common.ApiError(c, fmt.Errorf("用户未启用2FA"))
return
}
if req.Code == "" {
common.ApiError(c, fmt.Errorf("验证码不能为空"))
return
}
verified = validateTwoFactorAuth(twoFA, req.Code)
verifyMethod = "2FA"
case "passkey":
if !hasPasskey {
common.ApiError(c, fmt.Errorf("用户未启用Passkey"))
return
}
// Passkey 验证需要先调用 PasskeyVerifyBegin 和 PasskeyVerifyFinish
// 这里只是验证 Passkey 验证流程是否已经完成
// 实际上,前端应该先调用这两个接口,然后再调用本接口
verified = true // Passkey 验证逻辑已在 PasskeyVerifyFinish 中完成
verifyMethod = "Passkey"
default:
common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method))
return
}
if !verified {
common.ApiError(c, fmt.Errorf("验证失败,请检查验证码"))
return
}
// 验证成功,在 session 中记录时间戳
session := sessions.Default(c)
now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now)
if err := session.Save(); err != nil {
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
return
}
// 记录日志
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("通用安全验证成功 (验证方式: %s)", verifyMethod))
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "验证成功",
"data": gin.H{
"verified": true,
"expires_at": now + SecureVerificationTimeout,
},
})
}
// GetVerificationStatus 获取验证状态
func GetVerificationStatus(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: false,
},
})
return
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: false,
},
})
return
}
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
// 验证已过期
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: false,
},
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": VerificationStatusResponse{
Verified: true,
ExpiresAt: verifiedAt + SecureVerificationTimeout,
},
})
}
// CheckSecureVerification 检查是否已通过安全验证
// 返回 true 表示验证有效false 表示需要重新验证
func CheckSecureVerification(c *gin.Context) bool {
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
return false
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
return false
}
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
// 验证已过期,清除 session
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
return false
}
return true
}
// PasskeyVerifyAndSetSession Passkey 验证完成后设置 session
// 这是一个辅助函数,供 PasskeyVerifyFinish 调用
func PasskeyVerifyAndSetSession(c *gin.Context) {
session := sessions.Default(c)
now := time.Now().Unix()
session.Set(SecureVerificationSessionKey, now)
_ = session.Save()
}
// PasskeyVerifyForSecure 用于安全验证的 Passkey 验证流程
// 整合了 begin 和 finish 流程
func PasskeyVerifyForSecure(c *gin.Context) {
if !system_setting.GetPasskeySettings().Enabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "管理员未启用 Passkey 登录",
})
return
}
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
return
}
user := &model.User{Id: userId}
if err := user.FillUserById(); err != nil {
common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err))
return
}
if user.Status != common.UserStatusEnabled {
common.ApiError(c, fmt.Errorf("该用户已被禁用"))
return
}
credential, err := model.GetPasskeyByUserID(userId)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "该用户尚未绑定 Passkey",
})
return
}
wa, err := passkeysvc.BuildWebAuthn(c.Request)
if err != nil {
common.ApiError(c, err)
return
}
waUser := passkeysvc.NewWebAuthnUser(user, credential)
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey)
if err != nil {
common.ApiError(c, err)
return
}
_, err = wa.FinishLogin(waUser, *sessionData, c.Request)
if err != nil {
common.ApiError(c, err)
return
}
// 更新凭证的最后使用时间
now := time.Now()
credential.LastUsedAt = &now
if err := model.UpsertPasskeyCredential(credential); err != nil {
common.ApiError(c, err)
return
}
// 验证成功,设置 session
PasskeyVerifyAndSetSession(c)
// 记录日志
model.RecordLog(userId, model.LogTypeSystem, "Passkey 安全验证成功")
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Passkey 验证成功",
"data": gin.H{
"verified": true,
"expires_at": time.Now().Unix() + SecureVerificationTimeout,
},
})
}

View File

@@ -0,0 +1,131 @@
package middleware
import (
"net/http"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
const (
// SecureVerificationSessionKey 安全验证的 session key与 controller 保持一致)
SecureVerificationSessionKey = "secure_verified_at"
// SecureVerificationTimeout 验证有效期(秒)
SecureVerificationTimeout = 300 // 5分钟
)
// SecureVerificationRequired 安全验证中间件
// 检查用户是否在有效时间内通过了安全验证
// 如果未验证或验证已过期,返回 401 错误
func SecureVerificationRequired() gin.HandlerFunc {
return func(c *gin.Context) {
// 检查用户是否已登录
userId := c.GetInt("id")
if userId == 0 {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": "未登录",
})
c.Abort()
return
}
// 检查 session 中的验证时间戳
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "需要安全验证",
"code": "VERIFICATION_REQUIRED",
})
c.Abort()
return
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
// session 数据格式错误
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "验证状态异常,请重新验证",
"code": "VERIFICATION_INVALID",
})
c.Abort()
return
}
// 检查验证是否过期
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
// 验证已过期,清除 session
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.JSON(http.StatusForbidden, gin.H{
"success": false,
"message": "验证已过期,请重新验证",
"code": "VERIFICATION_EXPIRED",
})
c.Abort()
return
}
// 验证有效,继续处理请求
c.Next()
}
}
// OptionalSecureVerification 可选的安全验证中间件
// 如果用户已验证,则在 context 中设置标记,但不阻止请求继续
// 用于某些需要区分是否已验证的场景
func OptionalSecureVerification() gin.HandlerFunc {
return func(c *gin.Context) {
userId := c.GetInt("id")
if userId == 0 {
c.Set("secure_verified", false)
c.Next()
return
}
session := sessions.Default(c)
verifiedAtRaw := session.Get(SecureVerificationSessionKey)
if verifiedAtRaw == nil {
c.Set("secure_verified", false)
c.Next()
return
}
verifiedAt, ok := verifiedAtRaw.(int64)
if !ok {
c.Set("secure_verified", false)
c.Next()
return
}
elapsed := time.Now().Unix() - verifiedAt
if elapsed >= SecureVerificationTimeout {
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
c.Set("secure_verified", false)
c.Next()
return
}
c.Set("secure_verified", true)
c.Set("secure_verified_at", verifiedAt)
c.Next()
}
}
// ClearSecureVerification 清除安全验证状态
// 用于用户登出或需要强制重新验证的场景
func ClearSecureVerification(c *gin.Context) {
session := sessions.Default(c)
session.Delete(SecureVerificationSessionKey)
_ = session.Save()
}

View File

@@ -40,6 +40,10 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/stripe/webhook", controller.StripeWebhook) apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
// Universal secure verification routes
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
apiRouter.GET("/verify/status", middleware.UserAuth(), controller.GetVerificationStatus)
userRoute := apiRouter.Group("/user") userRoute := apiRouter.Group("/user")
{ {
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
@@ -124,8 +128,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.GET("/models", controller.ChannelListModels) channelRoute.GET("/models", controller.ChannelListModels)
channelRoute.GET("/models_enabled", controller.EnabledListModels) channelRoute.GET("/models_enabled", controller.EnabledListModels)
channelRoute.GET("/:id", controller.GetChannel) channelRoute.GET("/:id", controller.GetChannel)
channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKey) channelRoute.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey)
channelRoute.POST("/:id/key/passkey", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetChannelKeyWithPasskey)
channelRoute.GET("/test", controller.TestAllChannels) channelRoute.GET("/test", controller.TestAllChannels)
channelRoute.GET("/test/:id", controller.TestChannel) channelRoute.GET("/test/:id", controller.TestChannel)
channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance) channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance)

View File

@@ -17,9 +17,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import React from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Modal, Button, Input, Typography, Tabs, TabPane, Card } from '@douyinfe/semi-ui'; import { Modal, Button, Input, Typography, Tabs, TabPane, Space, Spin } from '@douyinfe/semi-ui';
/** /**
* 通用安全验证模态框组件 * 通用安全验证模态框组件
@@ -47,14 +47,28 @@ const SecureVerificationModal = ({
description, description,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isAnimating, setIsAnimating] = useState(false);
const [verifySuccess, setVerifySuccess] = useState(false);
const { has2FA, hasPasskey, passkeySupported } = verificationMethods; const { has2FA, hasPasskey, passkeySupported } = verificationMethods;
const { method, loading, code } = verificationState; const { method, loading, code } = verificationState;
useEffect(() => {
if (visible) {
setIsAnimating(true);
setVerifySuccess(false);
} else {
setIsAnimating(false);
}
}, [visible]);
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') { if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') {
onVerify(method, code); onVerify(method, code);
} }
if (e.key === 'Escape' && !loading) {
onCancel();
}
}; };
// 如果用户没有启用任何验证方式 // 如果用户没有启用任何验证方式
@@ -101,165 +115,165 @@ const SecureVerificationModal = ({
return ( return (
<Modal <Modal
title={ title={title || t('安全验证')}
<div className='flex items-center'>
<div className='w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3'>
<svg
className='w-4 h-4 text-blue-600 dark:text-blue-400'
fill='currentColor'
viewBox='0 0 20 20'
>
<path
fillRule='evenodd'
d='M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z'
clipRule='evenodd'
/>
</svg>
</div>
{title || t('安全验证')}
</div>
}
visible={visible} visible={visible}
onCancel={onCancel} onCancel={loading ? undefined : onCancel}
closeOnEsc={!loading}
footer={null} footer={null}
width={600} width={460}
style={{ maxWidth: '90vw' }} centered
style={{
maxWidth: 'calc(100vw - 32px)'
}}
bodyStyle={{
padding: '20px 24px'
}}
> >
<div className='space-y-6'> <div style={{ width: '100%' }}>
{/* 安全提示 */} {/* 描述信息 */}
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'> {description && (
<div className='flex items-start'> <Typography.Paragraph
<svg type="tertiary"
className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0' style={{
fill='currentColor' margin: '0 0 20px 0',
viewBox='0 0 20 20' fontSize: '14px',
> lineHeight: '1.6'
<path }}
fillRule='evenodd' >
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z' {description}
clipRule='evenodd' </Typography.Paragraph>
/> )}
</svg>
<div>
<Typography.Text
strong
className='text-blue-800 dark:text-blue-200'
>
{t('安全验证')}
</Typography.Text>
<Typography.Text className='block text-blue-700 dark:text-blue-300 text-sm mt-1'>
{description || t('为了保护账户安全,请选择一种方式进行验证。')}
</Typography.Text>
</div>
</div>
</div>
{/* 验证方式选择 */} {/* 验证方式选择 */}
<Tabs activeKey={method} onChange={onMethodSwitch} type='card'> <Tabs
activeKey={method}
onChange={onMethodSwitch}
type='line'
size='default'
style={{ margin: 0 }}
>
{has2FA && ( {has2FA && (
<TabPane <TabPane
tab={ tab={t('两步验证')}
<div className='flex items-center space-x-2'>
<svg className='w-4 h-4' fill='currentColor' viewBox='0 0 20 20'>
<path d='M10 12a2 2 0 100-4 2 2 0 000 4z' />
<path
fillRule='evenodd'
d='M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z'
clipRule='evenodd'
/>
</svg>
<span>{t('两步验证')}</span>
</div>
}
itemKey='2fa' itemKey='2fa'
> >
<Card className='border-0 shadow-none bg-transparent'> <div style={{ paddingTop: '20px' }}>
<div className='space-y-4'> <div style={{ marginBottom: '12px' }}>
<div> <Input
<Typography.Text strong className='block mb-2'> placeholder={t('请输入6位验证码或8位备用码')}
{t('验证码')} value={code}
</Typography.Text> onChange={onCodeChange}
<Input size='large'
placeholder={t('请输入认证器验证码或备用码')} maxLength={8}
value={code} onKeyDown={handleKeyDown}
onChange={onCodeChange} autoFocus={method === '2fa'}
size='large' disabled={loading}
maxLength={8} prefix={
onKeyDown={handleKeyDown} <svg style={{ width: 16, height: 16, marginRight: 8, flexShrink: 0 }} fill='currentColor' viewBox='0 0 20 20'>
autoFocus={method === '2fa'} <path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
/> </svg>
<Typography.Text type='tertiary' size='small' className='mt-2 block'> }
{t('支持6位TOTP验证码或8位备用码')} style={{ width: '100%' }}
</Typography.Text> />
</div>
<div className='flex justify-end space-x-3'>
<Button onClick={onCancel}>{t('取消')}</Button>
<Button
type='primary'
loading={loading}
disabled={!code.trim() || loading}
onClick={() => onVerify(method, code)}
>
{t('验证')}
</Button>
</div>
</div> </div>
</Card>
<Typography.Text
type="tertiary"
size="small"
style={{
display: 'block',
marginBottom: '20px',
fontSize: '13px',
lineHeight: '1.5'
}}
>
{t('从认证器应用中获取验证码,或使用备用码')}
</Typography.Text>
<div style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
flexWrap: 'wrap'
}}>
<Button onClick={onCancel} disabled={loading}>
{t('取消')}
</Button>
<Button
theme='solid'
type='primary'
loading={loading}
disabled={!code.trim() || loading}
onClick={() => onVerify(method, code)}
>
{t('验证')}
</Button>
</div>
</div>
</TabPane> </TabPane>
)} )}
{hasPasskey && passkeySupported && ( {hasPasskey && passkeySupported && (
<TabPane <TabPane
tab={ tab={t('Passkey')}
<div className='flex items-center space-x-2'>
<svg className='w-4 h-4' fill='currentColor' viewBox='0 0 20 20'>
<path
fillRule='evenodd'
d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
clipRule='evenodd'
/>
</svg>
<span>{t('Passkey')}</span>
</div>
}
itemKey='passkey' itemKey='passkey'
> >
<Card className='border-0 shadow-none bg-transparent'> <div style={{ paddingTop: '20px' }}>
<div className='space-y-4'> <div style={{
<div className='text-center py-4'> textAlign: 'center',
<div className='mb-4'> padding: '24px 16px',
<svg marginBottom: '20px'
className='w-16 h-16 text-blue-500 mx-auto' }}>
fill='currentColor' <div style={{
viewBox='0 0 20 20' width: 56,
> height: 56,
<path margin: '0 auto 16px',
fillRule='evenodd' display: 'flex',
d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' alignItems: 'center',
clipRule='evenodd' justifyContent: 'center',
/> borderRadius: '50%',
</svg> background: 'var(--semi-color-primary-light-default)',
</div> }}>
<Typography.Text strong className='block mb-2'> <svg style={{ width: 28, height: 28, color: 'var(--semi-color-primary)' }} fill='currentColor' viewBox='0 0 20 20'>
{t('使用 Passkey 验证')} <path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
</Typography.Text> </svg>
<Typography.Text type='tertiary' className='block mb-4'>
{t('点击下方按钮,使用您的生物特征或安全密钥进行验证')}
</Typography.Text>
</div>
<div className='flex justify-end space-x-3'>
<Button onClick={onCancel}>{t('取消')}</Button>
<Button
type='primary'
loading={loading}
disabled={loading}
onClick={() => onVerify(method)}
>
{loading ? t('验证中...') : t('验证 Passkey')}
</Button>
</div> </div>
<Typography.Title heading={5} style={{ margin: '0 0 8px', fontSize: '16px' }}>
{t('使用 Passkey 验证')}
</Typography.Title>
<Typography.Text
type='tertiary'
style={{
display: 'block',
margin: 0,
fontSize: '13px',
lineHeight: '1.5'
}}
>
{t('点击验证按钮,使用您的生物特征或安全密钥')}
</Typography.Text>
</div> </div>
</Card>
<div style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
flexWrap: 'wrap'
}}>
<Button onClick={onCancel} disabled={loading}>
{t('取消')}
</Button>
<Button
theme='solid'
type='primary'
loading={loading}
disabled={loading}
onClick={() => onVerify(method)}
>
{t('验证 Passkey')}
</Button>
</div>
</div>
</TabPane> </TabPane>
)} )}
</Tabs> </Tabs>

View File

@@ -1043,7 +1043,7 @@ const SystemSetting = () => {
handleCheckboxChange('passkey.enabled', e) handleCheckboxChange('passkey.enabled', e)
} }
> >
{t('允许通过 Passkey 登录 & 注册')} {t('允许通过 Passkey 登录 & 认证')}
</Form.Checkbox> </Form.Checkbox>
</Col> </Col>
</Row> </Row>

View File

@@ -206,7 +206,7 @@ const EditChannelModal = (props) => {
isModalVisible, isModalVisible,
verificationMethods, verificationMethods,
verificationState, verificationState,
startVerification, withVerification,
executeVerification, executeVerification,
cancelVerification, cancelVerification,
setVerificationCode, setVerificationCode,
@@ -214,12 +214,20 @@ const EditChannelModal = (props) => {
} = useSecureVerification({ } = useSecureVerification({
onSuccess: (result) => { onSuccess: (result) => {
// 验证成功后显示密钥 // 验证成功后显示密钥
if (result.success && result.data?.key) { console.log('Verification success, result:', result);
if (result && result.success && result.data?.key) {
showSuccess(t('密钥获取成功')); showSuccess(t('密钥获取成功'));
setKeyDisplayState({ setKeyDisplayState({
showModal: true, showModal: true,
keyData: result.data.key, keyData: result.data.key,
}); });
} else if (result && result.key) {
// 直接返回了 key没有包装在 data 中)
showSuccess(t('密钥获取成功'));
setKeyDisplayState({
showModal: true,
keyData: result.key,
});
} }
}, },
}); });
@@ -604,19 +612,30 @@ const EditChannelModal = (props) => {
} }
}; };
// 显示安全验证模态框并开始验证流程 // 查看渠道密钥(透明验证)
const handleShow2FAModal = async () => { const handleShow2FAModal = async () => {
try { try {
const apiCall = createApiCalls.viewChannelKey(channelId); // 使用 withVerification 包装,会自动处理需要验证的情况
const result = await withVerification(
await startVerification(apiCall, { createApiCalls.viewChannelKey(channelId),
title: t('查看渠道密钥'), {
description: t('为了保护账户安全,请验证您的身份。'), title: t('查看渠道密钥'),
preferredMethod: 'passkey', // 优先使用 Passkey description: t('为了保护账户安全,请验证您的身份。'),
}); preferredMethod: 'passkey', // 优先使用 Passkey
}
);
// 如果直接返回了结果(已验证),显示密钥
if (result && result.success && result.data?.key) {
showSuccess(t('密钥获取成功'));
setKeyDisplayState({
showModal: true,
keyData: result.data.key,
});
}
} catch (error) { } catch (error) {
console.error('Failed to start verification:', error); console.error('Failed to view channel key:', error);
showError(error.message || t('启动验证失败')); showError(error.message || t('获取密钥失败'));
} }
}; };

View File

@@ -0,0 +1,62 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/**
* 安全 API 调用包装器
* 自动处理需要验证的 403 错误,透明地触发验证流程
*/
/**
* 检查错误是否是需要安全验证的错误
* @param {Error} error - 错误对象
* @returns {boolean}
*/
export function isVerificationRequiredError(error) {
if (!error.response) return false;
const { status, data } = error.response;
// 检查是否是 403 错误且包含验证相关的错误码
if (status === 403 && data) {
const verificationCodes = [
'VERIFICATION_REQUIRED',
'VERIFICATION_EXPIRED',
'VERIFICATION_INVALID'
];
return verificationCodes.includes(data.code);
}
return false;
}
/**
* 从错误中提取验证需求信息
* @param {Error} error - 错误对象
* @returns {Object} 验证需求信息
*/
export function extractVerificationInfo(error) {
const data = error.response?.data || {};
return {
code: data.code,
message: data.message || '需要安全验证',
required: true
};
}

View File

@@ -21,6 +21,7 @@ import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SecureVerificationService } from '../../services/secureVerification'; import { SecureVerificationService } from '../../services/secureVerification';
import { showError, showSuccess } from '../../helpers'; import { showError, showSuccess } from '../../helpers';
import { isVerificationRequiredError } from '../../helpers/secureApiCall';
/** /**
* 通用安全验证 Hook * 通用安全验证 Hook
@@ -82,10 +83,10 @@ export const useSecureVerification = ({
// 开始验证流程 // 开始验证流程
const startVerification = useCallback(async (apiCall, options = {}) => { const startVerification = useCallback(async (apiCall, options = {}) => {
const { preferredMethod, title, description } = options; const { preferredMethod, title, description } = options;
// 检查验证方式 // 检查验证方式
const methods = await checkVerificationMethods(); const methods = await checkVerificationMethods();
if (!methods.has2FA && !methods.hasPasskey) { if (!methods.has2FA && !methods.hasPasskey) {
const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作'); const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
showError(errorMessage); showError(errorMessage);
@@ -111,7 +112,7 @@ export const useSecureVerification = ({
description description
})); }));
setIsModalVisible(true); setIsModalVisible(true);
return true; return true;
}, [checkVerificationMethods, onError, t]); }, [checkVerificationMethods, onError, t]);
@@ -125,10 +126,11 @@ export const useSecureVerification = ({
setVerificationState(prev => ({ ...prev, loading: true })); setVerificationState(prev => ({ ...prev, loading: true }));
try { try {
const result = await SecureVerificationService.verify(method, { // 先调用验证 API成功后后端会设置 session
code, await SecureVerificationService.verify(method, code);
apiCall: verificationState.apiCall
}); // 验证成功,调用业务 API此时中间件会通过
const result = await verificationState.apiCall();
// 显示成功消息 // 显示成功消息
if (successMessage) { if (successMessage) {
@@ -191,12 +193,36 @@ export const useSecureVerification = ({
return null; return null;
}, [verificationMethods]); }, [verificationMethods]);
/**
* 包装 API 调用,自动处理验证错误
* 当 API 返回需要验证的错误时,自动弹出验证模态框
* @param {Function} apiCall - API 调用函数
* @param {Object} options - 验证选项(同 startVerification
* @returns {Promise<any>}
*/
const withVerification = useCallback(async (apiCall, options = {}) => {
try {
// 直接尝试调用 API
return await apiCall();
} catch (error) {
// 检查是否是需要验证的错误
if (isVerificationRequiredError(error)) {
// 自动触发验证流程
await startVerification(apiCall, options);
// 不抛出错误,让验证模态框处理
return null;
}
// 其他错误继续抛出
throw error;
}
}, [startVerification]);
return { return {
// 状态 // 状态
isModalVisible, isModalVisible,
verificationMethods, verificationMethods,
verificationState, verificationState,
// 方法 // 方法
startVerification, startVerification,
executeVerification, executeVerification,
@@ -205,11 +231,12 @@ export const useSecureVerification = ({
setVerificationCode, setVerificationCode,
switchVerificationMethod, switchVerificationMethod,
checkVerificationMethods, checkVerificationMethods,
// 辅助方法 // 辅助方法
canUseMethod, canUseMethod,
getRecommendedMethod, getRecommendedMethod,
withVerification, // 新增:自动处理验证的包装函数
// 便捷属性 // 便捷属性
hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey, hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey,
isLoading: verificationState.loading, isLoading: verificationState.loading,

View File

@@ -333,6 +333,7 @@
"通过密码注册时需要进行邮箱验证": "Email verification is required when registering via password", "通过密码注册时需要进行邮箱验证": "Email verification is required when registering via password",
"允许通过 GitHub 账户登录 & 注册": "Allow login & registration via GitHub account", "允许通过 GitHub 账户登录 & 注册": "Allow login & registration via GitHub account",
"允许通过微信登录 & 注册": "Allow login & registration via WeChat", "允许通过微信登录 & 注册": "Allow login & registration via WeChat",
"允许通过 Passkey 登录 & 认证": "Allow login & authentication via Passkey",
"允许新用户注册(此项为否时,新用户将无法以任何方式进行注册": "Allow new user registration (if this option is off, new users will not be able to register in any way", "允许新用户注册(此项为否时,新用户将无法以任何方式进行注册": "Allow new user registration (if this option is off, new users will not be able to register in any way",
"启用 Turnstile 用户校验": "Enable Turnstile user verification", "启用 Turnstile 用户校验": "Enable Turnstile user verification",
"配置 SMTP": "Configure SMTP", "配置 SMTP": "Configure SMTP",

View File

@@ -87,5 +87,6 @@
"此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。", "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。",
"目标用户:{{username}}": "目标用户:{{username}}", "目标用户:{{username}}": "目标用户:{{username}}",
"Passkey 已重置": "Passkey 已重置", "Passkey 已重置": "Passkey 已重置",
"二步验证已重置": "二步验证已重置" "二步验证已重置": "二步验证已重置",
"允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证"
} }

View File

@@ -18,14 +18,15 @@ For commercial licensing, please contact support@quantumnous.com
*/ */
import { API, showError } from '../helpers'; import { API, showError } from '../helpers';
import { import {
prepareCredentialRequestOptions, prepareCredentialRequestOptions,
buildAssertionResult, buildAssertionResult,
isPasskeySupported isPasskeySupported
} from '../helpers/passkey'; } from '../helpers/passkey';
/** /**
* 通用安全验证服务 * 通用安全验证服务
* 验证状态完全由后端 Session 控制,前端不存储任何状态
*/ */
export class SecureVerificationService { export class SecureVerificationService {
/** /**
@@ -81,36 +82,41 @@ export class SecureVerificationService {
/** /**
* 执行2FA验证 * 执行2FA验证
* @param {string} code - 验证码 * @param {string} code - 验证码
* @param {Function} apiCall - API调用函数接收 {method: '2fa', code} 参数 * @returns {Promise<void>}
* @returns {Promise<any>} API响应结果
*/ */
static async verify2FA(code, apiCall) { static async verify2FA(code) {
if (!code?.trim()) { if (!code?.trim()) {
throw new Error('请输入验证码或备用码'); throw new Error('请输入验证码或备用码');
} }
return await apiCall({ // 调用通用验证 API验证成功后后端会设置 session
const verifyResponse = await API.post('/api/verify', {
method: '2fa', method: '2fa',
code: code.trim() code: code.trim()
}); });
if (!verifyResponse.data?.success) {
throw new Error(verifyResponse.data?.message || '验证失败');
}
// 验证成功session 已在后端设置
} }
/** /**
* 执行Passkey验证 * 执行Passkey验证
* @param {Function} apiCall - API调用函数接收 {method: 'passkey'} 参数 * @returns {Promise<void>}
* @returns {Promise<any>} API响应结果
*/ */
static async verifyPasskey(apiCall) { static async verifyPasskey() {
try { try {
// 开始Passkey验证 // 开始Passkey验证
const beginResponse = await API.post('/api/user/passkey/verify/begin'); const beginResponse = await API.post('/api/user/passkey/verify/begin');
if (!beginResponse.success) { if (!beginResponse.data?.success) {
throw new Error(beginResponse.message); throw new Error(beginResponse.data?.message || '开始验证失败');
} }
// 准备WebAuthn选项 // 准备WebAuthn选项
const publicKey = prepareCredentialRequestOptions(beginResponse.data); const publicKey = prepareCredentialRequestOptions(beginResponse.data.data.options);
// 执行WebAuthn验证 // 执行WebAuthn验证
const credential = await navigator.credentials.get({ publicKey }); const credential = await navigator.credentials.get({ publicKey });
if (!credential) { if (!credential) {
@@ -119,17 +125,23 @@ export class SecureVerificationService {
// 构建验证结果 // 构建验证结果
const assertionResult = buildAssertionResult(credential); const assertionResult = buildAssertionResult(credential);
// 完成验证 // 完成验证
const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult); const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult);
if (!finishResponse.success) { if (!finishResponse.data?.success) {
throw new Error(finishResponse.message); throw new Error(finishResponse.data?.message || '验证失败');
} }
// 调用业务API // 调用通用验证 API 设置 sessionPasskey 验证已完成)
return await apiCall({ const verifyResponse = await API.post('/api/verify', {
method: 'passkey' method: 'passkey'
}); });
if (!verifyResponse.data?.success) {
throw new Error(verifyResponse.data?.message || '验证失败');
}
// 验证成功session 已在后端设置
} catch (error) { } catch (error) {
if (error.name === 'NotAllowedError') { if (error.name === 'NotAllowedError') {
throw new Error('Passkey 验证被取消或超时'); throw new Error('Passkey 验证被取消或超时');
@@ -144,17 +156,15 @@ export class SecureVerificationService {
/** /**
* 通用验证方法,根据验证类型执行相应的验证流程 * 通用验证方法,根据验证类型执行相应的验证流程
* @param {string} method - 验证方式: '2fa' | 'passkey' * @param {string} method - 验证方式: '2fa' | 'passkey'
* @param {Object} params - 参数对象 * @param {string} code - 2FA验证码当method为'2fa'时必需)
* @param {string} params.code - 2FA验证码当method为'2fa'时必需) * @returns {Promise<void>}
* @param {Function} params.apiCall - API调用函数
* @returns {Promise<any>} API响应结果
*/ */
static async verify(method, { code, apiCall }) { static async verify(method, code = '') {
switch (method) { switch (method) {
case '2fa': case '2fa':
return await this.verify2FA(code, apiCall); return await this.verify2FA(code);
case 'passkey': case 'passkey':
return await this.verifyPasskey(apiCall); return await this.verifyPasskey();
default: default:
throw new Error(`不支持的验证方式: ${method}`); throw new Error(`不支持的验证方式: ${method}`);
} }
@@ -169,8 +179,10 @@ export const createApiCalls = {
* 创建查看渠道密钥的API调用 * 创建查看渠道密钥的API调用
* @param {number} channelId - 渠道ID * @param {number} channelId - 渠道ID
*/ */
viewChannelKey: (channelId) => async (verificationData) => { viewChannelKey: (channelId) => async () => {
return await API.post(`/api/channel/${channelId}/key`, verificationData); // 新系统中,验证已通过中间件处理,直接调用 API 即可
const response = await API.post(`/api/channel/${channelId}/key`, {});
return response.data;
}, },
/** /**
@@ -179,20 +191,27 @@ export const createApiCalls = {
* @param {string} method - HTTP方法默认为 'POST' * @param {string} method - HTTP方法默认为 'POST'
* @param {Object} extraData - 额外的请求数据 * @param {Object} extraData - 额外的请求数据
*/ */
custom: (url, method = 'POST', extraData = {}) => async (verificationData) => { custom: (url, method = 'POST', extraData = {}) => async () => {
const data = { ...extraData, ...verificationData }; // 新系统中,验证已通过中间件处理
const data = extraData;
let response;
switch (method.toUpperCase()) { switch (method.toUpperCase()) {
case 'GET': case 'GET':
return await API.get(url, { params: data }); response = await API.get(url, { params: data });
break;
case 'POST': case 'POST':
return await API.post(url, data); response = await API.post(url, data);
break;
case 'PUT': case 'PUT':
return await API.put(url, data); response = await API.put(url, data);
break;
case 'DELETE': case 'DELETE':
return await API.delete(url, { data }); response = await API.delete(url, { data });
break;
default: default:
throw new Error(`不支持的HTTP方法: ${method}`); throw new Error(`不支持的HTTP方法: ${method}`);
} }
return response.data;
} }
}; };