f
This commit is contained in:
46
internal/domains/security/entities/blacklist.go
Normal file
46
internal/domains/security/entities/blacklist.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package entities
|
||||
|
||||
import "time"
|
||||
|
||||
// IPBlacklistEntry 管理端维护的 IP 黑名单
|
||||
type IPBlacklistEntry struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
IP string `gorm:"type:varchar(64);not null;uniqueIndex" json:"ip"`
|
||||
Reason string `gorm:"type:varchar(500);not null;default:''" json:"reason"`
|
||||
Source string `gorm:"type:varchar(50);not null;default:'self'" json:"source"`
|
||||
CreatedBy string `gorm:"type:varchar(36);not null;default:''" json:"created_by"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime;index" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
DeletedAt *time.Time `gorm:"index" json:"-"` // 软删用手动字段,便于简单实现
|
||||
IsActive bool `gorm:"not null;default:true;index" json:"is_active"`
|
||||
}
|
||||
|
||||
func (IPBlacklistEntry) TableName() string {
|
||||
return "ip_blacklist_entries"
|
||||
}
|
||||
|
||||
// HitType 拦截类型
|
||||
const (
|
||||
HitTypeUserLogin = "user_login"
|
||||
HitTypeUserJWT = "user_jwt"
|
||||
HitTypeUserAPI = "user_api"
|
||||
HitTypeIP = "ip"
|
||||
)
|
||||
|
||||
// BlacklistHitRecord 黑名单拦截记录
|
||||
type BlacklistHitRecord struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
HitType string `gorm:"type:varchar(32);not null;index:idx_hit_type_created,priority:1" json:"hit_type"`
|
||||
UserID string `gorm:"type:varchar(36);index" json:"user_id,omitempty"`
|
||||
Phone string `gorm:"type:varchar(20);index" json:"phone,omitempty"`
|
||||
IP string `gorm:"type:varchar(64);index" json:"ip,omitempty"`
|
||||
Path string `gorm:"type:varchar(255);not null;default:''" json:"path"`
|
||||
Method string `gorm:"type:varchar(16);not null;default:''" json:"method"`
|
||||
Reason string `gorm:"type:varchar(500);not null;default:''" json:"reason"`
|
||||
UserAgent string `gorm:"type:varchar(512);not null;default:''" json:"user_agent"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime;index:idx_hit_type_created,priority:2;index" json:"created_at"`
|
||||
}
|
||||
|
||||
func (BlacklistHitRecord) TableName() string {
|
||||
return "blacklist_hit_records"
|
||||
}
|
||||
228
internal/domains/security/services/blacklist_service.go
Normal file
228
internal/domains/security/services/blacklist_service.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hyapi-server/internal/domains/security/entities"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BlacklistService 黑名单管理与拦截记录
|
||||
type BlacklistService struct {
|
||||
db *gorm.DB
|
||||
logger *zap.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
ipCache map[string]entities.IPBlacklistEntry
|
||||
cacheAt time.Time
|
||||
cacheTTL time.Duration
|
||||
}
|
||||
|
||||
func NewBlacklistService(db *gorm.DB, logger *zap.Logger) *BlacklistService {
|
||||
return &BlacklistService{
|
||||
db: db,
|
||||
logger: logger,
|
||||
ipCache: map[string]entities.IPBlacklistEntry{},
|
||||
cacheTTL: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BlacklistService) InvalidateIPCache() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.ipCache = map[string]entities.IPBlacklistEntry{}
|
||||
s.cacheAt = time.Time{}
|
||||
}
|
||||
|
||||
func (s *BlacklistService) refreshIPCache(ctx context.Context) error {
|
||||
s.mu.RLock()
|
||||
fresh := time.Since(s.cacheAt) < s.cacheTTL && s.cacheAt.Unix() > 0
|
||||
s.mu.RUnlock()
|
||||
if fresh {
|
||||
return nil
|
||||
}
|
||||
|
||||
var list []entities.IPBlacklistEntry
|
||||
if err := s.db.WithContext(ctx).Where("is_active = ?", true).Find(&list).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
m := make(map[string]entities.IPBlacklistEntry, len(list))
|
||||
for _, e := range list {
|
||||
m[strings.TrimSpace(e.IP)] = e
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.ipCache = m
|
||||
s.cacheAt = time.Now()
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsIPBlocked 检查 IP 是否在管理端黑名单(支持精确 IP)
|
||||
func (s *BlacklistService) IsIPBlocked(ctx context.Context, ip string) (bool, *entities.IPBlacklistEntry) {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return false, nil
|
||||
}
|
||||
_ = s.refreshIPCache(ctx)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if e, ok := s.ipCache[ip]; ok {
|
||||
cp := e
|
||||
return true, &cp
|
||||
}
|
||||
// 简易通配:缓存里带 * 的模式
|
||||
for pattern, e := range s.ipCache {
|
||||
if matchIPPattern(ip, pattern) {
|
||||
cp := e
|
||||
return true, &cp
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func matchIPPattern(ip, pattern string) bool {
|
||||
if !strings.Contains(pattern, "*") {
|
||||
return ip == pattern
|
||||
}
|
||||
parts := strings.Split(pattern, ".")
|
||||
clientParts := strings.Split(ip, ".")
|
||||
if len(parts) != 4 || len(clientParts) != 4 {
|
||||
return false
|
||||
}
|
||||
for i := range parts {
|
||||
if parts[i] != "*" && parts[i] != clientParts[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// RecordHit 异步写入拦截记录
|
||||
func (s *BlacklistService) RecordHit(hit *entities.BlacklistHitRecord) {
|
||||
if hit == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := s.db.WithContext(ctx).Create(hit).Error; err != nil {
|
||||
s.logger.Warn("写入黑名单拦截记录失败", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *BlacklistService) ListIP(ctx context.Context, page, pageSize int, keyword string) ([]entities.IPBlacklistEntry, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
q := s.db.WithContext(ctx).Model(&entities.IPBlacklistEntry{}).Where("is_active = ?", true)
|
||||
if keyword != "" {
|
||||
q = q.Where("ip LIKE ? OR reason LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []entities.IPBlacklistEntry
|
||||
err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
func (s *BlacklistService) AddIP(ctx context.Context, ip, reason, source, createdBy string) (*entities.IPBlacklistEntry, error) {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return nil, fmt.Errorf("IP不能为空")
|
||||
}
|
||||
if strings.Contains(ip, "*") {
|
||||
// 允许简单通配,跳过 net.ParseIP
|
||||
} else if net.ParseIP(ip) == nil {
|
||||
return nil, fmt.Errorf("IP格式无效")
|
||||
}
|
||||
if reason == "" {
|
||||
return nil, fmt.Errorf("原因不能为空")
|
||||
}
|
||||
if source == "" {
|
||||
source = "self"
|
||||
}
|
||||
|
||||
var existing entities.IPBlacklistEntry
|
||||
err := s.db.WithContext(ctx).Where("ip = ?", ip).First(&existing).Error
|
||||
if err == nil {
|
||||
if existing.IsActive {
|
||||
return nil, fmt.Errorf("该IP已在黑名单中")
|
||||
}
|
||||
existing.IsActive = true
|
||||
existing.Reason = reason
|
||||
existing.Source = source
|
||||
existing.CreatedBy = createdBy
|
||||
if err := s.db.WithContext(ctx).Save(&existing).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.InvalidateIPCache()
|
||||
return &existing, nil
|
||||
}
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry := &entities.IPBlacklistEntry{
|
||||
IP: ip,
|
||||
Reason: reason,
|
||||
Source: source,
|
||||
CreatedBy: createdBy,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := s.db.WithContext(ctx).Create(entry).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.InvalidateIPCache()
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (s *BlacklistService) RemoveIP(ctx context.Context, id uint64) error {
|
||||
res := s.db.WithContext(ctx).Model(&entities.IPBlacklistEntry{}).
|
||||
Where("id = ? AND is_active = ?", id, true).
|
||||
Updates(map[string]interface{}{"is_active": false})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return fmt.Errorf("记录不存在或已移除")
|
||||
}
|
||||
s.InvalidateIPCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BlacklistService) ListHits(ctx context.Context, page, pageSize int, hitType, keyword string) ([]entities.BlacklistHitRecord, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
q := s.db.WithContext(ctx).Model(&entities.BlacklistHitRecord{})
|
||||
if hitType != "" {
|
||||
q = q.Where("hit_type = ?", hitType)
|
||||
}
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
q = q.Where("phone LIKE ? OR ip LIKE ? OR user_id LIKE ? OR path LIKE ? OR reason LIKE ?", like, like, like, like, like)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []entities.BlacklistHitRecord
|
||||
err := q.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error
|
||||
return list, total, err
|
||||
}
|
||||
@@ -39,6 +39,13 @@ type User struct {
|
||||
LoginCount int `gorm:"default:0" json:"login_count" comment:"登录次数统计"`
|
||||
Permissions string `gorm:"type:text" json:"permissions" comment:"权限列表(JSON格式存储)"`
|
||||
|
||||
// 用户黑名单(网安合规:纳入后拦截登录与 API)
|
||||
IsBlacklisted bool `gorm:"column:is_blacklisted;default:false;index" json:"is_blacklisted" comment:"是否已列入黑名单"`
|
||||
BlacklistReason string `gorm:"column:blacklist_reason;type:varchar(500)" json:"blacklist_reason,omitempty" comment:"纳入黑名单原因"`
|
||||
BlacklistSource string `gorm:"column:blacklist_source;type:varchar(50)" json:"blacklist_source,omitempty" comment:"来源:self_发现/police_公安通报/test"`
|
||||
BlacklistedAt *time.Time `gorm:"column:blacklisted_at" json:"blacklisted_at,omitempty" comment:"黑名单生效时间"`
|
||||
BlacklistedBy string `gorm:"column:blacklisted_by;type:varchar(36)" json:"blacklisted_by,omitempty" comment:"操作人用户ID"`
|
||||
|
||||
// 时间戳字段
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" comment:"创建时间"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" comment:"更新时间"`
|
||||
@@ -457,7 +464,7 @@ func (u *User) ResetPassword(newPassword, confirmPassword string) error {
|
||||
// CanLogin 检查用户是否可以登录
|
||||
func (u *User) CanLogin() bool {
|
||||
// 检查用户是否被删除
|
||||
if !u.DeletedAt.Time.IsZero() {
|
||||
if u.IsDeleted() {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -466,14 +473,62 @@ func (u *User) CanLogin() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果是管理员,检查是否激活
|
||||
if u.IsAdmin() && !u.Active {
|
||||
// 黑名单用户禁止登录
|
||||
if u.IsBlacklisted {
|
||||
return false
|
||||
}
|
||||
|
||||
// 未激活禁止登录(普通用户与管理员一致)
|
||||
if !u.Active {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// IsOnBlacklist 是否在黑名单中
|
||||
func (u *User) IsOnBlacklist() bool {
|
||||
return u.IsBlacklisted
|
||||
}
|
||||
|
||||
// AddToBlacklist 纳入黑名单(立即生效)
|
||||
func (u *User) AddToBlacklist(reason, source, operatorUserID string) error {
|
||||
if u.IsAdmin() {
|
||||
return fmt.Errorf("不能将管理员列入黑名单")
|
||||
}
|
||||
if u.IsBlacklisted {
|
||||
return fmt.Errorf("用户已在黑名单中")
|
||||
}
|
||||
if reason == "" {
|
||||
return NewValidationError("纳入原因不能为空")
|
||||
}
|
||||
if source == "" {
|
||||
source = "self"
|
||||
}
|
||||
now := time.Now()
|
||||
u.IsBlacklisted = true
|
||||
u.BlacklistReason = reason
|
||||
u.BlacklistSource = source
|
||||
u.BlacklistedAt = &now
|
||||
u.BlacklistedBy = operatorUserID
|
||||
u.Active = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveFromBlacklist 移出黑名单并恢复激活
|
||||
func (u *User) RemoveFromBlacklist() error {
|
||||
if !u.IsBlacklisted {
|
||||
return fmt.Errorf("用户不在黑名单中")
|
||||
}
|
||||
u.IsBlacklisted = false
|
||||
u.BlacklistReason = ""
|
||||
u.BlacklistSource = ""
|
||||
u.BlacklistedAt = nil
|
||||
u.BlacklistedBy = ""
|
||||
u.Active = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsActive 检查用户是否处于活跃状态
|
||||
func (u *User) IsActive() bool {
|
||||
return u.DeletedAt.Time.IsZero()
|
||||
|
||||
@@ -6,11 +6,12 @@ type ListUsersQuery struct {
|
||||
PageSize int `json:"page_size"`
|
||||
Phone string `json:"phone"`
|
||||
UserType string `json:"user_type"` // 用户类型: user/admin
|
||||
IsActive *bool `json:"is_active"` // 是否激活
|
||||
IsCertified *bool `json:"is_certified"` // 是否已认证
|
||||
CompanyName string `json:"company_name"` // 企业名称
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
IsActive *bool `json:"is_active"` // 是否激活
|
||||
IsCertified *bool `json:"is_certified"` // 是否已认证
|
||||
IsBlacklisted *bool `json:"is_blacklisted"` // 是否黑名单
|
||||
CompanyName string `json:"company_name"` // 企业名称
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
}
|
||||
|
||||
// ListSMSCodesQuery 短信验证码列表查询参数
|
||||
|
||||
@@ -8,23 +8,27 @@ import (
|
||||
|
||||
"hyapi-server/internal/domains/user/entities"
|
||||
"hyapi-server/internal/domains/user/repositories"
|
||||
securityEntities "hyapi-server/internal/domains/security/entities"
|
||||
securityServices "hyapi-server/internal/domains/security/services"
|
||||
)
|
||||
|
||||
// UserAuthService 用户认证领域服务
|
||||
// 负责用户认证相关的业务逻辑,包括密码验证、登录状态管理等
|
||||
type UserAuthService struct {
|
||||
userRepo repositories.UserRepository
|
||||
logger *zap.Logger
|
||||
userRepo repositories.UserRepository
|
||||
blacklist *securityServices.BlacklistService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewUserAuthService 创建用户认证领域服务
|
||||
func NewUserAuthService(
|
||||
userRepo repositories.UserRepository,
|
||||
blacklist *securityServices.BlacklistService,
|
||||
logger *zap.Logger,
|
||||
) *UserAuthService {
|
||||
return &UserAuthService{
|
||||
userRepo: userRepo,
|
||||
logger: logger,
|
||||
userRepo: userRepo,
|
||||
blacklist: blacklist,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +39,17 @@ func (s *UserAuthService) ValidatePassword(ctx context.Context, phone, password
|
||||
return nil, fmt.Errorf("用户名或密码错误")
|
||||
}
|
||||
|
||||
if user.IsOnBlacklist() {
|
||||
if s.blacklist != nil {
|
||||
s.blacklist.RecordHit(&securityEntities.BlacklistHitRecord{
|
||||
HitType: securityEntities.HitTypeUserLogin,
|
||||
UserID: user.ID,
|
||||
Phone: user.Phone,
|
||||
Reason: user.BlacklistReason,
|
||||
})
|
||||
}
|
||||
return nil, fmt.Errorf("账号已被列入黑名单,无法登录")
|
||||
}
|
||||
if !user.CanLogin() {
|
||||
return nil, fmt.Errorf("用户状态异常,无法登录")
|
||||
}
|
||||
@@ -54,6 +69,17 @@ func (s *UserAuthService) ValidateUserLogin(ctx context.Context, phone string) (
|
||||
return nil, fmt.Errorf("用户不存在")
|
||||
}
|
||||
|
||||
if user.IsOnBlacklist() {
|
||||
if s.blacklist != nil {
|
||||
s.blacklist.RecordHit(&securityEntities.BlacklistHitRecord{
|
||||
HitType: securityEntities.HitTypeUserLogin,
|
||||
UserID: user.ID,
|
||||
Phone: user.Phone,
|
||||
Reason: user.BlacklistReason,
|
||||
})
|
||||
}
|
||||
return nil, fmt.Errorf("账号已被列入黑名单,无法登录")
|
||||
}
|
||||
if !user.CanLogin() {
|
||||
return nil, fmt.Errorf("用户状态异常,无法登录")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user