告别 Session:Spring Boot 实现 JWT 无状态登录认证全攻略
在现代前后端分离的架构中,传统的 Session/Cookie 认证方式逐渐显露出疲态:服务器内存压力大、难以跨域、不支持分布式扩展。取而代之的是 JWT (JSON Web Token) 技术。
很多初学者对“登录”、“Token”、“JWT”这几个概念的关系感到困惑:登录时发生了什么?Token 是怎么生成的?后端怎么验证它?
本文将用最清晰的逻辑和完整的代码,带你从零实现一套基于 Spring Boot + Spring Security + JWT 的无状态登录认证系统。
🔍 一、核心概念:它们到底是什么关系?
在写代码前,我们先理清三个关键概念:
1. 登录 (Login)
这是一个动作。用户提交用户名和密码,后端验证通过后,颁发一个“通行证”。
2. Token (令牌)
这是一个概念。它就是那个“通行证”。
- 传统模式 (Session) :通行证是一张纸条,上面写个编号(Session ID)。服务器得有个大本子(内存/Redis)去查这个编号对应谁。
- 现代模式 (JWT) :通行证本身就是一张加密的身份证,上面直接写着“我是张三,有效期2小时”。服务器不需要查本子,只要验证身份证的防伪标记(签名)是真的,就信你。
3. JWT (JSON Web Token)
这是 Token 的一种具体实现标准。
它长这样:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlphbmdTYW4iLCJleHAiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
它由三部分组成,用 . 连接:
- Header (头部) :声明算法(如 HS256)和类型。
- Payload (载荷) :存放用户信息(如 UserID、角色、过期时间)。注意:这里不要放密码!
- Signature (签名) :用服务器的“密钥”对前两部分进行签名,防止篡改。
核心优势:无状态。服务器不需要存储任何会话信息,天然支持集群和微服务。
🛠️ 二、准备工作:引入依赖
我们需要两个核心依赖:Spring Security(安全框架)和 JJWT(JWT 工具库)。
在 pom.xml 中添加:
<dependencies>
<!-- 1. Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 2. Spring Security (负责安全拦截) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- 3. JJWT (生成和解析 JWT 的工具) -->
<!-- 注意:jjwt 0.12+ 版本拆分了依赖,这里使用经典的 0.9.1 或 0.11.x 版本方便上手 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- Lombok (简化代码,可选) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
💻 三、核心代码实现四步走
我们将分四步完成:
- JWT 工具类:负责生成和解析 Token。
- 用户详情服务:告诉 Security 怎么从数据库加载用户。
- JWT 过滤器:拦截请求,检查 Token 是否有效。
- 安全配置:配置哪些接口需要登录,哪些不需要。
第一步:编写 JWT 工具类 (JwtUtil.java)
这是核心引擎,负责“发证”和“验票”。
package com.example.demo.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Component
public class JwtUtil {
// 密钥,生产环境一定要放在配置文件里,并且要够长够复杂!
private static final String SECRET = "my-super-secret-key-which-is-long-enough";
// 过期时间:2 小时 (毫秒)
private static final long EXPIRATION = 7200000L;
/**
* 生成 Token
* @param username 用户名
* @return token 字符串
*/
public String generateToken(String username) {
Map<String, Object> claims = new HashMap<>();
claims.put("sub", username); // subject
claims.put("created", new Date());
return createToken(claims, username);
}
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims)
.setSubject(subject)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
.signWith(SignatureAlgorithm.HS512, SECRET) // 使用 HS512 算法签名
.compact();
}
/**
* 解析 Token,获取用户名
*/
public String getUsernameFromToken(String token) {
return getClaimsFromToken(token).getSubject();
}
/**
* 验证 Token 是否有效
*/
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token);
return true;
} catch (Exception e) {
return false; // 过期、签名错误等都会捕获
}
}
private Claims getClaimsFromToken(String token) {
return Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(token)
.getBody();
}
}
第二步:实现 UserDetailsService
Spring Security 需要知道去哪里找用户。我们需要实现 UserDetailsService 接口。
注:实际项目中这里应该调用数据库查询,这里为了演示简单,写死了一个用户。
package com.example.demo.security;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
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 CustomUserDetailsService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 模拟数据库查询
if ("admin".equals(username)) {
// 参数:用户名,密码,权限列表
// 实际密码应该是加密后的(如 BCrypt),这里明文仅作演示
return new User("admin", "123456",
Collections.singletonList(new SimpleGrantedAuthority("ROLE_ADMIN")));
}
throw new UsernameNotFoundException("用户不存在:" + username);
}
}
第三步:编写 JWT 认证过滤器 (JwtAuthenticationFilter)
这是最关键的一步。每次请求进来,过滤器会检查 Header 里有没有 Token。如果有,就解析并设置到安全上下文中,Spring Security 就会认为“这个人已经登录了”。
package com.example.demo.security;
import com.example.demo.util.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
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;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
// 1. 从请求头获取 Token
// 格式通常是:Authorization: Bearer <token>
String header = request.getHeader("Authorization");
String token = null;
if (StringUtils.hasText(header) && header.startsWith("Bearer ")) {
token = header.substring(7);
}
// 2. 如果有 Token 且未认证,则进行验证
if (StringUtils.hasText(token) && SecurityContextHolder.getContext().getAuthentication() == null) {
if (jwtUtil.validateToken(token)) {
// 3. 解析出用户名
String username = jwtUtil.getUsernameFromToken(token);
// 4. 加载用户详情
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 5. 构建认证对象,存入上下文
// 一旦存入,后续的业务代码就可以通过 SecurityContextHolder 获取当前用户
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
}
第四步:配置 Spring Security (SecurityConfig)
最后,我们要告诉 Spring Security:
- 把我们的过滤器加进去。
- 哪些接口公开(如登录、注册),哪些需要认证。
- 开启无状态模式(禁用 Session)。
package com.example.demo.config;
import com.example.demo.security.CustomUserDetailsService;
import com.example.demo.security.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
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.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
// 注意:Spring Security 6+ 写法略有不同,此处以 Spring Security 5.x/Boot 2.x-3.0 兼容写法为例
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
// 配置密码编码器
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// 配置 AuthenticationManager (用于登录接口手动认证)
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// 1. 关闭 CSRF (前后端分离通常不需要)
.csrf().disable()
// 2. 设置为无状态,不使用 Session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 3. 配置请求授权规则
.authorizeRequests()
// 登录接口、静态资源等允许匿名访问
.antMatchers("/api/login").permitAll()
// 其他所有请求都需要认证
.anyRequest().authenticated()
.and()
// 4. 添加 JWT 过滤器 (在用户名密码过滤器之前执行)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
// 禁止缓存,防止敏感信息被浏览器缓存
http.headers().cacheControl();
}
}
🚀 四、编写登录接口与受保护接口
现在基础设施好了,我们来写具体的业务代码。
1. 登录接口 (AuthController)
用户 POST 用户名和密码,我们验证通过后生成 Token 返回。
package com.example.demo.controller;
import com.example.demo.util.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class AuthController {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtUtil jwtUtil;
@PostMapping("/login")
public Map<String, Object> login(@RequestBody Map<String, String> loginData) {
String username = loginData.get("username");
String password = loginData.get("password");
try {
// 1. 进行身份认证 (如果失败会抛异常)
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(username, password)
);
// 2. 认证通过,生成 Token
String token = jwtUtil.generateToken(username);
// 3. 返回结果
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("msg", "登录成功");
result.put("token", token); // 前端拿到这个 token,以后存在 localStorage
return result;
} catch (BadCredentialsException e) {
Map<String, Object> error = new HashMap<>();
error.put("code", 401);
error.put("msg", "用户名或密码错误");
return error;
}
}
}
2. 受保护的测试接口 (TestController)
这个接口只有登录成功后才能访问。
package com.example.demo.controller;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class TestController {
@GetMapping("/profile")
public Map<String, Object> getProfile() {
// 从安全上下文中获取当前登录用户
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
String username;
if (principal instanceof UserDetails) {
username = ((UserDetails) principal).getUsername();
} else {
username = principal.toString();
}
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("msg", "访问成功");
result.put("data", "你好," + username + "! 这是你的私密数据。");
return result;
}
}
🧪 五、如何测试?
启动项目后,使用 Postman 或 curl 进行测试。
场景 1:未登录直接访问私密接口
- 请求:
GET http://localhost:8080/api/profile - 结果:
401 Unauthorized(被 Security 拦截了)
场景 2:登录获取 Token
-
请求:
POST http://localhost:8080/api/login -
Body (JSON) :
{ "username": "admin", "password": "123456" } -
结果:
{ "code": 200, "msg": "登录成功", "token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIs..." }复制返回的
token字符串。
场景 3:携带 Token 访问私密接口
-
请求:
GET http://localhost:8080/api/profile -
Header:
- Key:
Authorization - Value:
Bearer <刚才复制的 token>
(注意:Bearer 后面有个空格)
- Key:
-
结果:
{ "code": 200, "msg": "访问成功", "data": "你好,admin! 这是你的私密数据。" }
🎉 成功!你已经实现了一套完整的无状态认证系统。
💡 六、最佳实践与注意事项
-
密钥安全:
代码中的SECRET绝对不能硬编码在代码里提交到 Git!务必放入application.yml或环境变量中,且生产环境要使用高强度的随机字符串。 -
Token 刷新机制:
JWT 一旦签发,在过期前无法作废(除非引入黑名单机制)。最佳实践是:- Access Token:有效期短(如 15 分钟),用于业务请求。
- Refresh Token:有效期长(如 7 天),专门用来换取新的 Access Token。
- 当 Access Token 过期时,前端用 Refresh Token 请求刷新接口,获取新 Token。
-
HTTPS:
JWT 在传输过程中如果被截获,攻击者可以直接冒充用户。必须在生产环境启用 HTTPS,防止中间人攻击。 -
敏感信息:
Payload 部分只是 Base64 编码,不是加密!任何人都可以解码看到内容。千万不要把密码、手机号等敏感信息放在 JWT 里。
结语
从 Session 到 JWT,不仅仅是技术的升级,更是架构思维的转变。掌握了这套流程,你就具备了开发现代前后端分离应用、甚至微服务架构的基础能力。
接下来,你可以尝试:
- 对接真实的 MySQL 数据库。
- 实现“角色权限控制”(如只有 ADMIN 能删除用户)。
- 添加 Token 刷新接口。
安全之路无止境,祝你编码愉快!