229 lines
7.7 KiB
Python
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
|