Files
gsfdc/server/tests/test_security.py
T

229 lines
7.7 KiB
Python

import base64
import inspect
from datetime import datetime, timedelta, timezone
import pytest
def test_captcha_missing_wrong_and_second_use():
from app.services.captcha import CAPTCHA_ALPHABET, consume_captcha, issue_captcha
from app.services.redis_client import get_redis
assert consume_captcha("", "ab12") is False
assert consume_captcha("missing-id", "") is False
assert consume_captcha("", "") is False
try:
client = get_redis()
client.ping()
except Exception:
pytest.skip("Redis 不可达")
wrong = issue_captcha()
wrong_id = wrong["captchaId"]
assert wrong["image"].startswith("data:image/png;base64,")
raw = base64.b64decode(wrong["image"].split(",", 1)[1])
assert raw.startswith(b"\x89PNG")
stored = client.get(f"gsfdc:captcha:{wrong_id}")
assert isinstance(stored, str)
assert len(stored) == 4
assert set(stored).issubset(set(CAPTCHA_ALPHABET.lower()))
ttl = client.ttl(f"gsfdc:captcha:{wrong_id}")
assert 0 < ttl <= 120
assert consume_captcha(wrong_id, "0000") is False
assert client.get(f"gsfdc:captcha:{wrong_id}") is None
assert consume_captcha(wrong_id, stored) is False
ok = issue_captcha()
ok_id = ok["captchaId"]
stored_ok = client.get(f"gsfdc:captcha:{ok_id}")
assert consume_captcha(ok_id, stored_ok.upper()) is True
assert consume_captcha(ok_id, stored_ok) is False
def test_is_currently_locked_respects_utc_and_does_not_mutate():
from types import SimpleNamespace
from app.services.login_guard import is_currently_locked
user = SimpleNamespace(failed_login_count=2, locked_until=None)
assert is_currently_locked(user) is False
assert user.failed_login_count == 2
user.locked_until = datetime.now(timezone.utc) + timedelta(minutes=1)
assert is_currently_locked(user) is True
assert user.failed_login_count == 2
user.locked_until = datetime.now(timezone.utc) - timedelta(seconds=1)
assert is_currently_locked(user) is False
user.locked_until = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(seconds=1)
assert is_currently_locked(user) is False
assert user.failed_login_count == 2
def test_lock_after_small_limit_and_past_deadline():
pytest.importorskip("app.db")
pytest.importorskip("app.models.content")
pytest.importorskip("app.models.rbac")
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.models.content import SiteSetting
from app.models.rbac import AdminUser
from app.services.login_guard import is_currently_locked, read_login_policy, record_failure
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SiteSetting.__table__.create(engine)
AdminUser.__table__.create(engine)
db = sessionmaker(bind=engine, expire_on_commit=False)()
try:
assert read_login_policy(db) == (5, 15)
db.add(SiteSetting(key="login_max_failures", value="2"))
db.add(SiteSetting(key="login_lock_minutes", value="3"))
db.commit()
user = _new_user(AdminUser, "sec-lock-test")
db.add(user)
db.commit()
db.refresh(user)
assert is_currently_locked(user) is False
record_failure(db, user)
assert user.failed_login_count == 1
assert user.locked_until is None
assert is_currently_locked(user) is False
assert user.failed_login_count == 1
record_failure(db, user)
assert user.failed_login_count == 0
assert user.locked_until is not None
locked_until = user.locked_until
if locked_until.tzinfo is not None:
locked_until = locked_until.astimezone(timezone.utc).replace(tzinfo=None)
delta = locked_until - datetime.now(timezone.utc).replace(tzinfo=None)
assert timedelta(minutes=2) < delta < timedelta(minutes=4)
assert is_currently_locked(user) is True
assert user.failed_login_count == 0
user.locked_until = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(seconds=30)
db.add(user)
db.commit()
db.refresh(user)
assert is_currently_locked(user) is False
assert user.failed_login_count == 0
finally:
db.close()
def test_write_audit_does_not_accept_secret_fields():
from app.services.audit import write_audit
signature = inspect.signature(write_audit)
names = list(signature.parameters)
assert names == ["user_id", "username", "method", "path", "status_code", "ip"]
for parameter in signature.parameters.values():
assert parameter.kind is inspect.Parameter.KEYWORD_ONLY
banned = {"body", "password", "captcha", "captcha_id", "id_card", "mobile", "phone"}
assert banned.isdisjoint(names)
with pytest.raises(TypeError):
write_audit(
user_id=None,
username=None,
method="POST",
path="/api/admin/auth/login",
status_code=400,
ip="127.0.0.1",
body="x",
)
with pytest.raises(TypeError):
write_audit(
user_id=None,
username=None,
method="POST",
path="/api/admin/auth/login",
status_code=400,
ip="127.0.0.1",
password="x",
)
with pytest.raises(TypeError):
write_audit(
user_id=None,
username=None,
method="POST",
path="/api/admin/auth/login",
status_code=400,
ip="127.0.0.1",
captcha="x",
)
def test_ip_blacklist_blocks_then_unblocks():
from app.core.config import get_settings
if not (get_settings().database_url or "").strip():
pytest.skip("DATABASE_URL 为空")
from sqlalchemy import delete
from app.db import SessionLocal
from app.models.security import AdminIpBlacklist
from app.services.ip_guard import is_ip_blocked
test_ip = "203.0.113.214"
db = SessionLocal()
try:
AdminIpBlacklist.__table__.create(db.get_bind(), checkfirst=True)
db.execute(delete(AdminIpBlacklist).where(AdminIpBlacklist.ip == test_ip))
db.commit()
assert is_ip_blocked(test_ip) is False
db.add(AdminIpBlacklist(ip=test_ip, remark="security-test"))
db.commit()
assert is_ip_blocked(test_ip) is True
db.execute(delete(AdminIpBlacklist).where(AdminIpBlacklist.ip == test_ip))
db.commit()
assert is_ip_blocked(test_ip) is False
finally:
try:
db.rollback()
db.execute(delete(AdminIpBlacklist).where(AdminIpBlacklist.ip == test_ip))
db.commit()
except Exception:
db.rollback()
finally:
db.close()
def _new_user(model, username: str):
user = model()
if hasattr(user, "username"):
user.username = username
if hasattr(user, "status"):
user.status = "active"
if hasattr(user, "failed_login_count"):
user.failed_login_count = 0
if hasattr(user, "locked_until"):
user.locked_until = None
if hasattr(user, "display_name"):
user.display_name = "lock-test"
if hasattr(user, "password_hash"):
user.password_hash = "test-only-hash"
for column in user.__table__.columns:
if column.primary_key or column.nullable:
continue
if column.default is not None or column.server_default is not None:
continue
if getattr(user, column.key) is not None:
continue
try:
kind = column.type.python_type
except Exception:
kind = str
setattr(user, column.key, 0 if kind is int else "test")
return user