auth.py 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. """登录与注销接口。"""
  2. from __future__ import annotations
  3. from datetime import datetime, timezone
  4. from flask import 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.common.response import success_response
  16. from dms.database.transaction import for_update, transaction
  17. from dms.extensions import db
  18. from dms.models import User
  19. from dms.security.auth_context import get_auth_context
  20. from dms.security.decorators import bearer_auth_required
  21. from dms.security.jwt_tokens import issue_access_token
  22. from dms.security.passwords import verify_password
  23. from dms.services.audit_service import auth_audit, client_ip, record_login_failure
  24. from dms.services.ip_entry_service import verify_login_ip
  25. from dms.services.login_lockout import (
  26. clear_failed_attempts,
  27. is_locked,
  28. register_failed_password_attempt,
  29. )
  30. from dms.services.user_service import allowed_modules_for_user, user_summary
  31. def _login_payload() -> tuple[str, str, bool]:
  32. if not request.is_json:
  33. raise InvalidArgumentError("请求体必须是JSON对象")
  34. body = request.get_json(silent=True)
  35. if not isinstance(body, dict):
  36. raise InvalidArgumentError("请求体必须是JSON对象")
  37. expected = {"username", "password", "keepSignedIn"}
  38. if set(body) != expected:
  39. raise InvalidArgumentError("请求体必须且只能包含username、password和keepSignedIn")
  40. username = body["username"]
  41. password = body["password"]
  42. keep_signed_in = body["keepSignedIn"]
  43. if not isinstance(username, str) or not username.strip():
  44. raise InvalidArgumentError("username不能为空")
  45. if not isinstance(password, str) or not password:
  46. raise InvalidArgumentError("password不能为空")
  47. if not isinstance(keep_signed_in, bool):
  48. raise InvalidArgumentError("keepSignedIn必须是布尔值")
  49. return username.strip(), password, keep_signed_in
  50. @api_v1.post("/auth/login")
  51. def login():
  52. username, password, keep_signed_in = _login_payload()
  53. user = db.session.scalar(
  54. select(User).where(User.username == username, User.is_deleted.is_(False))
  55. )
  56. # 锁定检查先于密码校验:锁定期间一律拒绝,不延长锁定窗口
  57. if user is not None and is_locked(user):
  58. record_login_failure(
  59. user=user,
  60. attempted_username=username,
  61. failure_reason="ACCOUNT_LOCKED",
  62. )
  63. raise AccountLockedError()
  64. password_valid = verify_password(
  65. user.password_hash if user is not None else None,
  66. password,
  67. )
  68. if user is None or not password_valid:
  69. record_login_failure(
  70. user=user,
  71. attempted_username=username,
  72. failure_reason="INVALID_CREDENTIALS",
  73. )
  74. # 仅密码错误计入锁定计数(用户名不存在无账号可锁)
  75. if user is not None:
  76. register_failed_password_attempt(user)
  77. raise InvalidCredentialsError()
  78. if user.status != EnabledStatus.ENABLED.value:
  79. record_login_failure(
  80. user=user,
  81. attempted_username=username,
  82. failure_reason="USER_DISABLED",
  83. )
  84. raise UserDisabledError()
  85. try:
  86. verify_login_ip(user)
  87. except LoginIpForbiddenError:
  88. record_login_failure(
  89. user=user,
  90. attempted_username=username,
  91. failure_reason="IP_NOT_ALLOWED",
  92. )
  93. raise
  94. token, expires_in = issue_access_token(user, keep_signed_in=keep_signed_in)
  95. with transaction() as session:
  96. user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None)
  97. user.last_login_ip = client_ip()
  98. clear_failed_attempts(user)
  99. user.row_version += 1
  100. session.add(
  101. auth_audit(
  102. action=AuditAction.LOGIN,
  103. result=AuditResult.SUCCESS,
  104. user=user,
  105. detail={"keepSignedIn": keep_signed_in},
  106. )
  107. )
  108. return success_response(
  109. {
  110. "accessToken": token,
  111. "tokenType": "Bearer",
  112. "expiresIn": expires_in,
  113. "user": user_summary(
  114. user, allowed_modules=allowed_modules_for_user(user)
  115. ),
  116. }
  117. )
  118. @api_v1.post("/auth/logout")
  119. @bearer_auth_required
  120. def logout():
  121. context = get_auth_context()
  122. with transaction() as session:
  123. user = session.scalar(for_update(select(User).where(User.id == context.user_id)))
  124. if user is None or user.is_deleted:
  125. raise InvalidCredentialsError()
  126. user.auth_version += 1
  127. user.row_version += 1
  128. session.add(
  129. auth_audit(
  130. action=AuditAction.LOGOUT,
  131. result=AuditResult.SUCCESS,
  132. user=user,
  133. detail={"authVersionIncremented": True},
  134. )
  135. )
  136. return success_response()