diff --git a/controllers/admin/auth.go b/controllers/admin/auth.go index 1be9226..c97fc98 100644 --- a/controllers/admin/auth.go +++ b/controllers/admin/auth.go @@ -189,6 +189,82 @@ func LogoutHandler(c *gin.Context) { }) } +// RefreshTokenHandler 刷新管理员会话令牌 +// - 校验当前会话(Cookie 或 Authorization) +// - 重新签发 JWT 并同步写回 Cookie +// - 返回前端可直接持久化的新 token 信息 +func RefreshTokenHandler(c *gin.Context) { + token, err := getJWTCookie(c) + if err != nil || token == "" { + clearInvalidJWTCookie(c) + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 1, + "msg": "未登录或会话已过期", + "data": nil, + }) + return + } + + claims, err := parseJWTToken(token) + if err != nil { + clearInvalidJWTCookie(c) + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 1, + "msg": "无效的会话信息", + "data": nil, + }) + return + } + + if !validateAdminPasswordHash(claims, c) { + clearInvalidJWTCookie(c) + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 1, + "msg": "会话已失效,请重新登录", + "data": nil, + }) + return + } + + db, err := database.GetDB() + if err != nil { + authBaseController.HandleInternalError(c, "数据库连接失败", err) + return + } + + var adminUser models.User + if err := db.Where("uuid = ?", claims.UUID).First(&adminUser).Error; err != nil { + clearInvalidJWTCookie(c) + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 1, + "msg": "会话已失效,请重新登录", + "data": nil, + }) + return + } + + newToken, err := generateJWTTokenForAdmin(adminUser.Username, adminUser.Password, adminUser.UUID, adminUser.Role) + if err != nil { + authBaseController.HandleInternalError(c, "生成令牌失败", err) + return + } + + secure, sameSite, domain, maxAge := services.GetSettingsService().GetCookieConfig() + cookieObj := utils.CreateSecureCookie("admin_session", newToken, maxAge, domain, secure, sameSite) + c.SetCookie(cookieObj.Name, cookieObj.Value, cookieObj.MaxAge, cookieObj.Path, cookieObj.Domain, cookieObj.Secure, cookieObj.HttpOnly) + + expireHours := services.GetSettingsService().GetJWTExpire() + if expireHours <= 0 { + expireHours = 24 + } + + authBaseController.HandleSuccess(c, "刷新成功", gin.H{ + "accessToken": newToken, + "refreshToken": newToken, + "expires": time.Now().Add(time.Duration(expireHours) * time.Hour), + }) +} + // ============================================================================ // CSRF 相关辅助函数 // ============================================================================ diff --git a/server/admin.go b/server/admin.go index 480caef..a25db09 100644 --- a/server/admin.go +++ b/server/admin.go @@ -14,6 +14,7 @@ func RegisterAdminRoutes(rg *gin.RouterGroup) { admin.GET("/captcha", adminctl.CaptchaHandler) admin.GET("/csrf", adminctl.CSRFTokenHandler) admin.POST("/login", adminctl.LoginHandler) + admin.POST("/refresh-token", adminctl.RefreshTokenHandler) // 公开设置API admin.GET("/settings/public", adminctl.SettingsPublicHandler) diff --git a/utils/path.go b/utils/path.go index 2c0bfc2..6b3635b 100644 --- a/utils/path.go +++ b/utils/path.go @@ -3,28 +3,25 @@ package utils import ( "os" "path/filepath" + "runtime" "strings" ) // GetRootDir 获取当前程序运行的真实根目录 -// 能够智能、跨平台地识别是编译后的可执行文件运行,还是通过 `go run` 运行(通常在临时目录下) +// 能跨平台地识别是在生产环境执行二进制文件,还是在开发阶段使用 go run/test 乃至 IDE 调试运行 func GetRootDir() string { var baseDir string - // 首先尝试获取当前工作目录 workDir, err := os.Getwd() if err != nil { workDir = "." } - // 获取程序可执行文件所在目录 execPath, err := os.Executable() if err != nil { - // 如果获取可执行文件路径失败,使用当前工作目录 return workDir } - // 解析软链接,获取真实物理路径(macOS 下 /tmp 经常是 /private/tmp 的软链) realExecPath, err := filepath.EvalSymlinks(execPath) if err == nil { execPath = realExecPath @@ -36,27 +33,23 @@ func GetRootDir() string { realTempDir = os.TempDir() } - // 跨平台安全地判断 execDir 是否在 realTempDir 内部 - // 使用 filepath.Rel 可以避免直接 HasPrefix 带来的大小写、路径分隔符以及部分目录名重合的问题 - rel, err := filepath.Rel(realTempDir, execDir) isGoRun := false - if err == nil { - // 如果 rel 不以 ".." 开头,说明 execDir 在 TempDir 内部,即为 go run 模式 - if rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { - isGoRun = true - } - } else { - // fallback: 如果 Rel 失败(例如跨盘符),则退回简单的 HasPrefix 判断(带上分隔符防误判) - cleanTemp := filepath.Clean(realTempDir) + string(os.PathSeparator) - cleanExec := filepath.Clean(execDir) + string(os.PathSeparator) - if strings.HasPrefix(strings.ToLower(cleanExec), strings.ToLower(cleanTemp)) { - isGoRun = true - } + rel, err := filepath.Rel(realTempDir, execDir) + if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + isGoRun = true + } else if strings.Contains(execPath, "go-build") || strings.Contains(execPath, "__debug_bin") || strings.HasSuffix(execPath, ".test") { + isGoRun = true + } else if strings.Contains(filepath.Base(execPath), "___go_build") || strings.HasPrefix(filepath.Base(execPath), "dlv") { + isGoRun = true } if isGoRun { - baseDir = workDir + // 开发模式下,利用 runtime 获取 utils/path.go 所在的绝对路径 + // 向上两级即可得到项目的真实绝对根目录,避免因终端 CWD 不同导致的配置读取和连接失败 + _, b, _, _ := runtime.Caller(0) + baseDir = filepath.Dir(filepath.Dir(b)) } else { + // 生产模式下(正式编译的独立二进制),返回可执行文件所在目录 baseDir = execDir }