auth.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. """登录与注销接口。"""
  2. from __future__ import annotations
  3. from datetime import datetime, timezone
  4. from flask import current_app, request
  5. from sqlalchemy import select
  6. from dms.api.v1.blueprint import api_v1
  7. from dms.common.enums import AuditAction, AuditResult, EnabledStatus
  8. from dms.common.errors import (
  9. AccountLockedError,
  10. InvalidArgumentError,
  11. InvalidCredentialsError,
  12. LoginIpForbiddenError,
  13. UserDisabledError,
  14. )
  15. from dms.services.captcha_service import issue as captcha_issue, verify as captcha_verify
  16. from dms.common.response import success_response
  17. from dms.database.transaction import for_update, transaction
  18. from dms.extensions import db
  19. from dms.models import User
  20. from dms.security.auth_context import get_auth_context
  21. from dms.security.decorators import bearer_auth_required
  22. from dms.security.jwt_tokens import issue_access_token, issue_reauth_token
  23. from dms.security.passwords import verify_password
  24. from dms.services.admin_permission import SUPER_ADMIN_USERNAME
  25. from dms.services.audit_service import auth_audit, client_ip, record_login_failure
  26. from dms.services.ip_entry_service import verify_login_ip
  27. from dms.services.login_lockout import (
  28. clear_failed_attempts,
  29. is_locked,
  30. register_failed_password_attempt,
  31. )
  32. from dms.services.user_service import allowed_modules_for_user, user_summary
  33. def _login_payload() -> tuple[str, str, bool, str | None, str | None]:
  34. if not request.is_json:
  35. raise InvalidArgumentError("请求体必须是JSON对象")
  36. body = request.get_json(silent=True)
  37. if not isinstance(body, dict):
  38. raise InvalidArgumentError("请求体必须是JSON对象")
  39. # 验证码开启(安全需求4)时请求体扩为五键,关闭时保持三键不变
  40. captcha_enabled = current_app.config.get("DMS_CAPTCHA_ENABLED", True)
  41. expected = {"username", "password", "keepSignedIn"}
  42. if captcha_enabled:
  43. expected |= {"captchaId", "captchaCode"}
  44. if set(body) != expected:
  45. raise InvalidArgumentError(
  46. "请求体必须且只能包含username、password和keepSignedIn"
  47. + ("、captchaId和captchaCode" if captcha_enabled else "")
  48. )
  49. username = body["username"]
  50. password = body["password"]
  51. keep_signed_in = body["keepSignedIn"]
  52. if not isinstance(username, str) or not username.strip():
  53. raise InvalidArgumentError("username不能为空")
  54. if not isinstance(password, str) or not password:
  55. raise InvalidArgumentError("password不能为空")
  56. if not isinstance(keep_signed_in, bool):
  57. raise InvalidArgumentError("keepSignedIn必须是布尔值")
  58. captcha_id: str | None = None
  59. captcha_code: str | None = None
  60. if captcha_enabled:
  61. captcha_id = body["captchaId"]
  62. captcha_code = body["captchaCode"]
  63. if not isinstance(captcha_id, str) or not captcha_id.strip():
  64. raise InvalidArgumentError("captchaId不能为空")
  65. if not isinstance(captcha_code, str) or not captcha_code.strip():
  66. raise InvalidArgumentError("captchaCode不能为空")
  67. return username.strip(), password, keep_signed_in, captcha_id, captcha_code
  68. @api_v1.get("/auth/captcha")
  69. def captcha():
  70. """登录图片验证码(安全需求4):开启时返回 data URL 图片,关闭时仅返回 enabled=false。"""
  71. if not current_app.config.get("DMS_CAPTCHA_ENABLED", True):
  72. return success_response({"enabled": False})
  73. data: dict[str, object] = captcha_issue()
  74. data["enabled"] = True
  75. return success_response(data)
  76. @api_v1.post("/auth/login")
  77. def login():
  78. username, password, keep_signed_in, captcha_id, captcha_code = _login_payload()
  79. # 验证码是防爆破第一道闸:先于用户查询/锁定检查,一次性消费不计锁定;
  80. # 开关关闭时直接放行(对齐 Java CaptchaService.verify,登录回归三键契约)
  81. if current_app.config.get("DMS_CAPTCHA_ENABLED", True):
  82. captcha_verify(captcha_id, captcha_code)
  83. # 超级管理员登录开关(安全需求7)关闭时按用户不存在处理:锁定/状态/IP
  84. # 检查全部短路,报错与密码错误完全一致,不泄露账号存在
  85. admin_login_blocked = (
  86. username == SUPER_ADMIN_USERNAME
  87. and not current_app.config.get("DMS_ALLOW_ADMIN_LOGIN", True)
  88. )
  89. user = (
  90. None
  91. if admin_login_blocked
  92. else db.session.scalar(
  93. select(User).where(User.username == username, User.is_deleted.is_(False))
  94. )
  95. )
  96. # 锁定检查先于密码校验:锁定期间一律拒绝,不延长锁定窗口
  97. if user is not None and is_locked(user):
  98. record_login_failure(
  99. user=user,
  100. attempted_username=username,
  101. failure_reason="ACCOUNT_LOCKED",
  102. )
  103. raise AccountLockedError()
  104. password_valid = verify_password(
  105. user.password_hash if user is not None else None,
  106. password,
  107. )
  108. if user is None or not password_valid:
  109. record_login_failure(
  110. user=user,
  111. attempted_username=username,
  112. failure_reason="INVALID_CREDENTIALS",
  113. )
  114. # 仅密码错误计入锁定计数(用户名不存在无账号可锁)
  115. if user is not None:
  116. register_failed_password_attempt(user)
  117. raise InvalidCredentialsError()
  118. if user.status != EnabledStatus.ENABLED.value:
  119. record_login_failure(
  120. user=user,
  121. attempted_username=username,
  122. failure_reason="USER_DISABLED",
  123. )
  124. raise UserDisabledError()
  125. try:
  126. verify_login_ip(user)
  127. except LoginIpForbiddenError:
  128. record_login_failure(
  129. user=user,
  130. attempted_username=username,
  131. failure_reason="IP_NOT_ALLOWED",
  132. )
  133. raise
  134. token, expires_in = issue_access_token(user, keep_signed_in=keep_signed_in)
  135. with transaction() as session:
  136. user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None)
  137. user.last_login_ip = client_ip()
  138. clear_failed_attempts(user)
  139. user.row_version += 1
  140. session.add(
  141. auth_audit(
  142. action=AuditAction.LOGIN,
  143. result=AuditResult.SUCCESS,
  144. user=user,
  145. detail={"keepSignedIn": keep_signed_in},
  146. )
  147. )
  148. return success_response(
  149. {
  150. "accessToken": token,
  151. "tokenType": "Bearer",
  152. "expiresIn": expires_in,
  153. # 页面空闲自动登出(契约1.29):分钟数,0 表示关闭
  154. "idleLogoutMinutes": current_app.config["DMS_IDLE_LOGOUT_MINUTES"],
  155. "user": user_summary(
  156. user, allowed_modules=allowed_modules_for_user(user)
  157. ),
  158. }
  159. )
  160. @api_v1.post("/auth/logout")
  161. @bearer_auth_required
  162. def logout():
  163. context = get_auth_context()
  164. with transaction() as session:
  165. user = session.scalar(for_update(select(User).where(User.id == context.user_id)))
  166. if user is None or user.is_deleted:
  167. raise InvalidCredentialsError()
  168. user.auth_version += 1
  169. user.row_version += 1
  170. session.add(
  171. auth_audit(
  172. action=AuditAction.LOGOUT,
  173. result=AuditResult.SUCCESS,
  174. user=user,
  175. detail={"authVersionIncremented": True},
  176. )
  177. )
  178. return success_response()
  179. @api_v1.post("/auth/reauth")
  180. @bearer_auth_required
  181. def reauth():
  182. """敏感操作二次认证:登录态重输登录密码,换取 5 分钟 reauth 令牌(契约1.21)。"""
  183. if not request.is_json:
  184. raise InvalidArgumentError("请求体必须是JSON对象")
  185. body = request.get_json(silent=True)
  186. if not isinstance(body, dict) or set(body) != {"password"}:
  187. raise InvalidArgumentError("请求体必须且只能包含password")
  188. password = body["password"]
  189. if not isinstance(password, str) or not password:
  190. raise InvalidArgumentError("password不能为空")
  191. context = get_auth_context()
  192. user = db.session.scalar(
  193. select(User).where(User.id == context.user_id, User.is_deleted.is_(False))
  194. )
  195. if user is None or not verify_password(user.password_hash, password):
  196. raise InvalidCredentialsError()
  197. token, expires_in = issue_reauth_token(user)
  198. return success_response({"reauthToken": token, "expiresIn": expires_in})