ARTICLE DETAIL

资讯详情

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

手搓极简Spring Security:从过滤器链到认证授权全解析

手搓极简Spring Security:从过滤器链到认证授权全解析 1. 从“会用框架”到“手搓一个”——这项目到底在解决什么问题先说个我自己的真实经历。有段时间我在维护一个老项目里面用到了 Spring Security每次加个接口权限都要翻半天文档一会儿antMatchers被废弃了一会儿 Spring Boot 3 里SecurityFilterChain又换了一套写法WebSecurityConfigurerAdapter直接没了。更头疼的是一旦出现 403 或者过滤器链顺序不对排查问题就像在黑洞里摸象——你只知道结果不对但中间到底经过了多少个 Filter、每个 Filter 里干了什么完全是一笔糊涂账。当时我就在想如果我亲手从头写一个极简版的安全框架哪怕功能只有 Spring Security 的十分之一但每一个判断都是我写的、每一行逻辑我都知道为什么存在是不是以后再遇到官方框架的问题就不会这么被动了于是就有了“手搓一个 Spring Security”这个项目。这个项目不是要造一个生产级的轮子去替代 Spring Security而是要把安全框架最核心的那几条链路拆开、揉碎、再拼起来。具体来说我给自己定的边界是支持基于 Session 的登录认证用户名密码支持简单的角色权限控制能拦截 URL支持方法级注解鉴权比如RequireRole(ADMIN)能自动配置像 Spring Boot Starter 一样引入依赖就能用最后把它封装成一个自定义 Starter看看框架化要解决哪些问题如果你正在用 Spring Security 但总觉得它像个黑盒或者你面试时被问到“Spring Security 的过滤器链是怎么工作的”只会背概念那这篇文章很适合你。我会用 Java Spring Boot 3 的实现思路把整个手搓过程完整讲一遍包括我踩过的坑和绕过的远路。提示这个项目的完整代码我会在文中分阶段给出关键实现但更像是一份“设计蓝图 核心代码片段”建议你跟着思路自己敲一遍收获会比我直接甩一个 GitHub 仓库链接大得多。2. 地基怎么打——基于 Servlet Filter 的安全拦截链路安全框架要解决的第一个问题不是“怎么判断用户是谁”而是“我怎么能在每个请求进来的时候都插一脚”。Spring Security 的答案是 Filter我们也躲不开这条路。2.1 为什么一定是 Filter而不是 Interceptor 或 AOP很多初学者会问拦截请求用 Spring MVC 的HandlerInterceptor不就行了吗为什么安全框架非得用 Filter关键在于执行时机。HandlerInterceptor是 Spring MVC 的组件它生效的前提是——请求已经进入了 Spring MVC 的DispatcherServlet。也就是说在拦截器动手之前请求已经经过了一系列处理。而Filter是 Servlet 容器层的组件它在请求到达DispatcherServlet之前就已经执行了。简单打个比方Filter是小区门口的保安任何车要进小区都得先过他这一关HandlerInterceptor是单元楼的闸机车已经到了楼下才验证。安全这件事必须要在最外围做否则静态资源、恶意请求就可能绕过 Spring MVC 直接打到容器层。你总不希望一个没登录的人已经走进了单元楼才被闸机拦下来吧而且 Filter 还有个特点它是标准的jakarta.servlet接口不依赖 Spring天然适合做通用安全能力。Spring Security 之所以强大正是因为它把这套 Filter 机制玩出了花用一条过滤器链串起了认证、授权、异常处理、会话管理等所有能力。2.2 手写一个最基础的 SecurityFilter我们要实现的第一个组件就是一个能嵌入任何 Servlet 应用的 Filter。它的职责很纯粹拿到请求检查“这个人认没认过证”没认证就拦下来。先定义一个自己的SecurityFilterpublic class SecurityFilter implements Filter { // 一个简单的过滤器链抽象后面会扩展 private final ListSecurityHandler handlerChain new ArrayList(); Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; HttpServletResponse httpResponse (HttpServletResponse) response; // 遍历我们自己定义的处理器链 for (SecurityHandler handler : handlerChain) { if (!handler.handle(httpRequest, httpResponse)) { // 处理器返回 false说明请求被拦截未登录/无权限 return; } } // 所有检查都通过了放行到下一个 Filter 或 Servlet chain.doFilter(request, response); } }这里我引入了一个SecurityHandler接口目的是把后续的认证、授权逻辑都变成链上的一个节点这样扩展起来方便。你看到这个结构是不是觉得有点像 Spring Security 的SecurityFilterChain没错它就是受到FilterChain的启发——本质上所有安全框架的拦截模型都是同一套思想把安全策略做成一个个独立的处理器按顺序执行任何一个不通过就中断请求。这里的注册方式先不用考虑框架化直接手动构建一个实例放进FilterRegistrationBean就行Configuration public class SecurityManualConfig { Bean public FilterRegistrationBeanSecurityFilter securityFilterRegistration() { FilterRegistrationBeanSecurityFilter registration new FilterRegistrationBean(); registration.setFilter(new SecurityFilter()); registration.addUrlPatterns(/*); registration.setOrder(Integer.MIN_VALUE 100); return registration; } }注意setOrder这个细节安全过滤器一定要尽可能往前排让它在最早阶段介入请求否则后面如果还有别的过滤器容易漏掉请求。Spring Security 的springSecurityFilterChain默认 order 就是SecurityProperties.DEFAULT_FILTER_ORDER也就是-100跟这里的思路一致。经验之谈如果你在 Spring Boot 3 里发现自己的过滤器没有生效先检查两件事一是FilterRegistrationBean有没有加/*二是 order 是不是被别的过滤器挤到后面去了。这两个低级问题我至少遇到过三次。2.3 一个 Handler 节点应该长什么样有了基座之后我们定义SecurityHandler接口。真正的设计点在于——如何处理“通过”和“不通过”这两种结果。public interface SecurityHandler { /** * 处理请求 * * return true 表示继续往后执行false 表示请求已被处理要中断 */ boolean handle(HttpServletRequest request, HttpServletResponse response) throws IOException; }这个设计很简单但它跟 Spring Security 内部的Filter链有两个重要差别Spring Security 的过滤器是完整的Filter有自己的doFilter可以决定“继续调下一个 Filter”还是“直接写响应”。我们这里简化为一个布尔返回值少了嵌套的FilterChain代码更直观但表达力弱一些。如果你的项目想模拟得更逼真可以让 handler 也持有FilterChain自己决定怎么调。Spring Security 的 Filter 之间有复杂的顺序关系UsernamePasswordAuthenticationFilter必须在AuthorizationFilter之前因为先认证才能授权。我们的链表同样要控制顺序但顺序规则简单得多就写在SecurityFilter里按列表顺序遍历。有了这个 Button 之后下一步要填充的就是这个链上最核心的两个节点认证节点和授权节点。认证这段是真正硬碰硬的地方我把整个过程拆成了三步来写。3. 认证这块硬骨头——从登录到会话的完整闭环很多人学 Spring Security 第一个接触的概念就是UsernamePasswordAuthenticationFilter它拦截登录请求调用AuthenticationManager最后把Authentication对象塞进SecurityContext。我们手搓版本要解决的问题是一样的但我不打算完全复刻它的复杂结构而是抓住几个最关键环节登录接口、令牌创建、会话保存、请求重建登录态、退出登录。3.1 用户信息从哪来——UserDetailsService 抽象安全框架第一步要确认的是“用户名密码对不对”。大部分教程把这一步写成查数据库但它不该耦合具体的数据访问方式。用户可能来自数据库、Redis、LDAP也可能是配置文件里写死的账号。所以第一步是抽象一个“根据用户名加载用户信息”的接口我给它取名叫UserDetailsService没错跟 Spring Security 同名因为它解决问题的方式是正确的public interface UserDetailsService { UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; }这个接口返回UserDetails它是一个脱敏后的用户模型只暴露跟安全相关的信息比如用户名、密码编码后的、角色列表。真正的用户实体不该直接流到安全层否则在日志不小心打印的时候会把不必要的字段都带出来。我们定义一个简单的UserDetailspublic class UserDetails { private final String username; private final String password; // 这里存的是 BCrypt 后的密文 private final ListString roles; // 角色列表如 ADMIN, USER // 构造器、getter 省略 }这段逻辑看似只有几行但它明确了安全框架和业务代码的边界安全框架只认抽象接口不关心用户数据存在哪里。你想换数据源就实现一个新的UserDetailsService安全框架的其他部分一行都不用改。这个抽象我强烈建议你保留它是整个认证体系里最容易被忽略但价值最高的设计。3.2 登录接口的实现——校验密码、签发会话接下来是登录接口。按照现代安全实践密码绝对不能用明文。我们使用BCrypt哈希算法来校验因为它是自适应哈希通过增加计算时间成本来抵抗暴力破解。代码大致是这样的RestController public class LoginController { private final UserDetailsService userDetailsService; private final TokenManager tokenManager; PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest loginRequest) { // 第一步加载用户 UserDetails userDetails userDetailsService.loadUserByUsername(loginRequest.getUsername()); if (userDetails null) { throw new BadCredentialsException(用户名或密码错误); } // 第二步校验密码 if (!BCrypt.checkpw(loginRequest.getPassword(), userDetails.getPassword())) { throw new BadCredentialsException(用户名或密码错误); } // 第三步签发令牌并存入会话存储 String token tokenManager.createToken(userDetails.getUsername(), userDetails.getRoles()); return ResponseEntity.ok(new LoginResponse(token)); } }这里有几个容易想当然的细节得展开讲讲。第一BCrypt.checkpw的返回值概率上可能出问题吗BCrypt 的实现中两个不同的密码哈希碰撞的几率极小可以忽略不计。但这里有一个很现实的坑如果你的注册模块用的是BCrypt.gensalt()生成盐登录校验时必须用同一个checkpw方法不要自己去解密比对因为 BCrypt 的盐是嵌在密文里的。第二为什么返回给前端的是一个token而不是直接把用户信息放进 Session核心目的是让登录态可以脱离“Servlet 原生 Session”存在。Session 和 Token 之争在业界很常见我们手搓版本的选择是签发一个随机的 Token在服务端维护一个 token - 用户信息的映射表。这样有两个好处一是可以精确控制会话过期时间二是可以在服务端主动踢人下线把映射删掉即可。而如果是无状态的 JWT踢人下线就很难实现了。3.3 Token Manager——把会话状态管起来TokenManager是这个设计里的状态中心。它内部持有一个并发安全的映射表负责 token 的创建、校验、续期和销毁。Component public class TokenManager { // token - 会话详情 private final ConcurrentMapString, SessionInfo sessionStore new ConcurrentHashMap(); private final SecureRandom secureRandom new SecureRandom(); public String createToken(String username, ListString roles) { // 生成一个足够随机的 token byte[] bytes new byte[32]; secureRandom.nextBytes(bytes); String token Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); SessionInfo sessionInfo new SessionInfo(username, roles, System.currentTimeMillis()); sessionStore.put(token, sessionInfo); return token; } public SessionInfo validateToken(String token) { SessionInfo sessionInfo sessionStore.get(token); if (sessionInfo null) { return null; } // 过期判断 long expiredAt sessionInfo.getCreatedAt() sessionTimeoutMillis; if (System.currentTimeMillis() expiredAt) { sessionStore.remove(token); return null; } return sessionInfo; } public void invalidateToken(String token) { sessionStore.remove(token); } }为什么 token 要 32 个随机的字节再用 Base64 编码因为安全的会话凭证必须有足够的熵。32 字节的随机数意味着 256 位随机空间暴力枚举在合理时间内不可行。如果你用 UUID 或者时间戳拼字符串当 token等于给攻击者开了后门。这个细节在生产代码里尤其要命。过期时间这里我用了构造器注入一个sessionTimeoutMillis具体配置放到application.yml里。手搓安全框架也要养成好习惯凡是策略性的东西都给个配置项别写死在代码里。mine-security.session-timeout30m3.4 认证 Filter——每次请求都重建登录态有了 token 之后每次请求怎么让安全层知道“你是你”答案是请求头里带上Authorization: Bearer token认证过滤器从 header 里取出 token校验通过后把用户信息放进请求上下文。这个AuthenticationFilter就是我们第 2 章里所说的 handler 链的第一个重要节点。它的核心逻辑如下public class AuthenticationFilter implements SecurityHandler { private final TokenManager tokenManager; Override public boolean handle(HttpServletRequest request, HttpServletResponse response) throws IOException { // 登录接口本身放行不能拦 if (/login.equals(request.getRequestURI()) HttpMethod.POST.matches(request.getMethod())) { return true; } String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { String token header.substring(7); SessionInfo sessionInfo tokenManager.validateToken(token); if (sessionInfo ! null) { // 将用户信息绑定到当前请求线程 SecurityContextHolder.setCurrentUser(sessionInfo); return true; } } // 未认证返回 401 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或会话已过期\}); return false; } }这中间有一个很重要的概念也是手搓 Spring Security 最值得吸收的设计SecurityContextHolder。它本质上是一个ThreadLocal容器在当前线程处理请求的整个生命周期里保存登录用户信息。Spring Security 也是这么做的只不过它还特意做了子线程传递和清理机制。我们在手搓版本里要小心一个隐患ThreadLocal 在线程池场景里会发生线程复用如果不清理下一个请求拿到的可能是上一个用户的信息。这是安全大忌。解决方式是在 web 请求的 finally 块里清理或者用一个专门的 filter 包一层 try-finallypublic class SecurityContextCleanFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { chain.doFilter(request, response); } finally { SecurityContextHolder.clear(); } } }踩坑教训我第一次写的时候忘了做清理压测时发现第 5 个请求起某些接口会偶发返回另一个用户的数据。排查了很久才意识到是 ThreadLocal 没清。这个坑几乎每个手写安全框架的人都会踩一遍务必引以为戒。到这里“登录 - 签发 token - 请求校验”这条认证闭环算是跑通了。你会发现这套流程跟 Spring Security 的“认证管理器 - SecurityContext 填充”是同一个骨架只是我们把它完全摊开在了自己面前每一行都知道为什么。4. 授权模型——URL 规则与注解拦截的实现难点认证解决了“你是谁”授权要解决的是“你能干什么”。这部分我做了两个粒度粗粒度的 URL 规则匹配和细粒度的方法级注解。这也是生产中最常碰到的两类需求前者控制菜单/页面的访问范围后者控制按钮/接口的操作权限。4.1 先做粗粒度——URL 权限规则匹配URL 级授权的逻辑可以抽象成一段配置什么路径需要什么角色。我们参考 Spring Security 的写法提供链式 APIConfiguration public class MySecurityConfig { Bean public SecurityRuleRegistry securityRuleRegistry() { return SecurityRuleRegistry.create() .antMatchers(/admin/**).hasRole(ADMIN) .antMatchers(/user/**).hasAnyRole(ADMIN, USER) .antMatchers(/login, /public/**).permitAll() .anyRequest().authenticated(); } }实现这个 API 的核心是SecurityRuleRegistry内部维护一个规则列表。真正花心思的地方是反斜杠**通配符的匹配算法。public class SecurityRuleRegistry { private final ListUrlRule rules new ArrayList(); public SecurityRuleRegistry antMatchers(String pattern) { UrlRule rule new UrlRule(pattern); rules.add(rule); return this; } public boolean match(String uri, String httpMethod, ListString roles) { for (UrlRule rule : rules) { if (rule.matches(uri, httpMethod)) { // permitAll 直接通过 if (rule.isPermitAll()) { return true; } // 检查角色是否匹配 if (rule.getRequiredRoles().isEmpty()) { return false; // 需要登录但没有角色要求 } for (String requiredRole : rule.getRequiredRoles()) { if (roles.contains(requiredRole)) { return true; } } return false; } } // 默认拒绝 return false; } }匹配器的实现有个很关键的原则规则顺序必须敏感。第一条匹配的规则生效后面的规则不再检查。这与 Spring Security 的matcher语义一致。所以anyRequest().authenticated()一定要放在最后因为它的通配程度最高放在前面会导致所有请求先命中它后面的精细规则全部失效。UrlRule内部我们做一个简化版的路径匹配支持**和*public class UrlRule { private final String pattern; private final HttpMethod method; private ListString requiredRoles new ArrayList(); private boolean permitAll; public boolean matches(String uri, HttpMethod httpMethod) { // 先比对 HTTP 方法 if (this.method ! null httpMethod ! this.method) { return false; } // ** 最简单的方式是转成正则 StringBuilder regex new StringBuilder(^); String[] parts pattern.split(/); for (String part : parts) { if (**.equals(part)) { regex.append((/.*)?); } else if (*.equals(part)) { regex.append(/[^/]*); } else { regex.append(/).append(Pattern.quote(part)); } } regex.append($); return Pattern.compile(regex.toString()).matcher(uri).matches(); } }这里没有用复杂的 Ant 表达式库而是直接转正则对付绝大多数/**、/*场景已经够了。如果你想做得更完整可以参考 Spring 的AntPathMatcher源码但手搓阶段我们都从简。4.2 再上细粒度——自定义注解实现方法级鉴权URL 规则能挡住“进错了门”的人但挡不住“进了门之后干越权事”的人。比如两个用户都能访问/api/orders但只有管理员能调用删除订单的接口。这种场景就得靠方法级注解。实现方式很简单用 Spring AOP。定义一个注解RequireRoleTarget({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String[] value(); }然后写一个 Aspect在方法执行前检查当前用户的角色Aspect Component public class RoleCheckAspect { Before(annotation(requireRole)) public void checkRole(JoinPoint joinPoint, RequireRole requireRole) { SessionInfo currentUser SecurityContextHolder.getCurrentUser(); if (currentUser null) { throw new AccessDeniedException(未登录); } ListString currentRoles currentUser.getRoles(); for (String needRole : requireRole.value()) { if (currentRoles.contains(needRole)) { return; } } throw new AccessDeniedException(无权限访问该资源); } }这段代码很短但里面暴露了两层取舍。第一层是为什么选 AOP 而不是手动判断。如果每个接口都手写一套角色判断代码里会充斥着重复的if (user.getRoles().contains(ADMIN))逻辑时间一长必然有人漏写。AOP 把横切逻辑集中起来业务方法里只需要声明式地标一个RequireRole(ADMIN)好处是意图清晰、实现统一。第二层是为什么用自定义异常而不直接返回 401/403。安全框架的异常应当一路抛给全局异常处理器。在 Spring Boot 里用RestControllerAdvice统一处理即可RestControllerAdvice public class SecurityExceptionHandler { ExceptionHandler(AccessDeniedException.class) public ResponseEntity? handleAccessDenied(AccessDeniedException e) { return ResponseEntity.status(HttpStatus.FORBIDDEN) .body({\code\:403,\message\:\无权限访问\}); } ExceptionHandler(BadCredentialsException.class) public ResponseEntity? handleBadCredentials(BadCredentialsException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body({\code\:401,\message\:\用户名或密码错误\}); } }这里有个常见误解要澄清异常类型名称叫AccessDeniedException不代表它只在运行时被捕获。Spring 的RestControllerAdvice只处理从 Controller 层往上抛出的异常。如果你的 Aspect 在 Service 方法上执行异常被某个 Service 内部 catch 了却没重新抛出外面的全局处理器是接不到的。经验是切面层的安全异常不要吞掉要让它在调用栈里一直向上传。4.3 授权模型的一个关键取舍——角色是字符串还是权限点在写RequireRole的时候我反复思考过一个设计问题角色用字符串还是拆得更细用权限点比如“管理员”是一个角色但“删除订单”是一个权限点一个管理员角色往往拥有成百上千个权限点。最终我的选择是手搓版本里角色字符串就够用了但要在角色加载时映射成权限点集合。这个映射可以维护在配置里mine-security.role-hierarchy.ADMINUSER,ORDER_DELETE mine-security.role-hierarchy.USERORDER_VIEW鉴权时判断的是权限点是否存在而不是死板地判断角色名。这样一来以后出现“运营人员也只能删除订单但不能查看用户详情”这种需求时不需要改代码只改配置就行。Spring Security 其实也有类似的RoleHierarchy机制是我后来看源码时发现的思路殊途同归。到这里认证授权的主链路基本上齐了。你可以试着把SecurityFilter挂到任意一个 Spring Boot 3 项目上登录、访问受保护接口、越权返回 403这三件事都能体验到。但离“像 Spring Security 一样好用”还差一步——自动配置。5. 框架化的最后一公里——自动配置与组件暴露手搓到这一步方案已经能用了引入依赖、加一个Configuration、注册 Filter、配置规则。但每个项目都要写这一堆模板代码实在不够“框架”。Spring Security 之所以好用其中一个原因是它经历了这一步你不必自己组装过滤器链只要定义SecurityFilterChainBean 就行剩下的框架替你完成装配。我们也来做这个事——写一个 Spring Boot Starter让使用方纯依赖 配置即可生效。5.1 目录结构与 AutoConfiguration一个标准的自定义 Starter 项目长这样mine-security-starter/ ├── pom.xml └── src/ └── main/ ├── java/ │ └── com/example/security/ │ ├── MineSecurityAutoConfiguration.java │ ├── SecurityFilter.java │ ├── TokenManager.java │ ├── UserDetailsService.java │ └── ... (其他组件) └── resources/ └── META-INF/ └── spring/ └── org.springframework.boot.autoconfigure.AutoConfiguration.importsSpring Boot 3 有一个重要变化自动配置的注册文件从META-INF/spring.factories变成了META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports。如果你照着旧教程手写 Starter会发现配置死活不生效多半就是栽在这个路径变化上。文件内容很简单就一行com.example.security.MineSecurityAutoConfiguration然后自动配置类里用条件注解来控制装配时机AutoConfiguration ConditionalOnMissingBean(SecurityFilter.class) EnableConfigurationProperties(MineSecurityProperties.class) public class MineSecurityAutoConfiguration { Bean ConditionalOnMissingBean public TokenManager tokenManager() { return new TokenManager(); } Bean ConditionalOnMissingBean public SecurityFilter securityFilter(TokenManager tokenManager, SecurityRuleRegistry ruleRegistry) { SecurityFilter filter new SecurityFilter(); // 装配认证处理器 filter.addHandler(new AuthenticationFilter(tokenManager)); // 装配授权处理器 filter.addHandler(new AuthorizationFilter(ruleRegistry)); return filter; } Bean ConditionalOnMissingBean public FilterRegistrationBeanSecurityFilter securityFilterRegistration(SecurityFilter filter) { FilterRegistrationBeanSecurityFilter registration new FilterRegistrationBean(); registration.setFilter(filter); registration.addUrlPatterns(/*); registration.setOrder(-100); return registration; } Bean ConditionalOnMissingBean public SecurityRuleRegistry securityRuleRegistry() { return SecurityRuleRegistry.create() .antMatchers(/login).permitAll() .anyRequest().authenticated(); } }ConditionalOnMissingBean非常关键它允许使用方在自己的项目里覆盖默认实现。比如你有自己的TokenManager基于 Redis 实现只要在自己的配置类里定义TokenManagerBean自动配置里的默认 Bean 就会让位不会冲突。这也是 Starter 设计里最核心的“可覆盖”原则。5.2 配置属性类——把安全性参数暴露给使用者再定义一个MineSecurityProperties把密码加密强度、会话超时时间、匿名可访问路径等不常变化的参数暴露成配置项ConfigurationProperties(prefix mine.security) public class MineSecurityProperties { /** 会话超时时间默认30分钟 */ private Duration sessionTimeout Duration.ofMinutes(30); /** 是否开启方法级安全切面 */ private boolean enableMethodSecurity true; // getter / setter 省略 }有了它接入方只需要在application.yml里写mine: security: session-timeout: 30m而它背后的会话管理、token 生成、过滤器链装配全部被自动配置隐藏起来了。从用户视角来看这就跟 Spring Security 的使用体验非常接近了——依赖包 少量配置 定义规则。5.3 实测一个 Demo——把自研框架跑起来为了验证这套东西真的能跑我建了一个最小 Demo 工程步骤非常少引入mine-security-starter依赖提供一个UserDetailsServiceBean 模拟用户数据写一个受保护的 Controller启动项目用 curl 测几个链路一个简化版UserDetailsService可以这样实现Component public class DemoUserDetailsService implements UserDetailsService { Override public UserDetails loadUserByUsername(String username) { // 真实项目这里会查库demo 里写死 MapString, String users Map.of( admin, BCrypt.hashpw(admin123, BCrypt.gensalt()), user, BCrypt.hashpw(user123, BCrypt.gensalt()) ); if (!users.containsKey(username)) { throw new UsernameNotFoundException(username); } ListString roles username.equals(admin) ? List.of(ADMIN) : List.of(USER); return new UserDetails(username, users.get(username), roles); } }登录接口调用curl -X POST http://localhost:8080/login \ -H Content-Type: application/json \ -d {username:admin,password:admin123}拿到 token 后访问受保护接口curl -X GET http://localhost:8080/admin/users \ -H Authorization: Bearer token整个过程从我敲完代码到跑通大概半小时不到。相比第一次读 Spring Security 官方文档这个速度要快得多因为你遇到的每一个报错都是你自己代码里的逻辑链条一目了然。这种“我能 hold 住它”的信心是直接使用大框架很难获得的。6. 手搓版本和官方版的差距——实测对比与心得总结如果你以为这篇文章到此结束那就错了。手搓的核心价值不在于“我写了个可用的东西”而在于通过实测对比你能清晰地知道 Spring Security 那些看起来玄乎的设计到底解决了什么问题、补齐了什么短板。这部分的对比心得我先用一张表列出来再展开细说。能力维度手搓版本Spring Security 官方版认证方式简单 Bearer TokenForm Login、OAuth2、JWT、CAS、Remember-Me 等过滤器链手动 list 顺序遍历SecurityFilterChain支持多链、动态构建、复杂排序授权模型URL 规则 注解URL/Method 双重授权表达式引擎hasAuthority、hasRole、PreAuthorize会话管理ConcurrentHashMap 存内存持久化会话、集群共享、并发控制、Session Fixation 防护CSRF 防护未实现默认开启 Token 校验安全响应头未实现内置大量安全响应头配置如 HSTS、X-Content-Type-Options密码加密BCryptDelegatingPasswordEncoder全哈希算法 兼容升级异常处理手动 controller advice过滤器级 ExceptionTranslation统一处理链内异常扩展性需要改代码通过 Delegates、Filters、Managers 高度可扩展6.1 手搓版本缺失的三个关键安全能力第一是CSRF 防护。Spring Security 在配置里默认开启 CSRF Token 校验而且把 Token 和 Session 绑定。手搓版本完全没做意味着如果接入项目里有用户登录后访问了攻击者构造的页面攻击者完全可以借用户的会话状态发请求。这在一开始的 demo 里没问题一旦上线就是实打实的安全漏洞。第二是安全响应头。Spring Security 默认会给响应加上一组 HTTP 头包括X-Content-Type-Options: nosniff、X-Frame-Options: DENY、Cache-Control: no-store等。这些头能防御很多基础攻击比如点击劫持、MIME 类型嗅探。手搓版本没加就需要使用方自己记得配过滤器或者一步步加上。好在这些头本质上就是往响应里写字段加上并不难难的是你知道要加它们。第三是会话的并发控制与异地登录提醒。真正的生产环境经常要求“同一账号同一时间只能一个设备在线”或者“踢掉之前的登录会话”。手搓版只有一个ConcurrentHashMap映射表在新 token 生成时不会主动清理旧 token。要补充这个能力你需要在TokenManager.createToken里按用户名维度遍历旧会话并删除。实现起来不难但你要是没往这个方向想系统上了生产就会出事故。6.2 正式清单在手搓里学到的设计思想说完了差距说说收获。以下三点是我觉得做这个项目最值回票价的部分。第一是安全框架的分层思想。认证和授权是两件完全独立的事开发者经常混为一谈。手搓过程中会被迫思考认证失败了该走到哪一步授权失败了该走到哪一步Spring Security 把这两件事拆成不同的过滤器且认证拦截发生在前、授权判断发生在后这个顺序本身就是一种架构设计。你现在再看官方文档里UsernamePasswordAuthenticationFilter和AuthorizationFilter的代码会发现它们之间的关系早就刻在你脑海里了。第二是**“可覆盖优先于默认实现”**的 Starter 设计理念。自动配置里大量使用ConditionalOnMissingBean这个模式在手搓完毕之后被平移到了我的日常工作中。我现在写内部工具类、数据源配置的时候都会问自己一句这个东西别人需要替换吗如果可能那就把默认实现做成可覆盖的。这个习惯的价值不比写安全框架本身小。第三是调试排错的思维方式。以前用 Spring Security 遇到 401 或 403我的第一反应是翻文档、搜答案。手搓之后遇到任何安全相关问题我都会先在脑子里过一遍过滤器链的执行次序请求到哪个环节了认证过了没角色匹配命中了吗这样排查问题的效率提升了不止一倍。这也是我建议大家动手手搓的根本原因——你不需要造出媲美 Spring Security 的轮子但你一定要亲手转一圈轮子才会知道轮子上的每根辐条都在干什么。6.3 后续扩展方向如果你看完文章也想动手试我建议你按这样的顺序迭代先把第 2 章的 Filter 基座搭出来跑通一个“所有请求都需要登录”的版本把第 3 章的登录和 TokenManager 补齐用 curl 手工验证登录态加 URL 规则匹配实现admin/**和user/**的粗细权限加 AOP 注解让它具备方法级权限控制能力最后封装成 Starter做一个干净的最小可运行 demo每一层做完了你都可以拿它去跟 Spring Security 的对应能力对照。碰到一个官方新增的配置项就想想自己实现的版本里有没有对应的东西、为什么有、为什么没有。这种对照学习法比从头啃一遍官方文档要高效得多。我个人在实际操作中的体会是手搓之后重新看 Spring Security 源码的恐惧感基本消失了。Filter 链、AuthenticationManager、AuthorizationManager 这些概念不再是抽象名词而是变成了一幕幕自己写过的画面。如果你也想摆脱“只会配置不会原理”的窘境花一两天时间把这个项目折腾一遍绝对物超所值。
返回列表