diff --git a/controllers/admin/app.go b/controllers/admin/app.go index 81d4d4b..546b8b5 100644 --- a/controllers/admin/app.go +++ b/controllers/admin/app.go @@ -521,24 +521,42 @@ func AppDeleteHandler(c *gin.Context) { return } - // 删除相关的变量记录 - if err := tx.Where("app_uuid = ?", app.UUID).Delete(&models.Variable{}).Error; err != nil { + // 检查是否有关联的变量 + var varCount int64 + if err := tx.Model(&models.Variable{}).Where("app_uuid = ?", app.UUID).Count(&varCount).Error; err != nil { tx.Rollback() - logrus.WithError(err).Error("Failed to delete related variables") + logrus.WithError(err).Error("Failed to count related variables") c.JSON(http.StatusInternalServerError, gin.H{ "code": 1, - "msg": "删除相关变量失败", + "msg": "检查关联变量失败", + }) + return + } + if varCount > 0 { + tx.Rollback() + c.JSON(http.StatusBadRequest, gin.H{ + "code": 1, + "msg": "该应用下存在关联变量,禁止删除", }) return } - // 删除相关的函数记录 - if err := tx.Where("app_uuid = ?", app.UUID).Delete(&models.Function{}).Error; err != nil { + // 检查是否有关联的函数 + var funcCount int64 + if err := tx.Model(&models.Function{}).Where("app_uuid = ?", app.UUID).Count(&funcCount).Error; err != nil { tx.Rollback() - logrus.WithError(err).Error("Failed to delete related functions") + logrus.WithError(err).Error("Failed to count related functions") c.JSON(http.StatusInternalServerError, gin.H{ "code": 1, - "msg": "删除相关函数失败", + "msg": "检查关联函数失败", + }) + return + } + if funcCount > 0 { + tx.Rollback() + c.JSON(http.StatusBadRequest, gin.H{ + "code": 1, + "msg": "该应用下存在关联函数,禁止删除", }) return } @@ -581,7 +599,7 @@ func AppDeleteHandler(c *gin.Context) { logrus.WithFields(logrus.Fields{ "app_id": app.ID, "app_uuid": app.UUID, - }).Debug("Successfully deleted app and related APIs, Variables and Functions") + }).Debug("Successfully deleted app and related APIs") c.JSON(http.StatusOK, gin.H{ "code": 0, @@ -1254,6 +1272,46 @@ func AppsBatchDeleteHandler(c *gin.Context) { // 删除这些应用的所有相关接口 if len(appUUIDs) > 0 { + // 检查是否有关联的变量 + var varCount int64 + if err := tx.Model(&models.Variable{}).Where("app_uuid IN ?", appUUIDs).Count(&varCount).Error; err != nil { + tx.Rollback() + logrus.WithError(err).Error("Failed to count related variables") + c.JSON(http.StatusInternalServerError, gin.H{ + "code": 1, + "msg": "检查关联变量失败", + }) + return + } + if varCount > 0 { + tx.Rollback() + c.JSON(http.StatusBadRequest, gin.H{ + "code": 1, + "msg": "所选应用中存在关联变量,禁止删除", + }) + return + } + + // 检查是否有关联的函数 + var funcCount int64 + if err := tx.Model(&models.Function{}).Where("app_uuid IN ?", appUUIDs).Count(&funcCount).Error; err != nil { + tx.Rollback() + logrus.WithError(err).Error("Failed to count related functions") + c.JSON(http.StatusInternalServerError, gin.H{ + "code": 1, + "msg": "检查关联函数失败", + }) + return + } + if funcCount > 0 { + tx.Rollback() + c.JSON(http.StatusBadRequest, gin.H{ + "code": 1, + "msg": "所选应用中存在关联函数,禁止删除", + }) + return + } + if err := tx.Where("app_uuid IN ?", appUUIDs).Delete(&models.API{}).Error; err != nil { tx.Rollback() logrus.WithError(err).Error("Failed to delete related APIs") diff --git a/controllers/admin/auth.go b/controllers/admin/auth.go index 1d0df5c..1be9226 100644 --- a/controllers/admin/auth.go +++ b/controllers/admin/auth.go @@ -13,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + "github.com/sirupsen/logrus" "github.com/spf13/viper" ) @@ -83,37 +84,51 @@ func LoginHandler(c *gin.Context) { // 验证验证码 if !VerifyCaptcha(c, body.Captcha) { - recordLoginLog(c, body.Username, 0, "验证码错误") - authBaseController.HandleValidationError(c, "验证码错误") + recordLoginLog(c, "", body.Username, 0, "验证码错误或已过期") + authBaseController.HandleValidationError(c, "验证码错误或已过期") return } // 从数据库中查找对应的用户 db, err := database.GetDB() if err != nil { - recordLoginLog(c, body.Username, 0, "数据库连接失败") + recordLoginLog(c, "", body.Username, 0, "数据库连接失败") authBaseController.HandleInternalError(c, "数据库连接失败", err) return } var user models.User - if err := db.Where("username = ? AND role = ?", body.Username, 0).First(&user).Error; err != nil { - recordLoginLog(c, body.Username, 0, "用户不存在或非管理员") + if err := db.Where("username = ?", body.Username).First(&user).Error; err != nil { + recordLoginLog(c, user.UUID, body.Username, 0, "用户不存在") authBaseController.HandleValidationError(c, "用户不存在或密码错误") return } + // 检查账号状态 (Status=1 表示启用,否则禁止登录) + if user.Status != 1 { + recordLoginLog(c, user.UUID, body.Username, 0, "账号已被禁用") + authBaseController.HandleValidationError(c, "该账号已被禁用,请联系超级管理员") + return + } + + // 检查是否允许登录 (role=0 或 role=1 允许登录,role=2 不允许) + if user.Role > 1 { + recordLoginLog(c, user.UUID, body.Username, 0, "权限不足") + authBaseController.HandleValidationError(c, "权限不足,禁止登录") + return + } + // 验证密码(使用盐值校验) if !utils.VerifyPasswordWithSalt(body.Password, user.PasswordSalt, user.Password) { - recordLoginLog(c, body.Username, 0, "密码错误") + recordLoginLog(c, user.UUID, body.Username, 0, "密码错误") authBaseController.HandleValidationError(c, "用户不存在或密码错误") return } // 生成JWT令牌 - token, err := generateJWTTokenForAdmin(user.Username, user.Password, user.UUID) + token, err := generateJWTTokenForAdmin(user.Username, user.Password, user.UUID, user.Role) if err != nil { - recordLoginLog(c, body.Username, 0, "生成令牌失败") + recordLoginLog(c, user.UUID, body.Username, 0, "生成令牌失败") authBaseController.HandleInternalError(c, "生成令牌失败", err) return } @@ -125,19 +140,20 @@ func LoginHandler(c *gin.Context) { cookie := utils.CreateSecureCookie("admin_session", token, maxAge, domain, secure, sameSite) c.SetCookie(cookie.Name, cookie.Value, cookie.MaxAge, cookie.Path, cookie.Domain, cookie.Secure, cookie.HttpOnly) - recordLoginLog(c, body.Username, 1, "登录成功") + recordLoginLog(c, user.UUID, body.Username, 1, "登录成功") authBaseController.HandleSuccess(c, "登录成功", gin.H{ "redirect": "/admin", "avatar": user.Avatar, "nickname": user.Nickname, "username": user.Username, + "role": user.Role, "token": token, }) } // recordLoginLog 记录登录日志 // status: 1-成功, 0-失败 -func recordLoginLog(c *gin.Context, username string, status int, message string) { +func recordLoginLog(c *gin.Context, uuid string, username string, status int, message string) { db, err := database.GetDB() if err != nil { // 记录日志失败不应影响主流程,但可以记录到系统日志 @@ -147,10 +163,11 @@ func recordLoginLog(c *gin.Context, username string, status int, message string) log := models.LoginLog{ Type: "admin", + UUID: uuid, Username: username, IP: c.ClientIP(), Status: status, - Message: message, + Message: "登录管理 - " + message, UserAgent: c.Request.UserAgent(), CreatedAt: time.Now(), } @@ -237,8 +254,9 @@ func getJWTSecret() []byte { return []byte(secret) } - // 3. 使用默认不安全密钥(仅开发环境) - return []byte("default-insecure-jwt-secret") + // 3. 如果仍未获取到,则记录严重错误并抛出 panic,拒绝使用硬编码的不安全密钥 + logrus.Fatal("致命安全错误: 无法获取有效的 JWT 密钥,请检查数据库设置或重新安装系统。系统拒绝以不安全模式运行。") + return nil } // ============================================================================ @@ -248,8 +266,8 @@ func getJWTSecret() []byte { // JWTClaims JWT载荷结构体 type JWTClaims struct { Username string `json:"username"` - UUID string `json:"uuid"` // 添加虚拟角色UUID - Role int `json:"role"` // 添加虚拟角色 + UUID string `json:"uuid"` // 用户UUID + Role int `json:"role"` // 用户角色 PasswordHash string `json:"password_hash"` // 密码哈希摘要,用于验证密码是否被修改 jwt.RegisteredClaims } @@ -258,7 +276,7 @@ type JWTClaims struct { // - 包含管理员用户名信息和密码哈希 // - 设置过期时间 // - 使用HMAC-SHA256签名 -func generateJWTTokenForAdmin(username, passwordHash string, adminUUID string) (string, error) { +func generateJWTTokenForAdmin(username, passwordHash string, adminUUID string, role int) (string, error) { // 生成密码哈希摘要(使用SHA256) // 注意:传入的 passwordHash 已经是数据库存的 Hash,这里我们再次 Hash 还是直接用? // atomicLibrary 的实现是: utils.GenerateSHA256Hash(adminUser.Password) @@ -271,7 +289,7 @@ func generateJWTTokenForAdmin(username, passwordHash string, adminUUID string) ( claims := JWTClaims{ Username: username, UUID: adminUUID, - Role: 0, // 0表示超级管理员 + Role: role, // 用户真实角色 PasswordHash: passwordHashDigest, // 包含密码哈希摘要 RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(services.GetSettingsService().GetJWTExpire()) * time.Hour)), @@ -339,9 +357,23 @@ func validateAdminPasswordHash(claims *JWTClaims, c *gin.Context) bool { // 获取当前数据库中的管理员用户 var adminUser models.User - if err := db.Where("username = ? AND role = ?", claims.Username, 0).First(&adminUser).Error; err != nil { - fmt.Printf("[SECURITY WARNING] Admin user not found in database - Username=%s, IP=%s\n", - claims.Username, c.ClientIP()) + if err := db.Where("uuid = ?", claims.UUID).First(&adminUser).Error; err != nil { + fmt.Printf("[SECURITY WARNING] Admin user not found in database - UUID=%s, IP=%s\n", + claims.UUID, c.ClientIP()) + return false + } + + // 检查账号状态 (Status=1 表示启用,否则强制下线) + if adminUser.Status != 1 { + fmt.Printf("[SECURITY WARNING] Admin user is disabled - UUID=%s, IP=%s\n", + claims.UUID, c.ClientIP()) + return false + } + + // 检查是否允许登录 (role=0 或 role=1 允许,role=2不允许访问admin后台) + if adminUser.Role > 1 { + fmt.Printf("[SECURITY WARNING] Admin user role > 1 - UUID=%s, IP=%s\n", + claims.UUID, c.ClientIP()) return false } @@ -413,7 +445,17 @@ func IsAdminAuthenticatedHttp(r *http.Request) bool { } var adminUser models.User - if err := db.Where("username = ? AND role = ?", claims.Username, 0).First(&adminUser).Error; err != nil { + if err := db.Where("uuid = ?", claims.UUID).First(&adminUser).Error; err != nil { + return false + } + + // 检查账号状态 (Status=1 表示启用,否则强制下线) + if adminUser.Status != 1 { + return false + } + + // 检查是否允许登录 (role=0 或 role=1 允许,role=2不允许访问admin后台) + if adminUser.Role > 1 { return false } @@ -525,10 +567,10 @@ func GetCurrentAdminUserWithRefresh(c *gin.Context) (*JWTClaims, bool, error) { // 获取当前的 PasswordHash db, _ := database.GetDB() var adminUser models.User - db.Where("username = ? AND role = ?", claims.Username, 0).First(&adminUser) + db.Where("uuid = ? AND role = ?", claims.UUID, claims.Role).First(&adminUser) // 使用新的有效期生成令牌 - newToken, err := generateJWTTokenForAdmin(claims.Username, adminUser.Password, claims.UUID) + newToken, err := generateJWTTokenForAdmin(claims.Username, adminUser.Password, claims.UUID, adminUser.Role) if err == nil { tokenToSet = newToken refreshed = true diff --git a/controllers/admin/captcha.go b/controllers/admin/captcha.go index b055a99..06186d0 100644 --- a/controllers/admin/captcha.go +++ b/controllers/admin/captcha.go @@ -1,67 +1,33 @@ package admin import ( - "crypto/rand" "encoding/base64" - "math/big" "net/http" "strings" "NetworkAuth/middleware" + "NetworkAuth/utils" "github.com/gin-gonic/gin" "github.com/mojocn/base64Captcha" "github.com/sirupsen/logrus" ) -// ============================================================================ -// 全局变量 -// ============================================================================ - -// 全局验证码存储器 -var store = base64Captcha.DefaultMemStore - -// ============================================================================ -// 辅助函数 -// ============================================================================ - -// secureRandomInt 生成安全的随机整数,范围 [0, max) -func secureRandomInt(max int) (int, error) { - n, err := rand.Int(rand.Reader, big.NewInt(int64(max))) - if err != nil { - return 0, err - } - return int(n.Int64()), nil -} - -// ============================================================================ -// API处理器 -// ============================================================================ - // CaptchaHandler 生成验证码图片 // GET /admin/captcha - 返回验证码图片 func CaptchaHandler(c *gin.Context) { - // 随机生成4-6位长度 - // 使用crypto/rand生成安全的随机数 - randomNum, err := secureRandomInt(3) - if err != nil { - c.String(http.StatusInternalServerError, "生成随机数失败") - return - } - captchaLength := 4 + randomNum // 4-6位随机长度 - - // 配置验证码参数 - 使用字母数字混合 + // 配置与 User 端一致,采用较弱的验证码强度以提升正常用户体验 driver := base64Captcha.DriverString{ Height: 60, Width: 200, - NoiseCount: 0, - ShowLineOptions: 2 | 4, - Length: captchaLength, - Source: "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789", // 混合大小写字母和数字,去除易混淆字符 + Length: 4, + NoiseCount: 20, // 加点背景噪点干扰 + ShowLineOptions: 2 | 4, // 加点干扰线 + Source: "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789", } - // 生成验证码 - captcha := base64Captcha.NewCaptcha(&driver, store) + // 生成验证码,使用共享的 CaptchaStore + captcha := base64Captcha.NewCaptcha(&driver, utils.CaptchaStore) id, b64s, _, err := captcha.Generate() if err != nil { c.String(http.StatusInternalServerError, "生成验证码失败") @@ -105,24 +71,6 @@ func VerifyCaptcha(c *gin.Context, captchaValue string) bool { return false } - // 先尝试原始值验证 - if store.Verify(captchaId, captchaValue, false) { - // 验证成功后删除验证码 - store.Verify(captchaId, captchaValue, true) - return true - } - - // 如果原始值验证失败,尝试小写验证 - if store.Verify(captchaId, strings.ToLower(captchaValue), false) { - // 验证成功后删除验证码 - store.Verify(captchaId, strings.ToLower(captchaValue), true) - return true - } - - // 最后尝试大写验证 - if store.Verify(captchaId, strings.ToUpper(captchaValue), true) { - return true - } - - return false + // 调用共享的 VerifyCaptcha + return utils.VerifyCaptcha(captchaId, captchaValue) } diff --git a/controllers/admin/dashboard.go b/controllers/admin/dashboard.go index 382d989..caa3444 100644 --- a/controllers/admin/dashboard.go +++ b/controllers/admin/dashboard.go @@ -5,7 +5,6 @@ import ( "NetworkAuth/controllers" "NetworkAuth/middleware" "NetworkAuth/models" - "NetworkAuth/services" "NetworkAuth/utils/timeutil" "github.com/gin-gonic/gin" @@ -43,8 +42,6 @@ func formatDBType(dbType string) string { // ============================================================================ // API处理器 // ============================================================================ - -// SystemInfoHandler 系统信息API接口 // 返回系统运行状态的JSON数据,用于前端定时刷新 func SystemInfoHandler(c *gin.Context) { version := constants.AppVersion @@ -107,41 +104,3 @@ func DashboardStatsHandler(c *gin.Context) { handlersBaseController.HandleSuccess(c, "ok", data) } - -// DashboardLoginLogsHandler 获取管理员最近登录日志 -func DashboardLoginLogsHandler(c *gin.Context) { - db, ok := handlersBaseController.GetDB(c) - if !ok { - return - } - - // 获取分页参数 - page, limit := handlersBaseController.GetPaginationParams(c) - - // 获取当前管理员信息(可能是 username 或 admin_username,具体取决于认证中间件设置的 key) - username := c.GetString("admin_username") - if username == "" { - // 尝试获取其他可能的键名 - username = c.GetString("username") - } - - var total int64 - query := db.Model(&models.LoginLog{}).Where("type = ?", "admin") - - // 如果有用户名,则仅过滤该用户的日志 - if username != "" { - query = query.Where("username = ?", username) - } - - logs, total, err := services.Paginate[models.LoginLog](query, page, limit, "created_at desc") - if err != nil { - handlersBaseController.HandleInternalError(c, "获取登录日志失败", err) - return - } - - data := gin.H{ - "total": total, - "list": logs, - } - handlersBaseController.HandleSuccess(c, "获取登录日志成功", data) -} diff --git a/controllers/admin/log.go b/controllers/admin/log.go new file mode 100644 index 0000000..3f3e125 --- /dev/null +++ b/controllers/admin/log.go @@ -0,0 +1,287 @@ +package admin + +import ( + "NetworkAuth/controllers" + "NetworkAuth/models" + "NetworkAuth/services" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// ============================================================================ +// 全局变量 +// ============================================================================ + +var logBaseController = controllers.NewBaseController() + +// ============================================================================ +// 登录日志 API处理器 +// ============================================================================ + +// DashboardLoginLogsHandler 获取管理员最近登录日志 +func DashboardLoginLogsHandler(c *gin.Context) { + db, ok := logBaseController.GetDB(c) + if !ok { + return + } + + // 获取分页参数 + page, limit := logBaseController.GetPaginationParams(c) + + // 获取当前管理员信息 + uuid := c.GetString("admin_uuid") + username := c.GetString("admin_username") + + var total int64 + query := db.Model(&models.LoginLog{}) + + // 如果有用户名,则仅过滤该用户的日志 + if uuid != "" { + query = query.Where("uuid = ? OR (uuid = '' AND username = ?)", uuid, username) + } + + logs, total, err := services.Paginate[models.LoginLog](query, page, limit, "created_at desc") + if err != nil { + logBaseController.HandleInternalError(c, "获取登录日志失败", err) + return + } + + data := gin.H{ + "total": total, + "list": logs, + } + logBaseController.HandleSuccess(c, "获取登录日志成功", data) +} + +// LoginLogsListHandler 登录日志列表API处理器 +func LoginLogsListHandler(c *gin.Context) { + // 获取分页参数 + page, limit := logBaseController.GetPaginationParams(c) + + // 获取数据库连接 + db, ok := logBaseController.GetDB(c) + if !ok { + return + } + + query := db.Model(&models.LoginLog{}) + + // 筛选条件:账号或UUID合并搜索 + if username := strings.TrimSpace(c.Query("username")); username != "" { + query = query.Where("username = ? OR uuid = ?", username, username) + } + + // 筛选条件:IP + if ip := strings.TrimSpace(c.Query("ip")); ip != "" { + query = query.Where("ip = ?", ip) + } + + // 筛选条件:状态 + if statusStr := strings.TrimSpace(c.Query("status")); statusStr != "" { + if status, err := strconv.Atoi(statusStr); err == nil { + query = query.Where("status = ?", status) + } + } + + // 筛选条件:时间范围 + query = logBaseController.ApplyTimeRangeQuery(c, query, "created_at") + + // 泛型分页查询 + logs, total, err := services.Paginate[models.LoginLog](query, page, limit, "created_at DESC") + if err != nil { + logBaseController.HandleInternalError(c, "获取日志列表失败", err) + return + } + + // 转换数据格式 + var list []map[string]interface{} + for _, log := range logs { + list = append(list, map[string]interface{}{ + "id": log.ID, + "uuid": log.UUID, + "username": log.Username, + "ip": log.IP, + "status": log.Status, + "message": log.Message, + "user_agent": log.UserAgent, + "created_at": log.CreatedAt, + }) + } + + logBaseController.HandleSuccess(c, "ok", gin.H{ + "list": list, + "total": total, + }) +} + +// LoginLogsClearHandler 清空登录日志API处理器 +func LoginLogsClearHandler(c *gin.Context) { + // 鉴权拦截:仅超级管理员 (role=0) 允许清空日志 + if role, exists := c.Get("admin_role"); !exists || role.(int) != 0 { + logBaseController.HandleValidationError(c, "权限不足,仅超级管理员可清空日志") + return + } + + db, ok := logBaseController.GetDB(c) + if !ok { + return + } + + // 检查数据库类型 + dbType := db.Dialector.Name() + + if dbType == "sqlite" { + // SQLite 不支持 TRUNCATE,直接使用 DELETE 和重置自增序列 + if err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&models.LoginLog{}).Error; err != nil { + logrus.WithError(err).Error("Failed to clear login logs") + logBaseController.HandleInternalError(c, "清空登录日志失败", err) + return + } + // 重置 sqlite 的自增序列 + db.Exec("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'login_logs'") + // 释放空间 + db.Exec("VACUUM") + } else { + // 其他数据库(如 MySQL/PostgreSQL)尝试使用 TRUNCATE + if err := db.Exec("TRUNCATE TABLE login_logs").Error; err != nil { + // 如果 TRUNCATE 失败,回退到 DELETE + if err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&models.LoginLog{}).Error; err != nil { + logrus.WithError(err).Error("Failed to clear login logs") + logBaseController.HandleInternalError(c, "清空登录日志失败", err) + return + } + } + } + + // 记录操作日志 + var operator, operatorUUID string + operator = c.GetString("admin_username") + operatorUUID = c.GetString("admin_uuid") + if operator == "" { + operator = "system" + operatorUUID = "system" + } + + log := models.OperationLog{ + OperationType: "清空登录日志", + Operator: operator, + OperatorUUID: operatorUUID, + Details: "管理员清空了所有登录日志", + CreatedAt: time.Now(), + } + db.Create(&log) + + logBaseController.HandleSuccess(c, "登录日志已清空", nil) +} + +// ============================================================================ +// 操作日志 API处理器 +// ============================================================================ + +// LogsListHandler 日志列表API处理器 +func LogsListHandler(c *gin.Context) { + // 获取分页参数 + page, limit := logBaseController.GetPaginationParams(c) + + // 获取搜索参数 + operationType := strings.TrimSpace(c.Query("operation_type")) + operator := strings.TrimSpace(c.Query("operator")) + + // 获取数据库连接 + db, ok := logBaseController.GetDB(c) + if !ok { + return + } + + query := db.Model(&models.OperationLog{}) + + // 筛选条件 + if operationType != "" { + query = query.Where("operation_type = ?", operationType) + } + if operator != "" { + // 支持按 UUID 或 用户名 筛选 + query = query.Where("operator_uuid = ? OR operator = ?", operator, operator) + } + + // 筛选条件:时间范围 + query = logBaseController.ApplyTimeRangeQuery(c, query, "created_at") + + // 泛型分页查询 + logs, total, err := services.Paginate[models.OperationLog](query, page, limit, "created_at DESC") + if err != nil { + logrus.WithError(err).Error("查询日志列表失败") + logBaseController.HandleInternalError(c, "查询日志列表失败", err) + return + } + + logBaseController.HandleSuccess(c, "获取日志列表成功", gin.H{ + "list": logs, + "total": total, + }) +} + +// LogsClearHandler 清空日志API处理器 +func LogsClearHandler(c *gin.Context) { + // 鉴权拦截:仅超级管理员 (role=0) 允许清空日志 + if role, exists := c.Get("admin_role"); !exists || role.(int) != 0 { + logBaseController.HandleValidationError(c, "权限不足,仅超级管理员可清空日志") + return + } + + db, ok := logBaseController.GetDB(c) + if !ok { + return + } + + // 检查数据库类型 + dbType := db.Dialector.Name() + + if dbType == "sqlite" { + // SQLite 不支持 TRUNCATE,直接使用 DELETE 和重置自增序列 + if err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&models.OperationLog{}).Error; err != nil { + logrus.WithError(err).Error("清空操作日志失败") + logBaseController.HandleInternalError(c, "清空操作日志失败", err) + return + } + // 重置 sqlite 的自增序列 + db.Exec("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'operation_logs'") + // 释放空间 + db.Exec("VACUUM") + } else { + // 其他数据库(如 MySQL/PostgreSQL)尝试使用 TRUNCATE + if err := db.Exec("TRUNCATE TABLE operation_logs").Error; err != nil { + // 如果 TRUNCATE 失败,回退到 DELETE + if err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("1 = 1").Delete(&models.OperationLog{}).Error; err != nil { + logrus.WithError(err).Error("清空操作日志失败") + logBaseController.HandleInternalError(c, "清空操作日志失败", err) + return + } + } + } + + // 记录操作日志 (因为刚刚清空了,这条将是第一条) + var operator, operatorUUID string + operator = c.GetString("admin_username") + operatorUUID = c.GetString("admin_uuid") + if operator == "" { + operator = "system" + operatorUUID = "system" + } + + log := models.OperationLog{ + OperationType: "清空日志", + Operator: operator, + OperatorUUID: operatorUUID, + Details: "管理员清空了所有操作日志", + CreatedAt: time.Now(), + } + db.Create(&log) + + logBaseController.HandleSuccess(c, "日志已清空", nil) +} diff --git a/controllers/admin/login_log.go b/controllers/admin/login_log.go deleted file mode 100644 index 7c30adb..0000000 --- a/controllers/admin/login_log.go +++ /dev/null @@ -1,147 +0,0 @@ -package admin - -import ( - "NetworkAuth/controllers" - "NetworkAuth/models" - "NetworkAuth/services" - "strconv" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "gorm.io/gorm" -) - -// ============================================================================ -// 全局变量 -// ============================================================================ - -var loginLogBaseController = controllers.NewBaseController() - -// ============================================================================ -// 辅助函数 -// ============================================================================ - -// RecordLoginLog 记录登录日志 -func RecordLoginLog(c *gin.Context, username string, status int, message string) { - db, ok := loginLogBaseController.GetDB(c) - if !ok { - return - } - - log := models.LoginLog{ - Type: "admin", - Username: username, - IP: c.ClientIP(), - Status: status, - Message: message, - UserAgent: c.Request.UserAgent(), - CreatedAt: time.Now(), - } - - if err := db.Create(&log).Error; err != nil { - logrus.WithError(err).Error("Failed to create login log") - } -} - -// ============================================================================ -// API处理器 -// ============================================================================ - -// LoginLogsListHandler 登录日志列表API处理器 -func LoginLogsListHandler(c *gin.Context) { - // 获取分页参数 - page, limit := loginLogBaseController.GetPaginationParams(c) - - // 获取数据库连接 - db, ok := loginLogBaseController.GetDB(c) - if !ok { - return - } - - // 兼容旧数据(Type为空)和新数据(Type=admin) - query := db.Model(&models.LoginLog{}).Where("type = ? OR type = ? OR type IS NULL", "admin", "") - - // 筛选条件:用户名 - if username := strings.TrimSpace(c.Query("username")); username != "" { - query = query.Where("username = ?", username) - } - - // 筛选条件:IP - if ip := strings.TrimSpace(c.Query("ip")); ip != "" { - query = query.Where("ip = ?", ip) - } - - // 筛选条件:状态 - if statusStr := strings.TrimSpace(c.Query("status")); statusStr != "" { - if status, err := strconv.Atoi(statusStr); err == nil { - query = query.Where("status = ?", status) - } - } - - // 筛选条件:时间范围 - query = loginLogBaseController.ApplyTimeRangeQuery(c, query, "created_at") - - // 泛型分页查询 - logs, total, err := services.Paginate[models.LoginLog](query, page, limit, "created_at DESC") - if err != nil { - loginLogBaseController.HandleInternalError(c, "获取日志列表失败", err) - return - } - - // 转换数据格式 - var list []map[string]interface{} - for _, log := range logs { - list = append(list, map[string]interface{}{ - "id": log.ID, - "username": log.Username, - "ip": log.IP, - "status": log.Status, - "message": log.Message, - "user_agent": log.UserAgent, - "created_at": log.CreatedAt, - }) - } - - loginLogBaseController.HandleSuccess(c, "ok", gin.H{ - "list": list, - "total": total, - }) -} - -// LoginLogsClearHandler 清空登录日志API处理器 -func LoginLogsClearHandler(c *gin.Context) { - db, ok := loginLogBaseController.GetDB(c) - if !ok { - return - } - - // 物理删除所有登录日志 - if err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Where("type = ? OR type = ? OR type IS NULL", "admin", "").Delete(&models.LoginLog{}).Error; err != nil { - logrus.WithError(err).Error("Failed to clear login logs") - loginLogBaseController.HandleInternalError(c, "清空登录日志失败", err) - return - } - - // 记录操作日志 - var operator, operatorUUID string - if claims, _, err := GetCurrentAdminUserWithRefresh(c); err == nil && claims != nil { - operator = claims.Username - operatorUUID = claims.UUID - } else { - operator = "admin" - operatorUUID = "00000000-0000-0000-0000-000000000000" - } - - log := models.OperationLog{ - OperationType: "清空登录日志", - Operator: operator, - OperatorUUID: operatorUUID, - Details: "管理员清空了所有登录日志", - CreatedAt: time.Now(), - } - db.Create(&log) - - loginLogBaseController.HandleSuccess(c, "登录日志已清空", nil) -} diff --git a/controllers/admin/operation_log.go b/controllers/admin/operation_log.go deleted file mode 100644 index 6df2a3d..0000000 --- a/controllers/admin/operation_log.go +++ /dev/null @@ -1,102 +0,0 @@ -package admin - -import ( - "NetworkAuth/controllers" - "NetworkAuth/models" - "NetworkAuth/services" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" - "gorm.io/gorm" -) - -// ============================================================================ -// 全局变量 -// ============================================================================ - -var logBaseController = controllers.NewBaseController() - -// ============================================================================ -// API处理器 -// ============================================================================ - -// LogsListHandler 日志列表API处理器 -func LogsListHandler(c *gin.Context) { - // 获取分页参数 - page, limit := logBaseController.GetPaginationParams(c) - - // 获取搜索参数 - operationType := strings.TrimSpace(c.Query("operation_type")) - operator := strings.TrimSpace(c.Query("operator")) - - // 获取数据库连接 - db, ok := logBaseController.GetDB(c) - if !ok { - return - } - - query := db.Model(&models.OperationLog{}) - - // 筛选条件 - if operationType != "" { - query = query.Where("operation_type = ?", operationType) - } - if operator != "" { - // 支持按 UUID 或 用户名 筛选 - query = query.Where("operator_uuid = ? OR operator = ?", operator, operator) - } - - // 筛选条件:时间范围 - query = logBaseController.ApplyTimeRangeQuery(c, query, "created_at") - - // 泛型分页查询 - logs, total, err := services.Paginate[models.OperationLog](query, page, limit, "created_at DESC") - if err != nil { - logrus.WithError(err).Error("查询日志列表失败") - logBaseController.HandleInternalError(c, "查询日志列表失败", err) - return - } - - logBaseController.HandleSuccess(c, "获取日志列表成功", gin.H{ - "list": logs, - "total": total, - }) -} - -// LogsClearHandler 清空日志API处理器 -func LogsClearHandler(c *gin.Context) { - db, ok := logBaseController.GetDB(c) - if !ok { - return - } - - // 开启事务进行清空 - if err := db.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(&models.OperationLog{}).Error; err != nil { - logrus.WithError(err).Error("清空操作日志失败") - logBaseController.HandleInternalError(c, "清空操作日志失败", err) - return - } - - // 记录操作日志 (因为刚刚清空了,这条将是第一条) - var operator, operatorUUID string - if claims, _, err := GetCurrentAdminUserWithRefresh(c); err == nil && claims != nil { - operator = claims.Username - operatorUUID = claims.UUID - } else { - operator = "admin" - operatorUUID = "00000000-0000-0000-0000-000000000000" - } - - log := models.OperationLog{ - OperationType: "清空日志", - Operator: operator, - OperatorUUID: operatorUUID, - Details: "管理员清空了所有操作日志", - CreatedAt: time.Now(), - } - db.Create(&log) - - logBaseController.HandleSuccess(c, "日志已清空", nil) -} diff --git a/controllers/admin/settings.go b/controllers/admin/settings.go index 9e4faf1..f4e36d5 100644 --- a/controllers/admin/settings.go +++ b/controllers/admin/settings.go @@ -244,7 +244,7 @@ func SettingsPublicHandler(c *gin.Context) { var list []models.Settings // 查询公开的基本信息、维护模式和所有前端平台配置 - if err := db.Where("name IN ? OR name LIKE ?", []string{"site_title", "site_description", "site_keywords", "site_logo", "contact_email", "maintenance_mode"}, "platform_%").Find(&list).Error; err != nil { + if err := db.Where("name IN ? OR name LIKE ?", []string{"site_title", "site_description", "site_keywords", "site_logo", "contact_email", "maintenance_mode", "hide_login_entrance"}, "platform_%").Find(&list).Error; err != nil { authBaseController.HandleInternalError(c, "查询失败", err) return } diff --git a/controllers/install/install.go b/controllers/install/install.go index 9edb812..ffa4c8d 100644 --- a/controllers/install/install.go +++ b/controllers/install/install.go @@ -18,6 +18,13 @@ import ( // InstallSubmitHandler 处理安装表单提交 func InstallSubmitHandler(c *gin.Context) { + // 二次安全校验:检查系统是否已经安装 + isInstalledStr := services.GetSettingsService().GetString("is_installed", "0") + if isInstalledStr == "1" { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "msg": "系统已安装,禁止重复初始化"}) + return + } + var req struct { // 数据库配置 DbType string `json:"db_type" binding:"required,oneof=sqlite mysql"` @@ -109,14 +116,13 @@ func InstallSubmitHandler(c *gin.Context) { // 更新或创建超级管理员账号 var adminUser models.User - if err := tx.Where("uuid = ?", "00000000-0000-0000-0000-000000000000").First(&adminUser).Error; err != nil { + if err := tx.Where("role = ?", 0).First(&adminUser).Error; err != nil { // 如果不存在则创建 adminUser = models.User{ - UUID: "00000000-0000-0000-0000-000000000000", Username: strings.TrimSpace(req.AdminUsername), Password: adminPasswordHash, PasswordSalt: adminSalt, - Nickname: "管理员", + Nickname: "超级管理员", Avatar: "", Role: 0, Status: 1, @@ -133,7 +139,7 @@ func InstallSubmitHandler(c *gin.Context) { adminUser.Username = strings.TrimSpace(req.AdminUsername) adminUser.Password = adminPasswordHash adminUser.PasswordSalt = adminSalt - adminUser.Nickname = "管理员" + adminUser.Nickname = "超级管理员" adminUser.Role = 0 if err := tx.Save(&adminUser).Error; err != nil { tx.Rollback() diff --git a/database/settings.go b/database/settings.go index 60c045f..54d6e1c 100644 --- a/database/settings.go +++ b/database/settings.go @@ -51,6 +51,11 @@ func SeedDefaultSettings() error { Value: "0", Description: "维护模式,0=关闭维护模式,1=开启维护模式", }, + { + Name: "hide_login_entrance", + Value: "0", + Description: "隐藏登录入口,0=显示,1=隐藏(门户中不显示管理员或子账号登录入口)", + }, { Name: "encryption_key", Value: encryptionKey, diff --git a/go.mod b/go.mod index 5466899..e1acfb1 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,17 @@ module NetworkAuth go 1.25.0 require ( + github.com/andybalholm/brotli v1.2.1 github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 + github.com/go-resty/resty/v2 v2.17.2 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/mojocn/base64Captcha v1.3.8 github.com/redis/go-redis/v9 v9.18.0 github.com/sirupsen/logrus v1.9.3 + github.com/skycheung803/go-bypasser v0.0.0-20250704092818-4b1591564510 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 github.com/xuri/excelize/v2 v2.10.1 @@ -26,6 +29,7 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudflare/circl v1.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -36,15 +40,21 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-rod/rod v0.116.2 // indirect + github.com/go-rod/stealth v0.4.9 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-viper/mapstructure/v2 v2.2.1 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/imroc/req/v3 v3.49.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -53,6 +63,7 @@ require ( github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/refraction-networking/utls v1.6.7 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/richardlehane/mscfb v1.0.6 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect @@ -67,6 +78,11 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + github.com/ysmood/fetchup v0.2.3 // indirect + github.com/ysmood/goob v0.4.0 // indirect + github.com/ysmood/got v0.40.0 // indirect + github.com/ysmood/gson v0.7.3 // indirect + github.com/ysmood/leakless v0.9.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.9.0 // indirect diff --git a/go.sum b/go.sum index 7727d31..aab26ec 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -12,6 +14,8 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys= +github.com/cloudflare/circl v1.5.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -46,6 +50,13 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk= +github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= +github.com/go-rod/rod v0.113.0/go.mod h1:aiedSEFg5DwG/fnNbUOTPMTTWX3MRj6vIs/a684Mthw= +github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= +github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= +github.com/go-rod/stealth v0.4.9 h1:X2PmQk4DUF2wzw6GOsWjW/glb8K5ebnftbEvLh7MlZ4= +github.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= @@ -62,10 +73,17 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= -github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/imroc/req/v3 v3.49.0 h1:5Rac2qvz7Dq0E3PeBo/c2szV3hagPQIGLoHtfBmYhu4= +github.com/imroc/req/v3 v3.49.0/go.mod h1:XZf4t94DNJzcA0UOBlA68hmSrWsAyvN407ADdH4mzCA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -74,6 +92,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -101,6 +121,8 @@ github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SA github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/refraction-networking/utls v1.6.7 h1:zVJ7sP1dJx/WtVuITug3qYUq034cDq9B2MR1K67ULZM= +github.com/refraction-networking/utls v1.6.7/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0= 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= @@ -115,6 +137,8 @@ github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsF github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/skycheung803/go-bypasser v0.0.0-20250704092818-4b1591564510 h1:4/6v3lBnoJOjc878II458DVLurtYS3H4S09TBT6PDf8= +github.com/skycheung803/go-bypasser v0.0.0-20250704092818-4b1591564510/go.mod h1:7moDdG5Uh/9NTDph5drq217W+drrX9MIa4obMx9o91A= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= @@ -153,6 +177,25 @@ github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzx github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/gop v0.0.2/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk= +github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg= +github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk= +github.com/ysmood/got v0.34.1/go.mod h1:yddyjq/PmAf08RMLSwDjPyCvHvYed+WjHnQxpH851LM= +github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= +github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= +github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY= +github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM= +github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.8.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= +github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= +github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= @@ -231,6 +274,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/middleware/ratelimit.go b/middleware/ratelimit.go new file mode 100644 index 0000000..b92ba23 --- /dev/null +++ b/middleware/ratelimit.go @@ -0,0 +1,58 @@ +package middleware + +import ( + "NetworkAuth/utils" + "context" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +// RateLimit 基于 Redis 的简单固定窗口限流中间件 +// limit: 时间窗口内允许的最大请求数 +// window: 时间窗口大小 +func RateLimit(limit int, window time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + client := utils.GetRedis() + if client == nil { + // 如果 Redis 未配置或不可用,则放行(降级处理) + c.Next() + return + } + + ip := c.ClientIP() + path := c.FullPath() + if path == "" { + path = c.Request.URL.Path + } + + // 构建 Redis Key,按 IP 和接口路径限制 + key := fmt.Sprintf("ratelimit:%s:%s", path, ip) + ctx := context.Background() + + // 使用 INCR 增加计数 + count, err := client.Incr(ctx, key).Result() + if err != nil { + c.Next() + return + } + + // 如果是第一次访问,设置过期时间 + if count == 1 { + client.Expire(ctx, key, window) + } + + if count > int64(limit) { + c.JSON(http.StatusTooManyRequests, gin.H{ + "code": 429, + "msg": "请求过于频繁,请稍后再试", + }) + c.Abort() + return + } + + c.Next() + } +} \ No newline at end of file diff --git a/models/api.go b/models/api.go index 7c340ca..5d10d1c 100644 --- a/models/api.go +++ b/models/api.go @@ -24,8 +24,8 @@ const ( APITypeSingleLogin = 10 // 卡密登录 // 账号管理 - APITypeUserLogin = 20 // 用户登录 - APITypeUserRegin = 21 // 用户注册 + APITypeUserLogin = 20 // 字系统登录 + APITypeUserRegin = 21 // 字系统注册 APITypeUserRecharge = 22 // 用户充值 // 登出操作 @@ -188,8 +188,8 @@ func GetAPITypes(categorized bool) interface{} { { Name: "账号管理", Types: []APITypeInfo{ - {Type: APITypeUserLogin, Name: "用户登录"}, - {Type: APITypeUserRegin, Name: "用户注册"}, + {Type: APITypeUserLogin, Name: "子系统登录"}, + {Type: APITypeUserRegin, Name: "子系统注册"}, {Type: APITypeUserRecharge, Name: "用户充值"}, }, }, diff --git a/models/login_log.go b/models/login_log.go index 47a368e..8cde507 100644 --- a/models/login_log.go +++ b/models/login_log.go @@ -8,6 +8,7 @@ import ( type LoginLog struct { ID uint `gorm:"primarykey" json:"id"` Type string `gorm:"type:varchar(20);index;comment:日志类型(admin/user)" json:"type"` + UUID string `gorm:"type:char(36);index;comment:用户UUID" json:"uuid"` Username string `gorm:"type:varchar(100);index;comment:登录用户名" json:"username"` IP string `gorm:"type:varchar(50);comment:登录IP" json:"ip"` Status int `gorm:"type:tinyint;comment:登录状态 1:成功 0:失败" json:"status"` diff --git a/models/user.go b/models/user.go index f97b8ea..6ea4e5b 100644 --- a/models/user.go +++ b/models/user.go @@ -22,7 +22,7 @@ type User struct { Password string `gorm:"size:255;not null;comment:密码哈希值"` PasswordSalt string `gorm:"size:64;not null;comment:密码加密盐值"` Status int `gorm:"not null;default:1;comment:状态:0禁用,1启用" json:"status"` - Role int `gorm:"not null;default:2;comment:角色类型:0超级管理员,1代理成员,2普通成员" json:"role"` + Role int `gorm:"not null;default:2;comment:角色类型:0超级管理员,1管理员,2子账号" json:"role"` Permissions string `gorm:"size:255;comment:权限列表,逗号分隔" json:"permissions"` Nickname string `gorm:"size:64;comment:用户昵称" json:"nickname"` Remark string `gorm:"size:255;comment:备注信息" json:"remark"` diff --git a/server/admin.go b/server/admin.go index 8fa6132..df52de0 100644 --- a/server/admin.go +++ b/server/admin.go @@ -41,12 +41,12 @@ func RegisterAdminRoutes(rg *gin.RouterGroup) { authorized.POST("/settings/generate-key", adminctl.SettingsGenerateKeyHandler) // 操作日志API - authorized.GET("/logs", adminctl.LogsListHandler) - authorized.POST("/logs/clear", adminctl.LogsClearHandler) + authorized.GET("/logs", adminctl.LogsListHandler) // 获取操作日志列表 + authorized.POST("/logs/clear", adminctl.LogsClearHandler) // 清空操作日志 // 登录日志API - authorized.GET("/login_logs", adminctl.LoginLogsListHandler) - authorized.POST("/login_logs/clear", adminctl.LoginLogsClearHandler) + authorized.GET("/login_logs", adminctl.LoginLogsListHandler) // 获取登录日志列表 + authorized.POST("/login_logs/clear", adminctl.LoginLogsClearHandler) // 清空登录日志 // 子账号相关API (Mock) authorized.GET("/subaccounts/simple", adminctl.SubAccountSimpleListHandler) diff --git a/server/default.go b/server/default.go index f626f04..4c9b826 100644 --- a/server/default.go +++ b/server/default.go @@ -2,6 +2,8 @@ package server import ( defaultctrl "NetworkAuth/controllers/default" + "NetworkAuth/middleware" + "time" "github.com/gin-gonic/gin" ) @@ -11,6 +13,6 @@ import ( func RegisterDefaultRoutes(rg *gin.RouterGroup) { homeGroup := rg.Group("/home") - // 根路径 - homeGroup.GET("", defaultctrl.RootHandler) + // 根路径 (限制:每分钟最多 60 次请求,防止 CC) + homeGroup.GET("", middleware.RateLimit(60, time.Minute), defaultctrl.RootHandler) } diff --git a/services/request/resty_client.go b/services/request/resty_client.go new file mode 100644 index 0000000..55bd246 --- /dev/null +++ b/services/request/resty_client.go @@ -0,0 +1,326 @@ +package request + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "reflect" + "strings" + "time" + "unsafe" + + "github.com/andybalholm/brotli" + "github.com/go-resty/resty/v2" + "github.com/skycheung803/go-bypasser" +) + +type RestyClient struct { + client *resty.Client +} + +func (request *RestyClient) Resty() *resty.Client { + return request.client +} + +// NewClient 创建一个基于 uTLS 指纹与 HTTP/2 指纹的 Resty 客户端 +// baseURL 不为空则设置默认 BaseURL;proxyStr 不为空则启用 HTTP 代理(仅 HTTP/1.1) +// persistCookies 启用持久化 Cookie;followRedirect 启用重定向跟随;timeout 设置超时时间(秒,0 或负数则默认 60 秒) +func NewClient(baseURL string, proxyStr string, persistCookies bool, timeout int) *RestyClient { + rc := resty.New() + + if baseURL != "" { + rc.SetBaseURL(baseURL) + } + + if persistCookies { + jar, _ := cookiejar.New(nil) + rc.SetCookieJar(jar) + } + + // 设置请求超时时间,如果传入 0 或负数则默认 60 秒 + if timeout <= 0 { + timeout = 60 + } + rc.SetTimeout(time.Duration(timeout) * time.Second) + + // 统一设置客户端默认请求头(调用级 headers 可覆盖),字段按字母顺序排列 + rc.SetHeader("accept", "*/*") + rc.SetHeader("accept-language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6") + rc.SetHeader("connection", "keep-alive") + rc.SetHeader("pragma", "no-cache") + rc.SetHeader("priority", "u=1,i") + rc.SetHeader("sec-ch-ua", "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"") + rc.SetHeader("sec-ch-ua-mobile", "?0") + rc.SetHeader("sec-ch-ua-platform", "\"macOS\"") + rc.SetHeader("sec-fetch-dest", "empty") + rc.SetHeader("sec-fetch-mode", "cors") + rc.SetHeader("sec-fetch-site", "same-origin") + rc.SetHeader("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36") + + // 初始化 go-bypasser 替代原有的 spoofed-round-tripper + opts := []bypasser.BypasserOption{ + bypasser.WithInsecureSkipVerify(true), + } + if proxyStr != "" { + opts = append(opts, bypasser.WithProxy(proxyStr)) + } + + bypass, err := bypasser.NewBypasser(opts...) + if err != nil { + panic(err) + } + + rc.SetTransport(bypass.Transport) + + return &RestyClient{client: rc} +} + +// fillResponseBody 使用反射强制填充响应体 +// 当 Resty 因为重定向策略错误而提前返回时,它可能不会读取 Body +// 此方法手动读取 RawResponse.Body 并回填到 resty.Response 的私有 body 字段中 +func (request *RestyClient) fillResponseBody(resp *resty.Response) { + if resp == nil || resp.RawResponse == nil { + return + } + // 如果已经有 body 内容,则不处理 + if len(resp.Body()) > 0 { + return + } + + // 读取底层 Body + bodyBytes, err := io.ReadAll(resp.RawResponse.Body) + if err != nil { + return + } + resp.RawResponse.Body.Close() + // 重置 Body 以便后续可能得读取 + resp.RawResponse.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + // 使用反射设置私有字段 body + v := reflect.ValueOf(resp).Elem() + f := v.FieldByName("body") + if f.IsValid() { + // 必须使用 UnsafeAddr 获取未导出字段的地址 + rf := reflect.NewAt(f.Type(), unsafe.Pointer(f.UnsafeAddr())).Elem() + rf.SetBytes(bodyBytes) + } + + // 设置 size 字段 + s := v.FieldByName("size") + if s.IsValid() { + rs := reflect.NewAt(s.Type(), unsafe.Pointer(s.UnsafeAddr())).Elem() + rs.SetInt(int64(len(bodyBytes))) + } +} + +// makeReq 构造带可选请求头的 resty.Request +// 功能:基于客户端创建请求对象,并在传入 headers 时进行设置 +// 返回:带有请求头的请求对象 +func (request *RestyClient) makeReq(headers map[string]string, cookies []*http.Cookie) *resty.Request { + req := request.client.R() + if len(headers) > 0 { + req = req.SetHeaders(headers) + } + if len(cookies) > 0 { + req = req.SetCookies(cookies) + } + return req +} + +// doWithEncodingFallback 封装请求发送并在出现压缩相关错误时进行一次降级重试 +// 逻辑:首次请求失败且错误包含 gzip/zstd/brotli/magic number mismatch 时,设置 accept-encoding 为 identity 重试一次 +func (request *RestyClient) doWithEncodingFallback(headers map[string]string, cookies []*http.Cookie, allowRedirect bool, do func(*resty.Request) (*resty.Response, error)) (*resty.Response, error) { + req := request.makeReq(headers, cookies) + if allowRedirect { + request.client.SetRedirectPolicy(resty.FlexibleRedirectPolicy(10)) + } else { + // 使用 http.ErrUseLastResponse 确保 302 响应被返回且 Body 可读,而不是报错 + request.client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + })) + } + resp, err := do(req) + + // 尝试补救响应体(特别是当重定向被禁用导致报错时) + request.fillResponseBody(resp) + + if err == nil { + return resp, nil + } + s := err.Error() + if strings.Contains(s, "gzip: invalid header") || strings.Contains(s, "magic number mismatch") || strings.Contains(s, "zstd") || strings.Contains(s, "brotli") { + h2 := map[string]string{} + for k, v := range headers { + if strings.ToLower(k) != "accept-encoding" { + h2[k] = v + } + } + h2["Accept-Encoding"] = "identity" + req2 := request.makeReq(h2, cookies) + resp2, err2 := do(req2) + request.fillResponseBody(resp2) + if err2 == nil { + return resp2, nil + } + } + return resp, err +} + +// decodeResponse 处理响应解压与 JSON 解析 +// 功能:自动识别 gzip 压缩并解压;在 result 非空时按 JSON 解析到 result +// 返回:解析错误(成功时为 nil) +func (request *RestyClient) decodeResponse(resp *resty.Response, result interface{}) error { + if resp == nil { + return nil + } + ct := strings.ToLower(resp.Header().Get("Content-Type")) + ce := strings.ToLower(resp.Header().Get("Content-Encoding")) + body := resp.Body() + if strings.Contains(ce, "gzip") && len(body) > 0 { + gr, gerr := gzip.NewReader(bytes.NewReader(body)) + if gerr == nil { + defer gr.Close() + if dec, derr := io.ReadAll(gr); derr == nil { + body = dec + resp.SetBody(body) + } + } + } else if strings.Contains(ce, "deflate") && len(body) > 0 { + // 处理 deflate 压缩 + dr := flate.NewReader(bytes.NewReader(body)) + defer dr.Close() + if dec, derr := io.ReadAll(dr); derr == nil { + body = dec + resp.SetBody(body) + } + } else if strings.Contains(ce, "br") && len(body) > 0 { + // 处理 brotli 压缩 + br := brotli.NewReader(bytes.NewReader(body)) + if dec, derr := io.ReadAll(br); derr == nil { + body = dec + resp.SetBody(body) // 将解压后的 body 写回 response + } + } + if result != nil && (strings.Contains(ct, "application/json") || json.Valid(body)) { + if err := json.Unmarshal(body, result); err != nil { + return err + } + } + return nil +} + +// RestyGet 发送 GET 请求 +func (request *RestyClient) RestyGet(path string, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) { + resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) { + return r.Get(path) + }) + if resp == nil && err != nil { + return nil, err + } + + if err := request.decodeResponse(resp, result); err != nil { + return nil, err + } + + return resp, err +} + +// RestyPost 发送 POST 请求 +func (request *RestyClient) RestyPost(path string, data any, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) { + resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) { + return r.SetBody(data).Post(path) + }) + if resp == nil && err != nil { + return nil, err + } + + if err := request.decodeResponse(resp, result); err != nil { + return nil, err + } + + return resp, err +} + +// RestyPut 发送 PUT 请求 +// 功能:发送 PUT,支持请求级 headers 覆盖客户端默认,自动识别 gzip 并解析 JSON +// 返回:响应对象与错误信息 +func (request *RestyClient) RestyPut(path string, data any, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) { + resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) { + return r.SetBody(data).Put(path) + }) + if resp == nil && err != nil { + return nil, err + } + + if err := request.decodeResponse(resp, result); err != nil { + return nil, err + } + + return resp, err +} + +// RestyPatch 发送 PATCH 请求 +// 功能:发送 PATCH,支持请求级 headers 覆盖客户端默认,自动识别 gzip 并解析 JSON +// 返回:响应对象与错误信息 +func (request *RestyClient) RestyPatch(path string, data any, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) { + resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) { + return r.SetBody(data).Patch(path) + }) + if resp == nil && err != nil { + return nil, err + } + + if err := request.decodeResponse(resp, result); err != nil { + return nil, err + } + + return resp, err +} + +// RestyDelete 发送 DELETE 请求 +// 功能:发送 DELETE,支持请求级 headers 覆盖客户端默认,自动识别 gzip 并解析 JSON +// 返回:响应对象与错误信息 +func (request *RestyClient) RestyDelete(path string, result interface{}, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) { + resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) { + return r.Delete(path) + }) + if resp == nil && err != nil { + return nil, err + } + + if err := request.decodeResponse(resp, result); err != nil { + return nil, err + } + + return resp, err +} + +// RestyHead 发送 HEAD 请求 +// 功能:发送 HEAD,支持请求级 headers 覆盖客户端默认;HEAD 通常无正文 +// 返回:响应对象与错误信息 +func (request *RestyClient) RestyHead(path string, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) { + resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) { + return r.Head(path) + }) + if resp == nil && err != nil { + return nil, err + } + return resp, err +} + +// RestyOptions 发送 OPTIONS 请求 +// 功能:发送 OPTIONS,支持请求级 headers 覆盖客户端默认 +// 返回:响应对象与错误信息 +func (request *RestyClient) RestyOptions(path string, headers map[string]string, cookies []*http.Cookie, allowRedirect bool) (*resty.Response, error) { + resp, err := request.doWithEncodingFallback(headers, cookies, allowRedirect, func(r *resty.Request) (*resty.Response, error) { + return r.Options(path) + }) + if resp == nil && err != nil { + return nil, err + } + return resp, err +} diff --git a/utils/captcha.go b/utils/captcha.go new file mode 100644 index 0000000..f8c15ef --- /dev/null +++ b/utils/captcha.go @@ -0,0 +1,38 @@ +package utils + +import ( + "strings" + + "github.com/mojocn/base64Captcha" +) + +// CaptchaStore 全局验证码存储器 +// 使用 base64Captcha 提供的默认内存存储,确保 admin 和 user 端可以共享验证码状态 +var CaptchaStore = base64Captcha.DefaultMemStore + +// VerifyCaptcha 验证验证码的有效性 +// captchaId: 验证码的唯一标识符 +// captchaValue: 用户输入的验证码内容 +// 返回值: 验证是否通过 +// 该函数提供函数级注释,支持大小写不敏感匹配,验证通过后会自动删除验证码 +func VerifyCaptcha(captchaId, captchaValue string) bool { + if captchaId == "" || captchaValue == "" { + return false + } + + // 使用 switch 进行连续逻辑判断,尝试不同的大小写组合 + switch { + case CaptchaStore.Verify(captchaId, captchaValue, true): + // 原始值匹配成功 + return true + case CaptchaStore.Verify(captchaId, strings.ToLower(captchaValue), true): + // 小写匹配成功 + return true + case CaptchaStore.Verify(captchaId, strings.ToUpper(captchaValue), true): + // 大写匹配成功 + return true + default: + // 匹配失败 + return false + } +}