以下是针对非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 {
@Autowired
private JwtAuthenticationFilter jwtAuthFilter;
@Bean
public 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 {
@Autowired
private JwtUtil jwtUtil;
@Override
protected 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 {
@Override
public 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 {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private 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.data
if (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?.status
const messageMap = {
400: '请求错误',
401: '未授权,请重新登录',
403: '拒绝访问',
404: '资源不存在',
500: '服务器错误',
502: '网关错误',
503: '服务不可用',
504: '网关超时'
}
const errorMessage = messageMap[status] || error.message
showErrorToast(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 存储 token
userStore.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,所以后端必须放行所有预检请求才行。