Vue 3 + FastAPI 登录认证实战:JWT、路由守卫与权限管理
在前面的教程中,我们已经使用 Vue 3 和 Element Plus 搭建了一个基础后台管理界面,实现了侧边栏、顶部导航、数据表格、搜索、分页和表单弹窗等功能。
但是,目前的管理系统还缺少一个重要功能:用户登录与权限控制。
一个真正可用的后台管理系统,不能允许任何人直接访问管理页面,也不能让普通用户随意调用管理员接口。
本篇将结合 FastAPI 和 Vue 3,实现一套完整的身份认证与权限管理机制。
最终实现以下功能:
-
用户登录与退出登录
-
FastAPI 签发 JWT Token
-
后端验证 Token 有效性
-
Axios 自动携带 Token
-
Vue Router 路由守卫
-
登录状态持久化
-
管理员与普通用户权限区分
-
后端 API 权限验证
-
Token 过期处理
一、理解 JWT 身份认证机制
JWT(JSON Web Token)是一种常见的身份认证令牌格式。
用户登录成功后,后端生成一个 Token 并返回给前端。前端在后续请求中携带该 Token,后端验证令牌是否合法、是否过期,以及当前用户是否具有访问权限。
整个过程如下:
用户输入账号密码
|
v
Vue 3 发送登录请求
|
v
FastAPI 验证账号密码
|
v
生成 JWT Token
|
v
返回 Token 和用户信息
|
v
Vue 3 保存登录状态
|
v
访问受保护页面或 API
|
v
Axios 携带 Authorization
|
v
FastAPI 验证 JWT
|
v
检查用户权限
|
v
返回业务数据
JWT 通常由三部分组成:
Header.Payload.Signature
-
Header:描述令牌类型和签名算法。
-
Payload:保存用户标识、过期时间等声明。
-
Signature:用于验证令牌是否被篡改。
需要注意,JWT 的 Payload 通常只是经过 Base64URL 编码,并不是加密内容。因此,不应将密码、银行卡信息或其他敏感数据直接写入 JWT。
二、搭建后端认证环境
本篇继续使用 FastAPI,并使用以下依赖:
| 依赖 | 用途 |
|---|---|
| FastAPI | API 框架 |
| pwdlib[argon2] | 密码哈希与验证 |
| PyJWT | JWT 生成与验证 |
| python-multipart | 接收表单数据 |
| Pydantic | 请求数据验证 |
安装依赖:
pip install fastapi uvicorn "pwdlib[argon2]" PyJWT python-multipart
创建项目目录:
backend/
├── main.py
├── security.py
├── auth.py
├── dependencies.py
└── requirements.txt
其中:
-
security.py:密码哈希和 JWT 处理。 -
auth.py:登录接口。 -
dependencies.py:身份认证和权限依赖。 -
main.py:应用入口。
本篇为了集中讲解认证流程,使用内存用户数据演示。正式项目应将用户数据保存在数据库中。
三、配置 JWT 与密码安全
1. 生成 JWT 密钥
不要在代码中使用固定的弱密钥。
可以通过 Python 生成随机密钥:
python -c "import secrets; print(secrets.token_urlsafe(48))"
将生成的结果保存到服务器环境变量中。
Linux 示例:
export JWT_SECRET_KEY="替换成实际生成的随机密钥"
正式部署时,应通过 systemd 环境文件、密钥管理服务等方式保存密钥,而不是直接写在公开代码仓库中。
2. 编写 security.py
import os
from datetime import datetime, timedelta, timezone
import jwt
from pwdlib import PasswordHash
SECRET_KEY = os.environ["JWT_SECRET_KEY"]
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
password_hash = PasswordHash.recommended()
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(
plain_password: str,
hashed_password: str,
) -> bool:
return password_hash.verify(
plain_password,
hashed_password,
)
def create_access_token(
user_id: int,
role: str,
) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": str(user_id),
"role": role,
"iat": now,
"exp": now + timedelta(
minutes=ACCESS_TOKEN_EXPIRE_MINUTES
),
}
return jwt.encode(
payload,
SECRET_KEY,
algorithm=ALGORITHM,
)
def decode_access_token(token: str) -> dict:
return jwt.decode(
token,
SECRET_KEY,
algorithms=[ALGORITHM],
options={
"require": ["sub", "exp", "iat"],
},
)
这里主要实现了三个功能:
-
hash_password():对用户密码进行安全哈希。 -
verify_password():验证用户输入的密码。 -
create_access_token():生成 JWT。 -
decode_access_token():验证 JWT 并解析内容。
密码不能以明文形式保存到数据库中。
3. JWT 为什么需要过期时间?
如果一个 Token 永久有效,一旦泄露,攻击者就可能长期使用它访问系统。
因此,需要设置合理的有效期。
本篇设置:
ACCESS_TOKEN_EXPIRE_MINUTES = 30
即访问令牌有效期为 30 分钟。
正式系统还可以引入 Refresh Token,通过独立的续期接口获取新的 Access Token。
四、创建用户数据与登录接口
创建 auth.py:
from fastapi import APIRouter, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from security import (
create_access_token,
hash_password,
verify_password,
)
router = APIRouter(prefix="/auth", tags=["认证"])
# 演示用户,正式项目应使用数据库
users = {
"admin": {
"id": 1,
"username": "admin",
"password_hash": hash_password("Admin123456!"),
"role": "admin",
"is_active": True,
},
"editor": {
"id": 2,
"username": "editor",
"password_hash": hash_password("Editor123456!"),
"role": "editor",
"is_active": True,
},
}
@router.post("/login")
def login(
form_data: OAuth2PasswordRequestForm = Depends(),
):
user = users.get(form_data.username)
if (
user is None
or not verify_password(
form_data.password,
user["password_hash"],
)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
if not user["is_active"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="账号已停用",
)
token = create_access_token(
user_id=user["id"],
role=user["role"],
)
return {
"access_token": token,
"token_type": "bearer",
"user": {
"id": user["id"],
"username": user["username"],
"role": user["role"],
},
}
这里还需要在文件顶部导入 Depends:
from fastapi import APIRouter, Depends, HTTPException, status
登录接口使用 OAuth2PasswordRequestForm 接收表单格式的账号和密码,因此需要安装 python-multipart。
注意:示例中的账号密码仅用于本地教学测试,不能用于真实生产环境。正式项目应使用数据库用户表和安全的初始管理员创建流程。
五、实现用户身份验证
登录接口签发 Token 后,后端还需要在每次受保护请求中验证它。
创建 dependencies.py:
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from security import decode_access_token
from auth import users
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="/auth/login"
)
def get_current_user(
token: str = Depends(oauth2_scheme),
):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="登录状态无效或已过期",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_access_token(token)
user_id = int(payload["sub"])
except Exception:
raise credentials_exception
user = next(
(
item for item in users.values()
if item["id"] == user_id
),
None,
)
if user is None or not user["is_active"]:
raise credentials_exception
return user
这里使用 OAuth2PasswordBearer 从请求头中读取 Token。
前端请求时需要携带:
Authorization: Bearer JWT_TOKEN
后端验证成功后,返回当前用户对象。
这里有一个重要原则:
不要仅依赖 JWT 中的角色信息决定用户权限。
本例中 JWT 的角色字段只是令牌声明,实际权限仍应从数据库中读取当前用户的最新角色。这样管理员被降权或账号被停用后,不会因为旧 Token 中保存着原角色就继续拥有权限。
六、实现角色权限控制
实际后台系统通常不止一种用户角色。
例如:
| 角色 | 权限 |
|---|---|
| admin | 管理所有用户和系统设置 |
| editor | 管理内容,但不能修改系统权限 |
| viewer | 只能查看数据 |
可以使用依赖函数进行角色判断。
继续编辑 dependencies.py:
def require_roles(*allowed_roles):
def role_checker(
current_user=Depends(get_current_user),
):
if current_user["role"] not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="没有权限执行此操作",
)
return current_user
return role_checker
然后在 API 中使用:
from fastapi import APIRouter, Depends
from dependencies import (
get_current_user,
require_roles,
)
router = APIRouter(prefix="/api", tags=["管理接口"])
@router.get("/profile")
def get_profile(
user=Depends(get_current_user),
):
return {
"id": user["id"],
"username": user["username"],
"role": user["role"],
}
@router.get("/admin/users")
def admin_users(
user=Depends(require_roles("admin")),
):
return {
"message": "只有管理员可以访问",
}
@router.get("/editor/content")
def editor_content(
user=Depends(require_roles("admin", "editor")),
):
return {
"message": "管理员和编辑可以访问",
}
这样就实现了不同角色的 API 访问控制。
401 表示用户尚未通过身份认证,403 表示用户已经登录,但没有执行该操作的权限。
七、整合 FastAPI 应用
编辑 main.py:
from fastapi import FastAPI
from auth import router as auth_router
from dependencies import get_current_user
from fastapi import Depends
from fastapi.responses import JSONResponse
app = FastAPI(
title="管理系统 API",
version="1.0.0",
)
app.include_router(auth_router)
@app.get("/api/health")
def health():
return {"status": "ok"}
@app.get("/api/me")
def me(user=Depends(get_current_user)):
return {
"id": user["id"],
"username": user["username"],
"role": user["role"],
}
启动后端:
uvicorn main:app --reload --port 8000
现在可以通过 Swagger 文档测试登录接口:
http://127.0.0.1:8000/docs
注意:如果 auth.py 使用的是前面演示的内存用户字典,后端重启后仍然会重新初始化用户数据。
八、Vue 3 前端实现登录页面
后端已经能够签发 JWT,接下来需要在 Vue 3 中实现登录界面。
建议增加以下文件:
src/
├── api/
│ └── request.js
├── layouts/
│ └── AdminLayout.vue
├── router/
│ └── index.js
├── stores/
│ └── auth.js
├── views/
│ ├── Login.vue
│ ├── Dashboard.vue
│ └── UserList.vue
└── main.js
1. 创建登录状态管理
安装 Pinia:
npm install pinia
编辑 src/main.js:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(ElementPlus)
app.use(router)
app.mount('#app')
创建 src/stores/auth.js:
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('access_token') || '',
user: JSON.parse(
localStorage.getItem('user_info') || 'null'
),
}),
getters: {
isLoggedIn: state => Boolean(state.token),
role: state => state.user?.role || '',
},
actions: {
setLogin(data) {
this.token = data.access_token
this.user = data.user
localStorage.setItem(
'access_token',
this.token
)
localStorage.setItem(
'user_info',
JSON.stringify(this.user)
)
},
logout() {
this.token = ''
this.user = null
localStorage.removeItem('access_token')
localStorage.removeItem('user_info')
},
},
})
Pinia 用于集中管理登录状态,避免多个页面重复维护 Token 和用户信息。
这里使用 localStorage 保存访问令牌,方便刷新页面后恢复登录状态。
但需要注意,localStorage 可以被同源 JavaScript 读取。如果网站存在 XSS 漏洞,攻击者可能窃取 Token。安全要求较高的系统可以考虑使用 HttpOnly + Secure + SameSite Cookie,并配套 CSRF 防护。
2. 创建登录页面
创建 src/views/Login.vue:
<script setup>
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import request from '../api/request'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const loading = ref(false)
const form = reactive({
username: '',
password: '',
})
async function handleLogin() {
if (!form.username || !form.password) {
ElMessage.warning('请输入用户名和密码')
return
}
loading.value = true
try {
const params = new URLSearchParams()
params.append('username', form.username)
params.append('password', form.password)
const result = await request.post(
'/auth/login',
params,
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
)
authStore.setLogin(result)
ElMessage.success('登录成功')
const redirect = route.query.redirect
router.replace(
typeof redirect === 'string' &&
redirect.startsWith('/') &&
!redirect.startsWith('//')
? redirect
: '/dashboard'
)
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-page">
<el-card class="login-card">
<h2>管理系统登录</h2>
<el-form @submit.prevent="handleLogin">
<el-form-item>
<el-input
v-model="form.username"
placeholder="请输入用户名"
size="large"
/>
</el-form-item>
<el-form-item>
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
show-password
size="large"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-button
type="primary"
size="large"
class="login-button"
:loading="loading"
@click="handleLogin"
>
登录
</el-button>
</el-form>
</el-card>
</div>
</template>
<style scoped>
.login-page {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: #f2f5fa;
}
.login-card {
width: 380px;
max-width: calc(100vw - 32px);
}
.login-card h2 {
text-align: center;
margin-bottom: 30px;
}
.login-button {
width: 100%;
}
</style>
这里使用 URLSearchParams 发送表单数据,因为 FastAPI 的 OAuth2PasswordRequestForm 并不接收普通 JSON 格式的账号密码。
九、配置 Axios 自动携带 JWT
前端登录成功后,需要在后续请求中自动携带 Token。
编辑 src/api/request.js:
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '../stores/auth'
import router from '../router'
const request = axios.create({
baseURL: '/api',
timeout: 10000,
})
// 请求拦截器
request.interceptors.request.use(config => {
const authStore = useAuthStore()
if (authStore.token) {
config.headers.Authorization =
`Bearer ${authStore.token}`
}
return config
})
// 响应拦截器
request.interceptors.response.use(
response => response.data,
error => {
const status = error.response?.status
if (status === 401) {
const authStore = useAuthStore()
authStore.logout()
if (router.currentRoute.value.path !== '/login') {
router.replace({
path: '/login',
query: {
redirect: router.currentRoute.value.fullPath,
},
})
}
} else if (status === 403) {
ElMessage.error('没有权限访问该资源')
} else {
ElMessage.error(
error.response?.data?.detail ||
'请求失败,请稍后重试'
)
}
return Promise.reject(error)
}
)
export default request
现在,前端不需要在每个接口中重复填写 Authorization 请求头。
需要注意:响应拦截器中,只有 401 才应该清理登录状态。403 表示权限不足,不代表用户一定需要退出登录。
十、实现 Vue Router 路由守卫
登录认证不仅需要保护后端接口,也需要限制前端页面的访问。
创建或修改 src/router/index.js:
import {
createRouter,
createWebHistory,
} from 'vue-router'
import { useAuthStore } from '../stores/auth'
import AdminLayout from '../layouts/AdminLayout.vue'
import Login from '../views/Login.vue'
import Dashboard from '../views/Dashboard.vue'
import UserList from '../views/UserList.vue'
const routes = [
{
path: '/login',
component: Login,
meta: {
guest: true,
},
},
{
path: '/',
component: AdminLayout,
redirect: '/dashboard',
meta: {
requiresAuth: true,
},
children: [
{
path: 'dashboard',
component: Dashboard,
meta: {
title: '首页概览',
},
},
{
path: 'users',
component: UserList,
meta: {
title: '用户管理',
roles: ['admin'],
},
},
],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach(to => {
const authStore = useAuthStore()
if (
to.matched.some(record => record.meta.requiresAuth) &&
!authStore.isLoggedIn
) {
return {
path: '/login',
query: {
redirect: to.fullPath,
},
}
}
if (to.path === '/login' && authStore.isLoggedIn) {
return '/dashboard'
}
const requiredRoles = to.matched
.flatMap(record => record.meta.roles || [])
if (
requiredRoles.length &&
!requiredRoles.includes(authStore.role)
) {
return '/403'
}
return true
})
export default router
路由守卫主要完成三个任务:
-
未登录用户访问受保护页面时,跳转到登录页。
-
已登录用户访问登录页时,跳转到管理首页。
-
根据路由配置中的角色要求限制页面访问。
注意:这里使用本地保存的角色信息进行前端页面导航控制,只能改善交互体验,不能作为真正的安全边界。后端仍然必须独立检查用户权限。
还需要增加一个 403 页面,例如 src/views/Forbidden.vue:
<template>
<div style="text-align: center; padding: 100px">
<h1>403</h1>
<p>抱歉,您没有权限访问该页面。</p>
<router-link to="/dashboard">返回首页</router-link>
</div>
</template>
并在路由表中增加:
import Forbidden from '../views/Forbidden.vue'
{
path: '/403',
component: Forbidden,
}
十一、实现退出登录
在后台布局的顶部用户菜单中,可以增加退出登录操作。
例如:
<script setup>
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
const router = useRouter()
const authStore = useAuthStore()
function handleLogout() {
authStore.logout()
router.replace('/login')
}
</script>
<template>
<el-dropdown>
<span class="user-info">
{{ authStore.user?.username || '用户' }}
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleLogout">
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
退出登录时,前端清除本地保存的 Token 和用户信息,并跳转到登录页面。
不过,前端清除 Token 并不意味着服务端已经撤销该令牌。
如果业务要求用户退出后立即使令牌失效,还需要在后端增加 Token 撤销机制,例如维护令牌黑名单,或者采用可撤销的会话管理方案。
十二、测试完整登录流程
现在可以启动前后端进行联调。
启动 FastAPI:
uvicorn main:app --reload --port 8000
启动 Vue 3:
npm run dev
打开:
http://127.0.0.1:5173/login
使用本篇演示账号进行测试:
| 用户名 | 密码 | 角色 |
|---|---|---|
| admin | Admin123456! | admin |
| editor | Editor123456! | editor |
测试流程:
-
未登录时访问
/dashboard,应跳转到登录页面。 -
输入正确账号密码,完成登录。
-
登录成功后,自动跳转到原来想访问的页面。
-
访问
/api/me,检查当前用户信息。 -
使用 editor 登录,访问管理员专属的用户管理接口,应返回 403。
-
使用 admin 登录,访问管理员接口,应正常返回数据。
-
等待 Access Token 过期,再次请求受保护接口,应返回 401。
-
点击退出登录,检查是否清除了本地登录状态。
可以使用浏览器开发者工具的 Network 面板检查请求头,确认是否存在:
Authorization: Bearer eyJ...
这表示前端已经成功携带 JWT。
十三、生产环境还需要完善哪些功能?
本篇实现的是基础 JWT 身份认证和角色权限控制。正式上线还需要进一步完善以下内容。
1. 数据库用户管理
将演示用户字典替换为 MySQL 用户表,支持用户注册、账号停用、密码修改和角色管理。
2. Refresh Token 机制
Access Token 不宜长期有效。可以使用短期 Access Token 和较长期 Refresh Token,并实现安全的续期接口。
Refresh Token 应具有撤销能力,并考虑令牌轮换和重放检测。
3. Token 安全存储
不要认为 localStorage 是完全安全的。需要结合 XSS 防护评估存储方案。使用 Cookie 时,还需要考虑 CSRF 防护。
4. 密码安全
使用 Argon2 等密码哈希算法,不保存明文密码,不在日志中记录用户密码。
5. 登录防暴力破解
对登录接口实施请求频率限制,连续失败时可以采取验证码、临时锁定或其他风险控制措施。
6. 权限细分
角色权限之外,还可以设计资源级权限、操作权限和数据范围权限。
例如,普通编辑人员只能修改自己创建的内容。
7. Token 撤销
用户退出登录、修改密码或账号被禁用时,需要根据安全需求使现有令牌失效。
8. HTTPS
生产环境必须通过 HTTPS 传输登录凭据和访问令牌,避免令牌在传输过程中被窃取。
9. 密钥管理
JWT 签名密钥必须安全保存,并制定密钥轮换方案。不同环境不应共用同一密钥。
10. 安全审计
记录登录成功、登录失败、密码修改、角色变更和重要管理操作,方便追踪安全事件。
十四、总结
本篇完成了 Vue 3 + FastAPI 管理系统的基础身份认证与权限控制。
后端部分实现了:
-
密码哈希与验证
-
JWT Token 签发
-
JWT Token 解码验证
-
当前用户识别
-
角色权限检查
-
401 和 403 状态处理
前端部分实现了:
-
登录页面
-
Pinia 登录状态管理
-
Axios 自动携带 Token
-
Vue Router 路由守卫
-
页面角色限制
-
退出登录
通过前后端配合,我们完成了一套基本的登录认证流程。
需要牢记:前端路由守卫负责页面访问体验,后端权限校验才是真正的安全控制。 即使用户绕过 Vue 页面,直接使用 Postman、curl 或其他工具调用 API,后端也必须能够拒绝未授权请求。
- 点赞
- 收藏
- 关注作者
评论(0)