ip_entry_service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. """部门IP登录限制:IP条目池维护、部门分配与登录IP校验。"""
  2. from __future__ import annotations
  3. import ipaddress
  4. from datetime import datetime, timezone
  5. from typing import Any
  6. from sqlalchemy import func, or_, select
  7. from sqlalchemy.exc import IntegrityError
  8. from dms.common.enums import AuditAction, AuditTarget, RoleCode
  9. from dms.common.errors import (
  10. ConflictError,
  11. InvalidArgumentError,
  12. LoginIpForbiddenError,
  13. ResourceNotFoundError,
  14. )
  15. from dms.common.pagination import PageRequest, page_result
  16. from dms.common.response import serialize_id
  17. from dms.database.transaction import transaction
  18. from dms.extensions import db
  19. from dms.models import IpEntry, OrgIpAssignment, Organization, User
  20. from dms.security.auth_context import get_auth_context
  21. from dms.services.audit_service import business_audit, client_ip
  22. IP_ENTRY_CREATE_FIELDS = {"ipValue", "remark"}
  23. IP_ENTRY_UPDATE_FIELDS = {"ipValue", "remark", "rowVersion"}
  24. ORG_ASSIGNMENT_FIELDS = {"ipEntryIds", "rowVersion"}
  25. IP_VALUE_MAX_LENGTH = 64
  26. REMARK_MAX_LENGTH = 128
  27. def _utc_now() -> datetime:
  28. return datetime.now(timezone.utc).replace(tzinfo=None)
  29. def _row_version(value: Any) -> int:
  30. if type(value) is not int or value < 0:
  31. raise InvalidArgumentError("rowVersion必须是非负整数")
  32. return value
  33. def _id_list(value: Any, *, field: str) -> list[int]:
  34. if not isinstance(value, list) or any(
  35. not isinstance(item, str) or not item.isdecimal() or int(item) <= 0
  36. for item in value
  37. ):
  38. raise InvalidArgumentError(f"{field}必须是正整数形式的字符串ID数组")
  39. ids = [int(item) for item in value]
  40. if len(set(ids)) != len(ids):
  41. raise InvalidArgumentError(f"{field}不能包含重复ID")
  42. return ids
  43. def _exact_payload(payload: Any, expected: set[str]) -> dict[str, Any]:
  44. if not isinstance(payload, dict) or set(payload) != expected:
  45. raise InvalidArgumentError(
  46. "请求体字段必须且只能包含:" + "、".join(sorted(expected))
  47. )
  48. return payload
  49. def normalize_ip_value(value: Any) -> str:
  50. """规范化精确IP或CIDR网段文本;CIDR要求主机位为0(strict=True)。"""
  51. if not isinstance(value, str):
  52. raise InvalidArgumentError("ipValue必须是字符串")
  53. text = value.strip()
  54. if not text:
  55. raise InvalidArgumentError("ipValue不能为空")
  56. if len(text) > IP_VALUE_MAX_LENGTH:
  57. raise InvalidArgumentError(
  58. f"ipValue长度不能超过{IP_VALUE_MAX_LENGTH}"
  59. )
  60. try:
  61. if "/" in text:
  62. return str(ipaddress.ip_network(text, strict=True))
  63. return str(ipaddress.ip_address(text))
  64. except ValueError as exc:
  65. if "host bits" in str(exc):
  66. raise InvalidArgumentError(
  67. f"网段要求主机位为0,请输入如192.168.1.0/24的形式:{text}"
  68. ) from exc
  69. raise InvalidArgumentError(f"IP地址或网段格式非法:{text}") from exc
  70. def _remark(value: Any) -> str:
  71. if value is None:
  72. return ""
  73. if not isinstance(value, str):
  74. raise InvalidArgumentError("remark必须是字符串")
  75. normalized = value.strip()
  76. if len(normalized) > REMARK_MAX_LENGTH:
  77. raise InvalidArgumentError(f"remark长度不能超过{REMARK_MAX_LENGTH}")
  78. return normalized
  79. def entry_summary(entry: IpEntry) -> dict[str, object]:
  80. return {
  81. "id": serialize_id(entry.id),
  82. "ipValue": entry.ip_value,
  83. "remark": entry.remark,
  84. "rowVersion": entry.row_version,
  85. }
  86. def _entry_row(session, entry_id: int, *, for_update: bool = False) -> IpEntry:
  87. stmt = select(IpEntry).where(
  88. IpEntry.id == entry_id, IpEntry.is_deleted.is_(False)
  89. )
  90. if for_update:
  91. stmt = stmt.with_for_update()
  92. entry = session.scalar(stmt)
  93. if entry is None:
  94. raise ResourceNotFoundError("IP条目不存在")
  95. return entry
  96. def _ensure_ip_free(session, ip_value: str, *, exclude_id: int) -> None:
  97. duplicated = session.scalar(
  98. select(IpEntry.id).where(
  99. IpEntry.ip_value == ip_value,
  100. IpEntry.is_deleted.is_(False),
  101. IpEntry.id != exclude_id,
  102. )
  103. )
  104. if duplicated is not None:
  105. raise ConflictError("该IP已存在")
  106. # ─── IP条目池 CRUD ─────────────────────────────────────────────────────────
  107. def list_ip_entries(
  108. *,
  109. keyword: str | None,
  110. page_request: PageRequest,
  111. ) -> dict[str, object]:
  112. filters = [IpEntry.is_deleted.is_(False)]
  113. text = keyword.strip() if isinstance(keyword, str) else ""
  114. if text:
  115. pattern = f"%{text}%"
  116. filters.append(
  117. or_(IpEntry.ip_value.like(pattern), IpEntry.remark.like(pattern))
  118. )
  119. total = db.session.scalar(
  120. select(func.count(IpEntry.id)).where(*filters)
  121. ) or 0
  122. entries = db.session.scalars(
  123. select(IpEntry)
  124. .where(*filters)
  125. .order_by(IpEntry.id.asc())
  126. .offset(page_request.offset)
  127. .limit(page_request.page_size)
  128. ).all()
  129. return page_result(
  130. [entry_summary(entry) for entry in entries],
  131. page=page_request.page,
  132. page_size=page_request.page_size,
  133. total=total,
  134. )
  135. def create_ip_entry(payload: Any) -> dict[str, object]:
  136. body = _exact_payload(payload, IP_ENTRY_CREATE_FIELDS)
  137. ip_value = normalize_ip_value(body["ipValue"])
  138. remark = _remark(body["remark"])
  139. actor_id = get_auth_context().user_id
  140. try:
  141. with transaction() as session:
  142. _ensure_ip_free(session, ip_value, exclude_id=0)
  143. entry = IpEntry(
  144. ip_value=ip_value,
  145. remark=remark,
  146. created_by=actor_id,
  147. updated_by=actor_id,
  148. )
  149. session.add(entry)
  150. session.flush()
  151. session.add(
  152. business_audit(
  153. action=AuditAction.CREATE_IP_ENTRY,
  154. target=AuditTarget.IP_ENTRY,
  155. target_id=entry.id,
  156. target_name=entry.ip_value,
  157. detail={"ipValue": entry.ip_value, "remark": entry.remark},
  158. )
  159. )
  160. return entry_summary(entry)
  161. except IntegrityError as exc:
  162. raise ConflictError("该IP已存在") from exc
  163. def update_ip_entry(entry_id: int, payload: Any) -> dict[str, object]:
  164. body = _exact_payload(payload, IP_ENTRY_UPDATE_FIELDS)
  165. ip_value = normalize_ip_value(body["ipValue"])
  166. remark = _remark(body["remark"])
  167. expected_version = _row_version(body["rowVersion"])
  168. actor_id = get_auth_context().user_id
  169. now = _utc_now()
  170. with transaction() as session:
  171. entry = _entry_row(session, entry_id, for_update=True)
  172. if entry.row_version != expected_version:
  173. raise ConflictError(
  174. "数据已被其他用户修改,请刷新后重试",
  175. details={"currentRowVersion": entry.row_version},
  176. )
  177. _ensure_ip_free(session, ip_value, exclude_id=entry.id)
  178. before = {"ipValue": entry.ip_value, "remark": entry.remark}
  179. entry.ip_value = ip_value
  180. entry.remark = remark
  181. entry.updated_by = actor_id
  182. entry.updated_at = now
  183. entry.row_version += 1
  184. session.add(
  185. business_audit(
  186. action=AuditAction.EDIT_IP_ENTRY,
  187. target=AuditTarget.IP_ENTRY,
  188. target_id=entry.id,
  189. target_name=entry.ip_value,
  190. detail={
  191. "before": before,
  192. "after": {"ipValue": entry.ip_value, "remark": entry.remark},
  193. },
  194. )
  195. )
  196. return entry_summary(entry)
  197. def delete_ip_entry(entry_id: int, row_version: int) -> dict[str, object]:
  198. actor_id = get_auth_context().user_id
  199. now = _utc_now()
  200. with transaction() as session:
  201. entry = _entry_row(session, entry_id, for_update=True)
  202. if entry.row_version != row_version:
  203. raise ConflictError(
  204. "数据已被其他用户修改,请刷新后重试",
  205. details={"currentRowVersion": entry.row_version},
  206. )
  207. referenced = session.scalar(
  208. select(func.count(OrgIpAssignment.id)).where(
  209. OrgIpAssignment.ip_entry_id == entry_id,
  210. OrgIpAssignment.is_deleted.is_(False),
  211. )
  212. )
  213. if referenced:
  214. raise ConflictError("该IP已分配给部门,请先取消分配")
  215. target_name = entry.ip_value
  216. entry.is_deleted = True
  217. entry.deleted_at = now
  218. entry.updated_by = actor_id
  219. entry.updated_at = now
  220. entry.row_version += 1
  221. session.add(
  222. business_audit(
  223. action=AuditAction.DELETE_IP_ENTRY,
  224. target=AuditTarget.IP_ENTRY,
  225. target_id=entry_id,
  226. target_name=target_name,
  227. detail={"ipValue": target_name, "rowVersion": entry.row_version},
  228. )
  229. )
  230. return {"id": serialize_id(entry_id), "deleted": True}
  231. # ─── 部门分配(整体替换语义) ───────────────────────────────────────────────
  232. def list_org_ip_assignments(organization_id: int) -> dict[str, object]:
  233. organization = db.session.scalar(
  234. select(Organization.id).where(
  235. Organization.id == organization_id,
  236. Organization.is_deleted.is_(False),
  237. )
  238. )
  239. if organization is None:
  240. raise ResourceNotFoundError("机构不存在")
  241. entry_ids = db.session.scalars(
  242. select(OrgIpAssignment.ip_entry_id)
  243. .where(
  244. OrgIpAssignment.organization_id == organization_id,
  245. OrgIpAssignment.is_deleted.is_(False),
  246. )
  247. .order_by(OrgIpAssignment.ip_entry_id.asc())
  248. ).all()
  249. return {"ipEntryIds": [serialize_id(value) for value in entry_ids]}
  250. def update_org_ip_assignments(
  251. organization_id: int,
  252. payload: Any,
  253. ) -> dict[str, object]:
  254. body = _exact_payload(payload, ORG_ASSIGNMENT_FIELDS)
  255. entry_ids = _id_list(body["ipEntryIds"], field="ipEntryIds")
  256. expected_version = _row_version(body["rowVersion"])
  257. actor_id = get_auth_context().user_id
  258. now = _utc_now()
  259. with transaction() as session:
  260. organization = session.scalar(
  261. select(Organization)
  262. .where(
  263. Organization.id == organization_id,
  264. Organization.is_deleted.is_(False),
  265. )
  266. .with_for_update()
  267. )
  268. if organization is None:
  269. raise ResourceNotFoundError("机构不存在")
  270. if organization.row_version != expected_version:
  271. raise ConflictError(
  272. "数据已被其他用户修改,请刷新后重试",
  273. details={"currentRowVersion": organization.row_version},
  274. )
  275. target_ids = set(entry_ids)
  276. rows: list = []
  277. if target_ids:
  278. rows = session.execute(
  279. select(IpEntry.id, IpEntry.ip_value).where(
  280. IpEntry.id.in_(sorted(target_ids)),
  281. IpEntry.is_deleted.is_(False),
  282. )
  283. ).all()
  284. valid_ids = {row.id for row in rows}
  285. if target_ids - valid_ids:
  286. raise InvalidArgumentError("ipEntryIds包含无效IP条目")
  287. existing_rows = session.scalars(
  288. select(OrgIpAssignment).where(
  289. OrgIpAssignment.organization_id == organization_id,
  290. OrgIpAssignment.is_deleted.is_(False),
  291. )
  292. ).all()
  293. existing_map = {row.ip_entry_id: row for row in existing_rows}
  294. for ip_entry_id, row in existing_map.items():
  295. if ip_entry_id not in target_ids:
  296. row.is_deleted = True
  297. row.deleted_at = now
  298. row.updated_by = actor_id
  299. row.updated_at = now
  300. for ip_entry_id in sorted(target_ids - existing_map.keys()):
  301. session.add(
  302. OrgIpAssignment(
  303. organization_id=organization_id,
  304. ip_entry_id=ip_entry_id,
  305. created_by=actor_id,
  306. updated_by=actor_id,
  307. )
  308. )
  309. organization.updated_by = actor_id
  310. organization.updated_at = now
  311. organization.row_version += 1
  312. session.add(
  313. business_audit(
  314. action=AuditAction.ASSIGN_ORG_IP,
  315. target=AuditTarget.ORGANIZATION,
  316. target_id=organization_id,
  317. target_name=organization.org_name,
  318. detail={
  319. "ipEntries": [
  320. {
  321. "id": serialize_id(row.id),
  322. "ipValue": row.ip_value,
  323. }
  324. for row in sorted(
  325. rows, key=lambda item: item.id
  326. )
  327. ]
  328. if target_ids
  329. else [],
  330. "rowVersion": organization.row_version,
  331. },
  332. )
  333. )
  334. return {
  335. "id": serialize_id(organization_id),
  336. "ipEntryIds": [serialize_id(value) for value in sorted(target_ids)],
  337. "rowVersion": organization.row_version,
  338. }
  339. # ─── 登录IP校验 ────────────────────────────────────────────────────────────
  340. def verify_login_ip(user: User) -> None:
  341. """密码与状态校验通过后调用:部门分配了IP列表则校验来源IP命中。"""
  342. if user.role_code == RoleCode.ADMIN.value:
  343. return
  344. if user.organization_id is None:
  345. return
  346. ip_values = db.session.execute(
  347. select(IpEntry.ip_value)
  348. .join(
  349. OrgIpAssignment, OrgIpAssignment.ip_entry_id == IpEntry.id
  350. )
  351. .where(
  352. OrgIpAssignment.organization_id == user.organization_id,
  353. OrgIpAssignment.is_deleted.is_(False),
  354. IpEntry.is_deleted.is_(False),
  355. )
  356. ).scalars().all()
  357. if not ip_values:
  358. return
  359. raw_ip = client_ip()
  360. if raw_ip is None:
  361. raise LoginIpForbiddenError()
  362. try:
  363. address = ipaddress.ip_address(raw_ip.strip())
  364. except ValueError as exc:
  365. raise LoginIpForbiddenError() from exc
  366. for value in ip_values:
  367. try:
  368. if "/" in value:
  369. network = ipaddress.ip_network(value)
  370. if address.version == network.version and address in network:
  371. return
  372. elif address == ipaddress.ip_address(value):
  373. return
  374. except ValueError:
  375. # 存量数据异常时按不匹配处理(安全优先),不中断登录流程判断
  376. continue
  377. raise LoginIpForbiddenError()
  378. __all__ = [
  379. "create_ip_entry",
  380. "delete_ip_entry",
  381. "entry_summary",
  382. "list_ip_entries",
  383. "list_org_ip_assignments",
  384. "normalize_ip_value",
  385. "update_ip_entry",
  386. "update_org_ip_assignments",
  387. "verify_login_ip",
  388. ]