ARTICLE DETAIL

资讯详情

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

基于Spring Boot+Spring Security+JWT的官方账号认证系统实战

基于Spring Boot+Spring Security+JWT的官方账号认证系统实战 之前在做账号系统的国际化改造时碰到一个非常典型的账号注册场景用户的显示名称是“谷口愛季”登录用户名却是airi.taniguchi.official。刚开始我以为这只是普通的用户名结果在开发环境里连续踩了不少坑——数据库唯一约束对大小写和点号的处理、Spring Security 的UsernameNotFoundException提示、JWT 密钥与字符编码等等都和这类包含日文汉字、英文小写和点号的账号有关。这篇文章就围绕这个场景从零到一实现一套基于 Spring Boot Spring Security JWT 的官方账号认证系统并且把账号建模、密码加密、登录发令牌、受保护接口、常见报错和生产建议全部梳理清楚。1. 背景与核心概念1.1 什么是官方账号认证在没有统一认证模块之前业务方通常会把用户密码明文或简单加密后放在各自的表里每次接入新系统都要重复写登录逻辑接口权限也很难统一控制。官方账号认证Official Account Authentication指的是由一套统一的认证服务管理账号、密码、令牌和权限业务系统只负责接收令牌并校验权限。这样做的优势很明显第一账号生命周期集中管理停用、禁用、延期不需要每个业务系统单独处理第二登录安全和令牌签发可以统一加固比如密码加密、登录限流、令牌过期策略第三后续扩展 OAuth2 授权、SSO 单点登录时只需要在认证服务上增加能力不需要改动所有业务方。本文实现的认证服务虽然规模不大但已经具备这些核心要素。1.2 OAuth2 与自研 JWT 认证的区别有些读者会问官方账号认证和 OAuth2 是不是一个东西严格来说不是。OAuth2 是一种授权框架重点解决“允许第三方应用在用户授权后访问受限资源”的问题常见流程包括授权码模式、客户端模式、密码模式等。而自研 JWT 认证通常指“登录成功后签发一个 JWT后续请求携带该令牌访问资源”的简单方案属于认证层不强制包含授权委托语义。本文为了便于演示采用自研 JWT 方案用一个/api/auth/login接口签发令牌后续请求通过Authorization: Bearer token访问/api/me等受保护接口。如果业务上有第三方接入需求可以在同样基础上接入 Spring Authorization Server把当前认证服务升级为完整的授权服务器这部分在文末给出学习路线。2. 环境准备与版本说明2.1 开发环境实例环境准备部分只需要三板斧安装 JDK、安装 Maven、准备一个 IDE。示例代码基于 Java 8 语法编写Spring Boot 版本以 2.7.x 为例理论上也可以运行在更高版本但需要注意 Spring Security 5.7 之后推荐使用SecurityFilterChain风格的写法示例中正是这种风格所以迁移成本不大。数据库采用 H2 内存数据库演示不需要额外安装服务启动项目后内存表会自动创建如果要切换到 MySQL只需要修改application.yml中数据源配置并引入mysql-connector-java依赖。IDE 方面使用 IntelliJ IDEA 或 VS Code 均可命令行工具使用 Maven 的mvn命令。2.2 核心依赖说明项目功能拆开来看需要四类依赖Web 层使用spring-boot-starter-web负责提供 HTTP 接口安全层使用spring-boot-starter-security负责拦截请求和密码加密持久层使用spring-boot-starter-data-jpa配合 H2 数据库自动建表JWT 工具使用io.jsonwebtoken:jjwt:0.9.1。这里需要特别提醒jjwt 0.9.1 是较老的版本只适合学习和内部工具生产环境建议使用 0.12.x 系列的jjwt-api、jjwt-impl、jjwt-jackson三个依赖同时调整签名 API。因为新版本 API 变化较大本文为了减少代码噪音使用最直观的 0.9.1 写法你在迁移时需要根据官方文档替换对应类名。3. 核心知识拆解账号存储、密码加密与 JWT3.1 用户名规范化从“谷口愛季”到登录名“谷口愛季”是显示名称airi.taniguchi.official是登录名。实际项目里显示名称可以随便取它只用于前端展示登录名则必须遵循稳定的唯一规则。很多系统只把登录名简单toLowerCase()但日文用户名还涉及 Unicode 归一化问题。比如“愛”和“爱”是全角与半角、别名与简化字的关系如果不做归一化同一个用户在不同输入法下可能注册出两个账号。建议在保存和查询登录名时统一执行Normalizer.normalize(username, Normalizer.Form.NFKC)并检查是否包含只允许的字符集。本文示例中没有强制实现完整的白名单但代码结构里保留了normalizeUsername方法方便你按业务补充正则规则。3.2 密码存储为什么不能明文保存官方账号系统里密码是最重要的敏感信息。一旦数据库泄露明文密码会造成连锁风险所以必须使用不可逆的密码哈希算法。Spring Security 自带的BCryptPasswordEncoder是目前最常用的方案它内部会自动添加随机盐每次加密结果都不同但matches()方法可以正确校验。使用方式很简单注册时调用passwordEncoder.encode(rawPassword)登录时调用passwordEncoder.matches(rawPassword, encodedPassword)。一定不要把encode和matches搞反也不要在自定义UserDetailsService里做字符串相等判断。示例中的初始化数据已经用encode生成了密文所以你看到数据库里的密码是$2a$10$...这种格式这是正常现象。3.3 JWT 令牌结构与校验JWTJSON Web Token由三部分组成Header、Payload、Signature每一段都用 Base64URL 编码中间用点号连接。Header 通常声明算法HS256Payload 里可以放sub主题、iat签发时间、exp过期时间以及自定义字段如role。Signature 是使用密钥对 Header 和 Payload 计算出的签名保证令牌内容没有被篡改。服务端使用JwtUtil生成令牌并约定Authorization: Bearer token的携带方式JWT 默认是无状态认证服务端不需要保存会话这也意味着一个令牌在过期前无法主动失效所以刷新令牌和吊销机制要另行设计。后续最佳实践部分会专门说明密钥管理和有效期设置。4. 完整实战从零搭建官方账号认证系统4.1 创建项目结构下面的树形结构展示了本次实战的项目骨架official-account-auth ├─ pom.xml └─ src/main ├─ java/com/example/officialaccount │ ├─ OfficialAccountApplication.java │ ├─ config │ │ ├─ SecurityConfig.java │ │ └─ DataInitializer.java │ ├─ controller │ │ └─ AuthController.java │ ├─ dto │ │ └─ LoginRequest.java │ ├─ entity │ │ └─ User.java │ ├─ repository │ │ └─ UserRepository.java │ ├─ security │ │ ├─ JwtAuthenticationFilter.java │ │ └─ JwtUtil.java │ └─ service │ └─ UserDetailsServiceImpl.java └─ resources └─ application.yml创建项目时可以直接用 Spring Initializr 生成也可以手动创建 Maven 工程。下面从 Maven 配置开始逐文件说明每个类的职责和关键细节保证你照着复制也能跑起来。4.2 添加 Maven 依赖打开pom.xml把核心依赖补充完整。为了让文章方便复制这里给出最小可运行配置?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent groupIdcom.example/groupId artifactIdofficial-account-auth/artifactId version1.0.0/version nameofficial-account-auth/name properties java.version1.8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version /dependency dependency groupIdjavax.xml.bind/groupId artifactIdjaxb-api/artifactId version2.3.1/version /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /project之所以额外引入jaxb-api是因为jjwt 0.9.1内部会用到 Java 8 之后不再默认加载的 JAXB 类尤其是在 JDK 11 环境下容易出现ClassNotFoundException: javax.xml.bind.DatatypeConverter。如果你使用的是 JDK 8这个依赖可以去掉如果你使用更高版本 JDK 或换用jjwt 0.12.x则需要按新版本的文档调整依赖组合。版本号 2.7.18 属于 Spring Boot 2.7 系列的一个补丁版本实际项目可以根据自己公司的基线选择稳定版本核心代码不受影响。4.3 编写 application.yml在src/main/resources目录下新建application.yml写入以下配置server: port: 8080 spring: datasource: url: jdbc:h2:mem:official_account_db;DB_CLOSE_DELAY-1 driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true h2: console: enabled: true app: jwt: # 生产环境务必通过环境变量或配置中心注入不要硬编码 secret: change-this-secret-to-a-strong-random-value-2024 expire-hours: 24这里有几个细节需要说明。jdbc:h2:mem:official_account_db;DB_CLOSE_DELAY-1表示使用内存数据库并且 JVM 退出前不自动关闭避免DataInitializer写入的数据在控制台查询时丢失。ddl-auto: update会在启动时根据实体类自动建表适合演示和快速原型生产环境建议改为validate或使用专门的数据库迁移工具。spring.h2.console.enabled: true只是方便查看内存数据如果你不需要控制台可以把这段删掉。JWT 密钥不能太短使用 HS256 算法时密钥长度至少要 256 位示例字符串长度满足要求但生产环境要放到环境变量中。4.4 用户实体与仓库接下来编写用户实体文件路径为src/main/java/com/example/officialaccount/entity/User.javapackage com.example.officialaccount.entity; import javax.persistence.*; Entity Table(name t_user, uniqueConstraints UniqueConstraint(columnNames username)) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(nullable false, length 64) private String username; Column(nullable false) private String password; Column(nullable false, length 32) private String displayName; Column(nullable false) private boolean enabled true; Column(nullable false, length 64) private String role ROLE_USER; public Long getId() { return id; } public void setId(Long id) { this.id id; } public String getUsername() { return username; } public void setUsername(String username) { this.username username; } public String getPassword() { return password; } public void setPassword(String password) { this.password password; } public String getDisplayName() { return displayName; } public void setDisplayName(String displayName) { this.displayName displayName; } public boolean isEnabled() { return enabled; } public void setEnabled(boolean enabled) { this.enabled enabled; } public String getRole() { return role; } public void setRole(String role) { this.role role; } }实体类上使用Table(uniqueConstraints UniqueConstraint(columnNames username))给username字段增加了数据库唯一约束。为什么要强调这一点因为认证系统里重复账号是底线问题如果只靠应用层判断高并发注册时很可能出现两个请求同时查到不存在、同时插入相同用户名的情况唯一索引是最后的防线。displayName字段存用户展示名称比如这里的“谷口愛季”它不参与登录校验。接着创建仓库接口文件路径为src/main/java/com/example/officialaccount/repository/UserRepository.javapackage com.example.officialaccount.repository; import com.example.officialaccount.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface UserRepository extends JpaRepositoryUser, Long { OptionalUser findByUsername(String username); }接口只需要继承JpaRepositorySpring Data JPA 会自动实现通用方法。findByUsername的返回值使用OptionalUser比返回User更安全调用方可以用orElseThrow明确表达“找不到账号”的异常行为也可以避免到处写if (user null)的判断。这里没有任何 SQL 需要手写方法名解析由框架完成。4.5 用户加载与密码校验Spring Security 的认证流程中UserDetailsService是连接数据库与安全框架的关键桥梁。新建src/main/java/com/example/officialaccount/service/UserDetailsServiceImpl.javapackage com.example.officialaccount.service; import com.example.officialaccount.entity.User; import com.example.officialaccount.repository.UserRepository; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; import java.util.Collections; Service public class UserDetailsServiceImpl implements UserDetailsService { private final UserRepository userRepository; public UserDetailsServiceImpl(UserRepository userRepository) { this.userRepository userRepository; } Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user userRepository.findByUsername(username) .orElseThrow(() - new UsernameNotFoundException(账号不存在: username)); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), user.isEnabled(), true, true, true, Collections.singletonList(new SimpleGrantedAuthority(user.getRole())) ); } }这里最容易犯的错误是在loadUserByUsername里自己写密码匹配。Spring Security 的DaoAuthenticationProvider会先调用loadUserByUsername拿到用户信息然后用配置的PasswordEncoder执行matches()进行校验所以这个方法只负责“找用户并返回安全框架所需的信息”。账号不存在时抛出UsernameNotFoundException不要返回null否则框架会包一层奇怪的AuthenticationServiceException排查问题会多绕一个弯。用户名是airi.taniguchi.official这种带点号的小写字符串在查询时要保持完全一致所以我们在登录入口统一做归一化。4.6 JWT 工具类JWT 的生成和解析统一封装在JwtUtil中文件路径为src/main/java/com/example/officialaccount/security/JwtUtil.javapackage com.example.officialaccount.security; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.Date; import java.util.HashMap; import java.util.Map; Component public class JwtUtil { Value(${app.jwt.secret}) private String secret; Value(${app.jwt.expire-hours}) private Long expireHours; public String generateToken(String username, String role) { MapString, Object claims new HashMap(); claims.put(role, role); return Jwts.builder() .setClaims(claims) .setSubject(username) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expireHours * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } }generateToken把用户名放到sub把角色放到自定义roleclaim并设置签发时间和过期时间。parseToken负责解析并校验签名如果令牌被篡改、过期或者密钥不匹配会抛出ExpiredJwtException、SignatureException等异常过滤器里统一捕获并忽略最后交给 Spring Security 返回 401。这里要特别注意secret从配置文件读取不要写在代码里如果密钥太短JJWT 会直接抛异常所以配置里特意给了足够长的示例字符串。expire-hours设置为 24演示环境可以接受生产环境建议根据业务调整成 2 小时甚至更短。4.7 JWT 认证过滤器过滤器是让每一次请求都能带上用户身份的关键。新建src/main/java/com/example/officialaccount/security/JwtAuthenticationFilter.javapackage com.example.officialaccount.security; import io.jsonwebtoken.Claims; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Collections; public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; public JwtAuthenticationFilter(JwtUtil jwtUtil) { this.jwtUtil jwtUtil; } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String header request.getHeader(Authorization); if (StringUtils.hasText(header) header.startsWith(Bearer )) { String token header.substring(7); try { Claims claims jwtUtil.parseToken(token); String username claims.getSubject(); String role claims.get(role, String.class); if (StringUtils.hasText(username) SecurityContextHolder.getContext().getAuthentication() null) { UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( username, null, Collections.singletonList(new SimpleGrantedAuthority(role))); SecurityContextHolder.getContext().setAuthentication(authentication); } } catch (Exception e) { // 解析失败时不设置认证信息后续逻辑会返回 401 } } filterChain.doFilter(request, response); } }过滤器继承OncePerRequestFilter保证每次请求只执行一次。拿到Authorization头并判断是否为Bearer前缀后截取 token 并解析。解析成功就构造UsernamePasswordAuthenticationToken然后放入SecurityContextHolder这样后续的接口就能通过Authentication authentication获取当前用户名。这里没有设置credentials密码字段因为 JWT 认证后不需要再保存密码。异常处理策略是“解析失败就跳过”不要在这里直接返回错误响应否则会导致无法进入自定义异常处理器。4.8 安全配置安全配置是整个认证系统的核心开关新建src/main/java/com/example/officialaccount/config/SecurityConfig.javapackage com.example.officialaccount.config; import com.example.officialaccount.security.JwtAuthenticationFilter; import com.example.officialaccount.security.JwtUtil; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; Configuration EnableWebSecurity public class SecurityConfig { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtUtil jwtUtil) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/api/auth/login, /h2-console/**).permitAll() .antMatchers(/api/me).authenticated() .anyRequest().authenticated() .and() .addFilterBefore(new JwtAuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class) .headers().frameOptions().sameOrigin(); return http.build(); } }由于 JWT 是无状态认证配置里禁用了 CSRF 并设置SessionCreationPolicy.STATELESS这对前后端分离项目比较合适。登录接口和 H2 控制台放行/api/me必须认证后才能访问其他请求统一authenticated()。frameOptions().sameOrigin()是为了允许 H2 控制台使用 iframe否则浏览器会拦截。addFilterBefore把自定义 JWT 过滤器放到用户名密码认证过滤器之前保证请求先经过 JWT 认证再进入授权判断。生产环境建议保留 CSRF 防护或确认接口都是无状态的第三方调用避免引入安全漏洞。4.9 登录接口与受保护接口接下来编写登录和查询当前用户信息的 Controller文件路径为src/main/java/com/example/officialaccount/controller/AuthController.javapackage com.example.officialaccount.controller; import com.example.officialaccount.dto.LoginRequest; import com.example.officialaccount.entity.User; import com.example.officialaccount.repository.UserRepository; import com.example.officialaccount.security.JwtUtil; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; import java.text.Normalizer; import java.util.Locale; import java.util.Map; RestController RequestMapping(/api) public class AuthController { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; public AuthController(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtUtil jwtUtil) { this.userRepository userRepository; this.passwordEncoder passwordEncoder; this.jwtUtil jwtUtil; } private String normalizeUsername(String rawUsername) { if (rawUsername null) { return null; } String normalized Normalizer.normalize(rawUsername, Normalizer.Form.NFKC); return normalized.toLowerCase(Locale.ROOT).trim(); } PostMapping(/auth/login) public ResponseEntity? login(RequestBody LoginRequest request) { String username normalizeUsername(request.getUsername()); User user userRepository.findByUsername(username) .orElseThrow(() - new IllegalArgumentException(账号不存在: username)); if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { return ResponseEntity.status(401).body(密码错误); } if (!user.isEnabled()) { return ResponseEntity.status(403).body(账号已被禁用); } String token jwtUtil.generateToken(user.getUsername(), user.getRole()); return ResponseEntity.ok(Map.of( token, token, username, user.getUsername(), displayName, user.getDisplayName() )); } GetMapping(/me) public ResponseEntity? me(Authentication authentication) { String username authentication.getName(); User user userRepository.findByUsername(username) .orElseThrow(() - new IllegalArgumentException(账号不存在)); return ResponseEntity.ok(Map.of( username, user.getUsername(), displayName, user.getDisplayName(), role, user.getRole() )); } }登录接口接收用户名和密码首先对用户名做归一化NFKC能兼容全角/半角差异toLowerCase可以避免大小写重复注册trim去掉首尾空格。这里刻意把归一化方法放在 Controller 中只是为了演示实际生产项目建议把这段逻辑放到领域服务里保证注册、登录、找回密码都走同一套规则。如果密码不匹配返回 401如果账号禁用返回 403。/api/me接收 Spring Security 注入的Authentication取当前登录用户名后查询数据库返回用户基本信息。4.10 登录请求 DTO 与初始化数据新建src/main/java/com/example/officialaccount/dto/LoginRequest.javapackage com.example.officialaccount.dto; public class LoginRequest { private String username; private String password; public String getUsername() { return username; } public void setUsername(String username) { this.username username; } public String getPassword() { return password; } public void setPassword(String password) { this.password password; } }DTO 负责接收前端 JSON 请求体字段名要和 JSON 中的username、password一一对应。这里没有加NotNull等校验注解生产项目建议引入spring-boot-starter-validation在 DTO 上声明非空和长度约束避免空指针或异常信息不友好。再写初始化数据的配置类文件路径为src/main/java/com/example/officialaccount/config/DataInitializer.javapackage com.example.officialaccount.config; import com.example.officialaccount.entity.User; import com.example.officialaccount.repository.UserRepository; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.password.PasswordEncoder; Configuration public class DataInitializer { Bean public CommandLineRunner initData(UserRepository userRepository, PasswordEncoder passwordEncoder) { return args - { if (userRepository.findByUsername(airi.taniguchi.official).isEmpty()) { User user new User(); user.setUsername(airi.taniguchi.official); user.setPassword(passwordEncoder.encode(Test1234!)); user.setDisplayName(谷口愛季); user.setEnabled(true); user.setRole(ROLE_USER); userRepository.save(user); } }; } }CommandLineRunner会在 Spring Boot 启动完成后执行这里的作用是插入一条演示账号。用户名和显示名完全对应标题中的“airi.taniguchi.official”和“谷口愛季”密码统一用BCryptPasswordEncoder加密存储。你可能会问为什么不用固定 SQL 初始化因为每次启动内存数据库都是空的让 JPA 自动建表后通过 Java 代码插入可以复用passwordEncoder不会出现手工 SQL 里密码格式不正确的问题。演示账号的密码强度虽然达标但只能在测试环境使用。4.11 启动项目与验证结果全部代码准备好后在项目根目录执行mvn spring-boot:run看到类似Started OfficialAccountApplication in x.xxx seconds的日志说明启动成功。打开另一个终端先用 curl 请求登录接口curl -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:airi.taniguchi.official,password:Test1234!}预期返回 JSON{token:eyJhbGciOiJIUzI1NiJ9...,username:airi.taniguchi.official,displayName:谷口愛季}拿到 token 后把它放到访问受保护接口的请求头中curl http://localhost:8080/api/me \ -H Authorization: Bearer 这里换成上一步的token预期返回当前用户信息。如果不带 token 或 token 错误会收到 401/403 响应。如果你想验证 H2 控制台可以在浏览器打开http://localhost:8080/h2-consoleJDBC URL 填jdbc:h2:mem:official_account_db用户名和密码分别是sa和空就能看到t_user表和初始化数据。5. 常见问题与排查思路5.1 常见报错汇总问题现象常见原因解决思路启动时报ClassNotFoundException: javax.xml.bind.DatatypeConverterJDK 11 缺少 JAXB引入jaxb-api依赖或升级到 jjwt 0.12.x登录返回 500日志显示NaN或UsernameNotFound用户名归一化不一致或账号确实不存在检查数据库是否存在该 user统一使用normalizeUsername登录返回“密码错误”密码明文与BCrypt密文不匹配确认初始化数据使用passwordEncoder.encode()不要手写哈希JWT 解析报SecretKey太短app.jwt.secret长度不足至少要 256 位随机字符串不要使用过度简单的值/h2-console页面是空白或被 iframe 拦截缺少frameOptions().sameOrigin()在SecurityConfig中给 H2 控制台放行并允许同源 frame请求/api/me返回 401没有携带 token或 token 过期检查Authorization: Bearer token并确认expire-hours配置中文显示名显示乱码数据库连接或 JSON 编解码字符集问题H2 默认 UTF-8 一般不会乱码MySQL 请确认连接参数characterEncodingutf85.2 排查步骤遇到问题时建议先看启动日志确认是否有依赖异常或 Bean 创建失败。第二步用curl单独请求登录接口和/api/me判断是认证环节还是接口授权环节出问题。第三步打开 H2 控制台查看t_user表的实际数据重点确认username是否完全一致、密码是否$2a$10$...开头。第四步检查请求头格式很多 401 是因为Authorization头里少了Bearer前缀。最后再结合过滤器中是否抛异常、异常是否被 catch 来判断 JWT 解析环节的状态。按这个顺序排查大部分问题都能定位到具体模块。6. 最佳实践与工程建议6.1 用户名字段规范化这次实战中我们用Normalizer.Form.NFKC加toLowerCase(Locale.ROOT)处理了airi.taniguchi.official这样的登录名。实际生产项目还应该进一步定义白名单比如允许字母、数字、点号、下划线、短横线禁止连续点号或开头结尾为点号。这个规则要写入注册校验也要写入登录前的查询归一化确保同一个人的账号在不同场景下不会因为入参差异变成两个账号。数据库端除了唯一索引还要注意表字符集。MySQL 中建议使用utf8mb4和utf8mb4_bin排序规则这样点号、大小写、日文汉字的比较行为更可预期如果是 Oracle需要额外关注NLS_COMP和NLS_SORT对用户名查询的影响。6.2 密码与令牌安全密码加密一定要使用 BCrypt 或更现代的Argon2算法不要用 MD5、SHA-1 这类可快速计算的哈希。BCrypt 的强度参数strength默认是 10如果机器性能允许可以提到 12但会稍微增加内存和耗时。令牌方面access token有效期不要设置太长本文演示用了 24 小时生产环境推荐 1 到 2 小时刷新令牌要单独设计和存储尽量支持主动吊销。密钥要使用随机生成的长字符串不要用代码里这类示例值。如果系统包含多种环境建议通过环境变量或 Apollo 等配置中心管理app.jwt.secret并保证密钥轮换时有兜底方案。6.3 日志与配置管理认证系统最容易出现的安全事故就是把密码和令牌打进日志。无论你在哪个模块打印日志都要避免输出password、token、Authorization头、数据库连接串中的密码等敏感信息。可以在全局过滤器里对响应体脱敏也可以在 DTO 的toString中忽略敏感字段。配置管理方面spring.jpa.show-sql只适合开发环境生产环境建议关闭避免 SQL 日志暴露表结构。登录接口要增加限流和账号锁定策略比如同一个 IP 一分钟内失败超过 5 次就临时封禁防止暴力破解。这些内容虽然看起来和认证代码无关但对一个真正要上线的官方账号系统来说重要性不亚于 JWT 本身。7. 总结与学习路线这次围绕“谷口愛季”和airi.taniguchi.official这个典型账号场景完成了从账号建模、密码加密、JWT 生成、过滤器校验到接口验证的整套流程。最值得记住的一点是认证系统的价值不在于登录接口有多炫而在于账号唯一性、密码不可逆、令牌生命周期和安全边界这些细节是否做到位。下一步可以先从 Spring Authorization Server 入手把自研 JWT 登录升级成标准 OAuth2 授权码模式再结合 Redis 实现 refresh token 的存储与吊销机制。如果你在实操中遇到了和文中不同的报错欢迎在评论区把报错贴出来一起讨论具体解决办法。
返回列表