
1. 入门前的全景视角Spring Security到底在解决什么问题如果你动手写过几个后端接口很快就会有这种感觉自己写的登录逻辑总是差点意思——密码明文存数据库、接口谁都能调、权限判断散落在业务代码里到处是if。一开始项目小无所谓等接口一多或者要交付给客户验收安全问题就立刻成了短板。Spring Security就是来解决这一整套“访问控制”问题的它是一套基于过滤链的认证与授权框架和Servlet容器深度集成靠一组Filter把请求在进入Controller之前拦住完成身份确认、权限判定、会话管理等动作。换句话说它让你不用从零手写那套“每次请求都要检查token、查数据库、判断角色”的重复代码而是用一套成熟的、可扩展的机制把这些公共逻辑下沉到底层。那这篇快速入门能给你什么我会从一个没有任何Security经验、但已经写过Spring Boot接口的开发者的视角带你把这个框架的核心脉络捋一遍先理解它的整体设计和运行模型再动手搭一个基于数据库账号密码的完整示例最后深入到定制化改造和性能调优。不论你用的是Spring Boot 2.x还是3.x这套思路都通用版本差异我会在关键节点单独标注。注意如果你只是想快速跑通一个“能登录、能拦截权限”的Demo本文前半部分足够如果你想真正用在自己的项目里把后半部分的“定制化常见场景”读透能帮你少踩很多坑。2. 核心模型拆解认证、授权、过滤器链和安全上下文2.1 认证与授权不能混为一谈很多新手容易把“登录”和“权限”搞到一起去觉得登录成功之后权限自然就对了。实际上这是两件完全不同层级的事。认证解决的是“你是谁”的问题——出示凭证账号密码、短信验证码、第三方token系统确认身份成立由此得到一个表示当前用户的身份对象。授权解决的是“你能干什么”的问题——系统根据身份判断你有权访问哪些URL、调用哪些方法、操作哪些数据。Spring Security把这两件事用两道防线分别处理认证由AuthenticationManager牵头经过一系列AuthenticationProvider去校验凭证授权则交给授权管理器在请求级别进行antMatchers/requestMatchers的路径匹配在方法级别配合PreAuthorize这类注解做细粒度控制。理解这个区分之后再回头看常见的报错就清楚多了401是“没认证成功”而403是“已经认证了但权限不够”。这俩在http响应上的状态码不同对应的排查思路也完全不同。2.2 过滤器链请求到达Controller前要过几道关卡Spring Security在Servlet中注册了一条由多个Filter组成的过滤器链。这就像进入小区要经过好几道门禁第一道查有没有门禁卡认证第二道看你是不是这个小区的业主授权第三道再看你是业主还是租客角色。常见的几道关卡包括SecurityContextPersistenceFilter从Session中恢复安全上下文UsernamePasswordAuthenticationFilter拦截登录请求构建认证BasicAuthenticationFilter处理HTTP Basic认证ExceptionTranslationFilter捕获过滤链中的认证/授权异常翻译成对应的HTTP状态文字AuthorizationFilter对请求做最终的授权判断这些过滤器的顺序不是随便排列的上下文恢复一定要在最前面因为后面的过滤器都需要从SecurityContext里拿当前用户信息授权过滤一定在身份认证过滤器之后否则连登录用户是谁都不知道就去判断权限肯定会误伤。实操提示如果你在配置类里看到http.addFilterBefore()、http.addFilterAt()这类API就是在往这条链里插自定义过滤器。插入位置极其重要插错了会导致你的校验逻辑根本执行不到或者已经被之前的安全策略拦截了。2.3 SecurityContextHolder当前请求的“临时身份卡”认证成功之后身份信息保存在哪答案是SecurityContextHolder它本质上是ThreadLocal存储当前线程的安全上下文。SecurityContext里装着一个Authentication对象这个对象里有三部分principal用户主体通常是UserDetails对象、credentials凭证通常认证成功后会清空防止泄露、authorities权限集合一个或多个GrantedAuthority。这个机制直接影响你的业务代码怎么拿当前用户。在任意一个Controller方法里可以写SecurityContextHolder.getContext().getAuthentication()拿到身份对象然后取principal转成你自己的用户类。很多项目会封装一个AuthenticationPrincipal作用同样。需要特别注意的是请求结束、线程回到线程池时ThreadLocal要清理干净否则下一个请求可能拿到上一个用户的信息。Spring Security的过滤器链在finally中会自动做清理但如果你的框架里有异步线程、手动创建的线程池就得小心上下文丢失和串用户的问题。经验总结多线程环境下千万不要直接在子线程里调用SecurityContextHolder.getContext()你拿到的大概率是null因为上下文根本没有传递过去。要么在进入子线程前把Authentication对象手动传进去要么使用Spring Security提供的DelegatingSecurityContextExecutor/DelegatingSecurityContextRunnable来包装任务。3. 从零搭建第一个工程引入依赖与分段式配置3.1 引入starter依赖先感受“默认安全”创建一个常规的Spring Boot项目在pom.xml中加入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency只引入这个依赖安全功能就已经生效了——所有接口都会被保护控制台会打印一串默认密码账号是user。这就是框架的“零配置默认安全”启动项目再访问任意接口会弹出HTTP Basic认证对话框或者返回401。这一步是很多教程跳过的细节但恰恰是我建议你先自己体验的在什么都不配的情况下框架默认把所有请求都锁死了。Security的设计理念就是“先默认拒绝再显式放行”这和很多自己写的脚手架“默认全放行遇到敏感接口才校验”的思路正好相反。3.2 从UserDetailsService到PasswordEncoder配置类的最小闭环只靠默认账号没法真正用起来接下来写配置类。先定义一个继承WebSecurityConfigurerAdapter的类——注意这里有一个重要版本差异Spring Boot 2.7.x及之前继承WebSecurityConfigurerAdapter再重写configure方法是最常见写法从Spring Security 5.7.0起官方推荐直接以SecurityFilterChain的Bean方式配置。我建议新项目直接采用新写法因为旧习惯切换到新版本时会很痛苦。核心配置代码如下Configuration EnableWebSecurity public class SecurityConfig { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public UserDetailsService userDetailsService(DataSource dataSource) { JdbcUserDetailsManager manager JdbcUserDetailsManager(dataSource); return manager; } Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(/public/**).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .anyRequest().authenticated() ) .formLogin(form - form .loginProcessingUrl(/login) .permitAll() ) .logout(logout - logout.logoutSuccessUrl(/login?logout)); return http.build(); } }这段代码背后有三个关键点逐一说明。第一BCryptPasswordEncoder是最常用的密码编码器哈希过程引入随机盐所以同一个密码每次加密结果不同这是正确的——不要写一个equals去比对密文而是调用passwordEncoder.matches(rawPassword, encodedPassword)。第二JdbcUserDetailsManager是Spring Security内置的数据库账号实现默认表结构是users和authorities两张表。初学者最省力的方式就是用它的默认SQL建表然后插入测试账号。第三requestMatchers的路径匹配是有顺序的越具体的规则越放前面anyRequest()兜底。这里很多人踩坑把permitAll写在authenticated后面导致公共接口也被锁了。3.3 建表、插入用户跑通第一个真实登录为了演示用内存数据库H2或者本地的MySQL都行建立表结构。Spring Security官方文档能找到users和authorities的默认建表语句我简化如下create table users ( username varchar(50) not null primary key, password varchar(100) not null, enabled boolean not null ); create table authorities ( username varchar(50) not null, authority varchar(50) not null, constraint fk_authorities_users foreign key(username) references users(username) );插入用户时密码必须用BCrypt编码后的字符串不能直接明文。写一个初始化代码生成密文public static void main(String[] args) { BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); String rawPassword 123456; String encoded encoder.encode(rawPassword); System.out.println(encoded); }然后把编码后的字符串插入到users表再给admin角色insert into users (username, password, enabled) values (admin, $2a$10$..., true); insert into authorities (username, authority) values (admin, ROLE_ADMIN);启动项目用刚才配置的loginProcessingUrl(/login)提交表单如果一切正常就会跳转到默认的成功页面访问/admin/**下的接口时管理员可进普通用户进不去。能走到这一步说明你已经跑通了认证和授权的最小闭环。4. 授权模型详解从路径规则到方法级权限4.1 不同场景下的路径防护方案Spring Security授权模型里requestMatchers是核心入口。Spring Security 6.1之后antMatchers、mvcMatchers统一收拢到requestMatchers传参既是路径字符串也可以传AntPathRequestMatcher、MvcRequestMatcher等更复杂的匹配器。常用的路径规则分三层第一层完全不设防。/public/**、/static/**、/login、/register等用permitAll()放行。第二层必须有身份但任意角色都行。anyRequest().authenticated()。第三层必须在身份之上再限定角色。/admin/**需要ROLE_ADMIN/user/**需要ROLE_USER。注意hasRole(ADMIN)在源码里会自动加上ROLE_前缀也就是说数据库里的authority要存ROLE_ADMIN。而hasAuthority(ROLE_ADMIN)则不做前缀追加你写什么就比什么。这个细节经常导致“明明数据库有权限但访问403”的情况。4.2 在方法层面做细粒度拦截路径匹配对很多场景已经够用了但它有局限同样的URL不同用户进来可能要根据资源归属判断能不能操作。比如“用户只能编辑自己的文章”路径规则就写不出这种逻辑。这时用方法级安全注解更合适。先开启注解功能Configuration EnableWebSecurity EnableMethodSecurity public class SecurityConfig { // ... }然后在Service方法上标注PreAuthorize(hasRole(ADMIN)) public void deleteUser(Long id) { // ... } PreAuthorize(#username authentication.principal.username) public void updateProfile(String username) { // ... }PreAuthorize接受SpEL表达式能读取方法参数通过#参数名、认证对象通过authentication.principal甚至能调用bean方法。这个功能在“数据级权限判断”上非常实用能把复杂的业务权限逻辑收敛到注解表达式里而不是在Service内部写一堆if和取用户信息的模板代码。我实操心得很深的一点是启用方法安全之后代理机制会介入。如果你的Service方法被同类内部调用this.method()注解不会生效因为Spring的AOP代理不会被内部自调用触发。要么注入自己要么拆到别的Bean里要么确保调用是通过代理对象进行的否则你会怀疑“明明加了注解怎么不管用”。4.3 角色继承与权限树真实业务中权限往往有层级关系——比如ROLE_ADMIN天然包含ROLE_USER的权限。Spring Security提供RoleHierarchy接口配置一个角色继承关系就不用重复在数据库里给admin同时挂USER和ADMIN两个权限了。Bean public RoleHierarchy roleHierarchy() { RoleHierarchyImpl hierarchy new RoleHierarchyImpl(); hierarchy.setHierarchy(ROLE_ADMIN ROLE_USER); return hierarchy; }注意这个配置不能省如果你在SecurityFilterChain里用到了roleHierarchy()Spring Security在方法安全上提取roleHierarchy的时候要从创建的WebSecurityExpressionHandler里去拿。配置Bean的名称不变、全限定类名匹配通常能正常装配但版本升级后要留意是否有告警日志。少配置这个Bean时最常见的问题是“admin能访问路径但调用带PreAuthorize(hasRole(USER))的方法却403”。路径级别和方法级别用的表达式处理器不是同一个实例。5. 定制与改造数据库动态用户、JWT接口认证是怎么接进来的5.1 从固定表结构到自建用户表的迁移方案JdbcUserDetailsManager默认的表结构固定得很死很多项目的用户表是sys_user字段名也不是enabled、authority这一套。最简单的做法是不依赖JdbcUserDetailsManager自己实现UserDetailsService。Service public class UserDetailsServiceImpl implements UserDetailsService { Autowired private UserMapper userMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user userMapper.selectByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } ListGrantedAuthority authorities userMapper.selectRolesByUserId(user.getId()) .stream() .map(role - new SimpleGrantedAuthority(ROLE_ role.getRoleCode())) .toList(); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.getEnabled(), true, true, true, authorities ); } }一旦UserDetailsService这个Bean存在Spring Security在认证时就会自动使用它。你不需要改SecurityFilterChain的配置登录逻辑就自然切换到你的数据库查询。这是典型的“面向接口编程”的好处——框架依赖的不是具体实现而是UserDetailsService契约。5.2 无状态加持JWT如何利用过滤器链接入大多数前后端分离项目受不了Session改用JWT把用户身份信息直接放在token里。Spring Security本身没有绑定JWT你要做的就是写一个自定义过滤器从请求头里读取token解析出用户信息手动放进SecurityContextHolder然后放行给后续的授权过滤器。常见结构分三部分。JWT工具类负责生成和解析tokenComponent public class JwtTokenUtil { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private Long expire; public String generateToken(String username) { return Jwts.builder() .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expire)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public String getUsernameFromToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody().getSubject(); } public boolean validateToken(String token) { try { Jwts.parser().setSigningKey(secret).parseClaimsJws(token); return true; } catch (Exception e) { return false; } } }JWT认证过滤器扩展OncePerRequestFilterComponent public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtTokenUtil jwtTokenUtil; Autowired private UserDetailsService userDetailsService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { String token header.substring(7); if (jwtTokenUtil.validateToken(token)) { String username jwtTokenUtil.getUsernameFromToken(token); UserDetails userDetails userDetailsService.loadUserByUsername(username); if (userDetails ! null) { UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } } } chain.doFilter(request, response); } }注意OncePerRequestFilter这个类名的意义它保证过滤器在一次请求中无论经过多少次转发只执行一次避免token被重复解析导致性能浪费和重复认证的问题。然后在SecurityFilterChain里手动注册这个过滤器http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);插入位置为什么是在UsernamePasswordAuthenticationFilter之前核心原因在于UsernamePasswordAuthenticationFilter是负责账号密码表单登录的而JWT场景下请求里没有账号密码你的过滤器必须先解析出身份如果你把JWT过滤器放在它的后面那么AuthenticationManager在遇到没有账号密码的请求时会直接抛出异常整个请求根本轮不到你的过滤器执行。放在它之前是让JWT认证得出的Authentication对象有机会被后续的授权逻辑读取。5.3 记住我、登录失败处理、退出登录的细节配置实用的登录流程不止验证账号密码还包括失败处理、记住我、退出处理这几个环节。失败处理可以自定义AuthenticationFailureHandler比如返回JSON提示“密码错误”、记录失败次数、限制暴力破解。成功处理可以自定义AuthenticationSuccessHandler把生成的token放进响应体。.formLogin(form - form .successHandler((request, response, authentication) - { response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:200,\msg\:\登录成功\}); }) .failureHandler((request, response, exception) - { response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\密码错误\}); }))退出登录需要注意如果接口是无状态的Session注销本身没有意义真正的“退出”是客户端把token丢弃。但服务端通常可以做一层token黑名单或者依赖token的过期时间。简单的项目可以让登录成功返回的token有效期短一些配合刷新机制来控制登出。经验提醒:记住我功能的rememberMe()实现依赖cookie和数据库token存储适合传统Session项目。你如果做的是前后端分离加JWT直接不用考虑它免得被cookie问题绕晕。6. 常见问题与排查工具经验6.1 登录后重复循环、403、无状态接口401的速查表遇到安全相关的问题第一步不是看业务代码而是抓包看请求状态码和响应头。下面是常见问题的对照表。现象状态码典型原因排查方向访问任何接口都弹登录框401默认安全策略生效没配置放行检查requestMatchers的放行顺序登录成功但跳404404loginProcessingUrl和表单提交的action不一致核对前后端提交路径GET能访问、POST被403403CSRF防护默认开启前后端分离环境下建议csrf(csrf - csrf.disable())明明给用户配了角色还是403403hasRole和数据库authorities前缀不一致检查ROLE_前缀是否匹配静态资源一直加载不出来403或302静态资源路径被authenticated()接管将/static/**、/css/**、/js/**放行JWT过滤器没执行401过滤器位置插错检查addFilterBefore的注册顺序CSRF这个点值得单独说明。传统浏览器表单提交存在跨站伪造请求风险Spring Security默认开启防CSRF的token校验。但纯接口项目里每个请求都带随机token很麻烦所以绝大多数前后端分离项目会直接关掉这个防护——前提是你确认接口不是靠浏览器cookie做认证的并且调用方都是你自己的前端应用。如果实在是老系统必须保留cookie认证不要关CSRF而是在前端请求头里加上同步token。6.2 写自定义过滤器前的三个检查很多新手在自定义过滤器环节翻车原因往往不在代码本身而在对过滤器链的理解。我建议做三件事。第一确认你的过滤器被注册了。Spring Boot的组件扫描一般是能扫到Component的但如果你的配置类路径和过滤器类路径不在同一层级或者过滤器类没有标注任何Bean注解它在链上就不存在。第二确认它执行的位置。可以在过滤器里加一行日志启动后访问接口看日志在哪个阶段打印。如果日志出现在认证之后那你的过滤器可能插错了位置。第三确认异常处理方式。过滤器里的异常不会自动进入ControllerAdvice的异常处理器也不会被ExceptionTranslationFilter正常接住。你需要在过滤器里手动response.sendError()或者调用setStatus返回JSON否则会出现“控制台报错前端拿到的是一个奇怪的页面”的情况。6.3 排查日志与常见性能问题Spring Security本身可能产生大量DEBUG级别日志特别是过滤器链的执行过程非常适合学习阶段开着看。在application.yml中配置logging: level: org.springframework.security: DEBUG你会看到类似Securing GET /admin/user、Authorization successful这样的日志能从链路层面看出是哪个过滤器拦截了请求。排查时通过Secured开头、Filter顺序等关键词能快速定位到具体拦截点。性能方面需要注意三个点。密码哈希BCryptPasswordEncoder是CPU密集型操作强度默认10如果认证并发很高建议评估是否需要降低强度或者引入缓存。JWT的解析是对称/非对称密钥的运算本身不算重但每次请求都会解析一次热点接口如果服务器负载很高可以把解析结果按token做短时缓存。数据库查询是真正的瓶颈——UserDetailsService的loadUserByUsername每次登录都会执行如果账户表数据量很大字段上要有索引否则登录接口会拖垮数据库。6.4 升级版本时最容易踩的坑Spring Security的版本演化对初学者很不友好主要体现在配置API重命名上。我从2.x走到现在最深刻的体会是不要盲从老教程先确认版本再搜配置。归纳如下几处关键变迁方便对照5.7起WebSecurityConfigurerAdapter被标记为过时官方推荐组件化SecurityFilterChain配置。5.4起antMatchers逐渐被requestMatchers取代到6.x里authorizeRequests()换成了authorizeHttpRequests()。6.0起默认不允许http.formLogin()之后不指定登录页面而再调用and()链式写法。DSL风格变成了lambda配置老写法直接报错。6.3起构造器要求显式指定UserDetailsService和AuthenticationManager的情况变多内部默认行为开始收缩。所以如果你翻到一篇两三年前的帖子照着敲然后发现编译不过不一定是你代码写错了很可能是版本差异。最简单的处理方式就是搜索时加上你当前使用的“版本号 类名 方法名”。7. 实战后的几点经验沉淀这篇文章写到这里核心内容已经覆盖了搭一个Spring Security项目从入门到能定制改造的完整路径。最后沉淀几个我自己在实际项目中的体会。第一个体会不要上来就引一堆高级特性。先跑通默认安全策略再逐步加入数据库用户、JWT过滤器和细粒度权限注解。每一步都验证一个环节出了问题能快速定位。这套框架的抽象层级很高一上来就组合多种自定义实现排查问题的成本会成倍增加。第二个体会权限模型要先想清楚再动手。是用RBAC角色-权限分离还是简单角色判断是路径级权限还是方法级权限这个决定会主导你的表结构、配置编写和后续维护成本。很多项目做到一半发现角色和权限混在一起不得不回头重构UserDetailsService和授权表达式非常痛苦。第三个体会不要把安全逻辑散落在业务代码里。Spring Security给你的能力是集中管理认证和授权如果你在Service里又手动判断角色、又手动解析token那就等于绕过了框架还会遇到线程上下文拿不到用户信息的各种诡异问题。真正合理的做法是能通过PreAuthorize和路径规则表达的逻辑绝不写死在方法内部。最后一个建议把org.springframework.security的DEBUG日志当成你的学习伙伴。每次看到请求经过哪些过滤器、为什么被拒绝、授权表达式是怎么求值的都会加深你对这套机制的理解。等你能熟练阅读这些日志来定位问题时你对Spring Security的掌握就已经超过大多数“只会在网上找配置复制粘贴”的开发者的水平了。