document_password_service.py 11 KB

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