尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

SpringBoot项目实战:JWT认证授权全流程与避坑指南

SpringBoot项目实战:JWT认证授权全流程与避坑指南 1. 项目概述为什么是JWT在构建现代Web应用特别是前后端分离的架构时用户认证是一个绕不开的核心环节。传统的基于Session的认证方式在分布式、微服务场景下显得力不从心每次请求都需要查询服务端Session存储带来了扩展性和维护上的挑战。这时JWTJSON Web Token作为一种自包含的、无状态的认证方案就成为了许多开发者的首选。简单来说JWT就像一张由服务器签发、客户端保管的“数字身份证”。这张身份证里不仅包含了用户是谁用户ID还可以附带一些额外的“权限说明”如角色、权限列表并且自带防伪签名。客户端在后续请求中只需在HTTP头里出示这张身份证服务器通过验证签名就能确认用户身份无需再去查询数据库或缓存。这对于SpringBoot项目来说意味着我们可以用极简的依赖和配置快速构建出一套安全、高效、易于扩展的认证授权体系。我经历过从Session到Token的迁移也踩过不少JWT的坑。今天我就基于一个典型的SpringBoot项目手把手带你从零开始集成JWT实现用户登录认证。我们会涵盖从依赖引入、工具类编写、登录接口开发、Token校验拦截到权限控制的全流程并重点分享那些官方文档里不会写的实战经验和避坑指南。无论你是正在开发一个新项目还是打算改造旧有的认证系统这篇内容都能给你提供一份可直接“抄作业”的可靠方案。2. 核心依赖与JWT工具类封装2.1 依赖选型与引入在SpringBoot项目中集成JWT我们首先需要引入相关的库。Java领域最常用的JWT库是jjwt它由Auth0维护API设计友好功能完善。我们选择当前稳定且广泛使用的版本。在你的pom.xml文件中添加以下依赖dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency注意这里将jjwt-impl和jjwt-jackson设置为runtime范围是因为我们在编码时只依赖jjwt-api中定义的接口。这种写法遵循了“面向接口编程”的原则使得实现可以替换同时也让你的编译期依赖更清晰。另外确保你的SpringBoot版本与这些依赖兼容一般SpringBoot 2.x及以上版本都没有问题。除了JWT我们通常还需要Spring Security来协助我们完成Web安全配置但为了初次集成更清晰我们先从最核心的JWT工具类开始暂时不引入Spring Security的复杂配置。2.2 JWT工具类设计与实现工具类的目标是封装JWT的生成、解析和验证逻辑对外提供简洁易用的方法。一个健壮的工具类需要考虑密钥管理、Token过期时间、自定义声明等问题。首先我们创建一个JwtUtil类。密钥Secret是签名和验证Token的核心绝对不应该硬编码在代码中。最佳实践是从应用配置文件如application.yml中读取。# application.yml jwt: secret: your-256-bit-secret-key-needs-to-be-very-long-and-random expiration: 7200 # Token过期时间单位秒这里设2小时 token-header: Authorization # 前端传递Token的请求头名称 token-prefix: Bearer # Token前缀通常为Bearer对应的配置类JwtPropertiesimport lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; Data Component ConfigurationProperties(prefix jwt) public class JwtProperties { private String secret; private Long expiration; private String tokenHeader; private String tokenPrefix; }接下来是JwtUtil的核心实现。我将关键步骤拆解并加上详细注释import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.util.Date; import java.util.HashMap; import java.util.Map; Slf4j Component RequiredArgsConstructor public class JwtUtil { private final JwtProperties jwtProperties; // 生成安全的密钥对象避免每次签名都重新计算 private SecretKey getSigningKey() { // 将配置的字符串密钥转换为安全的Key对象 // 注意jjwt 0.10.0 版本推荐使用Keys.hmacShaKeyFor方法 return Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes()); } /** * 生成JWT Token * param userId 用户唯一标识 * param username 用户名 * param extraClaims 额外的自定义声明如角色、权限 * return 生成的Token字符串 */ public String generateToken(String userId, String username, MapString, Object extraClaims) { MapString, Object claims new HashMap(); // 标准声明 claims.put(sub, userId); // 主题通常放用户ID claims.put(username, username); claims.put(iat, new Date()); // 签发时间 // 合并额外声明 if (extraClaims ! null) { claims.putAll(extraClaims); } return Jwts.builder() .setClaims(claims) // 设置载荷 .setExpiration(new Date(System.currentTimeMillis() jwtProperties.getExpiration() * 1000)) // 过期时间 .signWith(getSigningKey(), SignatureAlgorithm.HS256) // 指定算法和密钥签名 .compact(); // 生成字符串 } /** * 从Token中解析出Claims载荷 * param token JWT字符串 * return Claims对象 * throws ExpiredJwtException Token过期 * throws UnsupportedJwtException Token格式错误 * throws MalformedJwtException Token结构错误 * throws SignatureException 签名验证失败 * throws IllegalArgumentException 参数错误 */ public Claims parseToken(String token) { // 移除可能携带的前缀如Bearer String actualToken token.replace(jwtProperties.getTokenPrefix(), ).trim(); return Jwts.parserBuilder() .setSigningKey(getSigningKey()) // 设置验证密钥 .build() .parseClaimsJws(actualToken) // 解析并验证 .getBody(); // 获取载荷 } /** * 验证Token是否有效未过期且签名正确 * param token JWT字符串 * return 是否有效 */ public boolean validateToken(String token) { try { parseToken(token); // 如果解析不抛异常则认为有效 return true; } catch (JwtException | IllegalArgumentException e) { log.warn(JWT token validation failed: {}, e.getMessage()); return false; } } /** * 从Token中获取用户IDsubject */ public String getUserIdFromToken(String token) { return parseToken(token).getSubject(); } /** * 从Token中获取用户名 */ public String getUsernameFromToken(String token) { return parseToken(token).get(username, String.class); } /** * 检查Token是否即将过期例如在过期前30分钟内 * 用于实现Token的自动续期Refresh Token */ public boolean isTokenExpiringSoon(String token, long minutesBefore) { try { Claims claims parseToken(token); Date expiration claims.getExpiration(); // 计算距离过期还有多少毫秒 long timeUntilExpiry expiration.getTime() - System.currentTimeMillis(); return timeUntilExpiry 0 timeUntilExpiry (minutesBefore * 60 * 1000); } catch (Exception e) { // 如果解析失败视为无效或已过期 return true; } } }实操心得在getSigningKey()方法中我使用了Keys.hmacShaKeyFor来生成密钥。这是jjwt0.10.0版本后推荐的方式它能确保密钥长度符合所选签名算法如HS256需要至少256位的要求。如果你直接使用字符串.getBytes()作为signWith的参数在密钥长度不足时可能会遇到隐晦的错误。另外parseToken方法抛出的异常类型非常具体在实际的全局异常处理中我们可以根据不同的异常类型返回更精确的错误提示给前端比如“登录已过期请重新登录”或“Token无效”。3. 用户登录与Token签发流程有了JWT工具类接下来我们实现用户登录的核心业务逻辑。这个过程通常涉及控制器Controller、服务层Service和数据访问层Repository/DAO。3.1 用户实体与数据准备首先定义一个简单的用户实体和对应的数据库表。这里为了简化我们使用内存中的Map模拟用户存储。import lombok.Data; import java.util.ArrayList; import java.util.List; Data public class User { private String id; private String username; private String password; // 实际存储应为加密后的密文 private ListString roles new ArrayList(); // 用户角色 }在服务层我们初始化一些测试用户。切记实际项目中密码绝对不能明文存储import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import javax.annotation.PostConstruct; import java.util.HashMap; import java.util.Map; Service public class UserService { private final MapString, User userStore new HashMap(); private final BCryptPasswordEncoder passwordEncoder new BCryptPasswordEncoder(); PostConstruct public void initUsers() { // 模拟从数据库加载用户密码使用BCrypt加密 User admin new User(); admin.setId(1); admin.setUsername(admin); admin.setPassword(passwordEncoder.encode(admin123)); admin.setRoles(List.of(ROLE_ADMIN, ROLE_USER)); userStore.put(admin, admin); User user new User(); user.setId(2); user.setUsername(user); user.setPassword(passwordEncoder.encode(user123)); user.setRoles(List.of(ROLE_USER)); userStore.put(user, user); } public User findByUsername(String username) { return userStore.get(username); } }注意事项这里使用了BCryptPasswordEncoder来加密密码。BCrypt是当前公认的安全的密码哈希算法它内置了盐salt并且计算速度可调节能有效抵御彩虹表攻击。在Spring Security中它也是默认的密码编码器。千万不要使用MD5、SHA-1等已被证明不安全的算法更不要自己实现加密逻辑。3.2 登录接口实现登录接口接收用户名和密码验证通过后生成JWT Token返回给客户端。我们创建一个登录请求的DTOData Transfer Objectimport lombok.Data; import javax.validation.constraints.NotBlank; Data public class LoginRequest { NotBlank(message 用户名不能为空) private String username; NotBlank(message 密码不能为空) private String password; }以及登录成功的响应DTOimport lombok.Data; Data public class LoginResponse { private String token; private String tokenType Bearer; private Long expiresIn; // 过期时间秒 private String username; private ListString roles; }现在实现登录的Controller和Service// AuthController.java import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; 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; import javax.validation.Valid; RestController RequestMapping(/api/auth) RequiredArgsConstructor public class AuthController { private final AuthService authService; PostMapping(/login) public ResponseEntityLoginResponse login(Valid RequestBody LoginRequest request) { LoginResponse response authService.authenticate(request); return ResponseEntity.ok(response); } }// AuthService.java import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map; Slf4j Service RequiredArgsConstructor public class AuthService { private final UserService userService; private final JwtUtil jwtUtil; private final BCryptPasswordEncoder passwordEncoder; public LoginResponse authenticate(LoginRequest request) { // 1. 根据用户名查找用户 User user userService.findByUsername(request.getUsername()); if (user null) { log.warn(登录失败用户不存在用户名{}, request.getUsername()); throw new RuntimeException(用户名或密码错误); // 建议使用自定义业务异常 } // 2. 验证密码 if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { log.warn(登录失败密码错误用户名{}, request.getUsername()); throw new RuntimeException(用户名或密码错误); } // 3. 准备JWT的自定义声明Claims MapString, Object extraClaims new HashMap(); extraClaims.put(roles, user.getRoles()); // 将用户角色放入Token // 4. 生成JWT Token String token jwtUtil.generateToken(user.getId(), user.getUsername(), extraClaims); // 5. 构建响应 LoginResponse response new LoginResponse(); response.setToken(token); response.setExpiresIn(jwtProperties.getExpiration()); // 从配置读取 response.setUsername(user.getUsername()); response.setRoles(user.getRoles()); log.info(用户登录成功{}, user.getUsername()); return response; } }核心细节解析在authenticate方法中密码验证使用了passwordEncoder.matches()方法。这个方法会对比前端传来的明文密码和数据库存储的BCrypt密文是否匹配。即使两个用户的密码相同由于BCrypt内置的随机盐他们加密后的密文也是不同的这极大地增强了安全性。另外我们将用户的角色列表放入了Token的extraClaims中这样在后续的接口鉴权时就可以直接从Token中读取用户角色而无需再次查询数据库这是JWT提升性能的关键点之一。4. 拦截器实现与Token校验Token生成并返回给前端后前端需要在后续的请求中携带它。通常的做法是放在HTTP请求的Authorization头中格式为Bearer your_token。我们的后端需要拦截这些请求验证Token的有效性并将用户信息放入当前请求上下文中。4.1 自定义拦截器Interceptor在SpringBoot中我们可以使用HandlerInterceptor来实现这个功能。相比FilterInterceptor能更紧密地与Spring MVC集成更容易获取Spring容器中的Bean。首先创建一个拦截器类JwtAuthenticationInterceptorimport lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; Slf4j Component RequiredArgsConstructor public class JwtAuthenticationInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; private final JwtProperties jwtProperties; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 从请求头获取Token String authHeader request.getHeader(jwtProperties.getTokenHeader()); if (authHeader null || !authHeader.startsWith(jwtProperties.getTokenPrefix())) { log.debug(请求头中未找到有效的Authorization信息URI: {}, request.getRequestURI()); // 这里不直接抛出异常因为有些接口如登录是公开的。权限控制交给后续的注解或配置。 // 将用户身份设置为匿名 request.setAttribute(currentUserId, null); return true; // 继续执行后续拦截器和控制器 } // 2. 提取Token字符串 String token authHeader.substring(jwtProperties.getTokenPrefix().length()).trim(); // 3. 验证Token有效性 if (!jwtUtil.validateToken(token)) { log.warn(Token验证失败URI: {}, request.getRequestURI()); // 返回401未授权状态码 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\: 401, \msg\: \Token无效或已过期\}); return false; // 中断请求 } // 4. Token有效解析用户信息并存入请求属性 try { String userId jwtUtil.getUserIdFromToken(token); String username jwtUtil.getUsernameFromToken(token); // 将用户信息放入request属性供Controller使用 request.setAttribute(currentUserId, userId); request.setAttribute(currentUsername, username); log.debug(用户认证通过: userId{}, username{}, URI{}, userId, username, request.getRequestURI()); } catch (Exception e) { log.error(解析Token中的用户信息失败, e); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } return true; // 验证通过继续执行 } }4.2 注册拦截器并配置白名单拦截器需要注册到Spring MVC的配置中才能生效。同时我们需要配置一个白名单Whitelist让登录、注册等公开接口不经过Token验证。创建一个Web配置类WebMvcConfigimport lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.Arrays; import java.util.List; Configuration RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { private final JwtAuthenticationInterceptor jwtAuthenticationInterceptor; // 公开路径不需要Token验证 private static final ListString EXCLUDE_PATHS Arrays.asList( /api/auth/login, /api/auth/register, /swagger-ui/**, // Swagger UI /v3/api-docs/**, // OpenAPI文档 /error, /favicon.ico ); Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtAuthenticationInterceptor) .addPathPatterns(/api/**) // 拦截所有/api开头的请求 .excludePathPatterns(EXCLUDE_PATHS); // 排除公开路径 } }避坑指南在配置白名单时一定要小心。我曾经在一个项目中漏掉了/error路径导致当拦截器内部抛出异常时Spring Boot默认的/error处理路径也被拦截了造成了递归重定向和死循环。另外像/favicon.ico这样的静态资源请求也常常被忘记排除虽然可能不会导致功能问题但会在日志里产生大量无关的警告信息。一个好的习惯是在拦截器的preHandle方法开头加一行调试日志打印请求的URI这样在开发阶段能清晰地看到哪些路径被拦截了。4.3 在Controller中获取当前用户信息拦截器已经把解析出的用户ID和用户名放入了HttpServletRequest的属性中。在Controller里我们可以通过一个辅助方法来方便地获取这些信息。创建一个工具类RequestContextHolder或直接在Controller中注入HttpServletRequest// 方式一使用RequestScope Bean (更优雅) Component RequestScope // 每个HTTP请求一个实例 public class CurrentUser { private String userId; private String username; // getters and setters ... } // 在拦截器中设置CurrentUser Bean的值需要额外注入CurrentUser// 方式二简单直接在BaseController中提供方法 public class BaseController { /** * 从当前请求属性中获取用户ID */ protected String getCurrentUserId(HttpServletRequest request) { Object userId request.getAttribute(currentUserId); return userId ! null ? (String) userId : null; } /** * 从当前请求属性中获取用户名 */ protected String getCurrentUsername(HttpServletRequest request) { Object username request.getAttribute(currentUsername); return username ! null ? (String) username : null; } } // 在具体的Controller中继承BaseController并使用 RestController RequestMapping(/api/user) public class UserController extends BaseController { GetMapping(/profile) public ResponseEntityUserProfile getProfile(HttpServletRequest request) { String userId getCurrentUserId(request); if (userId null) { throw new RuntimeException(用户未登录); } // ... 根据userId查询用户资料 return ResponseEntity.ok(userProfile); } }实操心得我个人更倾向于第一种方式即使用RequestScope的Bean来持有当前用户信息。这样业务代码Service层也可以方便地注入这个Bean来获取用户信息而不需要将HttpServletRequest层层传递。但它的实现稍微复杂一点需要在拦截器中通过ApplicationContext来获取这个Bean并设置值。对于中小型项目第二种直接在Controller中获取的方式更简单直观。你可以根据项目的复杂度和团队习惯来选择。5. 结合Spring Security实现细粒度权限控制到目前为止我们的系统实现了“认证”Authentication即识别用户是谁。但一个完整的系统还需要“授权”Authorization即判断用户是否有权限执行某个操作。虽然我们可以在每个Controller方法里手动检查角色但这太繁琐且容易出错。Spring Security正是解决这个问题的权威框架。5.1 引入Spring Security依赖在pom.xml中添加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency引入后默认所有接口都会被保护需要登录。我们需要自定义配置来整合我们的JWT逻辑。5.2 自定义Security配置类创建一个继承WebSecurityConfigurerAdapter的配置类注意Spring Security 5.7 推荐使用基于组件的配置但为了兼容性和清晰度这里仍使用旧版风格新项目可研究新API。import lombok.RequiredArgsConstructor; 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.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; Configuration EnableWebSecurity RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { return authConfig.getAuthenticationManager(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 禁用CSRF因为使用JWT无状态且通常是前后端分离项目 .csrf().disable() // 基于Token不需要Session .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() // 配置请求授权规则 .authorizeRequests() .antMatchers(/api/auth/**).permitAll() // 认证相关接口公开 .antMatchers(/api/admin/**).hasRole(ADMIN) // 需要ADMIN角色 .antMatchers(/api/user/**).hasAnyRole(USER, ADMIN) // 需要USER或ADMIN角色 .antMatchers(/swagger-ui/**, /v3/api-docs/**).permitAll() // API文档公开 .anyRequest().authenticated() // 其他所有请求都需要认证 .and() // 添加我们自定义的JWT过滤器 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }5.3 实现JWT认证过滤器Spring Security的核心是一系列过滤器链。我们需要创建一个自定义过滤器放在链中合适的位置通常在UsernamePasswordAuthenticationFilter之前用来拦截请求解析JWT并设置Spring Security的认证信息。import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; Slf4j Component RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final JwtProperties jwtProperties; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 获取Token的逻辑与之前拦截器类似 String authHeader request.getHeader(jwtProperties.getTokenHeader()); if (authHeader null || !authHeader.startsWith(jwtProperties.getTokenPrefix())) { filterChain.doFilter(request, response); return; } String token authHeader.substring(jwtProperties.getTokenPrefix().length()).trim(); try { if (jwtUtil.validateToken(token)) { Claims claims jwtUtil.parseToken(token); String userId claims.getSubject(); String username claims.get(username, String.class); // 从Token中提取角色我们在生成Token时放入了roles声明 ListString roles claims.get(roles, List.class); ListSimpleGrantedAuthority authorities roles ! null ? roles.stream().map(role - new SimpleGrantedAuthority(role)).collect(Collectors.toList()) : Collections.emptyList(); // 创建Authentication对象并设置到SecurityContext中 UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userId, null, authorities); // 可以在这里把更多用户信息放入details authentication.setDetails(new HashMapString, Object() {{ put(username, username); put(userId, userId); }}); SecurityContextHolder.getContext().setAuthentication(authentication); log.debug(已为用户 [{}] 设置Spring Security认证上下文, username); } } catch (Exception e) { log.error(JWT认证过滤器处理失败, e); // 清理上下文确保后续过滤器不会误认为已认证 SecurityContextHolder.clearContext(); } // 继续执行过滤器链 filterChain.doFilter(request, response); } }5.4 使用注解进行方法级权限控制配置好Spring Security后我们就可以在Controller的方法上使用注解进行精细的权限控制了。import org.springframework.security.access.prepost.PreAuthorize; RestController RequestMapping(/api/admin) public class AdminController { GetMapping(/dashboard) PreAuthorize(hasRole(ADMIN)) // 必须拥有ROLE_ADMIN角色 public ResponseEntityString adminDashboard() { return ResponseEntity.ok(欢迎来到管理员仪表盘); } GetMapping(/users) PreAuthorize(hasAuthority(ROLE_ADMIN)) // 与hasRole类似但需要完整权限名包含ROLE_前缀 public ResponseEntityListUser listAllUsers() { // ... 业务逻辑 return ResponseEntity.ok(users); } } RestController RequestMapping(/api/user) public class UserController { GetMapping(/profile) PreAuthorize(isAuthenticated()) // 只要认证通过即可访问 public ResponseEntityUserProfile getMyProfile() { // 可以通过SecurityContextHolder获取当前用户 String userId (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); // ... 业务逻辑 return ResponseEntity.ok(profile); } PutMapping(/profile) PreAuthorize(hasRole(USER)) // 需要USER角色 public ResponseEntityVoid updateProfile(RequestBody ProfileUpdateRequest request) { // ... 业务逻辑 return ResponseEntity.ok().build(); } }核心细节解析PreAuthorize注解支持强大的Spring EL表达式。例如你可以实现“只能修改自己的资料”这种权限PreAuthorize(#userId authentication.principal)其中#userId是方法参数。这比在方法体内写if判断要清晰和安全得多。另外注意hasRole(ADMIN)和hasAuthority(ROLE_ADMIN)的区别。Spring Security默认会给角色名自动加上ROLE_前缀。使用hasRole时你传入ADMIN框架会检查ROLE_ADMIN权限使用hasAuthority时你必须传入完整的权限名ROLE_ADMIN。6. 进阶话题Token自动续期与单点登录思考6.1 Token自动续期Refresh Token策略JWT的一个固有缺点是一旦签发在有效期内无法主动使其失效除非修改密钥但这会影响所有用户。为了平衡安全性和用户体验常见的做法是使用短期的Access Token和长期的Refresh Token。Access Token用于访问业务接口有效期较短如30分钟。Refresh Token专门用于获取新的Access Token有效期较长如7天存储于服务端如Redis或数据库可被主动吊销。实现思路登录成功后不仅返回Access Token同时生成一个唯一的Refresh Token可以是UUID并关联用户ID存入Redis设置较长TTL。前端在Access Token过期后使用Refresh Token调用一个专门的/api/auth/refresh接口。后端验证Refresh Token是否存在且有效若有效则签发新的Access Token和可选的新的Refresh Token并让旧的Refresh Token失效。如果Refresh Token也过期或被吊销则用户需要重新登录。这种方案增加了服务端的状态管理但提供了更好的安全控制可以主动登出用户和稍好的用户体验。6.2 单点登录SSO的简易实现如果你有多个独立的SpringBoot应用例如一个主站app1.com和一个管理后台admin.app1.com希望用户在一个系统登录后访问另一个系统也自动登录这就是单点登录。基于JWT实现一个简单的SSO可以这样做统一的认证中心Auth Center建立一个独立的认证服务例如auth.app1.com专门负责用户登录和签发JWT。共享密钥所有子系统app1.com,admin.app1.com使用相同的JWT密钥来验证Token。登录流程用户访问app1.com被重定向到auth.app1.com/login。用户在认证中心登录成功认证中心生成JWT并重定向回app1.com同时将Token作为参数或设置Cookie需考虑跨域。app1.com获取到Token将其存储在本地如LocalStorage或Cookie。访问其他系统用户访问admin.app1.com该站点发现本地没有Token重定向到认证中心。认证中心检查用户是否已有全局会话例如通过一个加密的中央Cookie如果有则直接签发Token并重定向回admin.app1.com。用户无需再次输入密码。注意事项这种基于共享密钥的简单SSO适用于受信任的兄弟域名。对于更复杂或安全要求更高的场景如跨顶级域名可能需要引入OAuth 2.0或OpenID Connect协议。此外Token的传递安全使用HTTPS、防止XSS攻击窃取LocalStorage中的Token和注销是所有Token方案都需要仔细处理的问题。7. 常见问题排查与安全加固实录在实际开发和线上运维中你会遇到各种各样的问题。这里记录了几个最典型的问题和我的解决思路。7.1 问题排查速查表问题现象可能原因排查步骤与解决方案登录成功但后续接口返回4011. 前端未正确携带Token。2. Token格式错误如缺少Bearer前缀。3. Token已过期。4. 密钥不匹配开发/生产环境配置不同。1. 检查浏览器开发者工具的Network面板确认Authorization请求头是否正确携带。2. 核对Token字符串确保格式为Bearer token注意中间有空格。3. 使用在线工具如 jwt.io 解码Token检查exp字段。4. 确认服务端jwt.secret配置与签发Token时使用的密钥一致。某些公开接口如Swagger也被要求登录拦截器或Spring Security的白名单配置有误未正确排除路径。1. 检查WebMvcConfig中的EXCLUDE_PATHS或Security配置中的antMatchers().permitAll()。2. 注意路径匹配规则/api/public/**可以匹配/api/public/下的所有子路径。报错io.jsonwebtoken.security.SecurityException1. 签名验证失败密钥错误。2. Token被篡改。1. 这是最可能的原因百分之百确认生成和验证Token使用的是同一个密钥。2. 确保密钥长度足够HS256至少256位/32字节。3. 检查Token在传输过程中是否被截断或修改。获取到的用户角色为null生成Token时未将角色信息放入自定义声明Claims或解析时类型转换错误。1. 检查AuthService.generateToken方法确保extraClaims包含了roles字段。2. 在JwtAuthenticationFilter中检查解析Claims的代码claims.get(roles, List.class)。在Service层无法获取当前用户在非Controller层如Service、DAOSecurityContextHolder可能为空或不是当前请求的上下文。1. 确保你的方法调用是在一个HTTP请求线程内。异步任务需要手动传递SecurityContext。2. 考虑使用RequestScope的Bean如前文提到的CurrentUser来持有用户信息并在拦截器中注入设置。Token过期时间不生效1. 生成Token时设置的过期时间计算错误。2. 服务器时间不同步。1. 检查JwtUtil.generateToken中setExpiration的参数确保是当前时间 配置的秒数 * 1000。2. 确保服务器系统时间准确可以使用NTP服务同步时间。7.2 安全加固建议使用强密钥并妥善保管JWT的安全完全依赖于密钥。务必使用足够长且随机的字符串作为jwt.secret建议32位以上随机字符串。绝对不要将密钥提交到代码仓库应通过环境变量或配置中心注入。启用HTTPSJWT在传输过程中是明文的仅Base64编码如果通过HTTP传输会被中间人轻易窃取。生产环境必须使用HTTPS。控制Token有效时间Access Token的有效期不宜过长建议30分钟到2小时。结合Refresh Token机制来平衡安全与体验。防范XSS攻击如果前端将Token存储在localStorage中需防范XSS攻击导致Token被盗。确保对用户输入进行严格过滤和转义设置Content-Security-Policy头。也可以考虑使用HttpOnly的Cookie来存储Token但这会带来一些CSRF方面的考虑。实现Token黑名单可选对于需要主动让Token失效的场景如用户修改密码、管理员踢人可以维护一个短期的Token黑名单如存入RedisTTL设为原Token的剩余有效期。在验证Token时除了检查签名和过期时间再检查一下是否在黑名单中。这虽然引入了状态但提供了更强的控制力。不要在Token中存放敏感信息JWT的Payload部分是Base64编码并非加密。任何人都可以解码看到内容。切勿在Token中存放密码、手机号等敏感信息。集成JWT不是一劳永逸的它需要你根据自己项目的安全等级和业务需求在便捷性和安全性之间做出恰当的权衡和配置。从简单的登录认证起步逐步考虑续期、注销、单点登录等进阶需求你的认证授权体系就会越来越完善。
返回列表