传统Spring MVC + RESTful 与 Vue3 结合 JWT Token 验证的示例
以下是针对非Spring Boot项目(传统Spring MVC)的示例
一、项目结构
src/
├── main/
│ ├── java/
│ │ └── com/
│ │ └── example/
│ │ ├── config/ # 配置类目录
│ │ │ ├── SecurityConfig.java
│ │ │ ├── WebMvcConfig.java
│ │ │ └── JwtFilter.java
│ │ ├── controller/ # 控制器
│ │ ├── service/ # 服务层
│ │ ├── util/ # 工具类
│ │ │ └── JwtUtil.java
│ │ └── model/ # 数据模型
│ └── resources/
│ ├── applicationContext.xml # XML配置(可选)
│ └── web.xml # Servlet配置
二、核心配置
1.引入相关依赖
<!-- Spring Security --><dependency><groupId>org.springframework</groupId><artifactId>spring-webmvc</artifactId><version>5.3.30</version></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-web</artifactId><version>5.7.1</version></dependency><dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-config</artifactId><version>5.7.1</version></dependency><!-- JWT Token --><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version></dependency>
2. web.xml 配置(传统部署方式)
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"version="4.0"><!--配置DispatcherServlet--><servlet><servlet-name>springmvc</servlet-name><servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class><init-param><param-name>contextConfigLocation</param-name><param-value>classpath:applicationContext.xml</param-value></init-param></servlet><servlet-mapping><servlet-name>springmvc</servlet-name><url-pattern>/</url-pattern></servlet-mapping> <!--Spring Security 过滤器--><filter><filter-name>springSecurityFilterChain</filter-name><filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class></filter><filter-mapping><filter-name>springSecurityFilterChain</filter-name><url-pattern>/*</url-pattern></filter-mapping>
</web-app>
3. Spring Security Java 配置(替代XML)
@Configuration
@EnableWebSecurity
public class SecurityConfig {@Autowiredprivate JwtAuthenticationFilter jwtAuthFilter;@Beanpublic SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {http.cors().and() // 显式启用CORS配置.csrf().disable().authorizeHttpRequests(auth -> auth.requestMatchers(new AntPathRequestMatcher(HttpMethod.OPTIONS.name())).permitAll()// 允许所有OPTIONS请求.requestMatchers(new AntPathRequestMatcher("/login")).permitAll() // 允许登录接口公开.requestMatchers(new AntPathRequestMatcher("/register")).permitAll() // 允许注册接口公开.requestMatchers(new AntPathRequestMatcher("/captcha")).permitAll() // 允许验证码接口公开.anyRequest().authenticated() // 其他接口需要认证).sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);return http.build();}
}
4. JWT 过滤器(适配传统项目)
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {@Autowiredprivate JwtUtil jwtUtil;@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws IOException, ServletException {String authHeader = request.getHeader("Authorization");String token = null;String username = null;// 直接放行 OPTIONS(预检) 请求(restful风格接口必须放行此处)if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {chain.doFilter(request, response);return;}// 从请求头提取 Token(格式:Bearer <token>)if (authHeader != null && authHeader.startsWith("Bearer ")) {token = authHeader.substring(7);try {username = jwtUtil.extractUsername(token);} catch (Exception e) {// Token 解析失败直接拦截sendUnauthorized(response);return;}}// 如果 Token 有效且用户未认证,则设置认证信息// 验证 Token 有效性if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {if (!jwtUtil.validateToken(token)) {sendUnauthorized(response);return;}// 创建认证信息UsernamePasswordAuthenticationToken authentication =new UsernamePasswordAuthenticationToken(username, null, new ArrayList<>());SecurityContextHolder.getContext().setAuthentication(authentication);}// 关键拦截点:已通过过滤器但仍未认证的请求if (isProtectedPath(request.getRequestURI()) &&SecurityContextHolder.getContext().getAuthentication() == null) {sendUnauthorized(response);return;}chain.doFilter(request, response);}private boolean isProtectedPath(String path) {return !path.startsWith("/login")&& !path.startsWith("/register")&& !path.startsWith("/captcha");}private void sendUnauthorized(HttpServletResponse response) throws IOException {response.setStatus(HttpStatus.UNAUTHORIZED.value());response.getWriter().write("Unauthorized - Missing or invalid token");}
}
三、后端对接要点
1. 跨域配置(CORS)
(1)、方式1:WebMvcConfig
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {@Overridepublic void addCorsMappings(CorsRegistry registry) {registry.addMapping("/api/**").allowedOrigins("http://localhost:3000").allowedMethods("GET", "POST", "PUT", "DELETE").allowedHeaders("*").allowCredentials(true);}
}
(2)、方式2:applicationConfig.xml配置方式
<!-- 配置全局跨域 :注意该标签需放在mvc:annotation-driven标签前面--><mvc:cors><mvc:mapping path="/**"allowed-origins="http://localhost:5173, https://example.com"allowed-methods="GET, POST, PUT, DELETE, OPTIONS"allowed-headers="Content-Type, Authorization"allow-credentials="true"max-age="3600"/></mvc:cors>
2. 登录接口示例
@RestController
@RequestMapping("/api/auth")
public class AuthController {@Autowiredprivate AuthenticationManager authenticationManager;@Autowiredprivate JwtUtil jwtUtil;@Autowiredprivate JwtUserDetailsService userDetailsService;@PostMapping("/login")public ResponseEntity<?> createAuthenticationToken(@RequestBody JwtRequest authenticationRequest) throws Exception {authenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword());final UserDetails userDetails = userDetailsService.loadUserByUsername(authenticationRequest.getUsername());final String jwt = jwtUtil.generateToken(userDetails);return ResponseEntity.ok(new JwtResponse(jwt));}private void authenticate(String username, String password) throws Exception {try {authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));} catch (BadCredentialsException e) {throw new Exception("INVALID_CREDENTIALS", e);}}
}
四、前端代码示例
1、封装axios
在请求拦截器中获取保存在localStorage或store中的Token并添加到请求头中
// src/utils/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '@/router'
import useUserStore from '@/store'
import config from '@/config'const userStore = useUserStore()
axios.defaults.withCredentials = true; // 全局配置携带凭证(必须配置,否则因为跨域请求默认不携带 Cookie,会导致每次请求生成新 Session)
const service = axios.create({
baseURL:config.baseURL,timeout: 10000,headers: {'Content-Type': 'application/json;charset=UTF-8'}
})// 请求拦截器
service.interceptors.request.use(config => {const token = userStore.getToken()if (token) {config.headers.Authorization = `Bearer ${token}`}return config},error => {return Promise.reject(error)}
)// 响应拦截器
service.interceptors.response.use(response => {const res = response.dataif (res.code !== 200) {if (res.code === 401) {console.log("401")handleLogout()}showErrorToast(res.message || '请求失败')return Promise.reject(new Error(res.message || 'Error'))}return res.data},error => {const status = error.response?.statusconst messageMap = {400: '请求错误',401: '未授权,请重新登录',403: '拒绝访问',404: '资源不存在',500: '服务器错误',502: '网关错误',503: '服务不可用',504: '网关超时'}const errorMessage = messageMap[status] || error.messageshowErrorToast(errorMessage)if (status === 401) {handleLogout()}return Promise.reject(error)}
)// 封装GET请求
export function get(url, params = {}, config = {}) {return service.get(url, { params, ...config })
}// 封装POST请求
export function post(url, data = {}, config = {}) {return service.post(url, data, config)
}// 封装带文件上传的POST请求
export function postFile(url, data = {}, config = {}) {return service.post(url, data, {headers: {'Content-Type': 'multipart/form-data'},...config})
}// 错误提示
function showErrorToast(message) {ElMessage({type: 'error',message,duration: 3000})
}// 处理登出逻辑
function handleLogout() {store.dispatch('user/logout')router.push('/login')
}export default service
2、登录
将获取到的Token保存在localStorage或store中,下述代码是保存在pinia store中
//登录成功showSuccessToast(result.msg);// 关键:使用 Pinia 存储 tokenuserStore.login(result.token, { username: username.value })router.push("/home")
3、访问受保护的接口
因为封装好的axios在发送请求时会自动携带Token,所以访问受保护的接口时无需再额外处理
五、常见问题处理
1. 403 Forbidden 错误
// 检查是否配置了CSRF保护(REST API通常需要禁用)
http.csrf().disable()
2. Token 不生效
// 确保请求头包含:
Authorization: Bearer <your_token>// 检查CORS配置是否允许Authorization头
.allowedHeaders("Authorization", "Content-Type")
3. 用户认证失败
// 检查UserDetailsService实现
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {// 必须从数据库或其他存储中加载用户信息
}
六、注意事项
因为跨域请求会提前发送一个Request Method为OPTIONS的预检请求,而此请求是浏览器自动发送的,不会携带Token,所以后端必须放行所有预检请求才行。