diff --git a/internal/constant/constant.go b/internal/constant/constant.go index 0beef47..55885d2 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -38,8 +38,9 @@ const ( KeyTitle = "title" KeySubtitle = "subtitle" KeyIcon = "icon" - KeyPageSize = "page_size" - KeyCookieDays = "cookie_days" + KeyPageSize = "page_size" + KeyCookieDays = "cookie_days" + KeyApiToken = "api_token" // System Settings Key 常量 KeyInitialized = "initialized" diff --git a/internal/controllers/settings_controller.go b/internal/controllers/settings_controller.go index 34ea9f9..0cea173 100644 --- a/internal/controllers/settings_controller.go +++ b/internal/controllers/settings_controller.go @@ -5,8 +5,10 @@ import ( "runtime" "strconv" + "encoding/json" "fmt" "os" + "strings" "time" "github.com/engigu/baihu-panel/internal/constant" @@ -82,6 +84,16 @@ func (sc *SettingsController) ChangePassword(c *gin.Context) { // GetSiteSettings 获取站点设置 func (sc *SettingsController) GetSiteSettings(c *gin.Context) { settings := sc.settingsService.GetSection(constant.SectionSite) + + // 解析 JSON 格式的 API Token + if tokenJson, ok := settings[constant.KeyApiToken]; ok && tokenJson != "" { + var tokenData map[string]string + if err := json.Unmarshal([]byte(tokenJson), &tokenData); err == nil { + settings["api_token"] = tokenData["token"] + settings["api_token_expire"] = tokenData["expire_at"] + } + } + utils.Success(c, settings) } @@ -100,11 +112,13 @@ func (sc *SettingsController) GetPublicSiteSettings(c *gin.Context) { // UpdateSiteSettings 更新站点设置 func (sc *SettingsController) UpdateSiteSettings(c *gin.Context) { var req struct { - Title string `json:"title"` - Subtitle string `json:"subtitle"` - Icon string `json:"icon"` - PageSize string `json:"page_size"` - CookieDays string `json:"cookie_days"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Icon string `json:"icon"` + PageSize string `json:"page_size"` + CookieDays string `json:"cookie_days"` + ApiToken string `json:"api_token"` + ApiTokenExpire string `json:"api_token_expire"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -112,12 +126,24 @@ func (sc *SettingsController) UpdateSiteSettings(c *gin.Context) { return } + apiTokenJson := "" + if req.ApiToken != "" || req.ApiTokenExpire != "" { + tokenData := map[string]string{ + "token": req.ApiToken, + "expire_at": req.ApiTokenExpire, + } + if b, err := json.Marshal(tokenData); err == nil { + apiTokenJson = string(b) + } + } + values := map[string]string{ constant.KeyTitle: req.Title, constant.KeySubtitle: req.Subtitle, constant.KeyIcon: req.Icon, constant.KeyPageSize: req.PageSize, constant.KeyCookieDays: req.CookieDays, + constant.KeyApiToken: apiTokenJson, } if err := sc.settingsService.SetSection(constant.SectionSite, values); err != nil { @@ -128,6 +154,13 @@ func (sc *SettingsController) UpdateSiteSettings(c *gin.Context) { utils.SuccessMsg(c, "保存成功") } +// GenerateApiToken 随机生成API Token +func (sc *SettingsController) GenerateApiToken(c *gin.Context) { + utils.Success(c, gin.H{ + "token": strings.ToLower(utils.RandomString(32)), + }) +} + // GetSchedulerSettings 获取调度设置 func (sc *SettingsController) GetSchedulerSettings(c *gin.Context) { settings := sc.settingsService.GetSection(constant.SectionScheduler) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 5444e2c..f7ab102 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -1,7 +1,11 @@ package middleware import ( + "encoding/json" + "time" + "github.com/engigu/baihu-panel/internal/constant" + "github.com/engigu/baihu-panel/internal/services" "github.com/engigu/baihu-panel/internal/utils" "github.com/gin-gonic/gin" @@ -9,7 +13,13 @@ import ( // AuthRequired 认证中间件 func AuthRequired() gin.HandlerFunc { + settingsSvc := services.NewSettingsService() return func(c *gin.Context) { + // 校验 API Token (实验特性) + if checkApiToken(c, settingsSvc) { + return + } + token, err := c.Cookie(constant.CookieName) if err != nil || token == "" { utils.Unauthorized(c, "请先登录") @@ -32,6 +42,49 @@ func AuthRequired() gin.HandlerFunc { } } +// checkApiToken 校验 API Token (实验特性,后续可能移除或重构) +// 返回 true 表示校验通过并已放行请求 +func checkApiToken(c *gin.Context, settingsSvc *services.SettingsService) bool { + apiToken := c.GetHeader("X-API-Token") + if apiToken == "" { + return false + } + + siteConfig := settingsSvc.GetSection(constant.SectionSite) + tokenJson, ok := siteConfig[constant.KeyApiToken] + if !ok || tokenJson == "" { + return false + } + + var tokenData map[string]string + if err := json.Unmarshal([]byte(tokenJson), &tokenData); err != nil { + return false + } + + expectedToken, ok := tokenData["token"] + if !ok || expectedToken == "" || apiToken != expectedToken { + return false + } + + // 检查过期时间 + if expireStr, ok := tokenData["expire_at"]; ok && expireStr != "" { + // 前端传来的时间格式是 YYYY-MM-DD,使用 2006-01-02 解析 + expireDate, err := time.Parse("2006-01-02", expireStr) + if err == nil { + // 将过期时间设为当天的 23:59:59 + expireDate = expireDate.Add(23*time.Hour + 59*time.Minute + 59*time.Second) + if time.Now().After(expireDate) { + return false + } + } + } + + c.Set("userID", uint(1)) // 模拟 Admin 角色 + c.Set("username", "api_token_user") + c.Next() + return true +} + // SetAuthCookie 设置认证 Cookie,expireDays 为过期天数 func SetAuthCookie(c *gin.Context, token string, expireDays int) { maxAge := 86400 * expireDays diff --git a/internal/router/router.go b/internal/router/router.go index 504e6d0..e681f59 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -189,6 +189,7 @@ func Setup(c *Controllers) *gin.Engine { settings.POST("/password", c.Settings.ChangePassword) settings.GET("/site", c.Settings.GetSiteSettings) settings.PUT("/site", c.Settings.UpdateSiteSettings) + settings.POST("/site/api-token/generate", c.Settings.GenerateApiToken) settings.GET("/paths", c.Settings.GetPaths) settings.GET("/scheduler", c.Settings.GetSchedulerSettings) settings.PUT("/scheduler", c.Settings.UpdateSchedulerSettings) diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 858573d..99c88c0 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -125,6 +125,7 @@ export const api = { getPublicSite: () => request<{ title: string; subtitle: string; icon: string; demo_mode: boolean }>('/settings/public'), updateSite: (data: SiteSettings) => request('/settings/site', { method: 'PUT', body: JSON.stringify(data) }), + generateApiToken: () => request<{ token: string }>('/settings/site/api-token/generate', { method: 'POST' }), getScheduler: () => request('/settings/scheduler'), updateScheduler: (data: SchedulerSettings) => request('/settings/scheduler', { method: 'PUT', body: JSON.stringify(data) }), @@ -401,6 +402,8 @@ export interface SiteSettings { icon: string page_size: string cookie_days: string + api_token?: string + api_token_expire?: string } export interface SchedulerSettings { diff --git a/web/src/views/settings/SiteSettings.vue b/web/src/views/settings/SiteSettings.vue index f78443e..7ae7a69 100644 --- a/web/src/views/settings/SiteSettings.vue +++ b/web/src/views/settings/SiteSettings.vue @@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button' import { api, type SiteSettings } from '@/api' import { toast } from 'vue-sonner' import { useSiteSettings } from '@/composables/useSiteSettings' +import { Badge } from '@/components/ui/badge' +import { RefreshCw, Copy } from 'lucide-vue-next' const { refreshSettings } = useSiteSettings() @@ -14,7 +16,9 @@ const form = ref({ subtitle: '', icon: '', page_size: '10', - cookie_days: '7' + cookie_days: '7', + api_token: '', + api_token_expire: '' }) const loading = ref(false) @@ -51,6 +55,32 @@ async function saveSettings() { } } +async function generateToken() { + try { + const res = await api.settings.generateApiToken() + form.value.api_token = res.token + + // 如果没有设置过期时间,默认给一年后 + if (!form.value.api_token_expire) { + const d = new Date() + d.setFullYear(d.getFullYear() + 1) + form.value.api_token_expire = d.toISOString().split('T')[0] + } + } catch { + toast.error('生成 Token 失败') + } +} + +async function copyToken() { + if (!form.value.api_token) return + try { + await navigator.clipboard.writeText(form.value.api_token) + toast.success('Token 已复制到剪贴板') + } catch { + toast.error('复制失败,请手动复制') + } +} + onMounted(loadSettings) @@ -84,6 +114,35 @@ onMounted(loadSettings) + +
+
+

API Token

+ 实验特性,可能变更 +
+

开启全局 API 直接访问能力,配置后可通过请求头 X-API-Token: <在此生成的Token> 无需登录直接调用系统的所有接口,请妥善保管并设置合理的有效期。

+ +
+ +
+ + + +
+
+ +
+ +
+ +
超过此日期后该 Token 将失效,置空代表该特性完全关闭。
+
+
+