Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
QWEN_API_KEY=your-qwen-api-key
QWEN_STOCK_MODEL=qwen-plus

# 管理后台登录
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-this-password
ADMIN_TOKEN_SECRET=change-this-random-secret
ADMIN_TOKEN_TTL_SECONDS=43200

# 数据与运行时(云端 B/S 生产使用 Postgres)
AKSHARE_TIMEOUT=30
DB_MODE=postgres
Expand Down
18 changes: 11 additions & 7 deletions backend/app/api/api.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,30 @@
from fastapi import APIRouter

from app.api.endpoints import health
from app.api.endpoints import auth, health
from app.core.config import settings


def _legacy_sqlite_routes_enabled() -> bool:
return settings.DB_MODE != "postgres" or settings.ENABLE_LEGACY_SQLITE_MODULES


def _include_data_display_routes(router: APIRouter) -> None:
from app.api.endpoints import admin, data_hub, market

router.include_router(market.router, prefix="/market", tags=["market"])
router.include_router(admin.router, prefix="/admin", tags=["admin"])
router.include_router(data_hub.router, prefix="/data-hub", tags=["data-hub"])


def _include_legacy_sqlite_routes(router: APIRouter) -> None:
from app.api.endpoints import (
admin,
ai,
analysis,
batch_import,
charts,
data_dev,
data_hub,
database,
factors,
market,
preset_tasks,
sectors,
stock_screener,
Expand All @@ -31,8 +36,6 @@ def _include_legacy_sqlite_routes(router: APIRouter) -> None:
router.include_router(sectors.router, prefix="/sectors", tags=["sectors"])
router.include_router(ai.router, prefix="/ai", tags=["ai"])
router.include_router(charts.router, prefix="/charts", tags=["charts"])
router.include_router(market.router, prefix="/market", tags=["market"])
router.include_router(admin.router, prefix="/admin", tags=["admin"])
router.include_router(analysis.router, prefix="/analysis", tags=["analysis"])
router.include_router(database.router, prefix="/database", tags=["database"])
router.include_router(data_dev.router, prefix="/data-dev", tags=["data-dev"])
Expand All @@ -41,12 +44,13 @@ def _include_legacy_sqlite_routes(router: APIRouter) -> None:
router.include_router(strategy.router, prefix="/strategy", tags=["strategy"])
router.include_router(factors.router, prefix="/factors", tags=["factors"])
router.include_router(stock_screener.router, prefix="/screener", tags=["screener"])
router.include_router(data_hub.router, prefix="/data-hub", tags=["data-hub"])


def create_api_router(include_legacy_sqlite_routes: bool | None = None) -> APIRouter:
router = APIRouter()
router.include_router(health.router, prefix="/health", tags=["health"])
router.include_router(auth.router, prefix="/auth", tags=["auth"])
_include_data_display_routes(router)

if include_legacy_sqlite_routes is None:
include_legacy_sqlite_routes = _legacy_sqlite_routes_enabled()
Expand Down
5 changes: 3 additions & 2 deletions backend/app/api/endpoints/admin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from fastapi import APIRouter, BackgroundTasks
from fastapi import APIRouter, BackgroundTasks, Depends
from app.core.admin_auth import require_admin
from app.services.scheduler_service import scheduler_service

router = APIRouter()
router = APIRouter(dependencies=[Depends(require_admin)])

@router.post("/fetch-history")
async def trigger_history_fetch(background_tasks: BackgroundTasks):
Expand Down
39 changes: 39 additions & 0 deletions backend/app/api/endpoints/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, status

from app.core.admin_auth import authenticate_admin, create_admin_token, require_admin
from app.core.config import settings

router = APIRouter()


class AdminLoginRequest(BaseModel):
username: str
password: str


class AdminLoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
username: str


@router.post("/admin/login", response_model=AdminLoginResponse)
async def admin_login(payload: AdminLoginRequest) -> AdminLoginResponse:
if not authenticate_admin(payload.username, payload.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid admin credentials.",
)

return AdminLoginResponse(
access_token=create_admin_token(payload.username),
expires_in=settings.ADMIN_TOKEN_TTL_SECONDS,
username=payload.username,
)


@router.get("/admin/me")
async def admin_me(username: str = Depends(require_admin)) -> dict[str, str]:
return {"username": username}
99 changes: 99 additions & 0 deletions backend/app/core/admin_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import base64
import binascii
import hashlib
import hmac
import json
import secrets
import time
from typing import Annotated

from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from app.core.config import settings

_BEARER = HTTPBearer(auto_error=False)


def _encode_json(data: dict[str, object]) -> str:
raw = json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8")
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")


def _decode_json(value: str) -> dict[str, object]:
padding = "=" * (-len(value) % 4)
raw = base64.urlsafe_b64decode(f"{value}{padding}")
return json.loads(raw.decode("utf-8"))


def _sign(value: str, secret: str) -> str:
digest = hmac.new(secret.encode("utf-8"), value.encode("ascii"), hashlib.sha256).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")


def _token_secret() -> str:
return settings.ADMIN_TOKEN_SECRET or settings.ADMIN_PASSWORD


def _ensure_admin_configured() -> None:
if not settings.ADMIN_PASSWORD:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Admin login is not configured.",
)


def authenticate_admin(username: str, password: str) -> bool:
_ensure_admin_configured()
return secrets.compare_digest(username, settings.ADMIN_USERNAME) and secrets.compare_digest(
password,
settings.ADMIN_PASSWORD,
)


def create_admin_token(username: str, now: int | None = None) -> str:
_ensure_admin_configured()
issued_at = int(time.time()) if now is None else now
payload = {
"sub": username,
"iat": issued_at,
"exp": issued_at + settings.ADMIN_TOKEN_TTL_SECONDS,
}
encoded_payload = _encode_json(payload)
signature = _sign(encoded_payload, _token_secret())
return f"{encoded_payload}.{signature}"


def verify_admin_token(token: str, now: int | None = None) -> str:
_ensure_admin_configured()
try:
encoded_payload, signature = token.split(".", 1)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin token.") from exc

expected_signature = _sign(encoded_payload, _token_secret())
if not secrets.compare_digest(signature, expected_signature):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin token.")

try:
payload = _decode_json(encoded_payload)
username = str(payload["sub"])
expires_at = int(payload["exp"])
except (KeyError, TypeError, ValueError, UnicodeDecodeError, binascii.Error) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin token.") from exc

current_time = int(time.time()) if now is None else now
if current_time >= expires_at:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin token has expired.")
if not secrets.compare_digest(username, settings.ADMIN_USERNAME):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin token.")

return username


def require_admin(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_BEARER)],
) -> str:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Admin login required.")
return verify_admin_token(credentials.credentials)
6 changes: 6 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ def assemble_operation_allowlist(cls, v: Union[str, List[str]]) -> Union[List[st
elif isinstance(v, (list, str)):
return v
raise ValueError(v)

# Admin authentication
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD: str = ""
ADMIN_TOKEN_SECRET: str = ""
ADMIN_TOKEN_TTL_SECONDS: int = 60 * 60 * 12

# Database mode: production defaults to Postgres. Legacy SQLite is opt-in.
DB_MODE: str = "postgres"
Expand Down
Loading