6880ff1b97
后端: - POST /api/auth/login:admin 账密(Settings 默认 admin/admin123, ADMIN_USER/ADMIN_PASSWORD 可覆盖)换取共享 api_token,免 token 中间件 - 防爆破:同 IP 15min 窗口内 5 次失败锁 15min(429),成功登录清零 - 错误信息统一「用户名或密码错误」(防用户名枚举) - 测试 7 个新增(TDD),全量 500 绿 前端: - LoginView + 路由守卫(未登录跳 /login,回跳 redirect) - axios 请求拦截器自动带 Bearer;401 清登录态回登录页 - 媒体直链统一 mediaUrl() 带 ?token=(img/audio 标签发不了 header), 替换 4 处裸拼;downloadBlob 手动带头 - isLoggedIn 按 key 存在判定 — 后端鉴权关闭时空 token 不锁死登录页 - MainLayout 侧栏底部退出登录 compose:两份编排透传 ADMIN_USER/ADMIN_PASSWORD(默认 admin/admin123) Co-Authored-By: Claude Code <noreply@anthropic.com>
137 lines
5.0 KiB
Python
137 lines
5.0 KiB
Python
"""Tests for ``POST /api/auth/login`` (username/password → shared API token).
|
|
|
|
Behavior pinned here:
|
|
|
|
* Correct credentials -> ``200 {"token": <api_token>}`` (the SPA then uses it
|
|
as the Bearer token for every subsequent call).
|
|
* Wrong username or password -> ``401`` with a generic message (no user
|
|
enumeration — the same detail for both cases).
|
|
* The login endpoint itself is exempt from the shared-token middleware —
|
|
obviously, the caller doesn't have a token yet.
|
|
* Brute-force guard: the 5th consecutive failure from one IP locks that IP
|
|
out for 15 minutes (``429`` even with correct credentials); a successful
|
|
login resets the failure counter.
|
|
* Works with auth disabled (empty ``API_TOKEN``) -> returns an empty token.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.api import auth as auth_module
|
|
from app.api.main import app
|
|
from app.config import get_settings
|
|
|
|
|
|
@pytest.fixture
|
|
async def login_client(monkeypatch):
|
|
"""In-process client with a clean settings/login state per test."""
|
|
monkeypatch.delenv("API_TOKEN", raising=False)
|
|
monkeypatch.delenv("ADMIN_USER", raising=False)
|
|
monkeypatch.delenv("ADMIN_PASSWORD", raising=False)
|
|
get_settings.cache_clear()
|
|
failures = getattr(auth_module, "_login_failures", None)
|
|
if failures is not None:
|
|
failures.clear()
|
|
ac = AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
|
|
await ac.__aenter__()
|
|
yield ac
|
|
await ac.aclose()
|
|
get_settings.cache_clear()
|
|
|
|
|
|
async def test_login_with_correct_credentials_returns_token(login_client, monkeypatch):
|
|
monkeypatch.setenv("API_TOKEN", "test-secret")
|
|
get_settings.cache_clear()
|
|
r = await login_client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json() == {"token": "test-secret"}
|
|
|
|
|
|
async def test_login_with_wrong_password_returns_401(login_client, monkeypatch):
|
|
monkeypatch.setenv("API_TOKEN", "test-secret")
|
|
get_settings.cache_clear()
|
|
r = await login_client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": "nope"}
|
|
)
|
|
assert r.status_code == 401
|
|
assert r.json()["detail"] == "用户名或密码错误"
|
|
|
|
|
|
async def test_login_with_unknown_user_same_error_as_wrong_password(
|
|
login_client, monkeypatch
|
|
):
|
|
"""No user enumeration: unknown user and wrong password read identically."""
|
|
monkeypatch.setenv("API_TOKEN", "test-secret")
|
|
get_settings.cache_clear()
|
|
r = await login_client.post(
|
|
"/api/auth/login", json={"username": "nobody", "password": "admin123"}
|
|
)
|
|
assert r.status_code == 401
|
|
assert r.json()["detail"] == "用户名或密码错误"
|
|
|
|
|
|
async def test_login_credentials_come_from_env(login_client, monkeypatch):
|
|
"""ADMIN_USER / ADMIN_PASSWORD override the defaults."""
|
|
monkeypatch.setenv("API_TOKEN", "s")
|
|
monkeypatch.setenv("ADMIN_USER", "boss")
|
|
monkeypatch.setenv("ADMIN_PASSWORD", "boss-pw")
|
|
get_settings.cache_clear()
|
|
ok = await login_client.post(
|
|
"/api/auth/login", json={"username": "boss", "password": "boss-pw"}
|
|
)
|
|
assert ok.status_code == 200
|
|
legacy = await login_client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
|
)
|
|
assert legacy.status_code == 401
|
|
|
|
|
|
async def test_login_with_auth_disabled_returns_empty_token(login_client):
|
|
"""Empty API_TOKEN (auth off) -> login still succeeds with an empty token."""
|
|
r = await login_client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
|
)
|
|
assert r.status_code == 200
|
|
assert r.json() == {"token": ""}
|
|
|
|
|
|
async def test_lockout_after_five_consecutive_failures(login_client, monkeypatch):
|
|
"""5 failures from one IP -> 6th attempt is 429 even with correct creds."""
|
|
monkeypatch.setenv("API_TOKEN", "s")
|
|
get_settings.cache_clear()
|
|
for _ in range(5):
|
|
r = await login_client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": "bad"}
|
|
)
|
|
assert r.status_code == 401
|
|
locked = await login_client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
|
)
|
|
assert locked.status_code == 429
|
|
|
|
|
|
async def test_successful_login_resets_failure_counter(login_client, monkeypatch):
|
|
"""4 failures + a success + 4 more failures -> still not locked."""
|
|
monkeypatch.setenv("API_TOKEN", "s")
|
|
get_settings.cache_clear()
|
|
for _ in range(4):
|
|
await login_client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": "bad"}
|
|
)
|
|
ok = await login_client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
|
)
|
|
assert ok.status_code == 200
|
|
for _ in range(4):
|
|
await login_client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": "bad"}
|
|
)
|
|
still_ok = await login_client.post(
|
|
"/api/auth/login", json={"username": "admin", "password": "admin123"}
|
|
)
|
|
assert still_ok.status_code == 200
|