feat: 全屏个股详情预览 + Tailwind 改版 + 账号鉴权
This commit is contained in:
155
backend/app/auth_api.py
Normal file
155
backend/app/auth_api.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""只登录、不注册的鉴权 API。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .auth import (
|
||||
get_auth_session,
|
||||
new_session_token,
|
||||
require_user,
|
||||
session_expiry,
|
||||
token_digest,
|
||||
utcnow,
|
||||
verify_dummy_password,
|
||||
verify_password,
|
||||
)
|
||||
from .config import settings
|
||||
from .db import get_session
|
||||
from .models import AuthSession, User
|
||||
from .schemas import CurrentUserOut, LoginRequest, LoginResponse
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
def set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=settings.auth_cookie_name,
|
||||
value=token,
|
||||
max_age=settings.auth_session_hours * 60 * 60,
|
||||
path="/api",
|
||||
secure=settings.auth_cookie_secure,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(
|
||||
key=settings.auth_cookie_name,
|
||||
path="/api",
|
||||
secure=settings.auth_cookie_secure,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> LoginResponse:
|
||||
now = utcnow()
|
||||
username = payload.username.strip()
|
||||
user = (await db.execute(select(User).where(User.username == username))).scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
verify_dummy_password(payload.password)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
|
||||
|
||||
if not user.is_active:
|
||||
verify_dummy_password(payload.password)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
|
||||
|
||||
if user.locked_until is not None and user.locked_until > now:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_423_LOCKED,
|
||||
detail=f"登录失败次数过多,请在 {user.locked_until.isoformat()} 后重试",
|
||||
)
|
||||
|
||||
if not verify_password(user.password_hash, payload.password):
|
||||
user.failed_login_count += 1
|
||||
if user.failed_login_count >= settings.auth_max_failed_logins:
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = now + timedelta(minutes=settings.auth_lock_minutes)
|
||||
user.updated_at = now
|
||||
await db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
|
||||
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login_at = now
|
||||
user.updated_at = now
|
||||
|
||||
# 登录时顺便清理过期或已吊销会话,避免单用户长期运行累积垃圾数据。
|
||||
await db.execute(
|
||||
delete(AuthSession).where(
|
||||
(AuthSession.expires_at <= now) | (AuthSession.revoked_at.is_not(None))
|
||||
)
|
||||
)
|
||||
|
||||
token = new_session_token()
|
||||
expires_at = session_expiry()
|
||||
db.add(
|
||||
AuthSession(
|
||||
user_id=user.id,
|
||||
token_hash=token_digest(token),
|
||||
created_at=now,
|
||||
expires_at=expires_at,
|
||||
last_seen_at=now,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("user-agent", "")[:512] or None,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
set_session_cookie(response, token)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return LoginResponse(user=CurrentUserOut.model_validate(user), expires_at=expires_at)
|
||||
|
||||
|
||||
@router.get("/me", response_model=CurrentUserOut)
|
||||
async def me(user: User = Depends(require_user)) -> CurrentUserOut:
|
||||
return CurrentUserOut.model_validate(user)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
response: Response,
|
||||
stock_session: str | None = Cookie(default=None, alias=settings.auth_cookie_name),
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> Response:
|
||||
if stock_session:
|
||||
await db.execute(
|
||||
update(AuthSession)
|
||||
.where(AuthSession.token_hash == token_digest(stock_session))
|
||||
.values(revoked_at=utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
clear_session_cookie(response)
|
||||
response.status_code = status.HTTP_204_NO_CONTENT
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout-all", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout_all(
|
||||
response: Response,
|
||||
user: User = Depends(require_user),
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> Response:
|
||||
await db.execute(
|
||||
update(AuthSession)
|
||||
.where(AuthSession.user_id == user.id, AuthSession.revoked_at.is_(None))
|
||||
.values(revoked_at=utcnow())
|
||||
)
|
||||
await db.commit()
|
||||
clear_session_cookie(response)
|
||||
response.status_code = status.HTTP_204_NO_CONTENT
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
Reference in New Issue
Block a user