diff --git a/common/totp.go b/common/totp.go new file mode 100644 index 00000000..ece5bc31 --- /dev/null +++ b/common/totp.go @@ -0,0 +1,153 @@ +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 { + if issuer := SystemName; issuer != "" { + return issuer + } + return "NewAPI" +} + +// 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) +} diff --git a/controller/twofa.go b/controller/twofa.go new file mode 100644 index 00000000..368289c9 --- /dev/null +++ b/controller/twofa.go @@ -0,0 +1,547 @@ +package controller + +import ( + "fmt" + "net/http" + "one-api/common" + "one-api/model" + "strconv" + "strings" + + "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 := pendingUserId.(int) + + // 获取用户信息 + 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 strings.Contains(err.Error(), "未启用2FA") { + 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已被强制禁用", + }) +} diff --git a/controller/user.go b/controller/user.go index 292ed8c6..6e968037 100644 --- a/controller/user.go +++ b/controller/user.go @@ -62,6 +62,32 @@ func Login(c *gin.Context) { }) 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) } diff --git a/go.mod b/go.mod index 94873c88..1def0b08 100644 --- a/go.mod +++ b/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/endpoints/v2 v2.6.5 // indirect github.com/aws/smithy-go v1.20.2 // indirect + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // 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/reflect2 v1.0.2 // 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/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect diff --git a/go.sum b/go.sum index 74eecd4c..4f5ae530 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ 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/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/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/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/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= @@ -169,6 +171,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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/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-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= diff --git a/model/main.go b/model/main.go index 013beacd..38dd2aee 100644 --- a/model/main.go +++ b/model/main.go @@ -251,6 +251,8 @@ func migrateDB() error { &QuotaData{}, &Task{}, &Setup{}, + &TwoFA{}, + &TwoFABackupCode{}, ) if err != nil { return err @@ -277,6 +279,8 @@ func migrateDBFast() error { {&QuotaData{}, "QuotaData"}, {&Task{}, "Task"}, {&Setup{}, "Setup"}, + {&TwoFA{}, "TwoFA"}, + {&TwoFABackupCode{}, "TwoFABackupCode"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) diff --git a/model/twofa.go b/model/twofa.go new file mode 100644 index 00000000..4a96ffb0 --- /dev/null +++ b/model/twofa.go @@ -0,0 +1,315 @@ +package model + +import ( + "errors" + "fmt" + "one-api/common" + "time" + + "gorm.io/gorm" +) + +// 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不能为空") + } + + // 同时删除相关的备用码记录(硬删除) + if err := DB.Unscoped().Where("user_id = ?", t.UserId).Delete(&TwoFABackupCode{}).Error; err != nil { + return err + } + + // 硬删除2FA记录 + return DB.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 { + // 先删除现有的备用码 + if err := DB.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 := DB.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 errors.New("用户未启用2FA") + } + + // 删除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 +} diff --git a/router/api-router.go b/router/api-router.go index bc49803a..16c78186 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -44,6 +44,7 @@ func SetApiRouter(router *gin.Engine) { { userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) 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.GET("/logout", controller.Logout) userRoute.GET("/epay/notify", controller.EpayNotify) @@ -66,6 +67,13 @@ func SetApiRouter(router *gin.Engine) { selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) selfRoute.POST("/aff_transfer", controller.TransferAffQuota) 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("/") @@ -78,6 +86,10 @@ func SetApiRouter(router *gin.Engine) { adminRoute.POST("/manage", controller.ManageUser) adminRoute.PUT("/", controller.UpdateUser) adminRoute.DELETE("/:id", controller.DeleteUser) + + // Admin 2FA routes + adminRoute.GET("/2fa/stats", controller.Admin2FAStats) + adminRoute.DELETE("/:id/2fa", controller.AdminDisable2FA) } } optionRoute := apiRouter.Group("/option") diff --git a/web/bun.lock b/web/bun.lock index ca4e337c..53467aa5 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -21,6 +21,7 @@ "lucide-react": "^0.511.0", "marked": "^4.1.1", "mermaid": "^11.6.0", + "qrcode.react": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", @@ -1492,6 +1493,8 @@ "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=="], "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-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=="], @@ -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/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-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=="], + "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=="], "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], diff --git a/web/package.json b/web/package.json index ba0df966..f014d84b 100644 --- a/web/package.json +++ b/web/package.json @@ -21,6 +21,7 @@ "lucide-react": "^0.511.0", "marked": "^4.1.1", "mermaid": "^11.6.0", + "qrcode.react": "^4.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", diff --git a/web/src/components/auth/LoginForm.js b/web/src/components/auth/LoginForm.js index f81dfd81..9c6650f8 100644 --- a/web/src/components/auth/LoginForm.js +++ b/web/src/components/auth/LoginForm.js @@ -50,6 +50,7 @@ import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons'; import OIDCIcon from '../common/logo/OIDCIcon.js'; import WeChatIcon from '../common/logo/WeChatIcon.js'; import LinuxDoIcon from '../common/logo/LinuxDoIcon.js'; +import TwoFAVerification from './TwoFAVerification.js'; import { useTranslation } from 'react-i18next'; const LoginForm = () => { @@ -78,6 +79,7 @@ const LoginForm = () => { const [resetPasswordLoading, setResetPasswordLoading] = useState(false); const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false); const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); + const [showTwoFA, setShowTwoFA] = useState(false); const logo = getLogo(); const systemName = getSystemName(); @@ -162,6 +164,13 @@ const LoginForm = () => { ); const { success, message, data } = res.data; if (success) { + // 检查是否需要2FA验证 + if (data && data.require_2fa) { + setShowTwoFA(true); + setLoginLoading(false); + return; + } + userDispatch({ type: 'login', payload: data }); setUserData(data); updateAPI(); @@ -280,6 +289,21 @@ const LoginForm = () => { 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 = () => { return (