diff --git a/docker-compose.server.yml b/docker-compose.server.yml index 66933e9..7684651 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -42,6 +42,9 @@ services: YK_COOKIE: ${YK_COOKIE:-} API_TOKEN: ${API_TOKEN:-} ENCRYPTION_KEY: ${ENCRYPTION_KEY:-} + # 登录页账密(ADMIN_USER/ADMIN_PASSWORD,默认 admin/admin123,可在 .env 覆盖) + ADMIN_USER: ${ADMIN_USER:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123} TZ: Asia/Shanghai networks: - default diff --git a/docker-compose.yml b/docker-compose.yml index b01cbcc..4cf5563 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -96,6 +96,9 @@ services: YK_COOKIE: ${YK_COOKIE:-} API_TOKEN: ${API_TOKEN:-} ENCRYPTION_KEY: ${ENCRYPTION_KEY:-} + # 登录页账密(默认 admin/admin123,可在 .env 覆盖) + ADMIN_USER: ${ADMIN_USER:-admin} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin123} volumes: # 媒体走 bind mount(不占 Docker 虚拟磁盘——本机实测 C 盘被 2.5GB # volume 撑爆导致引擎 500,2026-08-22);服务器上数据直观可见好备份 diff --git a/yunke-server/app/api/auth.py b/yunke-server/app/api/auth.py index 9589c22..cc6e017 100644 --- a/yunke-server/app/api/auth.py +++ b/yunke-server/app/api/auth.py @@ -19,15 +19,47 @@ reach it without knowing the token — that exemption is encoded here in from __future__ import annotations import secrets +import time from fastapi import HTTPException, Request, status from app.config import get_settings # Request paths that bypass auth even when a token is configured. Keep this -# minimal so the protected surface is easy to reason about — currently just the -# liveness probe. -_PUBLIC_PATHS = frozenset({"/api/health"}) +# minimal so the protected surface is easy to reason about — the liveness +# probe and the login endpoint itself (the caller has no token yet). +_PUBLIC_PATHS = frozenset({"/api/health", "/api/auth/login"}) + +# ---- Brute-force guard for POST /api/auth/login ------------------------- # +# In-memory per-IP failure tracker: MAX failures within WINDOW seconds locks +# the IP out for LOCKOUT seconds. Process-local by design (single-backend +# deployment; a restart simply clears the slate). +_LOGIN_MAX_FAILURES = 5 +_LOGIN_FAILURE_WINDOW_S = 15 * 60 +_LOGIN_LOCKOUT_S = 15 * 60 +# ip -> list of monotonic timestamps of recent failed logins +_login_failures: dict[str, list[float]] = {} + + +def _client_ip(request: Request) -> str: + """Best-effort client IP for the lockout key (test transport has none).""" + return request.client.host if request.client else "unknown" + + +def _login_locked_out(ip: str) -> bool: + """True when ``ip`` burned through MAX failures inside the window.""" + now = time.monotonic() + stamps = [t for t in _login_failures.get(ip, []) if now - t < _LOGIN_FAILURE_WINDOW_S] + _login_failures[ip] = stamps + return len(stamps) >= _LOGIN_MAX_FAILURES + + +def _record_login_failure(ip: str) -> None: + _login_failures.setdefault(ip, []).append(time.monotonic()) + + +def _reset_login_failures(ip: str) -> None: + _login_failures.pop(ip, None) async def require_auth(request: Request) -> None: diff --git a/yunke-server/app/api/main.py b/yunke-server/app/api/main.py index dfe4141..0a24e6b 100644 --- a/yunke-server/app/api/main.py +++ b/yunke-server/app/api/main.py @@ -46,6 +46,7 @@ from app.api.routers import ( customer, export, groups, + login, media, messages, moments, @@ -82,6 +83,7 @@ app.add_middleware( ) app.include_router(customer.router) +app.include_router(login.router) app.include_router(media.router) app.include_router(organization.router) app.include_router(messages.router) diff --git a/yunke-server/app/api/routers/login.py b/yunke-server/app/api/routers/login.py new file mode 100644 index 0000000..b690fb6 --- /dev/null +++ b/yunke-server/app/api/routers/login.py @@ -0,0 +1,61 @@ +"""POST /api/auth/login — username/password sign-in for the SPA. + +Exchanges admin credentials (``Settings.admin_user`` / ``admin_password``, +env-overridable) for the shared ``api_token`` so browser users never have to +handle the raw token string. The endpoint is exempt from the shared-token +middleware (see ``app.api.auth._PUBLIC_PATHS``) and rate-limited per IP by the +brute-force guard in ``app.api.auth``. +""" + +from __future__ import annotations + +import secrets + +from fastapi import APIRouter, HTTPException, Request, status +from pydantic import BaseModel + +from app.api.auth import ( + _client_ip, + _login_locked_out, + _record_login_failure, + _reset_login_failures, +) +from app.config import get_settings + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +class LoginBody(BaseModel): + username: str + password: str + + +@router.post("/login") +async def login(body: LoginBody, request: Request) -> dict[str, str]: + settings = get_settings() + ip = _client_ip(request) + + if _login_locked_out(ip): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="尝试次数过多,请 15 分钟后再试", + ) + + credentials_ok = secrets.compare_digest( + body.username.encode("utf-8"), settings.admin_user.encode("utf-8") + ) and secrets.compare_digest( + body.password.encode("utf-8"), settings.admin_password.encode("utf-8") + ) + if not credentials_ok: + # Same generic message for unknown user / wrong password (no + # user enumeration). + _record_login_failure(ip) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + ) + + _reset_login_failures(ip) + # Empty token when auth is disabled — the SPA stores it and the request + # interceptor simply skips the empty value. + return {"token": settings.api_token} diff --git a/yunke-server/app/config.py b/yunke-server/app/config.py index 1842ab4..0762a30 100644 --- a/yunke-server/app/config.py +++ b/yunke-server/app/config.py @@ -34,6 +34,13 @@ class Settings(BaseSettings): # `` or ``?token=``. See app/api/auth.py. api_token: str = "" + # Username/password for the SPA login page (``POST /api/auth/login``). + # On success the login hands out the ``api_token`` above so the SPA can + # act as a Bearer client; defaults keep local dev zero-config. Change both + # in prod via ADMIN_USER / ADMIN_PASSWORD. + admin_user: str = "admin" + admin_password: str = "admin123" + # Comma-separated list of allowed CORS origins for the viewing API. Empty # (default) -> only the dev fallback (``http://localhost:5173``) is allowed; # set ``CORS_ORIGINS=https://app.example.com`` in prod to lock cross-origin diff --git a/yunke-server/tests/test_api_login.py b/yunke-server/tests/test_api_login.py new file mode 100644 index 0000000..b8c890e --- /dev/null +++ b/yunke-server/tests/test_api_login.py @@ -0,0 +1,136 @@ +"""Tests for ``POST /api/auth/login`` (username/password → shared API token). + +Behavior pinned here: + +* Correct credentials -> ``200 {"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 diff --git a/yunkes/src/api/client.ts b/yunkes/src/api/client.ts index 94da504..40e560c 100644 --- a/yunkes/src/api/client.ts +++ b/yunkes/src/api/client.ts @@ -16,6 +16,36 @@ export const http = axios.create({ timeout: 15000, }) +// ---- 登录态(POST /api/auth/login 换取的共享 token,存 localStorage)---- + +const TOKEN_KEY = 'yunke_token' + +/** 当前 token(空串=后端鉴权关闭;是否登录过看 isLoggedIn,key 存在即已登录)。 */ +export function getToken(): string { + return localStorage.getItem(TOKEN_KEY) ?? '' +} + +/** 已登录 = key 存在(哪怕值是空串 — 后端鉴权关闭时 login 返回空 token)。 */ +export function isLoggedIn(): boolean { + return localStorage.getItem(TOKEN_KEY) !== null +} + +export function setToken(token: string): void { + // 空串也写入:表示"已登录但服务端无鉴权",避免守卫把人锁在登录页 + localStorage.setItem(TOKEN_KEY, token) +} + +export function clearToken(): void { + localStorage.removeItem(TOKEN_KEY) +} + +// 每个 API 请求自动带 Bearer 头(未登录/空 token 时不带 — 后端鉴权关闭时同样可通) +http.interceptors.request.use((config) => { + const token = getToken() + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + http.interceptors.response.use( (response) => response.data, (error) => { @@ -24,6 +54,14 @@ http.interceptors.response.use( return Promise.reject(error) } const status = error?.response?.status + // 401 = token 失效/被改:清掉本地登录态回登录页(登录请求自身的 401 + // 不重定向 — 那是密码错误,由登录页展示) + if (status === 401) { + clearToken() + if (!window.location.pathname.startsWith('/login')) { + window.location.href = '/login' + } + } const detail = error?.response?.data?.detail ?? error?.response?.data const message = typeof detail === 'string' && detail @@ -55,13 +93,28 @@ export function requestJson( return http.request({ method, url, data }) as unknown as Promise } +/** + * 本地媒体直链 — `/