feat: 重构鉴权系统为双Token无感刷新,并增加前端Token过期安全预判机制
This commit is contained in:
@ -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')
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
@ -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. 文件上传配置
|
||||
|
||||
@ -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}`)
|
||||
|
||||
@ -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<string[]>(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
|
||||
}
|
||||
|
||||
@ -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<string> => {
|
||||
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 <token>' 格式
|
||||
@ -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
|
||||
}
|
||||
|
||||
// 登录接口的错误由调用方单独处理,不再显示全局提示
|
||||
|
||||
Reference in New Issue
Block a user