From fe30fbd799fff63df3e6927589276f029d53c4a7 Mon Sep 17 00:00:00 2001 From: markyangkp Date: Wed, 15 Apr 2026 19:24:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AE=A4=E8=AF=81):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=9F=BA=E4=BA=8Ecookie=E7=9A=84=E8=AE=A4=E8=AF=81=E6=9C=BA?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构用户认证逻辑,改用httpOnly cookie存储token 添加用户名验证器类进行统一校验 实现登出接口并清理前端token相关代码 --- backend/api/account/__init__.py | 49 ++++++++++++++----- backend/api/account/user.py | 25 +++++++--- backend/core/utils/utils.py | 33 ++++++++++--- web/src/utils/http.ts | 84 +++++++++++++++++++++------------ 4 files changed, 138 insertions(+), 53 deletions(-) diff --git a/backend/api/account/__init__.py b/backend/api/account/__init__.py index e4691f5..a352493 100644 --- a/backend/api/account/__init__.py +++ b/backend/api/account/__init__.py @@ -1,7 +1,8 @@ """ 用户管理部分的路由 """ -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Response, Request +from fastapi.responses import JSONResponse import hashlib import jwt import datetime @@ -19,6 +20,7 @@ ADMIN_KEY = settings.ADMIN_KEY ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 60 +COOKIE_NAME = "access_token" pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") @@ -42,15 +44,31 @@ def create_access_token(data: dict, expires_delta: datetime.timedelta = None): encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return encoded_jwt -@router.post("/login", response_model=LoginResponse) -async def login(loginRequest: LoginRequest): +def set_token_cookie(response: Response, token: str, expires_minutes: int): + expires = datetime.datetime.utcnow() + datetime.timedelta(minutes=expires_minutes) + response.set_cookie( + key=COOKIE_NAME, + value=token, + httponly=True, + secure=False, + samesite="lax", + expires=expires, + path="/" + ) + +def clear_token_cookie(response: Response): + response.delete_cookie( + key=COOKIE_NAME, + path="/" + ) + +@router.post("/login") +async def login(loginRequest: LoginRequest, response: Response): mysql_client = MysqlClient() try: username = loginRequest.username password = loginRequest.password - # Workaround: Explicitly select needed columns to avoid 'status' column error - # Ideally, ensure the UserInfo model and database schema match. user_data = mysql_client.db.query( UserInfo.username, UserInfo.password, @@ -64,22 +82,22 @@ async def login(loginRequest: LoginRequest): if not verify_password(password, hashed_password): raise HTTPException(status_code=400, detail="Incorrect username or password") - # Use the fetched delete_sign value if delete_sign == True: raise HTTPException(status_code=400, detail="Account disabled") access_token_expires = datetime.timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) - # Use the fetched username for the token access_token = create_access_token( data={"sub": fetched_username}, expires_delta=access_token_expires ) + set_token_cookie(response, access_token, ACCESS_TOKEN_EXPIRE_MINUTES) + return LoginResponse(code=200, data=AccessToken(access_token=access_token,token_type="bearer"), message="Login Successful") finally: mysql_client.db.close() -@router.post("/signup", response_model=SignUpResponse) -def signup(signupRequest: SignUpRequest): +@router.post("/signup") +def signup(signupRequest: SignUpRequest, response: Response): mysql_client = MysqlClient() try: username = signupRequest.username @@ -102,12 +120,14 @@ def signup(signupRequest: SignUpRequest): data={"sub": new_user.username}, expires_delta=access_token_expires ) + set_token_cookie(response, access_token, ACCESS_TOKEN_EXPIRE_MINUTES) + return SignUpResponse(code=200, data=AccessToken(access_token=access_token,token_type="bearer"), message="Sign Up Successful") finally: mysql_client.db.close() -@router.post("/signup_admin", response_model=SignUpResponse) -def signup(signupRequest: SignUpAdminRequest): +@router.post("/signup_admin") +def signup(signupRequest: SignUpAdminRequest, response: Response): mysql_client = MysqlClient() try: username = signupRequest.username @@ -133,10 +153,17 @@ def signup(signupRequest: SignUpAdminRequest): data={"sub": new_user.username}, expires_delta=access_token_expires ) + set_token_cookie(response, access_token, ACCESS_TOKEN_EXPIRE_MINUTES) + return SignUpResponse(code=200, data=AccessToken(access_token=access_token,token_type="bearer"), message="Sign Up Successful") finally: mysql_client.db.close() +@router.post("/logout") +def logout(response: Response): + clear_token_cookie(response) + return {"code": 200, "message": "Logout successful"} + # 获取当前用户信息 @router.get("/me") def read_users_me(token: str = Depends(get_current_user)): diff --git a/backend/api/account/user.py b/backend/api/account/user.py index 1650e34..250bc2d 100644 --- a/backend/api/account/user.py +++ b/backend/api/account/user.py @@ -1,7 +1,22 @@ -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from typing import Any,List,Dict -class LoginRequest(BaseModel): +import re + +class UsernameValidator(BaseModel): username: str + + @field_validator('username') + @classmethod + def validate_username(cls, v): + if not v: + raise ValueError('用户名不能为空') + if len(v) < 3 or len(v) > 20: + raise ValueError('用户名长度必须在3-20个字符之间') + if not re.match(r'^[a-zA-Z0-9_]+$', v): + raise ValueError('用户名只能包含字母、数字和下划线') + return v + +class LoginRequest(UsernameValidator): password: str class AccessToken(BaseModel): @@ -15,12 +30,10 @@ class LoginResponse(BaseModel): -class SignUpRequest(BaseModel): - username: str +class SignUpRequest(UsernameValidator): password: str -class SignUpAdminRequest(BaseModel): - username: str +class SignUpAdminRequest(UsernameValidator): password: str admin_key: str diff --git a/backend/core/utils/utils.py b/backend/core/utils/utils.py index 21a3655..57687d7 100644 --- a/backend/core/utils/utils.py +++ b/backend/core/utils/utils.py @@ -1,6 +1,6 @@ import os import uuid -from fastapi import FastAPI, Depends, HTTPException, status +from fastapi import FastAPI, Depends, HTTPException, status, Request from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from config.config_info import settings import jwt @@ -18,22 +18,36 @@ SECRET_KEY = settings.SECRET_KEY ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 +COOKIE_NAME = "access_token" oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") def generate_unique_filename(original_filename): - # 获取文件扩展名 extension = os.path.splitext(original_filename)[1] - # 生成唯一的UUID unique_id = uuid.uuid4() - # 组合唯一标识符和扩展名 unique_filename = f"{unique_id}{extension}" return unique_filename -async def get_current_user(token: str = Depends(oauth2_scheme)): +async def get_token_from_request(request: Request): + token = request.cookies.get(COOKIE_NAME) + if token: + return token + + authorization = request.headers.get("Authorization") + if authorization and authorization.startswith("Bearer "): + return authorization[7:] + + return None + +async def get_current_user(request: Request): credentials_exception = HTTPException( status_code=401, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) + + token = await get_token_from_request(request) + if not token: + raise credentials_exception + try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get("sub") @@ -47,13 +61,17 @@ async def get_current_user(token: str = Depends(oauth2_scheme)): if user is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not Login") - return username -async def get_is_admin(token: str = Depends(oauth2_scheme)): +async def get_is_admin(request: Request): credentials_exception = HTTPException( status_code=401, detail="Could not validate credentials", headers={"WWW-Authenticate": "Bearer"}, ) + + token = await get_token_from_request(request) + if not token: + raise credentials_exception + try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) username: str = payload.get("sub") @@ -61,6 +79,7 @@ async def get_is_admin(token: str = Depends(oauth2_scheme)): raise credentials_exception except jwt.PyJWTError: raise credentials_exception + mysql_client = MysqlClient() user = mysql_client.db.query(UserInfo).filter(UserInfo.username == username).first() diff --git a/web/src/utils/http.ts b/web/src/utils/http.ts index 382d79f..b39fdd6 100644 --- a/web/src/utils/http.ts +++ b/web/src/utils/http.ts @@ -1,26 +1,47 @@ // http.ts -// 添加token相关的工具函数 +// 移除 localStorage 相关的 token 存储函数,改用 httpOnly cookie +// 保留空函数以保持向后兼容,但实际上不再使用 localStorage export const saveToken = (token: string) => { - localStorage.setItem('token', token); + // 不再使用 localStorage 存储 token,改为依赖 httpOnly cookie + console.warn('saveToken is deprecated, using httpOnly cookie instead'); }; export const getToken = () => { - return localStorage.getItem('token'); + // 不再从 localStorage 获取 token,改为依赖 httpOnly cookie + console.warn('getToken is deprecated, using httpOnly cookie instead'); + return null; }; export const removeToken = () => { - localStorage.removeItem('token'); + // 不再从 localStorage 移除 token,改为调用后端 logout 接口 + console.warn('removeToken is deprecated, use logout() instead'); }; -// 新增:获取认证请求头 +// 新增:登出方法 +export async function logout() { + try { + const baseURL = import.meta.env.VITE_APP_BASE_URL || 'http://127.0.0.1:9988'; + const response = await fetch(`${baseURL}/v1/api/mark/account/logout`, { + method: 'POST', + credentials: 'include', + }); + + if (!response.ok) { + throw new Error('登出失败'); + } + + return await response.json(); + } catch (error) { + console.error('Logout error:', error); + throw error; + } +} + +// 新增:获取认证请求头 - 现在返回空对象,因为 token 由 cookie 自动携带 const getAuthHeaders = (customHeaders?: any) => { - const token = getToken(); - const defaultHeaders = { - 'Authorization': token ? `Bearer ${token}` : '', - // 'Content-Type': 'application/json' - }; - return customHeaders ? { ...defaultHeaders, ...customHeaders } : defaultHeaders; + // 不再需要手动添加 Authorization header,因为 token 由 cookie 自动携带 + return customHeaders ? { ...customHeaders } : {}; }; export async function getRequest(url: string): Promise { @@ -28,17 +49,18 @@ export async function getRequest(url: string): Promise { const headers = getAuthHeaders(); const response = await fetch(url, { method: 'GET', - headers + headers, + credentials: 'include', }); if (!response.ok) { if (response.status === 401) { - removeToken(); // token无效时清除 + // 401 时不再需要手动清除 token,cookie 会在登出时清除 } throw new Error(`GET request failed: ${response.statusText}`); } - return await response.json() as T; // assuming the response is JSON + return await response.json() as T; } catch (error) { console.error('GET request error:', error); return undefined; @@ -56,11 +78,12 @@ export async function postRequest(url: string, body: any, customHeaders?: any method: 'POST', headers, body: isFormData ? body : JSON.stringify(body), + credentials: 'include', }); if (!response.ok) { if (response.status === 401) { - removeToken(); + // 401 时不再需要手动清除 token } throw new Error(`POST request failed: ${response.statusText}`); } @@ -82,22 +105,21 @@ export async function putRequest(url: string, body: any, customHeaders?: any) method: 'PUT', headers, body: isFormData ? body : JSON.stringify(body), + credentials: 'include', }); if (!response.ok) { if (response.status === 401) { - removeToken(); + // 401 时不再需要手动清除 token } throw new Error(`PUT request failed: ${response.statusText}`); } - // 确保响应体为 JSON 格式再解析 const contentType = response.headers.get("content-type"); if (contentType && contentType.includes("application/json")) { return await response.json() as T; } - // 如果不是 JSON 格式,返回空 return undefined; } catch (error) { console.error('PUT request error:', error); @@ -110,12 +132,13 @@ export async function deleteRequest(url: string): Promise { const headers = getAuthHeaders(); const response = await fetch(url, { method: 'DELETE', - headers + headers, + credentials: 'include', }); if (!response.ok) { if (response.status === 401) { - removeToken(); + // 401 时不再需要手动清除 token } throw new Error(`DELETE request failed: ${response.statusText}`); } @@ -137,7 +160,8 @@ export async function login(username: string, password: string) { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ username, password }) + body: JSON.stringify({ username, password }), + credentials: 'include', }); if (!response.ok) { @@ -145,8 +169,8 @@ export async function login(username: string, password: string) { } const result = await response.json(); - if (result.code === 200 && result.data.access_token) { - saveToken(result.data.access_token); + if (result.code === 200) { + // 不再手动保存 token,依赖 httpOnly cookie return result; } @@ -166,7 +190,8 @@ export async function signup(username: string, password: string) { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ username, password }) + body: JSON.stringify({ username, password }), + credentials: 'include', }); if (!response.ok) { @@ -174,8 +199,8 @@ export async function signup(username: string, password: string) { } const result = await response.json(); - if (result.code === 200 && result.data.access_token) { - saveToken(result.data.access_token); + if (result.code === 200) { + // 不再手动保存 token,依赖 httpOnly cookie return result; } @@ -195,7 +220,8 @@ export async function signupAdmin(username: string, password: string, adminKey: headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ username, password, admin_key: adminKey }) + body: JSON.stringify({ username, password, admin_key: adminKey }), + credentials: 'include', }); if (!response.ok) { @@ -204,8 +230,8 @@ export async function signupAdmin(username: string, password: string, adminKey: } const result = await response.json(); - if (result.code === 200 && result.data.access_token) { - saveToken(result.data.access_token); + if (result.code === 200) { + // 不再手动保存 token,依赖 httpOnly cookie return result; }