From 518e5c8284695ca87a49136b6f5646636c717249 Mon Sep 17 00:00:00 2001 From: "liweiliang0905@gmail.com" Date: Thu, 22 Jan 2026 14:24:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=20-=20=E6=9C=8D=E5=8A=A1=E7=AB=AF=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=20+=20Cookie=20+=20=E6=96=B0UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要改进: 1. 添加服务端认证中间件,未登录用户自动重定向到登录页 2. 使用 HTTPOnly Cookie 存储 token(比 localStorage 更安全) 3. 添加"记住我"功能(勾选:30天,不勾选:1天) 4. 添加登出 API (/api/auth/logout) 5. 登录/注册页面采用 Neumorphism 设计风格 - 健康主题配色(青色 + 绿色) - Lora + Raleway 字体组合 - 新拟态阴影效果 6. 支持登录后重定向到原页面 Co-Authored-By: Claude Opus 4.5 --- src/vitals/core/auth.py | 23 +- src/vitals/web/app.py | 763 +++++++++++++++++++++++++++++++--------- 2 files changed, 622 insertions(+), 164 deletions(-) diff --git a/src/vitals/core/auth.py b/src/vitals/core/auth.py index 0bc37b1..d929f47 100644 --- a/src/vitals/core/auth.py +++ b/src/vitals/core/auth.py @@ -11,7 +11,8 @@ import jwt # JWT 配置 JWT_SECRET = os.environ.get("JWT_SECRET", "vitals-dev-secret-key-change-in-production") JWT_ALGORITHM = "HS256" -JWT_EXPIRE_DAYS = 7 +JWT_EXPIRE_DAYS = 1 # 默认 1 天 +JWT_EXPIRE_DAYS_REMEMBER = 30 # 记住我:30 天 def hash_password(password: str) -> str: @@ -25,18 +26,32 @@ def verify_password(password: str, password_hash: str) -> bool: return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8")) -def create_token(user_id: int, username: str, is_admin: bool = False) -> str: - """创建 JWT Token""" +def create_token(user_id: int, username: str, is_admin: bool = False, remember_me: bool = False) -> str: + """创建 JWT Token + + Args: + user_id: 用户 ID + username: 用户名 + is_admin: 是否管理员 + remember_me: 是否记住登录状态(True: 30天, False: 1天) + """ + expire_days = JWT_EXPIRE_DAYS_REMEMBER if remember_me else JWT_EXPIRE_DAYS payload = { "user_id": user_id, "username": username, "is_admin": is_admin, - "exp": datetime.utcnow() + timedelta(days=JWT_EXPIRE_DAYS), + "exp": datetime.utcnow() + timedelta(days=expire_days), "iat": datetime.utcnow(), } return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) +def get_token_expire_seconds(remember_me: bool = False) -> int: + """获取 token 过期时间(秒)""" + days = JWT_EXPIRE_DAYS_REMEMBER if remember_me else JWT_EXPIRE_DAYS + return days * 24 * 60 * 60 + + def decode_token(token: str) -> Optional[dict]: """解码 JWT Token,返回 payload 或 None(如果无效/过期)""" try: diff --git a/src/vitals/web/app.py b/src/vitals/web/app.py index ba50ede..8eb43e2 100644 --- a/src/vitals/web/app.py +++ b/src/vitals/web/app.py @@ -5,16 +5,17 @@ from datetime import date, datetime, time, timedelta from pathlib import Path from typing import Optional -from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile +from fastapi import Cookie, Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse +from starlette.middleware.base import BaseHTTPMiddleware from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, field_validator from uuid import uuid4 from ..core import database as db from ..core.models import Exercise, Meal, Sleep, UserConfig, Weight, User, Reading, Invite -from ..core.auth import hash_password, verify_password, create_token, decode_token, generate_invite_code +from ..core.auth import hash_password, verify_password, create_token, decode_token, generate_invite_code, get_token_expire_seconds # 初始化数据库 db.init_db() @@ -69,6 +70,62 @@ app.add_middleware( allow_headers=["*"], ) + +# 认证中间件 +class AuthMiddleware(BaseHTTPMiddleware): + """服务端认证中间件 - 未登录用户重定向到登录页""" + + # 不需要认证的路径前缀 + PUBLIC_PREFIXES = ( + "/api/auth/login", + "/api/auth/register", + "/login", + "/register", + "/static", + "/photos", + "/docs", + "/openapi.json", + "/favicon.ico", + ) + + async def dispatch(self, request: Request, call_next): + path = request.url.path + + # 检查是否为公开路径 + if any(path.startswith(prefix) for prefix in self.PUBLIC_PREFIXES): + return await call_next(request) + + # 从 Cookie 获取 token + token = request.cookies.get("auth_token") + + # API 请求也支持 Header 认证(向后兼容) + if not token and path.startswith("/api/"): + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + token = auth_header[7:] + + # 验证 token + if token: + payload = decode_token(token) + if payload: + # token 有效,继续处理 + return await call_next(request) + + # 未认证 + if path.startswith("/api/"): + # API 请求返回 401 + return JSONResponse( + status_code=401, + content={"detail": "未登录或登录已过期"} + ) + else: + # 页面请求重定向到登录页 + redirect_url = f"/login?redirect={path}" if path != "/" else "/login" + return RedirectResponse(url=redirect_url, status_code=302) + + +app.add_middleware(AuthMiddleware) + # 静态文件 static_dir = Path(__file__).parent / "static" if static_dir.exists(): @@ -348,6 +405,7 @@ class ReadingResponse(BaseModel): class LoginInput(BaseModel): username: str password: str + remember_me: bool = False class RegisterInput(BaseModel): @@ -469,8 +527,8 @@ def get_current_user_id(authorization: Optional[str] = Header(None)) -> int: # ===== 认证 API ===== -@app.post("/api/auth/login", response_model=TokenResponse) -async def login(data: LoginInput): +@app.post("/api/auth/login") +async def login(data: LoginInput, response: Response): """用户登录""" user = db.get_user_by_name(data.username) if not user: @@ -482,20 +540,33 @@ async def login(data: LoginInput): if user.is_disabled: raise HTTPException(status_code=403, detail="账户已被禁用") - token = create_token(user.id, user.name, user.is_admin) - return TokenResponse( - token=token, - user=AuthUserResponse( - id=user.id, - name=user.name, - is_admin=user.is_admin, - is_disabled=user.is_disabled, - ) + # 创建 token(根据 remember_me 设置不同的过期时间) + token = create_token(user.id, user.name, user.is_admin, data.remember_me) + + # 设置 HTTPOnly Cookie + max_age = get_token_expire_seconds(data.remember_me) if data.remember_me else None + response.set_cookie( + key="auth_token", + value=token, + httponly=True, + secure=False, # HTTP 环境,生产环境应设为 True + samesite="lax", + max_age=max_age, # None 表示会话 Cookie ) + return { + "token": token, + "user": { + "id": user.id, + "name": user.name, + "is_admin": user.is_admin, + "is_disabled": user.is_disabled, + } + } -@app.post("/api/auth/register", response_model=TokenResponse) -async def register(data: RegisterInput): + +@app.post("/api/auth/register") +async def register(data: RegisterInput, response: Response): """用户注册(需要邀请码)""" # 验证邀请码 invite = db.get_invite_by_code(data.invite_code) @@ -528,18 +599,34 @@ async def register(data: RegisterInput): # 获取创建的用户 user = db.get_user(user_id) - # 生成 token + # 生成 token 并设置 Cookie token = create_token(user.id, user.name, user.is_admin) - return TokenResponse( - token=token, - user=AuthUserResponse( - id=user.id, - name=user.name, - is_admin=user.is_admin, - is_disabled=user.is_disabled, - ) + response.set_cookie( + key="auth_token", + value=token, + httponly=True, + secure=False, + samesite="lax", + max_age=get_token_expire_seconds(False), # 注册默认 1 天 ) + return { + "token": token, + "user": { + "id": user.id, + "name": user.name, + "is_admin": user.is_admin, + "is_disabled": user.is_disabled, + } + } + + +@app.post("/api/auth/logout") +async def logout(response: Response): + """用户登出""" + response.delete_cookie(key="auth_token") + return {"message": "登出成功"} + @app.get("/api/auth/me", response_model=AuthUserResponse) async def get_me(user: User = Depends(require_user)): @@ -1797,7 +1884,7 @@ async def clear_data(request: DataClearInput): def get_login_page_html() -> str: - """生成登录页面 HTML""" + """生成登录页面 HTML - Neumorphism 设计风格""" return """ @@ -1805,119 +1892,312 @@ def get_login_page_html() -> str: 登录 - Vitals 健康管理 + + +