ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot前后端分离项目整合Spring Security与JWT实战指南

Spring Boot前后端分离项目整合Spring Security与JWT实战指南 网上搜Spring Security教程一抓一大把但大多数能照着抄完就不错了真正把它和Spring Boot、前后端分离项目揉在一起讲清楚的少之又少。我去年把一个老项目的登录模块从Session模式重构到JWT无状态认证中间踩了不少坑顺手把整个接入过程重新走了一遍整理成这篇东西。这篇文章的核心是在Spring Boot Vue/React这类前后端完全分离的项目里Spring Security到底该怎么配、JWT怎么融进去、接口权限怎么控制、那些网上含糊其辞的EntryPoint和AccessDeniedHandler到底是个啥。我会把关键配置、每个类的作用和为什么这么写都讲明白适合刚开始接触Spring Security、或者被各种半吊子教程带偏过的同学。1. 先想清楚前后端分离下Spring Security到底帮你解决了什么好多人在前后端分离项目里用Spring Security一开始就懵了——网上的教程全是JSP页面、formLogin、redirect那一套跟自己的接口项目完全对不上。原因很简单Spring Security的设计初衷是服务端渲染的Web应用登录成功要跳转页面、Session里存用户信息、退出要销毁会话这套逻辑在前后端分离下全部不再适用。前后端分离下你需要的其实是三件事认出来的是谁认证、这个人能干什么授权、干不了的时候怎么告诉前端异常处理。1.1 从Session到Token认证范式的切换传统Web应用是这么认证的用户提交用户名密码服务端把用户信息存进Session同时把SessionId写进浏览器的Cookie。浏览器每次请求自动带上Cookie服务端拿SessionId去会话里查用户查到了就放行。整个过程完全靠浏览器的Cookie机制自动完成前端代码什么都不用干。前后端分离之后前端可能是Vue、小程序、App未必有Cookie这个概念也不一定跟你同域。这时候再用Session就有个跨域问题Cookie跨域要配各种credentials、withCredentials麻烦得要命。更麻烦的是如果以后要做集群部署Session还得考虑共享问题又引入Redis又是一堆配置。Token方案就好办得多。用户登录成功后服务端签发一个Token返回给前端前端存起来一般放localStorage或者请求头每次请求在Header里带上Authorization: Bearer token。服务端解析Token就能拿到用户身份不需要任何服务端存储天然支持集群部署。这就是无状态认证。如果你只是做单体应用、用户规模不大用Session Redis也没问题没必要为了追逐潮流硬上JWT。我那次重构是因为要接App和小程序Token方案确实省事。1.2 一个请求在Spring Security中经历了什么在接入之前你得对Spring Security的过滤链有一个基本认知否则后面写配置会完全不知道每个方法是在干什么。Spring Security的核心是一条过滤器链FilterChain。一个请求进来会依次经过这条链上的每个过滤器。有些过滤器做安全检查比如判断有没有登录有些做认证处理比如解析Token有些做授权判断比如当前用户有没有某个权限。任何一个环节不通过请求直接在这里被拦下返回异常全部通过请求才会走到你的Controller。这条链上大致长这样SecurityContextPersistenceFilter负责把当前请求的用户信息从SecurityContext里取出、放回。在一个请求内你随时可以通过SecurityContextHolder.getContext().getAuthentication()拿到当前用户。UsernamePasswordAuthenticationFilter处理表单登录前后端分离一般不用但你要知道它的存在。BasicAuthenticationFilter处理HTTP Basic认证一般也不用。自定义过滤器比如JWT过滤器你可以在链上插入自己的过滤器提前做过一层认证。ExceptionTranslationFilter捕获链上抛出的异常转化成对应的HTTP状态码或JSON返回。FilterSecurityInterceptor做最终的授权判断判断当前用户能不能访问这个URL。我画了个简单的请求流转请求进入 - JWT过滤器解析Token放入SecurityContext - 其他过滤器 - ExceptionTranslationFilter异常兜底 - 授权判断 - Controller理解这条链比你记住每个配置方法重要得多。后面所有配置本质都是在这条链上做文章加过滤器、改放行规则、定制返回结果。2. 工程搭建与依赖选择少走弯路的几个关键决定2.1 版本搭配Spring Boot 2.7.x Spring Security 5.7开始之前先把版本表给你这是最容易踩坑的地方。Spring Security 5.x和6.x的配置差异极大网上绝大多数教程是基于5.x写的如果你直接用Spring Boot 3.x内置Spring Security 6.x很多配置会直接报错或者方法找不到。组件版本说明Spring Boot2.7.182.x最后一个版本稳定生态兼容好Spring Security5.7.x随Spring Boot 2.7自动引入JDK8或112.7最高支持到JDK 178/11最稳JWT库jjwt 0.9.1简单易用或使用java-jwt如果你非要用Spring Boot 3.x那后面的配置类写法要改成Lambda风格加上authorizeHttpRequests等新API本文不展开先把2.7这条线吃透。2.2 为什么我最终选择了经典配置方式而不是纯LambdaSpring Security 5.x提供两种配置写法一种是传统的继承WebSecurityConfigurerAdapter另一种是纯Lambda链式写法。网上很多老教程用的是继承WebSecurityConfigurerAdapter但在5.7之后这个类已经标记DeprecatedSpring Boot 2.7里虽然还能用但控制台会有一堆deprecated警告看着烦。我最终选择了Lambda链式写法好处有两个一是没有废弃API的警告二是写法上更直观一个配置类就是一条从上到下的规则流。这套写法在Spring Security 6.x里也是一脉相承的以后升级如果你不嫌弃Spring Boot 3.0早期版本的各种坑会平滑很多。2.3 依赖清单不是越多越好pom.xml里加这三个就够dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency dependency groupIdcom.alibaba/groupId artifactIdfastjson/artifactId version1.2.83/version /dependencyfastjson用来做JSON序列化不习惯的换成Jackson也可以后面我会在响应封装那里说明为什么这里需要一个好用的JSON工具。加完依赖直接启动一次项目你会发现控制台打印了一行随机密码Using generated security password: 8b3d8f4a-...这个说明Spring Security已经生效了所有接口默认全部拦截默认用户名是user密码是那串随机字符串可以用它登录默认的表单登录页。第一次启动看到这个随机密码说明依赖装对了后面我们才会把它彻底替换成自己的登录逻辑。3. 核心SecurityConfig过滤链、加密器与白名单设计配置类是整篇教程的重头戏直接贴代码然后每一段给你解释为什么这么写。Configuration EnableWebSecurity EnableGlobalMethodSecurity(prePostEnabled true) public class SecurityConfig { Autowired private JwtAuthenticationFilter jwtAuthenticationFilter; Autowired private RestAuthenticationEntryPoint authenticationEntryPoint; Autowired private RestAccessDeniedHandler accessDeniedHandler; // 白名单不需要登录就能访问的接口 private static final String[] WHITE_LIST { /api/auth/login, /api/auth/register, /api/auth/captcha, /swagger-ui.html, /swagger-ui/**, /swagger-resources/**, /v2/api-docs, /webjars/**, /doc.html }; Bean public PasswordEncoder passwordEncoder() { // BCrypt加密每次加密结果不同但校验结果一致安全性更高 return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 前后端分离是接口服务没有页面跳转关掉CSRF .csrf().disable() // 不通过Session获取SecurityContext因为我们是无状态Token认证 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() // 白名单接口直接放行不走认证 .antMatchers(WHITE_LIST).permitAll() // 除了放行接口其他所有请求都需要认证 .anyRequest().authenticated() .and() // 未登录时的处理逻辑 .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint) // 登录了但没权限时的处理逻辑 .and() .exceptionHandling().accessDeniedHandler(accessDeniedHandler) .and() // 把自定义的JWT过滤器加到UsernamePasswordAuthenticationFilter之前 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }3.1 这段配置里的每一行都是什么意思.csrf().disable()CSRF防护是防止跨站请求伪造的它依赖Session和Cookie。前后端分离的情况下我们用的是Token且放在Header里CSRF那套校验机制完全是起反作用的它会校验请求里有没有_csrf参数所以必须关掉。很多初学者忘了关结果测试接口时发现GET能通、POST一直403就是这个原因。.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)这一行设定Spring Security不在Session里保存用户信息。其实JWT方案下服务端HttpSession不会存任何认证信息你不写这行也能跑但Spring Security默认是IF_REQUIRED策略在某些操作时还是会碰Session写上是把无状态彻底坐实。.authorizeRequests()和.antMatchers(WHITE_LIST).permitAll()放开登录注册等无需认证的接口。这里有个非常容易被忽略的细节如果你用了SpringDoc或者Swagger一定记得把/swagger-ui.html、/v2/api-docs、/webjars/**这些路径加到白名单里。我之前在线上的一个项目就是忘了放行/doc.html导致测试环境的接口文档打开后一直跳登录排查了半天才发现是Security拦截了静态资源。.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)把你的JWT过滤器插到UsernamePasswordAuthenticationFilter之前。为什么插在它前面因为UsernamePasswordAuthenticationFilter专门处理登录请求的我们希望JWT过滤器先跑——先看看请求里有没有Token如果有就解析并认证用户没有则直接放行让后面的过滤器拦截并触发401响应。3.2 为什么密码加密一定要用BCrypt而不是MD5在PasswordEncoder这里我说一下踩过的坑。老项目用的是MD5加盐后来接入Spring Security发现它根本不给用因为Spring Security从5.0开始默认只认PasswordEncoder接口而且最基础的要求是加密结果必须带盐。MD5属于非自适应加密计算速度极快GPU并行爆破成本极低属于过时方案。BCrypt的设计就是对抗暴力破解的它内部有随机盐同一个密码每次加密出的密文都不同而且计算强度可以调高让暴力破解的代价指数级上升。public class PasswordUtil { public static void main(String[] args) { BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); // 同一密码两次加密结果完全不一样 String encode1 encoder.encode(123456); String encode2 encoder.encode(123456); System.out.println(encode1); System.out.println(encode2); // 校验结果始终为true System.out.println(encoder.matches(123456, encode1)); System.out.println(encoder.matches(123456, encode2)); } }如果你项目里的老用户表是MD5密码想迁移到Spring Security记得在UserDetailsService里对老用户做个兼容判断先用BCrypt.matches校验如果校验失败再用MD5盐校验一次校验通过后顺手把密码升级成BCrypt格式。这是老项目平滑迁移的关键一步否则老用户全要重置密码被业务方骂死。4. 统一返回结构AuthenticationEntryPoint和AccessDeniedHandler这一节是最多人在网上问、又最少人讲透的地方。好多教程把这两个Handler说得神乎其神其实就两件事没登录和没权限返回给前端的JSON要长什么样。4.1 没登录和没权限是两种完全不同的情况先明确概念401 Unauthorized未认证请求头里没有Token或者Token过期/无效服务端压根不知道你是谁。403 Forbidden未授权服务端知道你是谁已经登录了但你这个人没有权限访问这个资源。比如普通用户去调管理员接口。Spring Security默认对这两类情况的处理方式都是重定向到一个HTML错误页或者返回一个默认的403 JSON。前后端分离项目里前端拿到的必须是一个结构化JSON才能统一弹提示、跳登录页。4.2 两个Handler的实现代码先写一个统一返回工具类让所有异常的返回格式一致public class ResponseResultT { private Integer code; private String message; private T data; public static T ResponseResultT success(T data) { ResponseResultT result new ResponseResult(); result.code 200; result.message success; result.data data; return result; } public static T ResponseResultT error(Integer code, String message) { ResponseResultT result new ResponseResult(); result.code code; result.message message; return result; } // getter/setter省略 }然后是未登录处理器Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setCharacterEncoding(UTF-8); response.setContentType(application/json); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 前端看到code401就知道要清掉本地Token并跳转登录页 ResponseResultObject result ResponseResult.error(401, 未登录或登录已过期); response.getWriter().write(JSON.toJSONString(result)); } }然后是无权限处理器Component public class RestAccessDeniedHandler implements AccessDeniedHandler { Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setCharacterEncoding(UTF-8); response.setContentType(application/json); response.setStatus(HttpServletResponse.SC_FORBIDDEN); ResponseResultObject result ResponseResult.error(403, 没有权限访问该资源); response.getWriter().write(JSON.toJSONString(result)); } }两个Handler写好并注册成Bean之后Spring Security会自动注入到异常处理链路里。以后任何未登录请求进来返回的是{code: 401, message: 未登录或登录已过期}登录但越权的返回{code: 403, message: 没有权限访问该资源}。这里有个细节值得注意AccessDeniedException在Spring Security里分两种情况。如果是在过滤链早期抛出的比如匿名用户访问受限资源会被ExceptionTranslationFilter先判断为未认证交给你写的AuthenticationEntryPoint处理如果是在FilterSecurityInterceptor授权阶段抛出的已登录但权限不足才走AccessDeniedHandler。实际项目里两种都要配齐只配一个会漏情况。5. 基于JWT的无状态认证从过滤器到UserDetailsService这一节是整套方案的发动机我把完整的链路拆开给你看。5.1 JWT工具类生成、解析、校验JWT由三段组成Header头部声明算法类型、Payload载荷声明用户信息和过期时间、Signature签名用服务端密钥对前两段签名。它的核心安全性在第三段服务端持有私钥Token里的任何内容被篡改签名立刻校验失败。Component public class JwtTokenUtil { // 密钥生产环境务必放到nacos/配置中心或环境变量不要硬编码在代码里 private static final String SECRET_KEY your-secret-key-please-change-in-production; // Token有效期7天单位毫秒 private static final long EXPIRE_TIME 7 * 24 * 60 * 60 * 1000L; // 刷新Token有效期30天 private static final long REFRESH_EXPIRE_TIME 30 * 24 * 60 * 60 * 1000L; private static final String CLAIM_KEY_USERNAME username; private static final String CLAIM_KEY_CREATED created; /** * 根据用户信息生成Token */ public String generateToken(String username) { MapString, Object claims new HashMap(); claims.put(CLAIM_KEY_USERNAME, username); claims.put(CLAIM_KEY_CREATED, new Date()); return Jwts.builder() .setClaims(claims) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS512, SECRET_KEY) .compact(); } /** * 从Token中解析出用户名 */ public String getUsernameFromToken(String token) { Claims claims Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody(); return claims.get(CLAIM_KEY_USERNAME, String.class); } /** * 判断Token是否过期 */ private boolean isTokenExpired(String token) { Date expiration Jwts.parser() .setSigningKey(SECRET_KEY) .parseClaimsJws(token) .getBody() .getExpiration(); return expiration.before(new Date()); } /** * 校验Token是否有效 */ public boolean validateToken(String token) { try { Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token); return !isTokenExpired(token); } catch (Exception e) { return false; } } }JJWT这个库0.9.1版本使用方法很稳定就是Jwts.builder()和Jwts.parser()两条链路。注意jwt对过期时间的处理解析时如果Token已过期会直接抛ExpiredJwtException所以我在validateToken里捕获了所有异常任何情况都返回false由过滤器统一处理为401。关于Token有效期这里多说一句不要设太长也不要太短。太长的话前端用户登录一次能用很久但Token一旦泄露别人也能用很久太短的话用户用得正起劲突然401体验很差。实际项目里我一般登录Token设2小时配合Refresh Token做无感续期。这篇文章先不拆刷新机制你把有效期设成7天先跑通后面再优化。5.2 JwtAuthenticationFilter一次请求里的认证主场过滤器负责每个请求进来时看看有没有Token、Token能不能解析出用户、用户有没有权限。核心代码如下Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtTokenUtil jwtTokenUtil; Autowired private UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 前置校验如果OPTIONS请求跨域预检直接放行 if (CorsUtils.isPreFlightRequest(request)) { filterChain.doFilter(request, response); return; } // 1. 从请求头中获取Token String authHeader request.getHeader(Authorization); if (StringUtils.isBlank(authHeader) || !authHeader.startsWith(Bearer )) { // 没有Token说明未登录放行后由Security拦截器统一处理 filterChain.doFilter(request, response); return; } String token authHeader.substring(7); // 2. 校验Token是否有效 if (!jwtTokenUtil.validateToken(token)) { // Token无效或过期这里不清除上下文让后面的异常处理返回401 filterChain.doFilter(request, response); return; } // 3. 从Token解析出用户名 String username jwtTokenUtil.getUsernameFromToken(token); // 4. 如果当前SecurityContext里没有认证信息则去数据库加载用户细节 if (username ! null SecurityContextHolder.getContext().getAuthentication() null) { UserDetails userDetails userDetailsService.loadUserByUsername(username); if (userDetails ! null) { // 这里可以把用户ID也塞进authentication里后面接口直接取不用查库 UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 认证信息放入SecurityContext SecurityContextHolder.getContext().setAuthentication(authentication); } } filterChain.doFilter(request, response); } }这一段是整个认证流程的核心我详细说一下每个步骤的逻辑第一步取Token的约定。前端通常在axios请求拦截器里写axios.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; });这个Bearer前缀是一种行业约定表示后面跟着的是Bearer Token。Spring Security的BearerTokenAuthenticationFilter默认也认这个格式不过我们自己写的过滤器就自己解析没用到内置的。第二步的异常不要在这里直接返回。很多新手喜欢在过滤器里写response.setStatus(401)直接返回我不建议这么做。原因在于过滤器里直接返回的话异常处理链路还没走到ExceptionTranslationFilter你写的AuthenticationEntryPoint根本不会触发返回的JSON格式就和全局统一格式不一致了。让请求继续走完由后续的授权过滤器发现没有认证信息再走你配置的authenticationEntryPoint返回结构才统一。第三步从Token解析用户名。这里有个性能小优化Token里存储的用户信息越少越好只放用户名这种唯一标识不要放手机号、邮箱这些可能变动的数据。每次请求解析出用户名后去数据库查用户是标准做法代价是每个请求多一次数据库查询。如果你的系统对性能特别敏感可以在Token里直接塞用户ID然后配一层本地缓存但这属于进阶优化不做展开。5.3 UserDetailsService连接数据库和Spring Security的桥梁UserDetailsService是Spring Security预留的扩展点它返回的UserDetails对象就是Spring Security用来判断用户是否存在、是否可用、有什么权限的依据。Service public class UserDetailsServiceImpl implements UserDetailsService { Autowired private SysUserMapper sysUserMapper; Autowired private SysRoleMapper sysRoleMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 1. 根据用户名查用户 SysUser user sysUserMapper.selectByUsername(username); if (user null) { // 抛异常会被Spring Security捕获转化为BadCredentialsException throw new UsernameNotFoundException(用户不存在); } // 2. 根据用户ID查角色列表 ListSysRole roles sysRoleMapper.selectByUserId(user.getId()); // 3. 把角色名集合转换成SimpleGrantedAuthority列表 ListSimpleGrantedAuthority authorities roles.stream() .map(role - new SimpleGrantedAuthority(ROLE_ role.getRoleCode())) .collect(Collectors.toList()); // 4. 返回Spring Security标准的User对象 return new User(user.getUsername(), user.getPassword(), authorities); } }这里我把角色表单独查出来是为了展示多表查询时的标准写法。实际项目里如果你用的是MyBatis-Plus直接selectByUsername能带出关联角色也一样的。特别注意角色Authority的命名约定Spring Security里如果角色名以ROLE_开头在hasRole()表达式里可以省略前缀。比如数据库里角色code是ADMIN你在授权表达式里写hasRole(ADMIN)它自动匹配的是ROLE_ADMIN这个Authority。如果你数据库里的角色code本身就不带ROLE_前缀一定要在转换时手动加否则hasRole永远匹配不上联调时百思不得其解。还有一个常见坑是用户被禁用status0的处理。User构造方法有五个参数的重载第四和第五个参数是isEnabled和isAccountNonLocked等状态位。如果用户被禁用了你要返回new User(username, password, enabled, accountNonExpired, credentialsNonExpired, accountNonLocked, authorities)这样的对象Spring Security就会自动拒绝这个用户登录。很多教程里直接三参数构造把状态位丢了禁用功能就形同虚设。注册接口的密码也要记得加密后入库Service public class AuthService { Autowired private PasswordEncoder passwordEncoder; Autowired private SysUserMapper sysUserMapper; public void register(String username, String rawPassword) { SysUser user new SysUser(); user.setUsername(username); // 加密后的密文入库千万不能存明文 user.setPassword(passwordEncoder.encode(rawPassword)); sysUserMapper.insert(user); } }这样从注册到登录的闭环就通了注册时BCrypt加密入库登录时AuthenticationManager校验密码之后每次请求由JwtAuthenticationFilter解析Token、加载用户、注入SecurityContext。5.4 登录接口握手签发的最后一环登录接口接收用户名密码校验通过后生成JWT返回给前端RestController RequestMapping(/api/auth) public class AuthController { Autowired private AuthenticationManager authenticationManager; Autowired private JwtTokenUtil jwtTokenUtil; Autowired private SysUserMapper sysUserMapper; PostMapping(/login) public ResponseResultMapString, Object login(RequestBody LoginRequest loginRequest) { // 1. 用AuthenticationManager校验用户名密码 UsernamePasswordAuthenticationToken authenticationToken new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword()); Authentication authenticate authenticationManager.authenticate(authenticationToken); // 2. 校验通过拿用户信息生成Token SecurityContextHolder.getContext().setAuthentication(authenticate); String username loginRequest.getUsername(); String token jwtTokenUtil.generateToken(username); // 3. 从数据库查用户基本信息一起返回给前端 SysUser user sysUserMapper.selectByUsername(username); MapString, Object data new HashMap(); data.put(token, token); data.put(userInfo, user); return ResponseResult.success(data); } }这里你会遇到第一个必须处理的配置问题Spring Boot 2.7里AuthenticationManager不会自动注入到容器需要你自己在SecurityConfig里声明。Bean Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); }如果你用的是Lambda配置方式没有继承WebSecurityConfigurerAdapter就得改用这种方式Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); }两种情况二选一。如果你看到NoSuchBeanDefinitionException: No qualifying bean of type AuthenticationManager就是这里没配。这里的authenticationManager.authenticate()会调用我刚写的UserDetailsService的loadUserByUsername然后比对密码。比对通过返回一个完整的Authentication对象比对失败会抛BadCredentialsExceptionSpring Boot默认返回500你需要单独捕获它ExceptionHandler(BadCredentialsException.class) public ResponseResultObject handleBadCredentials(BadCredentialsException e) { return ResponseResult.error(401, 用户名或密码错误); }6. 鉴权扩展注解权限控制与动态权限模型到目前为止你已经能登录、能拿到用户身份了。接下来最后一层拼图是授权——不同角色的人能不能访问不同的接口。6.1 PreAuthorize用法接口权限控制的最快路径在SecurityConfig上我已经加了EnableGlobalMethodSecurity(prePostEnabled true)这个注解它开启了方法级别的权限控制。有了它你就能直接在Controller方法上写RestController RequestMapping(/api/users) public class UserController { GetMapping(/list) PreAuthorize(hasRole(ADMIN)) public ResponseResultListSysUser listUsers() { return ResponseResult.success(sysUserMapper.selectList(null)); } GetMapping(/profile) PreAuthorize(hasAnyRole(ADMIN, USER)) public ResponseResultSysUser profile() { // 从SecurityContext拿当前登录用户名 String username SecurityContextHolder.getContext().getAuthentication().getName(); return ResponseResult.success(sysUserMapper.selectByUsername(username)); } }PreAuthorize表达式里可以用的常用方法表达式含义hasRole(ADMIN)拥有ADMIN角色自动匹配ROLE_ADMINhasAnyRole(ADMIN, USER)拥有任意一个指定角色hasAuthority(user:add)拥有指定权限字符串hasAnyAuthority(user:add, user:edit)拥有任意一个指定权限authenticated()已登录即可访问permitAll()全部放行实际项目里推荐用权限字符串如user:add而不是角色名因为权限是细粒度的角色是粗粒度的聚合体。你们业务方说“给管理员加个导出权限”如果按角色搞你得新建一个角色按权限搞只需给管理员这个角色关联一个user:export权限即可。6.2 一个常见坑Spring Boot 2.7里默认使用CGLIB代理对注解生效的影响你可能在热词里刷到过“springboot默认使用cglib代理”。Spring Boot 2.7开始默认spring.aop.proxy-target-classtrue也就是使用CGLIB代理而非JDK动态代理。这对PreAuthorize的影响是当你在类内部调用带权限注解的方法时注解不会生效。比如Service public class OrderService { public void createOrder() { // 直接调用this内部方法不会经过代理PreAuthorize拦截不到 this.cancelOrder(); } PreAuthorize(hasRole(ADMIN)) public void cancelOrder() { // ... } }这里的cancelOrder权限校验不起作用因为this.cancelOrder()走的是原始对象没经过CGLIB代理类。解决办法有两个把方法拆到不同的Service里互相调用或者注入自身代理Autowired private OrderService orderService;然后用orderService.cancelOrder()。这个坑在Spring Security上特别隐蔽因为权限校验是旁路逻辑不报错、不告警但接口没有真正受到保护。我在项目里就吃过一次亏排查小半天才反应过来是内部调用绕过了代理。6.3 进阶从角色到按钮级别的动态权限如果你做到权限管理系统角色下挂菜单、按钮这种动态结构每次请求都查一次用户权限显然不够优雅。实际做法是登录时把用户加载的权限集合塞进SecurityContext这样一次登录权限判断全程在内存里完成。// UserDetailsServiceImpl里加载权限 ListSimpleGrantedAuthority authorities new ArrayList(); // 加载角色 roles.forEach(role - authorities.add(new SimpleGrantedAuthority(ROLE_ role.getRoleCode()))); // 同时加载权限字符串按钮级别 ListString permissions sysPermissionMapper.selectByUserId(user.getId()); permissions.forEach(p - authorities.add(new SimpleGrantedAuthority(p))); return new User(user.getUsername(), user.getPassword(), authorities);这样你在方法上就能用hasAuthority(sys:user:add)控制到按钮粒度前端再配合v-ifhasPerm(sys:user:add)做按钮显隐就是一套完整的权限体系了。6.4 关于Spring Security 6.x迁移的一点提前说明我在开头提到版本这里补一句。Spring Security 5.7和6.x最核心的差异有三点WebSecurityConfigurerAdapter被彻底移除必须用Lambda配置antMatchers()改名为requestMatchers()路径匹配规则有变化EnableGlobalMethodSecurity改名为EnableMethodSecurity。如果你现在用的是5.7的Lambda写法迁移到6.x只需把方法名改一下结构基本不用动。这也是我坚持不教你WebSecurityConfigurerAdapter的原因——你从旧写法迁移等于重写一遍从5.7新写法迁移只是改几个方法名。7. 前后端联调跨域、放行、登录态保持的实操经验配置全写完了代码全跑通了最后是前后端联调阶段最容易出问题的几个点每个都是我实际踩过的。7.1 跨域配置有三处漏一处就联调不了前后端分离项目跨域是必聊话题。注意Spring Security的跨域不只是配一个CrossOrigin或者CorsFilter那么简单过滤器链的顺序决定了跨域能不能生效。如果CORS过滤器放在Security过滤器链之外预检请求会先被Spring Security拦截。我推荐的做法是在SecurityConfig里统一配置Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration new CorsConfiguration(); // 允许的前端地址生产环境改成具体域名 configuration.setAllowedOriginPatterns(Arrays.asList(*)); configuration.setAllowedMethods(Arrays.asList(GET, POST, PUT, DELETE, OPTIONS)); configuration.setAllowedHeaders(Arrays.asList(*)); configuration.setAllowCredentials(true); configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, configuration); return source; }然后在filterChain上加上http.cors().and()...这样Spring Security会自动把CORS过滤器加进链的最前面所有请求先过跨域再走认证。最关键的是预检请求OPTIONS被CORS过滤器处理掉了不会到达后面的认证过滤器省掉我在JwtAuthenticationFilter里写CorsUtils.isPreFlightRequest的判断逻辑留着也无妨双保险。千万不要用全局CrossOrigin注解一个一个Controller加容易漏而且和Security的过滤器顺序很难调和。7.2 白名单里的一个真实翻车案例我最早的白名单配置里忘了加/error这个路径。Spring Boot有一个默认的/error端点当请求处理过程中发生异常异常会被BasicErrorController转发到/error。但此时请求已经进过Security认证层如果这个路径不在白名单里会再次触发认证逻辑导致403而不是原始的500或400错误。前端拿到的永远是个“没有权限访问该资源”排查了半天才发现是我遗漏了/error。所以白名单最终的形态private static final String[] WHITE_LIST { /api/auth/login, /api/auth/register, /api/auth/captcha, /error, /swagger-ui.html, /swagger-ui/**, /swagger-resources/**, /v2/api-docs, /webjars/**, /doc.html };7.3 文件上传/下载接口和MinIO这类对象存储服务的联动项目里文件上传一般走MinIO这类对象存储服务。这时候有两个选择方式一直接调MinIO的预签名URL上传不走后端接口。这种情况下上传动作完全绕过后端认证前端拿到的URL有时效性安全性靠MinIO自身的签名机制保证和Spring Security无关。方式二上传走自己的后端接口你写一个/api/file/upload接口这个方法要加PreAuthorize(isAuthenticated())保证只有登录用户才能上传。文件落库时顺手把上传用户ID存进去后面排查违规文件时责任明确。我的经验是敏感业务文件走方式二因为要在数据库里留审计记录临时文件、公开图片走方式一省流量省性能。7.4 如何关闭/调整SpringDoc避免文档接口暴露你热词里提到的“springboot怎么关闭springdoc”。如果你用了springdoc-openapi做接口文档又不想让它在生产环境暴露做法是在配置文件里springdoc: api-docs: enabled: false swagger-ui: enabled: false但如果只是不想让未登录的人看文档就在Security白名单里去掉/doc.html和/v3/api-docsSpring Boot 3或/v2/api-docsSpring Boot 2只要登录可访问即可。两种思路二选一别同时配两套逻辑互相打架。7.5 关于Tomcat部署和Docker部署的一点提示前后端分离项目打jar包部署到Tomcat时Spring Security的配置不用动但要注意Tomcat的Session机制和Spring Security的stateless策略配合——我们的SessionCreationPolicy已经设成STATELESSTomcat的Session就不应该被应用使用。如果发现部署后每个请求都创建Session可以通过HttpSession调试检查一下是不是哪些代码误用了request.getSession()。用Docker部署时就简单了jar包跑在容器里端口映射出来Security层面不用特殊处理。唯一要注意的是环境变量注入密钥的问题JWT的SECRET_KEY一定不要打进镜像里用环境变量或者配置中心动态注入。镜像被人拉走密钥随之泄露等于Token可以被任意伪造整个认证体系就废了。8. 总结这份教程里我最想让你记住的几个点这套教程从头到尾跑下来其实一天就能完成但真正让我花时间的不是代码而是那些网上语焉不详的概念。最后我用几句话把核心逻辑再串一遍就当是我自己的长期记忆切口认证流程登录接口拿到用户名密码 - AuthenticationManager校验 - 通过后签发JWT - 前端存Token - 后续请求Header带Token - JwtAuthenticationFilter解析并注入SecurityContext - 授权过滤器校验。授权流程角色/权限在UserDetailsService里转换成Authority - 请求进入授权过滤器 - 根据PreAuthorize或URL规则判断 - 通过放行不通过交给AccessDeniedHandler。异常流程未登录 - AuthenticationEntryPoint返回401 - 前端收到后清Token跳登录页已登录但越权 - AccessDeniedHandler返回403 - 前端提示无权限。这几个闭环想清楚Spring Security在你手里就和普通工具没有区别。最后再说一个我实际用了一年之后才觉得值得做的事务必给JWT加一个“版本号”字段。用户修改密码或管理员封号时把该用户Token里的版本号对应记录置为失效这样Token即使没过期也无法继续使用能在不引入Redis的情况下实现主动踢人。我是在上线三个月后才补的这个功能当初设计Token结构时完全没想到这个场景导致功能上线后改起来比预期费事。如果让我重来一遍我第一步就会把Token的claims设计成{username, tokenVersion, created, exp}这个先见之明能帮你省很多后顾之忧。
返回列表