Files
xk-ai-agent/internal/handler/auth_handler.go
2026-08-14 21:50:48 +08:00

237 lines
7.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 JWT7 天)
// POST /refresh 带有效 token 换新 token前端剩余有效期不足时静默续签
// GET /profile 带 token 返回用户信息(前端启动时校验会话有效性)
//
// 设计要点:
// - 凭据来自 config.Panel默认 liqi/qiqi991012/999999config.yaml 可覆盖)
// - 签名密钥复用 middleware.JWTSecret与业务 API 的 JWT 校验同一把钥匙,
// 所以登录发的 token 天然能过所有现有接口的鉴权)
// - 登录失败统一模糊报错(不区分账号错还是密码错,防枚举)
// - 防爆破:同 IP 连续失败 5 次锁 10 分钟(内存计数,重启清零,内网面板够用)
// ========================================================================
// panelTokenTTL 登录 token 有效期7 天,前端会在剩余 <24h 时自动续签)
const panelTokenTTL = 7 * 24 * time.Hour
// panelRole 面板管理员角色名(写进 JWT claimsprofile 返回给前端展示)
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
//
// claimssub=用户名、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)
}