feat: 全屏个股详情预览 + Tailwind 改版 + 账号鉴权

This commit is contained in:
2026-08-14 22:37:18 +08:00
parent 0f8b9a7255
commit 4c2ea5521d
47 changed files with 2937 additions and 893 deletions

105
backend/app/auth.py Normal file
View File

@@ -0,0 +1,105 @@
"""密码校验、数据库会话与 FastAPI 鉴权依赖。"""
from __future__ import annotations
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError
from fastapi import Cookie, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from .config import settings
from .db import get_session
from .models import AuthSession, User
password_hasher = PasswordHasher(
time_cost=2,
memory_cost=19_456,
parallelism=1,
hash_len=32,
salt_len=16,
)
# 不存在的用户名也执行一次 Argon2降低用户名枚举与计时攻击差异。
_DUMMY_HASH = password_hasher.hash("not-a-real-password")
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def hash_password(password: str) -> str:
return password_hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
try:
return password_hasher.verify(password_hash, password)
except (VerificationError, InvalidHashError):
return False
def verify_dummy_password(password: str) -> None:
verify_password(_DUMMY_HASH, password)
def new_session_token() -> str:
return secrets.token_urlsafe(48)
def token_digest(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def session_expiry() -> datetime:
return utcnow() + timedelta(hours=settings.auth_session_hours)
def unauthorized(detail: str = "登录状态无效或已过期") -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=detail,
headers={"WWW-Authenticate": "Session"},
)
async def get_auth_session(
token: str,
db: AsyncSession,
) -> AuthSession | None:
now = utcnow()
stmt = (
select(AuthSession)
.options(selectinload(AuthSession.user))
.where(
AuthSession.token_hash == token_digest(token),
AuthSession.revoked_at.is_(None),
AuthSession.expires_at > now,
)
)
auth_session = (await db.execute(stmt)).scalar_one_or_none()
if auth_session is None or not auth_session.user.is_active:
return None
# 避免每个 API 请求都写数据库;最多每 5 分钟刷新一次活动时间。
if auth_session.last_seen_at < now - timedelta(minutes=5):
auth_session.last_seen_at = now
await db.commit()
return auth_session
async def require_user(
stock_session: str | None = Cookie(default=None, alias=settings.auth_cookie_name),
db: AsyncSession = Depends(get_session),
) -> User:
if not stock_session:
raise unauthorized()
auth_session = await get_auth_session(stock_session, db)
if auth_session is None:
raise unauthorized()
return auth_session.user