第一阶段:奠定基石(后台管理与数据库)
1.7 JWT 身份认证 (Req 2, 10)
任务书要求: 不同级别用户在登陆后的操作权限不同,解决安全设计问题。
对你们的要求: 大概理解逻辑,照着代码实现。
给零基础同学的话:
JWT (JSON Web Token) 就是系统的“电子身份证”或“令牌”。
以前大家去游乐园,进门买票。现在我们的系统也一样:
登录 (Login): 用户输入账号密码,如果正确,系统发给他一张 JWT 令牌 (一串很长的乱码字符串)。
访问 (Access): 用户在系统里做的任何操作(查数据、删人),都必须在请求头里带上这张令牌。
校验 (Verify): 后端有一个“保安”(拦截器),会检查每一张令牌。如果是假的或者过期的,直接踢出。
1. JWT 介绍
-
全称: JSON Web Token (官网:https://jwt.io/)
-
特点:
-
简洁: 就是一个字符串,方便在网络传输。
-
自包含: 令牌里可以存自定义数据(比如你的 ID、名字、角色),后端拿到令牌解密后就能知道你是谁,不用每次都去查数据库。
-
-
组成:
Header(头) .Payload(有效载荷/数据) .Signature(签名,防篡改)
2. JWT 的生成 (后端实现)
Step 1: 引入依赖
在 pom.xml 中添加 JWT 的工具包:
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
Step 2: 引入 JWT 工具类
在 com.xhu.headline_server.utils 包下创建 JwtUtils.java,负责生成和解析令牌。
package com.xhu.headline_server.utils;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
import java.util.Map;
public class JwtUtils {
private static String signKey = "SVRIRUlNQQ=="; // 签名密钥,绝密!
private static Long expire = 43200000L; // 过期时间 12小时
/** * 生成JWT令牌
* @return
*/
public static String generateJwt(Map<String,Object> claims){
String jwt = Jwts.builder()
.addClaims(claims)
.signWith(SignatureAlgorithm.HS256, signKey)
.setExpiration(new Date(System.currentTimeMillis() + expire))
.compact();
return jwt;
}
/** * 解析JWT令牌
* @param jwt JWT令牌
* @return JWT第二部分负载 payload 中存储的内容
*/
public static Claims parseJWT(String jwt){
Claims claims = Jwts.parser()
.setSigningKey(signKey)
.parseClaimsJws(jwt)
.getBody();
return claims;
}
}
Step 3: 完善 LoginController
在 controller 包下(或 controller.admin)创建或修改 LoginController。
(注:你需要先创建 UserDTO 和 LoginInfo 类,用来接收前端参数和返回结果)
@PostMapping("/admin/login")
public Map<String, Object> login(@RequestBody UserDTO userDTO) {
log.info("登录请求: {}", userDTO);
String username = userDTO.getUserName();
String password = userDTO.getPassword();
// 简单验证用户名和密码
LoginInfo flag = userService.login(username, password);
Map<String, Object> res = new HashMap<>();
if (flag == null) {
res.put("code", 0);
res.put("message", "用户名或密码错误");
return res;
}
res.put("code", 1);
res.put("data", flag);
return res;
}
Step 4: 完善 Service 登录逻辑
在 UserServiceImpl 中实现 login 方法。登录成功后,生成令牌并返回。
@Override
public LoginInfo login(String username, String password) {
// 1. 调用 mapper 查询用户
User user = userMapper.selectNameAndPassword(username, password);
if (user == null) {
// 登录失败:账号或密码错误
return null;
}
int grade = user.getRole();
if (grade != 0 && grade != 1) {
// 仅允许管理员和员工登录后台管理系统
return null;
}
// 2. 组装 JWT 负载数据
Map<String, Object> claims = new HashMap<>();
claims.put("userId", user.getId());
claims.put("userName", user.getUserName());
claims.put("role", user.getRole());
// 3. 使用 JwtUtils 生成 JWT 令牌
String token = JwtUtils.generateJwt(claims);
// 4. 封装 LoginInfo 返回
LoginInfo loginInfo = new LoginInfo();
loginInfo.setUserId(user.getId());
loginInfo.setUserName(user.getUserName());
loginInfo.setPassword(user.getPassword());
loginInfo.setRole(user.getRole());
loginInfo.setToken(token);
System.out.println("生成的 token: " + token);
return loginInfo;
}
3. JWT 校验 (拦截器)
原理: 我们需要一个“保安”(拦截器 Interceptor)。除了登录接口,其他所有请求都要检查是否带着合法的 Token。
Step 1: 定义拦截器实现类
在 Interceptor 包下新建 TokenInterceptor.java。
package com.xhu.headline_server.Interceptor;
import com.xhu.headline_server.utils.JwtUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
@Slf4j
@Component
public class TokenInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 1. 获取请求 url String url = request.getRequestURL().toString();
// 2. 登录接口直接放行
if (url.contains("/admin/login")) {
log.info("登录请求,直接放行: {}", url);
return true;
}
// 3. 从请求头获取 token String jwt = request.getHeader("token");
// 4. 判断 token 是否为空
if (!StringUtils.hasLength(jwt)) {
log.info("请求未携带 token,拒绝访问");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
// 5. 解析 token try {
JwtUtils.parseJWT(jwt);
} catch (Exception e) {
log.info("token 解析失败,拒绝访问", e);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
// 6. 放行
return true;
}
}
Step 2: 注册拦截器1
在 config 包下新建 WebConfig.java,告诉 SpringBoot 启用这个拦截器。2
Java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private TokenInterceptor tokenInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tokenInterceptor)
.addPathPatterns("/**") // 拦截所有
.excludePathPatterns(
"/admin/login", // 登录接口放行
"/", // 首页
"/index.html", // 前端入口
"/static/**", // 静态资源
"/assets/**", // Vite `/assets` 等
"/favicon.ico"
);
}
}
1.8 前端完善:登录与权限
目标: 后端接口有了,前端需要实现登录页面,并在每次请求时自动带上 Token。
1. 配置路由逻辑 (Router)
修改 src/router/index.ts。增加“路由守卫”,没登录的人强制踢回登录页。
import { createRouter, createWebHistory } from 'vue-router'
import LayoutView from '@/views/layout/index.vue'
import UserView from '@/views/user/index.vue'
import LoginView from '@/views/Login/index.vue' // 确保你创建了这个文件
const routes = [
// 登录页:单独显示,不在 Layout 下
{
path: '/login',
name: 'login',
component: LoginView,
},
// 需要登录的业务页面放在 Layout 下
{
path: '/',
component: LayoutView,
redirect: '/user',
children: [{ path: 'user', name: 'user', component: UserView }],
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 未登录时只允许访问 /login
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('auth_token')
console.log('[router.beforeEach] to:', to.path, 'token:', token)
// 未登录:只放行 /login,其余全部重定向到 /login
if (!token) {
if (to.path !== '/login') {
return next({ path: '/login', replace: true })
}
return next()
}
// 已登录:访问 /login 时重定向到用户管理页,其余正常放行
if (to.path === '/login') {
return next({ path: '/user', replace: true })
}
return next()
})
export default router
2. 拦截登录请求 (Axios)
修改 src/api/http.ts (或者你定义的 axios 配置文件)。让每次点击按钮发请求时,自动把 Token 塞进请求头。
import axios from 'axios'
// 创建一个 axios 实例,统一管理所有接口请求
const http = axios.create({
// 所有请求都会以 /api 开头,通过 Vite 代理转发到后端 http://localhost:7111
baseURL: '/api',
timeout: 10000,
})
// 请求拦截器:在每次请求前,自动从 localStorage 读取 token,放到请求头中
http.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers = config.headers || {}
config.headers.token = token
}
return config
},
(error) => Promise.reject(error),
)
http.interceptors.response.use(
(resp) => resp.data,
(error) => Promise.reject(error),
)
export default http
3. 编写前端登录页
在 src/views/Login/index.vue (新建目录和文件) 中粘贴以下代码。使用了 Inspira UI 的炫酷组件。
<template>
<AuroraBackground class="bg-wrapper">
<div class="login-overlay">
<div class="login-panel">
<el-card class="login-card" shadow="always">
<h3 class="title">系统登录</h3>
<el-form :model="form" :rules="rules" ref="formRef" label-position="left" label-width="0">
<el-form-item prop="userName" class="form-item-vanish">
<VanishingInput
v-model="form.userName"
:placeholders="namePlaceholders"
@submit="onVanishingSubmitName"
/>
</el-form-item>
<el-form-item prop="password" class="form-item-vanish">
<VanishingInput
v-model="form.password"
:placeholders="passwordPlaceholders"
@submit="onVanishingSubmitPassword"
/>
</el-form-item>
<el-form-item>
<el-checkbox v-model="form.remember">记住我</el-checkbox>
</el-form-item>
<el-form-item>
<div class="btn-wrap">
<RainbowButton @click="onSubmit">登录</RainbowButton>
</div>
</el-form-item>
</el-form>
</el-card>
</div>
</div>
</AuroraBackground>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
// 确保这些组件已经正确安装在 components 目录下
import AuroraBackground from '@/components/AuroraBackground.vue'
import VanishingInput from '@/components/inspira/VanishingInput.vue'
import RainbowButton from '@/components/inspira/RainbowButton.vue'
const router = useRouter()
const route = useRoute()
const formRef = ref()
// 登录表单数据
const form = reactive({
userName: '',
password: '',
remember: false,
})
// 基本校验规则
const rules = {
userName: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
// VanishingInput 占位内容
const namePlaceholders = ['请输入用户名', '示例:alice']
const passwordPlaceholders = ['请输入密码', '长度建议 8+ 位', '区分大小写']
// VanishingInput 提交时同步到 form
function onVanishingSubmitName(val: string) {
form.userName = val
}
function onVanishingSubmitPassword(val: string) {
form.password = val
}
// 点击登录按钮
const onSubmit = async () => {
try {
const resp = await fetch('/api/admin/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userName: form.userName,
password: form.password,
}),
})
const json = await resp.json()
console.log('login response:', json)
if (json.code != 1 || !json.data?.token) {
ElMessage.error(json.message || '用户名或密码错误')
return
}
const loginInfo = json.data
const roleNumber = typeof loginInfo.role === 'string' ? Number(loginInfo.role) : loginInfo.role
localStorage.setItem('auth_token', loginInfo.token)
localStorage.setItem(
'login_user',
JSON.stringify({
userId: loginInfo.userId,
userName: loginInfo.userName,
role: roleNumber,
avatarUrl: loginInfo.avatarUrl || '',
}),
)
console.log('after setItem auth_token:', localStorage.getItem('auth_token'))
ElMessage.success('登录成功')
const redirect = (route.query.redirect as string) || '/user'
router.replace(redirect)
} catch (err) {
console.error(err)
ElMessage.error('登录失败,请检查网络或服务器')
}
}
</script>
<style scoped>
/* 背景容器,保持背景组件充满视口 */
.bg-wrapper {
position: relative;
min-height: 100vh;
display: block;
overflow: hidden;
}
/* 覆盖层:使登录面板在背景正中央 */
.login-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
/* 登录面板允许交互 */
.login-panel {
width: 460px;
z-index: 10;
pointer-events: auto;
}
/* 卡片:圆角矩形 */
.login-card {
padding: 28px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.96);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
border: 1px solid rgba(0, 0, 0, 0.06);
color: #222;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
align-items: stretch;
}
/* 标题:黑色且居中 */
.title {
margin: 0 0 16px 0;
font-size: 22px;
text-align: center;
color: #000000;
font-weight: 600;
}
/* VanishingInput 容器让两个输入外观一致 */
.form-item-vanish {
margin-bottom: 14px;
}
/* 若 VanishingInput 内部需要填充样式,可在这里调整 wrapper */
.form-item-vanish > * {
width: 100%;
display: block;
}
/* 保证按钮有合理宽度 */
.btn-wrap ::v-deep button,
.btn-wrap ::v-deep .rainbow-button {
min-width: 220px;
border-radius: 8px;
display: block;
}
/* 窗口窄时折叠布局:隐藏背景交互,表单仍居中 */
@media (max-width: 1000px) {
.bg-wrapper {
min-height: auto;
}
.login-overlay {
position: static;
padding: 24px 0;
}
.login-panel {
width: 92%;
max-width: 460px;
margin: 0 auto;
}
.login-card {
background: rgba(255, 255, 255, 0.98);
}
}
</style>
**4. 优化 User 类逻辑
任务: 管理员可以修改数据,员工仅能进行查询。
打开 src/views/user/index.vue,在 <script> 和 <template> 中分别添加/修改以下代码:
修改 Script 部分:
(添加 props 定义)
<script setup lang="ts">
// ... 其他 import ...
const props = defineProps<{
isAdmin: boolean
}>()
// ... 其他代码 ...
</script>
修改 Template 部分 (操作列):
(使用 v-if=“props.isAdmin” 来控制按钮的显示)
<el-table-column label="操作" width="120">
<template #default="{ row }">
<div class="action-buttons">
<el-button v-if="props.isAdmin" size="small" type="primary" @click="updateUser(row)">
<el-icon><EditPen /></el-icon>
</el-button>
<el-button v-if="props.isAdmin" size="small" type="danger" @click="delUser(row)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</template>
</el-table-column>