Files

237 lines
7.8 KiB
Go
Raw Permalink Normal View History

2026-08-14 21:50:48 +08:00
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)
}