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 + JWT
3.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签发的Token,Python、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()内部会走一遍UserDetailsService+PasswordEncoder的校验链路。如果用户名不存在或密码错误,会抛出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。如果你不自定义AuthenticationEntryPoint,Spring 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;charset=UTF-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 认证系统排查速查表
| 现象 | 可能原因 | 排查优先级 |
|---|---|---|
| 所有请求401 | Token解析失败或过滤器未生效 | 先看过滤器是否被注册,再看Authorization头格式 |
| 部分请求401 | 多实例密钥不一致 | 检查各实例配置的JWT密钥是否相同 |
| 登录接口被刷 | 没有限流或限流失效 | 检查限流过滤器是否在登录路由上生效 |
| 用户能访问无权限接口 | 缺少方法级授权声明 | 检查@PreAuthorize注解是否遗漏 |
| 密码正确但登录失败 | 密码算法不匹配 | 检查数据库中的密码哈希格式与编码器是否一致 |
| 修改密码后老Token仍有效 | 无状态Token没有主动失效机制 | 设计上考虑黑名单或Refresh Token机制 |
7. 一点个人的实操体会
做了这么多年后端,我对认证系统的态度经历了三个阶段的变化。最早觉得“登录不就是比对一下密码嘛”;后来开始引入Spring Security,被过滤器链配置绕得头晕,一度想放弃;再后来,真正经历了一次线上被撞库的教训,才明白把这些繁琐的配置一个个啃下来是值得的。安全不是一个功能,而是一个持续对抗的过程。你堵住了一个洞,攻击者就会去试下一个洞。认证系统作为整个安全体系的第一道门,值得你把每一层都做扎实。
这个JWT + Spring Security的方案,是我在多个模拟项目和实际业务场景中反复验证过的组合。它不算最前沿,但胜在稳定、生态成熟、排查资料丰富。如果你正在做一个需要对外开放的系统,直接照着这套思路落地不会有大的偏差。如果你维护的是老系统,第一步先检查密码存储方式和登录限流,这两点通常是最大的短板。
最后分享一个细节:给所有登录相关的日志加上“用户名+IP+UserAgent+时间”的记录,并且定期分析失败尝试的分布规律。很多安全问题在被攻击者利用之前,其实已经写在日志里了,只是你没有去看。