diff --git a/inventory-backend/app/api/v1/auth.py b/inventory-backend/app/api/v1/auth.py index 1cf7480..4eefa91 100644 --- a/inventory-backend/app/api/v1/auth.py +++ b/inventory-backend/app/api/v1/auth.py @@ -67,6 +67,7 @@ def login(): response_data = { 'msg': '登录成功', 'access_token': result.get('access_token'), + 'refresh_token': result.get('refresh_token'), 'user': result.get('user') } return jsonify(response_data), 200 @@ -78,6 +79,31 @@ def login(): return jsonify({'msg': f'服务器内部错误: {str(e)}'}), 500 +@auth_bp.route('/refresh', methods=['POST']) +def refresh(): + """ + 使用 refresh_token 换发新的 access_token + """ + try: + data = request.get_json() + if not data or not data.get('refresh_token'): + return jsonify({'msg': '缺少 refresh_token'}), 400 + + refresh_token = data.get('refresh_token') + result = AuthService.refresh_access_token(refresh_token) + + return jsonify({ + 'msg': 'Token 刷新成功', + 'access_token': result.get('access_token') + }), 200 + + except ValueError as ve: + return jsonify({'msg': str(ve)}), 401 + except Exception as e: + current_app.logger.error(f"Token Refresh Error: {str(e)}") + return jsonify({'msg': f'Token 刷新失败: {str(e)}'}), 500 + + @auth_bp.route('/user/create', methods=['POST']) @jwt_required() @permission_required('system_user:operation') diff --git a/inventory-backend/app/services/auth_service.py b/inventory-backend/app/services/auth_service.py index 07e7a15..96000a5 100644 --- a/inventory-backend/app/services/auth_service.py +++ b/inventory-backend/app/services/auth_service.py @@ -2,7 +2,7 @@ from app.models.system import SysUser, SysRolePermission # <== 引入 SysRolePermission from app.extensions import db from sqlalchemy import func -from flask_jwt_extended import create_access_token +from flask_jwt_extended import create_access_token, create_refresh_token from app.utils.constants import UserRole from datetime import timedelta @@ -61,21 +61,67 @@ class AuthService: # Token 中 identity 存数据库ID,claims 存登录账号ID account_id = user_info.get('account_id', login_input) + # Access Token: 2小时有效期 access_token = create_access_token( identity=user_id, additional_claims={ 'role': user_role, 'username': account_id, # 存纯账号ID 'display_name': user_info.get('username') # 存显示名 - }, - expires_delta=timedelta(days=7) + } + ) + + # Refresh Token: 7天有效期 + refresh_token = create_refresh_token( + identity=user_id, + additional_claims={ + 'role': user_role, + 'username': account_id + } ) return { 'access_token': access_token, + 'refresh_token': refresh_token, 'user': user_info } + @staticmethod + def refresh_access_token(refresh_token_str): + """ + 使用 refresh_token 换发新的 access_token + """ + from flask_jwt_extended import decode_token + from flask import current_app + + try: + # 解码 refresh_token(不验证过期,仅获取 claims) + decoded = decode_token(refresh_token_str) + user_id = decoded.get('sub') + role = decoded.get('role') + username = decoded.get('username') + display_name = decoded.get('display_name') + + if not user_id: + raise ValueError("无效的 refresh_token") + + # 生成新的 access_token + new_access_token = create_access_token( + identity=user_id, + additional_claims={ + 'role': role, + 'username': username, + 'display_name': display_name + } + ) + + return { + 'access_token': new_access_token + } + + except Exception as e: + raise ValueError(f"Token 刷新失败: {str(e)}") + @staticmethod def create_user(data, operator_role): """ diff --git a/inventory-backend/config.py b/inventory-backend/config.py index 030b54e..e225997 100644 --- a/inventory-backend/config.py +++ b/inventory-backend/config.py @@ -31,8 +31,11 @@ class Config: # 逻辑:优先读环境变量,读不到就用默认字符串 JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'default-jwt-secret-key-if-missing') - # 设置 Token 过期时间 (这里设为 1 天) - JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=1) + # Access Token 有效期: 2 小时 + JWT_ACCESS_TOKEN_EXPIRES = timedelta(hours=2) + + # Refresh Token 有效期: 7 天 + JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=7) # ========================================================= # 4. 文件上传配置 diff --git a/inventory-web/src/router/index.ts b/inventory-web/src/router/index.ts index 190e703..3808be9 100644 --- a/inventory-web/src/router/index.ts +++ b/inventory-web/src/router/index.ts @@ -3,6 +3,7 @@ import type { RouteRecordRaw } from 'vue-router' import Layout from '@/layout/index.vue' import { useUserStore } from '@/stores/user' import BomManage from '@/views/bom/BomManage.vue' +import { ElMessage } from 'element-plus' // [新增] 扩展 RouteMeta 类型定义,防止 TS 报错 declare module 'vue-router' { @@ -231,12 +232,23 @@ const router = createRouter({ router.beforeEach((to, from, next) => { const userStore = useUserStore() - const token = userStore.token || localStorage.getItem('token') + const token = userStore.token || localStorage.getItem('access_token') || localStorage.getItem('token') // [修复] 优先从 user 对象获取,并统一转大写,防止大小写不一致导致权限失效 const rawRole = userStore.user?.role || userStore.role || localStorage.getItem('role') || 'user' const userRole = String(rawRole).toUpperCase() + // ============================================================ + // 安全兜底:检查 refresh_token 是否即将过期(30分钟) + // ============================================================ + if (token && userStore.isRefreshTokenExpiringSoon()) { + // 仅在用户主动操作时提示,避免页面加载就弹窗 + const isUserAction = to.path !== '/login' && to.path !== '/' + if (isUserAction) { + ElMessage.warning('您的登录状态即将失效,请及时保存数据并重新登录') + } + } + // 调试日志 if (to.path.includes('/system')) { console.log(`路由守卫检查: Path=${to.path}, UserRole=${userRole}, Required=${to.meta.roles}`) diff --git a/inventory-web/src/stores/user.ts b/inventory-web/src/stores/user.ts index 032203d..4e803a0 100644 --- a/inventory-web/src/stores/user.ts +++ b/inventory-web/src/stores/user.ts @@ -2,14 +2,47 @@ import { defineStore } from 'pinia' import { login } from '@/api/auth' import { getRolePermissions } from '@/api/system/permission' import { ref } from 'vue' +import axios from 'axios' + +// JWT 解码函数(手写实现,不依赖外部库) +function parseJWT(token: string): any { + if (!token) return null + try { + const base64Url = token.split('.')[1] + if (!base64Url) return null + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') + const jsonPayload = decodeURIComponent( + atob(base64) + .split('') + .map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)) + .join('') + ) + return JSON.parse(jsonPayload) + } catch (e) { + console.error('JWT parse error:', e) + return null + } +} + +// 获取 Token 过期时间戳 +function getTokenExp(token: string): number | null { + const decoded = parseJWT(token) + return decoded?.exp ? decoded.exp * 1000 : null // 转换为毫秒 +} export const useUserStore = defineStore('user', () => { // 1. State: 初始化时优先从 localStorage 获取,防止刷新丢失 - const token = ref(localStorage.getItem('token') || '') + const token = ref(localStorage.getItem('access_token') || localStorage.getItem('token') || '') + const refreshToken = ref(localStorage.getItem('refresh_token') || '') const role = ref(localStorage.getItem('role') || '') const username = ref(localStorage.getItem('username') || '') const permissions = ref(JSON.parse(localStorage.getItem('permissions') || '[]')) + // 兼容旧版 localStorage key + if (localStorage.getItem('token') && !localStorage.getItem('access_token')) { + localStorage.setItem('access_token', localStorage.getItem('token') || '') + } + // 2. Actions // 登录逻辑 const handleLogin = async (loginForm: any) => { @@ -32,6 +65,7 @@ export const useUserStore = defineStore('user', () => { // 更新 Pinia 状态 (内存) token.value = data.access_token + refreshToken.value = data.refresh_token || '' // 处理用户信息 (确保后端返回结构中有 user 字段) if (data.user) { @@ -44,8 +78,11 @@ export const useUserStore = defineStore('user', () => { localStorage.setItem('username', username.value) } - // 持久化存储 Token - localStorage.setItem('token', data.access_token) + // 持久化存储双 Token + localStorage.setItem('access_token', data.access_token) + if (data.refresh_token) { + localStorage.setItem('refresh_token', data.refresh_token) + } // 登录成功后,根据角色获取权限 if (role.value) { @@ -69,15 +106,24 @@ export const useUserStore = defineStore('user', () => { return true // 返回 true 表示登录成功 } + // 更新 Access Token(无感刷新时调用) + const setToken = (newToken: string) => { + token.value = newToken + localStorage.setItem('access_token', newToken) + } + // 退出逻辑 const logout = () => { // 1. 清空 Pinia 状态 (内存) token.value = '' + refreshToken.value = '' role.value = '' username.value = '' permissions.value = [] // 2. 清空 LocalStorage (硬盘) + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') localStorage.removeItem('token') localStorage.removeItem('role') localStorage.removeItem('username') @@ -107,6 +153,16 @@ export const useUserStore = defineStore('user', () => { } } + // 检查 refresh_token 是否即将过期(30分钟) + const isRefreshTokenExpiringSoon = (): boolean => { + if (!refreshToken.value) return true + const expTime = getTokenExp(refreshToken.value) + if (!expTime) return true + const now = Date.now() + const thirtyMinutes = 30 * 60 * 1000 + return expTime - now < thirtyMinutes + } + // 3. Getters / Helpers // 判断当前用户是否拥有某些角色 const hasRole = (roles: string[]) => { @@ -124,12 +180,15 @@ export const useUserStore = defineStore('user', () => { return { token, + refreshToken, role, username, permissions, handleLogin, + setToken, logout, refreshUserPermissions, + isRefreshTokenExpiringSoon, hasRole, hasPermission } diff --git a/inventory-web/src/utils/request.ts b/inventory-web/src/utils/request.ts index de397c8..ff4467c 100644 --- a/inventory-web/src/utils/request.ts +++ b/inventory-web/src/utils/request.ts @@ -1,6 +1,6 @@ -import axios from 'axios' +import axios, { AxiosError, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios' import { ElMessage } from 'element-plus' -import { useUserStore } from '@/stores/user' // 引入 Store 获取 Token +import { useUserStore } from '@/stores/user' // 1. 创建 axios 实例 const service = axios.create({ @@ -11,13 +11,54 @@ const service = axios.create({ timeout: 5000 }) -// 2. 请求拦截器 +// ============================================================ +// 2. 无感刷新 Token 核心逻辑 +// ============================================================ + +// 标记是否正在刷新 Token +let isRefreshing = false +// 重试队列:存储失败的请求,刷新成功后重新发送 +const retryQueue: { + resolve: (value: unknown) => void + reject: (reason?: unknown) => void + config: InternalAxiosRequestConfig +}[] = [] + +// 处理队列中的请求 +const processQueue = (newToken: string | null, error?: Error) => { + retryQueue.forEach(({ resolve, reject, config }) => { + if (newToken) { + // 更新 Authorization 头 + config.headers['Authorization'] = 'Bearer ' + newToken + // 重新发送请求 + service(config).then(resolve).catch(reject) + } else { + reject(error) + } + }) + // 清空队列 + retryQueue.length = 0 +} + +// 刷新 Token 接口 +const refreshToken = async (refreshTokenValue: string): Promise => { + const res = await axios.post('/api/v1/auth/refresh', { + refresh_token: refreshTokenValue + }) + const data = res.data + if (data.access_token) { + return data.access_token + } + throw new Error('Token 刷新失败') +} + +// ============================================================ +// 3. 请求拦截器 +// ============================================================ service.interceptors.request.use( (config) => { // 在发送请求之前做些什么 - // 注意:这里需要确保 Pinia 已经初始化,但在拦截器运行时组件早已加载,通常没问题 - // 为了安全起见,也可以直接读 localStorage,或者在函数内调用 store - const token = localStorage.getItem('token') + const token = localStorage.getItem('access_token') || localStorage.getItem('token') if (token && config.headers) { // Flask-JWT-Extended 默认需要 'Bearer ' 格式 @@ -30,7 +71,9 @@ service.interceptors.request.use( } ) -// 3. 响应拦截器 +// ============================================================ +// 4. 响应拦截器(核心:无感刷新) +// ============================================================ service.interceptors.response.use( (response) => { // Axios 默认包了一层 data,所以这里取 response.data @@ -47,35 +90,132 @@ service.interceptors.response.use( return res // 返回解包后的数据 } }, - (error) => { + async (error: AxiosError) => { console.log('err: ' + error) // for debug + + // 如果不是 axios 错误,直接抛出 + if (!error.response) { + return Promise.reject(error) + } + + const originalConfig = error.config as InternalAxiosRequestConfig & { _retry?: boolean } + let message = error.message || '请求失败' // 处理 HTTP 状态码错误 - const isLoginEndpoint = error.config && error.config.url.includes('/login') + const isLoginEndpoint = error.config && error.config.url && error.config.url.includes('/login') + const isRefreshEndpoint = error.config && error.config.url && error.config.url.includes('/refresh') - if (error.response) { - const status = error.response.status - const data = error.response.data + const status = error.response.status + const data = error.response.data as any - if (status === 401) { - // 对于登录接口的401错误,不执行登出重定向,仅提示错误 - if (!isLoginEndpoint) { - message = '登录已过期,请重新登录' + // ============================================================ + // 核心:401 错误处理 + 无感刷新 + // ============================================================ + if (status === 401) { + // 1. 如果是登录接口的 401,不执行刷新 + if (isLoginEndpoint) { + message = data?.msg || '用户名或密码错误' + ElMessage.error(message) + return Promise.reject(error) + } + + // 2. 如果是刷新接口的 401,说明 refresh_token 也过期了 + if (isRefreshEndpoint) { + message = '登录已彻底过期,请重新登录' + ElMessage.error(message) + // 清空所有 Token,跳转登录页 + localStorage.clear() + window.location.href = '/login' + return Promise.reject(error) + } + + // 3. 业务接口返回 401,尝试无感刷新 + if (!originalConfig) { + return Promise.reject(error) + } + + // 如果已经重试过(防止无限循环) + if (originalConfig._retry) { + message = '登录已过期,请重新登录' + localStorage.clear() + window.location.href = '/login' + return Promise.reject(error) + } + + // 标记为已重试 + originalConfig._retry = true + + // 获取 refresh_token + const refreshTokenValue = localStorage.getItem('refresh_token') + + if (!refreshTokenValue) { + // 没有 refresh_token,直接跳转登录 + message = '登录已过期,请重新登录' + ElMessage.error(message) + localStorage.clear() + window.location.href = '/login' + return Promise.reject(error) + } + + // 4. 尝试刷新 Token + if (!isRefreshing) { + isRefreshing = true + + try { + console.log('正在刷新 Token...') + const newAccessToken = await refreshToken(refreshTokenValue) + + // 刷新成功,更新本地 Token + const userStore = useUserStore() + userStore.setToken(newAccessToken) + + // 更新当前请求的 Authorization + originalConfig.headers['Authorization'] = 'Bearer ' + newAccessToken + + console.log('Token 刷新成功,重发队列中的请求') + + // 处理队列中的请求 + processQueue(newAccessToken) + + // 重发当前请求 + return service(originalConfig) + + } catch (refreshError) { + console.error('Token 刷新失败:', refreshError) + + // 刷新失败,清空队列并跳转登录 + processQueue(null, refreshError as Error) + + message = '登录已彻底过期,请重新登录' + ElMessage.error(message) localStorage.clear() window.location.href = '/login' + return Promise.reject(refreshError) + + } finally { + isRefreshing = false } - // 如果是登录接口,message会被后面的data.msg覆盖 - } else if (status === 403) { - message = '权限不足' - } else if (status === 404) { - message = '请求的资源不存在' - } else if (status === 500) { - message = '服务器内部错误' - } else if (data && data.msg) { - // 优先显示后端返回的错误信息 - message = data.msg + } else { + // 5. 正在刷新中,将当前请求加入队列等待 + return new Promise((resolve, reject) => { + retryQueue.push({ + resolve, + reject, + config: originalConfig + }) + }) } + + } else if (status === 403) { + message = '权限不足' + } else if (status === 404) { + message = '请求的资源不存在' + } else if (status === 500) { + message = '服务器内部错误' + } else if (data && data.msg) { + // 优先显示后端返回的错误信息 + message = data.msg } // 登录接口的错误由调用方单独处理,不再显示全局提示