test_b2_auth_and_directory.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. from __future__ import annotations
  2. from datetime import datetime, timedelta, timezone
  3. from uuid import uuid4
  4. import jwt
  5. import pytest
  6. from sqlalchemy import select
  7. from dms.extensions import db
  8. from dms.models import AuditLog, Organization, User
  9. from dms.security.passwords import hash_password, verify_password
  10. def _login(client, password, username="admin", keep_signed_in=False):
  11. return client.post(
  12. "/api/v1/auth/login",
  13. json={
  14. "username": username,
  15. "password": password,
  16. "keepSignedIn": keep_signed_in,
  17. },
  18. )
  19. def _auth(token):
  20. return {"Authorization": f"Bearer {token}"}
  21. def _custom_token(app, user, **overrides):
  22. now = datetime.now(timezone.utc)
  23. claims = {
  24. "sub": str(user.id),
  25. "username": user.username,
  26. "roleCode": user.role_code,
  27. "authVersion": user.auth_version,
  28. "iat": now,
  29. "exp": now + timedelta(hours=1),
  30. "jti": str(uuid4()),
  31. "iss": "dms",
  32. }
  33. claims.update(overrides)
  34. return jwt.encode(claims, app.config["DMS_JWT_SECRET"], algorithm="HS256")
  35. def test_argon2id_hash_and_verify(b2_password):
  36. password_hash = hash_password(b2_password)
  37. assert password_hash.startswith("$argon2id$")
  38. assert verify_password(password_hash, b2_password)
  39. assert not verify_password(password_hash, b2_password + "x")
  40. def test_login_success_and_safe_user_summary(b2_client, b2_password):
  41. response = _login(b2_client, b2_password)
  42. payload = response.get_json()
  43. assert response.status_code == 200
  44. assert payload["data"]["tokenType"] == "Bearer"
  45. # 契约1.22:令牌有效期配置化(DMS_JWT_EXPIRES_SECONDS 缺省 3600 秒)
  46. assert payload["data"]["expiresIn"] == 3600
  47. # 契约1.29:登录响应下发空闲自动登出分钟数(缺省5,0表示关闭)
  48. assert payload["data"]["idleLogoutMinutes"] == 5
  49. # 契约1.38:一次一码轮换回显(Python 版不实现轮换,恒 False)
  50. assert payload["data"]["tokenRotation"] is False
  51. user = payload["data"]["user"]
  52. assert isinstance(user["id"], str)
  53. assert user["allowedModules"] == [
  54. "BACKEND_MANAGEMENT",
  55. "AUDIT_LOG",
  56. "ORGANIZATION_USER_MANAGEMENT",
  57. "SYSTEM_MANAGEMENT",
  58. ]
  59. assert "passwordHash" not in user
  60. @pytest.mark.parametrize("username,password_suffix", [("missing", ""), ("admin", "-bad")])
  61. def test_invalid_credentials_are_generic(
  62. b2_app, b2_client, b2_password, username, password_suffix
  63. ):
  64. response = _login(b2_client, b2_password + password_suffix, username=username)
  65. assert response.status_code == 401
  66. assert response.get_json()["code"] == "INVALID_CREDENTIALS"
  67. with b2_app.app_context():
  68. audit = db.session.scalars(
  69. select(AuditLog).where(AuditLog.action_type == "LOGIN")
  70. ).all()
  71. assert len(audit) == 1
  72. assert audit[0].operation_result == "FAILURE"
  73. def test_disabled_user_login(b2_client, b2_password):
  74. response = _login(b2_client, b2_password, username="disabled")
  75. assert response.status_code == 401
  76. assert response.get_json()["code"] == "USER_DISABLED"
  77. @pytest.mark.parametrize("keep_signed_in,expires_in", [(False, 3600), (True, 604800)])
  78. def test_keep_signed_in_expiration(b2_client, b2_password, keep_signed_in, expires_in):
  79. response = _login(b2_client, b2_password, keep_signed_in=keep_signed_in)
  80. assert response.get_json()["data"]["expiresIn"] == expires_in
  81. def test_login_rejects_non_exact_payload(b2_client, b2_password):
  82. response = b2_client.post(
  83. "/api/v1/auth/login",
  84. json={"username": "admin", "password": b2_password, "keepSignedIn": False, "x": 1},
  85. )
  86. assert response.status_code == 400
  87. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  88. def test_empty_jwt_secret_fails_clearly(b2_app, b2_client, b2_password):
  89. b2_app.config["DMS_JWT_SECRET"] = ""
  90. response = _login(b2_client, b2_password)
  91. assert response.status_code == 500
  92. assert response.get_json()["code"] == "INTERNAL_ERROR"
  93. def test_jwt_has_required_claims(b2_app, b2_client, b2_password):
  94. token = _login(b2_client, b2_password).get_json()["data"]["accessToken"]
  95. claims = jwt.decode(
  96. token,
  97. b2_app.config["DMS_JWT_SECRET"],
  98. algorithms=["HS256"],
  99. issuer="dms",
  100. )
  101. assert {
  102. "sub", "username", "roleCode", "authVersion", "iat", "exp", "jti", "iss"
  103. } <= set(claims)
  104. assert isinstance(claims["sub"], str)
  105. def test_invalid_signature(b2_client):
  106. response = b2_client.get("/api/v1/users/me", headers=_auth("not-a-jwt"))
  107. assert response.status_code == 401
  108. assert response.get_json()["code"] == "TOKEN_INVALID"
  109. def test_expired_token(b2_app, b2_client):
  110. with b2_app.app_context():
  111. user = db.session.scalar(select(User).where(User.username == "admin"))
  112. token = _custom_token(
  113. b2_app,
  114. user,
  115. exp=datetime.now(timezone.utc) - timedelta(seconds=1),
  116. )
  117. response = b2_client.get("/api/v1/users/me", headers=_auth(token))
  118. assert response.status_code == 401
  119. assert response.get_json()["code"] == "TOKEN_EXPIRED"
  120. def test_auth_version_mismatch(b2_app, b2_client):
  121. with b2_app.app_context():
  122. user = db.session.scalar(select(User).where(User.username == "admin"))
  123. token = _custom_token(b2_app, user, authVersion=user.auth_version + 1)
  124. response = b2_client.get("/api/v1/users/me", headers=_auth(token))
  125. assert response.status_code == 401
  126. assert response.get_json()["code"] == "AUTH_VERSION_MISMATCH"
  127. def test_disabled_user_token(b2_app, b2_client):
  128. with b2_app.app_context():
  129. user = db.session.scalar(select(User).where(User.username == "disabled"))
  130. token = _custom_token(b2_app, user)
  131. response = b2_client.get("/api/v1/users/me", headers=_auth(token))
  132. assert response.status_code == 401
  133. assert response.get_json()["code"] == "USER_DISABLED"
  134. def test_logically_deleted_user_token(b2_app, b2_client, login_token):
  135. with b2_app.app_context():
  136. user = db.session.scalar(select(User).where(User.username == "admin"))
  137. user.is_deleted = True
  138. db.session.commit()
  139. response = b2_client.get("/api/v1/users/me", headers=_auth(login_token))
  140. assert response.status_code == 401
  141. assert response.get_json()["code"] == "TOKEN_INVALID"
  142. def test_missing_and_malformed_bearer(b2_client):
  143. assert b2_client.get("/api/v1/users/me").get_json()["code"] == "TOKEN_INVALID"
  144. response = b2_client.get(
  145. "/api/v1/users/me", headers={"Authorization": "Basic abc"}
  146. )
  147. assert response.status_code == 401
  148. assert response.get_json()["code"] == "TOKEN_INVALID"
  149. def test_current_user(b2_client, login_token):
  150. response = b2_client.get("/api/v1/users/me", headers=_auth(login_token))
  151. assert response.status_code == 200
  152. assert response.get_json()["data"]["username"] == "admin"
  153. def test_logout_invalidates_old_token_and_audits(b2_app, b2_client, login_token):
  154. with b2_app.app_context():
  155. before = db.session.scalar(
  156. select(User.auth_version).where(User.username == "admin")
  157. )
  158. response = b2_client.post("/api/v1/auth/logout", headers=_auth(login_token))
  159. assert response.status_code == 200
  160. second = b2_client.get("/api/v1/users/me", headers=_auth(login_token))
  161. assert second.status_code == 401
  162. assert second.get_json()["code"] == "AUTH_VERSION_MISMATCH"
  163. with b2_app.app_context():
  164. after = db.session.scalar(
  165. select(User.auth_version).where(User.username == "admin")
  166. )
  167. assert after == before + 1
  168. logout_audit = db.session.scalar(
  169. select(AuditLog).where(AuditLog.action_type == "LOGOUT")
  170. )
  171. assert logout_audit is not None
  172. assert logout_audit.operation_result == "SUCCESS"
  173. def test_audit_has_request_metadata_and_no_secrets(
  174. b2_app, b2_client, b2_password
  175. ):
  176. request_id = str(uuid4())
  177. response = b2_client.post(
  178. "/api/v1/auth/login",
  179. json={
  180. "username": "admin",
  181. "password": b2_password,
  182. "keepSignedIn": False,
  183. },
  184. headers={
  185. "X-Request-Id": request_id,
  186. "User-Agent": "b2-test-agent",
  187. "X-Forwarded-For": "192.0.2.10",
  188. },
  189. )
  190. token = response.get_json()["data"]["accessToken"]
  191. with b2_app.app_context():
  192. audit = db.session.scalar(
  193. select(AuditLog).where(AuditLog.action_type == "LOGIN")
  194. )
  195. rendered = " ".join(
  196. str(value)
  197. for value in (
  198. audit.failure_reason,
  199. audit.operation_detail,
  200. audit.target_name,
  201. )
  202. )
  203. assert audit.request_id == request_id
  204. assert audit.client_ip == "192.0.2.10"
  205. assert audit.user_agent == "b2-test-agent"
  206. assert b2_password not in rendered
  207. assert token not in rendered
  208. def test_organization_tree_and_stable_children(b2_client, login_token):
  209. response = b2_client.get(
  210. "/api/v1/organizations/tree", headers=_auth(login_token)
  211. )
  212. data = response.get_json()["data"]
  213. assert [item["orgCode"] for item in data] == ["ORG_ROOT"]
  214. # 不传status返回全部(含停用,管理界面可见可重新启用,契约1.29)
  215. assert [item["orgCode"] for item in data[0]["children"]] == [
  216. "ORG_OPS",
  217. "ORG_COMMS",
  218. "ORG_DISABLED",
  219. ]
  220. assert all(isinstance(item["id"], str) for item in data[0]["children"])
  221. def test_organization_keyword_preserves_ancestor(b2_client, login_token):
  222. response = b2_client.get(
  223. "/api/v1/organizations/tree?keyword=通信",
  224. headers=_auth(login_token),
  225. )
  226. root = response.get_json()["data"][0]
  227. assert root["orgCode"] == "ORG_ROOT"
  228. assert [item["orgCode"] for item in root["children"]] == ["ORG_COMMS"]
  229. def test_organization_status_filter(b2_client, login_token):
  230. response = b2_client.get(
  231. "/api/v1/organizations/tree?status=DISABLED",
  232. headers=_auth(login_token),
  233. )
  234. root = response.get_json()["data"][0]
  235. assert [item["orgCode"] for item in root["children"]] == ["ORG_DISABLED"]
  236. def test_user_pagination_and_keyword(b2_client, login_token):
  237. response = b2_client.get(
  238. "/api/v1/users?page=1&pageSize=2&keyword=员",
  239. headers=_auth(login_token),
  240. )
  241. page = response.get_json()["data"]
  242. assert response.status_code == 200
  243. assert page["pageSize"] == 2
  244. assert page["total"] == 1
  245. assert len(page["items"]) == 1
  246. def test_include_descendants(b2_app, b2_client, login_token):
  247. with b2_app.app_context():
  248. root_id = db.session.scalar(
  249. select(Organization.id).where(Organization.org_code == "ORG_ROOT")
  250. )
  251. direct = b2_client.get(
  252. f"/api/v1/users?organizationId={root_id}",
  253. headers=_auth(login_token),
  254. ).get_json()["data"]
  255. recursive = b2_client.get(
  256. f"/api/v1/users?organizationId={root_id}&includeDescendants=true",
  257. headers=_auth(login_token),
  258. ).get_json()["data"]
  259. assert direct["total"] == 1
  260. assert recursive["total"] == 3
  261. def test_user_status_filter(b2_client, login_token):
  262. response = b2_client.get(
  263. "/api/v1/users?status=DISABLED", headers=_auth(login_token)
  264. )
  265. items = response.get_json()["data"]["items"]
  266. assert [item["username"] for item in items] == ["disabled"]
  267. def test_page_size_limit(b2_client, login_token):
  268. response = b2_client.get(
  269. "/api/v1/users?pageSize=101", headers=_auth(login_token)
  270. )
  271. assert response.status_code == 400
  272. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  273. @pytest.mark.parametrize("username", ["user"])
  274. def test_admin_role_required_for_directory(
  275. b2_client, b2_password, username
  276. ):
  277. token = _login(
  278. b2_client, b2_password, username=username
  279. ).get_json()["data"]["accessToken"]
  280. assert (
  281. b2_client.get("/api/v1/users", headers=_auth(token)).status_code == 403
  282. )
  283. assert (
  284. b2_client.get(
  285. "/api/v1/organizations/tree", headers=_auth(token)
  286. ).status_code
  287. == 403
  288. )
  289. @pytest.mark.parametrize(
  290. "username,modules",
  291. [
  292. (
  293. "admin",
  294. [
  295. "BACKEND_MANAGEMENT",
  296. "AUDIT_LOG",
  297. "ORGANIZATION_USER_MANAGEMENT",
  298. "SYSTEM_MANAGEMENT",
  299. ],
  300. ),
  301. ("user", ["DOCUMENT_BROWSER"]),
  302. ],
  303. )
  304. def test_allowed_modules_mapping(
  305. b2_client, b2_password, username, modules
  306. ):
  307. response = _login(b2_client, b2_password, username=username)
  308. assert response.get_json()["data"]["user"]["allowedModules"] == modules