perf: login module

This commit is contained in:
fit2cloud-chenyw
2025-04-25 14:52:46 +08:00
parent 051507504b
commit dc934e7133
22 changed files with 412 additions and 93 deletions
+2
View File
@@ -181,3 +181,5 @@ cython_debug/
# PyPI configuration file
.pypirc
.DS_Store
+13 -11
View File
@@ -1,22 +1,24 @@
from fastapi import APIRouter, HTTPException
from apps.system.schemas.auth import LocalLoginSchema
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from common.core.deps import SessionDep
from ..crud.user import authenticate
from common.core.security import create_access_token
from datetime import timedelta
from common.core.config import settings
from common.core.schemas import Token
router = APIRouter(tags=["login"], prefix="/login")
router = APIRouter(tags=["login"])
@router.post("/localLogin")
@router.post("/access-token")
def local_login(
session: SessionDep,
schema: LocalLoginSchema,
) -> str:
user = authenticate(session=session, account=schema.account, password=schema.password)
form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
) -> Token:
user = authenticate(session=session, account=form_data.username, password=form_data.password)
if not user:
raise HTTPException(status_code=400, detail="Incorrect account or password")
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return create_access_token(
user.id, expires_delta=access_token_expires
)
user_dict = user.to_dict()
return Token(access_token=create_access_token(
user_dict, expires_delta=access_token_expires
))
+2 -4
View File
@@ -5,7 +5,5 @@ router = APIRouter(tags=["user"], prefix="/user")
@router.get("/info")
async def user_info():
return {
"name": "admin"
}
async def user_info(current_user: CurrentUser):
return current_user.to_dict()
-23
View File
@@ -1,23 +0,0 @@
from fastapi import HTTPException, Request
from common.core.config import settings
from common.core.deps import get_current_user
async def verify_token(request: Request, call_next):
print(request.url.path)
if request.url.path == "/login/localLogin":
return await call_next(request)
try:
key = settings.TOKEN_KEY
token = request.headers.get(key)
get_current_user(token)
except (Exception):
return HTTPException(status_code=401, detail="Invalid token")
response = await call_next(request)
return response
+7
View File
@@ -6,3 +6,10 @@ class sys_user(SQLModel, table=True):
account: str = Field(max_length=255, unique=True)
password: str = Field(max_length=255)
oid: int = Field(default=1)
def to_dict(self):
return {
"id": self.id,
"account": self.account,
"oid": self.oid
}
+12 -41
View File
@@ -2,48 +2,26 @@ from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
# from fastapi.security import OAuth2PasswordBearer
from jwt.exceptions import InvalidTokenError
from pydantic import ValidationError
from pydantic import BaseModel, ValidationError
from sqlmodel import Session
from common.core.schemas import TokenPayload, XOAuth2PasswordBearer
from common.core import security
from common.core.config import settings
from common.core.db import get_session
from apps.system.models.user import sys_user
import threading
""" reusable_oauth2 = OAuth2PasswordBearer(
reusable_oauth2 = XOAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
)
"""
thread_local = threading.local()
SessionDep = Annotated[Session, Depends(get_session)]
# TokenDep = Annotated[str, Depends(reusable_oauth2)]
TokenDep = Annotated[str, Depends(reusable_oauth2)]
def get_current_user(session: SessionDep, token: str) -> sys_user:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
# token_data = TokenPayload(**payload)
except (InvalidTokenError, ValidationError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
user = session.get(sys_user, payload.sub)
if not user:
raise HTTPException(status_code=404, detail="User not found")
""" if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user") """
thread_local.current_user = user
return user
def get_current_user_from_thread():
return getattr(thread_local, "current_user", None)
CurrentUser = Annotated[sys_user, Depends(get_current_user_from_thread)]
""" def get_current_user(session: SessionDep, token: TokenDep) -> User:
def get_current_user(session: SessionDep, token: TokenDep) -> sys_user:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
@@ -54,20 +32,13 @@ CurrentUser = Annotated[sys_user, Depends(get_current_user_from_thread)]
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
user = session.get(User, token_data.sub)
user = session.get(sys_user, token_data.id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
""" if not user.is_active:
raise HTTPException(status_code=400, detail="Inactive user") """
return user
CurrentUser = Annotated[sys_user, Depends(get_current_user)]
CurrentUser = Annotated[User, Depends(get_current_user)]
def get_current_active_superuser(current_user: CurrentUser) -> User:
if not current_user.is_superuser:
raise HTTPException(
status_code=403, detail="The user doesn't have enough privileges"
)
return current_user """
+31
View File
@@ -0,0 +1,31 @@
from typing import Optional
from fastapi import HTTPException, Request
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel
from sqlmodel import SQLModel
from starlette.status import HTTP_403_FORBIDDEN, HTTP_401_UNAUTHORIZED
from common.core.config import settings
from fastapi.security.utils import get_authorization_scheme_param
class TokenPayload(BaseModel):
account: str | None = None
id: int | None = None
oid: int | None = None
class Token(SQLModel):
access_token: str
token_type: str = "bearer"
class XOAuth2PasswordBearer(OAuth2PasswordBearer):
async def __call__(self, request: Request) -> Optional[str]:
authorization = request.headers.get(settings.TOKEN_KEY)
scheme, param = get_authorization_scheme_param(authorization)
if not authorization or scheme.lower() != "bearer":
if self.auto_error:
raise HTTPException(
status_code=HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
else:
return None
return param
+4 -2
View File
@@ -12,9 +12,11 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
def create_access_token(subject: str | Any, expires_delta: timedelta) -> str:
def create_access_token(data: dict | Any, expires_delta: timedelta) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + expires_delta
to_encode = {"exp": expire, "account": str(subject)}
to_encode.update({"exp": expire})
# to_encode = {"exp": expire, "account": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
-3
View File
@@ -2,11 +2,9 @@ import sentry_sdk
from fastapi import FastAPI
from fastapi.routing import APIRoute
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from apps.api import api_router
from common.core.config import settings
from apps.system.middleware.token import verify_token
def custom_generate_unique_id(route: APIRoute) -> str:
return f"{route.tags[0]}-{route.name}"
@@ -32,7 +30,6 @@ if settings.all_cors_origins:
)
app.include_router(api_router, prefix=settings.API_V1_STR)
# app.add_middleware(BaseHTTPMiddleware, dispatch=verify_token)
if __name__ == "__main__":
import uvicorn
+1 -1
View File
@@ -1,2 +1,2 @@
VITE_API_BASE_URL=http://localhost:3000
VITE_API_BASE_URL=http://localhost:8000/api/v1
VITE_APP_TITLE=SQLBot (Development)
+1 -1
View File
@@ -1,2 +1,2 @@
VITE_API_BASE_URL=https://api.SQLBot.com
VITE_API_BASE_URL=./
VITE_APP_TITLE=SQLBot
+1 -1
View File
@@ -6,5 +6,5 @@
// biome-ignore lint: disable
export {}
declare global {
const ElMessage: typeof import('element-plus/es')['ElMessage']
}
+1
View File
@@ -11,6 +11,7 @@ declare module 'vue' {
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
+2 -1
View File
@@ -10,7 +10,8 @@
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0"
"vue-router": "^4.5.0",
"web-storage-cache": "^1.1.1"
},
"devDependencies": {
"@element-plus/icons-vue": "^2.3.1",
+15
View File
@@ -0,0 +1,15 @@
import { request } from '@/utils/request'
export const AuthApi = {
login: (credentials: { username: string; password: string }) => request.post<{
data: any; token: string
}>('/login/access-token', credentials, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
requestOptions: {
rawResponse: true,
}
}),
logout: () => request.post('/auth/logout')
}
+2 -1
View File
@@ -6,6 +6,7 @@ import chat from '@/views/chat/index.vue'
import ds from '@/views/ds/index.vue'
import dashboard from '@/views/dashboard/index.vue'
import setting from '@/views/setting/index.vue'
import { watchRouter } from './watch'
const router = createRouter({
history: createWebHistory(),
routes: [
@@ -68,5 +69,5 @@ const router = createRouter({
}
]
})
watchRouter(router)
export default router
+32
View File
@@ -0,0 +1,32 @@
import { ElMessage } from 'element-plus'
import { useCache } from '@/utils/useCache'
const { wsCache } = useCache()
// 不需要验证token的白名单路由
const whiteList = ['/login']
export const watchRouter = (router: any) => {
router.beforeEach(async (to: any, from: any, next: any) => {
// 获取token
const token = wsCache.get('user.token')
// 如果没有token
if (!token) {
if (whiteList.includes(to.path)) {
next()
} else {
ElMessage.error('请先登录')
next('/login')
}
return
}
// 处理根路径重定向
if (to.path === '/') {
next('/chat')
return
}
// 已有token和用户信息,允许访问
if (to.path === '/login') {
next('/chat')
} else {
next()
}
})
}
+8 -3
View File
@@ -1,19 +1,24 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { AuthApi } from '@/api/login'
import { useCache } from '@/utils/useCache'
const { wsCache } = useCache()
export const useUserStore = defineStore('user', () => {
const token = ref('')
const username = ref('')
const login = async (formData: { username: string; password: string }) => {
token.value = 'mock-token'
username.value = formData.username
return Promise.resolve()
const res = await AuthApi.login(formData)
token.value = res.data.access_token
wsCache.set('user.token', token.value)
username.value = formData.username
}
const logout = () => {
token.value = ''
username.value = ''
wsCache.delete('user.token')
}
return {
+260
View File
@@ -0,0 +1,260 @@
// src/services/request.ts
import axios, {
AxiosError,
type AxiosInstance,
type AxiosRequestConfig,
type AxiosResponse,
type InternalAxiosRequestConfig,
type CancelTokenSource
} from 'axios'
import { useCache } from '@/utils/useCache'
const { wsCache } = useCache()
// Response data structure
export interface ApiResponse<T = unknown> {
code: number
data: T
message: string
success: boolean
[key: string]: any // Allow additional fields
}
// Extended request options
export interface RequestOptions {
silent?: boolean // Silent mode (no error alerts)
rawResponse?: boolean // Return raw Axios response
customError?: boolean // Custom error handling
retryCount?: number // Number of retry attempts
}
// Merged request configuration
export interface FullRequestConfig extends AxiosRequestConfig {
requestOptions?: RequestOptions
}
// Custom error type
export interface RequestError<T = any> extends Error {
config: FullRequestConfig
code?: string
request?: any
response?: AxiosResponse<T>
isAxiosError: boolean
}
class HttpService {
private instance: AxiosInstance
private cancelTokenSource: CancelTokenSource
constructor(config?: AxiosRequestConfig) {
this.cancelTokenSource = axios.CancelToken.source()
this.instance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
headers: {
'Content-Type': 'application/json',
...config?.headers
},
...config
})
this.setupInterceptors()
}
private setupInterceptors() {
// Request interceptor
this.instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Add auth token
const token = wsCache.get('user.token')
if (token && config.headers) {
config.headers['X-SQLBOT_TOKEN'] = `Bearer ${token}`
}
// Request logging
console.log(`[Request] ${config.method?.toUpperCase()} ${config.url}`)
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor
this.instance.interceptors.response.use(
(response: AxiosResponse) => {
console.log(`[Response] ${response.config.url}`, response.data)
// Return raw response if configured
if ((response.config as FullRequestConfig).requestOptions?.rawResponse) {
return response
}
// Handle business logic
if (response.data?.success !== true) {
return Promise.reject(response.data)
}
return response.data.data
},
async (error: AxiosError) => {
const config = error.config as FullRequestConfig & { __retryCount?: number }
const requestOptions = config?.requestOptions || {}
// Retry logic for specific status codes
const shouldRetry =
error.response?.status === 502 &&
(config.__retryCount || 0) < (requestOptions.retryCount || 3)
if (shouldRetry) {
config.__retryCount = (config.__retryCount || 0) + 1
// Exponential backoff
await new Promise(resolve =>
setTimeout(resolve, 1000 * (config.__retryCount || 1))
)
return this.instance.request(config)
}
// Unified error handling
if (!requestOptions.customError && !requestOptions.silent) {
this.handleError(error)
}
return Promise.reject(error)
}
)
}
private handleError(error: AxiosError) {
let errorMessage = 'Request error'
if (error.response) {
switch (error.response.status) {
case 400:
errorMessage = 'Invalid request parameters'
break
case 401:
errorMessage = 'Unauthorized, please login again'
// Redirect to login page if needed
break
case 403:
errorMessage = 'Access denied'
break
case 404:
errorMessage = 'Resource not found'
break
case 500:
errorMessage = 'Server error'
break
default:
errorMessage = `Server responded with error: ${error.response.status}`
}
} else if (error.request) {
errorMessage = 'No response from server'
} else if (axios.isCancel(error)) {
errorMessage = 'Request canceled'
return // Skip showing cancel messages
} else {
errorMessage = error['message'] || 'Unknown error'
}
// Show error using UI library (e.g., Element Plus, Ant Design)
console.error(errorMessage)
// ElMessage.error(errorMessage)
ElMessage({
message: errorMessage,
type: 'error',
duration: 0
})
}
// Cancel all pending requests
public cancelRequests(message?: string) {
this.cancelTokenSource.cancel(message)
// Create new token source for future requests
this.cancelTokenSource = axios.CancelToken.source()
}
// Base request method
public request<T = any>(config: FullRequestConfig): Promise<T> {
return this.instance.request({
cancelToken: this.cancelTokenSource.token,
...config
})
}
// GET request
public get<T = any>(url: string, config?: FullRequestConfig): Promise<T> {
return this.request({ ...config, method: 'GET', url })
}
// POST request
public post<T = any>(
url: string,
data?: any,
config?: FullRequestConfig
): Promise<T> {
return this.request({ ...config, method: 'POST', url, data })
}
// PUT request
public put<T = any>(
url: string,
data?: any,
config?: FullRequestConfig
): Promise<T> {
return this.request({ ...config, method: 'PUT', url, data })
}
// DELETE request
public delete<T = any>(url: string, config?: FullRequestConfig): Promise<T> {
return this.request({ ...config, method: 'DELETE', url })
}
// PATCH request
public patch<T = any>(
url: string,
data?: any,
config?: FullRequestConfig
): Promise<T> {
return this.request({ ...config, method: 'PATCH', url, data })
}
// File upload
public upload<T = any>(
url: string,
file: File,
fieldName = 'file',
config?: FullRequestConfig
): Promise<T> {
const formData = new FormData()
formData.append(fieldName, file)
return this.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
...config
})
}
// Download file
public download(
url: string,
config?: FullRequestConfig
): Promise<Blob> {
return this.request<Blob>({
...config,
method: 'GET',
url,
responseType: 'blob'
})
}
}
// Create singleton instance
export const request = new HttpService({
baseURL: import.meta.env.VITE_API_BASE_URL
})
+17
View File
@@ -0,0 +1,17 @@
/**
* 配置浏览器本地存储的方式,可直接存储对象数组。
*/
import WebStorageCache from 'web-storage-cache'
type CacheType = 'sessionStorage' | 'localStorage'
export const useCache = (type: CacheType = 'localStorage') => {
const wsCache: WebStorageCache = new WebStorageCache({
storage: type
})
return {
wsCache
}
}
+1 -1
View File
@@ -14,5 +14,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "auto-imports.d.ts"],
}