diff --git a/backend/database/seed.go b/backend/database/seed.go index d00dc95..34a10ce 100644 --- a/backend/database/seed.go +++ b/backend/database/seed.go @@ -3,7 +3,10 @@ package database import ( "backend/internal/model" "backend/utils" + "crypto/rand" + "encoding/base64" "log" + "os" "time" "gorm.io/gorm" @@ -11,9 +14,22 @@ import ( const ( defaultAdminUsername = "admin" + // 仅 debug 模式使用固定默认密码;release 模式首次启动生成随机密码并打印到日志 // Logan.Li defaultAdminPassword = "admin" ) +// initialAdminPassword 按运行模式决定初始管理员密码:release 随机生成,debug 用固定值 // Logan.Li +func initialAdminPassword() string { + if os.Getenv("GIN_MODE") == "release" { + buf := make([]byte, 15) + if _, err := rand.Read(buf); err != nil { + log.Fatal("[安全] 生成初始管理员密码失败:", err) + } + return base64.RawURLEncoding.EncodeToString(buf) + } + return defaultAdminPassword +} + // SeedDefaults 初始化默认数据:管理员账号 + 系统设置 + 示例业务数据 // Logan.Li func SeedDefaults(db *gorm.DB) error { if err := seedAdmin(db); err != nil { @@ -42,9 +58,10 @@ func seedAdmin(db *gorm.DB) error { return nil } + initialPassword := initialAdminPassword() admin := model.User{ Username: defaultAdminUsername, - Password: utils.HashPassword(defaultAdminPassword), + Password: utils.HashPassword(initialPassword), Email: "admin@opsx-licence.local", Role: "admin", IsActive: true, @@ -52,7 +69,8 @@ func seedAdmin(db *gorm.DB) error { if err := db.Create(&admin).Error; err != nil { return err } - log.Printf("[初始化] 已创建默认管理员账号: %s / %s(请尽快修改密码)", defaultAdminUsername, defaultAdminPassword) + log.Printf("[初始化] 已创建管理员账号: %s / %s(仅打印一次,release 部署后请立即修改密码)", + defaultAdminUsername, initialPassword) return nil } diff --git a/backend/internal/controller/auth/auth_controller.go b/backend/internal/controller/auth/auth_controller.go index 81ae285..04a51f6 100644 --- a/backend/internal/controller/auth/auth_controller.go +++ b/backend/internal/controller/auth/auth_controller.go @@ -3,6 +3,7 @@ package auth import ( "backend/config" authservice "backend/internal/service/auth" + "backend/utils" "net/http" "github.com/gin-gonic/gin" @@ -12,16 +13,18 @@ import ( // AuthController 认证控制器 // Logan.Li type AuthController struct { authService *authservice.AuthService + db *gorm.DB } // NewAuthController 创建控制器实例 // Logan.Li func NewAuthController(db *gorm.DB, cfg *config.Config) *AuthController { return &AuthController{ authService: authservice.NewAuthService(db, cfg), + db: db, } } -// Login 管理员登录 // Logan.Li +// Login 管理员登录(成功/失败均记审计) // Logan.Li func (ctrl *AuthController) Login(c *gin.Context) { var req struct { Username string `json:"username" binding:"required"` @@ -34,10 +37,12 @@ func (ctrl *AuthController) Login(c *gin.Context) { result, err := ctrl.authService.Login(req) if err != nil { + utils.LogAuthAction(ctrl.db, c, "login", req.Username, err.Error(), "failure") c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()}) return } + utils.LogAuthAction(ctrl.db, c, "login", req.Username, "登录成功", "success") c.JSON(http.StatusOK, gin.H{"code": 0, "message": "登录成功", "data": result}) } diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index bbaac08..31c04bd 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -16,9 +16,14 @@ type User struct { Role string `json:"role" gorm:"size:20;default:'admin'"` // 固定 admin,预留扩展 IsActive bool `json:"is_active" gorm:"default:true"` LastLogin *time.Time `json:"last_login,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index"` + // 会话失效基线:签发时间早于该值的 JWT 一律拒绝(改密码时刷新为当前时间) // Logan.Li + TokenValidAfter time.Time `json:"-" gorm:"default:CURRENT_TIMESTAMP"` + // 账号级登录防护:连续失败计数与锁定截止时间 // Logan.Li + FailedLoginAttempts int `json:"-" gorm:"default:0"` + LockedUntil *time.Time `json:"-" gorm:"default:null"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index"` } // TableName 指定表名 // Logan.Li diff --git a/backend/internal/service/auth/auth_service.go b/backend/internal/service/auth/auth_service.go index 1ffdfeb..58db181 100644 --- a/backend/internal/service/auth/auth_service.go +++ b/backend/internal/service/auth/auth_service.go @@ -6,6 +6,7 @@ import ( "backend/internal/model" "backend/utils" "errors" + "fmt" "time" "gorm.io/gorm" @@ -17,12 +18,19 @@ type AuthService struct { cfg *config.Config } +// 登录防护参数:连续失败 5 次锁定 15 分钟(账号级,与 IP 限流互补) // Logan.Li +const ( + maxLoginAttempts = 5 + loginLockDuration = 15 * time.Minute +) + // NewAuthService 创建 service 实例 // Logan.Li func NewAuthService(db *gorm.DB, cfg *config.Config) *AuthService { return &AuthService{db: db, cfg: cfg} } -// Login 校验账号密码并签发 JWT // Logan.Li +// Login 校验账号密码并签发 JWT。 +// 含账号级防护:连续失败达上限后锁定一段时间;成功登录清零计数。 // Logan.Li func (s *AuthService) Login(req dto.LoginRequest) (*dto.LoginResponse, error) { var user model.User if err := s.db.Where("username = ?", req.Username).First(&user).Error; err != nil { @@ -33,10 +41,25 @@ func (s *AuthService) Login(req dto.LoginRequest) (*dto.LoginResponse, error) { return nil, errors.New("账号已被禁用") } + // 账号锁定检查(到点自动解锁) // Logan.Li + if user.LockedUntil != nil && user.LockedUntil.After(time.Now()) { + remaining := int(time.Until(*user.LockedUntil).Minutes()) + 1 + return nil, fmt.Errorf("失败次数过多,账号已锁定,请约 %d 分钟后再试", remaining) + } + if !utils.CheckPassword(req.Password, user.Password) { + s.registerLoginFailure(&user) return nil, errors.New("用户名或密码错误") } + // 登录成功:清零失败计数与锁定 // Logan.Li + if user.FailedLoginAttempts > 0 || user.LockedUntil != nil { + s.db.Model(&user).Updates(map[string]interface{}{ + "failed_login_attempts": 0, + "locked_until": nil, + }) + } + // 更新最后登录时间 // Logan.Li now := time.Now() s.db.Model(&user).Update("last_login", now) @@ -59,6 +82,18 @@ func (s *AuthService) Login(req dto.LoginRequest) (*dto.LoginResponse, error) { }, nil } +// registerLoginFailure 记录一次登录失败,达到上限则锁定账号 // Logan.Li +func (s *AuthService) registerLoginFailure(user *model.User) { + attempts := user.FailedLoginAttempts + 1 + updates := map[string]interface{}{"failed_login_attempts": attempts} + if attempts >= maxLoginAttempts { + lockedUntil := time.Now().Add(loginLockDuration) + updates["locked_until"] = &lockedUntil + updates["failed_login_attempts"] = 0 // 锁定期结束后重新计数 // Logan.Li + } + s.db.Model(user).Updates(updates) +} + // GetCurrentUser 根据 ID 获取当前用户信息 // Logan.Li func (s *AuthService) GetCurrentUser(userID uint) (*dto.UserInfoResponse, error) { var user model.User @@ -75,7 +110,7 @@ func (s *AuthService) GetCurrentUser(userID uint) (*dto.UserInfoResponse, error) }, nil } -// ChangePassword 修改当前用户密码 // Logan.Li +// ChangePassword 修改当前用户密码;同时刷新会话失效基线,使所有旧令牌立即作废 // Logan.Li func (s *AuthService) ChangePassword(userID uint, req dto.ChangePasswordRequest) error { var user model.User if err := s.db.First(&user, userID).Error; err != nil { @@ -87,5 +122,8 @@ func (s *AuthService) ChangePassword(userID uint, req dto.ChangePasswordRequest) } user.Password = utils.HashPassword(req.NewPassword) + user.TokenValidAfter = time.Now() // 旧 JWT 全部失效,需重新登录 // Logan.Li + user.FailedLoginAttempts = 0 + user.LockedUntil = nil return s.db.Save(&user).Error } diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go index 4325866..ed897e4 100644 --- a/backend/middleware/auth.go +++ b/backend/middleware/auth.go @@ -2,15 +2,19 @@ package middleware import ( "backend/config" + "backend/internal/model" "backend/utils" "net/http" "strings" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) -// AuthMiddleware 校验访问令牌并将用户身份写入请求上下文。 // Logan.Li -func AuthMiddleware(cfg *config.Config) gin.HandlerFunc { +// AuthMiddleware 校验访问令牌并将用户身份写入请求上下文。 +// 除签名/过期外,还校验:账号是否存在且未禁用、令牌签发时间是否早于会话失效基线 +// (修改密码会刷新基线,旧令牌立即作废)。 // Logan.Li +func AuthMiddleware(cfg *config.Config, db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") if authHeader == "" { @@ -47,6 +51,38 @@ func AuthMiddleware(cfg *config.Config) gin.HandlerFunc { return } + // 账号状态与会话失效基线校验:改密/禁用/删除后旧令牌立即失效 // Logan.Li + if db != nil { + var user model.User + if err := db.First(&user, claims.UserID).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 401, + "message": "账号不存在或已被删除", + "data": nil, + }) + c.Abort() + return + } + if !user.IsActive { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 401, + "message": "账号已被禁用", + "data": nil, + }) + c.Abort() + return + } + if claims.IssuedAt != nil && user.TokenValidAfter.After(claims.IssuedAt.Time) { + c.JSON(http.StatusUnauthorized, gin.H{ + "code": 401, + "message": "登录状态已失效,请重新登录", + "data": nil, + }) + c.Abort() + return + } + } + // 将用户身份写入上下文,供后续 handler 使用 // Logan.Li c.Set("user_id", claims.UserID) c.Set("username", claims.Username) diff --git a/backend/router/router.go b/backend/router/router.go index 5ff9aed..b010daa 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -136,7 +136,7 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine { } // ============ 需要认证的管理路由 ============ - protected := api.Group("", middleware.AuthMiddleware(cfg)) + protected := api.Group("", middleware.AuthMiddleware(cfg, db)) { // 认证相关 register(protected, "GET", "/auth/me", authController.Me) diff --git a/backend/utils/audit.go b/backend/utils/audit.go index c8d5eaf..6afa8a9 100644 --- a/backend/utils/audit.go +++ b/backend/utils/audit.go @@ -60,3 +60,17 @@ func LogSystemAction(db *gorm.DB, action, targetType string, targetID uint, targ func GetClientIP(c *gin.Context) string { return c.ClientIP() } + +// LogAuthAction 记录认证类日志(登录成功/失败/锁定),登录前无用户上下文,显式传用户名 // Logan.Li +func LogAuthAction(db *gorm.DB, c *gin.Context, action, username, detail, result string) { + db.Create(&model.AuditLog{ + Username: username, + Action: action, + TargetType: "user", + TargetName: username, + Detail: detail, + Result: result, + IP: c.ClientIP(), + CreatedAt: time.Now(), + }) +}