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 健康管理 + + +
+
+
- - + +
- - + + +
+
+
+ +
+ @@ -1937,35 +2217,43 @@ def get_login_page_html() -> str: const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', body: JSON.stringify({ username: document.getElementById('username').value, password: document.getElementById('password').value, + remember_me: document.getElementById('rememberMe').checked, }) }); const data = await response.json(); if (response.ok) { - localStorage.setItem('token', data.token); + // 保存用户信息(非敏感) localStorage.setItem('user', JSON.stringify(data.user)); - window.location.href = '/'; + + // 显示成功状态 + btn.textContent = '✓ 登录成功'; + btn.classList.add('success'); + + // 跳转 + setTimeout(() => { + const params = new URLSearchParams(window.location.search); + const redirect = params.get('redirect') || '/'; + window.location.href = redirect; + }, 500); } else { error.textContent = data.detail || '登录失败'; error.style.display = 'block'; + btn.disabled = false; + btn.textContent = '登录'; } } catch (err) { error.textContent = '网络错误,请稍后重试'; error.style.display = 'block'; - } finally { btn.disabled = false; btn.textContent = '登录'; } }); - - // 如果已登录,跳转到首页 - if (localStorage.getItem('token')) { - window.location.href = '/'; - } @@ -1973,7 +2261,7 @@ def get_login_page_html() -> str: def get_register_page_html() -> str: - """生成注册页面 HTML""" + """生成注册页面 HTML - Neumorphism 设计风格""" return """ @@ -1981,133 +2269,258 @@ def get_register_page_html() -> str: 注册 - Vitals 健康管理 + + +
+
+
- - + +

需要邀请码才能注册,请联系管理员获取

- - + +
- - + +
- - + +
+ +
+ @@ -2128,6 +2541,12 @@ def get_register_page_html() -> str: return; } + if (password.length < 6) { + error.textContent = '密码至少需要 6 位'; + error.style.display = 'block'; + return; + } + btn.disabled = true; btn.textContent = '注册中...'; error.style.display = 'none'; @@ -2136,6 +2555,7 @@ def get_register_page_html() -> str: const response = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', body: JSON.stringify({ username: document.getElementById('username').value, password: password, @@ -2146,26 +2566,28 @@ def get_register_page_html() -> str: const data = await response.json(); if (response.ok) { - localStorage.setItem('token', data.token); + // 保存用户信息 localStorage.setItem('user', JSON.stringify(data.user)); - window.location.href = '/'; + + // 显示成功 + btn.textContent = '✓ 注册成功'; + + setTimeout(() => { + window.location.href = '/'; + }, 500); } else { error.textContent = data.detail || '注册失败'; error.style.display = 'block'; + btn.disabled = false; + btn.textContent = '注册'; } } catch (err) { error.textContent = '网络错误,请稍后重试'; error.style.display = 'block'; - } finally { btn.disabled = false; btn.textContent = '注册'; } }); - - // 如果已登录,跳转到首页 - if (localStorage.getItem('token')) { - window.location.href = '/'; - } @@ -2483,49 +2905,70 @@ def get_admin_page_html() -> str: checkAuth(); }); - function getToken() { - return localStorage.getItem('token'); - } - function checkAuth() { - const token = getToken(); const userStr = localStorage.getItem('user'); - if (!token || !userStr) { - window.location.href = '/login'; + if (!userStr) { + // 没有用户信息,尝试从 API 获取 + fetch('/api/auth/me', { credentials: 'same-origin' }) + .then(res => { + if (!res.ok) { + window.location.href = '/login'; + return; + } + return res.json(); + }) + .then(user => { + if (user) { + localStorage.setItem('user', JSON.stringify(user)); + currentUser = user; + initAdminPage(); + } + }) + .catch(() => window.location.href = '/login'); return; } try { currentUser = JSON.parse(userStr); - document.getElementById('userInfo').textContent = currentUser.name; - - if (!currentUser.is_admin) { - document.getElementById('unauthorizedView').style.display = 'block'; - return; - } - - document.getElementById('adminContent').style.display = 'block'; - loadUsers(); - loadInvites(); + initAdminPage(); } catch (e) { window.location.href = '/login'; } } - function logout() { - localStorage.removeItem('token'); + function initAdminPage() { + document.getElementById('userInfo').textContent = currentUser.name; + + if (!currentUser.is_admin) { + document.getElementById('unauthorizedView').style.display = 'block'; + return; + } + + document.getElementById('adminContent').style.display = 'block'; + loadUsers(); + loadInvites(); + } + + async function logout() { + try { + await fetch('/api/auth/logout', { + method: 'POST', + credentials: 'same-origin' + }); + } catch (e) { + // 忽略错误 + } localStorage.removeItem('user'); window.location.href = '/login'; } async function apiRequest(url, options = {}) { - const token = getToken(); const response = await fetch(url, { ...options, + credentials: 'same-origin', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, ...options.headers, } });