前言
在前后端分离的开发模式下,传统的 Session 认证方式已逐渐被 Token 认证取代。JWT(JSON Web Token)因其无状态、跨域友好、易于扩展等特点,成为 RESTful API 最流行的认证方案。本周我从零开始,在 Spring Boot 项目中集成了 Spring Security 和 JWT,并初始化了 Vue3 + Vite 前端项目,为前后端联调做好了准备。本文将详细记录这一过程,包括每一步的实现思路、关键代码和踩坑记录。
技术栈
-
后端:Spring Boot 3.x, Spring Security 7.x, JJWT 0.12.5, MySQL, JPA
-
前端:Vue3, Vite, axios, Vue Router 4
-
工具:IntelliJ IDEA, Postman, MySQL Workbench
整体流程
-
注册:用户提供用户名和密码,密码经 BCrypt 加密后存入数据库,同时分配默认角色(USER)。
-
登录:用户提交用户名和密码,Spring Security 的 AuthenticationManager 验证凭据,成功后生成 JWT Token 返回给客户端。
-
后续请求 :客户端在 HTTP 请求头中携带
Authorization: Bearer <token>。 -
JWT 过滤器:Spring Security 的过滤器链中,自定义的 JwtAuthenticationFilter 拦截每个请求,从请求头提取 Token,验证签名和有效期,若有效则将用户信息(含角色)设置到 SecurityContextHolder 中。
-
授权检查 :Spring Security 根据 Controller 方法上的
@PreAuthorize注解,判断当前用户是否拥有所需角色,若无则返回 403 Forbidden。 -
前端 Vue3项目通过 axios 拦截器自动携带 Token,并通过路由守卫保护需要登录的页面。
一、后端实现
1.1 添加依赖
在 pom.xml中添加 Spring Security 和 JWT 依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
说明:
-
spring-boot-starter-security会自动配置 Spring Security 的基础设施。 -
JJWT 是 Java 中广泛使用的 JWT 库,
jjwt-api是接口,jjwt-impl是实现,jjwt-jackson用于 JSON 序列化/反序列化。
添加后点击 IDEA 右下角的 Load Maven Changes 下载依赖。
1.2 创建用户实体与角色枚举
1.2 .1 Role 枚举 (com.example.backend.model.Role):
public enum Role {
USER,
ADMIN
}
注意: 枚举常量名称必须是大写,因为 Spring Security 的 hasRole()会自动添加 ROLE_前缀,所以数据库中存储的也应该是大写 USER或 ADMIN。
1.2 .2 创建 User 实体(实现 UserDetails)
UserDetails是 Spring Security 提供的接口,用于封装用户信息。我们让 User实体直接实现该接口,简化代码。
package com.example.backend.model;
import jakarta.persistence.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
@Entity
@Table(name = "users")
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String username;
@Column(nullable = false)
private String password;
@Enumerated(EnumType.STRING)
private Role role;
// 无参构造
public User() {}
// 全参构造
public User(Long id, String username, String password, Role role) {
this.id = id;
this.username = username;
this.password = password;
this.role = role;
}
// --- UserDetails 接口方法 让 User 实体直接充当 Spring Security 的用户对象,简化代码---
// 将 Role枚举转换为 ROLE_USER或 ROLE_ADMIN格式的权限
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 将角色转换为 GrantedAuthority
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() {
return true; // 账户未过期
}
@Override
public boolean isAccountNonLocked() {
return true; // 账户未锁定
}
@Override
public boolean isCredentialsNonExpired() {
return true; // 凭证未过期
}
@Override
public boolean isEnabled() {
return true; // 账户启用
}
// --- Getter 和 Setter ---
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public void setUsername(String username) { this.username = username; }
public void setPassword(String password) { this.password = password; }
public Role getRole() { return role; }
public void setRole(Role role) { this.role = role; }
}
关键点解释:
-
@Enumerated(EnumType.STRING):将枚举以字符串形式存入数据库(例如"USER"),而不是默认的数字序号,便于阅读和调试。 -
getAuthorities():返回一个包含单个SimpleGrantedAuthority的列表,其值为"ROLE_USER"或"ROLE_ADMIN"。Spring Security 的hasRole('USER')会自动添加ROLE_前缀,因此两者匹配。 -
实现
UserDetails后,Spring Security 可以直接使用该对象进行认证和授权。
1.2.3 UserRepository:
package com.example.backend.repository;
import com.example.backend.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// 根据用户名查询用户(用于登录认证)
Optional<User> findByUsername(String username);
// 判断用户名是否已存在(用于注册校验)
boolean existsByUsername(String username);
}
说明:
-
findByUsername返回Optional,优雅地处理用户不存在的情况。 -
existsByUsername返回布尔值,用于注册时检查用户名唯一性。
1.3 加载用户 ------ CustomUserDetailsService
Spring Security 在进行认证时,需要知道如何从数据库加载用户信息。我们需要实现 UserDetailsService接口。
// 从数据库加载用户信息,配置 Spring Security 的安全策略(密码编码、放行路径、禁用 CSRF 等)
package com.example.backend.service;
import com.example.backend.model.User;
import com.example.backend.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
// UsernameNotFoundException: 查到用户就返回,查不到就抛异常
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在: " + username));
return user; // User 本身实现了 UserDetails,直接返回
}
}
工作原理:
-
当用户登录时,Spring Security 的
DaoAuthenticationProvider会调用此方法获取用户信息。 -
然后它会用
PasswordEncoder比对用户提交的密码与数据库中存储的加密密码是否匹配。 -
如果匹配,认证成功;否则抛出
BadCredentialsException。
1.4 安全配置 ------ SecurityConfig
这是 Spring Security 的核心配置类。我们需要配置密码编码器、认证管理器、请求授权规则,并注册 JWT 过滤器。
package com.example.backend.config;
import com.example.backend.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
// 标记为配置类
@Configuration
//启用 Spring Security 的 Web 安全支持
@EnableWebSecurity
// 启用方法安全(@PreAuthorize 等注解),允许我们在 Controller 的方法上使用 @PreAuthorize、@PostAuthorize等注解进行细粒度权限控制
@EnableMethodSecurity
public class SecurityConfig {
@Autowired
private CustomUserDetailsService customUserDetailsService;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter; // 注入 JWT 过滤器
//密码编码器,用于加密和验证密码
@Bean
public PasswordEncoder passwordEncoder() {
//Spring Security 推荐的密码哈希算法
return new BCryptPasswordEncoder();
}
// 认证管理器(用于后续登录接口)
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
//定义安全过滤链,配置请求授权规则
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// ★ 新增:启用 CORS 支持,并指定配置来源
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 禁用 CSRF(前后端分离项目不需要)
.csrf(csrf -> csrf.disable())
// 设置会话管理为无状态(使用 JWT,不需要 Session)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 配置请求授权规则
.authorizeHttpRequests(auth -> auth
// 1. 放行注册和登录接口
.requestMatchers("/api/auth/**").permitAll()
// 2. 【新增】放行书籍相关的业务接口
// .requestMatchers("/api/books/**").permitAll()
// 3. 其他所有请求都需要认证
.anyRequest().authenticated()
)
// 设置自定义 UserDetailsService
.userDetailsService(customUserDetailsService)
// ★ 在 UsernamePasswordAuthenticationFilter 之前添加 JWT 过滤器
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// ★ 新增:定义 CORS 规则
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 允许来自任何源的请求(开发阶段使用 *,生产环境建议指定具体域名)
configuration.setAllowedOriginPatterns(Arrays.asList("*"));
// 允许的 HTTP 方法(必须包含 OPTIONS,因为浏览器预检请求会发 OPTIONS)
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
// 允许的请求头
configuration.setAllowedHeaders(Arrays.asList("*"));
// 允许携带凭证(如 Cookie,虽然 JWT 不常用,但保持兼容)
configuration.setAllowCredentials(true);
// 预检请求的有效期(秒),避免频繁发送 OPTIONS
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// 对所有路径应用此 CORS 配置
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
配置说明:
-
.cors():启用 CORS 并指定自定义规则,解决前端跨域问题。 -
.csrf().disable():前后端分离项目禁用 CSRF。 -
SessionCreationPolicy.STATELESS:无状态会话,使用 JWT。 -
requestMatchers("/api/auth/**").permitAll():放行注册登录接口。 -
addFilterBefore():将 JWT 过滤器置于默认登录过滤器之前。
1.5 编写 JWT 工具类
JWT 工具类负责生成 Token、解析 Token 和验证 Token。
1.5.1 创建 JwtUtil 类
package com.example.backend.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private Long expiration;
/**
* 将字符串密钥转换为 HMAC-SHA 算法所需的 Key 对象。
*/
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
/**
* 生成 JWT Token。
* @param username 用户名
* @param role 角色(如 "USER" 或 "ADMIN")
* @return 签名的 Token 字符串
*/
public String generateToken(String username, String role) {
Map<String, Object> claims = new HashMap<>();
claims.put("role", role); // 将角色放入自定义声明
return Jwts.builder()
.claims(claims) // 自定义数据
.subject(username) // 主题(用户名)
.issuedAt(new Date()) // 签发时间
.expiration(new Date(System.currentTimeMillis() + expiration)) // 过期时间
.signWith(getSigningKey()) // 使用密钥签名
.compact(); // 生成紧凑的 Token 字符串
}
/**
* 解析 Token,返回 Claims(包含所有声明)。
* 如果签名无效或 Token 格式错误,会抛出异常。
*/
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(getSigningKey()) // 设置验证密钥
.build()
.parseSignedClaims(token) // 解析并验证签名
.getPayload(); // 获取 Payload 部分
}
/**
* 从 Token 中提取用户名。
*/
public String getUsernameFromToken(String token) {
return parseToken(token).getSubject();
}
/**
* 从 Token 中提取角色。
*/
public String getRoleFromToken(String token) {
return parseToken(token).get("role", String.class);
}
/**
* 验证 Token 是否有效(签名正确且未过期)。
*/
public boolean validateToken(String token) {
try {
Claims claims = parseToken(token);
// 检查过期时间是否在当前时间之后
return !claims.getExpiration().before(new Date());
} catch (Exception e) {
// 任何解析异常都视为无效 Token
return false;
}
}
}
常见问题:
-
签名不匹配 :通常是因为项目重启后密钥改变,或者密钥长度不足。确保
application.yml中的secret是固定的且足够长。 -
Token 过期 :
validateToken会检查过期时间,过期后需要重新登录。
1.5.2 application.yml 配置:
jwt:
secret: MySuperSecretKeyForJWTTokenGeneration2026VeryLongString
expiration: 86400000 # 24小时
重要:
-
secret至少 32 个字符,越长越安全。切勿泄漏,否则任何人都可以伪造 Token。 -
expiration根据业务需求设置。24 小时对于大多数应用是合理的,过短会导致频繁登录,过长会增加安全风险。
1.6 编写 JWT 认证过滤器(JwtAuthenticationFilter.java)
过滤器是整个认证流程的核心。它拦截每个请求,尝试从请求头中提取 Token,验证后设置认证信息。
package com.example.backend.config;
import com.example.backend.model.User;
import com.example.backend.repository.UserRepository;
import com.example.backend.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component // 告诉 Spring:这是一个组件,请管理它
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil; // 注入 JWT 工具类
@Autowired
private UserRepository userRepository; // 注入用户 Repository
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 1. 从请求头中获取 Authorization 字段
String authHeader = request.getHeader("Authorization");
// 2. 检查 Authorization 是否存在且以 "Bearer " 开头
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
// 没有 Token,直接放行(后续会被 SecurityConfig 拦截)
filterChain.doFilter(request, response);
return;
}
// 3. 提取 Token(去掉 "Bearer " 前缀)
String token = authHeader.substring(7);
// 4. 从 Token 中提取用户名
String username = jwtUtil.getUsernameFromToken(token);
// 5. 检查用户名不为空,且当前还没有认证信息
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 6. 验证 Token 是否有效
if (jwtUtil.validateToken(token)) {
// 7. 从数据库查询用户信息
User user = userRepository.findByUsername(username).orElse(null);
if (user != null) {
// 8. 创建认证令牌(包含用户信息和权限)
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
user, // 用户对象
null, // 凭证(密码),这里不需要
user.getAuthorities() // 权限列表(角色)
);
// 9. 设置请求详情(IP、Session ID 等)
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// 10. 将认证信息设置到安全上下文中
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
}
// 11. 放行请求,继续执行后续的过滤器或 Controller
filterChain.doFilter(request, response);
}
}
关键点解释:
| 步骤 | 代码 | 说明 |
|---|---|---|
| 1-2 | 获取 Authorization 头 | 标准格式为 Bearer <token>,如果不是则以游客身份继续 |
| 3 | authHeader.substring(7) |
去掉 "Bearer "前缀(7个字符),得到纯 Token |
| 4 | jwtUtil.getUsernameFromToken(token) |
解析 Token 提取用户名,如果 Token 无效会抛出异常,被外层 catch 捕获 |
| 5 | SecurityContextHolder.getContext().getAuthentication() == null |
避免重复认证(例如同一个请求经过多次过滤器) |
| 6 | jwtUtil.validateToken(token) |
验证签名和有效期 |
| 7 | userRepository.findByUsername(username) |
从数据库获取最新用户信息(如果用户已被删除,则跳过认证) |
| 8 | new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()) |
创建已认证的令牌,第三个参数是权限列表 |
| 10 | SecurityContextHolder.getContext().setAuthentication(authToken) |
将认证信息存入线程局部变量,后续的 Controller 和 @PreAuthorize可以从中获取用户信息 |
常见问题:
-
过滤器未被调用 :确保在
SecurityConfig中通过addFilterBefore注册了该过滤器。 -
认证信息未设置 :检查
jwtUtil.validateToken(token)是否返回true,以及userRepository.findByUsername(username)是否返回非空用户。
1.7 实现注册与登录接口
1.7.1 创建请求/响应 DTO
AuthRequest.java:
package com.example.backend.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class AuthRequest {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 20, message = "用户名长度必须在3-20之间")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 40, message = "密码长度必须在6-40之间")
private String password;
// getters and setters
}
AuthResponse.java:
package com.example.backend.dto;
public class AuthResponse {
private String token;
private String username;
private String role;
public AuthResponse(String token, String username, String role) {
this.token = token;
this.username = username;
this.role = role;
}
// getters and setters
}
1.7.2 创建AuthController:
package com.example.backend.controller;
import com.example.backend.dto.AuthRequest;
import com.example.backend.dto.AuthResponse;
import com.example.backend.dto.Result;
import com.example.backend.exception.BusinessException;
import com.example.backend.model.Role;
import com.example.backend.model.User;
import com.example.backend.repository.UserRepository;
import com.example.backend.util.JwtUtil;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private AuthenticationManager authenticationManager;
// 注册接口
@PostMapping("/register")
public Result<AuthResponse> register(@Valid @RequestBody AuthRequest request) {
// 1. 检查用户名是否已存在
if (userRepository.existsByUsername(request.getUsername())) {
throw new BusinessException(400, "用户名已存在");
}
// 2. 创建新用户
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword())); // 加密密码
user.setRole(Role.USER); // 默认角色为普通用户
// 3. 保存到数据库
userRepository.save(user);
// 4. 生成 Token
String token = jwtUtil.generateToken(user.getUsername(), user.getRole().name());
// 5. 返回响应
AuthResponse response = new AuthResponse(token, user.getUsername(), user.getRole().name());
return Result.success(response);
}
// 登录接口
@PostMapping("/login")
public Result<AuthResponse> login(@Valid @RequestBody AuthRequest request) {
try {
// 1. 使用 AuthenticationManager 验证用户名和密码
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
// 2. 认证成功,获取用户信息
User user = (User) authentication.getPrincipal();
// 3. 生成 Token
String token = jwtUtil.generateToken(user.getUsername(), user.getRole().name());
// 4. 返回响应
AuthResponse response = new AuthResponse(token, user.getUsername(), user.getRole().name());
return Result.success(response);
} catch (Exception e) {
// 认证失败(用户名或密码错误)
throw new BusinessException(401, "用户名或密码错误");
}
}
// 测试用:注册管理员(生产环境不应开放)
@PostMapping("/register-admin")
public Result<AuthResponse> registerAdmin(@Valid @RequestBody AuthRequest request) {
if (userRepository.existsByUsername(request.getUsername())) {
throw new BusinessException(400, "用户名已存在");
}
User user = new User();
user.setUsername(request.getUsername());
user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setRole(Role.ADMIN); // 设置为管理员
userRepository.save(user);
String token = jwtUtil.generateToken(user.getUsername(), user.getRole().name());
AuthResponse response = new AuthResponse(token, user.getUsername(), user.getRole().name());
return Result.success(response);
}
}
登录接口详解:
-
authenticationManager.authenticate(...)会依次执行:-
调用
CustomUserDetailsService.loadUserByUsername()加载用户。 -
使用
PasswordEncoder.matches()比对密码。 -
如果匹配,返回已认证的
Authentication对象;否则抛出异常。
-
-
我们捕获所有异常,统一返回 401,避免泄露具体是用户名错误还是密码错误(安全最佳实践)。
1.8 角色权限控制
1.8.1 启用方法安全
确保 SecurityConfig类上有 @EnableMethodSecurity注解,这样 @PreAuthorize才会生效。
1.8.2 在 Controller 方法上添加权限注解
在 BookController的方法上添加 @PreAuthorize:
@RestController
@RequestMapping("/api")
public class BookController {
// 所有角色都可以查看书籍
@GetMapping("/books")
@PreAuthorize("hasAnyRole('USER', 'ADMIN')")
public Result<List<BookResponse>> getAllBooks(...) { ... }
// 只有管理员可以新增、修改、删除
@PostMapping("/books")
@PreAuthorize("hasRole('ADMIN')")
public Result<BookResponse> addBook(...) { ... }
@PutMapping("/books/{id}")
@PreAuthorize("hasRole('ADMIN')")
public Result<BookResponse> updateBook(...) { ... }
@DeleteMapping("/books/{id}")
@PreAuthorize("hasRole('ADMIN')")
public Result<Void> deleteBook(...) { ... }
}
注解说明:
-
@PreAuthorize("hasRole('ADMIN')"):在方法执行前检查当前用户是否拥有ROLE_ADMIN权限。 -
@PreAuthorize("hasAnyRole('USER', 'ADMIN')"):检查是否拥有ROLE_USER或ROLE_ADMIN之一。 -
如果权限不足,Spring Security 会抛出
AccessDeniedException,最终返回 403 Forbidden。
二、前端项目初始化
2.1 创建 Vue3 + Vite 项目
npm create vite@latest frontend -- --template vue
cd frontend
npm install
npm install axios vue-router@4
2.2 配置开发服务器代理(vite.config.js)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8085',
changeOrigin: true
}
}
}
})
2.3 创建 axios 实例与拦截器(src/api/http.js)
import axios from 'axios'
const http = axios.create({
baseURL: '/api',
timeout: 10000
})
http.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
http.interceptors.response.use(
response => response.data,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
window.location.hash = '#/login'
}
return Promise.reject(error)
}
)
export default http
2.4 配置路由守卫(src/router/index.js)
import { createRouter, createWebHashHistory } from 'vue-router'
const routes = [
{ path: '/login', component: () => import('../views/Login.vue') },
{ path: '/books', component: () => import('../views/BookList.vue') }
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.path !== '/login' && !token) {
next('/login')
} else {
next()
}
})
export default router
三、测试验证
3.1 注册
POST http://localhost:8085/api/auth/register
{
"username": "testuser",
"password": "123456"
}
返回 Token,状态码 200。
3.2 登录
POST http://localhost:8085/api/auth/login
{
"username": "testuser",
"password": "123456"
}
返回新的 Token。
3.3 访问受保护接口(携带 Token)
GET http://localhost:8085/api/books
Authorization: Bearer <token>
返回书籍列表。
3.4 权限不足
使用普通用户的 Token 调用 POST /api/books,返回 403。
3.5 前端测试
启动前端开发服务器,打开 http://localhost:3000,应自动跳转到登录页。输入正确的用户名密码后,跳转到书籍列表页,并能正常展示数据。
四、遇到的问题与解决
4.1 跨域问题
现象 :前端请求后端时报 blocked by CORS policy。
原因:后端未配置 CORS,或 Spring Security 的过滤器链拦截了预检请求。
解决 :在 SecurityConfig中添加 .cors()和 CorsConfigurationSourceBean,允许所有来源和方法。
4.2 JWT 签名不匹配
现象 :日志显示 JWT signature does not match locally computed signature。
原因 :项目重启后 jwt.secret被修改,或密钥长度不足。
解决 :确保 application.yml中的密钥固定且长度 ≥ 32 字符,重新登录获取新 Token。
4.3 403 Forbidden
现象:携带有效 Token 但仍返回 403。
原因 :角色名称大小写不匹配。数据库存储了 user(小写),而 @PreAuthorize中写的是 hasRole('USER')(大写)。
解决 :统一使用大写,数据库更新为 USER或 ADMIN。
4.4 前端代理不生效
现象 :前端请求 /api/books返回 404 或无法连接。
原因 :vite.config.js配置后未重启开发服务器。
解决 :修改配置后必须重启 npm run dev。
五、总结
本周我完成了从后端到前端的完整认证授权体系搭建:
-
后端实现了基于 Spring Security 和 JWT 的用户注册、登录、Token 验证、角色权限控制。
-
前端初始化了 Vue3 + Vite 项目,配置了 axios 拦截器自动携带 Token,并设置了路由守卫保护页面。
-
解决了跨域、签名不匹配、角色权限等实际问题。
现在,我的项目已经具备了生产级的认证能力,为后续的业务功能开发打下了坚实的基础。下一步,我将进入前后端联调阶段,实现完整的书籍管理功能。