加入用户登录体系,session cookie 鉴权,默认账号 admin/admin123

This commit is contained in:
2026-07-24 22:17:18 +08:00
parent 273924a36a
commit c628e208ca
6 changed files with 198 additions and 6 deletions
+33 -4
View File
@@ -1,10 +1,13 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from app.database import engine, Base from starlette.middleware.base import BaseHTTPMiddleware
from app.database import engine, Base, SessionLocal
from app.models import User
from app.seed import seed from app.seed import seed
from app.routers import contracts, analysis, data_input, admin from app.routers import contracts, analysis, data_input, admin, auth
TEMPLATES_DIR = Path(__file__).parent / "templates" TEMPLATES_DIR = Path(__file__).parent / "templates"
@@ -14,6 +17,30 @@ def setup_jinja(app: FastAPI):
app.state.templates = env app.state.templates = env
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Public paths
if request.url.path in ("/auth/login", "/auth/logout") or request.url.path.startswith("/auth/"):
return await call_next(request)
# Check session
user_id = request.cookies.get("ft_session")
if user_id:
db = SessionLocal()
try:
user = db.query(User).filter(User.id == int(user_id)).first()
if user:
request.state.user = user
return await call_next(request)
except (ValueError, TypeError):
pass
finally:
db.close()
# Redirect to login
return RedirectResponse(f"/auth/login?next={request.url.path}", status_code=303)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
setup_jinja(app) setup_jinja(app)
@@ -24,6 +51,9 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="期货量化系统", lifespan=lifespan) app = FastAPI(title="期货量化系统", lifespan=lifespan)
app.add_middleware(AuthMiddleware)
app.include_router(auth.router)
app.include_router(contracts.router) app.include_router(contracts.router)
app.include_router(analysis.router) app.include_router(analysis.router)
app.include_router(data_input.router) app.include_router(data_input.router)
@@ -32,5 +62,4 @@ app.include_router(admin.router)
@app.get("/") @app.get("/")
def root(): def root():
from fastapi.responses import RedirectResponse
return RedirectResponse("/contracts") return RedirectResponse("/contracts")
+27
View File
@@ -1,3 +1,5 @@
import hashlib
import os
from datetime import date from datetime import date
from sqlalchemy import String, Integer, Float, Date, ForeignKey, Boolean, UniqueConstraint from sqlalchemy import String, Integer, Float, Date, ForeignKey, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -60,3 +62,28 @@ class PositionSnapshot(Base):
position: Mapped[int] = mapped_column(Integer) position: Mapped[int] = mapped_column(Integer)
delta: Mapped[int] = mapped_column(Integer, default=0) delta: Mapped[int] = mapped_column(Integer, default=0)
avg_cost: Mapped[float] = mapped_column(Float) avg_cost: Mapped[float] = mapped_column(Float)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True)
password_hash: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(String(20), default="user")
@staticmethod
def hash_password(password: str) -> str:
salt = os.urandom(16)
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100000)
return salt.hex() + ":" + dk.hex()
def check_password(self, password: str) -> bool:
try:
salt_hex, dk_hex = self.password_hash.split(":")
salt = bytes.fromhex(salt_hex)
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100000)
return dk.hex() == dk_hex
except (ValueError, AttributeError):
return False
+52
View File
@@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
router = APIRouter(prefix="/auth", tags=["auth"])
SESSION_COOKIE = "ft_session"
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
user_id = request.cookies.get(SESSION_COOKIE)
if not user_id:
return None
try:
return db.query(User).filter(User.id == int(user_id)).first()
except (ValueError, TypeError):
return None
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
template = request.app.state.templates.get_template("login.html")
return HTMLResponse(template.render(request=request))
@router.post("/login")
def login(
request: Request,
username: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.username == username).first()
if not user or not user.check_password(password):
template = request.app.state.templates.get_template("login.html")
return HTMLResponse(
template.render(request=request, error="用户名或密码错误"),
status_code=401,
)
resp = RedirectResponse("/contracts/", status_code=303)
resp.set_cookie(SESSION_COOKIE, str(user.id), httponly=True, max_age=86400 * 7)
return resp
@router.get("/logout")
def logout():
resp = RedirectResponse("/auth/login", status_code=303)
resp.delete_cookie(SESSION_COOKIE)
return resp
+11 -1
View File
@@ -1,7 +1,7 @@
"""Seed database from existing data files. Run once manually or on first start.""" """Seed database from existing data files. Run once manually or on first start."""
from datetime import date from datetime import date
from app.database import engine, Base, SessionLocal from app.database import engine, Base, SessionLocal
from app.models import DailyBar, PositionSnapshot, Product, Contract from app.models import DailyBar, PositionSnapshot, Product, Contract, User
from app.engine.lock_strategy import compute_amp_5d from app.engine.lock_strategy import compute_amp_5d
# --- Seed OHLCV data --- # --- Seed OHLCV data ---
@@ -202,6 +202,16 @@ def seed():
db = SessionLocal() db = SessionLocal()
try: try:
# --- Seed default user ---
if not db.query(User).first():
admin = User(
username="admin",
password_hash=User.hash_password("admin123"),
role="admin",
)
db.add(admin)
db.flush()
# --- Seed products and contracts --- # --- Seed products and contracts ---
if not db.query(Product).first(): if not db.query(Product).first():
fg = Product(code="FG", name="玻璃", exchange="CZCE") fg = Product(code="FG", name="玻璃", exchange="CZCE")
+5 -1
View File
@@ -206,7 +206,11 @@
</a> </a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="theme-btn" id="themeToggle">🌙 暗色模式</button> <div style="font-size:0.78rem;color:var(--sub);margin-bottom:8px;">
👤 {{ request.state.user.username if request.state.user else '—' }}
</div>
<a href="/auth/logout" style="font-size:0.78rem;color:var(--sub);text-decoration:none;">退出登录</a>
<button class="theme-btn" style="margin-top:8px;" id="themeToggle">🌙 暗色模式</button>
</div> </div>
</aside> </aside>
+70
View File
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 · 期货量化</title>
<style>
:root {
--bg: #f8fafc; --surface: #fff; --fg: #1e293b; --sub: #64748b;
--border: #e2e8f0; --accent: #3b82f6; --danger: #ef4444;
}
[data-theme="dark"] {
--bg: #0f172a; --surface: #1e293b; --fg: #e2e8f0; --sub: #94a3b8;
--border: #334155; --accent: #60a5fa; --danger: #f87171;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, 'Segoe UI', system-ui, sans-serif;
background: var(--bg); color: var(--fg);
display: flex; align-items: center; justify-content: center;
min-height: 100vh;
}
.login-box {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 40px; width: 360px; max-width: 90vw;
}
.login-box h1 { font-size: 1.3rem; text-align: center; margin-bottom: 8px; }
.login-box .sub { color: var(--sub); font-size: 0.85rem; text-align: center; margin-bottom: 28px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 0.8rem; font-weight: 600; color: var(--sub); margin-bottom: 5px; }
.form-group input {
width: 100%; padding: 10px 12px; border: 1px solid var(--border);
border-radius: 6px; font-size: 0.9rem; background: var(--bg); color: var(--fg);
}
.form-group input:focus { outline: none; border-color: var(--accent); }
.btn {
width: 100%; padding: 10px; border: none; border-radius: 6px;
font-size: 0.9rem; font-weight: 600; cursor: pointer;
background: var(--accent); color: #fff; margin-top: 8px;
}
.btn:hover { opacity: 0.9; }
.error {
background: #fef2f2; color: var(--danger); padding: 10px 14px;
border-radius: 6px; font-size: 0.85rem; margin-bottom: 16px; text-align: center;
}
</style>
</head>
<body>
<div class="login-box">
<h1>📊 期货量化系统</h1>
<p class="sub">请输入账号密码</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="post">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" required autofocus>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn">登 录</button>
</form>
</div>
</body>
</html>