feat(backend): unified response helpers, refresh token, security hardening

- new resp package (OK/Page/Fail/BadRequest/.../InternalErr): unified error
  envelope {detail, code?}, sanitized 500s (full error logged server-side,
  generic message to client - stops DB error leakage), pagination envelope
  {items,total,page,page_size}; migrate 200+ inline c.JSON error sites
- refresh token: 7d rotation tokens (SHA-256 hashed at rest), public
  POST /auth/refresh with single-use rotation, POST /auth/logout revokes
  all user refresh tokens (endpoint previously missing - frontend 404)
- security: gh-ost command line password masked in task status payload;
  Instance.MarshalJSON guarantees encrypted passwords never leave the API
  (removes reliance on per-handler manual masking); production (DEBUG=false)
  with default JWT secret now auto-generates and persists a random secret
- safe.Go recover wrapper for background goroutines (instance probe,
  gh-ost async)
- delete dead service layer (zero callers)
- fix pre-existing go vet unkeyed bson.E literals
This commit is contained in:
Logan.Li committed 2026-10-26 17:21:00 +08:00
1 parent d5b8b8116c
commit 16f1f7d65f
26 files changed
+916 -963

No files matched your search

+62 -21
View File
@@ -8,6 +8,7 @@ import (
"strings"
"time"
"sql-platform/internal/handler/resp"
"sql-platform/internal/middleware"
"sql-platform/internal/model"
@@ -22,7 +23,7 @@ import (
func Login(c *gin.Context) {
var req model.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": "username and password required"})
resp.BadRequest(c, "username and password required")
return
}
@@ -34,7 +35,7 @@ func Login(c *gin.Context) {
maxAttempts := getSettingInt("login_max_attempts", 5)
lockMin := getSettingInt("login_lockout_minutes", 30)
remaining := int(user.LockedUntil.Sub(time.Now()).Minutes()) + 1
c.JSON(http.StatusTooManyRequests, gin.H{"detail": fmt.Sprintf("账号已锁定,连续 %d 次登录失败,请 %d 分钟后重试", maxAttempts, lockMin), "retry_after_minutes": remaining})
resp.TooManyRequests(c, fmt.Sprintf("账号已锁定,连续 %d 次登录失败,请 %d 分钟后重试", maxAttempts, lockMin), remaining)
return
}
// Lock expired → reset
@@ -47,7 +48,7 @@ func Login(c *gin.Context) {
if err == nil {
// ── 本地用户存在 ──
if user.IsActive != nil && !*user.IsActive {
c.JSON(http.StatusForbidden, gin.H{"detail": "Account is disabled"})
resp.Forbidden(c, "Account is disabled")
return
}
@@ -60,7 +61,7 @@ func Login(c *gin.Context) {
if ldapErr != nil || ldapInfo == nil {
// Both local and LDAP failed → record failed attempt
handleFailedLogin(&user)
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
resp.Unauthorized(c, "Invalid username or password")
return
}
// LDAP OK — update local user info
@@ -81,7 +82,7 @@ func Login(c *gin.Context) {
// ── 本地不存在该用户,尝试 LDAP 认证 ──
_, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password)
if ldapErr != nil || ldapInfo == nil {
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"})
resp.Unauthorized(c, "Invalid username or password")
return
}
// LDAP 认证成功,自动创建或恢复本地用户
@@ -102,7 +103,7 @@ func Login(c *gin.Context) {
// FirstOrCreate: 不存在则创建,已存在则获取(避免 uniqueIndex 冲突)
result := model.DB.Where("username = ?", ldapInfo.Username).FirstOrCreate(&newUser)
if result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to create LDAP user: " + result.Error.Error()})
resp.InternalErr(c, result.Error)
return
}
// 更新已有用户的 display_name / email(LDAP 信息可能变更)
@@ -119,7 +120,7 @@ func Login(c *gin.Context) {
}
user = newUser
} else {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Database error"})
resp.InternalMsg(c, "Database error")
return
}
@@ -129,7 +130,7 @@ func Login(c *gin.Context) {
user.LockedUntil = nil
token, err := middleware.GenerateToken(&user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to generate token"})
resp.InternalMsg(c, "Failed to generate token")
return
}
@@ -137,13 +138,53 @@ func Login(c *gin.Context) {
user.LastLogin = &now
model.DB.Save(&user)
// 签发 refresh token(失败不阻塞登录,仅记录)
refreshToken, refreshErr := middleware.GenerateRefreshToken(user.ID)
if refreshErr != nil {
fmt.Printf("generate refresh token failed: %v\n", refreshErr)
}
c.JSON(http.StatusOK, model.LoginResponse{
AccessToken: token,
TokenType: "bearer",
User: user,
AccessToken: token,
RefreshToken: refreshToken,
TokenType: "bearer",
User: user,
})
}
// POST /api/v1/auth/refresh — 用 refresh_token 轮换新的 token 对
func RefreshToken(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "refresh_token required")
return
}
user, newRefresh, err := middleware.ConsumeRefreshToken(req.RefreshToken)
if err != nil {
resp.Fail(c, http.StatusUnauthorized, "登录已过期,请重新登录")
return
}
access, err := middleware.GenerateToken(user)
if err != nil {
resp.InternalErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"access_token": access,
"refresh_token": newRefresh,
"token_type": "bearer",
})
}
// POST /api/v1/auth/logout — 撤销当前用户全部 refresh token
func Logout(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c)
middleware.RevokeUserRefreshTokens(userID)
resp.Message(c, "Logged out")
}
// LDAPAuthInfo holds extracted user info from LDAP
type LDAPAuthInfo struct {
Username string
@@ -258,7 +299,7 @@ func GetMe(c *gin.Context) {
userID, _, _ := middleware.GetCurrentUser(c)
var user model.User
if err := model.DB.First(&user, userID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "User not found"})
resp.NotFound(c, "User not found")
return
}
c.JSON(http.StatusOK, user)
@@ -282,13 +323,13 @@ func CreateUser(c *gin.Context) {
Role string `json:"role" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
hashed, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to hash password"})
resp.InternalMsg(c, "Failed to hash password")
return
}
@@ -305,9 +346,9 @@ func CreateUser(c *gin.Context) {
if err := model.DB.Create(&user).Error; err != nil {
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "duplicate") {
c.JSON(http.StatusBadRequest, gin.H{"detail": "Username already exists"})
resp.BadRequest(c, "Username already exists")
} else {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
}
return
}
@@ -320,13 +361,13 @@ func UpdateUser(c *gin.Context) {
id := c.Param("id")
var user model.User
if err := model.DB.First(&user, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"detail": "User not found"})
resp.NotFound(c, "User not found")
return
}
var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
resp.BadRequest(c, err.Error())
return
}
@@ -337,7 +378,7 @@ func UpdateUser(c *gin.Context) {
delete(input, "password")
if err := model.DB.Model(&user).Updates(input).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
@@ -350,11 +391,11 @@ func DeleteUser(c *gin.Context) {
id := c.Param("id")
// Protect admin (id=1) and dba (id=2) from being deleted
if id == "1" || id == "2" {
c.JSON(http.StatusForbidden, gin.H{"detail": "Cannot delete system reserved users"})
resp.Forbidden(c, "Cannot delete system reserved users")
return
}
if err := model.DB.Delete(&model.User{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
resp.InternalErr(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "User deleted"})