mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-03-30 02:25:00 +00:00
feat: passkey
This commit is contained in:
@@ -384,10 +384,11 @@ func GetChannel(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetChannelKey 验证2FA后获取渠道密钥
|
// GetChannelKey 验证2FA或Passkey后获取渠道密钥
|
||||||
func GetChannelKey(c *gin.Context) {
|
func GetChannelKey(c *gin.Context) {
|
||||||
type GetChannelKeyRequest struct {
|
type GetChannelKeyRequest struct {
|
||||||
Code string `json:"code" binding:"required"`
|
Code string `json:"code,omitempty"` // 2FA验证码或备用码
|
||||||
|
Method string `json:"method,omitempty"` // 验证方式: "2fa" 或 "passkey"
|
||||||
}
|
}
|
||||||
|
|
||||||
var req GetChannelKeyRequest
|
var req GetChannelKeyRequest
|
||||||
@@ -403,21 +404,108 @@ func GetChannelKey(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取2FA记录并验证
|
// 检查用户支持的验证方式
|
||||||
twoFA, err := model.GetTwoFAByUserId(userId)
|
twoFA, err := model.GetTwoFAByUserId(userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err))
|
common.ApiError(c, fmt.Errorf("获取2FA信息失败: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if twoFA == nil || !twoFA.IsEnabled {
|
passkey, passkeyErr := model.GetPasskeyByUserID(userId)
|
||||||
common.ApiError(c, fmt.Errorf("用户未启用2FA,无法查看密钥"))
|
hasPasskey := passkeyErr == nil && passkey != nil
|
||||||
|
|
||||||
|
has2FA := twoFA != nil && twoFA.IsEnabled
|
||||||
|
|
||||||
|
// 至少需要启用一种验证方式
|
||||||
|
if !has2FA && !hasPasskey {
|
||||||
|
common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey,无法查看密钥"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一的2FA验证逻辑
|
// 根据请求的验证方式进行验证
|
||||||
if !validateTwoFactorAuth(twoFA, req.Code) {
|
switch req.Method {
|
||||||
common.ApiError(c, fmt.Errorf("验证码或备用码错误,请重试"))
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,12 +522,12 @@ func GetChannelKey(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 记录操作日志
|
// 记录操作日志
|
||||||
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId))
|
model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d, 验证方式: passkey)", channelId))
|
||||||
|
|
||||||
// 统一的成功响应格式
|
// 返回渠道密钥
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "验证成功",
|
"message": "Passkey验证成功",
|
||||||
"data": map[string]interface{}{
|
"data": map[string]interface{}{
|
||||||
"key": channel.Key,
|
"key": channel.Key,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ func GetStatus(c *gin.Context) {
|
|||||||
common.OptionMapRWMutex.RLock()
|
common.OptionMapRWMutex.RLock()
|
||||||
defer common.OptionMapRWMutex.RUnlock()
|
defer common.OptionMapRWMutex.RUnlock()
|
||||||
|
|
||||||
|
passkeySetting := system_setting.GetPasskeySettings()
|
||||||
|
|
||||||
data := gin.H{
|
data := gin.H{
|
||||||
"version": common.Version,
|
"version": common.Version,
|
||||||
"start_time": common.StartTime,
|
"start_time": common.StartTime,
|
||||||
@@ -94,6 +96,13 @@ func GetStatus(c *gin.Context) {
|
|||||||
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
|
"oidc_enabled": system_setting.GetOIDCSettings().Enabled,
|
||||||
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
|
"oidc_client_id": system_setting.GetOIDCSettings().ClientId,
|
||||||
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
|
"oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint,
|
||||||
|
"passkey_login": passkeySetting.Enabled,
|
||||||
|
"passkey_display_name": passkeySetting.RPDisplayName,
|
||||||
|
"passkey_rp_id": passkeySetting.RPID,
|
||||||
|
"passkey_origins": passkeySetting.Origins,
|
||||||
|
"passkey_allow_insecure": passkeySetting.AllowInsecureOrigin,
|
||||||
|
"passkey_user_verification": passkeySetting.UserVerification,
|
||||||
|
"passkey_attachment": passkeySetting.AttachmentPreference,
|
||||||
"setup": constant.Setup,
|
"setup": constant.Setup,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
502
controller/passkey.go
Normal file
502
controller/passkey.go
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"one-api/common"
|
||||||
|
"one-api/model"
|
||||||
|
passkeysvc "one-api/service/passkey"
|
||||||
|
"one-api/setting/system_setting"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
webauthnlib "github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PasskeyRegisterBegin(c *gin.Context) {
|
||||||
|
if !system_setting.GetPasskeySettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未启用 Passkey 登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := getSessionUser(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := model.GetPasskeyByUserID(user.Id)
|
||||||
|
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, model.ErrPasskeyNotFound) {
|
||||||
|
credential = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
waUser := passkeysvc.NewWebAuthnUser(user, credential)
|
||||||
|
var options []webauthnlib.RegistrationOption
|
||||||
|
if credential != nil {
|
||||||
|
descriptor := credential.ToWebAuthnCredential().Descriptor()
|
||||||
|
options = append(options, webauthnlib.WithExclusions([]protocol.CredentialDescriptor{descriptor}))
|
||||||
|
}
|
||||||
|
|
||||||
|
creation, sessionData, err := wa.BeginRegistration(waUser, options...)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := passkeysvc.SaveSessionData(c, passkeysvc.RegistrationSessionKey, sessionData); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": gin.H{
|
||||||
|
"options": creation,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyRegisterFinish(c *gin.Context) {
|
||||||
|
if !system_setting.GetPasskeySettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未启用 Passkey 登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := getSessionUser(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credentialRecord, err := model.GetPasskeyByUserID(user.Id)
|
||||||
|
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, model.ErrPasskeyNotFound) {
|
||||||
|
credentialRecord = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.RegistrationSessionKey)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord)
|
||||||
|
credential, err := wa.FinishRegistration(waUser, *sessionData, c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passkeyCredential := model.NewPasskeyCredentialFromWebAuthn(user.Id, credential)
|
||||||
|
if passkeyCredential == nil {
|
||||||
|
common.ApiErrorMsg(c, "无法创建 Passkey 凭证")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.UpsertPasskeyCredential(passkeyCredential); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "Passkey 注册成功",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyDelete(c *gin.Context) {
|
||||||
|
user, err := getSessionUser(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "Passkey 已解绑",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyStatus(c *gin.Context) {
|
||||||
|
user, err := getSessionUser(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := model.GetPasskeyByUserID(user.Id)
|
||||||
|
if errors.Is(err, model.ErrPasskeyNotFound) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": gin.H{
|
||||||
|
"enabled": false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := gin.H{
|
||||||
|
"enabled": true,
|
||||||
|
"last_used_at": credential.LastUsedAt,
|
||||||
|
"backup_eligible": credential.BackupEligible,
|
||||||
|
"backup_state": credential.BackupState,
|
||||||
|
}
|
||||||
|
if credential != nil {
|
||||||
|
data["credential_aaguid"] = fmt.Sprintf("%x", credential.AAGUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyLoginBegin(c *gin.Context) {
|
||||||
|
if !system_setting.GetPasskeySettings().Enabled {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
assertion, sessionData, err := wa.BeginDiscoverableLogin()
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := passkeysvc.SaveSessionData(c, passkeysvc.LoginSessionKey, sessionData); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": gin.H{
|
||||||
|
"options": assertion,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyLoginFinish(c *gin.Context) {
|
||||||
|
if !system_setting.GetPasskeySettings().Enabled {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.LoginSessionKey)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := func(rawID, userHandle []byte) (webauthnlib.User, error) {
|
||||||
|
// 首先通过凭证ID查找用户
|
||||||
|
credential, err := model.GetPasskeyByCredentialID(rawID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过凭证获取用户
|
||||||
|
user := &model.User{Id: credential.UserID}
|
||||||
|
if err := user.FillUserById(); err != nil {
|
||||||
|
return nil, fmt.Errorf("用户信息获取失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status != common.UserStatusEnabled {
|
||||||
|
return nil, errors.New("该用户已被禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户句柄(如果提供的话)
|
||||||
|
if len(userHandle) > 0 {
|
||||||
|
if userID, parseErr := strconv.Atoi(string(userHandle)); parseErr == nil {
|
||||||
|
if userID != user.Id {
|
||||||
|
return nil, errors.New("用户句柄与凭证不匹配")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果解析失败,不做严格验证,因为某些情况下userHandle可能为空或格式不同
|
||||||
|
}
|
||||||
|
|
||||||
|
return passkeysvc.NewWebAuthnUser(user, credential), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
waUser, credential, err := wa.FinishPasskeyLogin(handler, *sessionData, c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userWrapper, ok := waUser.(*passkeysvc.WebAuthnUser)
|
||||||
|
if !ok {
|
||||||
|
common.ApiErrorMsg(c, "Passkey 登录状态异常")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelUser := userWrapper.ModelUser()
|
||||||
|
if modelUser == nil {
|
||||||
|
common.ApiErrorMsg(c, "Passkey 登录状态异常")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if modelUser.Status != common.UserStatusEnabled {
|
||||||
|
common.ApiErrorMsg(c, "该用户已被禁用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新凭证信息
|
||||||
|
updatedCredential := model.NewPasskeyCredentialFromWebAuthn(modelUser.Id, credential)
|
||||||
|
if updatedCredential == nil {
|
||||||
|
common.ApiErrorMsg(c, "Passkey 凭证更新失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
updatedCredential.LastUsedAt = &now
|
||||||
|
if err := model.UpsertPasskeyCredential(updatedCredential); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setupLogin(modelUser, c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminResetPasskey(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
common.ApiErrorMsg(c, "无效的用户 ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &model.User{Id: id}
|
||||||
|
if err := user.FillUserById(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := model.GetPasskeyByUserID(user.Id); err != nil {
|
||||||
|
if errors.Is(err, model.ErrPasskeyNotFound) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "该用户尚未绑定 Passkey",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "Passkey 已重置",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyVerifyBegin(c *gin.Context) {
|
||||||
|
if !system_setting.GetPasskeySettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未启用 Passkey 登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := getSessionUser(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := model.GetPasskeyByUserID(user.Id)
|
||||||
|
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)
|
||||||
|
assertion, sessionData, err := wa.BeginLogin(waUser)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := passkeysvc.SaveSessionData(c, passkeysvc.VerifySessionKey, sessionData); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": gin.H{
|
||||||
|
"options": assertion,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func PasskeyVerifyFinish(c *gin.Context) {
|
||||||
|
if !system_setting.GetPasskeySettings().Enabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "管理员未启用 Passkey 登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := getSessionUser(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credential, err := model.GetPasskeyByUserID(user.Id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "该用户尚未绑定 Passkey",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
waUser := passkeysvc.NewWebAuthnUser(user, credential)
|
||||||
|
_, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "Passkey 验证成功",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSessionUser(c *gin.Context) (*model.User, error) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
idRaw := session.Get("id")
|
||||||
|
if idRaw == nil {
|
||||||
|
return nil, errors.New("未登录")
|
||||||
|
}
|
||||||
|
id, ok := idRaw.(int)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("无效的会话信息")
|
||||||
|
}
|
||||||
|
user := &model.User{Id: id}
|
||||||
|
if err := user.FillUserById(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user.Status != common.UserStatusEnabled {
|
||||||
|
return nil, errors.New("该用户已被禁用")
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
20
go.mod
20
go.mod
@@ -1,7 +1,9 @@
|
|||||||
module one-api
|
module one-api
|
||||||
|
|
||||||
// +heroku goVersion go1.18
|
// +heroku goVersion go1.18
|
||||||
go 1.23.4
|
go 1.24.0
|
||||||
|
|
||||||
|
toolchain go1.24.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Calcium-Ion/go-epay v0.0.4
|
github.com/Calcium-Ion/go-epay v0.0.4
|
||||||
@@ -20,6 +22,7 @@ require (
|
|||||||
github.com/glebarez/sqlite v1.9.0
|
github.com/glebarez/sqlite v1.9.0
|
||||||
github.com/go-playground/validator/v10 v10.20.0
|
github.com/go-playground/validator/v10 v10.20.0
|
||||||
github.com/go-redis/redis/v8 v8.11.5
|
github.com/go-redis/redis/v8 v8.11.5
|
||||||
|
github.com/go-webauthn/webauthn v0.14.0
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible
|
github.com/golang-jwt/jwt v3.2.2+incompatible
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
@@ -35,10 +38,10 @@ require (
|
|||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
github.com/tidwall/sjson v1.2.5
|
github.com/tidwall/sjson v1.2.5
|
||||||
github.com/tiktoken-go/tokenizer v0.6.2
|
github.com/tiktoken-go/tokenizer v0.6.2
|
||||||
golang.org/x/crypto v0.35.0
|
golang.org/x/crypto v0.42.0
|
||||||
golang.org/x/image v0.23.0
|
golang.org/x/image v0.23.0
|
||||||
golang.org/x/net v0.35.0
|
golang.org/x/net v0.43.0
|
||||||
golang.org/x/sync v0.11.0
|
golang.org/x/sync v0.17.0
|
||||||
gorm.io/driver/mysql v1.4.3
|
gorm.io/driver/mysql v1.4.3
|
||||||
gorm.io/driver/postgres v1.5.2
|
gorm.io/driver/postgres v1.5.2
|
||||||
gorm.io/gorm v1.25.2
|
gorm.io/gorm v1.25.2
|
||||||
@@ -58,6 +61,7 @@ require (
|
|||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
@@ -65,8 +69,11 @@ require (
|
|||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||||
|
github.com/go-webauthn/x v0.1.25 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/google/go-cmp v0.6.0 // indirect
|
github.com/google/go-cmp v0.6.0 // indirect
|
||||||
|
github.com/google/go-tpm v0.9.5 // indirect
|
||||||
github.com/gorilla/context v1.1.1 // indirect
|
github.com/gorilla/context v1.1.1 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.1 // indirect
|
github.com/gorilla/securecookie v1.1.1 // indirect
|
||||||
github.com/gorilla/sessions v1.2.1 // indirect
|
github.com/gorilla/sessions v1.2.1 // indirect
|
||||||
@@ -91,11 +98,12 @@ require (
|
|||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.3 // indirect
|
github.com/yusufpapurcu/wmi v1.2.3 // indirect
|
||||||
golang.org/x/arch v0.12.0 // indirect
|
golang.org/x/arch v0.12.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
|
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
golang.org/x/text v0.22.0 // indirect
|
golang.org/x/text v0.29.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.2 // indirect
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.22.5 // indirect
|
modernc.org/libc v1.22.5 // indirect
|
||||||
|
|||||||
37
go.sum
37
go.sum
@@ -47,6 +47,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||||
@@ -89,16 +91,24 @@ github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq
|
|||||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||||
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
|
||||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
|
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
|
||||||
|
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
|
||||||
|
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
|
||||||
|
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
|
||||||
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||||
|
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
@@ -200,8 +210,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw=
|
github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw=
|
||||||
github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
|
github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
|
||||||
github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o=
|
github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o=
|
||||||
@@ -229,27 +240,31 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY
|
|||||||
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
|
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
|
||||||
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
|
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
|
||||||
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
|
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8=
|
||||||
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
|
golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
|
||||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -261,14 +276,14 @@ golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ func migrateDB() error {
|
|||||||
&Channel{},
|
&Channel{},
|
||||||
&Token{},
|
&Token{},
|
||||||
&User{},
|
&User{},
|
||||||
|
&PasskeyCredential{},
|
||||||
&Option{},
|
&Option{},
|
||||||
&Redemption{},
|
&Redemption{},
|
||||||
&Ability{},
|
&Ability{},
|
||||||
@@ -283,6 +284,7 @@ func migrateDBFast() error {
|
|||||||
{&Channel{}, "Channel"},
|
{&Channel{}, "Channel"},
|
||||||
{&Token{}, "Token"},
|
{&Token{}, "Token"},
|
||||||
{&User{}, "User"},
|
{&User{}, "User"},
|
||||||
|
{&PasskeyCredential{}, "PasskeyCredential"},
|
||||||
{&Option{}, "Option"},
|
{&Option{}, "Option"},
|
||||||
{&Redemption{}, "Redemption"},
|
{&Redemption{}, "Redemption"},
|
||||||
{&Ability{}, "Ability"},
|
{&Ability{}, "Ability"},
|
||||||
|
|||||||
202
model/passkey.go
Normal file
202
model/passkey.go
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"one-api/common"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrPasskeyNotFound = errors.New("passkey credential not found")
|
||||||
|
ErrFriendlyPasskeyNotFound = errors.New("Passkey 验证失败,请重试或联系管理员")
|
||||||
|
)
|
||||||
|
|
||||||
|
type PasskeyCredential struct {
|
||||||
|
ID int `json:"id" gorm:"primaryKey"`
|
||||||
|
UserID int `json:"user_id" gorm:"uniqueIndex;not null"`
|
||||||
|
CredentialID []byte `json:"credential_id" gorm:"type:blob;uniqueIndex;not null"`
|
||||||
|
PublicKey []byte `json:"public_key" gorm:"type:blob;not null"`
|
||||||
|
AttestationType string `json:"attestation_type" gorm:"type:varchar(255)"`
|
||||||
|
AAGUID []byte `json:"aaguid" gorm:"type:blob"`
|
||||||
|
SignCount uint32 `json:"sign_count" gorm:"default:0"`
|
||||||
|
CloneWarning bool `json:"clone_warning"`
|
||||||
|
UserPresent bool `json:"user_present"`
|
||||||
|
UserVerified bool `json:"user_verified"`
|
||||||
|
BackupEligible bool `json:"backup_eligible"`
|
||||||
|
BackupState bool `json:"backup_state"`
|
||||||
|
Transports string `json:"transports" gorm:"type:text"`
|
||||||
|
Attachment string `json:"attachment" gorm:"type:varchar(32)"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PasskeyCredential) TransportList() []protocol.AuthenticatorTransport {
|
||||||
|
if p == nil || strings.TrimSpace(p.Transports) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var transports []string
|
||||||
|
if err := json.Unmarshal([]byte(p.Transports), &transports); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]protocol.AuthenticatorTransport, 0, len(transports))
|
||||||
|
for _, transport := range transports {
|
||||||
|
result = append(result, protocol.AuthenticatorTransport(transport))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PasskeyCredential) SetTransports(list []protocol.AuthenticatorTransport) {
|
||||||
|
if len(list) == 0 {
|
||||||
|
p.Transports = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stringList := make([]string, len(list))
|
||||||
|
for i, transport := range list {
|
||||||
|
stringList[i] = string(transport)
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(stringList)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Transports = string(encoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PasskeyCredential) ToWebAuthnCredential() webauthn.Credential {
|
||||||
|
flags := webauthn.CredentialFlags{
|
||||||
|
UserPresent: p.UserPresent,
|
||||||
|
UserVerified: p.UserVerified,
|
||||||
|
BackupEligible: p.BackupEligible,
|
||||||
|
BackupState: p.BackupState,
|
||||||
|
}
|
||||||
|
|
||||||
|
return webauthn.Credential{
|
||||||
|
ID: p.CredentialID,
|
||||||
|
PublicKey: p.PublicKey,
|
||||||
|
AttestationType: p.AttestationType,
|
||||||
|
Transport: p.TransportList(),
|
||||||
|
Flags: flags,
|
||||||
|
Authenticator: webauthn.Authenticator{
|
||||||
|
AAGUID: p.AAGUID,
|
||||||
|
SignCount: p.SignCount,
|
||||||
|
CloneWarning: p.CloneWarning,
|
||||||
|
Attachment: protocol.AuthenticatorAttachment(p.Attachment),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPasskeyCredentialFromWebAuthn(userID int, credential *webauthn.Credential) *PasskeyCredential {
|
||||||
|
if credential == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
passkey := &PasskeyCredential{
|
||||||
|
UserID: userID,
|
||||||
|
CredentialID: credential.ID,
|
||||||
|
PublicKey: credential.PublicKey,
|
||||||
|
AttestationType: credential.AttestationType,
|
||||||
|
AAGUID: credential.Authenticator.AAGUID,
|
||||||
|
SignCount: credential.Authenticator.SignCount,
|
||||||
|
CloneWarning: credential.Authenticator.CloneWarning,
|
||||||
|
UserPresent: credential.Flags.UserPresent,
|
||||||
|
UserVerified: credential.Flags.UserVerified,
|
||||||
|
BackupEligible: credential.Flags.BackupEligible,
|
||||||
|
BackupState: credential.Flags.BackupState,
|
||||||
|
Attachment: string(credential.Authenticator.Attachment),
|
||||||
|
}
|
||||||
|
passkey.SetTransports(credential.Transport)
|
||||||
|
return passkey
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PasskeyCredential) ApplyValidatedCredential(credential *webauthn.Credential) {
|
||||||
|
if credential == nil || p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.CredentialID = credential.ID
|
||||||
|
p.PublicKey = credential.PublicKey
|
||||||
|
p.AttestationType = credential.AttestationType
|
||||||
|
p.AAGUID = credential.Authenticator.AAGUID
|
||||||
|
p.SignCount = credential.Authenticator.SignCount
|
||||||
|
p.CloneWarning = credential.Authenticator.CloneWarning
|
||||||
|
p.UserPresent = credential.Flags.UserPresent
|
||||||
|
p.UserVerified = credential.Flags.UserVerified
|
||||||
|
p.BackupEligible = credential.Flags.BackupEligible
|
||||||
|
p.BackupState = credential.Flags.BackupState
|
||||||
|
p.Attachment = string(credential.Authenticator.Attachment)
|
||||||
|
p.SetTransports(credential.Transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) {
|
||||||
|
if userID == 0 {
|
||||||
|
common.SysLog("GetPasskeyByUserID: empty user ID")
|
||||||
|
return nil, ErrFriendlyPasskeyNotFound
|
||||||
|
}
|
||||||
|
var credential PasskeyCredential
|
||||||
|
if err := DB.Where("user_id = ?", userID).First(&credential).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
common.SysLog(fmt.Sprintf("GetPasskeyByUserID: passkey not found for user %d", userID))
|
||||||
|
return nil, ErrFriendlyPasskeyNotFound
|
||||||
|
}
|
||||||
|
common.SysLog(fmt.Sprintf("GetPasskeyByUserID: database error for user %d: %v", userID, err))
|
||||||
|
return nil, ErrFriendlyPasskeyNotFound
|
||||||
|
}
|
||||||
|
return &credential, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) {
|
||||||
|
if len(credentialID) == 0 {
|
||||||
|
common.SysLog("GetPasskeyByCredentialID: empty credential ID")
|
||||||
|
return nil, ErrFriendlyPasskeyNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
var credential PasskeyCredential
|
||||||
|
if err := DB.Where("credential_id = ?", credentialID).First(&credential).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: passkey not found for credential ID length %d", len(credentialID)))
|
||||||
|
return nil, ErrFriendlyPasskeyNotFound
|
||||||
|
}
|
||||||
|
common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: database error for credential ID: %v", err))
|
||||||
|
return nil, ErrFriendlyPasskeyNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return &credential, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpsertPasskeyCredential(credential *PasskeyCredential) error {
|
||||||
|
if credential == nil {
|
||||||
|
common.SysLog("UpsertPasskeyCredential: nil credential provided")
|
||||||
|
return fmt.Errorf("Passkey 保存失败,请重试")
|
||||||
|
}
|
||||||
|
return DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 使用Unscoped()进行硬删除,避免唯一索引冲突
|
||||||
|
if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil {
|
||||||
|
common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err))
|
||||||
|
return fmt.Errorf("Passkey 保存失败,请重试")
|
||||||
|
}
|
||||||
|
if err := tx.Create(credential).Error; err != nil {
|
||||||
|
common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err))
|
||||||
|
return fmt.Errorf("Passkey 保存失败,请重试")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeletePasskeyByUserID(userID int) error {
|
||||||
|
if userID == 0 {
|
||||||
|
common.SysLog("DeletePasskeyByUserID: empty user ID")
|
||||||
|
return fmt.Errorf("删除失败,请重试")
|
||||||
|
}
|
||||||
|
// 使用Unscoped()进行硬删除,避免唯一索引冲突
|
||||||
|
if err := DB.Unscoped().Where("user_id = ?", userID).Delete(&PasskeyCredential{}).Error; err != nil {
|
||||||
|
common.SysLog(fmt.Sprintf("DeletePasskeyByUserID: failed to delete passkey for user %d: %v", userID, err))
|
||||||
|
return fmt.Errorf("删除失败,请重试")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -45,6 +45,8 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
|
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
|
||||||
userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login)
|
userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login)
|
||||||
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin)
|
userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin)
|
||||||
|
userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), controller.PasskeyLoginBegin)
|
||||||
|
userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), controller.PasskeyLoginFinish)
|
||||||
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
|
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
|
||||||
userRoute.GET("/logout", controller.Logout)
|
userRoute.GET("/logout", controller.Logout)
|
||||||
userRoute.GET("/epay/notify", controller.EpayNotify)
|
userRoute.GET("/epay/notify", controller.EpayNotify)
|
||||||
@@ -59,6 +61,12 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
selfRoute.PUT("/self", controller.UpdateSelf)
|
selfRoute.PUT("/self", controller.UpdateSelf)
|
||||||
selfRoute.DELETE("/self", controller.DeleteSelf)
|
selfRoute.DELETE("/self", controller.DeleteSelf)
|
||||||
selfRoute.GET("/token", controller.GenerateAccessToken)
|
selfRoute.GET("/token", controller.GenerateAccessToken)
|
||||||
|
selfRoute.GET("/passkey", controller.PasskeyStatus)
|
||||||
|
selfRoute.POST("/passkey/register/begin", controller.PasskeyRegisterBegin)
|
||||||
|
selfRoute.POST("/passkey/register/finish", controller.PasskeyRegisterFinish)
|
||||||
|
selfRoute.POST("/passkey/verify/begin", controller.PasskeyVerifyBegin)
|
||||||
|
selfRoute.POST("/passkey/verify/finish", controller.PasskeyVerifyFinish)
|
||||||
|
selfRoute.DELETE("/passkey", controller.PasskeyDelete)
|
||||||
selfRoute.GET("/aff", controller.GetAffCode)
|
selfRoute.GET("/aff", controller.GetAffCode)
|
||||||
selfRoute.GET("/topup/info", controller.GetTopUpInfo)
|
selfRoute.GET("/topup/info", controller.GetTopUpInfo)
|
||||||
selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp)
|
selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp)
|
||||||
@@ -87,6 +95,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
adminRoute.POST("/manage", controller.ManageUser)
|
adminRoute.POST("/manage", controller.ManageUser)
|
||||||
adminRoute.PUT("/", controller.UpdateUser)
|
adminRoute.PUT("/", controller.UpdateUser)
|
||||||
adminRoute.DELETE("/:id", controller.DeleteUser)
|
adminRoute.DELETE("/:id", controller.DeleteUser)
|
||||||
|
adminRoute.DELETE("/:id/passkey", controller.AdminResetPasskey)
|
||||||
|
|
||||||
// Admin 2FA routes
|
// Admin 2FA routes
|
||||||
adminRoute.GET("/2fa/stats", controller.Admin2FAStats)
|
adminRoute.GET("/2fa/stats", controller.Admin2FAStats)
|
||||||
@@ -116,6 +125,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
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(), 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)
|
||||||
|
|||||||
175
service/passkey/service.go
Normal file
175
service/passkey/service.go
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
package passkey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"one-api/common"
|
||||||
|
"one-api/setting/system_setting"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
webauthn "github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
RegistrationSessionKey = "passkey_registration_session"
|
||||||
|
LoginSessionKey = "passkey_login_session"
|
||||||
|
VerifySessionKey = "passkey_verify_session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildWebAuthn constructs a WebAuthn instance using the current passkey settings and request context.
|
||||||
|
func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) {
|
||||||
|
settings := system_setting.GetPasskeySettings()
|
||||||
|
if settings == nil {
|
||||||
|
return nil, errors.New("未找到 Passkey 设置")
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName := strings.TrimSpace(settings.RPDisplayName)
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = common.SystemName
|
||||||
|
}
|
||||||
|
|
||||||
|
origins, err := resolveOrigins(r, settings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rpID, err := resolveRPID(r, settings, origins)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
selection := protocol.AuthenticatorSelection{
|
||||||
|
ResidentKey: protocol.ResidentKeyRequirementRequired,
|
||||||
|
RequireResidentKey: protocol.ResidentKeyRequired(),
|
||||||
|
UserVerification: protocol.UserVerificationRequirement(settings.UserVerification),
|
||||||
|
}
|
||||||
|
if selection.UserVerification == "" {
|
||||||
|
selection.UserVerification = protocol.VerificationPreferred
|
||||||
|
}
|
||||||
|
if attachment := strings.TrimSpace(settings.AttachmentPreference); attachment != "" {
|
||||||
|
selection.AuthenticatorAttachment = protocol.AuthenticatorAttachment(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &webauthn.Config{
|
||||||
|
RPID: rpID,
|
||||||
|
RPDisplayName: displayName,
|
||||||
|
RPOrigins: origins,
|
||||||
|
AuthenticatorSelection: selection,
|
||||||
|
Debug: common.DebugEnabled,
|
||||||
|
Timeouts: webauthn.TimeoutsConfig{
|
||||||
|
Login: webauthn.TimeoutConfig{
|
||||||
|
Enforce: true,
|
||||||
|
Timeout: 2 * time.Minute,
|
||||||
|
TimeoutUVD: 2 * time.Minute,
|
||||||
|
},
|
||||||
|
Registration: webauthn.TimeoutConfig{
|
||||||
|
Enforce: true,
|
||||||
|
Timeout: 2 * time.Minute,
|
||||||
|
TimeoutUVD: 2 * time.Minute,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return webauthn.New(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveOrigins(r *http.Request, settings *system_setting.PasskeySettings) ([]string, error) {
|
||||||
|
if len(settings.Origins) > 0 {
|
||||||
|
origins := make([]string, 0, len(settings.Origins))
|
||||||
|
for _, origin := range settings.Origins {
|
||||||
|
trimmed := strings.TrimSpace(origin)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !settings.AllowInsecureOrigin && strings.HasPrefix(strings.ToLower(trimmed), "http://") {
|
||||||
|
return nil, fmt.Errorf("Passkey 不允许使用不安全的 Origin: %s", trimmed)
|
||||||
|
}
|
||||||
|
origins = append(origins, trimmed)
|
||||||
|
}
|
||||||
|
if len(origins) == 0 {
|
||||||
|
// 如果配置了Origins但过滤后为空,使用自动推导
|
||||||
|
goto autoDetect
|
||||||
|
}
|
||||||
|
return origins, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
autoDetect:
|
||||||
|
scheme := detectScheme(r)
|
||||||
|
if scheme == "http" && !settings.AllowInsecureOrigin && r.Host != "localhost" && r.Host != "127.0.0.1" && !strings.HasPrefix(r.Host, "127.0.0.1:") && !strings.HasPrefix(r.Host, "localhost:") {
|
||||||
|
return nil, fmt.Errorf("Passkey 仅支持 HTTPS,当前访问: %s://%s,请在 Passkey 设置中允许不安全 Origin 或配置 HTTPS", scheme, r.Host)
|
||||||
|
}
|
||||||
|
// 优先使用请求的完整Host(包含端口)
|
||||||
|
host := r.Host
|
||||||
|
|
||||||
|
// 如果无法从请求获取Host,尝试从ServerAddress获取
|
||||||
|
if host == "" && system_setting.ServerAddress != "" {
|
||||||
|
if parsed, err := url.Parse(system_setting.ServerAddress); err == nil && parsed.Host != "" {
|
||||||
|
host = parsed.Host
|
||||||
|
if scheme == "" && parsed.Scheme != "" {
|
||||||
|
scheme = parsed.Scheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
return nil, fmt.Errorf("无法确定 Passkey 的 Origin,请在系统设置或 Passkey 设置中指定。当前 Host: '%s', ServerAddress: '%s'", r.Host, system_setting.ServerAddress)
|
||||||
|
}
|
||||||
|
if scheme == "" {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
origin := fmt.Sprintf("%s://%s", scheme, host)
|
||||||
|
return []string{origin}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRPID(r *http.Request, settings *system_setting.PasskeySettings, origins []string) (string, error) {
|
||||||
|
rpID := strings.TrimSpace(settings.RPID)
|
||||||
|
if rpID != "" {
|
||||||
|
return hostWithoutPort(rpID), nil
|
||||||
|
}
|
||||||
|
if len(origins) == 0 {
|
||||||
|
return "", errors.New("Passkey 未配置 Origin,无法推导 RPID")
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(origins[0])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("无法解析 Passkey Origin: %w", err)
|
||||||
|
}
|
||||||
|
return hostWithoutPort(parsed.Host), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostWithoutPort(host string) string {
|
||||||
|
host = strings.TrimSpace(host)
|
||||||
|
if host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(host, ":") {
|
||||||
|
if host, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectScheme(r *http.Request) string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||||
|
parts := strings.Split(proto, ",")
|
||||||
|
return strings.ToLower(strings.TrimSpace(parts[0]))
|
||||||
|
}
|
||||||
|
if r.TLS != nil {
|
||||||
|
return "https"
|
||||||
|
}
|
||||||
|
if r.URL != nil && r.URL.Scheme != "" {
|
||||||
|
return strings.ToLower(r.URL.Scheme)
|
||||||
|
}
|
||||||
|
if r.Header.Get("X-Forwarded-Protocol") != "" {
|
||||||
|
return strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Protocol")))
|
||||||
|
}
|
||||||
|
return "http"
|
||||||
|
}
|
||||||
50
service/passkey/session.go
Normal file
50
service/passkey/session.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package passkey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
webauthn "github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errSessionNotFound = errors.New("Passkey 会话不存在或已过期")
|
||||||
|
|
||||||
|
func SaveSessionData(c *gin.Context, key string, data *webauthn.SessionData) error {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
if data == nil {
|
||||||
|
session.Delete(key)
|
||||||
|
return session.Save()
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
session.Set(key, string(payload))
|
||||||
|
return session.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func PopSessionData(c *gin.Context, key string) (*webauthn.SessionData, error) {
|
||||||
|
session := sessions.Default(c)
|
||||||
|
raw := session.Get(key)
|
||||||
|
if raw == nil {
|
||||||
|
return nil, errSessionNotFound
|
||||||
|
}
|
||||||
|
session.Delete(key)
|
||||||
|
_ = session.Save()
|
||||||
|
var data webauthn.SessionData
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case string:
|
||||||
|
if err := json.Unmarshal([]byte(value), &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
case []byte:
|
||||||
|
if err := json.Unmarshal(value, &data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, errors.New("Passkey 会话格式无效")
|
||||||
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
71
service/passkey/user.go
Normal file
71
service/passkey/user.go
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package passkey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"one-api/model"
|
||||||
|
|
||||||
|
webauthn "github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebAuthnUser struct {
|
||||||
|
user *model.User
|
||||||
|
credential *model.PasskeyCredential
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWebAuthnUser(user *model.User, credential *model.PasskeyCredential) *WebAuthnUser {
|
||||||
|
return &WebAuthnUser{user: user, credential: credential}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *WebAuthnUser) WebAuthnID() []byte {
|
||||||
|
if u == nil || u.user == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []byte(strconv.Itoa(u.user.Id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *WebAuthnUser) WebAuthnName() string {
|
||||||
|
if u == nil || u.user == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(u.user.Username)
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Sprintf("user-%d", u.user.Id)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *WebAuthnUser) WebAuthnDisplayName() string {
|
||||||
|
if u == nil || u.user == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
display := strings.TrimSpace(u.user.DisplayName)
|
||||||
|
if display != "" {
|
||||||
|
return display
|
||||||
|
}
|
||||||
|
return u.WebAuthnName()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
|
||||||
|
if u == nil || u.credential == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cred := u.credential.ToWebAuthnCredential()
|
||||||
|
return []webauthn.Credential{cred}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *WebAuthnUser) ModelUser() *model.User {
|
||||||
|
if u == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return u.user
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *WebAuthnUser) PasskeyCredential() *model.PasskeyCredential {
|
||||||
|
if u == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return u.credential
|
||||||
|
}
|
||||||
34
setting/system_setting/passkey.go
Normal file
34
setting/system_setting/passkey.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package system_setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"one-api/common"
|
||||||
|
"one-api/setting/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PasskeySettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
RPDisplayName string `json:"rp_display_name"`
|
||||||
|
RPID string `json:"rp_id"`
|
||||||
|
Origins []string `json:"origins"`
|
||||||
|
AllowInsecureOrigin bool `json:"allow_insecure_origin"`
|
||||||
|
UserVerification string `json:"user_verification"`
|
||||||
|
AttachmentPreference string `json:"attachment_preference"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultPasskeySettings = PasskeySettings{
|
||||||
|
Enabled: false,
|
||||||
|
RPDisplayName: common.SystemName,
|
||||||
|
RPID: "",
|
||||||
|
Origins: []string{},
|
||||||
|
AllowInsecureOrigin: false,
|
||||||
|
UserVerification: "preferred",
|
||||||
|
AttachmentPreference: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
config.GlobalConfig.Register("passkey", &defaultPasskeySettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPasskeySettings() *PasskeySettings {
|
||||||
|
return &defaultPasskeySettings
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ import {
|
|||||||
onGitHubOAuthClicked,
|
onGitHubOAuthClicked,
|
||||||
onOIDCClicked,
|
onOIDCClicked,
|
||||||
onLinuxDOOAuthClicked,
|
onLinuxDOOAuthClicked,
|
||||||
|
prepareCredentialRequestOptions,
|
||||||
|
buildAssertionResult,
|
||||||
|
isPasskeySupported,
|
||||||
} from '../../helpers';
|
} from '../../helpers';
|
||||||
import Turnstile from 'react-turnstile';
|
import Turnstile from 'react-turnstile';
|
||||||
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||||
@@ -39,7 +42,7 @@ import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
|||||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||||
import TelegramLoginButton from 'react-telegram-login';
|
import TelegramLoginButton from 'react-telegram-login';
|
||||||
|
|
||||||
import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
|
import { IconGithubLogo, IconMail, IconLock, IconKey } from '@douyinfe/semi-icons';
|
||||||
import OIDCIcon from '../common/logo/OIDCIcon';
|
import OIDCIcon from '../common/logo/OIDCIcon';
|
||||||
import WeChatIcon from '../common/logo/WeChatIcon';
|
import WeChatIcon from '../common/logo/WeChatIcon';
|
||||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
||||||
@@ -74,6 +77,8 @@ const LoginForm = () => {
|
|||||||
useState(false);
|
useState(false);
|
||||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||||
const [showTwoFA, setShowTwoFA] = useState(false);
|
const [showTwoFA, setShowTwoFA] = useState(false);
|
||||||
|
const [passkeySupported, setPasskeySupported] = useState(false);
|
||||||
|
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||||
|
|
||||||
const logo = getLogo();
|
const logo = getLogo();
|
||||||
const systemName = getSystemName();
|
const systemName = getSystemName();
|
||||||
@@ -95,6 +100,12 @@ const LoginForm = () => {
|
|||||||
}
|
}
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isPasskeySupported()
|
||||||
|
.then(setPasskeySupported)
|
||||||
|
.catch(() => setPasskeySupported(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get('expired')) {
|
if (searchParams.get('expired')) {
|
||||||
showError(t('未登录或登录已过期,请重新登录'));
|
showError(t('未登录或登录已过期,请重新登录'));
|
||||||
@@ -266,6 +277,55 @@ const LoginForm = () => {
|
|||||||
setEmailLoginLoading(false);
|
setEmailLoginLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePasskeyLogin = async () => {
|
||||||
|
if (!passkeySupported) {
|
||||||
|
showInfo('当前环境无法使用 Passkey 登录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.PublicKeyCredential) {
|
||||||
|
showInfo('当前浏览器不支持 Passkey');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPasskeyLoading(true);
|
||||||
|
try {
|
||||||
|
const beginRes = await API.post('/api/user/passkey/login/begin');
|
||||||
|
const { success, message, data } = beginRes.data;
|
||||||
|
if (!success) {
|
||||||
|
showError(message || '无法发起 Passkey 登录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKeyOptions = prepareCredentialRequestOptions(data?.options || data?.publicKey || data);
|
||||||
|
const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions });
|
||||||
|
const payload = buildAssertionResult(assertion);
|
||||||
|
if (!payload) {
|
||||||
|
showError('Passkey 验证失败,请重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishRes = await API.post('/api/user/passkey/login/finish', payload);
|
||||||
|
const finish = finishRes.data;
|
||||||
|
if (finish.success) {
|
||||||
|
userDispatch({ type: 'login', payload: finish.data });
|
||||||
|
setUserData(finish.data);
|
||||||
|
updateAPI();
|
||||||
|
showSuccess('登录成功!');
|
||||||
|
navigate('/console');
|
||||||
|
} else {
|
||||||
|
showError(finish.message || 'Passkey 登录失败,请重试');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
showInfo('已取消 Passkey 登录');
|
||||||
|
} else {
|
||||||
|
showError('Passkey 登录失败,请重试');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPasskeyLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 包装的重置密码点击处理
|
// 包装的重置密码点击处理
|
||||||
const handleResetPasswordClick = () => {
|
const handleResetPasswordClick = () => {
|
||||||
setResetPasswordLoading(true);
|
setResetPasswordLoading(true);
|
||||||
@@ -385,6 +445,19 @@ const LoginForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{status.passkey_login && passkeySupported && (
|
||||||
|
<Button
|
||||||
|
theme='outline'
|
||||||
|
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||||
|
type='tertiary'
|
||||||
|
icon={<IconKey size='large' />}
|
||||||
|
onClick={handlePasskeyLogin}
|
||||||
|
loading={passkeyLoading}
|
||||||
|
>
|
||||||
|
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider margin='12px' align='center'>
|
<Divider margin='12px' align='center'>
|
||||||
{t('或')}
|
{t('或')}
|
||||||
</Divider>
|
</Divider>
|
||||||
@@ -437,6 +510,18 @@ const LoginForm = () => {
|
|||||||
</Title>
|
</Title>
|
||||||
</div>
|
</div>
|
||||||
<div className='px-2 py-8'>
|
<div className='px-2 py-8'>
|
||||||
|
{status.passkey_login && passkeySupported && (
|
||||||
|
<Button
|
||||||
|
theme='outline'
|
||||||
|
type='tertiary'
|
||||||
|
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors mb-4'
|
||||||
|
icon={<IconKey size='large' />}
|
||||||
|
onClick={handlePasskeyLogin}
|
||||||
|
loading={passkeyLoading}
|
||||||
|
>
|
||||||
|
<span className='ml-3'>{t('使用 Passkey 登录')}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Form className='space-y-3'>
|
<Form className='space-y-3'>
|
||||||
<Form.Input
|
<Form.Input
|
||||||
field='username'
|
field='username'
|
||||||
|
|||||||
117
web/src/components/common/examples/ChannelKeyViewExample.jsx
Normal file
117
web/src/components/common/examples/ChannelKeyViewExample.jsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Modal } from '@douyinfe/semi-ui';
|
||||||
|
import { useSecureVerification } from '../../../hooks/common/useSecureVerification';
|
||||||
|
import { createApiCalls } from '../../../services/secureVerification';
|
||||||
|
import SecureVerificationModal from '../modals/SecureVerificationModal';
|
||||||
|
import ChannelKeyDisplay from '../ui/ChannelKeyDisplay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渠道密钥查看组件使用示例
|
||||||
|
* 展示如何使用通用安全验证系统
|
||||||
|
*/
|
||||||
|
const ChannelKeyViewExample = ({ channelId }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [keyData, setKeyData] = useState('');
|
||||||
|
const [showKeyModal, setShowKeyModal] = useState(false);
|
||||||
|
|
||||||
|
// 使用通用安全验证 Hook
|
||||||
|
const {
|
||||||
|
isModalVisible,
|
||||||
|
verificationMethods,
|
||||||
|
verificationState,
|
||||||
|
startVerification,
|
||||||
|
executeVerification,
|
||||||
|
cancelVerification,
|
||||||
|
setVerificationCode,
|
||||||
|
switchVerificationMethod,
|
||||||
|
} = useSecureVerification({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
// 验证成功后处理结果
|
||||||
|
if (result.success && result.data?.key) {
|
||||||
|
setKeyData(result.data.key);
|
||||||
|
setShowKeyModal(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
successMessage: t('密钥获取成功'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 开始查看密钥流程
|
||||||
|
const handleViewKey = async () => {
|
||||||
|
const apiCall = createApiCalls.viewChannelKey(channelId);
|
||||||
|
|
||||||
|
await startVerification(apiCall, {
|
||||||
|
title: t('查看渠道密钥'),
|
||||||
|
description: t('为了保护账户安全,请验证您的身份。'),
|
||||||
|
preferredMethod: 'passkey', // 可以指定首选验证方式
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 查看密钥按钮 */}
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
theme='outline'
|
||||||
|
onClick={handleViewKey}
|
||||||
|
>
|
||||||
|
{t('查看密钥')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* 安全验证模态框 */}
|
||||||
|
<SecureVerificationModal
|
||||||
|
visible={isModalVisible}
|
||||||
|
verificationMethods={verificationMethods}
|
||||||
|
verificationState={verificationState}
|
||||||
|
onVerify={executeVerification}
|
||||||
|
onCancel={cancelVerification}
|
||||||
|
onCodeChange={setVerificationCode}
|
||||||
|
onMethodSwitch={switchVerificationMethod}
|
||||||
|
title={verificationState.title}
|
||||||
|
description={verificationState.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 密钥显示模态框 */}
|
||||||
|
<Modal
|
||||||
|
title={t('渠道密钥信息')}
|
||||||
|
visible={showKeyModal}
|
||||||
|
onCancel={() => setShowKeyModal(false)}
|
||||||
|
footer={
|
||||||
|
<Button type='primary' onClick={() => setShowKeyModal(false)}>
|
||||||
|
{t('完成')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
width={700}
|
||||||
|
style={{ maxWidth: '90vw' }}
|
||||||
|
>
|
||||||
|
<ChannelKeyDisplay
|
||||||
|
keyData={keyData}
|
||||||
|
showSuccessIcon={true}
|
||||||
|
successText={t('密钥获取成功')}
|
||||||
|
showWarning={true}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChannelKeyViewExample;
|
||||||
271
web/src/components/common/modals/SecureVerificationModal.jsx
Normal file
271
web/src/components/common/modals/SecureVerificationModal.jsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
/*
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Modal, Button, Input, Typography, Tabs, TabPane, Card } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用安全验证模态框组件
|
||||||
|
* 配合 useSecureVerification Hook 使用
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {boolean} props.visible - 是否显示模态框
|
||||||
|
* @param {Object} props.verificationMethods - 可用的验证方式
|
||||||
|
* @param {Object} props.verificationState - 当前验证状态
|
||||||
|
* @param {Function} props.onVerify - 验证回调
|
||||||
|
* @param {Function} props.onCancel - 取消回调
|
||||||
|
* @param {Function} props.onCodeChange - 验证码变化回调
|
||||||
|
* @param {Function} props.onMethodSwitch - 验证方式切换回调
|
||||||
|
* @param {string} props.title - 模态框标题
|
||||||
|
* @param {string} props.description - 验证描述文本
|
||||||
|
*/
|
||||||
|
const SecureVerificationModal = ({
|
||||||
|
visible,
|
||||||
|
verificationMethods,
|
||||||
|
verificationState,
|
||||||
|
onVerify,
|
||||||
|
onCancel,
|
||||||
|
onCodeChange,
|
||||||
|
onMethodSwitch,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { has2FA, hasPasskey, passkeySupported } = verificationMethods;
|
||||||
|
const { method, loading, code } = verificationState;
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') {
|
||||||
|
onVerify(method, code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果用户没有启用任何验证方式
|
||||||
|
if (visible && !has2FA && !hasPasskey) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title || t('安全验证')}
|
||||||
|
visible={visible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={
|
||||||
|
<Button onClick={onCancel}>{t('确定')}</Button>
|
||||||
|
}
|
||||||
|
width={500}
|
||||||
|
style={{ maxWidth: '90vw' }}
|
||||||
|
>
|
||||||
|
<div className='text-center py-6'>
|
||||||
|
<div className='mb-4'>
|
||||||
|
<svg
|
||||||
|
className='w-16 h-16 text-yellow-500 mx-auto mb-4'
|
||||||
|
fill='currentColor'
|
||||||
|
viewBox='0 0 20 20'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule='evenodd'
|
||||||
|
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
|
||||||
|
clipRule='evenodd'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<Typography.Title heading={4} className='mb-2'>
|
||||||
|
{t('需要安全验证')}
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type='tertiary'>
|
||||||
|
{t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')}
|
||||||
|
</Typography.Text>
|
||||||
|
<br />
|
||||||
|
<Typography.Text type='tertiary'>
|
||||||
|
{t('请前往个人设置 → 安全设置进行配置。')}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<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}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={null}
|
||||||
|
width={600}
|
||||||
|
style={{ maxWidth: '90vw' }}
|
||||||
|
>
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* 安全提示 */}
|
||||||
|
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
|
||||||
|
<div className='flex items-start'>
|
||||||
|
<svg
|
||||||
|
className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0'
|
||||||
|
fill='currentColor'
|
||||||
|
viewBox='0 0 20 20'
|
||||||
|
>
|
||||||
|
<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'
|
||||||
|
clipRule='evenodd'
|
||||||
|
/>
|
||||||
|
</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'>
|
||||||
|
{has2FA && (
|
||||||
|
<TabPane
|
||||||
|
tab={
|
||||||
|
<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'
|
||||||
|
>
|
||||||
|
<Card className='border-0 shadow-none bg-transparent'>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong className='block mb-2'>
|
||||||
|
{t('验证码')}
|
||||||
|
</Typography.Text>
|
||||||
|
<Input
|
||||||
|
placeholder={t('请输入认证器验证码或备用码')}
|
||||||
|
value={code}
|
||||||
|
onChange={onCodeChange}
|
||||||
|
size='large'
|
||||||
|
maxLength={8}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoFocus={method === '2fa'}
|
||||||
|
/>
|
||||||
|
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
||||||
|
{t('支持6位TOTP验证码或8位备用码')}
|
||||||
|
</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>
|
||||||
|
</Card>
|
||||||
|
</TabPane>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPasskey && passkeySupported && (
|
||||||
|
<TabPane
|
||||||
|
tab={
|
||||||
|
<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'
|
||||||
|
>
|
||||||
|
<Card className='border-0 shadow-none bg-transparent'>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='text-center py-4'>
|
||||||
|
<div className='mb-4'>
|
||||||
|
<svg
|
||||||
|
className='w-16 h-16 text-blue-500 mx-auto'
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<Typography.Text strong className='block mb-2'>
|
||||||
|
{t('使用 Passkey 验证')}
|
||||||
|
</Typography.Text>
|
||||||
|
<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>
|
||||||
|
</Card>
|
||||||
|
</TabPane>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SecureVerificationModal;
|
||||||
@@ -26,6 +26,9 @@ import {
|
|||||||
showInfo,
|
showInfo,
|
||||||
showSuccess,
|
showSuccess,
|
||||||
setStatusData,
|
setStatusData,
|
||||||
|
prepareCredentialCreationOptions,
|
||||||
|
buildRegistrationResult,
|
||||||
|
isPasskeySupported,
|
||||||
} from '../../helpers';
|
} from '../../helpers';
|
||||||
import { UserContext } from '../../context/User';
|
import { UserContext } from '../../context/User';
|
||||||
import { Modal } from '@douyinfe/semi-ui';
|
import { Modal } from '@douyinfe/semi-ui';
|
||||||
@@ -66,6 +69,10 @@ const PersonalSetting = () => {
|
|||||||
const [disableButton, setDisableButton] = useState(false);
|
const [disableButton, setDisableButton] = useState(false);
|
||||||
const [countdown, setCountdown] = useState(30);
|
const [countdown, setCountdown] = useState(30);
|
||||||
const [systemToken, setSystemToken] = useState('');
|
const [systemToken, setSystemToken] = useState('');
|
||||||
|
const [passkeyStatus, setPasskeyStatus] = useState({ enabled: false });
|
||||||
|
const [passkeyRegisterLoading, setPasskeyRegisterLoading] = useState(false);
|
||||||
|
const [passkeyDeleteLoading, setPasskeyDeleteLoading] = useState(false);
|
||||||
|
const [passkeySupported, setPasskeySupported] = useState(false);
|
||||||
const [notificationSettings, setNotificationSettings] = useState({
|
const [notificationSettings, setNotificationSettings] = useState({
|
||||||
warningType: 'email',
|
warningType: 'email',
|
||||||
warningThreshold: 100000,
|
warningThreshold: 100000,
|
||||||
@@ -112,6 +119,10 @@ const PersonalSetting = () => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
getUserData();
|
getUserData();
|
||||||
|
|
||||||
|
isPasskeySupported()
|
||||||
|
.then(setPasskeySupported)
|
||||||
|
.catch(() => setPasskeySupported(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -160,11 +171,89 @@ const PersonalSetting = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadPasskeyStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await API.get('/api/user/passkey');
|
||||||
|
const { success, data, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
setPasskeyStatus({
|
||||||
|
enabled: data?.enabled || false,
|
||||||
|
last_used_at: data?.last_used_at || null,
|
||||||
|
backup_eligible: data?.backup_eligible || false,
|
||||||
|
backup_state: data?.backup_state || false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showError(message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略错误,保留默认状态
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegisterPasskey = async () => {
|
||||||
|
if (!passkeySupported || !window.PublicKeyCredential) {
|
||||||
|
showInfo(t('当前设备不支持 Passkey'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPasskeyRegisterLoading(true);
|
||||||
|
try {
|
||||||
|
const beginRes = await API.post('/api/user/passkey/register/begin');
|
||||||
|
const { success, message, data } = beginRes.data;
|
||||||
|
if (!success) {
|
||||||
|
showError(message || t('无法发起 Passkey 注册'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicKey = prepareCredentialCreationOptions(data?.options || data?.publicKey || data);
|
||||||
|
const credential = await navigator.credentials.create({ publicKey });
|
||||||
|
const payload = buildRegistrationResult(credential);
|
||||||
|
if (!payload) {
|
||||||
|
showError(t('Passkey 注册失败,请重试'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishRes = await API.post('/api/user/passkey/register/finish', payload);
|
||||||
|
if (finishRes.data.success) {
|
||||||
|
showSuccess(t('Passkey 注册成功'));
|
||||||
|
await loadPasskeyStatus();
|
||||||
|
} else {
|
||||||
|
showError(finishRes.data.message || t('Passkey 注册失败,请重试'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.name === 'AbortError') {
|
||||||
|
showInfo(t('已取消 Passkey 注册'));
|
||||||
|
} else {
|
||||||
|
showError(t('Passkey 注册失败,请重试'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPasskeyRegisterLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemovePasskey = async () => {
|
||||||
|
setPasskeyDeleteLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.delete('/api/user/passkey');
|
||||||
|
const { success, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess(t('Passkey 已解绑'));
|
||||||
|
await loadPasskeyStatus();
|
||||||
|
} else {
|
||||||
|
showError(message || t('操作失败,请重试'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(t('操作失败,请重试'));
|
||||||
|
} finally {
|
||||||
|
setPasskeyDeleteLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getUserData = async () => {
|
const getUserData = async () => {
|
||||||
let res = await API.get(`/api/user/self`);
|
let res = await API.get(`/api/user/self`);
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
userDispatch({ type: 'login', payload: data });
|
userDispatch({ type: 'login', payload: data });
|
||||||
|
await loadPasskeyStatus();
|
||||||
} else {
|
} else {
|
||||||
showError(message);
|
showError(message);
|
||||||
}
|
}
|
||||||
@@ -352,6 +441,12 @@ const PersonalSetting = () => {
|
|||||||
handleSystemTokenClick={handleSystemTokenClick}
|
handleSystemTokenClick={handleSystemTokenClick}
|
||||||
setShowChangePasswordModal={setShowChangePasswordModal}
|
setShowChangePasswordModal={setShowChangePasswordModal}
|
||||||
setShowAccountDeleteModal={setShowAccountDeleteModal}
|
setShowAccountDeleteModal={setShowAccountDeleteModal}
|
||||||
|
passkeyStatus={passkeyStatus}
|
||||||
|
passkeySupported={passkeySupported}
|
||||||
|
passkeyRegisterLoading={passkeyRegisterLoading}
|
||||||
|
passkeyDeleteLoading={passkeyDeleteLoading}
|
||||||
|
onPasskeyRegister={handleRegisterPasskey}
|
||||||
|
onPasskeyDelete={handleRemovePasskey}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 右侧:其他设置 */}
|
{/* 右侧:其他设置 */}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
Spin,
|
Spin,
|
||||||
Card,
|
Card,
|
||||||
Radio,
|
Radio,
|
||||||
|
Select,
|
||||||
} from '@douyinfe/semi-ui';
|
} from '@douyinfe/semi-ui';
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
import {
|
import {
|
||||||
@@ -77,6 +78,13 @@ const SystemSetting = () => {
|
|||||||
TurnstileSiteKey: '',
|
TurnstileSiteKey: '',
|
||||||
TurnstileSecretKey: '',
|
TurnstileSecretKey: '',
|
||||||
RegisterEnabled: '',
|
RegisterEnabled: '',
|
||||||
|
'passkey.enabled': '',
|
||||||
|
'passkey.rp_display_name': '',
|
||||||
|
'passkey.rp_id': '',
|
||||||
|
'passkey.origins': [],
|
||||||
|
'passkey.allow_insecure_origin': '',
|
||||||
|
'passkey.user_verification': 'preferred',
|
||||||
|
'passkey.attachment_preference': '',
|
||||||
EmailDomainRestrictionEnabled: '',
|
EmailDomainRestrictionEnabled: '',
|
||||||
EmailAliasRestrictionEnabled: '',
|
EmailAliasRestrictionEnabled: '',
|
||||||
SMTPSSLEnabled: '',
|
SMTPSSLEnabled: '',
|
||||||
@@ -114,6 +122,7 @@ const SystemSetting = () => {
|
|||||||
const [domainList, setDomainList] = useState([]);
|
const [domainList, setDomainList] = useState([]);
|
||||||
const [ipList, setIpList] = useState([]);
|
const [ipList, setIpList] = useState([]);
|
||||||
const [allowedPorts, setAllowedPorts] = useState([]);
|
const [allowedPorts, setAllowedPorts] = useState([]);
|
||||||
|
const [passkeyOrigins, setPasskeyOrigins] = useState([]);
|
||||||
|
|
||||||
const getOptions = async () => {
|
const getOptions = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -173,9 +182,28 @@ const SystemSetting = () => {
|
|||||||
case 'SMTPSSLEnabled':
|
case 'SMTPSSLEnabled':
|
||||||
case 'LinuxDOOAuthEnabled':
|
case 'LinuxDOOAuthEnabled':
|
||||||
case 'oidc.enabled':
|
case 'oidc.enabled':
|
||||||
|
case 'passkey.enabled':
|
||||||
|
case 'passkey.allow_insecure_origin':
|
||||||
case 'WorkerAllowHttpImageRequestEnabled':
|
case 'WorkerAllowHttpImageRequestEnabled':
|
||||||
item.value = toBoolean(item.value);
|
item.value = toBoolean(item.value);
|
||||||
break;
|
break;
|
||||||
|
case 'passkey.origins':
|
||||||
|
try {
|
||||||
|
const origins = item.value ? JSON.parse(item.value) : [];
|
||||||
|
setPasskeyOrigins(Array.isArray(origins) ? origins : []);
|
||||||
|
item.value = Array.isArray(origins) ? origins : [];
|
||||||
|
} catch (e) {
|
||||||
|
setPasskeyOrigins([]);
|
||||||
|
item.value = [];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'passkey.rp_display_name':
|
||||||
|
case 'passkey.rp_id':
|
||||||
|
case 'passkey.user_verification':
|
||||||
|
case 'passkey.attachment_preference':
|
||||||
|
// 确保字符串字段不为null/undefined
|
||||||
|
item.value = item.value || '';
|
||||||
|
break;
|
||||||
case 'Price':
|
case 'Price':
|
||||||
case 'MinTopUp':
|
case 'MinTopUp':
|
||||||
item.value = parseFloat(item.value);
|
item.value = parseFloat(item.value);
|
||||||
@@ -582,6 +610,45 @@ const SystemSetting = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitPasskeySettings = async () => {
|
||||||
|
const options = [];
|
||||||
|
|
||||||
|
// 只在值有变化时才提交,并确保空值转换为空字符串
|
||||||
|
if (originInputs['passkey.rp_display_name'] !== inputs['passkey.rp_display_name']) {
|
||||||
|
options.push({
|
||||||
|
key: 'passkey.rp_display_name',
|
||||||
|
value: inputs['passkey.rp_display_name'] || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (originInputs['passkey.rp_id'] !== inputs['passkey.rp_id']) {
|
||||||
|
options.push({
|
||||||
|
key: 'passkey.rp_id',
|
||||||
|
value: inputs['passkey.rp_id'] || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (originInputs['passkey.user_verification'] !== inputs['passkey.user_verification']) {
|
||||||
|
options.push({
|
||||||
|
key: 'passkey.user_verification',
|
||||||
|
value: inputs['passkey.user_verification'] || 'preferred',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (originInputs['passkey.attachment_preference'] !== inputs['passkey.attachment_preference']) {
|
||||||
|
options.push({
|
||||||
|
key: 'passkey.attachment_preference',
|
||||||
|
value: inputs['passkey.attachment_preference'] || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Origins总是提交,因为它们可能会被用户清空
|
||||||
|
options.push({
|
||||||
|
key: 'passkey.origins',
|
||||||
|
value: JSON.stringify(Array.isArray(passkeyOrigins) ? passkeyOrigins : []),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.length > 0) {
|
||||||
|
await updateOptions(options);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCheckboxChange = async (optionKey, event) => {
|
const handleCheckboxChange = async (optionKey, event) => {
|
||||||
const value = event.target.checked;
|
const value = event.target.checked;
|
||||||
|
|
||||||
@@ -957,6 +1024,126 @@ const SystemSetting = () => {
|
|||||||
</Form.Section>
|
</Form.Section>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Form.Section text={t('配置 Passkey')}>
|
||||||
|
<Text>{t('用以支持基于 WebAuthn 的无密码登录注册')}</Text>
|
||||||
|
<Banner
|
||||||
|
type='info'
|
||||||
|
description={t('Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式')}
|
||||||
|
style={{ marginBottom: 20, marginTop: 16 }}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
|
>
|
||||||
|
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||||
|
<Form.Checkbox
|
||||||
|
field='passkey.enabled'
|
||||||
|
noLabel
|
||||||
|
onChange={(e) =>
|
||||||
|
handleCheckboxChange('passkey.enabled', e)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('允许通过 Passkey 登录 & 注册')}
|
||||||
|
</Form.Checkbox>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row
|
||||||
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
|
>
|
||||||
|
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||||
|
<Form.Input
|
||||||
|
field='passkey.rp_display_name'
|
||||||
|
label={t('服务显示名称')}
|
||||||
|
placeholder={t('默认使用系统名称')}
|
||||||
|
extraText={t('用户注册时看到的网站名称,比如\'我的网站\'')}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||||
|
<Form.Input
|
||||||
|
field='passkey.rp_id'
|
||||||
|
label={t('网站域名标识')}
|
||||||
|
placeholder={t('例如:example.com')}
|
||||||
|
extraText={t('留空自动使用当前域名')}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row
|
||||||
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||||
|
<Form.Select
|
||||||
|
field='passkey.user_verification'
|
||||||
|
label={t('安全验证级别')}
|
||||||
|
placeholder={t('是否要求指纹/面容等生物识别')}
|
||||||
|
optionList={[
|
||||||
|
{ label: t('推荐使用(用户可选)'), value: 'preferred' },
|
||||||
|
{ label: t('强制要求'), value: 'required' },
|
||||||
|
{ label: t('不建议使用'), value: 'discouraged' },
|
||||||
|
]}
|
||||||
|
extraText={t('推荐:用户可以选择是否使用指纹等验证')}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||||
|
<Form.Select
|
||||||
|
field='passkey.attachment_preference'
|
||||||
|
label={t('设备类型偏好')}
|
||||||
|
placeholder={t('选择支持的认证设备类型')}
|
||||||
|
optionList={[
|
||||||
|
{ label: t('不限制'), value: '' },
|
||||||
|
{ label: t('本设备内置'), value: 'platform' },
|
||||||
|
{ label: t('外接设备'), value: 'cross-platform' },
|
||||||
|
]}
|
||||||
|
extraText={t('本设备:手机指纹/面容,外接:USB安全密钥')}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row
|
||||||
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||||
|
<Form.Checkbox
|
||||||
|
field='passkey.allow_insecure_origin'
|
||||||
|
noLabel
|
||||||
|
extraText={t('仅用于开发环境,生产环境应使用 HTTPS')}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleCheckboxChange('passkey.allow_insecure_origin', e)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('允许不安全的 Origin(HTTP)')}
|
||||||
|
</Form.Checkbox>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row
|
||||||
|
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
|
||||||
|
<Text strong>{t('允许的 Origins')}</Text>
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
{t('留空将自动使用服务器地址,多个 Origin 用于支持多域名部署')}
|
||||||
|
</Text>
|
||||||
|
<TagInput
|
||||||
|
value={passkeyOrigins}
|
||||||
|
onChange={(value) => {
|
||||||
|
setPasskeyOrigins(value);
|
||||||
|
setInputs(prev => ({
|
||||||
|
...prev,
|
||||||
|
'passkey.origins': value
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
placeholder={t('输入 Origin 后回车,如:https://example.com')}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Button onClick={submitPasskeySettings} style={{ marginTop: 16 }}>
|
||||||
|
{t('保存 Passkey 设置')}
|
||||||
|
</Button>
|
||||||
|
</Form.Section>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Form.Section text={t('配置邮箱域名白名单')}>
|
<Form.Section text={t('配置邮箱域名白名单')}>
|
||||||
<Text>{t('用以防止恶意用户利用临时邮箱批量注册')}</Text>
|
<Text>{t('用以防止恶意用户利用临时邮箱批量注册')}</Text>
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ const AccountManagement = ({
|
|||||||
handleSystemTokenClick,
|
handleSystemTokenClick,
|
||||||
setShowChangePasswordModal,
|
setShowChangePasswordModal,
|
||||||
setShowAccountDeleteModal,
|
setShowAccountDeleteModal,
|
||||||
|
passkeyStatus,
|
||||||
|
passkeySupported,
|
||||||
|
passkeyRegisterLoading,
|
||||||
|
passkeyDeleteLoading,
|
||||||
|
onPasskeyRegister,
|
||||||
|
onPasskeyDelete,
|
||||||
}) => {
|
}) => {
|
||||||
const renderAccountInfo = (accountId, label) => {
|
const renderAccountInfo = (accountId, label) => {
|
||||||
if (!accountId || accountId === '') {
|
if (!accountId || accountId === '') {
|
||||||
@@ -86,6 +92,10 @@ const AccountManagement = ({
|
|||||||
};
|
};
|
||||||
const isBound = (accountId) => Boolean(accountId);
|
const isBound = (accountId) => Boolean(accountId);
|
||||||
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
|
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
|
||||||
|
const passkeyEnabled = passkeyStatus?.enabled;
|
||||||
|
const lastUsedLabel = passkeyStatus?.last_used_at
|
||||||
|
? new Date(passkeyStatus.last_used_at).toLocaleString()
|
||||||
|
: t('尚未使用');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='!rounded-2xl'>
|
<Card className='!rounded-2xl'>
|
||||||
@@ -476,6 +486,58 @@ const AccountManagement = ({
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Passkey 设置 */}
|
||||||
|
<Card className='!rounded-xl w-full'>
|
||||||
|
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
||||||
|
<div className='flex items-start w-full sm:w-auto'>
|
||||||
|
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
||||||
|
<IconKey size='large' className='text-slate-600' />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Title heading={6} className='mb-1'>
|
||||||
|
{t('Passkey 登录')}
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type='tertiary' className='text-sm'>
|
||||||
|
{passkeyEnabled
|
||||||
|
? t('已启用 Passkey,无需密码即可登录')
|
||||||
|
: t('使用 Passkey 实现免密且更安全的登录体验')}
|
||||||
|
</Typography.Text>
|
||||||
|
<div className='mt-2 text-xs text-gray-500 space-y-1'>
|
||||||
|
<div>
|
||||||
|
{t('最后使用时间')}:{lastUsedLabel}
|
||||||
|
</div>
|
||||||
|
{/*{passkeyEnabled && (*/}
|
||||||
|
{/* <div>*/}
|
||||||
|
{/* {t('备份支持')}:*/}
|
||||||
|
{/* {passkeyStatus?.backup_eligible*/}
|
||||||
|
{/* ? t('支持备份')*/}
|
||||||
|
{/* : t('不支持')}*/}
|
||||||
|
{/* ,{t('备份状态')}:*/}
|
||||||
|
{/* {passkeyStatus?.backup_state ? t('已备份') : t('未备份')}*/}
|
||||||
|
{/* </div>*/}
|
||||||
|
{/*)}*/}
|
||||||
|
{!passkeySupported && (
|
||||||
|
<div className='text-amber-600'>
|
||||||
|
{t('当前设备不支持 Passkey')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
theme={passkeyEnabled ? 'outline' : 'solid'}
|
||||||
|
onClick={passkeyEnabled ? onPasskeyDelete : onPasskeyRegister}
|
||||||
|
className='w-full sm:w-auto'
|
||||||
|
icon={<IconKey />}
|
||||||
|
disabled={!passkeySupported && !passkeyEnabled}
|
||||||
|
loading={passkeyEnabled ? passkeyDeleteLoading : passkeyRegisterLoading}
|
||||||
|
>
|
||||||
|
{passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 两步验证设置 */}
|
{/* 两步验证设置 */}
|
||||||
<TwoFASetting t={t} />
|
<TwoFASetting t={t} />
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ import {
|
|||||||
} from '../../../../helpers';
|
} from '../../../../helpers';
|
||||||
import ModelSelectModal from './ModelSelectModal';
|
import ModelSelectModal from './ModelSelectModal';
|
||||||
import JSONEditor from '../../../common/ui/JSONEditor';
|
import JSONEditor from '../../../common/ui/JSONEditor';
|
||||||
import TwoFactorAuthModal from '../../../common/modals/TwoFactorAuthModal';
|
import SecureVerificationModal from '../../../common/modals/SecureVerificationModal';
|
||||||
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
|
import ChannelKeyDisplay from '../../../common/ui/ChannelKeyDisplay';
|
||||||
|
import { useSecureVerification } from '../../../../hooks/common/useSecureVerification';
|
||||||
|
import { createApiCalls } from '../../../../services/secureVerification';
|
||||||
import {
|
import {
|
||||||
IconSave,
|
IconSave,
|
||||||
IconClose,
|
IconClose,
|
||||||
@@ -193,43 +195,43 @@ const EditChannelModal = (props) => {
|
|||||||
const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加)
|
const [keyMode, setKeyMode] = useState('append'); // 密钥模式:replace(覆盖)或 append(追加)
|
||||||
const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户
|
const [isEnterpriseAccount, setIsEnterpriseAccount] = useState(false); // 是否为企业账户
|
||||||
|
|
||||||
// 2FA验证查看密钥相关状态
|
// 密钥显示状态
|
||||||
const [twoFAState, setTwoFAState] = useState({
|
const [keyDisplayState, setKeyDisplayState] = useState({
|
||||||
showModal: false,
|
showModal: false,
|
||||||
code: '',
|
|
||||||
loading: false,
|
|
||||||
showKey: false,
|
|
||||||
keyData: '',
|
keyData: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// 专门的2FA验证状态(用于TwoFactorAuthModal)
|
// 使用通用安全验证 Hook
|
||||||
const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false);
|
const {
|
||||||
const [verifyCode, setVerifyCode] = useState('');
|
isModalVisible,
|
||||||
const [verifyLoading, setVerifyLoading] = useState(false);
|
verificationMethods,
|
||||||
|
verificationState,
|
||||||
|
startVerification,
|
||||||
|
executeVerification,
|
||||||
|
cancelVerification,
|
||||||
|
setVerificationCode,
|
||||||
|
switchVerificationMethod,
|
||||||
|
} = useSecureVerification({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
// 验证成功后显示密钥
|
||||||
|
if (result.success && result.data?.key) {
|
||||||
|
setKeyDisplayState({
|
||||||
|
showModal: true,
|
||||||
|
keyData: result.data.key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
successMessage: t('密钥获取成功'),
|
||||||
|
});
|
||||||
|
|
||||||
// 2FA状态更新辅助函数
|
// 重置密钥显示状态
|
||||||
const updateTwoFAState = (updates) => {
|
const resetKeyDisplayState = () => {
|
||||||
setTwoFAState((prev) => ({ ...prev, ...updates }));
|
setKeyDisplayState({
|
||||||
};
|
|
||||||
|
|
||||||
// 重置2FA状态
|
|
||||||
const resetTwoFAState = () => {
|
|
||||||
setTwoFAState({
|
|
||||||
showModal: false,
|
showModal: false,
|
||||||
code: '',
|
|
||||||
loading: false,
|
|
||||||
showKey: false,
|
|
||||||
keyData: '',
|
keyData: '',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重置2FA验证状态
|
|
||||||
const reset2FAVerifyState = () => {
|
|
||||||
setShow2FAVerifyModal(false);
|
|
||||||
setVerifyCode('');
|
|
||||||
setVerifyLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 渠道额外设置状态
|
// 渠道额外设置状态
|
||||||
const [channelSettings, setChannelSettings] = useState({
|
const [channelSettings, setChannelSettings] = useState({
|
||||||
force_format: false,
|
force_format: false,
|
||||||
@@ -602,42 +604,31 @@ const EditChannelModal = (props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 使用TwoFactorAuthModal的验证函数
|
// 显示安全验证模态框并开始验证流程
|
||||||
const handleVerify2FA = async () => {
|
const handleShow2FAModal = async () => {
|
||||||
if (!verifyCode) {
|
|
||||||
showError(t('请输入验证码或备用码'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setVerifyLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const res = await API.post(`/api/channel/${channelId}/key`, {
|
console.log('=== handleShow2FAModal called ===');
|
||||||
code: verifyCode,
|
console.log('channelId:', channelId);
|
||||||
|
console.log('startVerification function:', typeof startVerification);
|
||||||
|
|
||||||
|
// 测试模态框状态
|
||||||
|
console.log('Current modal state:', isModalVisible);
|
||||||
|
|
||||||
|
const apiCall = createApiCalls.viewChannelKey(channelId);
|
||||||
|
console.log('apiCall created:', typeof apiCall);
|
||||||
|
|
||||||
|
const result = await startVerification(apiCall, {
|
||||||
|
title: t('查看渠道密钥'),
|
||||||
|
description: t('为了保护账户安全,请验证您的身份。'),
|
||||||
|
preferredMethod: 'passkey', // 优先使用 Passkey
|
||||||
});
|
});
|
||||||
if (res.data.success) {
|
console.log('startVerification result:', result);
|
||||||
// 验证成功,显示密钥
|
|
||||||
updateTwoFAState({
|
|
||||||
showModal: true,
|
|
||||||
showKey: true,
|
|
||||||
keyData: res.data.data.key,
|
|
||||||
});
|
|
||||||
reset2FAVerifyState();
|
|
||||||
showSuccess(t('验证成功'));
|
|
||||||
} else {
|
|
||||||
showError(res.data.message);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(t('获取密钥失败'));
|
console.error('handleShow2FAModal error:', error);
|
||||||
} finally {
|
showError(error.message || t('启动验证失败'));
|
||||||
setVerifyLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 显示2FA验证模态框 - 使用TwoFactorAuthModal
|
|
||||||
const handleShow2FAModal = () => {
|
|
||||||
setShow2FAVerifyModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const modelMap = new Map();
|
const modelMap = new Map();
|
||||||
|
|
||||||
@@ -741,10 +732,8 @@ const EditChannelModal = (props) => {
|
|||||||
}
|
}
|
||||||
// 重置本地输入,避免下次打开残留上一次的 JSON 字段值
|
// 重置本地输入,避免下次打开残留上一次的 JSON 字段值
|
||||||
setInputs(getInitValues());
|
setInputs(getInitValues());
|
||||||
// 重置2FA状态
|
// 重置密钥显示状态
|
||||||
resetTwoFAState();
|
resetKeyDisplayState();
|
||||||
// 重置2FA验证状态
|
|
||||||
reset2FAVerifyState();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVertexUploadChange = ({ fileList }) => {
|
const handleVertexUploadChange = ({ fileList }) => {
|
||||||
@@ -2498,17 +2487,17 @@ const EditChannelModal = (props) => {
|
|||||||
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
|
onVisibleChange={(visible) => setIsModalOpenurl(visible)}
|
||||||
/>
|
/>
|
||||||
</SideSheet>
|
</SideSheet>
|
||||||
{/* 使用TwoFactorAuthModal组件进行2FA验证 */}
|
{/* 使用通用安全验证模态框 */}
|
||||||
<TwoFactorAuthModal
|
<SecureVerificationModal
|
||||||
visible={show2FAVerifyModal}
|
visible={isModalVisible}
|
||||||
code={verifyCode}
|
verificationMethods={verificationMethods}
|
||||||
loading={verifyLoading}
|
verificationState={verificationState}
|
||||||
onCodeChange={setVerifyCode}
|
onVerify={executeVerification}
|
||||||
onVerify={handleVerify2FA}
|
onCancel={cancelVerification}
|
||||||
onCancel={reset2FAVerifyState}
|
onCodeChange={setVerificationCode}
|
||||||
title={t('查看渠道密钥')}
|
onMethodSwitch={switchVerificationMethod}
|
||||||
description={t('为了保护账户安全,请验证您的两步验证码。')}
|
title={verificationState.title}
|
||||||
placeholder={t('请输入验证码或备用码')}
|
description={verificationState.description}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 使用ChannelKeyDisplay组件显示密钥 */}
|
{/* 使用ChannelKeyDisplay组件显示密钥 */}
|
||||||
@@ -2531,10 +2520,10 @@ const EditChannelModal = (props) => {
|
|||||||
{t('渠道密钥信息')}
|
{t('渠道密钥信息')}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
visible={twoFAState.showModal && twoFAState.showKey}
|
visible={keyDisplayState.showModal}
|
||||||
onCancel={resetTwoFAState}
|
onCancel={resetKeyDisplayState}
|
||||||
footer={
|
footer={
|
||||||
<Button type='primary' onClick={resetTwoFAState}>
|
<Button type='primary' onClick={resetKeyDisplayState}>
|
||||||
{t('完成')}
|
{t('完成')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
@@ -2542,7 +2531,7 @@ const EditChannelModal = (props) => {
|
|||||||
style={{ maxWidth: '90vw' }}
|
style={{ maxWidth: '90vw' }}
|
||||||
>
|
>
|
||||||
<ChannelKeyDisplay
|
<ChannelKeyDisplay
|
||||||
keyData={twoFAState.keyData}
|
keyData={keyDisplayState.keyData}
|
||||||
showSuccessIcon={true}
|
showSuccessIcon={true}
|
||||||
successText={t('密钥获取成功')}
|
successText={t('密钥获取成功')}
|
||||||
showWarning={true}
|
showWarning={true}
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ const renderOperations = (
|
|||||||
showDemoteModal,
|
showDemoteModal,
|
||||||
showEnableDisableModal,
|
showEnableDisableModal,
|
||||||
showDeleteModal,
|
showDeleteModal,
|
||||||
|
showResetPasskeyModal,
|
||||||
|
showResetTwoFAModal,
|
||||||
t,
|
t,
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
@@ -253,6 +255,20 @@ const renderOperations = (
|
|||||||
>
|
>
|
||||||
{t('降级')}
|
{t('降级')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='warning'
|
||||||
|
size='small'
|
||||||
|
onClick={() => showResetPasskeyModal(record)}
|
||||||
|
>
|
||||||
|
{t('重置 Passkey')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type='warning'
|
||||||
|
size='small'
|
||||||
|
onClick={() => showResetTwoFAModal(record)}
|
||||||
|
>
|
||||||
|
{t('重置 2FA')}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type='danger'
|
type='danger'
|
||||||
size='small'
|
size='small'
|
||||||
@@ -275,6 +291,8 @@ export const getUsersColumns = ({
|
|||||||
showDemoteModal,
|
showDemoteModal,
|
||||||
showEnableDisableModal,
|
showEnableDisableModal,
|
||||||
showDeleteModal,
|
showDeleteModal,
|
||||||
|
showResetPasskeyModal,
|
||||||
|
showResetTwoFAModal,
|
||||||
}) => {
|
}) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -329,6 +347,8 @@ export const getUsersColumns = ({
|
|||||||
showDemoteModal,
|
showDemoteModal,
|
||||||
showEnableDisableModal,
|
showEnableDisableModal,
|
||||||
showDeleteModal,
|
showDeleteModal,
|
||||||
|
showResetPasskeyModal,
|
||||||
|
showResetTwoFAModal,
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import PromoteUserModal from './modals/PromoteUserModal';
|
|||||||
import DemoteUserModal from './modals/DemoteUserModal';
|
import DemoteUserModal from './modals/DemoteUserModal';
|
||||||
import EnableDisableUserModal from './modals/EnableDisableUserModal';
|
import EnableDisableUserModal from './modals/EnableDisableUserModal';
|
||||||
import DeleteUserModal from './modals/DeleteUserModal';
|
import DeleteUserModal from './modals/DeleteUserModal';
|
||||||
|
import ResetPasskeyModal from './modals/ResetPasskeyModal';
|
||||||
|
import ResetTwoFAModal from './modals/ResetTwoFAModal';
|
||||||
|
|
||||||
const UsersTable = (usersData) => {
|
const UsersTable = (usersData) => {
|
||||||
const {
|
const {
|
||||||
@@ -45,6 +47,8 @@ const UsersTable = (usersData) => {
|
|||||||
setShowEditUser,
|
setShowEditUser,
|
||||||
manageUser,
|
manageUser,
|
||||||
refresh,
|
refresh,
|
||||||
|
resetUserPasskey,
|
||||||
|
resetUserTwoFA,
|
||||||
t,
|
t,
|
||||||
} = usersData;
|
} = usersData;
|
||||||
|
|
||||||
@@ -55,6 +59,8 @@ const UsersTable = (usersData) => {
|
|||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [modalUser, setModalUser] = useState(null);
|
const [modalUser, setModalUser] = useState(null);
|
||||||
const [enableDisableAction, setEnableDisableAction] = useState('');
|
const [enableDisableAction, setEnableDisableAction] = useState('');
|
||||||
|
const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
|
||||||
|
const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
|
||||||
|
|
||||||
// Modal handlers
|
// Modal handlers
|
||||||
const showPromoteUserModal = (user) => {
|
const showPromoteUserModal = (user) => {
|
||||||
@@ -78,6 +84,16 @@ const UsersTable = (usersData) => {
|
|||||||
setShowDeleteModal(true);
|
setShowDeleteModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showResetPasskeyUserModal = (user) => {
|
||||||
|
setModalUser(user);
|
||||||
|
setShowResetPasskeyModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showResetTwoFAUserModal = (user) => {
|
||||||
|
setModalUser(user);
|
||||||
|
setShowResetTwoFAModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Modal confirm handlers
|
// Modal confirm handlers
|
||||||
const handlePromoteConfirm = () => {
|
const handlePromoteConfirm = () => {
|
||||||
manageUser(modalUser.id, 'promote', modalUser);
|
manageUser(modalUser.id, 'promote', modalUser);
|
||||||
@@ -94,6 +110,16 @@ const UsersTable = (usersData) => {
|
|||||||
setShowEnableDisableModal(false);
|
setShowEnableDisableModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetPasskeyConfirm = async () => {
|
||||||
|
await resetUserPasskey(modalUser);
|
||||||
|
setShowResetPasskeyModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetTwoFAConfirm = async () => {
|
||||||
|
await resetUserTwoFA(modalUser);
|
||||||
|
setShowResetTwoFAModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Get all columns
|
// Get all columns
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return getUsersColumns({
|
return getUsersColumns({
|
||||||
@@ -104,8 +130,20 @@ const UsersTable = (usersData) => {
|
|||||||
showDemoteModal: showDemoteUserModal,
|
showDemoteModal: showDemoteUserModal,
|
||||||
showEnableDisableModal: showEnableDisableUserModal,
|
showEnableDisableModal: showEnableDisableUserModal,
|
||||||
showDeleteModal: showDeleteUserModal,
|
showDeleteModal: showDeleteUserModal,
|
||||||
|
showResetPasskeyModal: showResetPasskeyUserModal,
|
||||||
|
showResetTwoFAModal: showResetTwoFAUserModal,
|
||||||
});
|
});
|
||||||
}, [t, setEditingUser, setShowEditUser]);
|
}, [
|
||||||
|
t,
|
||||||
|
setEditingUser,
|
||||||
|
setShowEditUser,
|
||||||
|
showPromoteUserModal,
|
||||||
|
showDemoteUserModal,
|
||||||
|
showEnableDisableUserModal,
|
||||||
|
showDeleteUserModal,
|
||||||
|
showResetPasskeyUserModal,
|
||||||
|
showResetTwoFAUserModal,
|
||||||
|
]);
|
||||||
|
|
||||||
// Handle compact mode by removing fixed positioning
|
// Handle compact mode by removing fixed positioning
|
||||||
const tableColumns = useMemo(() => {
|
const tableColumns = useMemo(() => {
|
||||||
@@ -188,6 +226,22 @@ const UsersTable = (usersData) => {
|
|||||||
manageUser={manageUser}
|
manageUser={manageUser}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ResetPasskeyModal
|
||||||
|
visible={showResetPasskeyModal}
|
||||||
|
onCancel={() => setShowResetPasskeyModal(false)}
|
||||||
|
onConfirm={handleResetPasskeyConfirm}
|
||||||
|
user={modalUser}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ResetTwoFAModal
|
||||||
|
visible={showResetTwoFAModal}
|
||||||
|
onCancel={() => setShowResetTwoFAModal(false)}
|
||||||
|
onConfirm={handleResetTwoFAConfirm}
|
||||||
|
user={modalUser}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
39
web/src/components/table/users/modals/ResetPasskeyModal.jsx
Normal file
39
web/src/components/table/users/modals/ResetPasskeyModal.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Modal } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
const ResetPasskeyModal = ({ visible, onCancel, onConfirm, user, t }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('确认重置 Passkey')}
|
||||||
|
visible={visible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={onConfirm}
|
||||||
|
type='warning'
|
||||||
|
>
|
||||||
|
{t('此操作将解绑用户当前的 Passkey,下次登录需要重新注册。')}{' '}
|
||||||
|
{user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResetPasskeyModal;
|
||||||
|
|
||||||
39
web/src/components/table/users/modals/ResetTwoFAModal.jsx
Normal file
39
web/src/components/table/users/modals/ResetTwoFAModal.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Modal } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
const ResetTwoFAModal = ({ visible, onCancel, onConfirm, user, t }) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={t('确认重置两步验证')}
|
||||||
|
visible={visible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={onConfirm}
|
||||||
|
type='warning'
|
||||||
|
>
|
||||||
|
{t('此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。')}{' '}
|
||||||
|
{user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ResetTwoFAModal;
|
||||||
|
|
||||||
@@ -27,3 +27,4 @@ export * from './data';
|
|||||||
export * from './token';
|
export * from './token';
|
||||||
export * from './boolean';
|
export * from './boolean';
|
||||||
export * from './dashboard';
|
export * from './dashboard';
|
||||||
|
export * from './passkey';
|
||||||
|
|||||||
137
web/src/helpers/passkey.js
Normal file
137
web/src/helpers/passkey.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
export function base64UrlToBuffer(base64url) {
|
||||||
|
if (!base64url) return new ArrayBuffer(0);
|
||||||
|
let padding = '='.repeat((4 - (base64url.length % 4)) % 4);
|
||||||
|
const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
const buffer = new ArrayBuffer(rawData.length);
|
||||||
|
const uintArray = new Uint8Array(buffer);
|
||||||
|
for (let i = 0; i < rawData.length; i += 1) {
|
||||||
|
uintArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bufferToBase64Url(buffer) {
|
||||||
|
if (!buffer) return '';
|
||||||
|
const uintArray = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < uintArray.byteLength; i += 1) {
|
||||||
|
binary += String.fromCharCode(uintArray[i]);
|
||||||
|
}
|
||||||
|
return window
|
||||||
|
.btoa(binary)
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareCredentialCreationOptions(payload) {
|
||||||
|
const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
|
||||||
|
if (!options) {
|
||||||
|
throw new Error('无法从服务端响应中解析 Passkey 注册参数');
|
||||||
|
}
|
||||||
|
const publicKey = {
|
||||||
|
...options,
|
||||||
|
challenge: base64UrlToBuffer(options.challenge),
|
||||||
|
user: {
|
||||||
|
...options.user,
|
||||||
|
id: base64UrlToBuffer(options.user?.id),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(options.excludeCredentials)) {
|
||||||
|
publicKey.excludeCredentials = options.excludeCredentials.map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: base64UrlToBuffer(item.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(options.attestationFormats) && options.attestationFormats.length === 0) {
|
||||||
|
delete publicKey.attestationFormats;
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareCredentialRequestOptions(payload) {
|
||||||
|
const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
|
||||||
|
if (!options) {
|
||||||
|
throw new Error('无法从服务端响应中解析 Passkey 登录参数');
|
||||||
|
}
|
||||||
|
const publicKey = {
|
||||||
|
...options,
|
||||||
|
challenge: base64UrlToBuffer(options.challenge),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(options.allowCredentials)) {
|
||||||
|
publicKey.allowCredentials = options.allowCredentials.map((item) => ({
|
||||||
|
...item,
|
||||||
|
id: base64UrlToBuffer(item.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRegistrationResult(credential) {
|
||||||
|
if (!credential) return null;
|
||||||
|
|
||||||
|
const { response } = credential;
|
||||||
|
const transports = typeof response.getTransports === 'function' ? response.getTransports() : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: credential.id,
|
||||||
|
rawId: bufferToBase64Url(credential.rawId),
|
||||||
|
type: credential.type,
|
||||||
|
authenticatorAttachment: credential.authenticatorAttachment,
|
||||||
|
response: {
|
||||||
|
attestationObject: bufferToBase64Url(response.attestationObject),
|
||||||
|
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||||
|
transports,
|
||||||
|
},
|
||||||
|
clientExtensionResults: credential.getClientExtensionResults?.() ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAssertionResult(assertion) {
|
||||||
|
if (!assertion) return null;
|
||||||
|
|
||||||
|
const { response } = assertion;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: assertion.id,
|
||||||
|
rawId: bufferToBase64Url(assertion.rawId),
|
||||||
|
type: assertion.type,
|
||||||
|
authenticatorAttachment: assertion.authenticatorAttachment,
|
||||||
|
response: {
|
||||||
|
authenticatorData: bufferToBase64Url(response.authenticatorData),
|
||||||
|
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
||||||
|
signature: bufferToBase64Url(response.signature),
|
||||||
|
userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null,
|
||||||
|
},
|
||||||
|
clientExtensionResults: assertion.getClientExtensionResults?.() ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isPasskeySupported() {
|
||||||
|
if (typeof window === 'undefined' || !window.PublicKeyCredential) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof window.PublicKeyCredential.isConditionalMediationAvailable === 'function') {
|
||||||
|
try {
|
||||||
|
const available = await window.PublicKeyCredential.isConditionalMediationAvailable();
|
||||||
|
if (available) return true;
|
||||||
|
} catch (error) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') {
|
||||||
|
try {
|
||||||
|
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
225
web/src/hooks/common/useSecureVerification.jsx
Normal file
225
web/src/hooks/common/useSecureVerification.jsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
/*
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { SecureVerificationService } from '../../services/secureVerification';
|
||||||
|
import { showError, showSuccess } from '../../helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用安全验证 Hook
|
||||||
|
* @param {Object} options - 配置选项
|
||||||
|
* @param {Function} options.onSuccess - 验证成功回调
|
||||||
|
* @param {Function} options.onError - 验证失败回调
|
||||||
|
* @param {string} options.successMessage - 成功提示消息
|
||||||
|
* @param {boolean} options.autoReset - 验证完成后是否自动重置状态,默认为 true
|
||||||
|
*/
|
||||||
|
export const useSecureVerification = ({
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
successMessage,
|
||||||
|
autoReset = true
|
||||||
|
} = {}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 验证方式可用性状态
|
||||||
|
const [verificationMethods, setVerificationMethods] = useState({
|
||||||
|
has2FA: false,
|
||||||
|
hasPasskey: false,
|
||||||
|
passkeySupported: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 模态框状态
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
|
||||||
|
// 当前验证状态
|
||||||
|
const [verificationState, setVerificationState] = useState({
|
||||||
|
method: null, // '2fa' | 'passkey'
|
||||||
|
loading: false,
|
||||||
|
code: '',
|
||||||
|
apiCall: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查可用的验证方式
|
||||||
|
const checkVerificationMethods = useCallback(async () => {
|
||||||
|
const methods = await SecureVerificationService.checkAvailableVerificationMethods();
|
||||||
|
setVerificationMethods(methods);
|
||||||
|
return methods;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始化时检查验证方式
|
||||||
|
useEffect(() => {
|
||||||
|
checkVerificationMethods();
|
||||||
|
}, [checkVerificationMethods]);
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
setVerificationState({
|
||||||
|
method: null,
|
||||||
|
loading: false,
|
||||||
|
code: '',
|
||||||
|
apiCall: null
|
||||||
|
});
|
||||||
|
setIsModalVisible(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 开始验证流程
|
||||||
|
const startVerification = useCallback(async (apiCall, options = {}) => {
|
||||||
|
console.log('startVerification called:', { apiCall, options });
|
||||||
|
const { preferredMethod, title, description } = options;
|
||||||
|
|
||||||
|
// 检查验证方式
|
||||||
|
console.log('Checking verification methods...');
|
||||||
|
const methods = await checkVerificationMethods();
|
||||||
|
console.log('Verification methods:', methods);
|
||||||
|
|
||||||
|
if (!methods.has2FA && !methods.hasPasskey) {
|
||||||
|
const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
|
||||||
|
console.error('No verification methods available:', errorMessage);
|
||||||
|
showError(errorMessage);
|
||||||
|
onError?.(new Error(errorMessage));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置默认验证方式
|
||||||
|
let defaultMethod = preferredMethod;
|
||||||
|
if (!defaultMethod) {
|
||||||
|
if (methods.hasPasskey && methods.passkeySupported) {
|
||||||
|
defaultMethod = 'passkey';
|
||||||
|
} else if (methods.has2FA) {
|
||||||
|
defaultMethod = '2fa';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Selected verification method:', defaultMethod);
|
||||||
|
|
||||||
|
setVerificationState(prev => ({
|
||||||
|
...prev,
|
||||||
|
method: defaultMethod,
|
||||||
|
apiCall,
|
||||||
|
title,
|
||||||
|
description
|
||||||
|
}));
|
||||||
|
setIsModalVisible(true);
|
||||||
|
console.log('Modal should be visible now');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, [checkVerificationMethods, onError, t]);
|
||||||
|
|
||||||
|
// 执行验证
|
||||||
|
const executeVerification = useCallback(async (method, code = '') => {
|
||||||
|
if (!verificationState.apiCall) {
|
||||||
|
showError(t('验证配置错误'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerificationState(prev => ({ ...prev, loading: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await SecureVerificationService.verify(method, {
|
||||||
|
code,
|
||||||
|
apiCall: verificationState.apiCall
|
||||||
|
});
|
||||||
|
|
||||||
|
// 显示成功消息
|
||||||
|
if (successMessage) {
|
||||||
|
showSuccess(successMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用成功回调
|
||||||
|
onSuccess?.(result, method);
|
||||||
|
|
||||||
|
// 自动重置状态
|
||||||
|
if (autoReset) {
|
||||||
|
resetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
showError(error.message || t('验证失败,请重试'));
|
||||||
|
onError?.(error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setVerificationState(prev => ({ ...prev, loading: false }));
|
||||||
|
}
|
||||||
|
}, [verificationState.apiCall, successMessage, onSuccess, onError, autoReset, resetState, t]);
|
||||||
|
|
||||||
|
// 设置验证码
|
||||||
|
const setVerificationCode = useCallback((code) => {
|
||||||
|
setVerificationState(prev => ({ ...prev, code }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 切换验证方式
|
||||||
|
const switchVerificationMethod = useCallback((method) => {
|
||||||
|
setVerificationState(prev => ({ ...prev, method, code: '' }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 取消验证
|
||||||
|
const cancelVerification = useCallback(() => {
|
||||||
|
resetState();
|
||||||
|
}, [resetState]);
|
||||||
|
|
||||||
|
// 检查是否可以使用某种验证方式
|
||||||
|
const canUseMethod = useCallback((method) => {
|
||||||
|
switch (method) {
|
||||||
|
case '2fa':
|
||||||
|
return verificationMethods.has2FA;
|
||||||
|
case 'passkey':
|
||||||
|
return verificationMethods.hasPasskey && verificationMethods.passkeySupported;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [verificationMethods]);
|
||||||
|
|
||||||
|
// 获取推荐的验证方式
|
||||||
|
const getRecommendedMethod = useCallback(() => {
|
||||||
|
if (verificationMethods.hasPasskey && verificationMethods.passkeySupported) {
|
||||||
|
return 'passkey';
|
||||||
|
}
|
||||||
|
if (verificationMethods.has2FA) {
|
||||||
|
return '2fa';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [verificationMethods]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
isModalVisible,
|
||||||
|
verificationMethods,
|
||||||
|
verificationState,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
startVerification,
|
||||||
|
executeVerification,
|
||||||
|
cancelVerification,
|
||||||
|
resetState,
|
||||||
|
setVerificationCode,
|
||||||
|
switchVerificationMethod,
|
||||||
|
checkVerificationMethods,
|
||||||
|
|
||||||
|
// 辅助方法
|
||||||
|
canUseMethod,
|
||||||
|
getRecommendedMethod,
|
||||||
|
|
||||||
|
// 便捷属性
|
||||||
|
hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey,
|
||||||
|
isLoading: verificationState.loading,
|
||||||
|
currentMethod: verificationState.method,
|
||||||
|
code: verificationState.code
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -86,7 +86,7 @@ export const useUsersData = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Search users with keyword and group
|
// Search users with keyword and group
|
||||||
const searchUsers = async (
|
const searchUsers = async (
|
||||||
startIdx,
|
startIdx,
|
||||||
pageSize,
|
pageSize,
|
||||||
searchKeyword = null,
|
searchKeyword = null,
|
||||||
@@ -154,6 +154,40 @@ export const useUsersData = () => {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetUserPasskey = async (user) => {
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await API.delete(`/api/user/${user.id}/passkey`);
|
||||||
|
const { success, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess(t('Passkey 已重置'));
|
||||||
|
} else {
|
||||||
|
showError(message || t('操作失败,请重试'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(t('操作失败,请重试'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetUserTwoFA = async (user) => {
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await API.delete(`/api/user/${user.id}/2fa`);
|
||||||
|
const { success, message } = res.data;
|
||||||
|
if (success) {
|
||||||
|
showSuccess(t('二步验证已重置'));
|
||||||
|
} else {
|
||||||
|
showError(message || t('操作失败,请重试'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(t('操作失败,请重试'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle page change
|
// Handle page change
|
||||||
const handlePageChange = (page) => {
|
const handlePageChange = (page) => {
|
||||||
setActivePage(page);
|
setActivePage(page);
|
||||||
@@ -271,6 +305,8 @@ export const useUsersData = () => {
|
|||||||
loadUsers,
|
loadUsers,
|
||||||
searchUsers,
|
searchUsers,
|
||||||
manageUser,
|
manageUser,
|
||||||
|
resetUserPasskey,
|
||||||
|
resetUserTwoFA,
|
||||||
handlePageChange,
|
handlePageChange,
|
||||||
handlePageSizeChange,
|
handlePageSizeChange,
|
||||||
handleRow,
|
handleRow,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"登 录": "Log In",
|
"登 录": "Log In",
|
||||||
"注 册": "Sign Up",
|
"注 册": "Sign Up",
|
||||||
"使用 邮箱或用户名 登录": "Sign in with Email or Username",
|
"使用 邮箱或用户名 登录": "Sign in with Email or Username",
|
||||||
|
"使用 Passkey 认证": "Authenticate with Passkey",
|
||||||
"使用 GitHub 继续": "Continue with GitHub",
|
"使用 GitHub 继续": "Continue with GitHub",
|
||||||
"使用 OIDC 继续": "Continue with OIDC",
|
"使用 OIDC 继续": "Continue with OIDC",
|
||||||
"使用 微信 继续": "Continue with WeChat",
|
"使用 微信 继续": "Continue with WeChat",
|
||||||
@@ -2130,5 +2131,58 @@
|
|||||||
"域名IP过滤详细说明": "⚠️ This is an experimental option. A domain may resolve to multiple IPv4/IPv6 addresses. If enabled, ensure the IP filter list covers these addresses, otherwise access may fail.",
|
"域名IP过滤详细说明": "⚠️ This is an experimental option. A domain may resolve to multiple IPv4/IPv6 addresses. If enabled, ensure the IP filter list covers these addresses, otherwise access may fail.",
|
||||||
"域名黑名单": "Domain Blacklist",
|
"域名黑名单": "Domain Blacklist",
|
||||||
"白名单": "Whitelist",
|
"白名单": "Whitelist",
|
||||||
"黑名单": "Blacklist"
|
"黑名单": "Blacklist",
|
||||||
|
"Passkey 登录": "Passkey Sign-in",
|
||||||
|
"已启用 Passkey,无需密码即可登录": "Passkey enabled. Passwordless login available.",
|
||||||
|
"使用 Passkey 实现免密且更安全的登录体验": "Use Passkey for a passwordless and more secure login experience.",
|
||||||
|
"最后使用时间": "Last used time",
|
||||||
|
"备份支持": "Backup support",
|
||||||
|
"支持备份": "Supported",
|
||||||
|
"不支持": "Not supported",
|
||||||
|
"备份状态": "Backup state",
|
||||||
|
"已备份": "Backed up",
|
||||||
|
"未备份": "Not backed up",
|
||||||
|
"当前设备不支持 Passkey": "Passkey is not supported on this device",
|
||||||
|
"注册 Passkey": "Register Passkey",
|
||||||
|
"解绑 Passkey": "Remove Passkey",
|
||||||
|
"Passkey 注册成功": "Passkey registration successful",
|
||||||
|
"Passkey 注册失败,请重试": "Passkey registration failed. Please try again.",
|
||||||
|
"已取消 Passkey 注册": "Passkey registration cancelled",
|
||||||
|
"Passkey 已解绑": "Passkey removed",
|
||||||
|
"操作失败,请重试": "Operation failed, please retry",
|
||||||
|
"重置 Passkey": "Reset Passkey",
|
||||||
|
"重置 2FA": "Reset 2FA",
|
||||||
|
"确认重置 Passkey": "Confirm Passkey Reset",
|
||||||
|
"确认重置两步验证": "Confirm Two-Factor Reset",
|
||||||
|
"此操作将解绑用户当前的 Passkey,下次登录需要重新注册。": "This will detach the user's current Passkey. They will need to register again on next login.",
|
||||||
|
"此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "This will disable the user's current two-factor setup. No verification code will be required until they enable it again.",
|
||||||
|
"目标用户:{{username}}": "Target user: {{username}}",
|
||||||
|
"Passkey 已重置": "Passkey has been reset",
|
||||||
|
"二步验证已重置": "Two-factor authentication has been reset",
|
||||||
|
"配置 Passkey": "Configure Passkey",
|
||||||
|
"用以支持基于 WebAuthn 的无密码登录注册": "Support WebAuthn-based passwordless login and registration",
|
||||||
|
"Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式": "Passkey is a passwordless authentication method based on WebAuthn standard, supporting fingerprint, face recognition, hardware keys and other authentication methods",
|
||||||
|
"服务显示名称": "Service Display Name",
|
||||||
|
"默认使用系统名称": "Default uses system name",
|
||||||
|
"用户注册时看到的网站名称,比如'我的网站'": "Website name users see during registration, e.g. 'My Website'",
|
||||||
|
"网站域名标识": "Website Domain ID",
|
||||||
|
"例如:example.com": "e.g.: example.com",
|
||||||
|
"留空自动使用当前域名": "Leave blank to auto-use current domain",
|
||||||
|
"安全验证级别": "Security Verification Level",
|
||||||
|
"是否要求指纹/面容等生物识别": "Whether to require fingerprint/face recognition",
|
||||||
|
"preferred": "preferred",
|
||||||
|
"required": "required",
|
||||||
|
"discouraged": "discouraged",
|
||||||
|
"推荐:用户可以选择是否使用指纹等验证": "Recommended: Users can choose whether to use fingerprint verification",
|
||||||
|
"设备类型偏好": "Device Type Preference",
|
||||||
|
"选择支持的认证设备类型": "Choose supported authentication device types",
|
||||||
|
"platform": "platform",
|
||||||
|
"cross-platform": "cross-platform",
|
||||||
|
"本设备:手机指纹/面容,外接:USB安全密钥": "Built-in: phone fingerprint/face, External: USB security key",
|
||||||
|
"允许不安全的 Origin(HTTP)": "Allow insecure Origin (HTTP)",
|
||||||
|
"仅用于开发环境,生产环境应使用 HTTPS": "For development only, use HTTPS in production",
|
||||||
|
"允许的 Origins": "Allowed Origins",
|
||||||
|
"留空将自动使用服务器地址,多个 Origin 用于支持多域名部署": "Leave blank to auto-use server address, multiple Origins for multi-domain deployment",
|
||||||
|
"输入 Origin 后回车,如:https://example.com": "Enter Origin and press Enter, e.g.: https://example.com",
|
||||||
|
"保存 Passkey 设置": "Save Passkey Settings"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"关于": "关于",
|
"关于": "关于",
|
||||||
"登录": "登录",
|
"登录": "登录",
|
||||||
"注册": "注册",
|
"注册": "注册",
|
||||||
|
"使用 Passkey 认证": "使用 Passkey 认证",
|
||||||
"退出": "退出",
|
"退出": "退出",
|
||||||
"语言": "语言",
|
"语言": "语言",
|
||||||
"展开侧边栏": "展开侧边栏",
|
"展开侧边栏": "展开侧边栏",
|
||||||
@@ -33,5 +34,58 @@
|
|||||||
"输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999",
|
"输入端口后回车,如:80 或 8000-8999": "输入端口后回车,如:80 或 8000-8999",
|
||||||
"更新SSRF防护设置": "更新SSRF防护设置",
|
"更新SSRF防护设置": "更新SSRF防护设置",
|
||||||
"域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。",
|
"域名IP过滤详细说明": "⚠️此功能为实验性选项,域名可能解析到多个 IPv4/IPv6 地址,若开启,请确保 IP 过滤列表覆盖这些地址,否则可能导致访问失败。",
|
||||||
"允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码"
|
"允许在 Stripe 支付中输入促销码": "允许在 Stripe 支付中输入促销码",
|
||||||
|
"Passkey 认证": "Passkey 认证",
|
||||||
|
"已启用 Passkey,可进行无密码认证": "已启用 Passkey,可进行无密码认证",
|
||||||
|
"使用 Passkey 实现免密且更安全的认证体验": "使用 Passkey 实现免密且更安全的认证体验",
|
||||||
|
"最后使用时间": "最后使用时间",
|
||||||
|
"备份支持": "备份支持",
|
||||||
|
"支持备份": "支持备份",
|
||||||
|
"不支持": "不支持",
|
||||||
|
"备份状态": "备份状态",
|
||||||
|
"已备份": "已备份",
|
||||||
|
"未备份": "未备份",
|
||||||
|
"当前设备不支持 Passkey": "当前设备不支持 Passkey",
|
||||||
|
"注册 Passkey": "注册 Passkey",
|
||||||
|
"解绑 Passkey": "解绑 Passkey",
|
||||||
|
"配置 Passkey": "配置 Passkey",
|
||||||
|
"用以支持基于 WebAuthn 的无密码登录注册": "用以支持基于 WebAuthn 的无密码登录注册",
|
||||||
|
"Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式": "Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式",
|
||||||
|
"服务显示名称": "服务显示名称",
|
||||||
|
"默认使用系统名称": "默认使用系统名称",
|
||||||
|
"用户注册时看到的网站名称,比如'我的网站'": "用户注册时看到的网站名称,比如'我的网站'",
|
||||||
|
"网站域名标识": "网站域名标识",
|
||||||
|
"例如:example.com": "例如:example.com",
|
||||||
|
"留空自动使用当前域名": "留空自动使用当前域名",
|
||||||
|
"安全验证级别": "安全验证级别",
|
||||||
|
"是否要求指纹/面容等生物识别": "是否要求指纹/面容等生物识别",
|
||||||
|
"preferred": "preferred",
|
||||||
|
"required": "required",
|
||||||
|
"discouraged": "discouraged",
|
||||||
|
"推荐:用户可以选择是否使用指纹等验证": "推荐:用户可以选择是否使用指纹等验证",
|
||||||
|
"设备类型偏好": "设备类型偏好",
|
||||||
|
"选择支持的认证设备类型": "选择支持的认证设备类型",
|
||||||
|
"platform": "platform",
|
||||||
|
"cross-platform": "cross-platform",
|
||||||
|
"本设备:手机指纹/面容,外接:USB安全密钥": "本设备:手机指纹/面容,外接:USB安全密钥",
|
||||||
|
"允许不安全的 Origin(HTTP)": "允许不安全的 Origin(HTTP)",
|
||||||
|
"仅用于开发环境,生产环境应使用 HTTPS": "仅用于开发环境,生产环境应使用 HTTPS",
|
||||||
|
"允许的 Origins": "允许的 Origins",
|
||||||
|
"留空将自动使用服务器地址,多个 Origin 用于支持多域名部署": "留空将自动使用服务器地址,多个 Origin 用于支持多域名部署",
|
||||||
|
"输入 Origin 后回车,如:https://example.com": "输入 Origin 后回车,如:https://example.com",
|
||||||
|
"保存 Passkey 设置": "保存 Passkey 设置",
|
||||||
|
"Passkey 注册成功": "Passkey 注册成功",
|
||||||
|
"Passkey 注册失败,请重试": "Passkey 注册失败,请重试",
|
||||||
|
"已取消 Passkey 注册": "已取消 Passkey 注册",
|
||||||
|
"Passkey 已解绑": "Passkey 已解绑",
|
||||||
|
"操作失败,请重试": "操作失败,请重试",
|
||||||
|
"重置 Passkey": "重置 Passkey",
|
||||||
|
"重置 2FA": "重置 2FA",
|
||||||
|
"确认重置 Passkey": "确认重置 Passkey",
|
||||||
|
"确认重置两步验证": "确认重置两步验证",
|
||||||
|
"此操作将解绑用户当前的 Passkey,下次登录需要重新注册。": "此操作将解绑用户当前的 Passkey,下次登录需要重新注册。",
|
||||||
|
"此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。": "此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。",
|
||||||
|
"目标用户:{{username}}": "目标用户:{{username}}",
|
||||||
|
"Passkey 已重置": "Passkey 已重置",
|
||||||
|
"二步验证已重置": "二步验证已重置"
|
||||||
}
|
}
|
||||||
|
|||||||
183
web/src/services/secureVerification.js
Normal file
183
web/src/services/secureVerification.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/*
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { API, showError } from '../helpers';
|
||||||
|
import {
|
||||||
|
prepareCredentialRequestOptions,
|
||||||
|
buildAssertionResult,
|
||||||
|
isPasskeySupported
|
||||||
|
} from '../helpers/passkey';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用安全验证服务
|
||||||
|
*/
|
||||||
|
export class SecureVerificationService {
|
||||||
|
/**
|
||||||
|
* 检查用户可用的验证方式
|
||||||
|
* @returns {Promise<{has2FA: boolean, hasPasskey: boolean, passkeySupported: boolean}>}
|
||||||
|
*/
|
||||||
|
static async checkAvailableVerificationMethods() {
|
||||||
|
try {
|
||||||
|
console.log('Checking user verification methods...');
|
||||||
|
const [twoFAResponse, passkeyResponse, passkeySupported] = await Promise.all([
|
||||||
|
API.get('/api/user/2fa/status'),
|
||||||
|
API.get('/api/user/passkey'),
|
||||||
|
isPasskeySupported()
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log('2FA response:', twoFAResponse);
|
||||||
|
console.log('Passkey response:', passkeyResponse);
|
||||||
|
console.log('Passkey browser support:', passkeySupported);
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
has2FA: twoFAResponse.success && twoFAResponse.data?.enabled === true,
|
||||||
|
hasPasskey: passkeyResponse.success && (passkeyResponse.data?.enabled === true || passkeyResponse.data?.status === 'enabled' || passkeyResponse.data !== null),
|
||||||
|
passkeySupported
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Final verification methods result:', result);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check verification methods:', error);
|
||||||
|
return {
|
||||||
|
has2FA: false,
|
||||||
|
hasPasskey: false,
|
||||||
|
passkeySupported: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行2FA验证
|
||||||
|
* @param {string} code - 验证码
|
||||||
|
* @param {Function} apiCall - API调用函数,接收 {method: '2fa', code} 参数
|
||||||
|
* @returns {Promise<any>} API响应结果
|
||||||
|
*/
|
||||||
|
static async verify2FA(code, apiCall) {
|
||||||
|
if (!code?.trim()) {
|
||||||
|
throw new Error('请输入验证码或备用码');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await apiCall({
|
||||||
|
method: '2fa',
|
||||||
|
code: code.trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行Passkey验证
|
||||||
|
* @param {Function} apiCall - API调用函数,接收 {method: 'passkey'} 参数
|
||||||
|
* @returns {Promise<any>} API响应结果
|
||||||
|
*/
|
||||||
|
static async verifyPasskey(apiCall) {
|
||||||
|
try {
|
||||||
|
// 开始Passkey验证
|
||||||
|
const beginResponse = await API.post('/api/user/passkey/verify/begin');
|
||||||
|
if (!beginResponse.success) {
|
||||||
|
throw new Error(beginResponse.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备WebAuthn选项
|
||||||
|
const publicKey = prepareCredentialRequestOptions(beginResponse.data);
|
||||||
|
|
||||||
|
// 执行WebAuthn验证
|
||||||
|
const credential = await navigator.credentials.get({ publicKey });
|
||||||
|
if (!credential) {
|
||||||
|
throw new Error('Passkey 验证被取消');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建验证结果
|
||||||
|
const assertionResult = buildAssertionResult(credential);
|
||||||
|
|
||||||
|
// 完成验证
|
||||||
|
const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult);
|
||||||
|
if (!finishResponse.success) {
|
||||||
|
throw new Error(finishResponse.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用业务API
|
||||||
|
return await apiCall({
|
||||||
|
method: 'passkey'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'NotAllowedError') {
|
||||||
|
throw new Error('Passkey 验证被取消或超时');
|
||||||
|
} else if (error.name === 'InvalidStateError') {
|
||||||
|
throw new Error('Passkey 验证状态无效');
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用验证方法,根据验证类型执行相应的验证流程
|
||||||
|
* @param {string} method - 验证方式: '2fa' | 'passkey'
|
||||||
|
* @param {Object} params - 参数对象
|
||||||
|
* @param {string} params.code - 2FA验证码(当method为'2fa'时必需)
|
||||||
|
* @param {Function} params.apiCall - API调用函数
|
||||||
|
* @returns {Promise<any>} API响应结果
|
||||||
|
*/
|
||||||
|
static async verify(method, { code, apiCall }) {
|
||||||
|
switch (method) {
|
||||||
|
case '2fa':
|
||||||
|
return await this.verify2FA(code, apiCall);
|
||||||
|
case 'passkey':
|
||||||
|
return await this.verifyPasskey(apiCall);
|
||||||
|
default:
|
||||||
|
throw new Error(`不支持的验证方式: ${method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预设的API调用函数工厂
|
||||||
|
*/
|
||||||
|
export const createApiCalls = {
|
||||||
|
/**
|
||||||
|
* 创建查看渠道密钥的API调用
|
||||||
|
* @param {number} channelId - 渠道ID
|
||||||
|
*/
|
||||||
|
viewChannelKey: (channelId) => async (verificationData) => {
|
||||||
|
return await API.post(`/api/channel/${channelId}/key`, verificationData);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建自定义API调用
|
||||||
|
* @param {string} url - API URL
|
||||||
|
* @param {string} method - HTTP方法,默认为 'POST'
|
||||||
|
* @param {Object} extraData - 额外的请求数据
|
||||||
|
*/
|
||||||
|
custom: (url, method = 'POST', extraData = {}) => async (verificationData) => {
|
||||||
|
const data = { ...extraData, ...verificationData };
|
||||||
|
|
||||||
|
switch (method.toUpperCase()) {
|
||||||
|
case 'GET':
|
||||||
|
return await API.get(url, { params: data });
|
||||||
|
case 'POST':
|
||||||
|
return await API.post(url, data);
|
||||||
|
case 'PUT':
|
||||||
|
return await API.put(url, data);
|
||||||
|
case 'DELETE':
|
||||||
|
return await API.delete(url, { data });
|
||||||
|
default:
|
||||||
|
throw new Error(`不支持的HTTP方法: ${method}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user