加入用户登录体系,session cookie 鉴权,默认账号 admin/admin123
This commit is contained in:
+33
-4
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user