test_captcha.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. """登录图片验证码(安全需求4):
  2. - 签发结构:captchaId + data URL PNG,明文不外泄;
  3. - 校验:成功、忽略大小写、一次性消费(无论成败)、过期、错误/缺失;
  4. - 接口层:开启时 GET /auth/captcha 返回图片与 enabled=true;
  5. 登录缺验证码字段或校验失败在查库前即被拒(INVALID_CAPTCHA,不计锁定)。
  6. 不依赖测试库:全部在 config 覆盖 + 内存 store 上完成。
  7. """
  8. from __future__ import annotations
  9. import base64
  10. import time
  11. import pytest
  12. from dms.common.errors import InvalidCaptchaError
  13. from dms.services import captcha_service as cs
  14. @pytest.fixture()
  15. def clean_store():
  16. cs._entries.clear()
  17. yield
  18. cs._entries.clear()
  19. def _issued_with_plain_code() -> tuple[str, str]:
  20. data = cs.issue()
  21. return data["captchaId"], cs._entries[data["captchaId"]][0]
  22. def test_issue_shape(clean_store):
  23. """签发返回 captchaId 与 data:image/png;base64 前缀图片,且为有效 PNG 头。"""
  24. data = cs.issue()
  25. assert set(data) == {"captchaId", "image"}
  26. assert data["captchaId"]
  27. prefix = "data:image/png;base64,"
  28. assert data["image"].startswith(prefix)
  29. raw = base64.b64decode(data["image"][len(prefix):])
  30. assert raw.startswith(b"\x89PNG")
  31. def test_verify_success_and_case_insensitive(clean_store):
  32. captcha_id, code = _issued_with_plain_code()
  33. cs.verify(captcha_id, code.lower())
  34. def test_verify_consumes_once(clean_store):
  35. """一次性消费:第二次校验(即使正确)也拒绝,防重放。"""
  36. captcha_id, code = _issued_with_plain_code()
  37. cs.verify(captcha_id, code)
  38. with pytest.raises(InvalidCaptchaError):
  39. cs.verify(captcha_id, code)
  40. def test_verify_wrong_code_fails_and_consumes(clean_store):
  41. """错误尝试即作废,防止同一验证码反复试密码。"""
  42. captcha_id, code = _issued_with_plain_code()
  43. with pytest.raises(InvalidCaptchaError):
  44. cs.verify(captcha_id, "X" * len(code))
  45. with pytest.raises(InvalidCaptchaError):
  46. cs.verify(captcha_id, code)
  47. def test_verify_expired(clean_store):
  48. captcha_id, code = _issued_with_plain_code()
  49. # 把过期时刻拉回过去(monotonic 时钟,120 秒窗口)
  50. cs._entries[captcha_id] = (code, time.monotonic() - 1)
  51. with pytest.raises(InvalidCaptchaError):
  52. cs.verify(captcha_id, code)
  53. def test_verify_missing_arguments(clean_store):
  54. with pytest.raises(InvalidCaptchaError):
  55. cs.verify(None, None)
  56. with pytest.raises(InvalidCaptchaError):
  57. cs.verify("whatever", " ")
  58. def test_captcha_endpoint_and_login_guard(flask_app):
  59. """/auth/captcha 开关行为 + 开启时登录请求体扩为五键、校验先于查库。"""
  60. client = flask_app.test_client()
  61. # 开关关闭(conftest 设定):仅返回 enabled=false,登录不再被验证码拦截
  62. response = client.get("/api/v1/auth/captcha")
  63. assert response.status_code == 200
  64. assert response.get_json()["data"] == {"enabled": False}
  65. three_keys = {"username": "u", "password": "p", "keepSignedIn": False}
  66. flask_app.config["DMS_CAPTCHA_ENABLED"] = True
  67. cs._entries.clear()
  68. try:
  69. response = client.get("/api/v1/auth/captcha")
  70. assert response.status_code == 200
  71. payload = response.get_json()["data"]
  72. assert payload["enabled"] is True
  73. assert set(payload) == {"enabled", "captchaId", "image"}
  74. # 缺 captchaId/captchaCode:结构校验 400 INVALID_ARGUMENT
  75. response = client.post("/api/v1/auth/login", json=three_keys)
  76. assert response.status_code == 400
  77. assert response.get_json()["code"] == "INVALID_ARGUMENT"
  78. # 带未知 captchaId:查库前即 400 INVALID_CAPTCHA(无测试库依赖)
  79. response = client.post(
  80. "/api/v1/auth/login",
  81. json={
  82. **three_keys,
  83. "captchaId": "not-exist",
  84. "captchaCode": "ABCD",
  85. },
  86. )
  87. assert response.status_code == 400
  88. assert response.get_json()["code"] == "INVALID_CAPTCHA"
  89. # 真实签发的验证码先被消费(模拟已使用过),重放同样拒绝
  90. captcha_id = payload["captchaId"]
  91. code = cs._entries[captcha_id][0]
  92. cs.verify(captcha_id, code)
  93. response = client.post(
  94. "/api/v1/auth/login",
  95. json={
  96. **three_keys,
  97. "captchaId": captcha_id,
  98. "captchaCode": code,
  99. },
  100. )
  101. assert response.status_code == 400
  102. assert response.get_json()["code"] == "INVALID_CAPTCHA"
  103. finally:
  104. flask_app.config["DMS_CAPTCHA_ENABLED"] = False
  105. cs._entries.clear()