news 2026/10/7 2:04:30

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于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. 核心知识拆解:账号存储、密码加密与 JWT

3.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 令牌结构与校验

JWT(JSON Web Token)由三部分组成:Header、Payload、Signature,每一段都用 Base64URL 编码,中间用点号连接。Header 通常声明算法HS256,Payload 里可以放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 version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>official-account-auth</artifactId> <version>1.0.0</version> <name>official-account-auth</name> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.3.1</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-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.java:

package 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.java:

package 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 JpaRepository<User, Long> { Optional<User> findByUsername(String username); }

接口只需要继承JpaRepository,Spring Data JPA 会自动实现通用方法。findByUsername的返回值使用Optional<User>,比返回User更安全,调用方可以用orElseThrow明确表达“找不到账号”的异常行为,也可以避免到处写if (user == null)的判断。这里没有任何 SQL 需要手写,方法名解析由框架完成。

4.5 用户加载与密码校验

Spring Security 的认证流程中,UserDetailsService是连接数据库与安全框架的关键桥梁。新建src/main/java/com/example/officialaccount/service/UserDetailsServiceImpl.java:

package 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.java:

package 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) { Map<String, 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.java:

package 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.java:

package 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.java:

package 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.java:

package 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.java:

package 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-console,JDBC 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 请确认连接参数characterEncoding=utf8

5.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 的存储与吊销机制。如果你在实操中遇到了和文中不同的报错,欢迎在评论区把报错贴出来,一起讨论具体解决办法。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 2:04:29

小智AI语音控制实战:MCP工具注册与系统音量调节全流程

前几天夜里我一直在折腾一件事&#xff1a;让小智AI在听懂“把音量调到百分之四十”之后&#xff0c;真的动手去改系统音量&#xff0c;而不是只回我一句“好的&#xff0c;已为你调低音量”。这个目标听起来很基础&#xff0c;但真走完才发现&#xff0c;背后其实是一条很长的…

作者头像 李华
网站建设 2026/10/7 2:02:32

Samba 4 域控运维脚本集:备份、巡检与信息采集实战

简介&#xff1a;这份资源汇集了在 Samba 4&#xff08;AD-DC&#xff09;环境中日常运维常用的 Shell 脚本集合&#xff0c;面向在 Debian Jessie 与 Debian Stretch 上搭建、维护 Samba 域控及成员服务器的系统管理员与运维人员。内容涵盖备份、权限检查、sysvol ACL 设置、域…

作者头像 李华