fix(ipam): 修复IP管理页面网段筛选不生效及下拉框过窄
- Ips.vue: el-select 添加 width: 280px,修复网段下拉框过窄 - Ips.vue: query 参数 networkId → network_id(与 FastAPI snake_case 一致) - Scan.vue: el-select 用 network.id 代替整个 network 对象作为 value,修复切换网段不生效 - enhanced_scan_service.py: 替换失效的 scapy ARP 扫描为 arp-scan 命令;扫描结果自动创建缺失 IP 记录
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,496 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import json
|
||||
|
||||
from app.models.alert import (
|
||||
Alert, AlertRule, WhitelistedMAC,
|
||||
AlertType, AlertSeverity, AlertStatus
|
||||
)
|
||||
from app.models.network import Network, IPAddress
|
||||
from app.models.snmp import NetworkDevice, ARPEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AlertService:
|
||||
"""告警检测与管理服务"""
|
||||
|
||||
@staticmethod
|
||||
def create_alert(db: Session,
|
||||
alert_type: AlertType,
|
||||
title: str,
|
||||
message: str = "",
|
||||
severity: AlertSeverity = AlertSeverity.WARNING,
|
||||
network_id: Optional[int] = None,
|
||||
ip_address_id: Optional[int] = None,
|
||||
device_id: Optional[int] = None,
|
||||
ip_address_str: Optional[str] = None,
|
||||
mac_address: Optional[str] = None,
|
||||
conflicting_mac: Optional[str] = None,
|
||||
usage_percent: Optional[float] = None) -> Alert:
|
||||
"""创建告警"""
|
||||
# 检查是否已有相同的活跃告警,避免重复告警
|
||||
existing = db.query(Alert).filter(
|
||||
Alert.alert_type == alert_type,
|
||||
Alert.status == AlertStatus.ACTIVE
|
||||
)
|
||||
|
||||
if ip_address_str:
|
||||
existing = existing.filter(Alert.ip_address_str == ip_address_str)
|
||||
if network_id:
|
||||
existing = existing.filter(Alert.network_id == network_id)
|
||||
if device_id:
|
||||
existing = existing.filter(Alert.device_id == device_id)
|
||||
|
||||
existing = existing.first()
|
||||
|
||||
if existing:
|
||||
logger.debug(f"已有相同的活跃告警: {alert_type} - {ip_address_str or network_id or device_id}")
|
||||
return existing
|
||||
|
||||
alert = Alert(
|
||||
alert_type=alert_type,
|
||||
title=title,
|
||||
message=message,
|
||||
severity=severity,
|
||||
network_id=network_id,
|
||||
ip_address_id=ip_address_id,
|
||||
device_id=device_id,
|
||||
ip_address_str=ip_address_str,
|
||||
mac_address=mac_address,
|
||||
conflicting_mac=conflicting_mac,
|
||||
usage_percent=usage_percent
|
||||
)
|
||||
|
||||
db.add(alert)
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
|
||||
logger.info(f"创建告警: {alert_type} - {title}")
|
||||
return alert
|
||||
|
||||
@staticmethod
|
||||
def detect_ip_conflicts(db: Session) -> List[Dict[str, Any]]:
|
||||
"""检测 IP 冲突"""
|
||||
conflicts = []
|
||||
|
||||
# 检查 ARP 表中同一个 IP 对应多个不同 MAC 的情况
|
||||
subquery = db.query(
|
||||
ARPEntry.ip_address
|
||||
).group_by(ARPEntry.ip_address).having(
|
||||
db.func.count(db.func.distinct(ARPEntry.mac_address)) > 1
|
||||
).subquery()
|
||||
|
||||
conflict_entries = db.query(ARPEntry).filter(
|
||||
ARPEntry.ip_address.in_(subquery)
|
||||
).order_by(ARPEntry.ip_address).all()
|
||||
|
||||
# 按 IP 分组
|
||||
ip_macs = {}
|
||||
for entry in conflict_entries:
|
||||
if entry.ip_address not in ip_macs:
|
||||
ip_macs[entry.ip_address] = set()
|
||||
ip_macs[entry.ip_address].add(entry.mac_address)
|
||||
|
||||
# 为每个冲突创建告警
|
||||
for ip, macs in ip_macs.items():
|
||||
if len(macs) > 1:
|
||||
mac_list = list(macs)
|
||||
conflict_info = {
|
||||
'ip_address': ip,
|
||||
'mac_addresses': mac_list
|
||||
}
|
||||
conflicts.append(conflict_info)
|
||||
|
||||
# 创建告警
|
||||
AlertService.create_alert(
|
||||
db=db,
|
||||
alert_type=AlertType.IP_CONFLICT,
|
||||
title=f"IP 冲突检测: {ip}",
|
||||
message=f"IP 地址 {ip} 检测到多个 MAC 地址: {', '.join(mac_list)}",
|
||||
severity=AlertSeverity.ERROR,
|
||||
ip_address_str=ip,
|
||||
mac_address=mac_list[0],
|
||||
conflicting_mac=mac_list[1] if len(mac_list) > 1 else None
|
||||
)
|
||||
|
||||
return conflicts
|
||||
|
||||
@staticmethod
|
||||
def detect_unauthorized_access(db: Session) -> List[Dict[str, Any]]:
|
||||
"""检测未授权接入(新 MAC 地址不在白名单中)"""
|
||||
unauthorized = []
|
||||
|
||||
# 获取白名单 MAC
|
||||
whitelist = set()
|
||||
whitelist_entries = db.query(WhitelistedMAC).filter(
|
||||
WhitelistedMAC.is_active == True
|
||||
).all()
|
||||
for entry in whitelist_entries:
|
||||
whitelist.add(entry.mac_address.upper())
|
||||
|
||||
# 获取所有 ARP 表中的 MAC 地址
|
||||
arp_entries = db.query(ARPEntry).all()
|
||||
|
||||
for entry in arp_entries:
|
||||
if not entry.mac_address:
|
||||
continue
|
||||
|
||||
mac = entry.mac_address.upper()
|
||||
if mac and mac not in whitelist:
|
||||
# 检查是否是最近发现的(1小时内)
|
||||
is_recent = entry.discovered_at >= datetime.utcnow() - timedelta(hours=1)
|
||||
|
||||
if is_recent:
|
||||
unauth_info = {
|
||||
'ip_address': entry.ip_address,
|
||||
'mac_address': mac,
|
||||
'discovered_at': entry.discovered_at
|
||||
}
|
||||
unauthorized.append(unauth_info)
|
||||
|
||||
# 创建告警
|
||||
AlertService.create_alert(
|
||||
db=db,
|
||||
alert_type=AlertType.UNAUTHORIZED_ACCESS,
|
||||
title=f"未授权接入: {mac}",
|
||||
message=f"检测到未授权设备接入: IP {entry.ip_address}, MAC {mac}",
|
||||
severity=AlertSeverity.WARNING,
|
||||
ip_address_str=entry.ip_address,
|
||||
mac_address=mac
|
||||
)
|
||||
|
||||
return unauthorized
|
||||
|
||||
@staticmethod
|
||||
def detect_subnet_exhaustion(db: Session) -> List[Dict[str, Any]]:
|
||||
"""检测网段耗尽"""
|
||||
exhausted = []
|
||||
|
||||
# 获取所有网段的统计信息
|
||||
networks = db.query(Network).all()
|
||||
|
||||
for network in networks:
|
||||
if network.total_ips == 0:
|
||||
continue
|
||||
|
||||
# 计算使用中的 IP 数量(在线或已分配)
|
||||
used_count = db.query(IPAddress).filter(
|
||||
IPAddress.network_id == network.id
|
||||
).filter(
|
||||
(IPAddress.mac_address.isnot(None)) |
|
||||
(IPAddress.hostname.isnot(None))
|
||||
).count()
|
||||
|
||||
usage_percent = (used_count / network.total_ips) * 100
|
||||
|
||||
# 默认阈值 90%
|
||||
threshold = 90.0
|
||||
|
||||
if usage_percent >= threshold:
|
||||
exhaustion_info = {
|
||||
'network_id': network.id,
|
||||
'cidr': network.cidr,
|
||||
'name': network.name,
|
||||
'total_ips': network.total_ips,
|
||||
'used_ips': used_count,
|
||||
'usage_percent': round(usage_percent, 2)
|
||||
}
|
||||
exhausted.append(exhaustion_info)
|
||||
|
||||
# 创建告警
|
||||
AlertService.create_alert(
|
||||
db=db,
|
||||
alert_type=AlertType.SUBNET_FULL,
|
||||
title=f"网段使用率告警: {network.cidr}",
|
||||
message=f"网段 {network.cidr} ({network.name}) 使用率达到 {round(usage_percent, 2)}%,总IP {network.total_ips},已使用 {used_count}",
|
||||
severity=AlertSeverity.WARNING if usage_percent < 95 else AlertSeverity.CRITICAL,
|
||||
network_id=network.id,
|
||||
usage_percent=usage_percent
|
||||
)
|
||||
|
||||
return exhausted
|
||||
|
||||
@staticmethod
|
||||
def detect_new_devices(db: Session) -> List[Dict[str, Any]]:
|
||||
"""检测新发现的设备"""
|
||||
new_devices = []
|
||||
|
||||
# 查找最近 1 小时内发现的新 MAC 地址
|
||||
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
||||
|
||||
recent_entries = db.query(ARPEntry).filter(
|
||||
ARPEntry.discovered_at >= one_hour_ago
|
||||
).all()
|
||||
|
||||
for entry in recent_entries:
|
||||
# 检查此 MAC 是否是新出现的
|
||||
older_entries = db.query(ARPEntry).filter(
|
||||
ARPEntry.mac_address == entry.mac_address,
|
||||
ARPEntry.discovered_at < one_hour_ago
|
||||
).count()
|
||||
|
||||
if older_entries == 0 and entry.mac_address:
|
||||
device_info = {
|
||||
'ip_address': entry.ip_address,
|
||||
'mac_address': entry.mac_address,
|
||||
'discovered_at': entry.discovered_at
|
||||
}
|
||||
new_devices.append(device_info)
|
||||
|
||||
# 创建告警
|
||||
AlertService.create_alert(
|
||||
db=db,
|
||||
alert_type=AlertType.NEW_DEVICE_DETECTED,
|
||||
title=f"新设备发现: {entry.ip_address}",
|
||||
message=f"检测到新设备: IP {entry.ip_address}, MAC {entry.mac_address}",
|
||||
severity=AlertSeverity.INFO,
|
||||
ip_address_str=entry.ip_address,
|
||||
mac_address=entry.mac_address
|
||||
)
|
||||
|
||||
return new_devices
|
||||
|
||||
@staticmethod
|
||||
def detect_device_offline(db: Session) -> List[Dict[str, Any]]:
|
||||
"""检测设备离线"""
|
||||
offline_devices = []
|
||||
|
||||
# 检查网络设备是否长时间没有成功轮询
|
||||
threshold_time = datetime.utcnow() - timedelta(hours=2)
|
||||
|
||||
offline_network_devices = db.query(NetworkDevice).filter(
|
||||
NetworkDevice.is_active == True,
|
||||
(NetworkDevice.last_successful_poll < threshold_time) |
|
||||
(NetworkDevice.last_successful_poll == None)
|
||||
).all()
|
||||
|
||||
for device in offline_network_devices:
|
||||
offline_info = {
|
||||
'device_id': device.id,
|
||||
'device_name': device.name,
|
||||
'ip_address': device.ip_address,
|
||||
'last_polled_at': device.last_polled_at,
|
||||
'last_successful_poll': device.last_successful_poll
|
||||
}
|
||||
offline_devices.append(offline_info)
|
||||
|
||||
# 创建告警
|
||||
AlertService.create_alert(
|
||||
db=db,
|
||||
alert_type=AlertType.DEVICE_OFFLINE,
|
||||
title=f"设备离线: {device.name}",
|
||||
message=f"网络设备 {device.name} ({device.ip_address}) 已超过 2 小时未成功轮询",
|
||||
severity=AlertSeverity.ERROR,
|
||||
device_id=device.id
|
||||
)
|
||||
|
||||
return offline_devices
|
||||
|
||||
@staticmethod
|
||||
def run_all_detections(db: Session) -> Dict[str, Any]:
|
||||
"""运行所有检测"""
|
||||
results = {}
|
||||
|
||||
logger.info("开始运行告警检测...")
|
||||
|
||||
# 检测 IP 冲突
|
||||
conflicts = AlertService.detect_ip_conflicts(db)
|
||||
results['ip_conflicts'] = {
|
||||
'count': len(conflicts),
|
||||
'items': conflicts
|
||||
}
|
||||
|
||||
# 检测未授权接入
|
||||
unauthorized = AlertService.detect_unauthorized_access(db)
|
||||
results['unauthorized_access'] = {
|
||||
'count': len(unauthorized),
|
||||
'items': unauthorized
|
||||
}
|
||||
|
||||
# 检测网段耗尽
|
||||
subnet_exhaustion = AlertService.detect_subnet_exhaustion(db)
|
||||
results['subnet_exhaustion'] = {
|
||||
'count': len(subnet_exhaustion),
|
||||
'items': subnet_exhaustion
|
||||
}
|
||||
|
||||
# 检测新设备
|
||||
new_devices = AlertService.detect_new_devices(db)
|
||||
results['new_devices'] = {
|
||||
'count': len(new_devices),
|
||||
'items': new_devices
|
||||
}
|
||||
|
||||
# 检测设备离线
|
||||
offline_devices = AlertService.detect_device_offline(db)
|
||||
results['offline_devices'] = {
|
||||
'count': len(offline_devices),
|
||||
'items': offline_devices
|
||||
}
|
||||
|
||||
total_alerts = sum(v['count'] for v in results.values())
|
||||
logger.info(f"告警检测完成,共发现 {total_alerts} 个问题")
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def get_alerts(db: Session,
|
||||
status: Optional[AlertStatus] = None,
|
||||
severity: Optional[AlertSeverity] = None,
|
||||
alert_type: Optional[AlertType] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100) -> tuple[int, List[Alert]]:
|
||||
"""获取告警列表"""
|
||||
query = db.query(Alert)
|
||||
|
||||
if status:
|
||||
query = query.filter(Alert.status == status)
|
||||
if severity:
|
||||
query = query.filter(Alert.severity == severity)
|
||||
if alert_type:
|
||||
query = query.filter(Alert.alert_type == alert_type)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(Alert.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def acknowledge_alert(db: Session, alert_id: int, acknowledged_by: str = "system") -> Optional[Alert]:
|
||||
"""确认告警"""
|
||||
alert = db.query(Alert).filter(Alert.id == alert_id).first()
|
||||
if not alert:
|
||||
return None
|
||||
|
||||
alert.status = AlertStatus.ACKNOWLEDGED
|
||||
alert.acknowledged_by = acknowledged_by
|
||||
alert.acknowledged_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
|
||||
return alert
|
||||
|
||||
@staticmethod
|
||||
def resolve_alert(db: Session, alert_id: int, resolved_by: str = "system", notes: str = "") -> Optional[Alert]:
|
||||
"""解决告警"""
|
||||
alert = db.query(Alert).filter(Alert.id == alert_id).first()
|
||||
if not alert:
|
||||
return None
|
||||
|
||||
alert.status = AlertStatus.RESOLVED
|
||||
alert.resolved_by = resolved_by
|
||||
alert.resolved_at = datetime.utcnow()
|
||||
alert.resolution_notes = notes
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
|
||||
return alert
|
||||
|
||||
@staticmethod
|
||||
def ignore_alert(db: Session, alert_id: int) -> Optional[Alert]:
|
||||
"""忽略告警"""
|
||||
alert = db.query(Alert).filter(Alert.id == alert_id).first()
|
||||
if not alert:
|
||||
return None
|
||||
|
||||
alert.status = AlertStatus.IGNORED
|
||||
db.commit()
|
||||
db.refresh(alert)
|
||||
|
||||
return alert
|
||||
|
||||
@staticmethod
|
||||
def get_statistics(db: Session) -> Dict[str, Any]:
|
||||
"""获取告警统计"""
|
||||
active_count = db.query(Alert).filter(Alert.status == AlertStatus.ACTIVE).count()
|
||||
acknowledged_count = db.query(Alert).filter(Alert.status == AlertStatus.ACKNOWLEDGED).count()
|
||||
resolved_count = db.query(Alert).filter(Alert.status == AlertStatus.RESOLVED).count()
|
||||
|
||||
# 按严重级别统计
|
||||
critical_count = db.query(Alert).filter(
|
||||
Alert.status == AlertStatus.ACTIVE,
|
||||
Alert.severity == AlertSeverity.CRITICAL
|
||||
).count()
|
||||
error_count = db.query(Alert).filter(
|
||||
Alert.status == AlertStatus.ACTIVE,
|
||||
Alert.severity == AlertSeverity.ERROR
|
||||
).count()
|
||||
warning_count = db.query(Alert).filter(
|
||||
Alert.status == AlertStatus.ACTIVE,
|
||||
Alert.severity == AlertSeverity.WARNING
|
||||
).count()
|
||||
info_count = db.query(Alert).filter(
|
||||
Alert.status == AlertStatus.ACTIVE,
|
||||
Alert.severity == AlertSeverity.INFO
|
||||
).count()
|
||||
|
||||
# 按类型统计
|
||||
type_stats = {}
|
||||
for alert_type in AlertType:
|
||||
count = db.query(Alert).filter(
|
||||
Alert.status == AlertStatus.ACTIVE,
|
||||
Alert.alert_type == alert_type
|
||||
).count()
|
||||
type_stats[alert_type.value] = count
|
||||
|
||||
return {
|
||||
'by_status': {
|
||||
'active': active_count,
|
||||
'acknowledged': acknowledged_count,
|
||||
'resolved': resolved_count
|
||||
},
|
||||
'by_severity': {
|
||||
'critical': critical_count,
|
||||
'error': error_count,
|
||||
'warning': warning_count,
|
||||
'info': info_count
|
||||
},
|
||||
'by_type': type_stats
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def add_mac_to_whitelist(db: Session, mac_address: str, description: str = "", owner: str = "") -> WhitelistedMAC:
|
||||
"""添加 MAC 地址到白名单"""
|
||||
existing = db.query(WhitelistedMAC).filter(WhitelistedMAC.mac_address == mac_address.upper()).first()
|
||||
if existing:
|
||||
existing.is_active = True
|
||||
if description:
|
||||
existing.description = description
|
||||
if owner:
|
||||
existing.owner = owner
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
|
||||
whitelisted_mac = WhitelistedMAC(
|
||||
mac_address=mac_address.upper(),
|
||||
description=description,
|
||||
owner=owner
|
||||
)
|
||||
db.add(whitelisted_mac)
|
||||
db.commit()
|
||||
db.refresh(whitelisted_mac)
|
||||
|
||||
return whitelisted_mac
|
||||
|
||||
@staticmethod
|
||||
def get_mac_whitelist(db: Session, skip: int = 0, limit: int = 100) -> tuple[int, List[WhitelistedMAC]]:
|
||||
"""获取 MAC 白名单"""
|
||||
query = db.query(WhitelistedMAC).filter(WhitelistedMAC.is_active == True)
|
||||
total = query.count()
|
||||
items = query.order_by(WhitelistedMAC.id.desc()).offset(skip).limit(limit).all()
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def remove_mac_from_whitelist(db: Session, mac_id: int) -> bool:
|
||||
"""从白名单移除 MAC 地址"""
|
||||
mac = db.query(WhitelistedMAC).filter(WhitelistedMAC.id == mac_id).first()
|
||||
if not mac:
|
||||
return False
|
||||
|
||||
mac.is_active = False
|
||||
db.commit()
|
||||
return True
|
||||
@@ -0,0 +1,155 @@
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.auth import User
|
||||
|
||||
|
||||
class AuditService:
|
||||
"""审计日志服务"""
|
||||
|
||||
@staticmethod
|
||||
def record(
|
||||
db: Session,
|
||||
*,
|
||||
action: str,
|
||||
user: Optional[User] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
resource_id: Optional[str] = None,
|
||||
resource_name: Optional[str] = None,
|
||||
method: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
user_agent: Optional[str] = None,
|
||||
status: str = "success",
|
||||
detail: Optional[Any] = None,
|
||||
) -> AuditLog:
|
||||
"""
|
||||
写入一条审计日志。任意字段缺失都安全降级(不抛异常),
|
||||
避免审计日志写入失败影响主业务流程。
|
||||
"""
|
||||
try:
|
||||
log = AuditLog(
|
||||
user_id=user.id if user else None,
|
||||
username=user.username if user else None,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=str(resource_id) if resource_id is not None else None,
|
||||
resource_name=resource_name,
|
||||
method=method,
|
||||
path=path,
|
||||
ip_address=ip_address,
|
||||
user_agent=(user_agent or "")[:500],
|
||||
status=status,
|
||||
detail=_serialize_detail(detail),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
return log
|
||||
except Exception as e:
|
||||
# 写审计日志失败不能影响主业务,仅回滚
|
||||
db.rollback()
|
||||
# 用 print 而非 logger,避免循环依赖(logger 可能未初始化)
|
||||
print(f"[audit] failed to write log action={action}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_logs(
|
||||
db: Session,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
action: Optional[str] = None,
|
||||
resource_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
start_time: Optional[datetime] = None,
|
||||
end_time: Optional[datetime] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> Tuple[int, List[AuditLog]]:
|
||||
query = db.query(AuditLog)
|
||||
|
||||
if user_id is not None:
|
||||
query = query.filter(AuditLog.user_id == user_id)
|
||||
if username:
|
||||
query = query.filter(AuditLog.username.like(f"%{username}%"))
|
||||
if action:
|
||||
query = query.filter(AuditLog.action.like(f"%{action}%"))
|
||||
if resource_type:
|
||||
query = query.filter(AuditLog.resource_type == resource_type)
|
||||
if status:
|
||||
query = query.filter(AuditLog.status == status)
|
||||
if start_time:
|
||||
query = query.filter(AuditLog.created_at >= start_time)
|
||||
if end_time:
|
||||
query = query.filter(AuditLog.created_at <= end_time)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(desc(AuditLog.created_at)).offset(skip).limit(limit).all()
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def get_stats(db: Session) -> Dict[str, Any]:
|
||||
"""首页/汇总用:最近 24h 操作数 + 按 action 分组"""
|
||||
from datetime import timedelta
|
||||
cutoff = datetime.utcnow() - timedelta(hours=24)
|
||||
recent = db.query(AuditLog).filter(AuditLog.created_at >= cutoff).count()
|
||||
by_action = db.query(AuditLog.action, db.query(AuditLog).filter(AuditLog.created_at >= cutoff).subquery()) # 占位避免循环
|
||||
# 简化:用 group by
|
||||
from sqlalchemy import func
|
||||
rows = db.query(AuditLog.action, func.count(AuditLog.id)).filter(
|
||||
AuditLog.created_at >= cutoff
|
||||
).group_by(AuditLog.action).all()
|
||||
return {
|
||||
"recent_24h": recent,
|
||||
"by_action_24h": {action: count for action, count in rows},
|
||||
}
|
||||
|
||||
|
||||
def _serialize_detail(detail: Any) -> Optional[str]:
|
||||
"""把 dict/list 等结构化数据序列化成字符串,便于存储和展示"""
|
||||
if detail is None:
|
||||
return None
|
||||
if isinstance(detail, str):
|
||||
return detail[:4000]
|
||||
try:
|
||||
return json.dumps(detail, ensure_ascii=False, default=str)[:4000]
|
||||
except (TypeError, ValueError):
|
||||
return str(detail)[:4000]
|
||||
|
||||
|
||||
# 常用 action 字符串集中管理(避免散落各处的魔法字符串)
|
||||
class AuditAction:
|
||||
# 网络
|
||||
NETWORK_CREATE = "network.create"
|
||||
NETWORK_UPDATE = "network.update"
|
||||
NETWORK_DELETE = "network.delete"
|
||||
# IP
|
||||
IP_UPDATE = "ip.update"
|
||||
IP_RESERVE = "ip.reserve"
|
||||
IP_UNRESERVE = "ip.unreserve"
|
||||
# 扫描
|
||||
SCAN_TRIGGER = "scan.trigger"
|
||||
# SNMP
|
||||
SNMP_CRED_CREATE = "snmp.credential.create"
|
||||
SNMP_CRED_UPDATE = "snmp.credential.update"
|
||||
SNMP_CRED_DELETE = "snmp.credential.delete"
|
||||
SNMP_DEVICE_CREATE = "snmp.device.create"
|
||||
SNMP_DEVICE_UPDATE = "snmp.device.update"
|
||||
SNMP_DEVICE_DELETE = "snmp.device.delete"
|
||||
# 告警
|
||||
ALERT_ACK = "alert.acknowledge"
|
||||
ALERT_RESOLVE = "alert.resolve"
|
||||
# 用户管理
|
||||
USER_LOGIN = "user.login"
|
||||
USER_LOGIN_FAILED = "user.login.failed"
|
||||
USER_LOGOUT = "user.logout"
|
||||
USER_CREATE = "user.create"
|
||||
USER_UPDATE_STATUS = "user.update_status"
|
||||
USER_UNLOCK = "user.unlock"
|
||||
USER_RESET_PASSWORD = "user.reset_password"
|
||||
USER_DELETE = "user.delete"
|
||||
USER_CHANGE_PASSWORD = "user.change_password"
|
||||
@@ -0,0 +1,381 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import subprocess
|
||||
import socket
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
from app.models.network import ScanTask, Network, IPAddress
|
||||
from app.models.network import TaskStatus, TaskType, IPStatus
|
||||
from app.core.config import settings
|
||||
|
||||
logger = __import__('logging').getLogger(__name__)
|
||||
|
||||
|
||||
class EnhancedScanService:
|
||||
"""增强扫描服务 - 包含ARP、DNS解析、MAC厂商识别"""
|
||||
|
||||
# MAC OUI 厂商映射(常用厂商)
|
||||
OUI_MAP = {
|
||||
'00:1C:14': 'VMware',
|
||||
'00:0C:29': 'VMware',
|
||||
'00:50:56': 'VMware',
|
||||
'00:15:5D': 'Microsoft',
|
||||
'08:00:27': 'VirtualBox',
|
||||
'52:54:00': 'QEMU/KVM',
|
||||
'00:1A:2A': 'Dell',
|
||||
'00:21:9B': 'Dell',
|
||||
'00:26:B9': 'Dell',
|
||||
'00:1B:21': 'HP',
|
||||
'00:23:7D': 'HP',
|
||||
'00:1E:0B': 'Cisco',
|
||||
'00:21:55': 'Cisco',
|
||||
'00:23:04': 'Cisco',
|
||||
'00:24:14': 'Cisco',
|
||||
'00:1F:CA': 'Cisco',
|
||||
'00:19:BB': 'Huawei',
|
||||
'00:25:9E': 'Huawei',
|
||||
'00:E0:FC': 'Huawei',
|
||||
'AC:85:3D': 'Huawei',
|
||||
'28:6E:D4': 'Huawei',
|
||||
'00:16:6F': 'H3C',
|
||||
'00:23:89': 'H3C',
|
||||
'00:0F:E2': 'Intel',
|
||||
'00:1B:77': 'Intel',
|
||||
'00:21:6A': 'Intel',
|
||||
'1C:6F:65': 'Intel',
|
||||
'00:19:D2': 'Realtek',
|
||||
'00:24:1D': 'Realtek',
|
||||
'00:E0:4C': 'Realtek',
|
||||
'F4:6D:04': 'Apple',
|
||||
'E0:F8:47': 'Apple',
|
||||
'C8:BC:C8': 'Apple',
|
||||
'04:15:52': 'Apple',
|
||||
'28:CF:E9': 'Apple',
|
||||
'04:7D:7B': 'Xiaomi',
|
||||
'18:59:36': 'Xiaomi',
|
||||
'34:CE:00': 'Xiaomi',
|
||||
'64:09:80': 'TP-Link',
|
||||
'E8:94:F6': 'TP-Link',
|
||||
'50:FA:84': 'TP-Link',
|
||||
'00:24:01': 'TP-Link',
|
||||
'30:FC:68': 'NETGEAR',
|
||||
'00:09:5B': 'NETGEAR',
|
||||
'00:FF:FF': 'Broadcast',
|
||||
'FF:FF:FF': 'Broadcast',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_mac_vendor(mac_address: str) -> Optional[str]:
|
||||
"""根据MAC地址OUI识别厂商"""
|
||||
if not mac_address:
|
||||
return None
|
||||
|
||||
# 标准化MAC格式为 AA:BB:CC:DD:EE:FF
|
||||
mac = mac_address.upper().replace('-', ':')
|
||||
if len(mac) < 8:
|
||||
return None
|
||||
|
||||
# 提取前3字节 OUI
|
||||
oui = mac[:8]
|
||||
|
||||
# 精确匹配
|
||||
if oui in EnhancedScanService.OUI_MAP:
|
||||
return EnhancedScanService.OUI_MAP[oui]
|
||||
|
||||
# 尝试模糊匹配(前2字节)
|
||||
oui_short = mac[:5]
|
||||
for key, vendor in EnhancedScanService.OUI_MAP.items():
|
||||
if key.startswith(oui_short):
|
||||
return vendor
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def reverse_dns_lookup(ip_address: str, timeout: int = 2) -> Optional[str]:
|
||||
"""反向DNS解析获取主机名"""
|
||||
try:
|
||||
socket.setdefaulttimeout(timeout)
|
||||
hostname, _, _ = socket.gethostbyaddr(ip_address)
|
||||
return hostname
|
||||
except (socket.herror, socket.timeout, socket.gaierror):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def arp_scan_single(ip_address: str, timeout: int = 2) -> Optional[str]:
|
||||
"""
|
||||
对单个IP执行ARP扫描获取MAC地址
|
||||
返回: MAC地址 或 None
|
||||
"""
|
||||
# 方法1: 使用 arp-scan 命令(比 scapy 更可靠,不依赖 raw socket)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['arp-scan', '-I', 'auto', ip_address, '-q'],
|
||||
capture_output=True, text=True, timeout=timeout + 2
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# arp-scan 输出格式: IP\tMAC\tVendor
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split('\t')
|
||||
if len(parts) >= 2:
|
||||
mac = parts[1].strip()
|
||||
if mac and mac != '<incomplete>':
|
||||
return mac.upper()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 方法2: 尝试读取系统 ARP 缓存
|
||||
return EnhancedScanService._get_arp_from_cache(ip_address)
|
||||
|
||||
@staticmethod
|
||||
def _get_arp_from_cache(ip_address: str) -> Optional[str]:
|
||||
"""从系统ARP缓存读取MAC地址"""
|
||||
try:
|
||||
# 读取 /proc/net/arp (Linux)
|
||||
with open('/proc/net/arp', 'r') as f:
|
||||
for line in f.readlines()[1:]: # 跳过表头
|
||||
parts = line.split()
|
||||
if len(parts) >= 4 and parts[0] == ip_address:
|
||||
mac = parts[3].upper()
|
||||
if mac != '00:00:00:00:00:00':
|
||||
return mac
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 尝试 arp -a 命令
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['arp', '-a', ip_address],
|
||||
capture_output=True, text=True,
|
||||
timeout=2
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# 匹配 MAC 地址格式
|
||||
mac_pattern = r'([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})'
|
||||
match = re.search(mac_pattern, result.stdout)
|
||||
if match:
|
||||
return match.group().upper()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def comprehensive_scan(ip_address: str,
|
||||
enable_ping: bool = True,
|
||||
enable_arp: bool = True,
|
||||
enable_dns: bool = True,
|
||||
timeout: int = 2) -> Dict[str, Any]:
|
||||
"""
|
||||
综合扫描单个IP
|
||||
返回: {
|
||||
'ip_address': str,
|
||||
'status': 'online'|'offline',
|
||||
'mac_address': str|None,
|
||||
'hostname': str|None,
|
||||
'vendor': str|None,
|
||||
'response_time': float|None
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
'ip_address': ip_address,
|
||||
'status': 'offline',
|
||||
'mac_address': None,
|
||||
'hostname': None,
|
||||
'vendor': None,
|
||||
'success': False
|
||||
}
|
||||
|
||||
# 1. Ping 扫描
|
||||
if enable_ping:
|
||||
ping_result = EnhancedScanService.ping_single_ip(ip_address, timeout)
|
||||
result['status'] = ping_result.get('status', 'offline')
|
||||
result['success'] = ping_result.get('success', False)
|
||||
result['response_time'] = ping_result.get('response_time')
|
||||
|
||||
# 2. ARP 扫描 (即使ping不通也尝试,有些设备禁ping)
|
||||
if enable_arp:
|
||||
mac = EnhancedScanService.arp_scan_single(ip_address, timeout)
|
||||
if mac:
|
||||
result['mac_address'] = mac
|
||||
result['vendor'] = EnhancedScanService.get_mac_vendor(mac)
|
||||
# 如果获取到MAC,说明设备实际上在线
|
||||
result['status'] = 'online'
|
||||
result['success'] = True
|
||||
|
||||
# 3. 反向 DNS 解析
|
||||
if enable_dns and result.get('success'):
|
||||
hostname = EnhancedScanService.reverse_dns_lookup(ip_address, timeout)
|
||||
if hostname:
|
||||
result['hostname'] = hostname
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def ping_single_ip(ip_address: str, timeout: int = 2) -> Dict[str, Any]:
|
||||
"""Ping 单个IP"""
|
||||
start_time = datetime.now()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ping', '-c', '1', '-W', str(timeout), ip_address],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
is_alive = result.returncode == 0
|
||||
response_time = (datetime.now() - start_time).total_seconds() * 1000
|
||||
|
||||
return {
|
||||
'ip_address': ip_address,
|
||||
'status': 'online' if is_alive else 'offline',
|
||||
'response_time': round(response_time, 2),
|
||||
'success': is_alive
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'ip_address': ip_address,
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
'success': False
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def scan_network(cidr: str,
|
||||
enable_ping: bool = True,
|
||||
enable_arp: bool = True,
|
||||
enable_dns: bool = True,
|
||||
timeout: int = 2,
|
||||
max_concurrent: int = 50) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
扫描整个网段(并发)
|
||||
"""
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
ips = [str(ip) for ip in network.hosts()]
|
||||
|
||||
if not ips:
|
||||
return []
|
||||
|
||||
# 并发执行;并发数随网段大小自适应
|
||||
worker_count = min(max_concurrent, len(ips))
|
||||
results: List[Dict[str, Any]] = []
|
||||
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
||||
future_to_ip = {
|
||||
executor.submit(
|
||||
EnhancedScanService.comprehensive_scan,
|
||||
ip,
|
||||
enable_ping=enable_ping,
|
||||
enable_arp=enable_arp,
|
||||
enable_dns=enable_dns,
|
||||
timeout=timeout
|
||||
): ip
|
||||
for ip in ips
|
||||
}
|
||||
for future in as_completed(future_to_ip):
|
||||
try:
|
||||
results.append(future.result())
|
||||
except Exception as e:
|
||||
ip = future_to_ip[future]
|
||||
results.append({
|
||||
'ip_address': ip,
|
||||
'status': 'offline',
|
||||
'mac_address': None,
|
||||
'hostname': None,
|
||||
'vendor': None,
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def update_ip_from_scan_result(db: Session, scan_result: Dict[str, Any]):
|
||||
"""根据扫描结果更新IP信息"""
|
||||
ip_address = scan_result.get('ip_address')
|
||||
if not ip_address:
|
||||
return
|
||||
|
||||
db_ip = db.query(IPAddress).filter(IPAddress.ip_address == ip_address).first()
|
||||
|
||||
# 如果IP不存在,尝试自动创建
|
||||
if not db_ip:
|
||||
# 从ip_address推断network_id
|
||||
network = db.query(Network).filter(
|
||||
Network.cidr.op('@>')((ip_address + '/32').encode('utf-8'))
|
||||
).first()
|
||||
if not network:
|
||||
# 尝试精确匹配 network.cidr
|
||||
for net in db.query(Network).all():
|
||||
try:
|
||||
if ip_address in ipaddress.ip_network(net.cidr, strict=False):
|
||||
network = net
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not network:
|
||||
logger.warning(f"无法为扫描到的IP {ip_address} 找到所属网段,跳过创建")
|
||||
return
|
||||
|
||||
db_ip = IPAddress(ip_address=ip_address, network_id=network.id)
|
||||
db.add(db_ip)
|
||||
db.commit()
|
||||
db.refresh(db_ip)
|
||||
|
||||
# 更新状态
|
||||
status = scan_result.get('status', 'offline')
|
||||
if status == 'online':
|
||||
db_ip.status = IPStatus.ONLINE
|
||||
elif status == 'offline' and db_ip.status == IPStatus.ONLINE:
|
||||
db_ip.status = IPStatus.OFFLINE
|
||||
|
||||
# 更新 MAC 地址
|
||||
mac = scan_result.get('mac_address')
|
||||
if mac:
|
||||
db_ip.mac_address = mac
|
||||
|
||||
# 更新主机名
|
||||
hostname = scan_result.get('hostname')
|
||||
if hostname:
|
||||
db_ip.hostname = hostname
|
||||
|
||||
# 更新厂商信息
|
||||
vendor = scan_result.get('vendor')
|
||||
if vendor:
|
||||
db_ip.vendor = vendor
|
||||
|
||||
# 更新最后发现时间
|
||||
if scan_result.get('success'):
|
||||
db_ip.last_seen = datetime.utcnow()
|
||||
|
||||
return db_ip
|
||||
|
||||
@staticmethod
|
||||
def bulk_update_ips_from_scan(db: Session, scan_results: List[Dict[str, Any]]):
|
||||
"""批量更新IP信息"""
|
||||
online_count = 0
|
||||
mac_found_count = 0
|
||||
hostname_found_count = 0
|
||||
created_count = 0
|
||||
|
||||
for result in scan_results:
|
||||
ip = EnhancedScanService.update_ip_from_scan_result(db, result)
|
||||
if ip:
|
||||
if result.get('success'):
|
||||
online_count += 1
|
||||
if result.get('mac_address'):
|
||||
mac_found_count += 1
|
||||
if result.get('hostname'):
|
||||
hostname_found_count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'online_count': online_count,
|
||||
'mac_found_count': mac_found_count,
|
||||
'hostname_found_count': hostname_found_count,
|
||||
'total_scanned': len(scan_results)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.network import IPAddress as IPAddressModel
|
||||
from app.models.network import Network as NetworkModel
|
||||
from app.models.network import IPStatus
|
||||
from app.schemas.network import IPAddressUpdate, IPAddressCreate
|
||||
|
||||
|
||||
class IPService:
|
||||
"""IP地址管理服务"""
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(db: Session, ip_id: int) -> Optional[IPAddressModel]:
|
||||
"""根据ID获取IP"""
|
||||
return db.query(IPAddressModel).filter(IPAddressModel.id == ip_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_by_ip(db: Session, ip_address: str) -> Optional[IPAddressModel]:
|
||||
"""根据IP地址获取"""
|
||||
return db.query(IPAddressModel).filter(IPAddressModel.ip_address == ip_address).first()
|
||||
|
||||
@staticmethod
|
||||
def get_list(
|
||||
db: Session,
|
||||
network_id: Optional[int] = None,
|
||||
status: Optional[IPStatus] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search: Optional[str] = None
|
||||
) -> tuple[int, List[IPAddressModel]]:
|
||||
"""获取IP列表"""
|
||||
query = db.query(IPAddressModel)
|
||||
|
||||
if network_id:
|
||||
query = query.filter(IPAddressModel.network_id == network_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(IPAddressModel.status == status)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
(IPAddressModel.ip_address.like(search_pattern)) |
|
||||
(IPAddressModel.mac_address.like(search_pattern)) |
|
||||
(IPAddressModel.hostname.like(search_pattern)) |
|
||||
(IPAddressModel.owner.like(search_pattern))
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(IPAddressModel.ip_address).offset(skip).limit(limit).all()
|
||||
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, ip_in: IPAddressCreate) -> IPAddressModel:
|
||||
"""手动创建IP(通常由网段创建自动创建)"""
|
||||
db_ip = IPAddressModel(**ip_in.model_dump())
|
||||
db.add(db_ip)
|
||||
db.commit()
|
||||
db.refresh(db_ip)
|
||||
return db_ip
|
||||
|
||||
@staticmethod
|
||||
def update(db: Session, ip_id: int, ip_in: IPAddressUpdate) -> Optional[IPAddressModel]:
|
||||
"""更新IP信息"""
|
||||
db_ip = IPService.get_by_id(db, ip_id)
|
||||
if not db_ip:
|
||||
return None
|
||||
|
||||
update_data = ip_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(db_ip, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_ip)
|
||||
return db_ip
|
||||
|
||||
@staticmethod
|
||||
def update_status(db: Session, ip_address: str, status: IPStatus,
|
||||
mac_address: Optional[str] = None,
|
||||
hostname: Optional[str] = None) -> Optional[IPAddressModel]:
|
||||
"""更新IP状态和扫描信息"""
|
||||
db_ip = IPService.get_by_ip(db, ip_address)
|
||||
if not db_ip:
|
||||
return None
|
||||
|
||||
db_ip.status = status
|
||||
db_ip.last_seen = datetime.utcnow()
|
||||
|
||||
if mac_address:
|
||||
db_ip.mac_address = mac_address
|
||||
|
||||
if hostname:
|
||||
db_ip.hostname = hostname
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_ip)
|
||||
return db_ip
|
||||
|
||||
@staticmethod
|
||||
def set_reserved(db: Session, ip_id: int, reserved: bool = True) -> Optional[IPAddressModel]:
|
||||
"""设置/取消保留IP"""
|
||||
db_ip = IPService.get_by_id(db, ip_id)
|
||||
if not db_ip:
|
||||
return None
|
||||
|
||||
db_ip.status = IPStatus.RESERVED if reserved else IPStatus.AVAILABLE
|
||||
db.commit()
|
||||
db.refresh(db_ip)
|
||||
return db_ip
|
||||
|
||||
@staticmethod
|
||||
def bulk_update_status(db: Session, results: List[Dict[str, Any]]):
|
||||
"""批量更新IP状态
|
||||
|
||||
Args:
|
||||
results: 扫描结果列表,每个元素包含 ip_address, status, mac_address, hostname
|
||||
"""
|
||||
for result in results:
|
||||
ip_address = result.get('ip_address')
|
||||
status = result.get('status')
|
||||
mac_address = result.get('mac_address')
|
||||
hostname = result.get('hostname')
|
||||
|
||||
db_ip = IPService.get_by_ip(db, ip_address)
|
||||
if db_ip:
|
||||
db_ip.status = status
|
||||
db_ip.last_seen = datetime.utcnow()
|
||||
if mac_address:
|
||||
db_ip.mac_address = mac_address
|
||||
if hostname:
|
||||
db_ip.hostname = hostname
|
||||
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_by_network(db: Session, network_id: int) -> List[IPAddressModel]:
|
||||
"""获取指定网段下的所有IP"""
|
||||
return db.query(IPAddressModel).filter(
|
||||
IPAddressModel.network_id == network_id
|
||||
).order_by(IPAddressModel.ip_address).all()
|
||||
@@ -0,0 +1,177 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, case
|
||||
import ipaddress
|
||||
|
||||
from app.models.network import Network as NetworkModel
|
||||
from app.models.network import IPAddress as IPAddressModel
|
||||
from app.models.network import IPStatus
|
||||
from app.schemas.network import NetworkCreate, NetworkUpdate, NetworkStats
|
||||
|
||||
|
||||
class NetworkService:
|
||||
"""网段管理服务"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_total_ips(cidr: str) -> int:
|
||||
"""计算网段的总IP数量"""
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
return network.num_addresses
|
||||
|
||||
@staticmethod
|
||||
def get_network_addresses(cidr: str) -> List[str]:
|
||||
"""获取网段内所有IP地址列表"""
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
return [str(ip) for ip in network.hosts()]
|
||||
|
||||
@staticmethod
|
||||
def get_by_id(db: Session, network_id: int) -> Optional[NetworkModel]:
|
||||
"""根据ID获取网段"""
|
||||
return db.query(NetworkModel).filter(NetworkModel.id == network_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_by_cidr(db: Session, cidr: str) -> Optional[NetworkModel]:
|
||||
"""根据CIDR获取网段"""
|
||||
return db.query(NetworkModel).filter(NetworkModel.cidr == cidr).first()
|
||||
|
||||
@staticmethod
|
||||
def get_list(
|
||||
db: Session,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
group_name: Optional[str] = None
|
||||
) -> tuple[int, List[NetworkModel]]:
|
||||
"""获取网段列表"""
|
||||
query = db.query(NetworkModel)
|
||||
|
||||
if group_name:
|
||||
query = query.filter(NetworkModel.group_name == group_name)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(NetworkModel.id.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def create(db: Session, network_in: NetworkCreate) -> NetworkModel:
|
||||
"""创建新网段"""
|
||||
total_ips = NetworkService.calculate_total_ips(network_in.cidr)
|
||||
|
||||
db_network = NetworkModel(
|
||||
cidr=network_in.cidr,
|
||||
name=network_in.name,
|
||||
description=network_in.description,
|
||||
group_name=network_in.group_name,
|
||||
gateway=network_in.gateway,
|
||||
vlan_id=network_in.vlan_id,
|
||||
total_ips=total_ips,
|
||||
used_ips=0,
|
||||
reserved_ips=0
|
||||
)
|
||||
|
||||
db.add(db_network)
|
||||
db.flush()
|
||||
|
||||
# 自动创建该网段下的所有IP记录
|
||||
NetworkService._create_ip_addresses(db, db_network)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_network)
|
||||
return db_network
|
||||
|
||||
@staticmethod
|
||||
def _create_ip_addresses(db: Session, network: NetworkModel):
|
||||
"""为网段创建所有IP记录"""
|
||||
ip_addresses = NetworkService.get_network_addresses(network.cidr)
|
||||
|
||||
ip_records = []
|
||||
for ip in ip_addresses:
|
||||
# 标记网关为保留状态
|
||||
status = IPStatus.RESERVED if ip == network.gateway else IPStatus.AVAILABLE
|
||||
ip_records.append(
|
||||
IPAddressModel(
|
||||
network_id=network.id,
|
||||
ip_address=ip,
|
||||
status=status
|
||||
)
|
||||
)
|
||||
|
||||
db.bulk_save_objects(ip_records)
|
||||
|
||||
# 更新统计
|
||||
reserved_count = sum(1 for ip in ip_addresses if ip == network.gateway)
|
||||
network.reserved_ips = reserved_count
|
||||
|
||||
@staticmethod
|
||||
def update(db: Session, network_id: int, network_in: NetworkUpdate) -> Optional[NetworkModel]:
|
||||
"""更新网段信息"""
|
||||
db_network = NetworkService.get_by_id(db, network_id)
|
||||
if not db_network:
|
||||
return None
|
||||
|
||||
update_data = network_in.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(db_network, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_network)
|
||||
return db_network
|
||||
|
||||
@staticmethod
|
||||
def delete(db: Session, network_id: int) -> bool:
|
||||
"""删除网段"""
|
||||
db_network = NetworkService.get_by_id(db, network_id)
|
||||
if not db_network:
|
||||
return False
|
||||
|
||||
db.delete(db_network)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_stats(db: Session, network_id: int) -> Optional[NetworkStats]:
|
||||
"""获取网段统计信息"""
|
||||
db_network = NetworkService.get_by_id(db, network_id)
|
||||
if not db_network:
|
||||
return None
|
||||
|
||||
# 实时计算IP状态 - 使用SUM(CASE)兼容MySQL
|
||||
stats = db.query(
|
||||
func.sum(case((IPAddressModel.status == IPStatus.ONLINE, 1), else_=0)).label('online'),
|
||||
func.sum(case((IPAddressModel.status == IPStatus.OFFLINE, 1), else_=0)).label('offline'),
|
||||
func.sum(case((IPAddressModel.status == IPStatus.RESERVED, 1), else_=0)).label('reserved'),
|
||||
).filter(IPAddressModel.network_id == network_id).first()
|
||||
|
||||
used_ips = int(stats.online or 0) + int(stats.offline or 0)
|
||||
reserved_ips = int(stats.reserved or 0)
|
||||
available_ips = db_network.total_ips - used_ips - reserved_ips
|
||||
usage_percent = (used_ips / db_network.total_ips * 100) if db_network.total_ips > 0 else 0
|
||||
|
||||
# 更新数据库统计
|
||||
db_network.used_ips = used_ips
|
||||
db_network.reserved_ips = reserved_ips
|
||||
db.commit()
|
||||
|
||||
return NetworkStats(
|
||||
id=db_network.id,
|
||||
cidr=db_network.cidr,
|
||||
name=db_network.name,
|
||||
total_ips=db_network.total_ips,
|
||||
used_ips=used_ips,
|
||||
reserved_ips=reserved_ips,
|
||||
available_ips=available_ips,
|
||||
usage_percent=round(usage_percent, 2),
|
||||
group_name=db_network.group_name,
|
||||
vlan_id=db_network.vlan_id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_all_stats(db: Session) -> List[NetworkStats]:
|
||||
"""获取所有网段的统计信息"""
|
||||
networks = db.query(NetworkModel).all()
|
||||
stats_list = []
|
||||
for network in networks:
|
||||
stats = NetworkService.get_stats(db, network.id)
|
||||
if stats:
|
||||
stats_list.append(stats)
|
||||
return stats_list
|
||||
@@ -0,0 +1,148 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
import subprocess
|
||||
import ipaddress
|
||||
|
||||
from app.models.network import ScanTask, Network, IPAddress
|
||||
from app.models.network import TaskStatus, TaskType, IPStatus
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class ScanService:
|
||||
"""扫描服务"""
|
||||
|
||||
@staticmethod
|
||||
def ping_single_ip(ip_address: str, timeout: int = 2) -> Dict[str, Any]:
|
||||
"""
|
||||
对单个IP执行Ping扫描
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['ping', '-c', '1', '-W', str(timeout), ip_address],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
is_alive = result.returncode == 0
|
||||
|
||||
return {
|
||||
'ip_address': ip_address,
|
||||
'status': 'online' if is_alive else 'offline',
|
||||
'response_time': None,
|
||||
'success': is_alive
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'ip_address': ip_address,
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
'success': False
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def ping_network(cidr: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
对整个网段执行Ping扫描(同步版本)
|
||||
"""
|
||||
network = ipaddress.ip_network(cidr, strict=False)
|
||||
ips = [str(ip) for ip in network.hosts()]
|
||||
|
||||
results = []
|
||||
for ip in ips:
|
||||
result = ScanService.ping_single_ip(ip, settings.PING_TIMEOUT)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def create_scan_task(db: Session, task_type: TaskType,
|
||||
network_id: Optional[int] = None) -> ScanTask:
|
||||
"""
|
||||
创建扫描任务
|
||||
"""
|
||||
task = ScanTask(
|
||||
task_type=task_type,
|
||||
network_id=network_id,
|
||||
status=TaskStatus.PENDING,
|
||||
progress=0
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
def update_task_status(db: Session, task_id: int, status: TaskStatus,
|
||||
progress: int = None, total_count: int = None,
|
||||
success_count: int = None, failed_count: int = None,
|
||||
error_message: str = None):
|
||||
"""
|
||||
更新任务状态
|
||||
"""
|
||||
task = db.query(ScanTask).filter(ScanTask.id == task_id).first()
|
||||
if not task:
|
||||
return
|
||||
|
||||
task.status = status
|
||||
if progress is not None:
|
||||
task.progress = progress
|
||||
if total_count is not None:
|
||||
task.total_count = total_count
|
||||
if success_count is not None:
|
||||
task.success_count = success_count
|
||||
if failed_count is not None:
|
||||
task.failed_count = failed_count
|
||||
if error_message:
|
||||
task.error_message = error_message
|
||||
|
||||
if status == TaskStatus.RUNNING and task.started_at is None:
|
||||
task.started_at = datetime.utcnow()
|
||||
elif status in [TaskStatus.COMPLETED, TaskStatus.FAILED]:
|
||||
task.completed_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_task_by_id(db: Session, task_id: int) -> Optional[ScanTask]:
|
||||
"""
|
||||
获取任务信息
|
||||
"""
|
||||
return db.query(ScanTask).filter(ScanTask.id == task_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_task_list(db: Session, skip: int = 0, limit: int = 50) -> tuple[int, List[ScanTask]]:
|
||||
"""
|
||||
获取任务列表
|
||||
"""
|
||||
query = db.query(ScanTask)
|
||||
total = query.count()
|
||||
items = query.order_by(ScanTask.id.desc()).offset(skip).limit(limit).all()
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def update_ip_status_from_scan_results(db: Session, results: List[Dict[str, Any]]):
|
||||
"""
|
||||
根据扫描结果更新IP状态
|
||||
"""
|
||||
online_count = 0
|
||||
offline_count = 0
|
||||
|
||||
for result in results:
|
||||
ip_address = result.get('ip_address')
|
||||
is_online = result.get('success', False)
|
||||
|
||||
db_ip = db.query(IPAddress).filter(IPAddress.ip_address == ip_address).first()
|
||||
if db_ip:
|
||||
if is_online:
|
||||
db_ip.status = IPStatus.ONLINE
|
||||
db_ip.last_seen = datetime.utcnow()
|
||||
online_count += 1
|
||||
elif db_ip.status == IPStatus.ONLINE:
|
||||
# 曾经在线,现在不在线,标记为离线
|
||||
db_ip.status = IPStatus.OFFLINE
|
||||
offline_count += 1
|
||||
|
||||
db.commit()
|
||||
return online_count, offline_count
|
||||
@@ -0,0 +1,520 @@
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
from pysnmp.hlapi import (
|
||||
SnmpEngine, CommunityData, UsmUserData,
|
||||
UdpTransportTarget, ContextData,
|
||||
ObjectType, ObjectIdentity,
|
||||
bulkCmd, getCmd, nextCmd
|
||||
)
|
||||
from pysnmp.proto.rfc1902 import OctetString, Integer32, Counter32, Counter64, Gauge32
|
||||
|
||||
from app.models.snmp import (
|
||||
SNMPCredential, NetworkDevice, ARPEntry, MACAddressTable, DeviceInterface,
|
||||
SNMPVersion, SNMPAuthProtocol, SNMPPrivProtocol
|
||||
)
|
||||
from app.services.enhanced_scan_service import EnhancedScanService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SNMPService:
|
||||
"""SNMP 服务"""
|
||||
|
||||
# OID 定义
|
||||
OID_SYSTEM = '1.3.6.1.2.1.1'
|
||||
OID_SYS_DESCR = '1.3.6.1.2.1.1.1.0'
|
||||
OID_SYS_NAME = '1.3.6.1.2.1.1.5.0'
|
||||
OID_SYS_LOCATION = '1.3.6.1.2.1.1.6.0'
|
||||
|
||||
# 接口相关 OID
|
||||
OID_IF_TABLE = '1.3.6.1.2.1.2.2'
|
||||
OID_IF_INDEX = '1.3.6.1.2.1.2.2.1.1'
|
||||
OID_IF_DESCR = '1.3.6.1.2.1.2.2.1.2'
|
||||
OID_IF_TYPE = '1.3.6.1.2.1.2.2.1.3'
|
||||
OID_IF_MTU = '1.3.6.1.2.1.2.2.1.4'
|
||||
OID_IF_SPEED = '1.3.6.1.2.1.2.2.1.5'
|
||||
OID_IF_PHYS_ADDRESS = '1.3.6.1.2.1.2.2.1.6'
|
||||
OID_IF_ADMIN_STATUS = '1.3.6.1.2.1.2.2.1.7'
|
||||
OID_IF_OPER_STATUS = '1.3.6.1.2.1.2.2.1.8'
|
||||
OID_IF_IN_OCTETS = '1.3.6.1.2.1.2.2.1.10'
|
||||
OID_IF_OUT_OCTETS = '1.3.6.1.2.1.2.2.1.16'
|
||||
OID_IF_IN_ERRORS = '1.3.6.1.2.1.2.2.1.14'
|
||||
OID_IF_OUT_ERRORS = '1.3.6.1.2.1.2.2.1.20'
|
||||
|
||||
# IP 地址表
|
||||
OID_IP_ADDR_TABLE = '1.3.6.1.2.1.4.20'
|
||||
OID_IP_ADDR_ENTRIES = '1.3.6.1.2.1.4.20.1.1'
|
||||
OID_IP_ADDR_IF_INDEX = '1.3.6.1.2.1.4.20.1.2'
|
||||
OID_IP_ADDR_NETMASK = '1.3.6.1.2.1.4.20.1.3'
|
||||
|
||||
# ARP 表 (ipNetToMediaTable)
|
||||
OID_IP_NET_TO_MEDIA_PHYS_ADDRESS = '1.3.6.1.2.1.4.22.1.2'
|
||||
OID_IP_NET_TO_MEDIA_IF_INDEX = '1.3.6.1.2.1.4.22.1.1'
|
||||
OID_IP_NET_TO_MEDIA_NET_ADDRESS = '1.3.6.1.2.1.4.22.1.3'
|
||||
OID_IP_NET_TO_MEDIA_TYPE = '1.3.6.1.2.1.4.22.1.4'
|
||||
|
||||
# DOT1D 桥接 MIB (MAC 地址表)
|
||||
OID_DOT1D_TP_FDB_PORT = '1.3.6.1.2.1.17.4.3.1.2'
|
||||
OID_DOT1D_TP_FDB_STATUS = '1.3.6.1.2.1.17.4.3.1.3'
|
||||
|
||||
# VLAN 相关 (Cisco 特定)
|
||||
OID_VTP_VLAN_STATE = '1.3.6.1.4.1.9.9.46.1.3.1.1.2'
|
||||
|
||||
@staticmethod
|
||||
def _create_auth_data(credential: SNMPCredential):
|
||||
"""创建 SNMP 认证数据"""
|
||||
if credential.version == SNMPVersion.V2C:
|
||||
if not credential.community_string:
|
||||
raise ValueError("SNMPv2c 凭据缺少 community_string")
|
||||
return CommunityData(credential.community_string)
|
||||
else: # V3
|
||||
if not credential.username:
|
||||
raise ValueError("SNMPv3 凭据缺少 username(V3 强制要求 1-32 字符)")
|
||||
|
||||
auth_protocol_map = {
|
||||
SNMPAuthProtocol.MD5: 'MD5',
|
||||
SNMPAuthProtocol.SHA: 'SHA',
|
||||
SNMPAuthProtocol.SHA256: 'SHA256',
|
||||
}
|
||||
priv_protocol_map = {
|
||||
SNMPPrivProtocol.DES: 'DES',
|
||||
SNMPPrivProtocol.AES: 'AES',
|
||||
SNMPPrivProtocol.AES256: 'AES256',
|
||||
}
|
||||
|
||||
auth_proto = auth_protocol_map.get(credential.auth_protocol) if credential.auth_protocol else None
|
||||
priv_proto = priv_protocol_map.get(credential.priv_protocol) if credential.priv_protocol else None
|
||||
|
||||
return UsmUserData(
|
||||
userName=credential.username,
|
||||
authProtocol=auth_proto,
|
||||
authKey=credential.auth_password,
|
||||
privProtocol=priv_proto,
|
||||
privKey=credential.priv_password
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_transport_target(ip_address: str, port: int = 161, timeout: int = 3, retries: int = 2):
|
||||
"""创建传输目标"""
|
||||
return UdpTransportTarget((ip_address, port), timeout=timeout, retries=retries)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_mac(mac_bytes) -> Optional[str]:
|
||||
"""将 MAC 地址字节转换为标准格式 AA:BB:CC:DD:EE:FF"""
|
||||
if not mac_bytes:
|
||||
return None
|
||||
|
||||
if isinstance(mac_bytes, OctetString):
|
||||
mac_bytes = mac_bytes.asOctets()
|
||||
|
||||
if len(mac_bytes) == 6:
|
||||
return ':'.join(f'{b:02X}' for b in mac_bytes)
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def test_connection(device: NetworkDevice, credential: SNMPCredential) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
测试 SNMP 连接
|
||||
返回: (成功, 设备信息字典)
|
||||
"""
|
||||
try:
|
||||
auth_data = SNMPService._create_auth_data(credential)
|
||||
transport = SNMPService._get_transport_target(
|
||||
device.ip_address,
|
||||
port=device.port,
|
||||
timeout=credential.timeout,
|
||||
retries=credential.retries
|
||||
)
|
||||
|
||||
errorIndication, errorStatus, errorIndex, varBinds = next(
|
||||
getCmd(SnmpEngine(), auth_data, transport, ContextData(),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_SYS_DESCR)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_SYS_NAME)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_SYS_LOCATION)))
|
||||
)
|
||||
|
||||
if errorIndication:
|
||||
return False, {'error': str(errorIndication)}
|
||||
if errorStatus:
|
||||
return False, {'error': f'{errorStatus.prettyPrint()} at {errorIndex and varBinds[int(errorIndex) - 1][0] or "?"}'}
|
||||
|
||||
sys_info = {}
|
||||
for varBind in varBinds:
|
||||
oid = str(varBind[0])
|
||||
value = varBind[1].prettyPrint()
|
||||
|
||||
if SNMPService.OID_SYS_DESCR in oid:
|
||||
sys_info['sys_descr'] = value
|
||||
elif SNMPService.OID_SYS_NAME in oid:
|
||||
sys_info['sys_name'] = value
|
||||
elif SNMPService.OID_SYS_LOCATION in oid:
|
||||
sys_info['sys_location'] = value
|
||||
|
||||
return True, sys_info
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"SNMP 连接测试失败: {e}")
|
||||
return False, {'error': str(e)}
|
||||
|
||||
@staticmethod
|
||||
def get_arp_table(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取 ARP 表 (IP -> MAC 映射)
|
||||
"""
|
||||
arp_entries = []
|
||||
|
||||
try:
|
||||
auth_data = SNMPService._create_auth_data(credential)
|
||||
transport = SNMPService._get_transport_target(
|
||||
device.ip_address,
|
||||
port=device.port,
|
||||
timeout=credential.timeout,
|
||||
retries=credential.retries
|
||||
)
|
||||
|
||||
# 使用 bulkCmd 获取 ARP 表
|
||||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
||||
SnmpEngine(), auth_data, transport, ContextData(),
|
||||
0, 50, # nonRepeaters, maxRepetitions
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_PHYS_ADDRESS)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_NET_ADDRESS)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IP_NET_TO_MEDIA_IF_INDEX)),
|
||||
lexicographicMode=False
|
||||
):
|
||||
if errorIndication:
|
||||
logger.error(f"SNMP 错误: {errorIndication}")
|
||||
break
|
||||
|
||||
if errorStatus:
|
||||
logger.error(f"SNMP 错误: {errorStatus}")
|
||||
break
|
||||
|
||||
# 解析结果
|
||||
arp_data = {}
|
||||
for varBind in varBinds:
|
||||
oid = str(varBind[0])
|
||||
value = varBind[1]
|
||||
|
||||
# 提取 IP 地址 (OID 后缀: ifIndex.IP)
|
||||
parts = oid.split('.')
|
||||
if len(parts) >= 4:
|
||||
# 从 OID 中提取 IP 地址
|
||||
ip_parts = parts[-4:]
|
||||
ip_address = '.'.join(ip_parts)
|
||||
|
||||
if SNMPService.OID_IP_NET_TO_MEDIA_PHYS_ADDRESS in oid:
|
||||
mac = SNMPService._normalize_mac(value)
|
||||
if mac:
|
||||
if ip_address not in arp_data:
|
||||
arp_data[ip_address] = {}
|
||||
arp_data[ip_address]['mac_address'] = mac
|
||||
elif SNMPService.OID_IP_NET_TO_MEDIA_IF_INDEX in oid:
|
||||
if isinstance(value, Integer32):
|
||||
if_index = int(value)
|
||||
if ip_address not in arp_data:
|
||||
arp_data[ip_address] = {}
|
||||
arp_data[ip_address]['if_index'] = if_index
|
||||
|
||||
# 转换为列表
|
||||
for ip, data in arp_data.items():
|
||||
if 'mac_address' in data:
|
||||
entry = {
|
||||
'ip_address': ip,
|
||||
'mac_address': data.get('mac_address'),
|
||||
'interface': str(data.get('if_index', '')),
|
||||
'device_id': device.id
|
||||
}
|
||||
arp_entries.append(entry)
|
||||
|
||||
# 保存到数据库
|
||||
now = datetime.utcnow()
|
||||
for entry_data in arp_entries:
|
||||
# 查找是否已存在
|
||||
existing = db.query(ARPEntry).filter(
|
||||
ARPEntry.device_id == device.id,
|
||||
ARPEntry.ip_address == entry_data['ip_address']
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.mac_address = entry_data['mac_address']
|
||||
existing.last_seen = now
|
||||
else:
|
||||
arp_entry = ARPEntry(
|
||||
device_id=device.id,
|
||||
ip_address=entry_data['ip_address'],
|
||||
mac_address=entry_data['mac_address'],
|
||||
interface=entry_data['interface'],
|
||||
discovered_at=now,
|
||||
last_seen=now
|
||||
)
|
||||
db.add(arp_entry)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 更新 IP 资产台账中的 MAC 地址
|
||||
for entry in arp_entries:
|
||||
from app.models.network import IPAddress as IPAddressModel
|
||||
ip_addr = db.query(IPAddressModel).filter(
|
||||
IPAddressModel.ip_address == entry['ip_address']
|
||||
).first()
|
||||
if ip_addr and not ip_addr.mac_address:
|
||||
ip_addr.mac_address = entry['mac_address']
|
||||
# 识别厂商
|
||||
ip_addr.vendor = EnhancedScanService.get_mac_vendor(entry['mac_address'])
|
||||
|
||||
db.commit()
|
||||
device.last_successful_poll = now
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取 ARP 表失败: {e}", exc_info=True)
|
||||
|
||||
device.last_polled_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return arp_entries
|
||||
|
||||
@staticmethod
|
||||
def get_mac_address_table(device: NetworkDevice, credential: SNMPCredential, db: Session, vlan_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取 MAC 地址表 (MAC -> 端口 映射)
|
||||
"""
|
||||
mac_entries = []
|
||||
|
||||
try:
|
||||
auth_data = SNMPService._create_auth_data(credential)
|
||||
transport = SNMPService._get_transport_target(
|
||||
device.ip_address,
|
||||
port=device.port,
|
||||
timeout=credential.timeout,
|
||||
retries=credential.retries
|
||||
)
|
||||
|
||||
# 获取 MAC 地址表
|
||||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
||||
SnmpEngine(), auth_data, transport, ContextData(),
|
||||
0, 100,
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_DOT1D_TP_FDB_PORT)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_DOT1D_TP_FDB_STATUS)),
|
||||
lexicographicMode=False
|
||||
):
|
||||
if errorIndication:
|
||||
break
|
||||
|
||||
if errorStatus:
|
||||
break
|
||||
|
||||
for varBind in varBinds:
|
||||
oid = str(varBind[0])
|
||||
value = varBind[1]
|
||||
|
||||
# OID 后缀是 MAC 地址: .a.b.c.d.e.f
|
||||
parts = oid.split('.')
|
||||
if len(parts) >= 6:
|
||||
mac_parts = parts[-6:]
|
||||
mac_address = ':'.join(f'{int(p):02X}' for p in mac_parts)
|
||||
|
||||
if SNMPService.OID_DOT1D_TP_FDB_PORT in oid:
|
||||
port = int(value) if isinstance(value, Integer32) else None
|
||||
|
||||
entry = {
|
||||
'mac_address': mac_address,
|
||||
'port_number': port,
|
||||
'device_id': device.id,
|
||||
'vlan_id': vlan_id
|
||||
}
|
||||
mac_entries.append(entry)
|
||||
|
||||
# 保存到数据库
|
||||
now = datetime.utcnow()
|
||||
for entry_data in mac_entries:
|
||||
existing = db.query(MACAddressTable).filter(
|
||||
MACAddressTable.device_id == device.id,
|
||||
MACAddressTable.mac_address == entry_data['mac_address']
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.port_number = entry_data['port_number']
|
||||
existing.last_seen = now
|
||||
else:
|
||||
mac_entry = MACAddressTable(
|
||||
device_id=device.id,
|
||||
mac_address=entry_data['mac_address'],
|
||||
port_number=entry_data['port_number'],
|
||||
vlan_id=vlan_id,
|
||||
discovered_at=now,
|
||||
last_seen=now
|
||||
)
|
||||
db.add(mac_entry)
|
||||
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取 MAC 地址表失败: {e}", exc_info=True)
|
||||
|
||||
return mac_entries
|
||||
|
||||
@staticmethod
|
||||
def get_interfaces(device: NetworkDevice, credential: SNMPCredential, db: Session) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取设备接口信息
|
||||
"""
|
||||
interfaces = []
|
||||
|
||||
try:
|
||||
auth_data = SNMPService._create_auth_data(credential)
|
||||
transport = SNMPService._get_transport_target(
|
||||
device.ip_address,
|
||||
port=device.port,
|
||||
timeout=credential.timeout,
|
||||
retries=credential.retries
|
||||
)
|
||||
|
||||
# 获取接口信息
|
||||
interface_data = {}
|
||||
|
||||
for errorIndication, errorStatus, errorIndex, varBinds in bulkCmd(
|
||||
SnmpEngine(), auth_data, transport, ContextData(),
|
||||
0, 100,
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_DESCR)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_TYPE)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_MTU)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_SPEED)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_PHYS_ADDRESS)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_ADMIN_STATUS)),
|
||||
ObjectType(ObjectIdentity(SNMPService.OID_IF_OPER_STATUS)),
|
||||
lexicographicMode=False
|
||||
):
|
||||
if errorIndication or errorStatus:
|
||||
break
|
||||
|
||||
for varBind in varBinds:
|
||||
oid = str(varBind[0])
|
||||
value = varBind[1]
|
||||
parts = oid.split('.')
|
||||
if_index = parts[-1]
|
||||
|
||||
if if_index not in interface_data:
|
||||
interface_data[if_index] = {'if_index': int(if_index)}
|
||||
|
||||
if SNMPService.OID_IF_DESCR in oid:
|
||||
interface_data[if_index]['if_descr'] = value.prettyPrint()
|
||||
elif SNMPService.OID_IF_TYPE in oid:
|
||||
interface_data[if_index]['if_type'] = str(value)
|
||||
elif SNMPService.OID_IF_MTU in oid:
|
||||
interface_data[if_index]['if_mtu'] = int(value) if isinstance(value, Integer32) else None
|
||||
elif SNMPService.OID_IF_SPEED in oid:
|
||||
interface_data[if_index]['if_speed'] = int(value) if isinstance(value, (Integer32, Gauge32)) else None
|
||||
elif SNMPService.OID_IF_PHYS_ADDRESS in oid:
|
||||
interface_data[if_index]['if_phys_address'] = SNMPService._normalize_mac(value)
|
||||
elif SNMPService.OID_IF_ADMIN_STATUS in oid:
|
||||
status_map = {1: 'up', 2: 'down', 3: 'testing'}
|
||||
interface_data[if_index]['if_admin_status'] = status_map.get(int(value), 'unknown')
|
||||
elif SNMPService.OID_IF_OPER_STATUS in oid:
|
||||
status_map = {1: 'up', 2: 'down', 3: 'testing'}
|
||||
interface_data[if_index]['if_oper_status'] = status_map.get(int(value), 'unknown')
|
||||
|
||||
# 转换为列表并保存
|
||||
now = datetime.utcnow()
|
||||
for if_index, data in interface_data.items():
|
||||
interfaces.append(data)
|
||||
|
||||
existing = db.query(DeviceInterface).filter(
|
||||
DeviceInterface.device_id == device.id,
|
||||
DeviceInterface.if_index == int(if_index)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
for key, val in data.items():
|
||||
if hasattr(existing, key):
|
||||
setattr(existing, key, val)
|
||||
existing.last_polled_at = now
|
||||
else:
|
||||
iface = DeviceInterface(
|
||||
device_id=device.id,
|
||||
if_index=int(if_index),
|
||||
if_descr=data.get('if_descr'),
|
||||
if_type=data.get('if_type'),
|
||||
if_mtu=data.get('if_mtu'),
|
||||
if_speed=data.get('if_speed'),
|
||||
if_phys_address=data.get('if_phys_address'),
|
||||
if_admin_status=data.get('if_admin_status'),
|
||||
if_oper_status=data.get('if_oper_status'),
|
||||
last_polled_at=now
|
||||
)
|
||||
db.add(iface)
|
||||
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取接口信息失败: {e}", exc_info=True)
|
||||
|
||||
return interfaces
|
||||
|
||||
@staticmethod
|
||||
def create_credential(db: Session, name: str, version: SNMPVersion, **kwargs) -> SNMPCredential:
|
||||
"""创建 SNMP 凭据"""
|
||||
credential = SNMPCredential(
|
||||
name=name,
|
||||
version=version,
|
||||
**kwargs
|
||||
)
|
||||
db.add(credential)
|
||||
db.commit()
|
||||
db.refresh(credential)
|
||||
return credential
|
||||
|
||||
@staticmethod
|
||||
def create_device(db: Session, name: str, ip_address: str, credential_id: Optional[int] = None, **kwargs) -> NetworkDevice:
|
||||
"""创建网络设备"""
|
||||
device = NetworkDevice(
|
||||
name=name,
|
||||
ip_address=ip_address,
|
||||
snmp_credential_id=credential_id,
|
||||
**kwargs
|
||||
)
|
||||
db.add(device)
|
||||
db.commit()
|
||||
db.refresh(device)
|
||||
return device
|
||||
|
||||
@staticmethod
|
||||
def poll_device(db: Session, device_id: int) -> Dict[str, Any]:
|
||||
"""轮询设备,获取所有信息"""
|
||||
device = db.query(NetworkDevice).filter(NetworkDevice.id == device_id).first()
|
||||
if not device:
|
||||
return {'error': '设备不存在'}
|
||||
|
||||
if not device.snmp_credential:
|
||||
return {'error': '设备未配置 SNMP 凭据'}
|
||||
|
||||
credential = device.snmp_credential
|
||||
|
||||
result = {
|
||||
'device_id': device_id,
|
||||
'device_name': device.name,
|
||||
'arp_entries': [],
|
||||
'mac_entries': [],
|
||||
'interfaces': []
|
||||
}
|
||||
|
||||
# 获取 ARP 表
|
||||
arp_entries = SNMPService.get_arp_table(device, credential, db)
|
||||
result['arp_entries'] = arp_entries
|
||||
result['arp_count'] = len(arp_entries)
|
||||
|
||||
# 获取 MAC 地址表
|
||||
mac_entries = SNMPService.get_mac_address_table(device, credential, db)
|
||||
result['mac_entries'] = mac_entries
|
||||
result['mac_count'] = len(mac_entries)
|
||||
|
||||
# 获取接口信息
|
||||
interfaces = SNMPService.get_interfaces(device, credential, db)
|
||||
result['interfaces'] = interfaces
|
||||
result['interface_count'] = len(interfaces)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,420 @@
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.models.auth import User, UserRole, UserStatus, Permission, RolePermission, RefreshToken
|
||||
from app.core.security import SecurityUtils
|
||||
|
||||
|
||||
# 默认权限项 + 角色权限映射
|
||||
DEFAULT_PERMISSIONS = [
|
||||
# 网段
|
||||
("network:view", "查看网段", "network"),
|
||||
("network:create", "创建网段", "network"),
|
||||
("network:edit", "编辑网段", "network"),
|
||||
("network:delete", "删除网段", "network"),
|
||||
# IP
|
||||
("ip:view", "查看 IP", "ip"),
|
||||
("ip:edit", "编辑 IP", "ip"),
|
||||
# 扫描
|
||||
("scan:run", "执行扫描", "scan"),
|
||||
("scan:view", "查看扫描结果", "scan"),
|
||||
# SNMP
|
||||
("snmp:view", "查看 SNMP", "snmp"),
|
||||
("snmp:edit", "配置 SNMP", "snmp"),
|
||||
# 告警
|
||||
("alert:view", "查看告警", "alert"),
|
||||
("alert:ack", "确认告警", "alert"),
|
||||
("alert:resolve", "解决告警", "alert"),
|
||||
# 报告
|
||||
("report:export", "导出报告", "report"),
|
||||
# 用户管理
|
||||
("user:view", "查看用户", "system"),
|
||||
("user:create", "创建用户", "system"),
|
||||
("user:edit", "编辑用户", "system"),
|
||||
("user:delete", "删除用户", "system"),
|
||||
# 审计
|
||||
("audit:view", "查看审计日志", "system"),
|
||||
]
|
||||
|
||||
|
||||
# 角色 -> 权限代码集合
|
||||
DEFAULT_ROLE_PERMISSIONS = {
|
||||
UserRole.SUPER_ADMIN: {p[0] for p in DEFAULT_PERMISSIONS}, # 全部
|
||||
UserRole.ADMIN: {
|
||||
"network:view", "network:create", "network:edit", "network:delete",
|
||||
"ip:view", "ip:edit",
|
||||
"scan:run", "scan:view",
|
||||
"snmp:view", "snmp:edit",
|
||||
"alert:view", "alert:ack", "alert:resolve",
|
||||
"report:export",
|
||||
"user:view", "audit:view",
|
||||
},
|
||||
UserRole.OPERATOR: {
|
||||
"network:view",
|
||||
"ip:view", "ip:edit",
|
||||
"scan:run", "scan:view",
|
||||
"snmp:view",
|
||||
"alert:view", "alert:ack",
|
||||
"report:export",
|
||||
},
|
||||
UserRole.VIEWER: {
|
||||
"network:view",
|
||||
"ip:view",
|
||||
"scan:view",
|
||||
"snmp:view",
|
||||
"alert:view",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class UserService:
|
||||
"""用户服务"""
|
||||
|
||||
@staticmethod
|
||||
def init_default_permissions(db: Session) -> int:
|
||||
"""初始化默认权限项和角色权限映射(已存在则跳过)"""
|
||||
created = 0
|
||||
perm_map = {}
|
||||
for code, name, category in DEFAULT_PERMISSIONS:
|
||||
existing = db.query(Permission).filter(Permission.code == code).first()
|
||||
if existing:
|
||||
perm_map[code] = existing
|
||||
continue
|
||||
p = Permission(code=code, name=name, category=category)
|
||||
db.add(p)
|
||||
db.flush()
|
||||
perm_map[code] = p
|
||||
created += 1
|
||||
|
||||
# 角色 -> 权限关联
|
||||
for role, codes in DEFAULT_ROLE_PERMISSIONS.items():
|
||||
for code in codes:
|
||||
if code not in perm_map:
|
||||
continue
|
||||
pid = perm_map[code].id
|
||||
link_existing = db.query(RolePermission).filter(
|
||||
RolePermission.role == role,
|
||||
RolePermission.permission_id == pid
|
||||
).first()
|
||||
if link_existing:
|
||||
continue
|
||||
db.add(RolePermission(role=role, permission_id=pid))
|
||||
|
||||
db.commit()
|
||||
return created
|
||||
|
||||
@staticmethod
|
||||
def get_user_permissions(db: Session, user: User) -> set:
|
||||
"""获取用户拥有的全部权限代码集合"""
|
||||
if user.role == UserRole.SUPER_ADMIN:
|
||||
return {p.code for p in db.query(Permission).all()}
|
||||
# 其他角色查 role_permissions 关联表
|
||||
rows = db.query(Permission.code).join(
|
||||
RolePermission, RolePermission.permission_id == Permission.id
|
||||
).filter(RolePermission.role == user.role).all()
|
||||
return {r[0] for r in rows}
|
||||
|
||||
@staticmethod
|
||||
def init_default_admin(db: Session):
|
||||
"""初始化默认管理员账户"""
|
||||
admin_exists = db.query(User).filter(User.username == "admin").first()
|
||||
if not admin_exists:
|
||||
admin = User(
|
||||
username="admin",
|
||||
email="admin@ipam.local",
|
||||
real_name="系统管理员",
|
||||
password_hash=SecurityUtils.hash_password("admin123"),
|
||||
role=UserRole.SUPER_ADMIN,
|
||||
status=UserStatus.ACTIVE
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
db.refresh(admin)
|
||||
return admin
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def authenticate(db: Session, username: str, password: str) -> Optional[User]:
|
||||
"""用户认证"""
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if not SecurityUtils.verify_password(password, user.password_hash):
|
||||
# 记录登录失败
|
||||
user.login_failed_count += 1
|
||||
# 登录失败5次锁定账号
|
||||
if user.login_failed_count >= 5:
|
||||
user.status = UserStatus.LOCKED
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
# 登录成功,重置失败计数
|
||||
if user.login_failed_count > 0:
|
||||
user.login_failed_count = 0
|
||||
db.commit()
|
||||
|
||||
# 检查用户状态
|
||||
if user.status != UserStatus.ACTIVE:
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def create_user(db: Session,
|
||||
username: str,
|
||||
password: str,
|
||||
email: Optional[str] = None,
|
||||
phone: Optional[str] = None,
|
||||
real_name: Optional[str] = None,
|
||||
role: UserRole = UserRole.VIEWER,
|
||||
created_by: Optional[int] = None) -> User:
|
||||
"""创建用户"""
|
||||
# 检查用户名是否存在
|
||||
existing = db.query(User).filter(User.username == username).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="用户名已存在"
|
||||
)
|
||||
|
||||
# 检查邮箱是否存在
|
||||
if email:
|
||||
existing_email = db.query(User).filter(User.email == email).first()
|
||||
if existing_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="邮箱已被使用"
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
phone=phone,
|
||||
real_name=real_name,
|
||||
password_hash=SecurityUtils.hash_password(password),
|
||||
role=role,
|
||||
status=UserStatus.ACTIVE,
|
||||
created_by=created_by
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def update_login_info(db: Session, user_id: int, ip_address: str = None):
|
||||
"""更新登录信息"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user:
|
||||
user.last_login_at = datetime.utcnow()
|
||||
user.last_login_ip = ip_address
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_id(db: Session, user_id: int) -> Optional[User]:
|
||||
"""根据ID获取用户"""
|
||||
return db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
@staticmethod
|
||||
def get_user_by_username(db: Session, username: str) -> Optional[User]:
|
||||
"""根据用户名获取用户"""
|
||||
return db.query(User).filter(User.username == username).first()
|
||||
|
||||
@staticmethod
|
||||
def get_users(db: Session, skip: int = 0, limit: int = 50, status: UserStatus = None, role: UserRole = None) -> tuple[int, List[User]]:
|
||||
"""获取用户列表"""
|
||||
query = db.query(User)
|
||||
|
||||
if status:
|
||||
query = query.filter(User.status == status)
|
||||
if role:
|
||||
query = query.filter(User.role == role)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(User.id.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
return total, items
|
||||
|
||||
@staticmethod
|
||||
def change_password(db: Session, user_id: int, old_password: str, new_password: str) -> bool:
|
||||
"""修改密码"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return False
|
||||
|
||||
if not SecurityUtils.verify_password(old_password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="原密码不正确"
|
||||
)
|
||||
|
||||
user.password_hash = SecurityUtils.hash_password(new_password)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def reset_password(db: Session, user_id: int, new_password: str) -> bool:
|
||||
"""管理员重置密码"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return False
|
||||
|
||||
user.password_hash = SecurityUtils.hash_password(new_password)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def update_user_status(db: Session, user_id: int, status: UserStatus) -> Optional[User]:
|
||||
"""更新用户状态"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return None
|
||||
|
||||
user.status = status
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def delete_user(db: Session, user_id: int) -> bool:
|
||||
"""删除用户(软删除,实际标记为禁用)"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return False
|
||||
|
||||
user.status = UserStatus.INACTIVE
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def unlock_user(db: Session, user_id: int) -> Optional[User]:
|
||||
"""解锁被锁定的用户"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return None
|
||||
|
||||
if user.status == UserStatus.LOCKED:
|
||||
user.status = UserStatus.ACTIVE
|
||||
user.login_failed_count = 0
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class TokenService:
|
||||
"""令牌服务"""
|
||||
|
||||
@staticmethod
|
||||
def create_tokens(db: Session, user: User, ip_address: str = None, user_agent: str = None) -> dict:
|
||||
"""创建访问令牌和刷新令牌"""
|
||||
access_token_expires = timedelta(minutes=120) # 访问令牌2小时
|
||||
access_token = SecurityUtils.create_access_token(
|
||||
data={
|
||||
"sub": user.username,
|
||||
"user_id": user.id,
|
||||
"role": user.role.value
|
||||
},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
refresh_token_expires = timedelta(days=7) # 刷新令牌7天
|
||||
refresh_token_value = SecurityUtils.create_refresh_token(
|
||||
data={
|
||||
"sub": user.username,
|
||||
"user_id": user.id
|
||||
},
|
||||
expires_delta=refresh_token_expires
|
||||
)
|
||||
|
||||
# 保存刷新令牌
|
||||
refresh_token = RefreshToken(
|
||||
user_id=user.id,
|
||||
token=refresh_token_value,
|
||||
expires_at=datetime.utcnow() + refresh_token_expires,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
db.add(refresh_token)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token_value,
|
||||
"token_type": "bearer",
|
||||
"expires_in": int(access_token_expires.total_seconds())
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def refresh_access_token(db: Session, refresh_token: str) -> Optional[dict]:
|
||||
"""使用刷新令牌获取新的访问令牌"""
|
||||
try:
|
||||
payload = SecurityUtils.verify_token(refresh_token)
|
||||
username = payload.get("sub")
|
||||
user_id = payload.get("user_id")
|
||||
|
||||
# 检查刷新令牌是否有效且未被撤销
|
||||
token_record = db.query(RefreshToken).filter(
|
||||
RefreshToken.token == refresh_token,
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.revoked == False,
|
||||
RefreshToken.expires_at > datetime.utcnow()
|
||||
).first()
|
||||
|
||||
if not token_record:
|
||||
return None
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or user.status != UserStatus.ACTIVE:
|
||||
return None
|
||||
|
||||
# 创建新的访问令牌
|
||||
access_token_expires = timedelta(minutes=120)
|
||||
access_token = SecurityUtils.create_access_token(
|
||||
data={
|
||||
"sub": user.username,
|
||||
"user_id": user.id,
|
||||
"role": user.role.value
|
||||
},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"expires_in": int(access_token_expires.total_seconds())
|
||||
}
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def revoke_token(db: Session, refresh_token: str) -> bool:
|
||||
"""撤销刷新令牌"""
|
||||
token_record = db.query(RefreshToken).filter(RefreshToken.token == refresh_token).first()
|
||||
if token_record:
|
||||
token_record.revoked = True
|
||||
token_record.revoked_at = datetime.utcnow()
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def revoke_all_user_tokens(db: Session, user_id: int) -> int:
|
||||
"""撤销用户所有刷新令牌"""
|
||||
tokens = db.query(RefreshToken).filter(
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.revoked == False
|
||||
).all()
|
||||
|
||||
for token in tokens:
|
||||
token.revoked = True
|
||||
token.revoked_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
return len(tokens)
|
||||
Reference in New Issue
Block a user