package handler import ( "log" "net/http" "strings" "sync" "time" "tcm-agent/internal/config" "tcm-agent/internal/middleware" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" ) // ======================================================================== // AuthHandler —— 独立管理前端(/admin SPA)的登录鉴权 // ======================================================================== // 暴露 3 个端点(前缀 /api/v1/auth): // POST /login 账号+密码+固定验证码 → 签发 HS256 JWT(7 天) // POST /refresh 带有效 token 换新 token(前端剩余有效期不足时静默续签) // GET /profile 带 token 返回用户信息(前端启动时校验会话有效性) // // 设计要点: // - 凭据来自 config.Panel(默认 liqi/qiqi991012/999999,config.yaml 可覆盖) // - 签名密钥复用 middleware.JWTSecret(与业务 API 的 JWT 校验同一把钥匙, // 所以登录发的 token 天然能过所有现有接口的鉴权) // - 登录失败统一模糊报错(不区分账号错还是密码错,防枚举) // - 防爆破:同 IP 连续失败 5 次锁 10 分钟(内存计数,重启清零,内网面板够用) // ======================================================================== // panelTokenTTL 登录 token 有效期(7 天,前端会在剩余 <24h 时自动续签) const panelTokenTTL = 7 * 24 * time.Hour // panelRole 面板管理员角色名(写进 JWT claims,profile 返回给前端展示) const panelRole = "panel_admin" // 防爆破参数:同 IP 连续 maxLoginFails 次失败 → 锁定 loginLockDuration const ( maxLoginFails = 5 loginLockDuration = 10 * time.Minute ) // loginFailEntry 单个 IP 的失败计数 type loginFailEntry struct { Count int // 连续失败次数 LockUntil time.Time // 锁定截止时间(零值表示未锁定) LastFail time.Time // 最后一次失败时间(做过期清理用) } // AuthHandler 登录处理器 type AuthHandler struct { cfg *config.Config mu sync.Mutex fails map[string]*loginFailEntry // key=客户端 IP } // NewAuthHandler 构造 func NewAuthHandler(cfg *config.Config) *AuthHandler { return &AuthHandler{cfg: cfg, fails: map[string]*loginFailEntry{}} } // loginRequest 登录入参 type loginRequest struct { Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` Captcha string `json:"captcha" binding:"required"` } // Login 登录换 token // POST /api/v1/auth/login func (h *AuthHandler) Login(c *gin.Context) { var req loginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusOK, gin.H{"code": 400, "message": "请填写账号、密码和验证码"}) return } ip := c.ClientIP() // 先查锁定状态:锁定期内直接拒绝,不做凭据比对(省 CPU 也防继续试探) if locked, remain := h.isLocked(ip); locked { c.JSON(http.StatusOK, gin.H{"code": 429, "message": "失败次数过多,请 " + remain + " 后再试"}) return } panel := h.cfg.Panel // 验证码 / 账号 / 密码全部比对;任何一项不对都返回同一句模糊报错(防枚举) if strings.TrimSpace(req.Captcha) != panel.Captcha || req.Username != panel.Username || req.Password != panel.Password { h.recordFail(ip) log.Printf("[Auth] 登录失败 ip=%s username=%s", ip, req.Username) c.JSON(http.StatusOK, gin.H{"code": 401, "message": "账号、密码或验证码错误"}) return } // 登录成功:清空该 IP 的失败计数 h.clearFail(ip) token, expireAt, err := h.signToken(panel.Username) if err != nil { log.Printf("[Auth] 签发 token 失败: %v", err) c.JSON(http.StatusOK, gin.H{"code": 500, "message": "签发凭证失败"}) return } log.Printf("[Auth] 登录成功 ip=%s username=%s", ip, panel.Username) c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{ "token": token, "expire_at": expireAt, // unix 秒,前端据此判断何时续签 "nick_name": panel.Username, // 顶栏展示 "role_name": "面板管理员", "role": panelRole, }}) } // Refresh 续签 token // POST /api/v1/auth/refresh // // 必须带一个仍然有效的 JWT(中间件已校验并注入 user_id), // 用旧 token 的身份签发一个全新 7 天 token。 // 过期的 token 无法续签(中间件直接 401),需要重新登录。 func (h *AuthHandler) Refresh(c *gin.Context) { userID := c.GetString("user_id") if userID == "" || c.GetString("auth_mode") != "jwt" { // 走 SharedSecret / 口令进来的调用方没有"会话"概念,不支持续签 c.JSON(http.StatusOK, gin.H{"code": 401, "message": "当前凭证不支持续签,请重新登录"}) return } token, expireAt, err := h.signToken(userID) if err != nil { c.JSON(http.StatusOK, gin.H{"code": 500, "message": "续签失败"}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{ "token": token, "expire_at": expireAt, }}) } // Profile 返回当前登录用户信息 // GET /api/v1/auth/profile // // 前端启动时调用:能拿到数据说明 token 还有效,直接进面板;401 则跳登录页 func (h *AuthHandler) Profile(c *gin.Context) { userID := c.GetString("user_id") if userID == "" || c.GetString("auth_mode") != "jwt" { c.JSON(http.StatusOK, gin.H{"code": 401, "message": "会话无效,请重新登录"}) return } c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{ "username": userID, "nick_name": userID, "role": c.GetString("user_role"), "role_name": "面板管理员", }}) } // signToken 签发面板 JWT // // claims:sub=用户名、role=panel_admin、iat/exp 标准字段。 // 密钥与 middleware.Auth 校验用的是同一把(middleware.JWTSecret), // 所以这个 token 能直接通过所有业务 API 的 JWT 鉴权路径。 func (h *AuthHandler) signToken(username string) (string, int64, error) { now := time.Now() expireAt := now.Add(panelTokenTTL) claims := jwt.MapClaims{ "sub": username, "role": panelRole, "iat": now.Unix(), "exp": expireAt.Unix(), } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) signed, err := token.SignedString([]byte(middleware.JWTSecret(h.cfg))) if err != nil { return "", 0, err } return signed, expireAt.Unix(), nil } // ---------------------------- 防爆破(内存计数) ---------------------------- // isLocked 判断某 IP 是否处于锁定期,返回剩余时间的人类可读描述 func (h *AuthHandler) isLocked(ip string) (bool, string) { h.mu.Lock() defer h.mu.Unlock() entry, ok := h.fails[ip] if !ok { return false, "" } if entry.LockUntil.IsZero() || time.Now().After(entry.LockUntil) { return false, "" } remain := time.Until(entry.LockUntil).Round(time.Second) return true, remain.String() } // recordFail 记录一次失败;达到阈值后设置锁定 func (h *AuthHandler) recordFail(ip string) { h.mu.Lock() defer h.mu.Unlock() // 顺手清理超过 1 小时没动静的旧条目,防 map 无限膨胀 cutoff := time.Now().Add(-time.Hour) for k, v := range h.fails { if v.LastFail.Before(cutoff) && (v.LockUntil.IsZero() || time.Now().After(v.LockUntil)) { delete(h.fails, k) } } entry, ok := h.fails[ip] if !ok { entry = &loginFailEntry{} h.fails[ip] = entry } // 锁定期已过则重新从 1 开始计数 if !entry.LockUntil.IsZero() && time.Now().After(entry.LockUntil) { entry.Count = 0 entry.LockUntil = time.Time{} } entry.Count++ entry.LastFail = time.Now() if entry.Count >= maxLoginFails { entry.LockUntil = time.Now().Add(loginLockDuration) log.Printf("[Auth] IP %s 连续失败 %d 次,锁定至 %s", ip, entry.Count, entry.LockUntil.Format("15:04:05")) } } // clearFail 登录成功后清空该 IP 的失败计数 func (h *AuthHandler) clearFail(ip string) { h.mu.Lock() defer h.mu.Unlock() delete(h.fails, ip) }