资讯动态

Spring Security + JWT 认证系统实战:从登录设计到安全加固

发布时间:2026/10/17 17:09:34 来源:尧图企业网站定制
1. 从“能登录”到“不敢登错”认证系统到底难在哪先抛一个很多人踩过的坑你花两周做了一个登录功能上线第三天被人用脚本扫出了三万个撞库请求第五天某大客户投诉说他们的账号被异地登录了。这时候你才发现所谓的“登录功能”和“安全认证系统”之间差得不是代码量而是对整个安全边界的理解。我见过不少Java开发者对认证的理解停留在“用户名密码比对通过就放行”。这种思路做内部工具没问题但只要系统面向公网哪怕只是个小体量的管理后台都可能在几小时内被试探性攻击打穿。真正的安全认证系统要解决的问题从来不是“怎么让用户进来”而是“在允许合法用户进入的同时让一切非法尝试付出足够高的代价”。这篇文章我会从整体设计讲起拆解认证系统的核心决策点再给出一个基于Spring生态、可以落地的实现方案最后整理我在真实项目里攒下的排查经验和踩坑记录。无论你是刚接触Web开发的后端新人还是维护过老系统、想重构认证模块的开发者应该都能从中找到自己能用的东西。2. 认证系统的整体设计思路先画边界再写代码2.1 认证(Authentication)与授权(Authorization)必须分开设计很多人把认证和授权混在一起谈这是设计层面第一个要纠正的习惯。认证解决的是“你是谁”的问题授权解决的是“你能干什么”的问题。两者在实现上有着完全不同的关注点混在一起会让系统越改越乱。认证环节的核心资产是用户的身份凭证比如密码、验证码、短信回调、第三方OAuth结果。这个环节要防的是“假身份进来”。授权环节的核心资产是角色和权限数据要防的是“真身份越权”。我在实际项目中见过最糟糕的设计是在User实体上挂了一个role字符串字段然后在每个接口里用if(admin.equals(user.getRole()))来判断能否操作。这种设计在用户量小的时候看不出问题但一旦权限粒度细化到“某部门的某张报表的某几列”就完全无法扩展。建议的做法是认证模块只负责产出“当前请求对应哪个用户”这一事实至于这个用户能碰哪些资源完全交给授权模块去管。认证层和业务层之间通过上下文对象传递身份信息不直接耦合数据库查询。2.2 无状态设计比有状态更接近现代Web架构的诉求传统的Session方案服务端把登录态存在内存或Redis里客户端拿一个JSESSIONID。这在单体应用时代没有问题但在前后端分离、多实例部署、移动端接入的场景下Session同步就成了麻烦事。你可能需要引入Spring Session来把会话数据外置到Redis还要处理会话失效、刷新策略等一系列衍生问题。Token方案则把用户身份信息直接编码进令牌本身服务端不再保存会话状态。每次请求到来服务端只需要验证令牌的签名和有效期就能确认请求者身份。这个思路极大地简化了水平扩展——任何一台实例都能独立验证令牌不需要共享会话存储。当然无状态也不是银弹。令牌一旦签发在到期之前很难主动废弃这就引出了黑名单机制、短令牌刷新令牌组合等一系列补偿设计。我在第四章的实操环节会给出一个兼顾安全性和易用性的方案。2.3 防护维度必须覆盖整个请求链路一个完整的认证系统看着是登录接口那几十行代码实际上牵扯到的是整条链路上每一层的防护传输层要有HTTPS兜底否则密码在网络上裸奔任何应用层加密都显得可笑。接入层要防暴力破解登录接口必须有频率限制和异常行为检测。应用层要防注入和越权认证通过后依然要校验每个请求的资源所有权。数据层要防脱库泄露密码绝不能明文存储即使数据库被拖走也不能让攻击者直接拿到可用凭证。我见过很多团队花大力气把JWT、OAuth2、指纹登录全都上了结果安全扫描一测发现登录接口的请求参数里带着明文的用户名和密码而且接口没有限流。这就是典型的“防线失效”比什么都不做还要危险因为团队会误以为自己是安全的。3. 核心技术选型与关键决策为什么是Spring Security JWT3.1 框架选型Spring Security是默认答案但别迷信“默认”Java生态里做认证绕不开Spring Security和Apache Shiro两个名字。Shiro以轻量著称API更简单中小型项目上手快。但我个人更倾向于Spring Security哪怕它的过滤器链机制对新手不太友好。根本原因在于Spring Security与Spring Boot的集成深度。你在Spring Boot里引入spring-boot-starter-security它自动帮你接管了所有请求的安全过滤配置方式也从XML演进到了链式API。更重要的是Spring Security的过滤器链机制让认证逻辑天然地沿着“请求进入→过滤器链处理→放行到Controller”这条路径展开你可以指定哪些路径需要认证、哪些路径匿名可访问、哪些路径必须拥有特定角色。Shiro的优势是学习曲线平缓文档里的示例代码十分钟就能跑起来。但它的社区活跃度和对OAuth2、OIDC等现代协议的支持明显弱于Spring Security。考虑到安全领域的技术更新频率我更愿意站在一个持续迭代的框架上。3.2 Token方案选型JWT不是唯一选择但它在通用性和易用性之间平衡得最好谈到无状态认证JWT几乎是默认选项。它的结构是Header.Payload.Signature三段式Header里声明算法Payload里携带用户标识和过期时间Signature保证令牌不被篡改。JWT最大的优点是自包含和跨语言。自包含意味着服务端不用查库就能知道“这个用户是谁、什么时候过期”跨语言意味着用Java签发的TokenPython、Go、Node.js的服务都能验证。这对于微服务架构、多端接入Web、iOS、Android、小程序来说非常省事。JWT也有几个需要正视的痛点。一是Payload是Base64编码不是加密千万别往里塞敏感信息二是无法在服务端主动注销除非引入黑名单机制三是如果签名密钥泄露攻击者可以自行签发任意身份的令牌。这些痛点我会在后面给出对应的解法。3.3 密码存储BCrypt依然是性价比最高的选择密码存储是认证系统里最不能出错的一环。早些年流行的MD5加盐方案在GPU暴力破解面前已经脆弱得不堪一击。现在的主流做法是使用自适应哈希算法BCrypt正是其中的代表。BCrypt的核心特点是计算速度慢相对MD5/SHA来说这让暴力破解的成本大幅上升。它内部自动处理盐值的生成和存储同一个密码每次哈希结果都不同攻击者即使拖走数据库也没法用彩虹表直接反查。Spring Security内置了BCryptPasswordEncoder直接注入就能用不需要引入额外依赖。我在项目里还遇到过从老系统迁移过来的历史遗留问题——老系统用MD5存密码用户不重新登录就没法换成BCrypt。我的处理方式是做一个“双模型校验”优先按BCrypt校验校验失败再退回到MD5盐的老逻辑如果老逻辑通过则立刻为用户生成BCrypt哈希并更新数据库。这样用户无感知地完成了平滑迁移。4. 实操落地一个可复用的JWT认证Demo全流程4.1 环境准备与技术栈清单我采用的这套方案基于Java 17 Spring Boot 3.x Spring Security 6.x。技术栈选型上Java 17启用了一些好用的语法特性比如record、sealed类Spring Boot 3.x强制要求Java 17起步是当前的主流版本。依赖方面核心只有三个spring-boot-starter-security提供认证授权框架和过滤器链。spring-boot-starter-web提供Web能力。io.jsonwebtoken:jjwt-api:0.11.5及其jjwt-impl、jjwt-jackson负责JWT的生成与解析。数据库我用H2的内嵌模式做演示生产环境换成MySQL或PostgreSQL即可。ORM选择Spring Data JPA这是Spring Boot体系里最安稳的持久层方案。注意Spring Boot 3.x对部分旧版依赖不兼容比如直接用spring-security-oauth2-client的旧坐标会报错。建议创建项目时直接通过Spring Initializr勾选依赖避免版本冲突。4.2 认证核心链路一注册与密码存储注册接口的逻辑比较简单但有一个容易踩坑的细节不要用save方法直接把前端传来的实体落库。前端传来的对象里通常有id、role之类的字段直接保存等于给攻击者开了后门。正确的做法是单独构造一个User实体或者使用DTO进行字段映射。PostMapping(/register) public ResponseEntity? register(RequestBody RegisterRequest request) { if (userRepository.existsByUsername(request.getUsername())) { return ResponseEntity.badRequest().body(用户名已存在); } User user new User(); user.setUsername(request.getUsername()); user.setPassword(passwordEncoder.encode(request.getPassword())); user.setRole(ROLE_USER); user.setEnabled(true); userRepository.save(user); return ResponseEntity.ok(注册成功); }密码经过passwordEncoder.encode()得到的BCrypt哈希形如$2a$10$7FzVnNO5DLK7nIVLZ5nH9uP4q3yQqy。这个哈希里包含了算法版本、cost因子、盐值和摘要你不需要额外建一个字段存盐值也绝对不要自己实现“加盐逻辑”BCrypt内部已经处理好了。4.3 认证核心链路二登录签发JWT登录接口要做三件事校验用户名密码、检查用户状态、签发Token。代码结构清晰直接PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest request) { Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( request.getUsername(), request.getPassword() ) ); User user (User) authentication.getPrincipal(); String token jwtUtil.generateToken(user.getUsername()); return ResponseEntity.ok(new LoginResponse(token, user.getUsername(), user.getRole())); }authenticationManager.authenticate()内部会走一遍UserDetailsServicePasswordEncoder的校验链路。如果用户名不存在或密码错误会抛出BadCredentialsException。这里不自己写if判断去比对密码原因在于Spring Security的校验流程还包含了用户是否锁定、是否禁用等状态检查自己写容易漏掉这些环节。JWT生成工具类的核心方法如下public String generateToken(String username) { return Jwts.builder() .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 1000 * 60 * 30)) .signWith(key, SignatureAlgorithm.HS256) .compact(); }这里有几个关键设置需要说明。过期时间我设置为30分钟这是经过实际体验权衡的结果太短会让用户频繁重新登录太长会放大Token泄露后的风险窗口。签发时间setIssuedAt一定要带上这样在排查问题时能追踪Token的生成时刻。签名密钥key建议从配置文件中读取不要硬编码在代码里而且要保证不少于32字节。4.4 认证核心链路三过滤器链校验Spring Security的工作机制是过滤器链。你配置的每条规则都会转化为过滤器链上的一环。请求进来后按顺序经过这些过滤器任何一个环节被拒绝请求都会直接返回错误不再继续向后传递。我的过滤器链配置如下Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .sessionManagement(session - session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth - auth .requestMatchers(/auth/login, /auth/register).permitAll() .requestMatchers(/admin/**).hasRole(ADMIN) .anyRequest().authenticated()) .exceptionHandling(ex - ex .authenticationEntryPoint(unauthorizedHandler)) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); }逐行拆解一下关键配置的意图.csrf(AbstractHttpConfigurer::disable)CSRF防护主要用于基于Cookie的Session认证场景防止跨站请求伪造。JWT模式下Token存放在请求头中浏览器不会自动携带CSRF的主要攻击面已经不存在所以可以关闭。但如果你的系统还有Cookie环境必须保留CSRF防护。.sessionCreationPolicy(SessionCreationPolicy.STATELESS)这是无状态认证的核心Spring Security将不再创建HttpSession也不从Session中读取认证信息。.requestMatchers(...)精确声明哪些URL放行哪些需要认证哪些需要特定角色。/auth/login和/auth/register是匿名放行其余请求一律要求认证。.addFilterBefore(...)把自定义的JWT过滤器插入到用户名密码认证过滤器之前确保请求进来先解析Token如果没有Token再走匿名认证流程。自定义JWT过滤器的核心逻辑如下Component public class JwtAuthenticationFilter extends OncePerRequestFilter { 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); try { String username jwtUtil.extractUsername(token); if (username ! null SecurityContextHolder.getContext() .getAuthentication() null) { UserDetails userDetails userDetailsService.loadUserByUsername(username); if (jwtUtil.validateToken(token, userDetails)) { UsernamePasswordAuthenticationToken authToken new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); authToken.setDetails(new WebAuthenticationDetailsSource() .buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); } } } catch (JwtException e) { // 令牌无效或过期不设置认证信息后续过滤器会拒绝 } } chain.doFilter(request, response); } }这个过滤器实现了一个关键机制从请求头中提取Bearer Token解析出用户名加载用户的完整信息包括角色权限构建认证对象放入SecurityContextHolder。后续的授权检查就能从SecurityContextHolder中拿到用户身份判断是否有权访问目标资源。4.5 授权模型角色与权限的最佳实践很多人纠结RBAC基于角色的访问控制到底应该怎么做。我实际项目的经验是角色(Role)和权限(Permission)要区分开。角色是权限的集合概念比如ROLE_ADMIN对应“用户管理、订单管理、报表查看”等权限业务代码中不要硬编码if(admin.equals(role))而是检查是否拥有具体权限。Spring Security里你可以通过hasRole(ADMIN)来检查角色也可以通过hasAuthority(user:delete)来检查权限。在UserDetails实现中getAuthorities()返回的集合既可以包含ROLE_ADMIN这种角色标识也可以包含user:delete这种细粒度权限标识。用PreAuthorize(hasAuthority(user:delete))注解到特定接口上就能实现细粒度的接口权限控制。这里有个细节容易搞混hasRole(ADMIN)会隐式添加ROLE_前缀而hasAuthority不会。所以数据库里存的角色名如果是ROLE_ADMIN用hasRole(ADMIN)检查如果是ADMIN要用hasRole(ADMIN)前先确认前缀规则。我建议统一存储时带上前缀避免混淆。4.6 配置无状态认证的异常处理无状态认证下AuthenticationEntryPoint很重要。老式Session认证里未登录用户访问受保护资源通常被重定向到登录页面但在前后端分离模式下前端期望的是返回JSON错误码比如401。如果你不自定义AuthenticationEntryPointSpring Security默认返回302重定向或者一个无内容的403前端根本没法处理。我的自定义实现是这样的Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未认证或Token已过期\}); } }同样AccessDeniedHandler负责处理“已认证但权限不足”的情况返回403。把这两个异常出口统一为JSON格式前端处理逻辑就简单多了。5. 安全加固与常见攻击的针对性防御5.1 暴力破解与撞库对登录接口做“慢”限流登录接口是认证系统最暴露的靶子。脚本可以用字典文件不断尝试用户名密码组合如果你不做任何限流一个8位纯数字密码在几个小时内就能被跑穿。我的建议是至少做两层限制。一层是IP维度同一个IP在固定时间窗口内的失败尝试次数超过阈值就临时封禁。另一层是账号维度同一个用户名在短时间内连续失败多次后强制要求图形验证码或锁定一段时间。Spring生态里可以用Bucket4j或者Resilience4j来做令牌桶限流。我在项目中用的是Bucket4j原因很简单API直观、支持分布式环境下的Redis存储、配置灵活。Bean public Filter registrationBean() { RateLimitFilter rateLimitFilter new RateLimitFilter(); rateLimitFilter.setRate(5, Duration.ofMinutes(1)); return rateLimitFilter; }注意限流过滤器要作用在登录接口所在的路由上而不是全局限流——全局限流会误伤正常用户尤其是来自同一出口IP的办公场景。5.2 Token泄露与盗用短Token Refresh Token的“两条腿走路”JWT是自包含的服务端无法主动让一个已签发的Token失效。如果你签发了有效期7天的Token而这个Token泄露了攻击者可以连续用7天。业界通用的解法是引入Refresh Token机制Access Token有效期短比如30分钟Refresh Token有效期长比如7天Access Token过期后用Refresh Token去换取新的Access Token。如果用户主动登出或修改密码服务端把Refresh Token加入黑名单就能实现“半主动”的作废机制。Refresh Token通常是随机生成的字符串存储在Redis等服务端存储中数据库里只保存它的哈希值。这样即使数据库泄露攻击者拿到哈希也没法直接使用。5.3 越权访问认证通过不等于授权通过认证系统做完了很多人以为就安全了。实际上OWASP Top 10里比重很高的一类漏洞是越权访问——水平越权和垂直越权。水平越权是普通用户A能访问普通用户B的资源比如订单ID是自增的改个参数就能看到别人的订单垂直越权是普通用户能调用管理员接口。认证系统只能保证“这个请求确实是某个用户发起的”但“这个用户能不能访问这个订单”是业务层需要校验的。一个常见的做法是使用PreAuthorize注解做静态权限声明对于动态的资源所有权必须在Service层里检查当前用户名和资源归属人是否一致。PreAuthorize(hasAuthority(order:view)) public Order getOrder(Long orderId) { Order order orderRepository.findById(orderId).orElseThrow(); if (!order.getUsername().equals(currentUsername())) { throw new AccessDeniedException(无权访问该订单); } return order; }这个检查逻辑虽然朴素却堵住了最大范围的越权漏洞。很多系统被攻击者通过改ID的方式遍历数据就是因为漏了这一条。5.4 敏感信息泄露从Payload到响应体的全面清理JWT的Payload是经过Base64编码的Base64只是编码不是加密。任何人拿到Token都可以用解码工具直接看到Payload内容。因此Payload里绝不能放手机号、身份证号、邮箱地址等敏感信息只放sub用户唯一标识和必要的过期时间。响应体方面登录接口返回的Token要避免连带返回“用户角色”、“用户ID”等不必要的信息。前端需要的用户信息应在登录成功后通过另一个受认证保护的接口获取。5.5 安全响应头浏览器层面的额外防线Spring Security默认启用了不少安全响应头但有几个需要结合业务调整。比如Cache-Control要防止登录后的页面被浏览器缓存X-Content-Type-Options要防止浏览器对响应内容进行MIME嗅探Content-Security-Policy要限制页面上可加载的资源来源降低XSS攻击影响面。在Spring Security 6.x里这些头默认就是开启的但你可以通过http.headers()自定义追加策略。不必过度配置默认状态对大多数项目已经足够关键是别手贱把这些默认头关掉。6. 常见问题与排查技巧实录6.1 登录成功后每次请求还是401怎么办这是我被问得最多的问题。排查思路按顺序来确认前端是否在每个请求的Authorization头中正确携带了Bearer前缀。不少前端代码漏了“Bearer ”这个前缀后端解析时找不到Token自然返回401。确认自定义JWT过滤器是否被正确插入过滤器链。配置了addFilterBefore但不生效常见原因是过滤器类没有被Spring容器管理检查Component注解有没有漏掉。确认OncePerRequestFilter是否重写的是doFilterInternal方法而不是doFilter。重写doFilter会导致逻辑被重复执行。确认SecurityContextHolder.getContext().getAuthentication()是否被后续某个过滤器清除。如果你写了一个跨域过滤器并且调用了SecurityContextHolder.clearContext()所有认证信息都会被抹掉。6.2 跨域请求CORS把认证拦截了前后端分离必然涉及跨域。Spring Security的CORS配置需要和Spring MVC的CORS配置协同一个常见的错误是只配了Spring MVC的CrossOrigin注解但SecurityFilterChain里的cors()没有配置导致安全过滤器在进入MVC之前直接拒绝了跨域请求。正确做法是在SecurityFilterChain里显式添加http.cors(cors - cors.configurationSource(corsConfigurationSource()));同时提供一个CorsConfigurationSourceBean允许的前端域名、请求方法、请求头都要明确配置不要把allowedOrigins设成*的同时还允许携带凭证这是浏览器规范禁止的。6.3 多实例部署下JWT密钥不一致导致随机401有多套环境测试、预发、生产或者多个实例时JWT签名密钥必须统一配置。不同实例如果各自生成随机密钥比如每台机器启动时随机生成就会出现用户请求偶尔成功、偶尔401的情况排查起来极其恶心。解决方案就是把密钥放到统一的配置中心或者环境变量里所有实例读取同一个值。密钥本身要定期轮换轮换时给新旧两个密钥设置一个重叠期让已经签发的Token在过渡期内仍然有效。6.4 密码加密算法升级的兼容迁移我在前文提到了MD5迁移BCrypt的兼容方案这里补充一个实操细节迁移过程中要记录每个用户当前使用的密码算法类型而不是通过“BCrypt校验失败就尝试MD5”的顺序硬猜。可以在用户表加一个password_type字段枚举BCRYPT和LEGACY_MD5。按类型选择校验算法避免每次登录都对两种算法做尝试。6.5 接口返回的异常信息暴露了内部实现细节默认的异常信息里可能包含类名、SQL语句、堆栈信息攻击者可以根据这些信息分析系统结构。建议在全局异常处理器里统一转换为固定的错误码和友好提示日志中详细记录堆栈用于排查但响应体只输出code和message。6.6 认证系统排查速查表现象可能原因排查优先级所有请求401Token解析失败或过滤器未生效先看过滤器是否被注册再看Authorization头格式部分请求401多实例密钥不一致检查各实例配置的JWT密钥是否相同登录接口被刷没有限流或限流失效检查限流过滤器是否在登录路由上生效用户能访问无权限接口缺少方法级授权声明检查PreAuthorize注解是否遗漏密码正确但登录失败密码算法不匹配检查数据库中的密码哈希格式与编码器是否一致修改密码后老Token仍有效无状态Token没有主动失效机制设计上考虑黑名单或Refresh Token机制7. 一点个人的实操体会做了这么多年后端我对认证系统的态度经历了三个阶段的变化。最早觉得“登录不就是比对一下密码嘛”后来开始引入Spring Security被过滤器链配置绕得头晕一度想放弃再后来真正经历了一次线上被撞库的教训才明白把这些繁琐的配置一个个啃下来是值得的。安全不是一个功能而是一个持续对抗的过程。你堵住了一个洞攻击者就会去试下一个洞。认证系统作为整个安全体系的第一道门值得你把每一层都做扎实。这个JWT Spring Security的方案是我在多个模拟项目和实际业务场景中反复验证过的组合。它不算最前沿但胜在稳定、生态成熟、排查资料丰富。如果你正在做一个需要对外开放的系统直接照着这套思路落地不会有大的偏差。如果你维护的是老系统第一步先检查密码存储方式和登录限流这两点通常是最大的短板。最后分享一个细节给所有登录相关的日志加上“用户名IPUserAgent时间”的记录并且定期分析失败尝试的分布规律。很多安全问题在被攻击者利用之前其实已经写在日志里了只是你没有去看。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑