Merge pull request #1487 from seefs001/feature/2fa
feat: implement two-factor authentication (2FA) support with user login and settings integration
This commit is contained in:
150
common/totp.go
Normal file
150
common/totp.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// 备用码配置
|
||||||
|
BackupCodeLength = 8 // 备用码长度
|
||||||
|
BackupCodeCount = 4 // 生成备用码数量
|
||||||
|
|
||||||
|
// 限制配置
|
||||||
|
MaxFailAttempts = 5 // 最大失败尝试次数
|
||||||
|
LockoutDuration = 300 // 锁定时间(秒)
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateTOTPSecret 生成TOTP密钥和配置
|
||||||
|
func GenerateTOTPSecret(accountName string) (*otp.Key, error) {
|
||||||
|
issuer := Get2FAIssuer()
|
||||||
|
return totp.Generate(totp.GenerateOpts{
|
||||||
|
Issuer: issuer,
|
||||||
|
AccountName: accountName,
|
||||||
|
Period: 30,
|
||||||
|
Digits: otp.DigitsSix,
|
||||||
|
Algorithm: otp.AlgorithmSHA1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateTOTPCode 验证TOTP验证码
|
||||||
|
func ValidateTOTPCode(secret, code string) bool {
|
||||||
|
// 清理验证码格式
|
||||||
|
cleanCode := strings.ReplaceAll(code, " ", "")
|
||||||
|
if len(cleanCode) != 6 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证验证码
|
||||||
|
return totp.Validate(cleanCode, secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateBackupCodes 生成备用恢复码
|
||||||
|
func GenerateBackupCodes() ([]string, error) {
|
||||||
|
codes := make([]string, BackupCodeCount)
|
||||||
|
|
||||||
|
for i := 0; i < BackupCodeCount; i++ {
|
||||||
|
code, err := generateRandomBackupCode()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
codes[i] = code
|
||||||
|
}
|
||||||
|
|
||||||
|
return codes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRandomBackupCode 生成单个备用码
|
||||||
|
func generateRandomBackupCode() (string, error) {
|
||||||
|
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
code := make([]byte, BackupCodeLength)
|
||||||
|
|
||||||
|
for i := range code {
|
||||||
|
randomBytes := make([]byte, 1)
|
||||||
|
_, err := rand.Read(randomBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
code[i] = charset[int(randomBytes[0])%len(charset)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化为 XXXX-XXXX 格式
|
||||||
|
return fmt.Sprintf("%s-%s", string(code[:4]), string(code[4:])), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateBackupCode 验证备用码格式
|
||||||
|
func ValidateBackupCode(code string) bool {
|
||||||
|
// 移除所有分隔符并转为大写
|
||||||
|
cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
|
||||||
|
if len(cleanCode) != BackupCodeLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查字符是否合法
|
||||||
|
for _, char := range cleanCode {
|
||||||
|
if !((char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeBackupCode 标准化备用码格式
|
||||||
|
func NormalizeBackupCode(code string) string {
|
||||||
|
cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
|
||||||
|
if len(cleanCode) == BackupCodeLength {
|
||||||
|
return fmt.Sprintf("%s-%s", cleanCode[:4], cleanCode[4:])
|
||||||
|
}
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashBackupCode 对备用码进行哈希
|
||||||
|
func HashBackupCode(code string) (string, error) {
|
||||||
|
normalizedCode := NormalizeBackupCode(code)
|
||||||
|
return Password2Hash(normalizedCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get2FAIssuer 获取2FA发行者名称
|
||||||
|
func Get2FAIssuer() string {
|
||||||
|
return SystemName
|
||||||
|
}
|
||||||
|
|
||||||
|
// getEnvOrDefault 获取环境变量或默认值
|
||||||
|
func getEnvOrDefault(key, defaultValue string) string {
|
||||||
|
if value, exists := os.LookupEnv(key); exists {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateNumericCode 验证数字验证码格式
|
||||||
|
func ValidateNumericCode(code string) (string, error) {
|
||||||
|
// 移除空格
|
||||||
|
code = strings.ReplaceAll(code, " ", "")
|
||||||
|
|
||||||
|
if len(code) != 6 {
|
||||||
|
return "", fmt.Errorf("验证码必须是6位数字")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为纯数字
|
||||||
|
if _, err := strconv.Atoi(code); err != nil {
|
||||||
|
return "", fmt.Errorf("验证码只能包含数字")
|
||||||
|
}
|
||||||
|
|
||||||
|
return code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateQRCodeData 生成二维码数据
|
||||||
|
func GenerateQRCodeData(secret, username string) string {
|
||||||
|
issuer := Get2FAIssuer()
|
||||||
|
accountName := fmt.Sprintf("%s (%s)", username, issuer)
|
||||||
|
return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=6&period=30",
|
||||||
|
issuer, accountName, secret, issuer)
|
||||||
|
}
|
||||||
553
controller/twofa.go
Normal file
553
controller/twofa.go
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"one-api/common"
|
||||||
|
"one-api/model"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setup2FARequest 设置2FA请求结构
|
||||||
|
type Setup2FARequest struct {
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify2FARequest 验证2FA请求结构
|
||||||
|
type Verify2FARequest struct {
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup2FAResponse 设置2FA响应结构
|
||||||
|
type Setup2FAResponse struct {
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
QRCodeData string `json:"qr_code_data"`
|
||||||
|
BackupCodes []string `json:"backup_codes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup2FA 初始化2FA设置
|
||||||
|
func Setup2FA(c *gin.Context) {
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
|
||||||
|
// 检查用户是否已经启用2FA
|
||||||
|
existing, err := model.GetTwoFAByUserId(userId)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing != nil && existing.IsEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户已启用2FA,请先禁用后重新设置",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果存在已禁用的2FA记录,先删除它
|
||||||
|
if existing != nil && !existing.IsEnabled {
|
||||||
|
if err := existing.Delete(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing = nil // 重置为nil,后续将创建新记录
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
user, err := model.GetUserById(userId, false)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成TOTP密钥
|
||||||
|
key, err := common.GenerateTOTPSecret(user.Username)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "生成2FA密钥失败",
|
||||||
|
})
|
||||||
|
common.SysError("生成TOTP密钥失败: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成备用码
|
||||||
|
backupCodes, err := common.GenerateBackupCodes()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "生成备用码失败",
|
||||||
|
})
|
||||||
|
common.SysError("生成备用码失败: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成二维码数据
|
||||||
|
qrCodeData := common.GenerateQRCodeData(key.Secret(), user.Username)
|
||||||
|
|
||||||
|
// 创建或更新2FA记录(暂未启用)
|
||||||
|
twoFA := &model.TwoFA{
|
||||||
|
UserId: userId,
|
||||||
|
Secret: key.Secret(),
|
||||||
|
IsEnabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing != nil {
|
||||||
|
// 更新现有记录
|
||||||
|
twoFA.Id = existing.Id
|
||||||
|
err = twoFA.Update()
|
||||||
|
} else {
|
||||||
|
// 创建新记录
|
||||||
|
err = twoFA.Create()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建备用码记录
|
||||||
|
if err := model.CreateBackupCodes(userId, backupCodes); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "保存备用码失败",
|
||||||
|
})
|
||||||
|
common.SysError("保存备用码失败: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
model.RecordLog(userId, model.LogTypeSystem, "开始设置两步验证")
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "2FA设置初始化成功,请使用认证器扫描二维码并输入验证码完成设置",
|
||||||
|
"data": Setup2FAResponse{
|
||||||
|
Secret: key.Secret(),
|
||||||
|
QRCodeData: qrCodeData,
|
||||||
|
BackupCodes: backupCodes,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable2FA 启用2FA
|
||||||
|
func Enable2FA(c *gin.Context) {
|
||||||
|
var req Setup2FARequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "参数错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
|
||||||
|
// 获取2FA记录
|
||||||
|
twoFA, err := model.GetTwoFAByUserId(userId)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if twoFA == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "请先完成2FA初始化设置",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if twoFA.IsEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "2FA已经启用",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证TOTP验证码
|
||||||
|
cleanCode, err := common.ValidateNumericCode(req.Code)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !common.ValidateTOTPCode(twoFA.Secret, cleanCode) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "验证码或备用码错误,请重试",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用2FA
|
||||||
|
if err := twoFA.Enable(); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
model.RecordLog(userId, model.LogTypeSystem, "成功启用两步验证")
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "两步验证启用成功",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable2FA 禁用2FA
|
||||||
|
func Disable2FA(c *gin.Context) {
|
||||||
|
var req Verify2FARequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "参数错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
|
||||||
|
// 获取2FA记录
|
||||||
|
twoFA, err := model.GetTwoFAByUserId(userId)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if twoFA == nil || !twoFA.IsEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户未启用2FA",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证TOTP验证码或备用码
|
||||||
|
cleanCode, err := common.ValidateNumericCode(req.Code)
|
||||||
|
isValidTOTP := false
|
||||||
|
isValidBackup := false
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// 尝试验证TOTP
|
||||||
|
isValidTOTP, _ = twoFA.ValidateTOTPAndUpdateUsage(cleanCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidTOTP {
|
||||||
|
// 尝试验证备用码
|
||||||
|
isValidBackup, err = twoFA.ValidateBackupCodeAndUpdateUsage(req.Code)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidTOTP && !isValidBackup {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "验证码或备用码错误,请重试",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁用2FA
|
||||||
|
if err := model.DisableTwoFA(userId); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
model.RecordLog(userId, model.LogTypeSystem, "禁用两步验证")
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "两步验证已禁用",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get2FAStatus 获取用户2FA状态
|
||||||
|
func Get2FAStatus(c *gin.Context) {
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
|
||||||
|
twoFA, err := model.GetTwoFAByUserId(userId)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := map[string]interface{}{
|
||||||
|
"enabled": false,
|
||||||
|
"locked": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if twoFA != nil {
|
||||||
|
status["enabled"] = twoFA.IsEnabled
|
||||||
|
status["locked"] = twoFA.IsLocked()
|
||||||
|
if twoFA.IsEnabled {
|
||||||
|
// 获取剩余备用码数量
|
||||||
|
backupCount, err := model.GetUnusedBackupCodeCount(userId)
|
||||||
|
if err != nil {
|
||||||
|
common.SysError("获取备用码数量失败: " + err.Error())
|
||||||
|
} else {
|
||||||
|
status["backup_codes_remaining"] = backupCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegenerateBackupCodes 重新生成备用码
|
||||||
|
func RegenerateBackupCodes(c *gin.Context) {
|
||||||
|
var req Verify2FARequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "参数错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
|
||||||
|
// 获取2FA记录
|
||||||
|
twoFA, err := model.GetTwoFAByUserId(userId)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if twoFA == nil || !twoFA.IsEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户未启用2FA",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证TOTP验证码
|
||||||
|
cleanCode, err := common.ValidateNumericCode(req.Code)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := twoFA.ValidateTOTPAndUpdateUsage(cleanCode)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "验证码或备用码错误,请重试",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成新的备用码
|
||||||
|
backupCodes, err := common.GenerateBackupCodes()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "生成备用码失败",
|
||||||
|
})
|
||||||
|
common.SysError("生成备用码失败: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存新的备用码
|
||||||
|
if err := model.CreateBackupCodes(userId, backupCodes); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "保存备用码失败",
|
||||||
|
})
|
||||||
|
common.SysError("保存备用码失败: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
model.RecordLog(userId, model.LogTypeSystem, "重新生成两步验证备用码")
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "备用码重新生成成功",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"backup_codes": backupCodes,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify2FALogin 登录时验证2FA
|
||||||
|
func Verify2FALogin(c *gin.Context) {
|
||||||
|
var req Verify2FARequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "参数错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从会话中获取pending用户信息
|
||||||
|
session := sessions.Default(c)
|
||||||
|
pendingUserId := session.Get("pending_user_id")
|
||||||
|
if pendingUserId == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "会话已过期,请重新登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
userId, ok := pendingUserId.(int)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "会话数据无效,请重新登录",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 获取用户信息
|
||||||
|
user, err := model.GetUserById(userId, false)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户不存在",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取2FA记录
|
||||||
|
twoFA, err := model.GetTwoFAByUserId(user.Id)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if twoFA == nil || !twoFA.IsEnabled {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户未启用2FA",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证TOTP验证码或备用码
|
||||||
|
cleanCode, err := common.ValidateNumericCode(req.Code)
|
||||||
|
isValidTOTP := false
|
||||||
|
isValidBackup := false
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// 尝试验证TOTP
|
||||||
|
isValidTOTP, _ = twoFA.ValidateTOTPAndUpdateUsage(cleanCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidTOTP {
|
||||||
|
// 尝试验证备用码
|
||||||
|
isValidBackup, err = twoFA.ValidateBackupCodeAndUpdateUsage(req.Code)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isValidTOTP && !isValidBackup {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "验证码或备用码错误,请重试",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2FA验证成功,清理pending会话信息并完成登录
|
||||||
|
session.Delete("pending_username")
|
||||||
|
session.Delete("pending_user_id")
|
||||||
|
session.Save()
|
||||||
|
|
||||||
|
setupLogin(user, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin2FAStats 管理员获取2FA统计信息
|
||||||
|
func Admin2FAStats(c *gin.Context) {
|
||||||
|
stats, err := model.GetTwoFAStats()
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "",
|
||||||
|
"data": stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDisable2FA 管理员强制禁用用户2FA
|
||||||
|
func AdminDisable2FA(c *gin.Context) {
|
||||||
|
userIdStr := c.Param("id")
|
||||||
|
userId, err := strconv.Atoi(userIdStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户ID格式错误",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查目标用户权限
|
||||||
|
targetUser, err := model.GetUserById(userId, false)
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
myRole := c.GetInt("role")
|
||||||
|
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "无权操作同级或更高级用户的2FA设置",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 禁用2FA
|
||||||
|
if err := model.DisableTwoFA(userId); err != nil {
|
||||||
|
if errors.Is(err, model.ErrTwoFANotEnabled) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"message": "用户未启用2FA",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录操作日志
|
||||||
|
adminId := c.GetInt("id")
|
||||||
|
model.RecordLog(userId, model.LogTypeManage,
|
||||||
|
fmt.Sprintf("管理员(ID:%d)强制禁用了用户的两步验证", adminId))
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"message": "用户2FA已被强制禁用",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -62,6 +62,32 @@ func Login(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否启用2FA
|
||||||
|
if model.IsTwoFAEnabled(user.Id) {
|
||||||
|
// 设置pending session,等待2FA验证
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Set("pending_username", user.Username)
|
||||||
|
session.Set("pending_user_id", user.Id)
|
||||||
|
err := session.Save()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "无法保存会话信息,请重试",
|
||||||
|
"success": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "请输入两步验证码",
|
||||||
|
"success": true,
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"require_2fa": true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setupLogin(&user, c)
|
setupLogin(&user, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -45,6 +45,7 @@ require (
|
|||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect
|
||||||
github.com/aws/smithy-go v1.20.2 // indirect
|
github.com/aws/smithy-go v1.20.2 // indirect
|
||||||
|
github.com/boombuler/barcode v1.1.0 // indirect
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -79,6 +80,7 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.1 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.1 // indirect
|
||||||
|
github.com/pquerna/otp v1.5.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -20,6 +20,10 @@ github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4 h1:JgHnonzbnA3pbqj76w
|
|||||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4/go.mod h1:nZspkhg+9p8iApLFoyAqfyuMP0F38acy2Hm3r5r95Cg=
|
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4/go.mod h1:nZspkhg+9p8iApLFoyAqfyuMP0F38acy2Hm3r5r95Cg=
|
||||||
github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
|
github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
|
||||||
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
|
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||||
|
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b h1:LTGVFpNmNHhj0vhOlfgWueFJ32eK9blaIlHR2ciXOT0=
|
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b h1:LTGVFpNmNHhj0vhOlfgWueFJ32eK9blaIlHR2ciXOT0=
|
||||||
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b/go.mod h1:2ZlV9BaUH4+NXIBF0aMdKKAnHTzqH+iMU4KUjAbL23Q=
|
github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b/go.mod h1:2ZlV9BaUH4+NXIBF0aMdKKAnHTzqH+iMU4KUjAbL23Q=
|
||||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
@@ -169,6 +173,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
|||||||
@@ -251,6 +251,8 @@ func migrateDB() error {
|
|||||||
&QuotaData{},
|
&QuotaData{},
|
||||||
&Task{},
|
&Task{},
|
||||||
&Setup{},
|
&Setup{},
|
||||||
|
&TwoFA{},
|
||||||
|
&TwoFABackupCode{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -277,6 +279,8 @@ func migrateDBFast() error {
|
|||||||
{&QuotaData{}, "QuotaData"},
|
{&QuotaData{}, "QuotaData"},
|
||||||
{&Task{}, "Task"},
|
{&Task{}, "Task"},
|
||||||
{&Setup{}, "Setup"},
|
{&Setup{}, "Setup"},
|
||||||
|
{&TwoFA{}, "TwoFA"},
|
||||||
|
{&TwoFABackupCode{}, "TwoFABackupCode"},
|
||||||
}
|
}
|
||||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||||
errChan := make(chan error, len(migrations))
|
errChan := make(chan error, len(migrations))
|
||||||
|
|||||||
322
model/twofa.go
Normal file
322
model/twofa.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"one-api/common"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrTwoFANotEnabled = errors.New("用户未启用2FA")
|
||||||
|
|
||||||
|
// TwoFA 用户2FA设置表
|
||||||
|
type TwoFA struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey"`
|
||||||
|
UserId int `json:"user_id" gorm:"unique;not null;index"`
|
||||||
|
Secret string `json:"-" gorm:"type:varchar(255);not null"` // TOTP密钥,不返回给前端
|
||||||
|
IsEnabled bool `json:"is_enabled" gorm:"default:false"`
|
||||||
|
FailedAttempts int `json:"failed_attempts" gorm:"default:0"`
|
||||||
|
LockedUntil *time.Time `json:"locked_until,omitempty"`
|
||||||
|
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TwoFABackupCode 备用码使用记录表
|
||||||
|
type TwoFABackupCode struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey"`
|
||||||
|
UserId int `json:"user_id" gorm:"not null;index"`
|
||||||
|
CodeHash string `json:"-" gorm:"type:varchar(255);not null"` // 备用码哈希
|
||||||
|
IsUsed bool `json:"is_used" gorm:"default:false"`
|
||||||
|
UsedAt *time.Time `json:"used_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTwoFAByUserId 根据用户ID获取2FA设置
|
||||||
|
func GetTwoFAByUserId(userId int) (*TwoFA, error) {
|
||||||
|
if userId == 0 {
|
||||||
|
return nil, errors.New("用户ID不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
var twoFA TwoFA
|
||||||
|
err := DB.Where("user_id = ?", userId).First(&twoFA).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil // 返回nil表示未设置2FA
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &twoFA, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTwoFAEnabled 检查用户是否启用了2FA
|
||||||
|
func IsTwoFAEnabled(userId int) bool {
|
||||||
|
twoFA, err := GetTwoFAByUserId(userId)
|
||||||
|
if err != nil || twoFA == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return twoFA.IsEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTwoFA 创建2FA设置
|
||||||
|
func (t *TwoFA) Create() error {
|
||||||
|
// 检查用户是否已存在2FA设置
|
||||||
|
existing, err := GetTwoFAByUserId(t.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
return errors.New("用户已存在2FA设置")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户存在
|
||||||
|
var user User
|
||||||
|
if err := DB.First(&user, t.UserId).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return errors.New("用户不存在")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB.Create(t).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新2FA设置
|
||||||
|
func (t *TwoFA) Update() error {
|
||||||
|
if t.Id == 0 {
|
||||||
|
return errors.New("2FA记录ID不能为空")
|
||||||
|
}
|
||||||
|
return DB.Save(t).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除2FA设置
|
||||||
|
func (t *TwoFA) Delete() error {
|
||||||
|
if t.Id == 0 {
|
||||||
|
return errors.New("2FA记录ID不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用事务确保原子性
|
||||||
|
return DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 同时删除相关的备用码记录(硬删除)
|
||||||
|
if err := tx.Unscoped().Where("user_id = ?", t.UserId).Delete(&TwoFABackupCode{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 硬删除2FA记录
|
||||||
|
return tx.Unscoped().Delete(t).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetFailedAttempts 重置失败尝试次数
|
||||||
|
func (t *TwoFA) ResetFailedAttempts() error {
|
||||||
|
t.FailedAttempts = 0
|
||||||
|
t.LockedUntil = nil
|
||||||
|
return t.Update()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementFailedAttempts 增加失败尝试次数
|
||||||
|
func (t *TwoFA) IncrementFailedAttempts() error {
|
||||||
|
t.FailedAttempts++
|
||||||
|
|
||||||
|
// 检查是否需要锁定
|
||||||
|
if t.FailedAttempts >= common.MaxFailAttempts {
|
||||||
|
lockUntil := time.Now().Add(time.Duration(common.LockoutDuration) * time.Second)
|
||||||
|
t.LockedUntil = &lockUntil
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.Update()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLocked 检查账户是否被锁定
|
||||||
|
func (t *TwoFA) IsLocked() bool {
|
||||||
|
if t.LockedUntil == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return time.Now().Before(*t.LockedUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBackupCodes 创建备用码
|
||||||
|
func CreateBackupCodes(userId int, codes []string) error {
|
||||||
|
return DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// 先删除现有的备用码
|
||||||
|
if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的备用码记录
|
||||||
|
for _, code := range codes {
|
||||||
|
hashedCode, err := common.HashBackupCode(code)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
backupCode := TwoFABackupCode{
|
||||||
|
UserId: userId,
|
||||||
|
CodeHash: hashedCode,
|
||||||
|
IsUsed: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(&backupCode).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateBackupCode 验证并使用备用码
|
||||||
|
func ValidateBackupCode(userId int, code string) (bool, error) {
|
||||||
|
if !common.ValidateBackupCode(code) {
|
||||||
|
return false, errors.New("验证码或备用码不正确")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedCode := common.NormalizeBackupCode(code)
|
||||||
|
|
||||||
|
// 查找未使用的备用码
|
||||||
|
var backupCodes []TwoFABackupCode
|
||||||
|
if err := DB.Where("user_id = ? AND is_used = false", userId).Find(&backupCodes).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证备用码
|
||||||
|
for _, bc := range backupCodes {
|
||||||
|
if common.ValidatePasswordAndHash(normalizedCode, bc.CodeHash) {
|
||||||
|
// 标记为已使用
|
||||||
|
now := time.Now()
|
||||||
|
bc.IsUsed = true
|
||||||
|
bc.UsedAt = &now
|
||||||
|
|
||||||
|
if err := DB.Save(&bc).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUnusedBackupCodeCount 获取未使用的备用码数量
|
||||||
|
func GetUnusedBackupCodeCount(userId int) (int, error) {
|
||||||
|
var count int64
|
||||||
|
err := DB.Model(&TwoFABackupCode{}).Where("user_id = ? AND is_used = false", userId).Count(&count).Error
|
||||||
|
return int(count), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableTwoFA 禁用用户的2FA
|
||||||
|
func DisableTwoFA(userId int) error {
|
||||||
|
twoFA, err := GetTwoFAByUserId(userId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if twoFA == nil {
|
||||||
|
return ErrTwoFANotEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除2FA设置和备用码
|
||||||
|
return twoFA.Delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableTwoFA 启用2FA
|
||||||
|
func (t *TwoFA) Enable() error {
|
||||||
|
t.IsEnabled = true
|
||||||
|
t.FailedAttempts = 0
|
||||||
|
t.LockedUntil = nil
|
||||||
|
return t.Update()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateTOTPAndUpdateUsage 验证TOTP并更新使用记录
|
||||||
|
func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) {
|
||||||
|
// 检查是否被锁定
|
||||||
|
if t.IsLocked() {
|
||||||
|
return false, fmt.Errorf("账户已被锁定,请在%v后重试", t.LockedUntil.Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证TOTP码
|
||||||
|
if !common.ValidateTOTPCode(t.Secret, code) {
|
||||||
|
// 增加失败次数
|
||||||
|
if err := t.IncrementFailedAttempts(); err != nil {
|
||||||
|
common.SysError("更新2FA失败次数失败: " + err.Error())
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证成功,重置失败次数并更新最后使用时间
|
||||||
|
now := time.Now()
|
||||||
|
t.FailedAttempts = 0
|
||||||
|
t.LockedUntil = nil
|
||||||
|
t.LastUsedAt = &now
|
||||||
|
|
||||||
|
if err := t.Update(); err != nil {
|
||||||
|
common.SysError("更新2FA使用记录失败: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateBackupCodeAndUpdateUsage 验证备用码并更新使用记录
|
||||||
|
func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) {
|
||||||
|
// 检查是否被锁定
|
||||||
|
if t.IsLocked() {
|
||||||
|
return false, fmt.Errorf("账户已被锁定,请在%v后重试", t.LockedUntil.Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证备用码
|
||||||
|
valid, err := ValidateBackupCode(t.UserId, code)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !valid {
|
||||||
|
// 增加失败次数
|
||||||
|
if err := t.IncrementFailedAttempts(); err != nil {
|
||||||
|
common.SysError("更新2FA失败次数失败: " + err.Error())
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证成功,重置失败次数并更新最后使用时间
|
||||||
|
now := time.Now()
|
||||||
|
t.FailedAttempts = 0
|
||||||
|
t.LockedUntil = nil
|
||||||
|
t.LastUsedAt = &now
|
||||||
|
|
||||||
|
if err := t.Update(); err != nil {
|
||||||
|
common.SysError("更新2FA使用记录失败: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTwoFAStats 获取2FA统计信息(管理员使用)
|
||||||
|
func GetTwoFAStats() (map[string]interface{}, error) {
|
||||||
|
var totalUsers, enabledUsers int64
|
||||||
|
|
||||||
|
// 总用户数
|
||||||
|
if err := DB.Model(&User{}).Count(&totalUsers).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用2FA的用户数
|
||||||
|
if err := DB.Model(&TwoFA{}).Where("is_enabled = true").Count(&enabledUsers).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledRate := float64(0)
|
||||||
|
if totalUsers > 0 {
|
||||||
|
enabledRate = float64(enabledUsers) / float64(totalUsers) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"total_users": totalUsers,
|
||||||
|
"enabled_users": enabledUsers,
|
||||||
|
"enabled_rate": fmt.Sprintf("%.1f%%", enabledRate),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ 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("/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)
|
||||||
@@ -66,6 +67,13 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
selfRoute.POST("/stripe/amount", controller.RequestStripeAmount)
|
selfRoute.POST("/stripe/amount", controller.RequestStripeAmount)
|
||||||
selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
|
selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
|
||||||
selfRoute.PUT("/setting", controller.UpdateUserSetting)
|
selfRoute.PUT("/setting", controller.UpdateUserSetting)
|
||||||
|
|
||||||
|
// 2FA routes
|
||||||
|
selfRoute.GET("/2fa/status", controller.Get2FAStatus)
|
||||||
|
selfRoute.POST("/2fa/setup", controller.Setup2FA)
|
||||||
|
selfRoute.POST("/2fa/enable", controller.Enable2FA)
|
||||||
|
selfRoute.POST("/2fa/disable", controller.Disable2FA)
|
||||||
|
selfRoute.POST("/2fa/backup_codes", controller.RegenerateBackupCodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
adminRoute := userRoute.Group("/")
|
adminRoute := userRoute.Group("/")
|
||||||
@@ -78,6 +86,10 @@ 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)
|
||||||
|
|
||||||
|
// Admin 2FA routes
|
||||||
|
adminRoute.GET("/2fa/stats", controller.Admin2FAStats)
|
||||||
|
adminRoute.DELETE("/:id/2fa", controller.AdminDisable2FA)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
optionRoute := apiRouter.Group("/option")
|
optionRoute := apiRouter.Group("/option")
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"marked": "^4.1.1",
|
"marked": "^4.1.1",
|
||||||
"mermaid": "^11.6.0",
|
"mermaid": "^11.6.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
@@ -1492,6 +1493,8 @@
|
|||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
|
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
|
||||||
|
|
||||||
"quansync": ["quansync@0.2.10", "", {}, "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A=="],
|
"quansync": ["quansync@0.2.10", "", {}, "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A=="],
|
||||||
|
|
||||||
"query-string": ["query-string@9.2.0", "", { "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", "split-on-first": "^3.0.0" } }, "sha512-YIRhrHujoQxhexwRLxfy3VSjOXmvZRd2nyw1PwL1UUqZ/ys1dEZd1+NSgXkne2l/4X/7OXkigEAuhTX0g/ivJQ=="],
|
"query-string": ["query-string@9.2.0", "", { "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", "split-on-first": "^3.0.0" } }, "sha512-YIRhrHujoQxhexwRLxfy3VSjOXmvZRd2nyw1PwL1UUqZ/ys1dEZd1+NSgXkne2l/4X/7OXkigEAuhTX0g/ivJQ=="],
|
||||||
@@ -1502,7 +1505,7 @@
|
|||||||
|
|
||||||
"rc-checkbox": ["rc-checkbox@3.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.25.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg=="],
|
"rc-checkbox": ["rc-checkbox@3.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.25.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg=="],
|
||||||
|
|
||||||
"rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="],
|
"rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="],
|
||||||
|
|
||||||
"rc-dialog": ["rc-dialog@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", "classnames": "^2.2.6", "rc-motion": "^2.3.0", "rc-util": "^5.21.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg=="],
|
"rc-dialog": ["rc-dialog@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", "classnames": "^2.2.6", "rc-motion": "^2.3.0", "rc-util": "^5.21.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg=="],
|
||||||
|
|
||||||
@@ -1946,8 +1949,6 @@
|
|||||||
|
|
||||||
"@lobehub/ui/lucide-react": ["lucide-react@0.484.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oZy8coK9kZzvqhSgfbGkPtTgyjpBvs3ukLgDPv14dSOZtBtboryWF5o8i3qen7QbGg7JhiJBz5mK1p8YoMZTLQ=="],
|
"@lobehub/ui/lucide-react": ["lucide-react@0.484.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oZy8coK9kZzvqhSgfbGkPtTgyjpBvs3ukLgDPv14dSOZtBtboryWF5o8i3qen7QbGg7JhiJBz5mK1p8YoMZTLQ=="],
|
||||||
|
|
||||||
"@lobehub/ui/rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="],
|
|
||||||
|
|
||||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="],
|
"@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="],
|
||||||
|
|
||||||
"@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@0.7.2", "", { "dependencies": { "@floating-ui/dom": "^0.5.3", "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg=="],
|
"@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@0.7.2", "", { "dependencies": { "@floating-ui/dom": "^0.5.3", "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg=="],
|
||||||
@@ -1964,6 +1965,8 @@
|
|||||||
|
|
||||||
"@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="],
|
"@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="],
|
||||||
|
|
||||||
|
"antd/rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="],
|
||||||
|
|
||||||
"antd/scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
|
"antd/scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
|
||||||
|
|
||||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"marked": "^4.1.1",
|
"marked": "^4.1.1",
|
||||||
"mermaid": "^11.6.0",
|
"mermaid": "^11.6.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-dropzone": "^14.2.3",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
|
|||||||
import OIDCIcon from '../common/logo/OIDCIcon.js';
|
import OIDCIcon from '../common/logo/OIDCIcon.js';
|
||||||
import WeChatIcon from '../common/logo/WeChatIcon.js';
|
import WeChatIcon from '../common/logo/WeChatIcon.js';
|
||||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
|
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
|
||||||
|
import TwoFAVerification from './TwoFAVerification.js';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const LoginForm = () => {
|
const LoginForm = () => {
|
||||||
@@ -78,6 +79,7 @@ const LoginForm = () => {
|
|||||||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
||||||
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false);
|
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false);
|
||||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||||
|
const [showTwoFA, setShowTwoFA] = useState(false);
|
||||||
|
|
||||||
const logo = getLogo();
|
const logo = getLogo();
|
||||||
const systemName = getSystemName();
|
const systemName = getSystemName();
|
||||||
@@ -162,6 +164,13 @@ const LoginForm = () => {
|
|||||||
);
|
);
|
||||||
const { success, message, data } = res.data;
|
const { success, message, data } = res.data;
|
||||||
if (success) {
|
if (success) {
|
||||||
|
// 检查是否需要2FA验证
|
||||||
|
if (data && data.require_2fa) {
|
||||||
|
setShowTwoFA(true);
|
||||||
|
setLoginLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
userDispatch({ type: 'login', payload: data });
|
userDispatch({ type: 'login', payload: data });
|
||||||
setUserData(data);
|
setUserData(data);
|
||||||
updateAPI();
|
updateAPI();
|
||||||
@@ -280,6 +289,21 @@ const LoginForm = () => {
|
|||||||
setOtherLoginOptionsLoading(false);
|
setOtherLoginOptionsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 2FA验证成功处理
|
||||||
|
const handle2FASuccess = (data) => {
|
||||||
|
userDispatch({ type: 'login', payload: data });
|
||||||
|
setUserData(data);
|
||||||
|
updateAPI();
|
||||||
|
showSuccess('登录成功!');
|
||||||
|
navigate('/console');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 返回登录页面
|
||||||
|
const handleBackToLogin = () => {
|
||||||
|
setShowTwoFA(false);
|
||||||
|
setInputs({ username: '', password: '', wechat_verification_code: '' });
|
||||||
|
};
|
||||||
|
|
||||||
const renderOAuthOptions = () => {
|
const renderOAuthOptions = () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
@@ -537,6 +561,35 @@ const LoginForm = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 2FA验证弹窗
|
||||||
|
const render2FAModal = () => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3">
|
||||||
|
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
两步验证
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
visible={showTwoFA}
|
||||||
|
onCancel={handleBackToLogin}
|
||||||
|
footer={null}
|
||||||
|
width={450}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<TwoFAVerification
|
||||||
|
onSuccess={handle2FASuccess}
|
||||||
|
onBack={handleBackToLogin}
|
||||||
|
isModal={true}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||||
{/* 背景模糊晕染球 */}
|
{/* 背景模糊晕染球 */}
|
||||||
@@ -547,6 +600,7 @@ const LoginForm = () => {
|
|||||||
? renderEmailLoginForm()
|
? renderEmailLoginForm()
|
||||||
: renderOAuthOptions()}
|
: renderOAuthOptions()}
|
||||||
{renderWeChatLoginModal()}
|
{renderWeChatLoginModal()}
|
||||||
|
{render2FAModal()}
|
||||||
|
|
||||||
{turnstileEnabled && (
|
{turnstileEnabled && (
|
||||||
<div className="flex justify-center mt-6">
|
<div className="flex justify-center mt-6">
|
||||||
|
|||||||
230
web/src/components/auth/TwoFAVerification.js
Normal file
230
web/src/components/auth/TwoFAVerification.js
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
/*
|
||||||
|
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, showSuccess } from '../../helpers';
|
||||||
|
import { Button, Card, Divider, Form, Input, Typography } from '@douyinfe/semi-ui';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||||
|
const [verificationCode, setVerificationCode] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!verificationCode) {
|
||||||
|
showError('请输入验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Validate code format
|
||||||
|
if (useBackupCode && verificationCode.length !== 8) {
|
||||||
|
showError('备用码必须是8位');
|
||||||
|
return;
|
||||||
|
} else if (!useBackupCode && !/^\d{6}$/.test(verificationCode)) {
|
||||||
|
showError('验证码必须是6位数字');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/user/login/2fa', {
|
||||||
|
code: verificationCode
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.data.success) {
|
||||||
|
showSuccess('登录成功');
|
||||||
|
// 保存用户信息到本地存储
|
||||||
|
localStorage.setItem('user', JSON.stringify(res.data.data));
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess(res.data.data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showError(res.data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError('验证失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isModal) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Paragraph className="text-gray-600 dark:text-gray-300">
|
||||||
|
请输入认证器应用显示的验证码完成登录
|
||||||
|
</Paragraph>
|
||||||
|
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<Form.Input
|
||||||
|
field="code"
|
||||||
|
label={useBackupCode ? "备用码" : "验证码"}
|
||||||
|
placeholder={useBackupCode ? "请输入8位备用码" : "请输入6位验证码"}
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={setVerificationCode}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
size="large"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
htmlType="submit"
|
||||||
|
type="primary"
|
||||||
|
loading={loading}
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
验证并登录
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
type="tertiary"
|
||||||
|
onClick={() => {
|
||||||
|
setUseBackupCode(!useBackupCode);
|
||||||
|
setVerificationCode('');
|
||||||
|
}}
|
||||||
|
style={{ marginRight: 16, color: '#1890ff', padding: 0 }}
|
||||||
|
>
|
||||||
|
{useBackupCode ? '使用认证器验证码' : '使用备用码'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{onBack && (
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
type="tertiary"
|
||||||
|
onClick={onBack}
|
||||||
|
style={{ color: '#1890ff', padding: 0 }}
|
||||||
|
>
|
||||||
|
返回登录
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||||
|
<Text size="small" type="secondary">
|
||||||
|
<strong>提示:</strong>
|
||||||
|
<br />
|
||||||
|
• 验证码每30秒更新一次
|
||||||
|
<br />
|
||||||
|
• 如果无法获取验证码,请使用备用码
|
||||||
|
<br />
|
||||||
|
• 每个备用码只能使用一次
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '60vh'
|
||||||
|
}}>
|
||||||
|
<Card style={{ width: 400, padding: 24 }}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||||
|
<Title heading={3}>两步验证</Title>
|
||||||
|
<Paragraph type="secondary">
|
||||||
|
请输入认证器应用显示的验证码完成登录
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<Form.Input
|
||||||
|
field="code"
|
||||||
|
label={useBackupCode ? "备用码" : "验证码"}
|
||||||
|
placeholder={useBackupCode ? "请输入8位备用码" : "请输入6位验证码"}
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={setVerificationCode}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
size="large"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
htmlType="submit"
|
||||||
|
type="primary"
|
||||||
|
loading={loading}
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
验证并登录
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
type="tertiary"
|
||||||
|
onClick={() => {
|
||||||
|
setUseBackupCode(!useBackupCode);
|
||||||
|
setVerificationCode('');
|
||||||
|
}}
|
||||||
|
style={{ marginRight: 16, color: '#1890ff', padding: 0 }}
|
||||||
|
>
|
||||||
|
{useBackupCode ? '使用认证器验证码' : '使用备用码'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{onBack && (
|
||||||
|
<Button
|
||||||
|
theme="borderless"
|
||||||
|
type="tertiary"
|
||||||
|
onClick={onBack}
|
||||||
|
style={{ color: '#1890ff', padding: 0 }}
|
||||||
|
>
|
||||||
|
返回登录
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 24, padding: 16, background: '#f6f8fa', borderRadius: 6 }}>
|
||||||
|
<Text size="small" type="secondary">
|
||||||
|
<strong>提示:</strong>
|
||||||
|
<br />
|
||||||
|
• 验证码每30秒更新一次
|
||||||
|
<br />
|
||||||
|
• 如果无法获取验证码,请使用备用码
|
||||||
|
<br />
|
||||||
|
• 每个备用码只能使用一次
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TwoFAVerification;
|
||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
renderModelTag,
|
renderModelTag,
|
||||||
getModelCategories
|
getModelCategories
|
||||||
} from '../../helpers';
|
} from '../../helpers';
|
||||||
|
import TwoFASetting from './TwoFASetting';
|
||||||
import Turnstile from 'react-turnstile';
|
import Turnstile from 'react-turnstile';
|
||||||
import { UserContext } from '../../context/User';
|
import { UserContext } from '../../context/User';
|
||||||
import { useTheme } from '../../context/Theme';
|
import { useTheme } from '../../context/Theme';
|
||||||
@@ -1041,6 +1042,9 @@ const PersonalSetting = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 两步验证设置 */}
|
||||||
|
<TwoFASetting />
|
||||||
|
|
||||||
{/* 危险区域 */}
|
{/* 危险区域 */}
|
||||||
<Card
|
<Card
|
||||||
className="!rounded-xl border-red-200 w-full"
|
className="!rounded-xl border-red-200 w-full"
|
||||||
|
|||||||
524
web/src/components/settings/TwoFASetting.js
Normal file
524
web/src/components/settings/TwoFASetting.js
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
/*
|
||||||
|
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, showSuccess, showWarning } from '../../helpers';
|
||||||
|
import { Banner, Button, Card, Checkbox, Divider, Form, Input, Modal, Tag, Typography } from '@douyinfe/semi-ui';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
|
||||||
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
const TwoFASetting = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [status, setStatus] = useState({
|
||||||
|
enabled: false,
|
||||||
|
locked: false,
|
||||||
|
backup_codes_remaining: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// 模态框状态
|
||||||
|
const [setupModalVisible, setSetupModalVisible] = useState(false);
|
||||||
|
const [enableModalVisible, setEnableModalVisible] = useState(false);
|
||||||
|
const [disableModalVisible, setDisableModalVisible] = useState(false);
|
||||||
|
const [backupModalVisible, setBackupModalVisible] = useState(false);
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const [setupData, setSetupData] = useState(null);
|
||||||
|
const [verificationCode, setVerificationCode] = useState('');
|
||||||
|
const [backupCodes, setBackupCodes] = useState([]);
|
||||||
|
const [confirmDisable, setConfirmDisable] = useState(false);
|
||||||
|
|
||||||
|
// 获取2FA状态
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
const res = await API.get('/api/user/2fa/status');
|
||||||
|
if (res.data.success) {
|
||||||
|
setStatus(res.data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError('获取2FA状态失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始化2FA设置
|
||||||
|
const handleSetup2FA = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/user/2fa/setup');
|
||||||
|
if (res.data.success) {
|
||||||
|
setSetupData(res.data.data);
|
||||||
|
setSetupModalVisible(true);
|
||||||
|
} else {
|
||||||
|
showError(res.data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError('设置2FA失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启用2FA
|
||||||
|
const handleEnable2FA = async () => {
|
||||||
|
if (!verificationCode) {
|
||||||
|
showWarning('请输入验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/user/2fa/enable', {
|
||||||
|
code: verificationCode
|
||||||
|
});
|
||||||
|
if (res.data.success) {
|
||||||
|
showSuccess('两步验证启用成功!');
|
||||||
|
setEnableModalVisible(false);
|
||||||
|
setSetupModalVisible(false);
|
||||||
|
setVerificationCode('');
|
||||||
|
fetchStatus();
|
||||||
|
} else {
|
||||||
|
showError(res.data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError('启用2FA失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 禁用2FA
|
||||||
|
const handleDisable2FA = async () => {
|
||||||
|
if (!verificationCode) {
|
||||||
|
showWarning('请输入验证码或备用码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmDisable) {
|
||||||
|
showWarning('请确认您已了解禁用两步验证的后果');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/user/2fa/disable', {
|
||||||
|
code: verificationCode
|
||||||
|
});
|
||||||
|
if (res.data.success) {
|
||||||
|
showSuccess('两步验证已禁用');
|
||||||
|
setDisableModalVisible(false);
|
||||||
|
setVerificationCode('');
|
||||||
|
setConfirmDisable(false);
|
||||||
|
fetchStatus();
|
||||||
|
} else {
|
||||||
|
showError(res.data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError('禁用2FA失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重新生成备用码
|
||||||
|
const handleRegenerateBackupCodes = async () => {
|
||||||
|
if (!verificationCode) {
|
||||||
|
showWarning('请输入验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.post('/api/user/2fa/backup_codes', {
|
||||||
|
code: verificationCode
|
||||||
|
});
|
||||||
|
if (res.data.success) {
|
||||||
|
setBackupCodes(res.data.data.backup_codes);
|
||||||
|
showSuccess('备用码重新生成成功');
|
||||||
|
setVerificationCode('');
|
||||||
|
fetchStatus();
|
||||||
|
} else {
|
||||||
|
showError(res.data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError('重新生成备用码失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyBackupCodes = () => {
|
||||||
|
const codesText = backupCodes.join('\n');
|
||||||
|
navigator.clipboard.writeText(codesText).then(() => {
|
||||||
|
showSuccess('备用码已复制到剪贴板');
|
||||||
|
}).catch(() => {
|
||||||
|
showError('复制失败,请手动复制');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Card
|
||||||
|
className="!rounded-xl transition-shadow w-full"
|
||||||
|
bodyStyle={{ padding: '20px' }}
|
||||||
|
shadows='hover'
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center flex-1">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3">
|
||||||
|
<svg className="w-5 h-5 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-gray-100">两步验证设置</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
两步验证(2FA)为您的账户提供额外的安全保护。启用后,登录时需要输入密码和验证器应用生成的验证码。
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center mt-2 space-x-2">
|
||||||
|
<Text strong>当前状态:</Text>
|
||||||
|
{status.enabled ? (
|
||||||
|
<Tag color="green" size="small">已启用</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color="red" size="small">未启用</Tag>
|
||||||
|
)}
|
||||||
|
{status.locked && (
|
||||||
|
<Tag color="orange" size="small">账户已锁定</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{status.enabled && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<Text size="small" type="secondary">剩余备用码:{status.backup_codes_remaining || 0} 个</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
{!status.enabled ? (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="default"
|
||||||
|
onClick={handleSetup2FA}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
启用两步验证
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
size="default"
|
||||||
|
onClick={() => setDisableModalVisible(true)}
|
||||||
|
>
|
||||||
|
禁用两步验证
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
onClick={() => setBackupModalVisible(true)}
|
||||||
|
>
|
||||||
|
重新生成备用码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 2FA设置模态框 */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3">
|
||||||
|
<svg className="w-4 h-4 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
设置两步验证
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
visible={setupModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setSetupModalVisible(false);
|
||||||
|
setSetupData(null);
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
width={650}
|
||||||
|
style={{ maxWidth: '90vw' }}
|
||||||
|
>
|
||||||
|
{setupData && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 步骤 1:扫描二维码 */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-center mb-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm font-medium mr-2">
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<Text strong className="text-gray-900 dark:text-gray-100">扫描二维码</Text>
|
||||||
|
</div>
|
||||||
|
<Paragraph className="text-gray-600 dark:text-gray-300 mb-4">
|
||||||
|
使用认证器应用(如 Google Authenticator、Microsoft Authenticator)扫描下方二维码:
|
||||||
|
</Paragraph>
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||||
|
<QRCodeSVG value={setupData.qr_code_data} size={180} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900 rounded-lg p-3">
|
||||||
|
<Text className="text-blue-800 dark:text-blue-200 text-sm">
|
||||||
|
或手动输入密钥:<Text code copyable className="ml-2">{setupData.secret}</Text>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 步骤 2:保存备用码 */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-center mb-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-orange-500 text-white flex items-center justify-center text-sm font-medium mr-2">
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<Text strong className="text-gray-900 dark:text-gray-100">保存备用码</Text>
|
||||||
|
</div>
|
||||||
|
<Paragraph className="text-gray-600 dark:text-gray-300 mb-4">
|
||||||
|
请将以下备用码保存在安全的地方。如果丢失手机,可以使用这些备用码登录:
|
||||||
|
</Paragraph>
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-lg p-4">
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||||
|
{setupData.backup_codes.map((code, index) => (
|
||||||
|
<div key={index} className="bg-white dark:bg-gray-700 p-2 rounded text-center">
|
||||||
|
<Text code className="text-sm">{code}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
const codesText = setupData.backup_codes.join('\n');
|
||||||
|
navigator.clipboard.writeText(codesText);
|
||||||
|
showSuccess('备用码已复制');
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
复制所有备用码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 步骤 3:验证设置 */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-center mb-3">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-green-500 text-white flex items-center justify-center text-sm font-medium mr-2">
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<Text strong className="text-gray-900 dark:text-gray-100">验证设置</Text>
|
||||||
|
</div>
|
||||||
|
<Paragraph className="text-gray-600 dark:text-gray-300 mb-4">
|
||||||
|
输入认证器应用显示的6位数字验证码:
|
||||||
|
</Paragraph>
|
||||||
|
<Form onSubmit={handleEnable2FA}>
|
||||||
|
<Form.Input
|
||||||
|
field="code"
|
||||||
|
placeholder="请输入6位验证码"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={setVerificationCode}
|
||||||
|
size="large"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
htmlType="submit"
|
||||||
|
type="primary"
|
||||||
|
loading={loading}
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
完成设置并启用两步验证
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 禁用2FA模态框 */}
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center mr-3">
|
||||||
|
<svg className="w-4 h-4 text-red-600 dark:text-red-400" 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>
|
||||||
|
禁用两步验证
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
visible={disableModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setDisableModalVisible(false);
|
||||||
|
setVerificationCode('');
|
||||||
|
setConfirmDisable(false);
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
width={550}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Banner
|
||||||
|
type="warning"
|
||||||
|
description={
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium">警告:禁用两步验证将会:</div>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li>降低您账户的安全性</li>
|
||||||
|
<li>永久删除您的两步验证设置</li>
|
||||||
|
<li>永久删除所有备用码(包括未使用的)</li>
|
||||||
|
<li>需要重新完整设置才能再次启用</li>
|
||||||
|
</ul>
|
||||||
|
<div className="text-sm text-red-600 dark:text-red-400 font-medium mt-2">
|
||||||
|
此操作不可撤销,请谨慎操作!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
<Form onSubmit={handleDisable2FA}>
|
||||||
|
<Form.Input
|
||||||
|
field="code"
|
||||||
|
label="验证码"
|
||||||
|
placeholder="请输入认证器验证码或备用码"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={setVerificationCode}
|
||||||
|
size="large"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Checkbox
|
||||||
|
checked={confirmDisable}
|
||||||
|
onChange={(e) => setConfirmDisable(e.target.checked)}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
htmlType="submit"
|
||||||
|
type="danger"
|
||||||
|
loading={loading}
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
disabled={!confirmDisable}
|
||||||
|
>
|
||||||
|
确认禁用两步验证
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 重新生成备用码模态框 */}
|
||||||
|
<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="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
重新生成备用码
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
visible={backupModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setBackupModalVisible(false);
|
||||||
|
setVerificationCode('');
|
||||||
|
setBackupCodes([]);
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{backupCodes.length === 0 ? (
|
||||||
|
<>
|
||||||
|
<Banner
|
||||||
|
type="warning"
|
||||||
|
description="重新生成备用码将使现有的备用码失效,请确保您已保存了当前的备用码。"
|
||||||
|
className="rounded-lg"
|
||||||
|
/>
|
||||||
|
<Form onSubmit={handleRegenerateBackupCodes}>
|
||||||
|
<Form.Input
|
||||||
|
field="code"
|
||||||
|
label="验证码"
|
||||||
|
placeholder="请输入认证器验证码"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={setVerificationCode}
|
||||||
|
size="large"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
htmlType="submit"
|
||||||
|
type="primary"
|
||||||
|
loading={loading}
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
生成新的备用码
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mx-auto mb-2">
|
||||||
|
<svg className="w-6 h-6 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<Text strong className="text-lg">新的备用码已生成</Text>
|
||||||
|
<Paragraph className="text-gray-600 dark:text-gray-300 mt-2">
|
||||||
|
请将以下备用码保存在安全的地方:
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-lg p-4">
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||||
|
{backupCodes.map((code, index) => (
|
||||||
|
<div key={index} className="bg-white dark:bg-gray-700 p-2 rounded text-center">
|
||||||
|
<Text code className="text-sm">{code}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={copyBackupCodes}
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
>
|
||||||
|
复制所有备用码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TwoFASetting;
|
||||||
Reference in New Issue
Block a user