diff --git a/.gitignore b/.gitignore index d72cd57..2af79b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # 编译后的二进制文件 -networkDev -networkDev.exe +NetworkAuth +NetworkAuth.exe *.exe *.exe~ *.dll diff --git a/cmd/root.go b/cmd/root.go index 48bcca7..2f49fe8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,142 +1,128 @@ -package cmd - -import ( - "io" - "networkDev/config" - "networkDev/utils/logger" - "os" - "path/filepath" - - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "gopkg.in/natefinch/lumberjack.v2" -) - -// ============================================================================ -// 全局变量 -// ============================================================================ - -var cfgFile string - -// ============================================================================ -// 命令定义 -// ============================================================================ - -// rootCmd 代表没有调用子命令时的基础命令 -var rootCmd = &cobra.Command{ - Use: "networkDev", - Short: "一个基于Cobra的网络验证服务器应用", - Long: `networkDev是一个使用Cobra CLI框架构建的网络验证服务器应用, -集成了Viper配置管理、Logrus日志记录和embed静态资源嵌入功能。`, - PersistentPreRun: func(cmd *cobra.Command, args []string) { - // 在加载配置前配置logrus用于非HTTP日志 - - setupLogrusForNonHTTP() - - }, -} - -// ============================================================================ -// 公共函数 -// ============================================================================ - -// Execute 添加所有子命令到根命令并设置适当的标志 -// 这由main.main()调用。只需要对rootCmd执行一次。 -func Execute() { - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } -} - -func init() { - cobra.OnInitialize(initConfig) - - // 在这里定义标志和配置设置 - rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "配置文件路径 (默认为 config.json)") -} - -// ============================================================================ -// 私有函数 -// ============================================================================ - -// setupLogrusForNonHTTP 配置logrus用于非HTTP日志 -// 在加载配置文件之前进行基本的logrus设置 -func setupLogrusForNonHTTP() { - // 设置日志格式 - logrus.SetFormatter(&logrus.TextFormatter{ - TimestampFormat: "2006-01-02 15:04:05", - FullTimestamp: true, - ForceColors: false, - DisableColors: true, - }) - - // 设置默认日志级别 - logrus.SetLevel(logrus.InfoLevel) - - // 设置输出目标(稍后会根据配置文件调整) - logrus.SetOutput(os.Stdout) - if cfgFile != "" { - config.Init(cfgFile) - } else { - config.Init("./config.json") - } - - // 根据配置文件进一步配置logrus - setupLogrusFromConfig() - - // 初始化HTTP日志处理器 - logger.InitLogger() - - // 记录配置加载完成 - logrus.Info("配置文件加载完成") -} - -// initConfig 读取配置文件和环境变量 -func initConfig() { - -} - -// setupLogrusFromConfig 根据配置文件进一步配置logrus -// 设置日志级别和输出目标,支持日志切割功能 -func setupLogrusFromConfig() { - // 设置日志级别 - if level := viper.GetString("log.level"); level != "" { - if logLevel, err := logrus.ParseLevel(level); err == nil { - logrus.SetLevel(logLevel) - } - } - - // 设置日志输出目标 - logFile := viper.GetString("log.file") - if logFile != "" { - // 确保日志目录存在 - logDir := filepath.Dir(logFile) - if err := os.MkdirAll(logDir, 0755); err != nil { - logrus.WithError(err).Error("创建日志目录失败") - return - } - - // 配置lumberjack日志轮转 - lumberjackLogger := &lumberjack.Logger{ - Filename: logFile, - MaxSize: viper.GetInt("log.max_size"), // MB - MaxBackups: viper.GetInt("log.max_backups"), // 保留的旧日志文件数量 - MaxAge: viper.GetInt("log.max_age"), // 天数 - Compress: true, // 压缩旧日志文件 - } - - // 同时输出到控制台和文件(带日志切割) - multiWriter := io.MultiWriter(os.Stdout, lumberjackLogger) - logrus.SetOutput(multiWriter) - - logrus.WithFields(logrus.Fields{ - "file": logFile, - "max_size": viper.GetInt("log.max_size"), - "max_backups": viper.GetInt("log.max_backups"), - "max_age": viper.GetInt("log.max_age"), - }).Info("日志切割功能已启用") - } - // 当日志文件路径为空时,保持默认输出到控制台,不创建任何目录 -} +package cmd + +import ( + "NetworkAuth/config" + "NetworkAuth/utils/logger" + "io" + "os" + "path/filepath" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "gopkg.in/natefinch/lumberjack.v2" +) + +var cfgFile string + +// rootCmd 代表没有调用子命令时的基础命令 +var rootCmd = &cobra.Command{ + Use: "NetworkAuth", + Short: "网络授权服务命令行工具", + Long: `网络授权服务 (NetworkAuth) 是一个专注于应用鉴权、接口管理和动态逻辑分发的后端系统。 +本命令行工具用于启动服务器、管理配置和执行维护任务。`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // 在加载配置前配置logrus用于非HTTP日志 + + setupLogrusForNonHTTP() + + }, +} + +// Execute 添加所有子命令到根命令并设置适当的标志 +// 这由main.main()调用。只需要对rootCmd执行一次。 +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + cobra.OnInitialize(initConfig) + + // 在这里定义标志和配置设置 + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "配置文件路径 (默认为 config.json)") +} + +// setupLogrusForNonHTTP 配置logrus用于非HTTP日志 +// 在加载配置文件之前进行基本的logrus设置 +func setupLogrusForNonHTTP() { + // 设置日志格式 + logrus.SetFormatter(&logrus.TextFormatter{ + TimestampFormat: "2006-01-02 15:04:05", + FullTimestamp: true, + ForceColors: false, + DisableColors: true, + }) + + // 设置默认日志级别 + logrus.SetLevel(logrus.InfoLevel) + + // 设置输出目标(稍后会根据配置文件调整) + logrus.SetOutput(os.Stdout) + if cfgFile != "" { + // 使用命令行指定的配置文件 + config.Init(cfgFile) + } else { + // 使用默认配置文件路径 + config.Init("./config.json") + } + + // 根据配置文件进一步配置logrus + setupLogrusFromConfig() + + // 初始化HTTP日志处理器 + logger.InitLogger() + + // 记录配置加载完成 + logrus.WithField("file", viper.ConfigFileUsed()).Info("配置文件加载完成") +} + +// initConfig 读取配置文件和环境变量 +func initConfig() { + +} + +// setupLogrusFromConfig 根据配置文件进一步配置logrus +// 设置日志级别和输出目标,支持日志切割功能 +func setupLogrusFromConfig() { + // 设置日志级别 + if level := viper.GetString("log.level"); level != "" { + if logLevel, err := logrus.ParseLevel(level); err == nil { + logrus.SetLevel(logLevel) + } + } + + // 设置日志输出目标 + logFile := viper.GetString("log.file") + if logFile != "" { + // 确保日志目录存在 + logDir := filepath.Dir(logFile) + if err := os.MkdirAll(logDir, 0755); err != nil { + logrus.WithError(err).Error("创建日志目录失败") + return + } + + // 配置lumberjack日志轮转 + lumberjackLogger := &lumberjack.Logger{ + Filename: logFile, + MaxSize: viper.GetInt("log.max_size"), // MB + MaxBackups: viper.GetInt("log.max_backups"), // 保留的旧日志文件数量 + MaxAge: viper.GetInt("log.max_age"), // 天数 + Compress: true, // 压缩旧日志文件 + } + + // 同时输出到控制台和文件(带日志切割) + multiWriter := io.MultiWriter(os.Stdout, lumberjackLogger) + logrus.SetOutput(multiWriter) + + logrus.WithFields(logrus.Fields{ + "file": logFile, + "max_size": viper.GetInt("log.max_size"), + "max_backups": viper.GetInt("log.max_backups"), + "max_age": viper.GetInt("log.max_age"), + }).Info("日志切割功能已启用") + } + // 当日志文件路径为空时,保持默认输出到控制台,不创建任何目录 +} diff --git a/cmd/server.go b/cmd/server.go index 90ec85e..4fe1fc3 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -3,19 +3,19 @@ package cmd import ( "context" "fmt" - "io" "net/http" "os" "os/signal" "syscall" "time" - "networkDev/database" - "networkDev/middleware" - "networkDev/server" - "networkDev/utils" - "networkDev/utils/logger" - "networkDev/web" + "NetworkAuth/database" + "NetworkAuth/middleware" + "NetworkAuth/server" + "NetworkAuth/services" + "NetworkAuth/utils" + "NetworkAuth/utils/logger" + "NetworkAuth/web" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" @@ -23,22 +23,14 @@ import ( "github.com/spf13/viper" ) -// ============================================================================ -// 命令定义 -// ============================================================================ - // serverCmd 代表服务器命令 var serverCmd = &cobra.Command{ Use: "server", - Short: "启动HTTP服务器", - Long: `启动一个简单的HTTP服务器,监听配置文件中指定的端口。`, + Short: "启动网络授权服务", + Long: `启动 NetworkAuth HTTP 服务器,监听配置文件中指定的端口,提供 Web 管理界面和 API 服务。`, Run: runServer, } -// ============================================================================ -// 初始化函数 -// ============================================================================ - func init() { // 将服务器命令添加到根命令 rootCmd.AddCommand(serverCmd) @@ -48,10 +40,6 @@ func init() { serverCmd.Flags().IntP("port", "p", 0, "服务器监听端口 (覆盖配置文件)") } -// ============================================================================ -// 主要函数 -// ============================================================================ - // runServer 运行HTTP服务器 func runServer(cmd *cobra.Command, args []string) { // 获取配置 @@ -63,21 +51,47 @@ func runServer(cmd *cobra.Command, args []string) { logger := logger.GetLogger() logger.LogServerStart(host, port) + // 重定向 Gin 框架内部日志到 Logrus + // 这将捕获 [GIN-debug] 路由注册日志和其他框架级输出 + gin.DefaultWriter = logger.WriterLevel(logrus.DebugLevel) + gin.DefaultErrorWriter = logger.WriterLevel(logrus.ErrorLevel) + + // 设置 Gin 模式 + if !viper.GetBool("server.dev_mode") { + gin.SetMode(gin.ReleaseMode) + } + // 初始化Redis(如果配置存在,失败不致命) utils.InitRedis() // 初始化数据库(根据 viper 配置选择 SQLite 或 MySQL) // 如果初始化失败则回退并退出 - if _, err := database.Init(); err != nil { + db, err := database.Init() + if err != nil { logrus.WithError(err).Fatal("数据库初始化失败") } - // 执行自动迁移(确保表结构存在) - if err := database.AutoMigrate(); err != nil { - logrus.WithError(err).Fatal("数据库自动迁移失败") - } - // 初始化默认系统设置(包含管理员账号) - if err := database.SeedDefaultSettings(); err != nil { - logrus.WithError(err).Fatal("默认系统设置初始化失败") + + if db != nil { + // 执行自动迁移(确保表结构存在) + if err := database.AutoMigrate(); err != nil { + logrus.WithError(err).Fatal("数据库自动迁移失败") + } + // 初始化默认系统设置 + if err := database.SeedDefaultSettings(); err != nil { + logrus.WithError(err).Fatal("默认系统设置初始化失败") + } + + // 初始化加密管理器 + // 从数据库设置中获取加密密钥 + encryptionKey := services.GetSettingsService().GetEncryptionKey() + if err := utils.InitEncryption(encryptionKey); err != nil { + logrus.WithError(err).Fatal("加密管理器初始化失败") + } + + // 启动日志清理定时任务 + services.StartLogCleanupTask() + } else { + logrus.Info("系统处于未初始化状态,跳过数据库自动迁移和设置加载") } // 创建HTTP服务器 @@ -87,10 +101,6 @@ func runServer(cmd *cobra.Command, args []string) { startServer(server) } -// ============================================================================ -// 辅助函数 -// ============================================================================ - // getServerHost 获取服务器监听地址 func getServerHost(cmd *cobra.Command) string { if host, _ := cmd.Flags().GetString("host"); host != "" { @@ -109,49 +119,51 @@ func getServerPort(cmd *cobra.Command) int { // createHTTPServer 创建HTTP服务器 func createHTTPServer(addr string) *http.Server { - // 配置Gin模式和日志 - configureGin() + // 创建 Gin 引擎 + r := gin.New() - // 创建Gin引擎 - router := gin.New() - - // 添加恢复中间件 - router.Use(gin.Recovery()) + // 使用默认的 Recovery 中间件 + r.Use(gin.Recovery()) // 添加日志中间件 - router.Use(middleware.WrapHandler()) + // 默认为 true,只有显式设置为 false 才关闭 + enableAccessLog := true + if viper.IsSet("server.access_log") { + enableAccessLog = viper.GetBool("server.access_log") + } + if enableAccessLog { + r.Use(middleware.WrapHandler()) + } - // 添加开发模式中间件(统一管理开发模式功能) - router.Use(middleware.DevModeMiddleware(router)) + // 添加安装检查中间件 + r.Use(middleware.InstallCheckMiddleware()) - // 加载模板 - if err := loadTemplates(router); err != nil { - logrus.WithError(err).Fatal("模板加载失败") + // 添加维护模式中间件 + r.Use(middleware.MaintenanceMiddleware()) + + // 添加开发模式中间件(统一管理开发模式功能:模板热重载等) + r.Use(middleware.DevModeMiddleware(r)) + + // 加载并设置 HTML 模板 + if tmpl, err := web.ParseTemplates(); err == nil { + r.SetHTMLTemplate(tmpl) + } else { + logrus.WithError(err).Error("HTML模板加载失败") } // 注册路由 - registerRoutes(router) + registerRoutes(r) return &http.Server{ Addr: addr, - Handler: router, + Handler: r, } } -// loadTemplates 加载模板到Gin引擎 -func loadTemplates(router *gin.Engine) error { - tmpl, err := web.ParseTemplates() - if err != nil { - return err - } - router.SetHTMLTemplate(tmpl) - return nil -} - // registerRoutes 注册HTTP路由 -func registerRoutes(router *gin.Engine) { +func registerRoutes(r *gin.Engine) { // 使用server包中的路由注册函数 - server.RegisterRoutes(router) + server.RegisterRoutes(r) } // startServer 启动服务器并处理优雅关闭 @@ -174,8 +186,6 @@ func startServer(server *http.Server) { // 等待中断信号 <-sigChan - // 清除终端上的 ^C 字符并移动光标到行首 - fmt.Print("\r\033[K") logger.Info("收到关闭信号,正在优雅关闭服务器...") // 创建一个带超时的上下文 @@ -189,20 +199,3 @@ func startServer(server *http.Server) { logger.LogServerStop() } } - -// configureGin 配置Gin的全局设置 -func configureGin() { - // 禁用Gin的颜色输出,提高控制台兼容性 - gin.DisableConsoleColor() - - // 设置Gin的输出为丢弃,因为我们使用自定义日志中间件 - gin.DefaultWriter = io.Discard - gin.DefaultErrorWriter = io.Discard - - // 根据配置设置Gin模式 - if viper.GetString("app.mode") == "production" { - gin.SetMode(gin.ReleaseMode) - } else { - gin.SetMode(gin.DebugMode) - } -} diff --git a/config/config.go b/config/config.go index 55f356c..c7f6065 100644 --- a/config/config.go +++ b/config/config.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "io/fs" - "os" "path/filepath" log "github.com/sirupsen/logrus" @@ -19,10 +18,11 @@ import ( // ServerConfig 服务器配置结构体 // 包含服务器运行相关的配置信息 type ServerConfig struct { - Host string `json:"host" mapstructure:"host"` // 服务器监听地址 - Port int `json:"port" mapstructure:"port"` // 服务器监听端口 - Dist string `json:"dist" mapstructure:"dist"` // 静态文件目录 - DevMode bool `json:"dev_mode" mapstructure:"dev_mode"` // 开发模式(跳过验证码等) + Host string `json:"host" mapstructure:"host"` // 服务器监听地址 + Port int `json:"port" mapstructure:"port"` // 服务器监听端口 + Dist string `json:"dist" mapstructure:"dist"` // 静态文件目录 + DevMode bool `json:"dev_mode" mapstructure:"dev_mode"` // 开发模式(跳过验证码等) + AccessLog bool `json:"access_log" mapstructure:"access_log"` // 是否输出访问日志 } // DatabaseConfig 数据库配置结构体 @@ -71,31 +71,12 @@ type LogConfig struct { MaxAge int `json:"max_age" mapstructure:"max_age"` // 日志文件保留天数 } -// CookieConfig Cookie配置结构体 -// 包含Cookie相关的安全配置信息 -type CookieConfig struct { - Secure bool `json:"secure" mapstructure:"secure"` // 是否只在HTTPS下发送Cookie - SameSite string `json:"same_site" mapstructure:"same_site"` // SameSite属性(Strict/Lax/None) - Domain string `json:"domain" mapstructure:"domain"` // Cookie域名 - MaxAge int `json:"max_age" mapstructure:"max_age"` // Cookie最大存活时间(秒) -} - -// SecurityConfig 安全配置结构体 -// 包含应用程序安全相关的配置信息 -type SecurityConfig struct { - JWTSecret string `json:"jwt_secret" mapstructure:"jwt_secret"` // JWT签名密钥 - EncryptionKey string `json:"encryption_key" mapstructure:"encryption_key"` // 数据加密密钥 - JWTRefresh int `json:"jwt_refresh" mapstructure:"jwt_refresh"` // JWT令牌刷新阈值(小时) - Cookie CookieConfig `json:"cookie" mapstructure:"cookie"` // Cookie配置 -} - // AppConfig 应用配置结构体 type AppConfig struct { Server ServerConfig `json:"server" mapstructure:"server"` Database DatabaseConfig `json:"database" mapstructure:"database"` Redis RedisConfig `json:"redis" mapstructure:"redis"` Log LogConfig `json:"log" mapstructure:"log"` - Security SecurityConfig `json:"security" mapstructure:"security"` } // ============================================================================ @@ -106,10 +87,11 @@ type AppConfig struct { func GetDefaultAppConfig() *AppConfig { return &AppConfig{ Server: ServerConfig{ - Host: "0.0.0.0", - Port: 8080, - Dist: "", - DevMode: false, + Host: "0.0.0.0", + Port: 8080, + Dist: "", + DevMode: false, + AccessLog: true, }, Database: DatabaseConfig{ Type: "sqlite", @@ -140,37 +122,9 @@ func GetDefaultAppConfig() *AppConfig { MaxBackups: 5, MaxAge: 30, }, - Security: SecurityConfig{ - JWTSecret: "", - EncryptionKey: "", - JWTRefresh: 6, - Cookie: CookieConfig{ - Secure: true, - SameSite: "Lax", - Domain: "", - MaxAge: 86400, - }, - }, } } -// GetSecureDefaultAppConfig 获取带有安全密钥的默认应用配置 -func GetSecureDefaultAppConfig() (*AppConfig, error) { - config := GetDefaultAppConfig() - - // 生成安全密钥 - jwtSecret, encryptionKey, err := GenerateSecureKeys() - if err != nil { - return nil, err - } - - // 设置安全密钥 - config.Security.JWTSecret = jwtSecret - config.Security.EncryptionKey = encryptionKey - - return config, nil -} - // Init 初始化配置文件 func Init(cfgFilePath string) { viper.SetConfigFile(cfgFilePath) @@ -180,18 +134,10 @@ func Init(cfgFilePath string) { if err := viper.ReadInConfig(); err != nil { var pathError *fs.PathError if errors.As(err, &pathError) { - log.Warn("未找到配置文件,使用默认配置") + log.Warn("未找到配置文件,使用默认配置在内存中运行(需通过安装页面初始化)") - // 生成带有安全密钥的默认配置 - defaultConfig, configErr := GetSecureDefaultAppConfig() - if configErr != nil { - log.WithFields( - log.Fields{ - "err": configErr, - }, - ).Error("生成安全配置失败,使用基础默认配置") - defaultConfig = GetDefaultAppConfig() - } + // 使用默认配置 + defaultConfig := GetDefaultAppConfig() // 将配置结构体转换为JSON configBytes, marshalErr := json.MarshalIndent(defaultConfig, "", " ") @@ -204,25 +150,7 @@ func Init(cfgFilePath string) { return } - // 写入配置文件 - err = os.WriteFile(cfgFilePath, configBytes, 0o644) - if err != nil { - log.WithFields( - log.Fields{ - "err": err, - }, - ).Error("写入默认配置文件失败") - } else { - // 只显示配置文件名,不显示完整路径 - fileName := filepath.Base(cfgFilePath) - log.WithFields( - log.Fields{ - "file": fileName, - }, - ).Info("写入默认配置文件成功(已生成安全密钥)") - } - - // 将配置加载到viper中 + // 将配置加载到viper中,但不写入文件 err = viper.ReadConfig(bytes.NewBuffer(configBytes)) if err != nil { log.WithFields( @@ -231,8 +159,10 @@ func Init(cfgFilePath string) { }, ).Error("读取默认配置失败") } else { - log.Info("已成功读取默认配置") + log.Info("已成功在内存中加载默认配置") } + + // 不在这里写入文件了,安装完成后通过 UpdateConfig 写入 } else { log.WithFields( log.Fields{ @@ -241,7 +171,7 @@ func Init(cfgFilePath string) { ).Fatal("配置文件解析错误") } } - + // 只显示配置文件名,不显示完整路径 configFile := viper.ConfigFileUsed() if configFile != "" { @@ -266,24 +196,62 @@ func Init(cfgFilePath string) { } } -// CreateDefaultConfig 创建默认配置文件 -func CreateDefaultConfig(filePath string) error { - // 生成带有安全密钥的默认配置 - defaultConfig, err := GetSecureDefaultAppConfig() - if err != nil { - log.WithFields( - log.Fields{ - "err": err, - }, - ).Error("生成安全配置失败,使用基础默认配置") - defaultConfig = GetDefaultAppConfig() - } - - // 将配置结构体转换为JSON - configBytes, err := json.MarshalIndent(defaultConfig, "", " ") - if err != nil { +// UpdateConfig 更新配置文件 +// 接收一个回调函数,在回调函数中修改配置对象,然后保存到文件 +func UpdateConfig(updateFn func(*AppConfig)) error { + // 1. 获取当前配置 + var currentConfig AppConfig + if err := viper.Unmarshal(¤tConfig); err != nil { return err } - return os.WriteFile(filePath, configBytes, 0o644) + // 2. 执行更新回调 + updateFn(¤tConfig) + + // 3. 将更新后的配置写回 Viper + // 注意:这里需要手动设置回 viper,否则 viper.WriteConfig() 写入的还是旧配置 + // 也可以直接序列化 currentConfig 写入文件 + + // 更新 Server 配置 + viper.Set("server.host", currentConfig.Server.Host) + viper.Set("server.port", currentConfig.Server.Port) + viper.Set("server.dist", currentConfig.Server.Dist) + viper.Set("server.dev_mode", currentConfig.Server.DevMode) + viper.Set("server.access_log", currentConfig.Server.AccessLog) + + // 更新 Database 配置 + viper.Set("database.type", currentConfig.Database.Type) + viper.Set("database.mysql.host", currentConfig.Database.MySQL.Host) + viper.Set("database.mysql.port", currentConfig.Database.MySQL.Port) + viper.Set("database.mysql.username", currentConfig.Database.MySQL.Username) + viper.Set("database.mysql.password", currentConfig.Database.MySQL.Password) + viper.Set("database.mysql.database", currentConfig.Database.MySQL.Database) + viper.Set("database.mysql.charset", currentConfig.Database.MySQL.Charset) + viper.Set("database.mysql.max_idle_conns", currentConfig.Database.MySQL.MaxIdleConns) + viper.Set("database.mysql.max_open_conns", currentConfig.Database.MySQL.MaxOpenConns) + viper.Set("database.sqlite.path", currentConfig.Database.SQLite.Path) + + // 更新 Redis 配置 + viper.Set("redis.host", currentConfig.Redis.Host) + viper.Set("redis.port", currentConfig.Redis.Port) + viper.Set("redis.password", currentConfig.Redis.Password) + viper.Set("redis.db", currentConfig.Redis.DB) + + // 更新 Log 配置 + viper.Set("log.level", currentConfig.Log.Level) + viper.Set("log.file", currentConfig.Log.File) + viper.Set("log.max_size", currentConfig.Log.MaxSize) + viper.Set("log.max_backups", currentConfig.Log.MaxBackups) + viper.Set("log.max_age", currentConfig.Log.MaxAge) + + // 4. 保存到文件 + if err := viper.WriteConfig(); err != nil { + // 如果配置文件不存在(比如只用了默认配置没写文件),则尝试 SafeWriteConfig + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + return viper.SafeWriteConfig() + } + return err + } + + return nil } diff --git a/config/validator.go b/config/validator.go index 1c5d5a8..ad153a9 100644 --- a/config/validator.go +++ b/config/validator.go @@ -61,11 +61,6 @@ func validateConfig(config *AppConfig) error { return fmt.Errorf("日志配置错误: %w", err) } - // 验证安全配置 - if err := validateSecurityConfig(&config.Security); err != nil { - return fmt.Errorf("安全配置错误: %w", err) - } - return nil } @@ -176,7 +171,6 @@ func validateLogConfig(config *LogConfig) error { } } } - // 当日志文件路径为空时,不进行目录检查和创建 // 验证日志轮转配置 if config.MaxSize <= 0 { @@ -192,32 +186,6 @@ func validateLogConfig(config *LogConfig) error { return nil } -// validateSecurityConfig 验证安全配置 -func validateSecurityConfig(config *SecurityConfig) error { - if len(config.JWTSecret) < 16 { - return errors.New("JWT密钥长度不能少于16个字符") - } - - if len(config.EncryptionKey) < 16 { - return errors.New("加密密钥长度不能少于16个字符") - } - - if config.JWTRefresh < 1 || config.JWTRefresh > 23 { - return errors.New("JWT令牌刷新阈值必须在1-23小时之间") - } - - // 检查是否使用默认值(生产环境警告) - if strings.Contains(config.JWTSecret, "default") { - log.Warn("检测到使用默认JWT密钥,生产环境请更换为安全的密钥") - } - - if strings.Contains(config.EncryptionKey, "default") { - log.Warn("检测到使用默认加密密钥,生产环境请更换为安全的密钥") - } - - return nil -} - // contains 检查切片是否包含指定元素 func contains(slice []string, item string) bool { for _, s := range slice { diff --git a/constants/status.go b/constants/status.go index 28efa6c..29c86ae 100644 --- a/constants/status.go +++ b/constants/status.go @@ -7,5 +7,5 @@ package constants // 应用程序版本信息 const ( // AppVersion 应用程序版本号 - AppVersion = "0.3.0" + AppVersion = "1.0.3" ) diff --git a/controllers/admin/api.go b/controllers/admin/api.go index 547e8b9..62c6d73 100644 --- a/controllers/admin/api.go +++ b/controllers/admin/api.go @@ -3,9 +3,9 @@ package admin import ( "encoding/hex" "net/http" - "networkDev/controllers" - "networkDev/models" - "networkDev/utils/encrypt" + "NetworkAuth/controllers" + "NetworkAuth/models" + "NetworkAuth/utils/encrypt" "strconv" "strings" diff --git a/controllers/admin/app.go b/controllers/admin/app.go index a0d94a7..2010d4f 100644 --- a/controllers/admin/app.go +++ b/controllers/admin/app.go @@ -1,12 +1,12 @@ package admin import ( + "NetworkAuth/controllers" + "NetworkAuth/models" "crypto/rand" "encoding/base64" "encoding/hex" "net/http" - "networkDev/controllers" - "networkDev/models" "strconv" "strings" @@ -247,7 +247,7 @@ func AppResetSecretHandler(c *gin.Context) { return } - logrus.WithField("app_uuid", app.UUID).Info("Successfully reset app secret") + logrus.WithField("app_uuid", app.UUID).Debug("Successfully reset app secret") c.JSON(http.StatusOK, gin.H{ "code": 0, @@ -295,7 +295,7 @@ func AppCreateHandler(c *gin.Context) { "download_type": req.DownloadType, "download_url": req.DownloadURL, "force_update": req.ForceUpdate, - }).Info("Received app create request") + }).Debug("Received app create request") // 创建应用 app := models.App{ @@ -344,8 +344,9 @@ func AppCreateHandler(c *gin.Context) { tx.Rollback() logrus.WithError(err).Error("Failed to create app") c.JSON(http.StatusInternalServerError, gin.H{ - "code": 1, - "msg": "创建应用失败", + "code": 1, + "msg": "创建应用失败: " + err.Error(), + "error": err.Error(), }) return } @@ -384,7 +385,7 @@ func AppCreateHandler(c *gin.Context) { return } - logrus.WithField("app_uuid", app.UUID).Info("Successfully created app with default APIs") + logrus.WithField("app_uuid", app.UUID).Debug("Successfully created app with default APIs") c.JSON(http.StatusOK, gin.H{ "code": 0, @@ -460,7 +461,7 @@ func AppUpdateHandler(c *gin.Context) { return } - logrus.WithField("app_id", app.ID).Info("Successfully updated app") + logrus.WithField("app_id", app.ID).Debug("Successfully updated app") c.JSON(http.StatusOK, gin.H{ "code": 0, @@ -557,7 +558,7 @@ func AppDeleteHandler(c *gin.Context) { logrus.WithFields(logrus.Fields{ "app_id": app.ID, "app_uuid": app.UUID, - }).Info("Successfully deleted app and related APIs") + }).Debug("Successfully deleted app and related APIs") c.JSON(http.StatusOK, gin.H{ "code": 0, @@ -629,7 +630,7 @@ func AppUpdateAppDataHandler(c *gin.Context) { logrus.WithFields(logrus.Fields{ "app_uuid": req.UUID, "app_name": app.Name, - }).Info("App data updated successfully") + }).Debug("App data updated successfully") c.JSON(http.StatusOK, gin.H{ "code": 0, @@ -701,7 +702,7 @@ func AppUpdateAnnouncementHandler(c *gin.Context) { logrus.WithFields(logrus.Fields{ "app_uuid": req.UUID, "app_name": app.Name, - }).Info("App announcement updated successfully") + }).Debug("App announcement updated successfully") c.JSON(http.StatusOK, gin.H{ "code": 0, @@ -870,7 +871,7 @@ func AppUpdateMultiConfigHandler(c *gin.Context) { logrus.WithFields(logrus.Fields{ "app_uuid": req.UUID, "app_name": app.Name, - }).Info("App multi config updated successfully") + }).Debug("App multi config updated successfully") c.JSON(http.StatusOK, gin.H{ "code": 0, @@ -1023,7 +1024,7 @@ func AppUpdateBindConfigHandler(c *gin.Context) { logrus.WithFields(logrus.Fields{ "app_uuid": req.UUID, "app_name": app.Name, - }).Info("App bind config updated successfully") + }).Debug("App bind config updated successfully") c.JSON(http.StatusOK, gin.H{ "code": 0, @@ -1161,7 +1162,7 @@ func AppUpdateRegisterConfigHandler(c *gin.Context) { logrus.WithFields(logrus.Fields{ "app_uuid": req.UUID, "app_name": app.Name, - }).Info("App register config updated successfully") + }).Debug("App register config updated successfully") c.JSON(http.StatusOK, gin.H{ "code": 0, @@ -1265,7 +1266,7 @@ func AppsBatchDeleteHandler(c *gin.Context) { logrus.WithFields(logrus.Fields{ "app_ids": req.IDs, "app_uuids": appUUIDs, - }).Info("Successfully batch deleted apps and related APIs") + }).Debug("Successfully batch deleted apps and related APIs") c.JSON(http.StatusOK, gin.H{ "code": 0, diff --git a/controllers/admin/auth.go b/controllers/admin/auth.go index 26bfae2..2c9e1b4 100644 --- a/controllers/admin/auth.go +++ b/controllers/admin/auth.go @@ -1,16 +1,16 @@ package admin import ( + "NetworkAuth/controllers" + "NetworkAuth/database" + "NetworkAuth/models" + "NetworkAuth/services" + "NetworkAuth/utils" "fmt" "net/http" "strings" "time" - "networkDev/controllers" - "networkDev/database" - "networkDev/models" - "networkDev/utils" - "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/spf13/viper" @@ -40,9 +40,9 @@ func LoginPageHandler(c *gin.Context) { // 获取或生成CSRF令牌 var token string - if existingToken := utils.GetCSRFTokenFromCookie(c); existingToken != "" { - // 重用现有的Cookie令牌 - token = existingToken + // 尝试从Cookie获取 + if cookie, err := c.Cookie(CSRFCookieName); err == nil && cookie != "" { + token = cookie } else { // 生成新的CSRF令牌并设置到Cookie newToken, err := utils.GenerateCSRFToken() @@ -53,21 +53,14 @@ func LoginPageHandler(c *gin.Context) { return } token = newToken - utils.SetCSRFToken(c, token) + setCSRFToken(c, token) } // 准备模板数据 - extraData := gin.H{ - "Title": "管理员登录", - } data := authBaseController.GetDefaultTemplateData() + data["Title"] = "管理员登录" data["CSRFToken"] = token - // 合并额外数据 - for key, value := range extraData { - data[key] = value - } - c.HTML(http.StatusOK, "login.html", data) } @@ -76,21 +69,30 @@ func LoginPageHandler(c *gin.Context) { // ============================================================================ // LoginHandler 管理员登录接口 -// - 接收JSON: {username, password} +// - 接收JSON: {username, password, captcha, csrf_token} +// - 验证CSRF令牌 +// - 验证验证码 // - 验证用户存在与密码正确性 -// - 仅允许 Role=0 的管理员登录 -// - 成功后设置简单的会话Cookie(后续可切换为JWT或更完善的Session) +// - 仅允许管理员登录 +// - 成功后设置JWT Cookie func LoginHandler(c *gin.Context) { var body struct { - Username string `json:"username"` - Password string `json:"password"` - Captcha string `json:"captcha"` + Username string `json:"username"` + Password string `json:"password"` + Captcha string `json:"captcha"` + CSRFToken string `json:"csrf_token"` } if !authBaseController.BindJSON(c, &body) { return } + // 1. 验证CSRF令牌 (Gin 方式) + if !validateCSRFToken(c, body.CSRFToken) { + authBaseController.HandleValidationError(c, "CSRF令牌验证失败") + return + } + if !authBaseController.ValidateRequired(c, map[string]interface{}{ "用户名": body.Username, "密码": body.Password, @@ -101,95 +103,133 @@ func LoginHandler(c *gin.Context) { // 验证验证码 if !VerifyCaptcha(c, body.Captcha) { + recordLoginLog(c, body.Username, 0, "验证码错误") authBaseController.HandleValidationError(c, "验证码错误") return } - // 获取数据库连接 - db, ok := authBaseController.GetDB(c) - if !ok { - return - } + // 获取系统设置服务 + settingsService := services.GetSettingsService() + adminUsername := settingsService.GetString("admin_username", "admin") + adminPasswordHash := settingsService.GetString("admin_password", "") + adminPasswordSalt := settingsService.GetString("admin_password_salt", "") - // 通过前缀匹配一次性获取所有管理员相关设置 - var adminSettings []models.Settings - if err := db.Where("name LIKE ?", "admin_%").Find(&adminSettings).Error; err != nil { - authBaseController.HandleValidationError(c, "用户不存在或密码错误") - return - } - - // 将设置转换为map便于查找 - settingsMap := make(map[string]string) - for _, setting := range adminSettings { - settingsMap[setting.Name] = setting.Value - } - - // 检查必要的设置是否存在 - adminUsername, hasUsername := settingsMap["admin_username"] - adminPassword, hasPassword := settingsMap["admin_password"] - adminPasswordSalt, hasSalt := settingsMap["admin_password_salt"] - - if !hasUsername || !hasPassword || !hasSalt { - authBaseController.HandleValidationError(c, "用户不存在或密码错误") + // 验证密码为空的情况(首次登录需要初始化) + if adminPasswordHash == "" || adminPasswordSalt == "" { + recordLoginLog(c, body.Username, 0, "管理员账号未初始化") + authBaseController.HandleInternalError(c, "管理员账号未初始化,请联系系统管理员", nil) return } // 验证用户名 if body.Username != adminUsername { + recordLoginLog(c, body.Username, 0, "用户名错误") authBaseController.HandleValidationError(c, "用户不存在或密码错误") return } - // 验证密码为空的情况(首次登录需要初始化) - if adminPassword == "" || adminPasswordSalt == "" { - authBaseController.HandleInternalError(c, "管理员账号未初始化,请联系系统管理员", nil) - return - } - - // 使用盐值验证密码 - if !utils.VerifyPasswordWithSalt(body.Password, adminPasswordSalt, adminPassword) { + // 验证密码(使用盐值校验) + if !utils.VerifyPasswordWithSalt(body.Password, adminPasswordSalt, adminPasswordHash) { + recordLoginLog(c, body.Username, 0, "密码错误") authBaseController.HandleValidationError(c, "用户不存在或密码错误") return } - // 创建虚拟用户对象用于生成JWT令牌 - adminUser := models.User{ - Username: adminUsername, - Password: adminPassword, - PasswordSalt: adminPasswordSalt, - } - // 生成JWT令牌 - token, err := generateJWTTokenForAdmin(adminUser) + token, err := generateJWTTokenForAdmin(body.Username, adminPasswordHash) if err != nil { + recordLoginLog(c, body.Username, 0, "生成令牌失败") authBaseController.HandleInternalError(c, "生成令牌失败", err) return } - // 设置JWT Cookie(使用安全配置) - cookie := utils.CreateSecureCookie("admin_session", token, utils.GetDefaultCookieMaxAge()) + // 设置JWT Cookie(HttpOnly,安全) + // 使用系统配置的Cookie参数 + secure, sameSite, domain, maxAge := settingsService.GetCookieConfig() + 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, "登录成功") authBaseController.HandleSuccess(c, "登录成功", gin.H{ "redirect": "/admin", }) } +// recordLoginLog 记录登录日志 +// status: 1-成功, 0-失败 +func recordLoginLog(c *gin.Context, username string, status int, message string) { + db, err := database.GetDB() + if err != nil { + // 记录日志失败不应影响主流程,但可以记录到系统日志 + fmt.Printf("Failed to connect to database for login log: %v\n", err) + 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 { + fmt.Printf("Failed to create login log: %v\n", err) + } +} + // LogoutHandler 管理员登出 -// - 清理JWT Cookie +// - 清理JWT Cookie会话 // - 确保令牌完全失效 func LogoutHandler(c *gin.Context) { // 清理JWT Cookie clearInvalidJWTCookie(c) - // 可选:将JWT令牌加入黑名单(需要Redis或数据库支持) - // 这里可以实现JWT黑名单机制 - authBaseController.HandleSuccess(c, "已退出登录", gin.H{ "redirect": "/admin/login", }) } +// ============================================================================ +// CSRF 相关辅助函数 +// ============================================================================ + +const ( + CSRFCookieName = "csrf_token" + CSRFHeaderName = "X-CSRF-Token" + CSRFFormField = "csrf_token" +) + +// setCSRFToken 设置CSRF令牌到Cookie (Gin适配) +func setCSRFToken(c *gin.Context, token string) { + c.SetCookie(CSRFCookieName, token, 3600*24, "/", "", false, false) + c.Header(CSRFHeaderName, token) +} + +// validateCSRFToken 验证CSRF令牌 (Gin适配) +func validateCSRFToken(c *gin.Context, requestToken string) bool { + // 获取Cookie中的令牌 + cookie, err := c.Cookie(CSRFCookieName) + if err != nil || cookie == "" { + return false + } + cookieToken := cookie + + // 如果请求体中没有提供token,尝试从Header获取 + if requestToken == "" { + requestToken = c.GetHeader(CSRFHeaderName) + } + + if requestToken == "" { + return false + } + + // 使用常量时间比较 + return strings.Compare(cookieToken, requestToken) == 0 +} + // ============================================================================ // 辅助函数 // ============================================================================ @@ -198,14 +238,27 @@ func LogoutHandler(c *gin.Context) { // - 统一的Cookie清理函数,确保一致性 // - 在JWT校验失败时自动调用,提升安全性和用户体验 func clearInvalidJWTCookie(c *gin.Context) { - cookie := utils.CreateExpiredCookie("admin_session") + _, _, domain, _ := services.GetSettingsService().GetCookieConfig() + cookie := utils.CreateExpiredCookie("admin_session", domain) c.SetCookie(cookie.Name, cookie.Value, cookie.MaxAge, cookie.Path, cookie.Domain, cookie.Secure, cookie.HttpOnly) } // getJWTSecret 动态获取当前的JWT密钥 // 修复安全漏洞:确保每次都从最新配置中获取密钥,而不是使用启动时的全局变量 func getJWTSecret() []byte { - return []byte(viper.GetString("security.jwt_secret")) + // 1. 尝试从数据库设置获取 + settingsService := services.GetSettingsService() + if secret := settingsService.GetJWTSecret(); secret != "" { + return []byte(secret) + } + + // 2. 尝试从配置文件获取(兼容旧配置) + if secret := viper.GetString("security.jwt_secret"); secret != "" { + return []byte(secret) + } + + // 3. 使用默认不安全密钥(仅开发环境) + return []byte("default-insecure-jwt-secret") } // ============================================================================ @@ -215,27 +268,40 @@ func getJWTSecret() []byte { // JWTClaims JWT载荷结构体 type JWTClaims struct { Username string `json:"username"` + UUID string `json:"uuid"` // 添加虚拟角色UUID + Role int `json:"role"` // 添加虚拟角色 PasswordHash string `json:"password_hash"` // 密码哈希摘要,用于验证密码是否被修改 jwt.RegisteredClaims } // generateJWTTokenForAdmin 生成管理员JWT令牌 -// - 包含管理员UUID、用户名信息 -// - 设置24小时过期时间 +// - 包含管理员用户名信息和密码哈希 +// - 设置过期时间 // - 使用HMAC-SHA256签名 -func generateJWTTokenForAdmin(adminUser models.User) (string, error) { +func generateJWTTokenForAdmin(username, passwordHash string) (string, error) { // 生成密码哈希摘要(使用SHA256) - passwordHashDigest := utils.GenerateSHA256Hash(adminUser.Password) + // 注意:传入的 passwordHash 已经是数据库存的 Hash,这里我们再次 Hash 还是直接用? + // atomicLibrary 的实现是: utils.GenerateSHA256Hash(adminUser.Password) + // 这里我们直接用数据库里的 Hash 值作为 Token 的一部分即可,或者对它再 Hash 一次。 + // 为了与 validateAdminPasswordHash 对应,我们需要知道验证时怎么比对。 + // validateAdminPasswordHash: currentPasswordHash := utils.GenerateSHA256Hash(adminPassword.Value) + // 所以这里也应该对数据库里的值进行 Hash。 + passwordHashDigest := utils.GenerateSHA256Hash(passwordHash) + + // 获取虚拟管理员UUID (NetworkAuth 项目默认为 admin-uuid-001) + adminUUID := services.GetSettingsService().GetString("admin_uuid", "admin-uuid-001") claims := JWTClaims{ - Username: adminUser.Username, + Username: username, + UUID: adminUUID, + Role: 0, // 0表示超级管理员 PasswordHash: passwordHashDigest, // 包含密码哈希摘要 RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(services.GetSettingsService().GetJWTExpire()) * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()), - Issuer: "凌动技术", - Subject: adminUser.Username, + Issuer: "NetworkAuth", + Subject: username, }, } @@ -303,7 +369,7 @@ func validateAdminPasswordHash(claims *JWTClaims, c *gin.Context) bool { return true } -// IsAdminAuthenticated 判断管理员是否已认证(导出) +// IsAdminAuthenticated 判断管理员是否已认证(Gin版本) // - 检查admin_session Cookie中的JWT令牌 // - 验证令牌签名、过期时间和用户角色 func IsAdminAuthenticated(c *gin.Context) bool { @@ -318,12 +384,48 @@ func IsAdminAuthenticated(c *gin.Context) bool { return false } - // 注释:由于这是管理员专用认证函数,不需要额外的角色验证 - // 验证密码哈希 return validateAdminPasswordHash(claims, c) } +// IsAdminAuthenticatedHttp 判断管理员是否已认证(HTTP兼容版本) +// 保留此方法以兼容未迁移的 Handler +func IsAdminAuthenticatedHttp(r *http.Request) bool { + cookie, err := r.Cookie("admin_session") + if err != nil || cookie.Value == "" { + return false + } + + // 解析并验证JWT令牌 + claims, err := parseJWTToken(cookie.Value) + if err != nil { + return false + } + + // 注意:HTTP 版本无法方便地获取 ClientIP 用于日志,且无法使用 Gin Context 的功能 + // 这里仅做基本的 Token 验证。如果 Token 包含了 PasswordHash,这里也会解析出来。 + // 但验证 PasswordHash 需要 DB 访问。 + // 为了完整性,我们应该也验证 PasswordHash。 + // 这里的 ClientIP 只能从 r.RemoteAddr 获取。 + + db, err := database.GetDB() + if err != nil { + return false + } + + var adminPassword models.Settings + if err := db.Where("name = ?", "admin_password").First(&adminPassword).Error; err != nil { + return false + } + + currentPasswordHash := utils.GenerateSHA256Hash(adminPassword.Value) + if claims.PasswordHash != currentPasswordHash { + return false + } + + return true +} + // IsAdminAuthenticatedWithCleanup 带自动清理功能的JWT校验函数 // - 当JWT校验失败时,自动清理失效的Cookie // - 适用于API接口等需要清理失效令牌的场景 @@ -341,8 +443,6 @@ func IsAdminAuthenticatedWithCleanup(c *gin.Context) bool { return false } - // 注释:由于这是管理员专用认证函数,不需要额外的角色验证 - // 验证密码哈希 if !validateAdminPasswordHash(claims, c) { clearInvalidJWTCookie(c) @@ -352,23 +452,18 @@ func IsAdminAuthenticatedWithCleanup(c *gin.Context) bool { return true } -// GetCurrentAdminUser 获取当前登录的管理员用户信息 -// - 从JWT令牌中提取用户信息 -// - 自动刷新接近过期的令牌(剩余时间少于6小时时刷新) -// - 返回用户ID、用户名和角色 -func GetCurrentAdminUser(c *gin.Context) (*JWTClaims, error) { - cookie, err := getJWTCookie(c) +// GetCurrentAdminUser 获取当前登录的管理员用户信息 (HTTP 兼容版) +func GetCurrentAdminUser(r *http.Request) (*JWTClaims, error) { + cookie, err := r.Cookie("admin_session") if err != nil { return nil, fmt.Errorf("未找到会话信息") } - claims, err := parseJWTToken(cookie) + claims, err := parseJWTToken(cookie.Value) if err != nil { return nil, fmt.Errorf("无效的会话信息") } - // 注释:由于这是管理员专用函数,不需要额外的角色验证 - return claims, nil } @@ -394,25 +489,56 @@ func GetCurrentAdminUserWithRefresh(c *gin.Context) (*JWTClaims, bool, error) { // 检查是否需要刷新令牌 refreshed := false - refreshThreshold := time.Duration(viper.GetInt("security.jwt_refresh")) * time.Hour + + // 动态获取刷新阈值:默认剩余时间少于6小时刷新 + refreshThresholdHours := services.GetSettingsService().GetJWTRefresh() + if refreshThresholdHours <= 0 { + refreshThresholdHours = 6 // 默认值 + } + refreshThreshold := time.Duration(refreshThresholdHours) * time.Hour + + // 动态获取JWT总有效期 + expireHours := services.GetSettingsService().GetJWTExpire() + if expireHours <= 0 { + expireHours = 24 // 默认值 + } + + // 动态获取Cookie配置(用于更新Cookie过期时间) + secure, sameSite, domain, maxAge := services.GetSettingsService().GetCookieConfig() + + // 1. 默认情况下,每次请求都更新Cookie的过期时间(滑动过期) + tokenToSet := cookie + shouldUpdateCookie := true + + // 2. 检查是否需要刷新JWT令牌(生成新的Token) if time.Until(claims.ExpiresAt.Time) < refreshThreshold { - adminUser := models.User{ - Username: claims.Username, - } - newToken, err := generateJWTTokenForAdmin(adminUser) + // 获取当前的 PasswordHash + db, _ := database.GetDB() + var adminPassword models.Settings + db.Where("name = ?", "admin_password").First(&adminPassword) + + // 使用新的有效期生成令牌 + newToken, err := generateJWTTokenForAdmin(claims.Username, adminPassword.Value) if err == nil { - c.SetCookie("admin_session", newToken, utils.GetDefaultCookieMaxAge(), "/", "", false, true) + tokenToSet = newToken refreshed = true - claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(24 * time.Hour)) + // 更新当前claims的过期时间 + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour)) claims.IssuedAt = jwt.NewNumericDate(time.Now()) } } + // 3. 执行Cookie更新 + if shouldUpdateCookie { + cookieObj := utils.CreateSecureCookie("admin_session", tokenToSet, maxAge, domain, secure, sameSite) + c.SetCookie(cookieObj.Name, cookieObj.Value, cookieObj.MaxAge, cookieObj.Path, cookieObj.Domain, cookieObj.Secure, cookieObj.HttpOnly) + } + return claims, refreshed, nil } -// AdminAuthRequired 管理员认证拦截中间件 +// AdminAuthRequired 管理员认证拦截中间件 (Gin Middleware) // - 未登录:重定向到 /admin/login // - 已登录:自动刷新接近过期的令牌,然后放行到后续处理器 func AdminAuthRequired() gin.HandlerFunc { @@ -424,8 +550,6 @@ func AdminAuthRequired() gin.HandlerFunc { clearInvalidJWTCookie(c) // 中文注释:区分普通页面请求与AJAX/JSON请求 - // - 对 AJAX/JSON:直接返回 401 JSON,便于前端处理(如提示重新登录) - // - 对普通页面:保持原有重定向到登录页 accept := c.GetHeader("Accept") xrw := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Requested-With"))) if strings.Contains(accept, "application/json") || xrw == "xmlhttprequest" { @@ -448,6 +572,11 @@ func AdminAuthRequired() gin.HandlerFunc { _ = claims // 避免未使用变量警告 } + // 将解析出的用户信息存入上下文,供后续处理使用 + c.Set("admin_uuid", claims.UUID) + c.Set("admin_username", claims.Username) + c.Set("admin_role", claims.Role) + c.Next() } } diff --git a/controllers/admin/captcha.go b/controllers/admin/captcha.go index b58084d..51ebcbf 100644 --- a/controllers/admin/captcha.go +++ b/controllers/admin/captcha.go @@ -7,11 +7,9 @@ import ( "net/http" "strings" - "github.com/gin-gonic/gin" - "networkDev/controllers" - "networkDev/middleware" - "networkDev/utils" + "NetworkAuth/middleware" + "github.com/gin-gonic/gin" "github.com/mojocn/base64Captcha" ) @@ -19,9 +17,6 @@ import ( // 全局变量 // ============================================================================ -// 创建基础控制器实例 -var captchaBaseController = controllers.NewBaseController() - // 全局验证码存储器 var store = base64Captcha.DefaultMemStore @@ -49,7 +44,7 @@ func CaptchaHandler(c *gin.Context) { // 使用crypto/rand生成安全的随机数 randomNum, err := secureRandomInt(3) if err != nil { - captchaBaseController.HandleInternalError(c, "生成随机数失败", err) + c.String(http.StatusInternalServerError, "生成随机数失败") return } captchaLength := 4 + randomNum // 4-6位随机长度 @@ -62,37 +57,31 @@ func CaptchaHandler(c *gin.Context) { ShowLineOptions: 2 | 4, Length: captchaLength, Source: "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789", // 混合大小写字母和数字,去除易混淆字符 - Fonts: []string{"wqy-microhei.ttc"}, } // 生成验证码 captcha := base64Captcha.NewCaptcha(&driver, store) id, b64s, _, err := captcha.Generate() if err != nil { - captchaBaseController.HandleInternalError(c, "生成验证码失败", err) + c.String(http.StatusInternalServerError, "生成验证码失败") return } - // 将验证码ID存储到session中(这里简化处理,实际项目中应该使用更安全的方式) - // 设置cookie来存储验证码ID - cookie := utils.CreateSecureCookie("captcha_id", id, 300) // 5分钟过期 - c.SetCookie(cookie.Name, cookie.Value, cookie.MaxAge, cookie.Path, cookie.Domain, cookie.Secure, cookie.HttpOnly) + // 将验证码ID存储到Cookie中 + c.SetCookie("captcha_id", id, 300, "/", "", false, true) - // 解码base64图片数据并返回 + // 设置响应头 c.Header("Content-Type", "image/png") c.Header("Cache-Control", "no-cache, no-store, must-revalidate") c.Header("Pragma", "no-cache") c.Header("Expires", "0") - // 直接返回base64编码的图片数据,让浏览器解析 - // 但是我们需要返回实际的图片数据,所以需要解码base64 - // 去掉data:image/png;base64,前缀 b64s = strings.TrimPrefix(b64s, "data:image/png;base64,") imgData, err := base64.StdEncoding.DecodeString(b64s) if err != nil { - captchaBaseController.HandleInternalError(c, "解码验证码图片失败", err) + c.String(http.StatusInternalServerError, "解码验证码图片失败") return } @@ -110,11 +99,7 @@ func VerifyCaptcha(c *gin.Context, captchaValue string) bool { // 从cookie中获取验证码ID captchaId, err := c.Cookie("captcha_id") - if err != nil { - return false - } - - if captchaId == "" { + if err != nil || captchaId == "" { return false } @@ -125,7 +110,7 @@ func VerifyCaptcha(c *gin.Context, captchaValue string) bool { return true } - // 如果原始值验证失败,尝试小写验证(因为显示的是大小写混合,但允许用户输入小写) + // 如果原始值验证失败,尝试小写验证 if store.Verify(captchaId, strings.ToLower(captchaValue), false) { // 验证成功后删除验证码 store.Verify(captchaId, strings.ToLower(captchaValue), true) @@ -139,22 +124,3 @@ func VerifyCaptcha(c *gin.Context, captchaValue string) bool { return false } - -// CaptchaAPIHandler 验证码API接口(可选,用于AJAX验证) -// POST /admin/api/captcha/verify - 验证验证码 -func CaptchaAPIHandler(c *gin.Context) { - var body struct { - Captcha string `json:"captcha"` - } - if !captchaBaseController.BindJSON(c, &body) { - return - } - - isValid := VerifyCaptcha(c, body.Captcha) - - if isValid { - captchaBaseController.HandleSuccess(c, "验证码正确", nil) - } else { - captchaBaseController.HandleValidationError(c, "验证码错误") - } -} diff --git a/controllers/admin/function.go b/controllers/admin/function.go index 9691ffc..8246db8 100644 --- a/controllers/admin/function.go +++ b/controllers/admin/function.go @@ -1,9 +1,9 @@ package admin import ( + "NetworkAuth/controllers" + "NetworkAuth/models" "net/http" - "networkDev/controllers" - "networkDev/models" "regexp" "strconv" "strings" @@ -299,7 +299,7 @@ func FunctionDeleteHandler(c *gin.Context) { return } - logrus.WithField("function_id", req.ID).Info("Successfully deleted function") + logrus.WithField("function_id", req.ID).Debug("Successfully deleted function") functionBaseController.HandleSuccess(c, "删除成功", nil) } @@ -331,7 +331,7 @@ func FunctionsBatchDeleteHandler(c *gin.Context) { return } - logrus.WithField("function_ids", req.IDs).Info("Successfully batch deleted functions") + logrus.WithField("function_ids", req.IDs).Debug("Successfully batch deleted functions") functionBaseController.HandleSuccess(c, "批量删除成功", nil) } diff --git a/controllers/admin/handlers.go b/controllers/admin/handlers.go index 836297c..c09f584 100644 --- a/controllers/admin/handlers.go +++ b/controllers/admin/handlers.go @@ -1,14 +1,15 @@ package admin import ( + "NetworkAuth/constants" + "NetworkAuth/controllers" + "NetworkAuth/middleware" + "NetworkAuth/models" + "NetworkAuth/services" + "NetworkAuth/utils" + "NetworkAuth/utils/timeutil" "net/http" - "networkDev/constants" - "networkDev/controllers" - "networkDev/middleware" - "networkDev/models" - "networkDev/services" - "networkDev/utils" - "networkDev/utils/timeutil" + "strconv" "github.com/gin-gonic/gin" "github.com/spf13/viper" @@ -83,11 +84,8 @@ func AdminLayoutHandler(c *gin.Context) { data["CSRFToken"] = token // 从数据库读取站点标题,如果失败则使用默认值 - if db, ok := handlersBaseController.GetDB(c); ok { - if siteTitle, err := services.FindSettingByName("site_title", db); err == nil && siteTitle != nil { - data["Title"] = siteTitle.Value - } - } + settingsSvc := services.GetSettingsService() + data["Title"] = settingsSvc.GetString("site_title", "后台管理") // 合并其他数据(如果有的话) extraData := gin.H{} @@ -192,3 +190,45 @@ func DashboardStatsHandler(c *gin.Context) { handlersBaseController.HandleSuccess(c, "ok", data) } + +// DashboardLoginLogsHandler 获取管理员最近登录日志 +func DashboardLoginLogsHandler(c *gin.Context) { + db, ok := handlersBaseController.GetDB(c) + if !ok { + return + } + + // 获取分页参数 + pageStr := c.DefaultQuery("page", "1") + limitStr := c.DefaultQuery("limit", "10") + page, _ := strconv.Atoi(pageStr) + limit, _ := strconv.Atoi(limitStr) + if page < 1 { + page = 1 + } + if limit < 1 || limit > 100 { + limit = 10 + } + offset := (page - 1) * limit + + var total int64 + // 当前模型的 LoginLog 本身就是专用于 admin 的登录日志模型(没有 type 字段),所以直接查询全部即可 + query := db.Model(&models.LoginLog{}) + + if err := query.Count(&total).Error; err != nil { + handlersBaseController.HandleInternalError(c, "获取登录日志总数失败", err) + return + } + + var logs []models.LoginLog + if err := query.Order("created_at desc").Offset(offset).Limit(limit).Find(&logs).Error; err != nil { + handlersBaseController.HandleInternalError(c, "获取登录日志列表失败", err) + return + } + + data := gin.H{ + "total": total, + "list": logs, + } + handlersBaseController.HandleSuccess(c, "获取登录日志成功", data) +} diff --git a/controllers/admin/login_log.go b/controllers/admin/login_log.go new file mode 100644 index 0000000..13d3f71 --- /dev/null +++ b/controllers/admin/login_log.go @@ -0,0 +1,174 @@ +package admin + +import ( + "NetworkAuth/controllers" + "NetworkAuth/models" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +// ============================================================================ +// 全局变量 +// ============================================================================ + +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") + } +} + +// LoginLogsFragmentHandler 登录日志页面片段处理器 +func LoginLogsFragmentHandler(c *gin.Context) { + c.HTML(http.StatusOK, "login_logs.html", gin.H{ + "Title": "登录日志", + }) +} + +// ============================================================================ +// API处理器 +// ============================================================================ + +// LoginLogsListHandler 登录日志列表API处理器 +func LoginLogsListHandler(c *gin.Context) { + // 获取分页参数 + page, _ := strconv.Atoi(c.Query("page")) + if page <= 0 { + page = 1 + } + limit, _ := strconv.Atoi(c.Query("limit")) + if limit <= 0 { + limit = 10 + } + + // 构建查询 + db, ok := loginLogBaseController.GetDB(c) + if !ok { + return + } + + var logs []models.LoginLog + var total int64 + + // 兼容旧数据(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) + } + } + + // 筛选条件:时间范围 + startTime := strings.TrimSpace(c.Query("start_time")) + endTime := strings.TrimSpace(c.Query("end_time")) + if startTime != "" && endTime != "" { + query = query.Where("created_at BETWEEN ? AND ?", startTime, endTime) + } + + // 统计总数 + if err := query.Count(&total).Error; err != nil { + loginLogBaseController.HandleInternalError(c, "获取日志总数失败", err) + return + } + + // 查询数据(时间倒序,从新到旧) + offset := (page - 1) * limit + if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&logs).Error; 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.Where("type = ?", "admin").Delete(&models.LoginLog{}).Error; err != nil { + logrus.WithError(err).Error("Failed to clear login logs") + loginLogBaseController.HandleInternalError(c, "清空登录日志失败", err) + return + } + + // 记录操作日志 + // 由于 NetworkAuth 中没有 SystemAdminUser 全局变量,这里暂时使用 "admin" + operator := "admin" + // 尝试从上下文获取用户名(如果中间件设置了的话) + // if user, exists := c.Get("username"); exists { + // operator = user.(string) + // } + + log := models.OperationLog{ + OperationType: "清空登录日志", + Operator: operator, + OperatorUUID: "", // NetworkAuth 中暂时无法获取 UUID + AppName: "-", + ProductName: "-", + TransactionID: "-", + 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 new file mode 100644 index 0000000..2a89b3e --- /dev/null +++ b/controllers/admin/operation_log.go @@ -0,0 +1,152 @@ +package admin + +import ( + "NetworkAuth/controllers" + "NetworkAuth/models" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// ============================================================================ +// 全局变量 +// ============================================================================ + +var logBaseController = controllers.NewBaseController() + +// ============================================================================ +// 页面处理器 +// ============================================================================ + +// LogsFragmentHandler 日志操作页面片段处理器 +func LogsFragmentHandler(c *gin.Context) { + c.HTML(http.StatusOK, "operation_logs.html", gin.H{ + "Title": "操作日志", + }) +} + +// ============================================================================ +// API处理器 +// ============================================================================ + +// LogsListHandler 日志列表API处理器 +func LogsListHandler(c *gin.Context) { + // 获取分页参数 + page, _ := strconv.Atoi(c.Query("page")) + if page <= 0 { + page = 1 + } + limit, _ := strconv.Atoi(c.Query("limit")) + if limit <= 0 { + limit = 10 + } + + // 获取搜索参数 + startTimeStr := strings.TrimSpace(c.Query("start_time")) + endTimeStr := strings.TrimSpace(c.Query("end_time")) + operationType := strings.TrimSpace(c.Query("operation_type")) + operator := strings.TrimSpace(c.Query("operator")) + transactionID := strings.TrimSpace(c.Query("transaction_id")) + + // 构建查询 + db, ok := logBaseController.GetDB(c) + if !ok { + return + } + + var logs []models.OperationLog + var total int64 + + 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) + } + if transactionID != "" { + // 优化:使用精确匹配提升查询性能 + query = query.Where("transaction_id = ?", transactionID) + } + if startTimeStr != "" { + if t, err := time.ParseInLocation("2006-01-02", startTimeStr, time.Local); err == nil { + query = query.Where("created_at >= ?", t) + } else if t, err := time.ParseInLocation("2006-01-02 15:04:05", startTimeStr, time.Local); err == nil { + query = query.Where("created_at >= ?", t) + } else { + query = query.Where("created_at >= ?", startTimeStr) + } + } + if endTimeStr != "" { + if t, err := time.ParseInLocation("2006-01-02", endTimeStr, time.Local); err == nil { + t = t.Add(24*time.Hour - time.Nanosecond) + query = query.Where("created_at <= ?", t) + } else if t, err := time.ParseInLocation("2006-01-02 15:04:05", endTimeStr, time.Local); err == nil { + query = query.Where("created_at <= ?", t) + } else { + if len(endTimeStr) == 10 { // yyyy-MM-dd + endTimeStr += " 23:59:59" + } + query = query.Where("created_at <= ?", endTimeStr) + } + } + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + logrus.WithError(err).Error("获取日志总数失败") + logBaseController.HandleInternalError(c, "获取日志总数失败", err) + return + } + + // 分页查询(时间倒序,从新到旧) + offset := (page - 1) * limit + if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error; 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 + } + + // 记录操作日志 (因为刚刚清空了,这条将是第一条) + operator := "admin" + log := models.OperationLog{ + OperationType: "清空日志", + Operator: operator, + OperatorUUID: "", + AppName: "-", + ProductName: "-", + TransactionID: "-", + Details: "管理员清空了所有操作日志", + CreatedAt: time.Now(), + } + db.Create(&log) + + logBaseController.HandleSuccess(c, "日志已清空", nil) +} diff --git a/controllers/admin/profile.go b/controllers/admin/profile.go new file mode 100644 index 0000000..a308b81 --- /dev/null +++ b/controllers/admin/profile.go @@ -0,0 +1,262 @@ +package admin + +import ( + "NetworkAuth/database" + "NetworkAuth/models" + "NetworkAuth/services" + "NetworkAuth/utils" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ProfileFragmentHandler 个人资料片段渲染 +// - 渲染个人资料与修改密码表单 +func ProfileFragmentHandler(c *gin.Context) { + c.HTML(http.StatusOK, "profile.html", map[string]interface{}{}) +} + +// ProfileInfoHandler 查询当前登录管理员的基本信息 +// - 返回 username 字段 +func ProfileInfoHandler(c *gin.Context) { + _, _, err := GetCurrentAdminUserWithRefresh(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 1, + "msg": "未登录或会话已过期", + "data": nil, + }) + return + } + + // 获取最新设置 + settingsService := services.GetSettingsService() + username := settingsService.GetString("admin_username", "admin") + + authBaseController.HandleSuccess(c, "ok", map[string]interface{}{ + "username": username, + }) +} + +// ProfilePasswordUpdateHandler 修改当前登录管理员的密码 +// - 接收 JSON: {old_password, new_password, confirm_password} +// - 校验旧密码正确性、新密码与确认一致性 +// - 成功后更新密码哈希 +func ProfilePasswordUpdateHandler(c *gin.Context) { + _, _, err := GetCurrentAdminUserWithRefresh(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 1, + "msg": "未登录或会话已过期", + "data": nil, + }) + return + } + + var body struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + ConfirmPassword string `json:"confirm_password"` + } + if !authBaseController.BindJSON(c, &body) { + return + } + + // 基础校验 + if body.OldPassword == "" || body.NewPassword == "" || body.ConfirmPassword == "" { + authBaseController.HandleValidationError(c, "旧密码/新密码/确认密码均不能为空") + return + } + if len(body.NewPassword) < 6 { + authBaseController.HandleValidationError(c, "新密码长度不能少于6位") + return + } + if body.NewPassword != body.ConfirmPassword { + authBaseController.HandleValidationError(c, "两次输入的新密码不一致") + return + } + if body.NewPassword == body.OldPassword { + authBaseController.HandleValidationError(c, "新密码不能与旧密码相同") + return + } + + // 获取当前密码设置 + settingsService := services.GetSettingsService() + currentHash := settingsService.GetString("admin_password", "") + currentSalt := settingsService.GetString("admin_password_salt", "") + + // 校验旧密码 + if !utils.VerifyPasswordWithSalt(body.OldPassword, currentSalt, currentHash) { + authBaseController.HandleValidationError(c, "旧密码不正确") + return + } + + // 生成新盐值和哈希 + newSalt, err := utils.GenerateRandomSalt() + if err != nil { + authBaseController.HandleInternalError(c, "生成盐值失败", err) + return + } + + newHash, err := utils.HashPasswordWithSalt(body.NewPassword, newSalt) + if err != nil { + authBaseController.HandleInternalError(c, "生成密码哈希失败", err) + return + } + + // 更新数据库 + db, ok := authBaseController.GetDB(c) + if !ok { + return + } + + // 更新 admin_password + if err := updateSetting(db, "admin_password", newHash); err != nil { + authBaseController.HandleInternalError(c, "更新密码失败", err) + return + } + + // 更新 admin_password_salt + if err := updateSetting(db, "admin_password_salt", newSalt); err != nil { + authBaseController.HandleInternalError(c, "更新盐值失败", err) + return + } + + // 刷新缓存 + settingsService.RefreshCache() + + // 清除相关缓存键 + _ = utils.RedisDel(c.Request.Context(), "setting:admin_password", "setting:admin_password_salt") + + // 获取当前用户名 + currentUsername := settingsService.GetString("admin_username", "admin") + + // 重新签发JWT并写入Cookie + token, err := generateJWTTokenForAdmin(currentUsername, newHash) + if err != nil { + authBaseController.HandleInternalError(c, "生成新令牌失败", err) + return + } + + secure, sameSite, domain, maxAge := settingsService.GetCookieConfig() + 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) + + authBaseController.HandleSuccess(c, "密码修改成功", nil) +} + +// ProfileUpdateHandler 修改当前登录管理员的用户名 +// - 接收 JSON: {username} +// - 校验用户名非空、长度 +// - 更新数据库后重新签发JWT并写入 Cookie,保持前端展示的一致性 +func ProfileUpdateHandler(c *gin.Context) { + _, _, err := GetCurrentAdminUserWithRefresh(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 1, + "msg": "未登录或会话已过期", + "data": nil, + }) + return + } + + var body struct { + Username string `json:"username"` + OldPassword string `json:"old_password"` + } + if !authBaseController.BindJSON(c, &body) { + return + } + + username := strings.TrimSpace(body.Username) + if username == "" { + authBaseController.HandleValidationError(c, "用户名不能为空") + return + } + if len(username) > 64 { + authBaseController.HandleValidationError(c, "用户名长度不能超过64字符") + return + } + + settingsService := services.GetSettingsService() + currentUsername := settingsService.GetString("admin_username", "admin") + + // 如果未变化则直接返回成功 + if strings.EqualFold(username, currentUsername) { + authBaseController.HandleSuccess(c, "保存成功", map[string]interface{}{ + "username": username, + }) + return + } + + // 修改用户名需要进行当前密码校验 + if strings.TrimSpace(body.OldPassword) == "" { + authBaseController.HandleValidationError(c, "修改用户名需要提供当前密码") + return + } + + currentHash := settingsService.GetString("admin_password", "") + currentSalt := settingsService.GetString("admin_password_salt", "") + + // 校验旧密码 + if !utils.VerifyPasswordWithSalt(body.OldPassword, currentSalt, currentHash) { + authBaseController.HandleValidationError(c, "当前密码不正确") + return + } + + // 更新数据库 + db, ok := authBaseController.GetDB(c) + if !ok { + return + } + + if err := updateSetting(db, "admin_username", username); err != nil { + authBaseController.HandleInternalError(c, "更新用户名失败", err) + return + } + + // 重新签发JWT并写入Cookie + token, err := generateJWTTokenForAdmin(username, currentHash) + if err != nil { + authBaseController.HandleInternalError(c, "生成新令牌失败", err) + return + } + + secure, sameSite, domain, maxAge := settingsService.GetCookieConfig() + 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) + + // 刷新缓存 + settingsService.RefreshCache() + _ = utils.RedisDel(c.Request.Context(), "setting:admin_username") + + authBaseController.HandleSuccess(c, "用户名修改成功", map[string]interface{}{ + "username": username, + }) +} + +// 辅助函数:更新设置项 +func updateSetting(db interface{}, name, value string) error { + // 类型断言 + gormDB, ok := db.(*gorm.DB) + if !ok { + // 如果断言失败,尝试重新获取连接 + var err error + gormDB, err = database.GetDB() + if err != nil { + return err + } + } + + var setting models.Settings + if err := gormDB.Where("name = ?", name).First(&setting).Error; err != nil { + // 如果不存在则创建 + setting = models.Settings{Name: name, Value: value} + return gormDB.Create(&setting).Error + } + + // 存在则更新 + return gormDB.Model(&setting).Update("value", value).Error +} diff --git a/controllers/admin/settings.go b/controllers/admin/settings.go index 6ffca22..37018aa 100644 --- a/controllers/admin/settings.go +++ b/controllers/admin/settings.go @@ -1,55 +1,52 @@ package admin import ( - "context" + "NetworkAuth/config" + "NetworkAuth/models" + "NetworkAuth/services" + "NetworkAuth/utils" "fmt" + "net/http" + "strconv" + "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" - "net/http" - "networkDev/controllers" - "networkDev/models" - "networkDev/services" - "networkDev/utils" ) -// ============================================================================ -// 全局变量 -// ============================================================================ - -// 创建基础控制器实例 -var settingsBaseController = controllers.NewBaseController() - -// ============================================================================ -// 页面处理器 -// ============================================================================ - // SettingsFragmentHandler 设置片段渲染 // - 渲染设置表单(通过前端JS调用API加载/保存) func SettingsFragmentHandler(c *gin.Context) { - c.HTML(http.StatusOK, "settings.html", gin.H{}) + c.HTML(http.StatusOK, "settings.html", map[string]interface{}{}) } -// ============================================================================ -// API处理器 -// ============================================================================ +// SubAccountSimpleListHandler 子账号简单列表API处理器 (Mock) +func SubAccountSimpleListHandler(c *gin.Context) { + // Mock implementation for NetworkAuth which has no subaccounts + c.JSON(http.StatusOK, gin.H{ + "code": 0, + "msg": "success", + "data": []interface{}{}, + }) +} // SettingsQueryHandler 设置查询API // - 返回所有设置项的 name:value 映射 func SettingsQueryHandler(c *gin.Context) { - db, ok := settingsBaseController.GetDB(c) + db, ok := authBaseController.GetDB(c) if !ok { return } + var list []models.Settings if err := db.Find(&list).Error; err != nil { - settingsBaseController.HandleInternalError(c, "查询失败", err) + authBaseController.HandleInternalError(c, "查询失败", err) return } res := map[string]string{} for _, s := range list { res[s.Name] = s.Value } - settingsBaseController.HandleSuccess(c, "ok", res) + authBaseController.HandleSuccess(c, "ok", res) } // SettingsUpdateHandler 更新系统设置处理器 @@ -65,7 +62,8 @@ func SettingsQueryHandler(c *gin.Context) { func SettingsUpdateHandler(c *gin.Context) { // 先尝试解析为直接字段格式 var directBody map[string]interface{} - if !settingsBaseController.BindJSON(c, &directBody) { + if err := c.ShouldBindJSON(&directBody); err != nil { + authBaseController.HandleValidationError(c, "请求体错误") return } @@ -82,7 +80,7 @@ func SettingsUpdateHandler(c *gin.Context) { } } } else { - settingsBaseController.HandleValidationError(c, "settings字段格式错误") + authBaseController.HandleValidationError(c, "settings字段格式错误") return } } else { @@ -99,11 +97,19 @@ func SettingsUpdateHandler(c *gin.Context) { } if len(settingsData) == 0 { - settingsBaseController.HandleValidationError(c, "无设置项") + authBaseController.HandleValidationError(c, "无设置项") return } - db, ok := settingsBaseController.GetDB(c) + // 验证设置项值 + for k, v := range settingsData { + if err := validateSettingValue(k, v); err != nil { + authBaseController.HandleValidationError(c, err.Error()) + return + } + } + + db, ok := authBaseController.GetDB(c) if !ok { return } @@ -113,13 +119,66 @@ func SettingsUpdateHandler(c *gin.Context) { // 批量处理设置项 for k, v := range settingsData { + // 特殊处理 admin_password + if k == "admin_password" { + // 如果密码为空,跳过更新(保留原密码) + if v == "" { + continue + } + + // 记录操作日志 + // 由于 NetworkAuth 中没有 SystemAdminUser 全局变量,这里暂时使用 "admin" + // operator := "admin" + // 尝试从上下文获取用户名(如果中间件设置了的话) + // if user, exists := c.Get("username"); exists { + // operator = user.(string) + // } + + // 生成随机盐值 + salt, err := utils.GenerateRandomSalt() + if err != nil { + authBaseController.HandleInternalError(c, "生成盐值失败", err) + return + } + + // 使用盐值哈希密码 + hash, err := utils.HashPasswordWithSalt(v, salt) + if err != nil { + authBaseController.HandleInternalError(c, "密码哈希失败", err) + return + } + + // 更新 salt 设置项(如果不存在则创建) + var saltSetting models.Settings + if err := db.Where("name = ?", "admin_password_salt").First(&saltSetting).Error; err != nil { + saltSetting = models.Settings{Name: "admin_password_salt", Value: salt} + if err := db.Create(&saltSetting).Error; err != nil { + logrus.WithError(err).Error("创建admin_password_salt失败") + authBaseController.HandleInternalError(c, "保存盐值失败", err) + return + } + } else { + if err := db.Model(&saltSetting).Update("value", salt).Error; err != nil { + logrus.WithError(err).Error("更新admin_password_salt失败") + authBaseController.HandleInternalError(c, "更新盐值失败", err) + return + } + } + + // 将盐值相关的缓存键加入清理列表 + keysToDel = append(keysToDel, "setting:admin_password_salt") + + // 将当前处理的值替换为哈希后的密码 + v = hash + } + var s models.Settings if err := db.Where("name = ?", k).First(&s).Error; err != nil { // 不存在则创建 s = models.Settings{Name: k, Value: v} if err := db.Create(&s).Error; err != nil { logrus.WithError(err).WithField("setting_name", k).Error("创建设置失败") - settingsBaseController.HandleInternalError(c, fmt.Sprintf("保存设置 %s 失败", k), err) + authBaseController.HandleInternalError(c, fmt.Sprintf("保存设置 %s 失败", k), err) return } @@ -127,7 +186,7 @@ func SettingsUpdateHandler(c *gin.Context) { // 存在则更新 if err := db.Model(&models.Settings{}).Where("id = ?", s.ID).Update("value", v).Error; err != nil { logrus.WithError(err).WithField("setting_name", k).Error("更新设置失败") - settingsBaseController.HandleInternalError(c, fmt.Sprintf("更新设置 %s 失败", k), err) + authBaseController.HandleInternalError(c, fmt.Sprintf("更新设置 %s 失败", k), err) return } @@ -137,10 +196,60 @@ func SettingsUpdateHandler(c *gin.Context) { } // 删除Redis缓存键(如果Redis不可用则静默跳过) - _ = utils.RedisDel(context.Background(), keysToDel...) + _ = utils.RedisDel(c.Request.Context(), keysToDel...) // 刷新内存中的设置缓存,保证后续读取一致 services.GetSettingsService().RefreshCache() - settingsBaseController.HandleSuccess(c, "保存成功", nil) + authBaseController.HandleSuccess(c, "保存成功", nil) +} + +// validateSettingValue 验证设置项值的合法性 +func validateSettingValue(key, value string) error { + switch key { + case "jwt_refresh": + // 验证JWT刷新时间:至少1小时 + hours, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("JWT刷新阈值必须是整数") + } + if hours < 1 { + return fmt.Errorf("JWT刷新阈值必须至少为1小时") + } + case "jwt_expire": + // 验证JWT有效期:至少1小时 + hours, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("JWT有效期必须是整数") + } + if hours < 1 { + return fmt.Errorf("JWT有效期必须至少为1小时") + } + } + return nil +} + +// SettingsGenerateKeyHandler 生成安全密钥API +// - type: "jwt" 或 "encryption" +func SettingsGenerateKeyHandler(c *gin.Context) { + keyType := c.Query("type") + var key string + var err error + + switch keyType { + case "jwt": + key, err = config.GenerateSecureJWTSecret() + case "encryption": + key, err = config.GenerateSecureEncryptionKey() + default: + authBaseController.HandleValidationError(c, "无效的密钥类型") + return + } + + if err != nil { + authBaseController.HandleInternalError(c, "生成密钥失败: "+err.Error(), err) + return + } + + authBaseController.HandleSuccess(c, "生成成功", map[string]string{"key": key}) } diff --git a/controllers/admin/user.go b/controllers/admin/user.go deleted file mode 100644 index 1ddcfff..0000000 --- a/controllers/admin/user.go +++ /dev/null @@ -1,282 +0,0 @@ -package admin - -import ( - "net/http" - "networkDev/controllers" - "networkDev/models" - "networkDev/utils" - "strings" - - "github.com/gin-gonic/gin" -) - -// ============================================================================ -// 全局变量 -// ============================================================================ - -// 创建基础控制器实例 -var baseController = controllers.NewBaseController() - -// ============================================================================ -// 页面处理器 -// ============================================================================ - -// UserFragmentHandler 个人资料片段渲染 -// - 渲染个人资料与修改密码表单 -func UserFragmentHandler(c *gin.Context) { - c.HTML(http.StatusOK, "user.html", gin.H{}) -} - -// ============================================================================ -// API处理器 -// ============================================================================ - -// UserProfileQueryHandler 获取当前登录管理员的用户名 -// - 返回 JSON: {username} -// - 直接从JWT获取用户名信息 -func UserProfileQueryHandler(c *gin.Context) { - claims, _, err := GetCurrentAdminUserWithRefresh(c) - if err != nil { - baseController.HandleValidationError(c, "未登录或会话已过期") - return - } - - baseController.HandleSuccess(c, "ok", gin.H{ - "username": claims.Username, - }) -} - -// UserPasswordUpdateHandler 修改当前登录管理员的密码 -// - 接收 JSON: {old_password, new_password, confirm_password} -// - 校验旧密码正确性、新密码与确认一致性 -// - 成功后更新密码哈希 -// - 自动刷新接近过期的JWT令牌 -func UserPasswordUpdateHandler(c *gin.Context) { - claims, _, err := GetCurrentAdminUserWithRefresh(c) - if err != nil { - baseController.HandleValidationError(c, "未登录或会话已过期") - return - } - - var body struct { - OldPassword string `json:"old_password"` - NewPassword string `json:"new_password"` - ConfirmPassword string `json:"confirm_password"` - } - - if !baseController.BindJSON(c, &body) { - return - } - - // 基础校验 - if !baseController.ValidateRequired(c, map[string]interface{}{ - "旧密码": body.OldPassword, - "新密码": body.NewPassword, - "确认密码": body.ConfirmPassword, - }) { - return - } - - if len(body.NewPassword) < 6 { - baseController.HandleValidationError(c, "新密码长度不能少于6位") - return - } - if body.NewPassword != body.ConfirmPassword { - baseController.HandleValidationError(c, "两次输入的新密码不一致") - return - } - if body.NewPassword == body.OldPassword { - baseController.HandleValidationError(c, "新密码不能与旧密码相同") - return - } - - // 注释:由于使用了AdminAuthRequired中间件,已确保是管理员用户 - - // 获取数据库连接 - db, ok := baseController.GetDB(c) - if !ok { - return - } - - // 通过前缀匹配一次性获取所有管理员相关设置 - var adminSettings []models.Settings - if err = db.Where("name LIKE ?", "admin_%").Find(&adminSettings).Error; err != nil { - baseController.HandleInternalError(c, "获取管理员设置失败", err) - return - } - - // 将设置转换为map便于查找 - settingsMap := make(map[string]string) - for _, setting := range adminSettings { - settingsMap[setting.Name] = setting.Value - } - - // 检查必要的设置是否存在 - adminPassword, hasPassword := settingsMap["admin_password"] - adminPasswordSalt, hasSalt := settingsMap["admin_password_salt"] - if !hasPassword || !hasSalt { - baseController.HandleInternalError(c, "管理员密码设置不完整", nil) - return - } - - // 校验旧密码 - if !utils.VerifyPasswordWithSalt(body.OldPassword, adminPasswordSalt, adminPassword) { - baseController.HandleValidationError(c, "旧密码不正确") - return - } - - // 生成新的密码盐值 - newSalt, err := utils.GenerateRandomSalt() - if err != nil { - baseController.HandleInternalError(c, "生成密码盐失败", err) - return - } - - // 生成新密码哈希 - newPasswordHash, err := utils.HashPasswordWithSalt(body.NewPassword, newSalt) - if err != nil { - baseController.HandleInternalError(c, "生成密码哈希失败", err) - return - } - - // 更新settings中的管理员密码和盐值 - if err = db.Model(&models.Settings{}).Where("name = ?", "admin_password").Update("value", newPasswordHash).Error; err != nil { - baseController.HandleInternalError(c, "更新密码失败", err) - return - } - if err = db.Model(&models.Settings{}).Where("name = ?", "admin_password_salt").Update("value", newSalt).Error; err != nil { - baseController.HandleInternalError(c, "更新密码盐值失败", err) - return - } - - // 重新生成JWT令牌(包含新的密码哈希摘要) - adminUser := models.User{ - Username: claims.Username, - Password: newPasswordHash, - PasswordSalt: newSalt, - } - newToken, err := generateJWTTokenForAdmin(adminUser) - if err != nil { - baseController.HandleInternalError(c, "生成新令牌失败", err) - return - } - - // 更新Cookie(使用安全配置) - c.SetCookie("admin_session", newToken, utils.GetDefaultCookieMaxAge(), "/", "", false, true) - - // 密码修改成功,已重新生成JWT令牌 - baseController.HandleSuccess(c, "密码修改成功", nil) -} - -// UserProfileUpdateHandler 修改当前登录管理员的用户名 -// - 接收 JSON: {username} -// - 校验用户名非空、长度与唯一性 -// - 更新数据库后重新签发JWT并写入 Cookie,保持前端展示的一致性 -// - 自动刷新接近过期的JWT令牌 -func UserProfileUpdateHandler(c *gin.Context) { - _, _, err := GetCurrentAdminUserWithRefresh(c) - if err != nil { - baseController.HandleValidationError(c, "未登录或会话已过期") - return - } - - var body struct { - Username string `json:"username"` - OldPassword string `json:"old_password"` - } - if !baseController.BindJSON(c, &body) { - return - } - - username := strings.TrimSpace(body.Username) - if username == "" { - baseController.HandleValidationError(c, "用户名不能为空") - return - } - if len(username) > 64 { - baseController.HandleValidationError(c, "用户名长度不能超过64字符") - return - } - - db, ok := baseController.GetDB(c) - if !ok { - return - } - - // 注释:由于使用了AdminAuthRequired中间件,已确保是管理员用户 - - // 获取所有管理员相关设置 - var adminSettings []models.Settings - if dbErr := db.Where("name LIKE ?", "admin_%").Find(&adminSettings).Error; dbErr != nil { - baseController.HandleInternalError(c, "获取管理员设置失败", dbErr) - return - } - - // 转换为map便于查找 - settingsMap := make(map[string]string) - for _, setting := range adminSettings { - settingsMap[setting.Name] = setting.Value - } - - adminUsername, exists := settingsMap["admin_username"] - if !exists { - baseController.HandleInternalError(c, "管理员用户名设置不存在", nil) - return - } - - adminPassword, exists := settingsMap["admin_password"] - if !exists { - baseController.HandleInternalError(c, "管理员密码设置不存在", nil) - return - } - - adminPasswordSalt, exists := settingsMap["admin_password_salt"] - if !exists { - baseController.HandleInternalError(c, "管理员密码盐值设置不存在", nil) - return - } - - // 如果用户名未变化则直接返回成功(无需校验旧密码) - if strings.EqualFold(username, adminUsername) { - baseController.HandleSuccess(c, "保存成功", gin.H{ - "username": username, - }) - return - } - - // 修改用户名需要进行当前密码校验 - if strings.TrimSpace(body.OldPassword) == "" { - baseController.HandleValidationError(c, "修改用户名需要提供当前密码") - return - } - - // 使用盐值验证当前密码 - if !utils.VerifyPasswordWithSalt(body.OldPassword, adminPasswordSalt, adminPassword) { - baseController.HandleValidationError(c, "当前密码不正确") - return - } - - // 更新管理员用户名设置 - if dbErr := db.Model(&models.Settings{}).Where("name = ?", "admin_username").Update("value", username).Error; dbErr != nil { - baseController.HandleInternalError(c, "更新管理员用户名失败", dbErr) - return - } - - // 重新签发JWT并写入Cookie - // 创建虚拟用户对象用于生成JWT令牌 - adminUser := models.User{ - Username: username, // 使用新的用户名 - Password: adminPassword, - PasswordSalt: adminPasswordSalt, - } - token, err := generateJWTTokenForAdmin(adminUser) - if err != nil { - baseController.HandleInternalError(c, "生成新令牌失败", err) - return - } - c.SetCookie("admin_session", token, utils.GetDefaultCookieMaxAge(), "/", "", false, true) - - baseController.HandleSuccess(c, "保存成功", gin.H{ - "username": username, - }) -} diff --git a/controllers/admin/utils.go b/controllers/admin/utils.go new file mode 100644 index 0000000..18bc078 --- /dev/null +++ b/controllers/admin/utils.go @@ -0,0 +1,7 @@ +package admin + +import ( + "NetworkAuth/controllers" +) + +var base = controllers.NewBaseController() diff --git a/controllers/admin/variable.go b/controllers/admin/variable.go index 83187b4..983cfd5 100644 --- a/controllers/admin/variable.go +++ b/controllers/admin/variable.go @@ -1,9 +1,9 @@ package admin import ( + "NetworkAuth/controllers" + "NetworkAuth/models" "net/http" - "networkDev/controllers" - "networkDev/models" "regexp" "strconv" "strings" @@ -319,7 +319,7 @@ func VariableDeleteHandler(c *gin.Context) { return } - logrus.WithField("variable_id", req.ID).Info("Successfully deleted variable") + logrus.WithField("variable_id", req.ID).Debug("Successfully deleted variable") variableBaseController.HandleSuccess(c, "删除成功", nil) } @@ -351,7 +351,7 @@ func VariablesBatchDeleteHandler(c *gin.Context) { return } - logrus.WithField("variable_ids", req.IDs).Info("Successfully batch deleted variables") + logrus.WithField("variable_ids", req.IDs).Debug("Successfully batch deleted variables") variableBaseController.HandleSuccess(c, "批量删除成功", nil) } diff --git a/controllers/base.go b/controllers/base.go index c496c0e..c6bc2f0 100644 --- a/controllers/base.go +++ b/controllers/base.go @@ -4,7 +4,7 @@ import ( "net/http" "strconv" - "networkDev/database" + "NetworkAuth/database" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -15,6 +15,7 @@ import ( // ============================================================================ // BaseController 基础控制器结构体 +// 提供通用的数据库访问和响应处理方法 type BaseController struct{} // ============================================================================ @@ -86,11 +87,18 @@ func (bc *BaseController) HandleInternalError(c *gin.Context, message string, er // HandleSuccess 统一处理成功响应 func (bc *BaseController) HandleSuccess(c *gin.Context, message string, data interface{}) { - c.JSON(http.StatusOK, gin.H{ + resp := gin.H{ "code": 0, "msg": message, "data": data, - }) + } + + // 检查是否有刷新的Token + if newToken, exists := c.Get("new_token"); exists { + resp["token"] = newToken + } + + c.JSON(http.StatusOK, resp) } // HandleCreated 统一处理创建成功响应 @@ -173,9 +181,9 @@ func (bc *BaseController) BindURI(c *gin.Context, obj interface{}) bool { // 返回包含系统基础信息的数据映射,包括站点标题、页脚文本、备案信息等 func (bc *BaseController) GetDefaultTemplateData() gin.H { return gin.H{ - "Title": "凌动技术", - "SystemName": "网络验证系统", - "FooterText": "© 2025 凌动技术 保留所有权利", + "Title": "NetworkAuth", + "SystemName": "NetworkAuth", + "FooterText": "© 2026 NetworkAuth 保留所有权利", "ICPRecord": "", "ICPRecordLink": "https://beian.miit.gov.cn", "PSBRecord": "", diff --git a/controllers/default/handlers.go b/controllers/default/handlers.go new file mode 100644 index 0000000..934d63a --- /dev/null +++ b/controllers/default/handlers.go @@ -0,0 +1,32 @@ +package default_ctrl + +import ( + "NetworkAuth/services" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +// RootHandler 根路径处理器 +// 使用模板渲染服务器信息页面 +func RootHandler(c *gin.Context) { + // 获取设置服务 + settings := services.GetSettingsService() + + // 传递模板数据 + data := map[string]interface{}{ + "Title": settings.GetString("site_title", "NetworkAuth Server"), + "Keywords": settings.GetString("site_keywords", ""), + "Description": settings.GetString("site_description", ""), + "SystemName": "系统提醒", // 对应 H1 + "WarningText": "🚫 未授权,拒绝访问", + "InfoText": "💬 如有问题,请联系网站管理员", + "FooterText": settings.GetString("footer_text", "Copyright © 2026 NetworkAuth. All Rights Reserved."), + "ICPRecord": settings.GetString("icp_record", ""), + "ICPRecordLink": settings.GetString("icp_record_link", "https://beian.miit.gov.cn"), + "CurrentYear": time.Now().Year(), + } + + c.HTML(http.StatusOK, "index.html", data) +} diff --git a/controllers/home/home.go b/controllers/home/home.go deleted file mode 100644 index 76df9c0..0000000 --- a/controllers/home/home.go +++ /dev/null @@ -1,59 +0,0 @@ -package home - -import ( - "net/http" - "networkDev/controllers" - "networkDev/database" - "networkDev/services" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" -) - -// ============================================================================ -// 全局变量 -// ============================================================================ - -var homeBaseController = controllers.NewBaseController() - -// ============================================================================ -// 辅助函数 -// ============================================================================ - -// getSettingValue 获取配置值,优先从数据库获取,不存在时使用默认值 -func getSettingValue(settingName string, defaultValue string, db *gorm.DB) string { - if setting, err := services.FindSettingByName(settingName, db); err == nil { - return setting.Value - } - return defaultValue -} - -// ============================================================================ -// 页面处理器 -// ============================================================================ - -// RootHandler 主页处理器 -func RootHandler(c *gin.Context) { - // 获取数据库连接 - db, err := database.GetDB() - if err != nil { - c.HTML(http.StatusInternalServerError, "error.html", gin.H{ - "error": "数据库连接失败", - }) - return - } - - // 获取默认模板数据 - data := homeBaseController.GetDefaultTemplateData() - - // 从数据库读取设置,优先使用数据库配置,不存在时使用默认值 - data["SystemName"] = getSettingValue("site_title", data["SystemName"].(string), db) - data["FooterText"] = getSettingValue("footer_text", data["FooterText"].(string), db) - data["ICPRecord"] = getSettingValue("icp_record", data["ICPRecord"].(string), db) - data["ICPRecordLink"] = getSettingValue("icp_record_link", data["ICPRecordLink"].(string), db) - data["PSBRecord"] = getSettingValue("psb_record", data["PSBRecord"].(string), db) - data["PSBRecordLink"] = getSettingValue("psb_record_link", data["PSBRecordLink"].(string), db) - data["title"] = "主页" - - c.HTML(http.StatusOK, "index.html", data) -} diff --git a/controllers/install/install.go b/controllers/install/install.go new file mode 100644 index 0000000..ac14bfc --- /dev/null +++ b/controllers/install/install.go @@ -0,0 +1,122 @@ +package install + +import ( + "NetworkAuth/config" + "NetworkAuth/database" + "NetworkAuth/models" + "NetworkAuth/services" + "NetworkAuth/utils" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// InstallPageHandler 渲染安装页面 +func InstallPageHandler(c *gin.Context) { + // 由于前端是通过模板渲染的,我们返回一个安装页面 + c.HTML(http.StatusOK, "install.html", gin.H{ + "title": "NetworkAuth 系统初始化", + }) +} + +// InstallSubmitHandler 处理安装表单提交 +func InstallSubmitHandler(c *gin.Context) { + var req struct { + // 数据库配置 + DbType string `json:"db_type" binding:"required,oneof=sqlite mysql"` + DbHost string `json:"db_host"` + DbPort int `json:"db_port"` + DbName string `json:"db_name"` + DbUser string `json:"db_user"` + DbPass string `json:"db_pass"` + + // 站点和管理员配置 + SiteTitle string `json:"site_title" binding:"required"` + AdminUsername string `json:"admin_username" binding:"required"` + AdminPassword string `json:"admin_password" binding:"required,min=6"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 1, "msg": "参数错误: " + err.Error()}) + return + } + + // 1. 更新配置文件 + err := config.UpdateConfig(func(cfg *config.AppConfig) { + cfg.Database.Type = req.DbType + if req.DbType == "mysql" { + cfg.Database.MySQL.Host = req.DbHost + cfg.Database.MySQL.Port = req.DbPort + cfg.Database.MySQL.Database = req.DbName + cfg.Database.MySQL.Username = req.DbUser + cfg.Database.MySQL.Password = req.DbPass + } + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": "更新配置文件失败: " + err.Error()}) + return + } + + // 2. 重新初始化数据库连接并执行迁移 + db, err := database.ReInit() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": "连接数据库失败: " + err.Error()}) + return + } + + if db == nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": "获取数据库实例失败"}) + return + } + + // 强制执行迁移确保表存在 + if err := database.AutoMigrate(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": "初始化数据表失败: " + err.Error()}) + return + } + + // 初始化系统默认设置 + database.SeedDefaultSettings() + + // 3. 生成新的管理员密码哈希和盐值 + adminSalt, err := utils.GenerateRandomSalt() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": "生成盐值失败"}) + return + } + adminPasswordHash, err := utils.HashPasswordWithSalt(req.AdminPassword, adminSalt) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": "加密密码失败"}) + return + } + + // 4. 更新设置表 + settingsToUpdate := map[string]string{ + "site_title": req.SiteTitle, + "admin_username": strings.TrimSpace(req.AdminUsername), + "admin_password": adminPasswordHash, + "admin_password_salt": adminSalt, + "is_installed": "1", // 标记为已安装 + } + + // 开启事务进行更新 + tx := db.Begin() + for name, value := range settingsToUpdate { + // 先尝试更新,如果没有该记录,则忽略(因为 AutoMigrate 已经创建了默认记录) + if err := tx.Model(&models.Settings{}).Where("name = ?", name).Update("value", value).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"code": 1, "msg": "保存设置失败: " + name}) + return + } + } + tx.Commit() + + // 5. 更新内存缓存 + settingsService := services.GetSettingsService() + for name, value := range settingsToUpdate { + settingsService.Set(name, value) + } + + c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "安装成功"}) +} diff --git a/database/database.go b/database/database.go index d3f6f98..8989503 100644 --- a/database/database.go +++ b/database/database.go @@ -1,15 +1,19 @@ package database import ( + "NetworkAuth/utils" "fmt" - "networkDev/utils" + "log" + "os" "sync" + "time" "github.com/glebarez/sqlite" "github.com/sirupsen/logrus" "github.com/spf13/viper" "gorm.io/driver/mysql" "gorm.io/gorm" + gLogger "gorm.io/gorm/logger" ) // ============================================================================ @@ -33,40 +37,7 @@ var ( func Init() (*gorm.DB, error) { var initErr error once.Do(func() { - dbType := viper.GetString("database.type") - switch dbType { - case "mysql": - initErr = initMySQL() - default: - initErr = initSQLite() - } - - // 如果数据库初始化成功,配置连接池和启动健康检查 - if initErr == nil && dbInstance != nil { - // 加载数据库配置 - var configPrefix string - if dbType == "mysql" { - configPrefix = "database.mysql" - } else { - configPrefix = "database.sqlite" - } - - dbConfig := utils.LoadDatabaseConfig(configPrefix) - - // 验证配置 - if err := utils.ValidateDatabaseConfig(dbConfig); err != nil { - logrus.WithError(err).Warn("数据库配置验证失败,使用默认配置") - dbConfig = utils.GetDefaultDatabaseConfig() - } - - // 配置连接池 - if err := utils.ConfigureConnectionPool(dbInstance, dbConfig); err != nil { - logrus.WithError(err).Error("配置数据库连接池失败") - } - - // 启动健康检查 - utils.StartHealthCheck(dbInstance, dbConfig) - } + initErr = performInit() }) return dbInstance, initErr } @@ -80,6 +51,89 @@ func GetDB() (*gorm.DB, error) { return Init() } +// ReInit 重新初始化数据库连接 +// 用于在修改配置后重新连接数据库 +func ReInit() (*gorm.DB, error) { + // 如果已有连接,尝试关闭它 + if dbInstance != nil { + if sqlDB, err := dbInstance.DB(); err == nil { + sqlDB.Close() + } + } + dbInstance = nil + + // 重新执行初始化逻辑(不经过 once.Do) + return dbInstance, performInit() +} + +func performInit() error { + // 检查是否已经有配置文件(通过检查文件是否存在) + configFile := viper.ConfigFileUsed() + // 如果 viper 没有使用配置文件(可能是因为没找到文件而使用了默认配置), + // 或者配置文件路径为空,我们应该假设处于未安装状态。 + // 但 viper.ConfigFileUsed() 在 ReadInConfig 成功后会返回文件名。 + // 如果 ReadInConfig 失败(因为文件不存在),viper 可能会返回空或者我们在 config.go 中设置的路径。 + + // 在 config.go 中,如果文件不存在,我们加载了默认配置但没有写文件。 + // 此时 viper.ConfigFileUsed() 可能是空的或者我们设置的路径。 + // 让我们检查该路径对应的文件是否存在。 + + if configFile == "" { + configFile = "config.json" + } + + _, err := os.Stat(configFile) + isConfigExists := !os.IsNotExist(err) + + // 如果配置文件不存在,说明还没有经过安装初始化,暂时不连接数据库 + if !isConfigExists { + logrus.Info("尚未初始化配置,跳过数据库连接") + return nil + } + + var initErr error + dbType := viper.GetString("database.type") + switch dbType { + case "mysql": + initErr = initMySQL() + default: + initErr = initSQLite() + } + + // 如果数据库初始化成功,配置连接池和启动健康检查 + if initErr == nil && dbInstance != nil { + // 加载数据库配置 + var configPrefix string + if dbType == "mysql" { + configPrefix = "database.mysql" + } else { + configPrefix = "database.sqlite" + } + + dbConfig := utils.LoadDatabaseConfig(configPrefix) + + // 验证配置 + if err := utils.ValidateDatabaseConfig(dbConfig); err != nil { + logrus.WithError(err).Warn("数据库配置验证失败,使用默认配置") + dbConfig = utils.GetDefaultDatabaseConfig() + } + + // 配置连接池 + if err := utils.ConfigureConnectionPool(dbInstance, dbConfig); err != nil { + logrus.WithError(err).Error("配置数据库连接池失败") + } + + // 启动健康检查 + utils.StartHealthCheck(dbInstance, dbConfig) + } + return initErr +} + +// SetDB 设置全局 *gorm.DB 实例(用于测试) +func SetDB(db *gorm.DB) { + dbInstance = db +} + // ============================================================================ // 私有函数 // ============================================================================ @@ -89,10 +143,20 @@ func GetDB() (*gorm.DB, error) { func initSQLite() error { path := viper.GetString("database.sqlite.path") if path == "" { - path = "./database.db" + path = "./recharge.db" } dsn := fmt.Sprintf("file:%s?cache=shared&_busy_timeout=5000&_fk=1", path) - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + var logLevel gLogger.LogLevel + switch viper.GetString("logger.level") { + case "debug": + logLevel = gLogger.Info + case "error": + logLevel = gLogger.Error + default: + logLevel = gLogger.Warn + } + gl := gLogger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), gLogger.Config{SlowThreshold: 2 * time.Second, LogLevel: logLevel, IgnoreRecordNotFoundError: true, Colorful: false}) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{Logger: gl}) if err != nil { logrus.WithError(err).Error("SQLite 初始化失败") return err @@ -124,7 +188,17 @@ func initMySQL() error { } dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", user, pass, host, port, dbname, charset) - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + var logLevel gLogger.LogLevel + switch viper.GetString("logger.level") { + case "debug": + logLevel = gLogger.Info + case "error": + logLevel = gLogger.Error + default: + logLevel = gLogger.Warn + } + gl := gLogger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), gLogger.Config{SlowThreshold: 2 * time.Second, LogLevel: logLevel, IgnoreRecordNotFoundError: true, Colorful: false}) + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: gl}) if err != nil { logrus.WithError(err).Error("MySQL 初始化失败") return err diff --git a/database/migrate.go b/database/migrate.go index 6aadfb2..46d43b5 100644 --- a/database/migrate.go +++ b/database/migrate.go @@ -1,180 +1,32 @@ -package database - -import ( - "fmt" - "networkDev/models" - "strings" - - "github.com/sirupsen/logrus" - "gorm.io/gorm" -) - -// ============================================================================ -// 公共函数 -// ============================================================================ - -// AutoMigrate 自动迁移数据库模型 -// - 会确保必要的数据表结构存在 -// - 不会破坏已有数据 -func AutoMigrate() error { - db, err := GetDB() - if err != nil { - return err - } - if err := db.AutoMigrate(&models.User{}, &models.Settings{}, &models.App{}, &models.API{}, &models.Variable{}, &models.Function{}); err != nil { - logrus.WithError(err).Error("AutoMigrate 执行失败") - return err - } - - // 兼容迁移:如果 users.password_salt 列长度 < 64,则扩大到 64 - if err := ensureUserPasswordSaltLength(db); err != nil { - logrus.WithError(err).Error("调整 users.password_salt 列长度失败") - return err - } - - // 兼容迁移:确保 tasks.verification_code 字段类型为 LONGTEXT 以支持大图片数据 - if err := ensureVerificationCodeType(db); err != nil { - logrus.WithError(err).Error("调整 tasks.verification_code 字段类型失败") - return err - } - - logrus.Info("AutoMigrate 执行完成") - return nil -} - -// ============================================================================ -// 私有函数 -// ============================================================================ - -// ensureVerificationCodeType 确保tasks.verification_code字段类型为LONGTEXT以支持大图片数据 -// 中文注释:检查并修改verification_code字段类型,支持Base64编码的大图片数据存储 -func ensureVerificationCodeType(db *gorm.DB) error { - // 获取数据库方言类型 - dialector := db.Dialector.Name() - - // 根据不同数据库类型执行不同的检查逻辑 - switch dialector { - case "mysql": - // MySQL/MariaDB使用INFORMATION_SCHEMA - var result struct { - ColumnName string `gorm:"column:COLUMN_NAME"` - ColumnType string `gorm:"column:COLUMN_TYPE"` - } - - err := db.Raw("SELECT COLUMN_NAME, COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1", - "tasks", "verification_code").Scan(&result).Error - - if err != nil { - return nil // 查询失败则跳过 - } - - // 检查列类型,如果不是LONGTEXT则修改 - if !strings.Contains(strings.ToLower(result.ColumnType), "longtext") { - alterSQL := "ALTER TABLE tasks MODIFY COLUMN verification_code LONGTEXT" - if err := db.Exec(alterSQL).Error; err != nil { - return fmt.Errorf("修改verification_code字段类型失败: %v", err) - } - logrus.Info("verification_code字段类型已更新为LONGTEXT") - } - case "sqlite": - // SQLite使用pragma_table_info检查列信息 - var columns []struct { - CID int `gorm:"column:cid"` - Name string `gorm:"column:name"` - Type string `gorm:"column:type"` - NotNull int `gorm:"column:notnull"` - DfltValue *string `gorm:"column:dflt_value"` - PK int `gorm:"column:pk"` - } - - err := db.Raw("PRAGMA table_info(tasks)").Scan(&columns).Error - if err != nil { - return nil // 查询失败则跳过 - } - - // 查找verification_code列 - for _, col := range columns { - if col.Name == "verification_code" { - // SQLite中,如果列类型不是TEXT,需要重建表 - if !strings.Contains(strings.ToLower(col.Type), "text") { - // SQLite不支持直接修改列类型,但GORM的AutoMigrate会处理这种情况 - logrus.Info("SQLite检测到verification_code字段类型需要更新,依赖GORM AutoMigrate处理") - } - break - } - } - default: - // 其他数据库类型暂不处理 - logrus.Infof("数据库类型 %s 暂不支持verification_code字段类型检查", dialector) - } - - return nil -} - -// ensureUserPasswordSaltLength 确保users.password_salt列长度至少为64 -// 中文注释:检查并修改password_salt列长度,兼容32字节(64十六进制字符)的盐值 -func ensureUserPasswordSaltLength(db *gorm.DB) error { - // 获取数据库方言类型 - dialector := db.Dialector.Name() - - // 根据不同数据库类型执行不同的检查逻辑 - switch dialector { - case "mysql": - // MySQL/MariaDB使用INFORMATION_SCHEMA - var result struct { - ColumnName string `gorm:"column:COLUMN_NAME"` - ColumnType string `gorm:"column:COLUMN_TYPE"` - } - - err := db.Raw("SELECT COLUMN_NAME, COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1", - "users", "password_salt").Scan(&result).Error - - if err != nil { - return nil // 查询失败则跳过 - } - - // 检查列类型,如果长度小于64则修改 - if strings.Contains(strings.ToLower(result.ColumnType), "varchar") { - if strings.Contains(result.ColumnType, "(32)") || strings.Contains(result.ColumnType, "(16)") { - alterSQL := "ALTER TABLE users MODIFY COLUMN password_salt VARCHAR(64)" - if err := db.Exec(alterSQL).Error; err != nil { - return fmt.Errorf("修改password_salt列长度失败: %v", err) - } - logrus.Info("password_salt列长度已更新为64") - } - } - case "sqlite": - // SQLite使用pragma_table_info检查列信息 - var columns []struct { - CID int `gorm:"column:cid"` - Name string `gorm:"column:name"` - Type string `gorm:"column:type"` - NotNull int `gorm:"column:notnull"` - DfltValue *string `gorm:"column:dflt_value"` - PK int `gorm:"column:pk"` - } - - err := db.Raw("PRAGMA table_info(users)").Scan(&columns).Error - if err != nil { - return nil // 查询失败则跳过 - } - - // 查找password_salt列 - for _, col := range columns { - if col.Name == "password_salt" { - // SQLite中,如果列类型包含长度限制且小于64,需要重建表 - if strings.Contains(strings.ToLower(col.Type), "varchar(32)") || - strings.Contains(strings.ToLower(col.Type), "varchar(16)") { - // SQLite不支持直接修改列类型,但GORM的AutoMigrate会处理这种情况 - logrus.Info("SQLite检测到password_salt列长度需要更新,依赖GORM AutoMigrate处理") - } - break - } - } - default: - // 其他数据库类型暂不处理 - logrus.Infof("数据库类型 %s 暂不支持password_salt列长度检查", dialector) - } - - return nil -} +package database + +import ( + "NetworkAuth/models" + + "github.com/sirupsen/logrus" +) + +// AutoMigrate 自动迁移数据库模型 +// - 会确保必要的数据表结构存在 +// - 不会破坏已有数据 +func AutoMigrate() error { + db, err := GetDB() + if err != nil { + return err + } + if err := db.AutoMigrate( + &models.Settings{}, + &models.OperationLog{}, + &models.LoginLog{}, + &models.App{}, + &models.API{}, + &models.Function{}, + &models.Variable{}, + &models.User{}, + ); err != nil { + logrus.WithError(err).Error("AutoMigrate 执行失败") + return err + } + logrus.Info("AutoMigrate 执行完成") + return nil +} diff --git a/database/settings.go b/database/settings.go index 42ccffd..818084d 100644 --- a/database/settings.go +++ b/database/settings.go @@ -1,186 +1,232 @@ -package database - -import ( - "networkDev/models" - "networkDev/utils" - - "github.com/sirupsen/logrus" - "gorm.io/gorm" -) - -// ============================================================================ -// 公共函数 -// ============================================================================ - -// SeedDefaultSettings 初始化默认系统设置 -// - 检查各项设置是否已存在,如不存在则创建默认值 -// - 包含站点基本信息、SEO设置等常用配置项 -func SeedDefaultSettings() error { - db, err := GetDB() - if err != nil { - return err - } - - // 定义默认设置项 - defaultSettings := []models.Settings{ - { - Name: "site_title", - Value: "凌动技术", - Description: "网站标题,显示在浏览器标题栏和页面顶部", - }, - { - Name: "site_keywords", - Value: "验证,网络,管理系统,网络验证,卡密管理,账户管理", - Description: "网站关键词,用于SEO优化,多个关键词用逗号分隔", - }, - { - Name: "site_description", - Value: "专业的网络验证管理系统,提供便捷的在线网络验证服务和设备管理功能", - Description: "网站描述,用于SEO优化和社交媒体分享", - }, - { - Name: "site_logo", - Value: "/favicon.ico", - Description: "网站Logo图片路径", - }, - { - Name: "contact_email", - Value: "admin@example.com", - Description: "联系邮箱,用于客服和业务咨询", - }, - { - Name: "max_upload_size", - Value: "10485760", - Description: "文件上传最大尺寸(字节),默认10MB", - }, - { - Name: "default_user_role", - Value: "1", - Description: "新用户默认角色,0=管理员,1=普通用户", - }, - { - Name: "session_timeout", - Value: "3600", - Description: "会话超时时间(秒),默认1小时", - }, - { - Name: "maintenance_mode", - Value: "0", - Description: "维护模式,0=关闭维护模式,1=开启维护模式", - }, - // ===== 管理员账号相关默认项 ===== - { - Name: "admin_username", - Value: "admin", - Description: "管理员用户名", - }, - { - Name: "admin_password", - Value: "", - Description: "管理员密码哈希值", - }, - { - Name: "admin_password_salt", - Value: "", - Description: "管理员密码加密盐值", - }, - // ===== 页脚与备案相关默认项 ===== - { - Name: "footer_text", - Value: "Copyright © 2025 凌动技术. All Rights Reserved.", - Description: "页脚展示的版权或说明信息", - }, - { - Name: "icp_record", - Value: "京ICP备12345678号", - Description: "ICP备案号,留空则不显示", - }, - { - Name: "icp_record_link", - Value: "https://beian.miit.gov.cn", - Description: "工信部ICP备案查询链接,留空则不显示", - }, - { - Name: "psb_record", - Value: "京公网安备 11000002000001号", - Description: "公安备案号,留空则不显示", - }, - { - Name: "psb_record_link", - Value: "https://www.beian.gov.cn/portal/registerSystemInfo?recordcode=11000002000001", - Description: "公安备案查询链接,留空则不显示", - }, - } - - // 逐个检查并创建不存在的设置项 - for _, setting := range defaultSettings { - var count int64 - if err := db.Model(&models.Settings{}).Where("name = ?", setting.Name).Count(&count).Error; err != nil { - return err - } - - if count == 0 { - if err := db.Create(&setting).Error; err != nil { - logrus.WithError(err).WithField("name", setting.Name).Error("创建默认设置失败") - return err - } - logrus.WithField("name", setting.Name).WithField("value", setting.Value).Debug("创建默认设置项") - } - } - - // 初始化默认管理员账号(如果密码为空) - if err := initDefaultAdmin(db); err != nil { - return err - } - - logrus.Info("默认系统设置初始化完成") - return nil -} - -// ============================================================================ -// 私有函数 -// ============================================================================ - -// initDefaultAdmin 初始化默认管理员账号 -// 如果admin_password为空,则生成默认密码admin123的哈希值 -func initDefaultAdmin(db *gorm.DB) error { - var passwordSetting models.Settings - if err := db.Where("name = ?", "admin_password").First(&passwordSetting).Error; err != nil { - logrus.WithError(err).Error("获取管理员密码设置失败") - return err - } - - // 如果密码已设置,跳过初始化 - if passwordSetting.Value != "" { - logrus.Debug("管理员密码已设置,跳过默认密码初始化") - return nil - } - - // 生成密码盐值 - salt, err := utils.GenerateRandomSalt() - if err != nil { - logrus.WithError(err).Error("生成密码盐值失败") - return err - } - - // 使用盐值生成密码哈希(默认密码:admin123) - hash, err := utils.HashPasswordWithSalt("admin123", salt) - if err != nil { - logrus.WithError(err).Error("生成密码哈希失败") - return err - } - - // 更新密码和盐值 - if err := db.Model(&models.Settings{}).Where("name = ?", "admin_password").Update("value", hash).Error; err != nil { - logrus.WithError(err).Error("更新管理员密码失败") - return err - } - - if err := db.Model(&models.Settings{}).Where("name = ?", "admin_password_salt").Update("value", salt).Error; err != nil { - logrus.WithError(err).Error("更新管理员密码盐值失败") - return err - } - - logrus.Info("默认管理员账号初始化完成,用户名: admin, 密码: admin123") - return nil -} +package database + +import ( + "NetworkAuth/config" + "NetworkAuth/models" + "NetworkAuth/utils" + + "github.com/sirupsen/logrus" +) + +// ============================================================================ +// 公共函数 +// ============================================================================ + +// SeedDefaultSettings 初始化默认系统设置 +// - 检查各项设置是否已存在,如不存在则创建默认值 +// - 包含站点基本信息、SEO设置等常用配置项 +func SeedDefaultSettings() error { + db, err := GetDB() + if err != nil { + return err + } + + // 生成安全的随机密钥 + jwtSecret, err := config.GenerateSecureJWTSecret() + if err != nil { + return err + } + encryptionKey, err := config.GenerateSecureEncryptionKey() + if err != nil { + return err + } + + // 生成默认管理员密码(admin123)的盐值和哈希 + // 这样可以确保admin_password和admin_password_salt在初始化时就有值 + adminSalt, err := utils.GenerateRandomSalt() + if err != nil { + return err + } + adminPasswordHash, err := utils.HashPasswordWithSalt("admin123", adminSalt) + if err != nil { + return err + } + + // 检查是否已有 admin_password,如果有,说明是旧版本升级,应该把 is_installed 默认设为 1 + var adminPwdCount int64 + db.Model(&models.Settings{}).Where("name = ?", "admin_password").Count(&adminPwdCount) + isInstalledDefault := "0" + if adminPwdCount > 0 { + isInstalledDefault = "1" + } + + // 定义默认设置项 + defaultSettings := []models.Settings{ + // ===== 系统安装状态 ===== + { + Name: "is_installed", + Value: isInstalledDefault, + Description: "系统是否已初始化安装,0=未安装,1=已安装", + }, + // ===== 管理员账号相关默认项 ===== + { + Name: "admin_username", + Value: "admin", + Description: "管理员用户名", + }, + { + Name: "admin_password", + Value: adminPasswordHash, + Description: "管理员密码哈希值", + }, + { + Name: "admin_password_salt", + Value: adminSalt, + Description: "管理员密码加密盐值", + }, + // ===== 系统和安全相关默认项 ===== + { + Name: "maintenance_mode", + Value: "0", + Description: "维护模式,0=关闭维护模式,1=开启维护模式", + }, + { + Name: "encryption_key", + Value: encryptionKey, + Description: "数据加密密钥", + }, + { + Name: "jwt_secret", + Value: jwtSecret, + Description: "JWT签名密钥", + }, + { + Name: "jwt_refresh", + Value: "6", + Description: "JWT令牌刷新阈值(小时)", + }, + { + Name: "jwt_expire", + Value: "24", + Description: "JWT令牌有效期(小时)", + }, + { + Name: "session_timeout", + Value: "3600", + Description: "会话超时时间(秒),默认1小时", + }, + { + Name: "max_upload_size", + Value: "10485760", + Description: "文件上传最大尺寸(字节),默认10MB", + }, + { + Name: "default_user_role", + Value: "1", + Description: "新用户默认角色,0=管理员,1=普通用户", + }, + // ===== 日志清理策略默认项 ===== + { + Name: "login_log_cleanup_days", + Value: "30", + Description: "登录日志保留天数(0表示不按天清理)", + }, + { + Name: "login_log_cleanup_limit", + Value: "10000", + Description: "登录日志保留条数(0表示不按数量清理)", + }, + { + Name: "operation_log_cleanup_days", + Value: "30", + Description: "操作日志保留天数(0表示不按天清理)", + }, + { + Name: "operation_log_cleanup_limit", + Value: "10000", + Description: "操作日志保留条数(0表示不按数量清理)", + }, + // ===== Cookie相关默认项 ===== + { + Name: "cookie_secure", + Value: "true", + Description: "Cookie Secure属性(是否只在HTTPS下发送)", + }, + { + Name: "cookie_same_site", + Value: "Lax", + Description: "Cookie SameSite属性(Strict/Lax/None)", + }, + { + Name: "cookie_domain", + Value: "", + Description: "Cookie域名", + }, + { + Name: "cookie_max_age", + Value: "86400", + Description: "Cookie最大存活时间(秒)", + }, + // ===== 站点基本信息默认项 ===== + { + Name: "site_title", + Value: "NetworkAuth", + Description: "网站标题,显示在浏览器标题栏和页面顶部", + }, + { + Name: "site_keywords", + Value: "NetworkAuth,鉴权,API管理,GoLang", + Description: "网站关键词,用于SEO优化,多个关键词用逗号分隔", + }, + { + Name: "site_description", + Value: "NetworkAuth 网络授权服务,专注于应用鉴权与接口管理", + Description: "网站描述,用于SEO优化和社交媒体分享", + }, + { + Name: "site_logo", + Value: "/static/logo.png", + Description: "网站Logo图片路径", + }, + { + Name: "contact_email", + Value: "admin@example.com", + Description: "联系邮箱,用于客服和业务咨询", + }, + // ===== 页脚与备案相关默认项 ===== + { + Name: "footer_text", + Value: "Copyright © 2026 NetworkAuth. All Rights Reserved.", + Description: "页脚展示的版权或说明信息", + }, + { + Name: "icp_record", + Value: "", + Description: "ICP备案号,留空则不显示", + }, + { + Name: "icp_record_link", + Value: "https://beian.miit.gov.cn", + Description: "工信部ICP备案查询链接,留空则不显示", + }, + { + Name: "psb_record", + Value: "", + Description: "公安备案号,留空则不显示", + }, + { + Name: "psb_record_link", + Value: "", + Description: "公安备案查询链接,留空则不显示", + }, + } + + // 逐个检查并创建不存在的设置项 + for _, setting := range defaultSettings { + var count int64 + if err := db.Model(&models.Settings{}).Where("name = ?", setting.Name).Count(&count).Error; err != nil { + return err + } + + if count == 0 { + if err := db.Create(&setting).Error; err != nil { + logrus.WithError(err).WithField("name", setting.Name).Error("创建默认设置失败") + return err + } + logrus.WithField("name", setting.Name).WithField("value", setting.Value).Debug("创建默认设置项") + } + } + + logrus.Info("默认系统设置初始化完成") + return nil +} diff --git a/go.mod b/go.mod index b6761b8..e74a761 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,19 @@ -module networkDev +module NetworkAuth -go 1.24.1 +go 1.25.0 require ( - github.com/gin-gonic/gin v1.11.0 + github.com/gin-gonic/gin v1.12.0 github.com/glebarez/sqlite v1.11.0 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.13.0 + github.com/redis/go-redis/v9 v9.18.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 - golang.org/x/crypto v0.41.0 + github.com/xuri/excelize/v2 v2.10.1 + golang.org/x/crypto v0.48.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gorm.io/driver/mysql v1.6.0 gorm.io/gorm v1.30.1 @@ -20,23 +21,24 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect - github.com/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + 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/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 github.com/fsnotify/fsnotify v1.8.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect 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.27.0 // indirect + github.com/go-playground/validator/v10 v10.30.1 // 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.2 // indirect - github.com/goccy/go-yaml v1.18.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect @@ -45,32 +47,34 @@ require ( 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 - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + 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.4 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.54.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // 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 github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/mock v0.5.0 // indirect + 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 + 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 - golang.org/x/arch v0.20.0 // indirect - golang.org/x/image v0.23.0 // indirect - golang.org/x/mod v0.26.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/tools v0.35.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/image v0.25.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect diff --git a/go.sum b/go.sum index 9801ba1..992af06 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,12 @@ 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= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= -github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= -github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= -github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +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/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= @@ -24,12 +26,12 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= -github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= @@ -40,16 +42,16 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= -github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +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-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= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= @@ -80,8 +82,9 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mojocn/base64Captcha v1.3.8 h1:rrN9BhCwXKS8ht1e21kvR3iTaMgf4qPC9sRoV52bqEg= @@ -90,17 +93,21 @@ github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= -github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= -github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= -github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= -github.com/redis/go-redis/v9 v9.13.0 h1:PpmlVykE0ODh8P43U0HqC+2NXHXwG+GUtQyz+MPKGRg= -github.com/redis/go-redis/v9 v9.13.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +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/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= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= @@ -121,44 +128,57 @@ github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqj github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= -github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= +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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= -golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= -golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -167,8 +187,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -176,8 +196,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -190,8 +208,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -209,22 +227,20 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 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= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 0ee83e5..d42b77b 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,9 @@ -package main - -import "networkDev/cmd" - -// main 是程序的入口点 -// 调用Cobra命令执行器来处理命令行参数和子命令 -func main() { - cmd.Execute() -} +package main + +import "NetworkAuth/cmd" + +// main 是程序的入口点 +// 调用Cobra命令执行器来处理命令行参数和子命令 +func main() { + cmd.Execute() +} diff --git a/middleware/devmode.go b/middleware/devmode.go index 0f8d136..4f6ba66 100644 --- a/middleware/devmode.go +++ b/middleware/devmode.go @@ -1,7 +1,7 @@ package middleware import ( - "networkDev/web" + "NetworkAuth/web" "github.com/gin-gonic/gin" "github.com/spf13/viper" diff --git a/middleware/install.go b/middleware/install.go new file mode 100644 index 0000000..f5a13d9 --- /dev/null +++ b/middleware/install.go @@ -0,0 +1,54 @@ +package middleware + +import ( + "NetworkAuth/services" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// InstallCheckMiddleware 检查系统是否已安装 +func InstallCheckMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + path := c.Request.URL.Path + + // 放行静态资源和favicon + if strings.HasPrefix(path, "/static/") || strings.HasPrefix(path, "/assets/") || path == "/favicon.ico" { + c.Next() + return + } + + // 检查是否为安装相关的路由 + isInstallRoute := path == "/install" || path == "/api/install" + + // 获取系统的安装状态 + // 在没有数据库的时候,GetSettingsService().GetString 会返回默认值 "0" + isInstalled := services.GetSettingsService().GetString("is_installed", "0") == "1" + + // 如果未安装且当前不是访问安装页面,则重定向到安装页面 + if !isInstalled && !isInstallRoute { + // 对于 API 请求,返回 JSON 提示 + if strings.HasPrefix(path, "/api/") || strings.Contains(path, "/api/") { + c.JSON(http.StatusForbidden, gin.H{ + "code": 403, + "msg": "系统未初始化,请先完成安装", + }) + c.Abort() + return + } + c.Redirect(http.StatusTemporaryRedirect, "/install") + c.Abort() + return + } + + // 如果已安装但尝试访问安装页面,则重定向到首页或后台 + if isInstalled && isInstallRoute { + c.Redirect(http.StatusTemporaryRedirect, "/admin") + c.Abort() + return + } + + c.Next() + } +} diff --git a/middleware/logging.go b/middleware/logging.go index b3a58e4..9ea25bf 100644 --- a/middleware/logging.go +++ b/middleware/logging.go @@ -1,11 +1,13 @@ package middleware import ( - "strings" "time" + "NetworkAuth/utils/logger" + "github.com/gin-gonic/gin" - "networkDev/utils/logger" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" ) // ============================================================================ @@ -34,76 +36,49 @@ func NewLoggingMiddleware(logger *logger.Logger) *LoggingMiddleware { // ============================================================================ // Handler 返回Gin中间件函数,用于记录HTTP请求日志 -// 记录格式遵循Apache Common Log Format +// 记录格式参考了更灵活的 NetworkAuth 实现,支持配置开关和日志级别检查 func (lm *LoggingMiddleware) Handler() gin.HandlerFunc { return func(c *gin.Context) { - // 记录开始时间 + // 检查是否启用了访问日志 + if !viper.GetBool("server.access_log") { + c.Next() + return + } + + // 如果日志级别不是Debug或更高(Trace),则不记录访问日志 + // 避免在Info级别输出过多的访问日志干扰正常业务日志 + if lm.logger.Level < logrus.DebugLevel { + c.Next() + return + } + start := time.Now() + path := c.Request.URL.Path + raw := c.Request.URL.RawQuery // 处理请求 c.Next() - // 计算处理时间 + // 计算响应时间 duration := time.Since(start) - // 获取客户端IP - clientIP := getClientIP(c) + if raw != "" { + path = path + "?" + raw + } - // 记录日志 - Apache Common Log Format - // 使用专门的HTTP日志方法避免User-Agent中的反斜杠被转义 + // 记录请求日志 lm.logger.LogRequestWithHeaders( c.Request.Method, - c.Request.RequestURI, - clientIP, + path, + c.ClientIP(), // 使用 Gin 内置的方法获取 IP c.Writer.Status(), duration, - "-", // referer (已废弃) + c.Errors.ByType(gin.ErrorTypePrivate).String(), c.Request.UserAgent(), ) } } -// ============================================================================ -// 私有函数 -// ============================================================================ - -// getClientIP 获取客户端真实IP地址 -// 优先从X-Forwarded-For、X-Real-IP等头部获取,最后使用RemoteAddr -func getClientIP(c *gin.Context) string { - // 检查X-Forwarded-For头部 - xForwardedFor := c.GetHeader("X-Forwarded-For") - if xForwardedFor != "" { - // X-Forwarded-For可能包含多个IP,取第一个 - ips := strings.Split(xForwardedFor, ",") - if len(ips) > 0 { - return strings.TrimSpace(ips[0]) - } - } - - // 检查X-Real-IP头部 - xRealIP := c.GetHeader("X-Real-IP") - if xRealIP != "" { - return xRealIP - } - - // 检查X-Forwarded头部 - xForwarded := c.GetHeader("X-Forwarded") - if xForwarded != "" { - return xForwarded - } - - // 使用RemoteAddr - remoteAddr := c.Request.RemoteAddr - if strings.Contains(remoteAddr, ":") { - // 移除端口号 - if idx := strings.LastIndex(remoteAddr, ":"); idx != -1 { - return remoteAddr[:idx] - } - } - - return remoteAddr -} - // ============================================================================ // 公共函数 // ============================================================================ @@ -111,7 +86,7 @@ func getClientIP(c *gin.Context) string { // WrapHandler 创建Gin日志中间件 // 使用全局日志记录器创建日志中间件 func WrapHandler() gin.HandlerFunc { - logger := logger.GetLogger() - middleware := NewLoggingMiddleware(logger) + log := logger.GetLogger() + middleware := NewLoggingMiddleware(log) return middleware.Handler() } diff --git a/middleware/maintenance.go b/middleware/maintenance.go new file mode 100644 index 0000000..b9c8aef --- /dev/null +++ b/middleware/maintenance.go @@ -0,0 +1,99 @@ +package middleware + +import ( + "net/http" + "strings" + + "NetworkAuth/services" + + "github.com/gin-gonic/gin" +) + +// MaintenanceMiddleware 维护模式中间件 +// 当开启维护模式时,拦截非白名单请求 +func MaintenanceMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 检查是否开启维护模式 + if !services.GetSettingsService().IsMaintenanceMode() { + c.Next() + return + } + + // 白名单检查(路径前缀匹配) + path := c.Request.URL.Path + + // 1. 允许静态资源 + if strings.HasPrefix(path, "/static/") || strings.HasPrefix(path, "/assets/") || path == "/favicon.ico" { + c.Next() + return + } + + // 2. 允许管理员后台相关接口(以便管理员登录关闭维护模式) + // 包括登录页、登录接口、API接口、CSRF Token等 + if strings.HasPrefix(path, "/admin") { + c.Next() + return + } + + // 3. 检查请求类型 + // AJAX/JSON 请求返回 503 JSON + accept := c.GetHeader("Accept") + xrw := strings.ToLower(strings.TrimSpace(c.GetHeader("X-Requested-With"))) + if strings.Contains(accept, "application/json") || xrw == "xmlhttprequest" || strings.HasPrefix(path, "/api/") { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "code": 503, + "success": false, + "msg": "系统正在维护中,请稍后再试", + }) + c.Abort() + return + } + + // 4. 普通页面请求渲染维护页面 + c.Header("Content-Type", "text/html; charset=utf-8") + c.Status(http.StatusServiceUnavailable) + c.Writer.WriteString(maintenanceHTML) + c.Abort() + } +} + +// 简单的维护页面 HTML +const maintenanceHTML = ` + +
+ + +为了提供更好的服务,系统正在进行升级维护。
请稍后访问,给您带来的不便敬请谅解。
u-d&&(s=u-d,a.length=s);for(var f=0;f =a)}}for(var h=this.__startIndex;h n?a:o,h=Math.abs(l.label.y-n);if(h>=u.maxY){var c=l.label.x-e-l.len2*r,p=i+l.len,f=Math.abs(c) t.unconstrainedWidth?null:d:null;i.setStyle("width",f)}var g=i.getBoundingRect();o.width=g.width;var y=(i.style.margin||0)+2.1;o.height=g.height+y,o.y-=(o.height-c)/2}}}function kM(t){return"center"===t.position}function LM(t){var e,n,i=t.getData(),r=[],o=!1,a=(t.get("minShowLabelAngle")||0)*CM,s=i.getLayout("viewRect"),l=i.getLayout("r"),u=s.width,h=s.x,c=s.y,p=s.height;function d(t){t.ignore=!0}i.each((function(t){var s=i.getItemGraphicEl(t),c=s.shape,p=s.getTextContent(),f=s.getTextGuideLine(),g=i.getItemModel(t),y=g.getModel("label"),v=y.get("position")||g.get(["emphasis","label","position"]),m=y.get("distanceToLabelLine"),x=y.get("alignTo"),_=$r(y.get("edgeDistance"),u),b=y.get("bleedMargin"),w=g.getModel("labelLine"),S=w.get("length");S=$r(S,u);var M=w.get("length2");if(M=$r(M,u),Math.abs(c.endAngle-c.startAngle)0?"right":"left":k>0?"left":"right"}var B=Math.PI,F=0,G=y.get("rotate");if(j(G))F=G*(B/180);else if("center"===v)F=0;else if("radial"===G||!0===G){F=k<0?-A+B:-A}else if("tangential"===G&&"outside"!==v&&"outer"!==v){var W=Math.atan2(k,L);W<0&&(W=2*B+W),L>0&&(W=B+W),F=W-B}if(o=!!F,p.x=I,p.y=T,p.rotation=F,p.setStyle({verticalAlign:"middle"}),P){p.setStyle({align:D});var H=p.states.select;H&&(H.x+=p.x,H.y+=p.y)}else{var Y=p.getBoundingRect().clone();Y.applyTransform(p.getComputedTransform());var X=(p.style.margin||0)+2.1;Y.y-=X/2,Y.height+=X,r.push({label:p,labelLine:f,position:v,len:S,len2:M,minTurnAngle:w.get("minTurnAngle"),maxSurfaceAngle:w.get("maxSurfaceAngle"),surfaceNormal:new De(k,L),linePoints:C,textAlign:D,labelDistance:m,labelAlignTo:x,edgeDistance:_,bleedMargin:b,rect:Y,unconstrainedWidth:Y.width,labelStyleWidth:p.style.width})}s.setTextConfig({inside:P})}})),!o&&t.get("avoidLabelOverlap")&&function(t,e,n,i,r,o,a,s){for(var l=[],u=[],h=Number.MAX_VALUE,c=-Number.MAX_VALUE,p=0;p i&&(i=e);var o=i%2?i+2:i+3;r=[];for(var a=0;ah[1]&&(h[1]=y),c[p++]=v}return r._count=p,r._indices=c,r._updateGetRawIdx(),r},t.prototype.each=function(t,e){if(this._count)for(var n=t.length,i=this._chunks,r=0,o=this.count();r1||n>0&&!t.noHeader;return E(t.blocks,(function(t){var n=lg(t);n>=e&&(e=n+ +(i&&(!n||ag(t)&&!t.noHeader)))})),e}return 0}function ug(t,e,n,i){var r,o=e.noHeader,a=(r=lg(e),{html:ig[r],richText:rg[r]}),s=[],l=e.blocks||[];lt(!l||Y(l)),l=l||[];var u=t.orderMode;if(e.sortBlocks&&u){l=l.slice();var h={valueAsc:"asc",valueDesc:"desc"};if(_t(h,u)){var c=new kf(h[u],null);l.sort((function(t,e){return c.evaluate(t.sortParam,e.sortParam)}))}else"seriesDesc"===u&&l.reverse()}E(l,(function(n,r){var o=e.valueFormatter,l=sg(n)(o?A(A({},t),{valueFormatter:o}):t,n,r>0?a.html:0,i);null!=l&&s.push(l)}));var p="richText"===t.renderMode?s.join(a.richText):pg(i,s.join(""),o?n:a.html);if(o)return p;var d=mp(e.header,"ordinal",t.useUTC),f=ng(i,t.renderMode).nameStyle,g=eg(i);return"richText"===t.renderMode?dg(t,d,f)+a.richText+p:pg(i,'5)return;var i=this._model.coordinateSystem.getSlidedAxisExpandWindow([t.offsetX,t.offsetY]);"none"!==i.behavior&&this._dispatchExpand({axisExpandWindow:i.axisExpandWindow})}this._mouseDownPoint=null},mousemove:function(t){if(!this._mouseDownPoint&&Rk(this,"mousemove")){var e=this._model,n=e.coordinateSystem.getSlidedAxisExpandWindow([t.offsetX,t.offsetY]),i=n.behavior;"jump"===i&&this._throttledDispatchExpand.debounceNextCall(e.get("axisExpandDebounce")),this._throttledDispatchExpand("none"===i?null:{axisExpandWindow:n.axisExpandWindow,animation:"jump"===i?null:{duration:0}})}}};function Rk(t,e){var n=t._model;return n.get("axisExpandable")&&n.get("axisExpandTriggerOn")===e}var Nk=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(){t.prototype.init.apply(this,arguments),this.mergeOption({})},e.prototype.mergeOption=function(t){var e=this.option;t&&C(e,t,!0),this._initDimensions()},e.prototype.contains=function(t,e){var n=t.get("parallelIndex");return null!=n&&e.getComponent("parallel",n)===this},e.prototype.setAxisExpand=function(t){E(["axisExpandable","axisExpandCenter","axisExpandCount","axisExpandWidth","axisExpandWindow"],(function(e){t.hasOwnProperty(e)&&(this.option[e]=t[e])}),this)},e.prototype._initDimensions=function(){var t=this.dimensions=[],e=this.parallelAxisIndex=[];E(B(this.ecModel.queryComponents({mainType:"parallelAxis"}),(function(t){return(t.get("parallelIndex")||0)===this.componentIndex}),this),(function(n){t.push("dim"+n.get("dim")),e.push(n.componentIndex)}))},e.type="parallel",e.dependencies=["parallelAxis"],e.layoutMode="box",e.defaultOption={z:0,left:80,top:60,right:80,bottom:60,layout:"horizontal",axisExpandable:!1,axisExpandCenter:null,axisExpandCount:0,axisExpandWidth:50,axisExpandRate:17,axisExpandDebounce:50,axisExpandSlideTriggerArea:[-.15,.05,.4],axisExpandTriggerOn:"click",parallelAxisDefault:null},e}(zp),Ek=function(t){function e(e,n,i,r,o){var a=t.call(this,e,n,i)||this;return a.type=r||"value",a.axisIndex=o,a}return n(e,t),e.prototype.isHorizontal=function(){return"horizontal"!==this.coordinateSystem.getModel().get("layout")},e}(ab);function zk(t,e,n,i,r,o){t=t||0;var a=n[1]-n[0];if(null!=r&&(r=Bk(r,[0,a])),null!=o&&(o=Math.max(o,null!=r?r:0)),"all"===i){var s=Math.abs(e[1]-e[0]);s=Bk(s,[0,a]),r=o=Bk(s,[r,o]),i=0}e[0]=Bk(e[0],n),e[1]=Bk(e[1],n);var l=Vk(e,i);e[i]+=t;var u,h=r||0,c=n.slice();return l.sign<0?c[0]+=h:c[1]-=h,e[i]=Bk(e[i],c),u=Vk(e,i),null!=r&&(u.sign!==l.sign||u.span