ip_entry_service.py 17 KB

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