document_password_service.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. """方案密码锁:判定、验证与设置/修改/清除。
  2. 核心语义(需求已确认):
  3. - 一个 Argon2id 哈希密码 + 7 个操作勾选位,仅主案(MAIN)可设密;
  4. - 普通用户:仅勾选操作需要密码;
  5. - 管理员(ADMIN):对设密主案的除 DELETE 外全部操作均需密码(防止越权查看),
  6. DELETE 免密但前端要求二次确认;
  7. - 修改/清除密码时任何人(含管理员)都必须提供原密码;
  8. - 密码经请求头 X-DMS-Document-Password 传递,避免进入URL/访问日志。
  9. """
  10. from __future__ import annotations
  11. from typing import Any
  12. from flask import request
  13. from sqlalchemy import select
  14. from dms.common.enums import (
  15. AuditAction,
  16. AuditTarget,
  17. DocumentPasswordAction,
  18. DocumentType,
  19. RoleCode,
  20. )
  21. from dms.common.errors import (
  22. ConflictError,
  23. DocumentPasswordInvalidError,
  24. DocumentPasswordRequiredError,
  25. InvalidArgumentError,
  26. ResourceNotFoundError,
  27. )
  28. from dms.common.response import serialize_id
  29. from dms.database.transaction import transaction
  30. from dms.models import Document
  31. from dms.security.auth_context import AuthContext
  32. from dms.security.passwords import hash_password, verify_password
  33. from dms.services.audit_service import business_audit
  34. PASSWORD_HEADER = "X-DMS-Document-Password"
  35. PASSWORD_FIELDS = {"password", "protectedActions"}
  36. PASSWORD_MAX_LENGTH = 128
  37. # 操作码 → 模型勾选位列名
  38. _ACTION_COLUMNS: dict[str, str] = {
  39. DocumentPasswordAction.VIEW.value: "protect_view",
  40. DocumentPasswordAction.DOWNLOAD.value: "protect_download",
  41. DocumentPasswordAction.EDIT.value: "protect_edit",
  42. DocumentPasswordAction.DELETE.value: "protect_delete",
  43. DocumentPasswordAction.CONFIG_PERMISSION.value: (
  44. "protect_config_permission"
  45. ),
  46. DocumentPasswordAction.BIND_ATTACHMENT.value: "protect_bind_attachment",
  47. DocumentPasswordAction.UNBIND_ATTACHMENT.value: (
  48. "protect_unbind_attachment"
  49. ),
  50. }
  51. def parse_protected_actions(value: Any) -> list[str]:
  52. """校验并返回合法的保护操作码列表(按枚举定义顺序稳定排序)。"""
  53. if not isinstance(value, list) or not value:
  54. raise InvalidArgumentError(
  55. "protectedActions必须是非空数组,元素为"
  56. "VIEW/DOWNLOAD/EDIT/DELETE/CONFIG_PERMISSION/"
  57. "BIND_ATTACHMENT/UNBIND_ATTACHMENT"
  58. )
  59. valid = {action.value for action in DocumentPasswordAction}
  60. seen: list[str] = []
  61. for item in value:
  62. if not isinstance(item, str) or item not in valid:
  63. raise InvalidArgumentError(
  64. "protectedActions包含未知操作码:" + repr(item)
  65. )
  66. if item not in seen:
  67. seen.append(item)
  68. return [action.value for action in DocumentPasswordAction if action.value in seen]
  69. def protected_actions_of(document: Document) -> list[str]:
  70. """文档当前勾选的保护操作码(未设密恒为空)。"""
  71. if document.password_hash is None:
  72. return []
  73. return [
  74. action
  75. for action, column in _ACTION_COLUMNS.items()
  76. if getattr(document, column)
  77. ]
  78. def password_required_for(
  79. document: Document,
  80. action: str,
  81. context: AuthContext,
  82. ) -> bool:
  83. """该用户对该文档的指定操作是否需要密码。"""
  84. if document.password_hash is None:
  85. return False
  86. if context.role_code == RoleCode.ADMIN:
  87. # 管理员对设密主案除删除外全部操作均需密码(不限于勾选位)
  88. return action != DocumentPasswordAction.DELETE.value
  89. column = _ACTION_COLUMNS.get(action)
  90. if column is None:
  91. return False
  92. return bool(getattr(document, column))
  93. def require_document_password(
  94. document: Document,
  95. action: str,
  96. password: str | None,
  97. context: AuthContext,
  98. ) -> None:
  99. """操作入口守卫:需要密码时校验,缺失/错误分别抛出对应错误。"""
  100. if not password_required_for(document, action, context):
  101. return
  102. if not password:
  103. raise DocumentPasswordRequiredError(
  104. details={"action": action, "documentId": serialize_id(document.id)}
  105. )
  106. if not verify_password(document.password_hash, password):
  107. raise DocumentPasswordInvalidError(
  108. details={"action": action, "documentId": serialize_id(document.id)}
  109. )
  110. def get_request_password() -> str | None:
  111. value = request.headers.get(PASSWORD_HEADER)
  112. return value if value else None
  113. def is_view_locked(document: Document, context: AuthContext) -> bool:
  114. """列表/详情序列化用:查看(文件正文)是否被密码锁拦截。"""
  115. return password_required_for(
  116. document, DocumentPasswordAction.VIEW.value, context
  117. )
  118. def view_password_verified(document: Document, context: AuthContext) -> bool:
  119. """详情专用:查看需密码时请求头密码是否正确(不抛错,供字段屏蔽判定)。"""
  120. if not is_view_locked(document, context):
  121. return True
  122. password = get_request_password()
  123. return bool(password) and verify_password(document.password_hash, password)
  124. def apply_password_lock(
  125. document: Document,
  126. password: str,
  127. actions: list[str],
  128. ) -> None:
  129. """写入密码哈希与勾选位(调用方保证仅 MAIN、已通过校验)。"""
  130. document.password_hash = hash_password(password)
  131. selected = set(actions)
  132. for action, column in _ACTION_COLUMNS.items():
  133. setattr(document, column, action in selected)
  134. def clear_password_lock(document: Document) -> None:
  135. document.password_hash = None
  136. for column in _ACTION_COLUMNS.values():
  137. setattr(document, column, False)
  138. def _pop_password_spec(
  139. payload: dict[str, Any],
  140. *,
  141. attachment: bool,
  142. ) -> tuple[str | None, list[str] | None]:
  143. """从上传 metadata 中摘出可选密码锁字段并校验(仅新增主案可用)。
  144. 返回 (password, protectedActions);未携带时为 (None, None)。
  145. """
  146. if not (PASSWORD_FIELDS & set(payload)):
  147. return None, None
  148. raw_type = payload.get("documentType")
  149. if attachment or raw_type != DocumentType.MAIN.value:
  150. raise InvalidArgumentError("密码锁仅支持上传主案(MAIN)时设置")
  151. if "overwriteDocumentId" in payload:
  152. raise InvalidArgumentError(
  153. "覆盖上传不能修改密码锁,请在上传后通过密码管理维护"
  154. )
  155. if "password" not in payload or "protectedActions" not in payload:
  156. raise InvalidArgumentError(
  157. "password与protectedActions必须同时提供"
  158. )
  159. password = payload.pop("password")
  160. if (
  161. not isinstance(password, str)
  162. or not password.strip()
  163. or len(password) > PASSWORD_MAX_LENGTH
  164. ):
  165. raise InvalidArgumentError(
  166. f"password必须是1~{PASSWORD_MAX_LENGTH}位的非空字符串"
  167. )
  168. actions = parse_protected_actions(payload.pop("protectedActions"))
  169. return password, actions
  170. def _require_active_main_plan(session, document_id: int) -> Document:
  171. document = session.scalar(
  172. select(Document)
  173. .where(
  174. Document.id == document_id,
  175. Document.document_type == DocumentType.MAIN.value,
  176. Document.is_deleted.is_(False),
  177. )
  178. .with_for_update()
  179. )
  180. if document is None:
  181. raise ResourceNotFoundError("主案不存在或不可设置密码锁")
  182. return document
  183. def _password_summary(document: Document) -> dict[str, object]:
  184. return {
  185. "id": serialize_id(document.id),
  186. "hasPassword": document.password_hash is not None,
  187. "protectedActions": protected_actions_of(document),
  188. "rowVersion": document.row_version,
  189. }
  190. def save_document_password(document_id: int, payload: dict[str, Any]) -> dict[str, object]:
  191. """设置/修改/清除主案密码锁。
  192. payload: { oldPassword?, password?, protectedActions?, rowVersion }
  193. - 未设密时设置:password + protectedActions 必填,无需 oldPassword;
  194. - 已设密时修改/清除:oldPassword 必填且正确;password 为空/缺省即清除。
  195. """
  196. if not isinstance(payload, dict) or set(payload) - {
  197. "oldPassword",
  198. "password",
  199. "protectedActions",
  200. "rowVersion",
  201. }:
  202. raise InvalidArgumentError(
  203. "请求体只允许oldPassword、password、protectedActions、rowVersion"
  204. )
  205. raw_version = payload.get("rowVersion")
  206. if not isinstance(raw_version, int) or isinstance(raw_version, bool) or raw_version < 0:
  207. raise InvalidArgumentError("rowVersion必须是非负整数")
  208. row_version = raw_version
  209. old_password = payload.get("oldPassword")
  210. new_password = payload.get("password")
  211. with transaction() as session:
  212. document = _require_active_main_plan(session, document_id)
  213. if document.row_version != row_version:
  214. raise ConflictError(
  215. "方案已被其他操作修改,请刷新后重试",
  216. details={"currentRowVersion": document.row_version},
  217. )
  218. if document.password_hash is not None:
  219. if not old_password or not verify_password(
  220. document.password_hash, old_password
  221. ):
  222. raise DocumentPasswordInvalidError(
  223. details={"reason": "oldPassword"}
  224. )
  225. elif old_password:
  226. raise InvalidArgumentError("该主案尚未设置密码,无需提供oldPassword")
  227. removing = not new_password
  228. if removing:
  229. if "protectedActions" in payload and payload["protectedActions"]:
  230. raise InvalidArgumentError(
  231. "清除密码时不能同时指定protectedActions"
  232. )
  233. clear_password_lock(document)
  234. action = AuditAction.DOCUMENT_PASSWORD_REMOVE
  235. detail: dict[str, Any] = {}
  236. else:
  237. if (
  238. not isinstance(new_password, str)
  239. or not new_password.strip()
  240. or len(new_password) > PASSWORD_MAX_LENGTH
  241. ):
  242. raise InvalidArgumentError(
  243. f"password必须是1~{PASSWORD_MAX_LENGTH}位的非空字符串"
  244. )
  245. actions = parse_protected_actions(payload.get("protectedActions"))
  246. apply_password_lock(document, new_password, actions)
  247. action = AuditAction.DOCUMENT_PASSWORD_SET
  248. detail = {"protectedActions": actions}
  249. document.row_version += 1
  250. detail["rowVersion"] = document.row_version
  251. session.add(
  252. business_audit(
  253. action=action,
  254. target=AuditTarget.DOCUMENT,
  255. target_id=document.id,
  256. target_name=document.document_name,
  257. detail=detail,
  258. )
  259. )
  260. result = _password_summary(document)
  261. return result