237 lines
7.8 KiB
Go
237 lines
7.8 KiB
Go
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)
|
||
}
|