加固后台管理安全

This commit is contained in:
yml2213
2026-06-11 07:23:00 +08:00
parent 5255b21141
commit 88b1df64e7
41 changed files with 1276 additions and 293 deletions
@@ -0,0 +1,101 @@
package adminauth
import (
"context"
"errors"
"strings"
"unicode"
"hfb_sys/backend/internal/model"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type BootstrapConfig struct {
AppEnv string
Username string
Password string
Nickname string
}
// BootstrapAdmin 在管理员表为空时按部署配置创建首个超级管理员。
func BootstrapAdmin(ctx context.Context, db *gorm.DB, cfg BootstrapConfig) error {
if db == nil {
return nil
}
var count int64
if err := db.WithContext(ctx).Model(&model.AdminUser{}).Count(&count).Error; err != nil {
if isAdminTableMissing(err) {
return nil
}
return err
}
if count > 0 {
return nil
}
username := strings.TrimSpace(cfg.Username)
password := cfg.Password
if username == "" || password == "" {
if strings.EqualFold(strings.TrimSpace(cfg.AppEnv), "production") {
return errors.New("admin bootstrap credentials are required when no admin exists in production")
}
return nil
}
if strings.EqualFold(strings.TrimSpace(cfg.AppEnv), "production") && !bootstrapPasswordStrong(password) {
return errors.New("ADMIN_BOOTSTRAP_PASSWORD must be at least 12 chars and include letters and digits in production")
}
nickname := strings.TrimSpace(cfg.Nickname)
if nickname == "" {
nickname = username
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
admin := model.AdminUser{
Username: username,
PasswordHash: string(hash),
Nickname: nickname,
Status: "active",
TokenVersion: 1,
PasswordMustChange: true,
}
if err := tx.Create(&admin).Error; err != nil {
return err
}
var superAdminRole model.Role
if err := tx.Where("code = ?", "super_admin").First(&superAdminRole).Error; err != nil {
return err
}
return tx.Create(&model.AdminUserRole{
AdminID: admin.ID,
RoleID: superAdminRole.ID,
}).Error
})
}
func bootstrapPasswordStrong(value string) bool {
if len([]rune(value)) < 12 {
return false
}
hasLetter := false
hasDigit := false
for _, r := range value {
if unicode.IsLetter(r) {
hasLetter = true
}
if unicode.IsDigit(r) {
hasDigit = true
}
}
return hasLetter && hasDigit
}
func isAdminTableMissing(err error) bool {
message := strings.ToLower(err.Error())
return strings.Contains(message, "admin_users") && (strings.Contains(message, "doesn't exist") || strings.Contains(message, "no such table"))
}
+9 -8
View File
@@ -7,14 +7,15 @@ import (
)
type AdminDTO struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Status string `json:"status"`
SupportStatus string `json:"support_status"`
Roles []RoleDTO `json:"roles"`
Permissions []string `json:"permissions"`
LastLoginAt *time.Time `json:"last_login_at"`
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Status string `json:"status"`
SupportStatus string `json:"support_status"`
PasswordMustChange bool `json:"password_must_change"`
Roles []RoleDTO `json:"roles"`
Permissions []string `json:"permissions"`
LastLoginAt *time.Time `json:"last_login_at"`
}
type RoleDTO struct {
+63 -6
View File
@@ -2,15 +2,23 @@ package adminauth
import (
"errors"
"io"
"net/http"
"strings"
"hfb_sys/backend/internal/middleware"
"hfb_sys/backend/internal/modules/auth"
"hfb_sys/backend/pkg/response"
"github.com/gin-gonic/gin"
)
const (
adminAccessCookieName = middleware.AdminAccessCookieName
adminRefreshCookieName = "hfb_admin_refresh"
adminRefreshCookieMaxAge = 14 * 24 * 60 * 60
)
type Handler struct {
service *Service
}
@@ -34,12 +42,13 @@ func (h *Handler) Login(c *gin.Context) {
response.BadRequest(c, "用户名、密码和验证码不能为空")
return
}
result, err := h.service.Login(c.Request.Context(), strings.TrimSpace(req.Username), req.Password, strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode))
result, err := h.service.Login(c.Request.Context(), strings.TrimSpace(req.Username), req.Password, strings.TrimSpace(req.CaptchaID), strings.TrimSpace(req.CaptchaCode), c.ClientIP())
if err != nil {
writeAdminAuthError(c, err)
return
}
response.OK(c, result)
setAdminAuthCookies(c, result.Tokens)
response.OK(c, gin.H{"admin": result.Admin})
}
func (h *Handler) Me(c *gin.Context) {
@@ -62,6 +71,16 @@ func (h *Handler) Me(c *gin.Context) {
}
func (h *Handler) Logout(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
if err := h.service.Logout(c.Request.Context(), adminID); err != nil {
writeAdminAuthError(c, err)
return
}
clearAdminAuthCookies(c)
response.OK(c, gin.H{"logged_out": true})
}
@@ -93,21 +112,57 @@ func currentAdminID(c *gin.Context) (uint64, bool) {
}
type AdminRefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
RefreshToken string `json:"refresh_token"`
}
func (h *Handler) Refresh(c *gin.Context) {
var req AdminRefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
if c.Request.Body != nil && c.Request.ContentLength != 0 {
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.BadRequest(c, "refresh_token 格式不正确")
return
}
}
refreshToken := strings.TrimSpace(req.RefreshToken)
if refreshToken == "" {
if cookieValue, err := c.Cookie(adminRefreshCookieName); err == nil {
refreshToken = strings.TrimSpace(cookieValue)
}
}
if refreshToken == "" {
response.BadRequest(c, "refresh_token 不能为空")
return
}
tokens, err := h.service.Refresh(c.Request.Context(), req.RefreshToken)
tokens, err := h.service.Refresh(c.Request.Context(), refreshToken)
if err != nil {
writeAdminAuthError(c, err)
return
}
response.OK(c, tokens)
setAdminAuthCookies(c, *tokens)
response.OK(c, gin.H{"refreshed": true, "expires_in": tokens.ExpiresInSeconds})
}
func setAdminAuthCookies(c *gin.Context, tokens auth.TokenPair) {
secure := isHTTPSRequest(c)
httpOnly := true
sameSite := http.SameSiteStrictMode
c.SetSameSite(sameSite)
c.SetCookie(adminAccessCookieName, tokens.AccessToken, int(tokens.ExpiresInSeconds), "/api/admin", "", secure, httpOnly)
c.SetCookie(adminRefreshCookieName, tokens.RefreshToken, adminRefreshCookieMaxAge, "/api/admin/auth/refresh", "", secure, httpOnly)
}
func clearAdminAuthCookies(c *gin.Context) {
secure := isHTTPSRequest(c)
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(adminAccessCookieName, "", -1, "/api/admin", "", secure, true)
c.SetCookie(adminRefreshCookieName, "", -1, "/api/admin/auth/refresh", "", secure, true)
}
func isHTTPSRequest(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
}
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
}
func writeAdminAuthError(c *gin.Context, err error) {
@@ -120,6 +175,8 @@ func writeAdminAuthError(c *gin.Context, err error) {
response.BadRequest(c, "用户名或密码错误")
case errors.Is(err, ErrCaptchaInvalid):
response.BadRequest(c, "验证码错误或已过期")
case errors.Is(err, ErrLoginLocked):
response.Error(c, http.StatusTooManyRequests, "login_locked", "登录失败次数过多,请稍后再试")
case errors.Is(err, ErrAdminDisabled):
response.Error(c, http.StatusForbidden, "admin_disabled", "管理员已禁用")
default:
@@ -21,9 +21,10 @@ import (
)
const (
defaultAdminUsername = "admin"
defaultAdminPassword = "admin123456"
captchaTTL = 3 * time.Minute
loginFailureTTL = 15 * time.Minute
loginLockTTL = 15 * time.Minute
loginMaxFailureCount = 5
)
type Repository struct {
@@ -58,16 +59,17 @@ func (r *Repository) Captcha(ctx context.Context) (*CaptchaDTO, error) {
}, nil
}
func (r *Repository) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string) (LoginResult, error) {
func (r *Repository) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string, clientIP string) (LoginResult, error) {
if err := r.verifyCaptcha(ctx, captchaID, captchaCode); err != nil {
return LoginResult{}, err
}
if err := r.ensureDefaultAdmin(ctx); err != nil {
if err := r.ensureLoginNotLocked(ctx, username, clientIP); err != nil {
return LoginResult{}, err
}
var admin model.AdminUser
if err := r.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
r.recordLoginFailure(ctx, username, clientIP)
return LoginResult{}, ErrInvalidCredential
}
return LoginResult{}, err
@@ -76,14 +78,19 @@ func (r *Repository) Login(ctx context.Context, username string, password string
return LoginResult{}, ErrAdminDisabled
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
r.recordLoginFailure(ctx, username, clientIP)
return LoginResult{}, ErrInvalidCredential
}
now := time.Now()
admin.LastLoginAt = &now
if admin.TokenVersion <= 0 {
admin.TokenVersion = 1
}
if err := r.db.WithContext(ctx).Save(&admin).Error; err != nil {
return LoginResult{}, err
}
tokens, err := r.jwt.GenerateSubjectPair(admin.ID, admin.Username, "admin")
r.clearLoginFailures(ctx, username, clientIP)
tokens, err := r.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion)
if err != nil {
return LoginResult{}, err
}
@@ -92,6 +99,26 @@ func (r *Repository) Login(ctx context.Context, username string, password string
return LoginResult{Admin: dto, Tokens: tokens}, nil
}
func (r *Repository) FindActiveForToken(ctx context.Context, id uint64, tokenVersion int64) (*model.AdminUser, error) {
var admin model.AdminUser
if err := r.db.WithContext(ctx).First(&admin, id).Error; err != nil {
return nil, err
}
if admin.Status != "active" {
return nil, ErrAdminDisabled
}
if admin.TokenVersion <= 0 || admin.TokenVersion != tokenVersion {
return nil, ErrInvalidRefreshToken
}
return &admin, nil
}
func (r *Repository) RevokeTokens(ctx context.Context, adminID uint64) error {
return r.db.WithContext(ctx).Model(&model.AdminUser{}).
Where("id = ?", adminID).
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
}
func (r *Repository) verifyCaptcha(ctx context.Context, captchaID string, captchaCode string) error {
if r.redis == nil {
return ErrDependencyUnavailable
@@ -136,48 +163,15 @@ func (r *Repository) UpdateSupportStatus(ctx context.Context, adminID uint64, st
Update("support_status", status).Error
}
func (r *Repository) ensureDefaultAdmin(ctx context.Context) error {
var count int64
if err := r.db.WithContext(ctx).Model(&model.AdminUser{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(defaultAdminPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
admin := model.AdminUser{
Username: defaultAdminUsername,
PasswordHash: string(hash),
Nickname: "超级管理员",
Status: "active",
}
if err := r.db.WithContext(ctx).Create(&admin).Error; err != nil {
return err
}
// 自动关联 super_admin 角色
var superAdminRole model.Role
if err := r.db.WithContext(ctx).Where("code = ?", "super_admin").First(&superAdminRole).Error; err == nil {
r.db.WithContext(ctx).Create(&model.AdminUserRole{
AdminID: admin.ID,
RoleID: superAdminRole.ID,
})
}
return nil
}
func toDTO(admin model.AdminUser) AdminDTO {
return AdminDTO{
ID: admin.ID,
Username: admin.Username,
Nickname: admin.Nickname,
Status: admin.Status,
SupportStatus: admin.SupportStatus,
LastLoginAt: admin.LastLoginAt,
ID: admin.ID,
Username: admin.Username,
Nickname: admin.Nickname,
Status: admin.Status,
SupportStatus: admin.SupportStatus,
PasswordMustChange: admin.PasswordMustChange,
LastLoginAt: admin.LastLoginAt,
}
}
@@ -224,6 +218,62 @@ func cachePermissions(ctx context.Context, r *Repository, adminID uint64, permCo
r.redis.Set(ctx, key, string(raw), 2*time.Hour)
}
func (r *Repository) ensureLoginNotLocked(ctx context.Context, username string, clientIP string) error {
if r.redis == nil {
return nil
}
locked, err := r.redis.Exists(ctx, loginLockKey(username, clientIP)).Result()
if err != nil {
return err
}
if locked > 0 {
return ErrLoginLocked
}
return nil
}
func (r *Repository) recordLoginFailure(ctx context.Context, username string, clientIP string) {
if r.redis == nil {
return
}
key := loginFailureKey(username, clientIP)
count, err := r.redis.Incr(ctx, key).Result()
if err != nil {
return
}
if count == 1 {
_ = r.redis.Expire(ctx, key, loginFailureTTL).Err()
}
if count >= loginMaxFailureCount {
_ = r.redis.Set(ctx, loginLockKey(username, clientIP), "1", loginLockTTL).Err()
}
}
func (r *Repository) clearLoginFailures(ctx context.Context, username string, clientIP string) {
if r.redis == nil {
return
}
_ = r.redis.Del(ctx, loginFailureKey(username, clientIP), loginLockKey(username, clientIP)).Err()
}
func loginFailureKey(username string, clientIP string) string {
return "admin:login:fail:" + loginKeyPart(clientIP) + ":" + loginKeyPart(username)
}
func loginLockKey(username string, clientIP string) string {
return "admin:login:lock:" + loginKeyPart(clientIP) + ":" + loginKeyPart(username)
}
func loginKeyPart(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.ReplaceAll(value, ":", "_")
value = strings.ReplaceAll(value, "/", "_")
if value == "" {
return "_"
}
return value
}
func captchaKey(id string) string {
return "admin:captcha:" + id
}
+21 -3
View File
@@ -13,6 +13,7 @@ var (
ErrCaptchaInvalid = errors.New("captcha invalid")
ErrAdminDisabled = errors.New("admin disabled")
ErrInvalidRefreshToken = errors.New("invalid refresh token")
ErrLoginLocked = errors.New("login locked")
)
type Service struct {
@@ -32,7 +33,17 @@ func (s *Service) Refresh(ctx context.Context, refreshToken string) (*auth.Token
if err != nil {
return nil, ErrInvalidRefreshToken
}
pair, err := s.jwt.GenerateSubjectPair(claims.UserID, claims.Phone, "admin")
if s.repo == nil {
return nil, ErrDependencyUnavailable
}
admin, err := s.repo.FindActiveForToken(ctx, claims.UserID, claims.TokenVersion)
if err != nil {
if errors.Is(err, ErrAdminDisabled) {
return nil, ErrAdminDisabled
}
return nil, ErrInvalidRefreshToken
}
pair, err := s.jwt.GenerateSubjectPairWithVersion(admin.ID, admin.Username, "admin", admin.TokenVersion)
if err != nil {
return nil, err
}
@@ -46,14 +57,14 @@ func (s *Service) Captcha(ctx context.Context) (*CaptchaDTO, error) {
return s.repo.Captcha(ctx)
}
func (s *Service) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string) (LoginResult, error) {
func (s *Service) Login(ctx context.Context, username string, password string, captchaID string, captchaCode string, clientIP string) (LoginResult, error) {
if s.repo == nil {
return LoginResult{}, ErrDependencyUnavailable
}
if username == "" || password == "" || captchaID == "" || captchaCode == "" {
return LoginResult{}, ErrInvalidCredential
}
return s.repo.Login(ctx, username, password, captchaID, captchaCode)
return s.repo.Login(ctx, username, password, captchaID, captchaCode, clientIP)
}
func (s *Service) Me(ctx context.Context, adminID uint64) (*AdminDTO, error) {
@@ -69,3 +80,10 @@ func (s *Service) UpdateSupportStatus(ctx context.Context, adminID uint64, statu
}
return s.repo.UpdateSupportStatus(ctx, adminID, status)
}
func (s *Service) Logout(ctx context.Context, adminID uint64) error {
if s.repo == nil {
return ErrDependencyUnavailable
}
return s.repo.RevokeTokens(ctx, adminID)
}