告别 Session:Spring Boot 实现 JWT 无状态登录认证全攻略

告别 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 中添加:

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>

💻 三、核心代码实现四步走

我们将分四步完成:

  1. JWT 工具类:负责生成和解析 Token。
  2. 用户详情服务:告诉 Security 怎么从数据库加载用户。
  3. JWT 过滤器:拦截请求,检查 Token 是否有效。
  4. 安全配置:配置哪些接口需要登录,哪些不需要。

第一步:编写 JWT 工具类 (JwtUtil.java)

这是核心引擎,负责"发证"和"验票"。

typescript 复制代码
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 接口。
注:实际项目中这里应该调用数据库查询,这里为了演示简单,写死了一个用户。

java 复制代码
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 就会认为"这个人已经登录了"。

java 复制代码
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:

  1. 把我们的过滤器加进去。
  2. 哪些接口公开(如登录、注册),哪些需要认证。
  3. 开启无状态模式(禁用 Session)。
scala 复制代码
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 返回。

typescript 复制代码
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)

这个接口只有登录成功后才能访问。

typescript 复制代码
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) :

    json 复制代码
    {
      "username": "admin",
      "password": "123456"
    }
  • 结果:

    css 复制代码
    {
      "code": 200,
      "msg": "登录成功",
      "token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJhZG1pbiIs..."
    }

    复制返回的 token 字符串。

场景 3:携带 Token 访问私密接口

  • 请求 : GET http://localhost:8080/api/profile

  • Header:

    • Key: Authorization
    • Value: Bearer <刚才复制的 token>
      (注意:Bearer 后面有个空格)
  • 结果:

    css 复制代码
    {
      "code": 200,
      "msg": "访问成功",
      "data": "你好,admin! 这是你的私密数据。"
    }

🎉 成功!你已经实现了一套完整的无状态认证系统。


💡 六、最佳实践与注意事项

  1. 密钥安全

    代码中的 SECRET 绝对不能硬编码在代码里提交到 Git!务必放入 application.yml 或环境变量中,且生产环境要使用高强度的随机字符串。

  2. Token 刷新机制

    JWT 一旦签发,在过期前无法作废(除非引入黑名单机制)。最佳实践是:

    • Access Token:有效期短(如 15 分钟),用于业务请求。
    • Refresh Token:有效期长(如 7 天),专门用来换取新的 Access Token。
    • 当 Access Token 过期时,前端用 Refresh Token 请求刷新接口,获取新 Token。
  3. HTTPS

    JWT 在传输过程中如果被截获,攻击者可以直接冒充用户。必须在生产环境启用 HTTPS,防止中间人攻击。

  4. 敏感信息

    Payload 部分只是 Base64 编码,不是加密!任何人都可以解码看到内容。千万不要把密码、手机号等敏感信息放在 JWT 里。

结语

从 Session 到 JWT,不仅仅是技术的升级,更是架构思维的转变。掌握了这套流程,你就具备了开发现代前后端分离应用、甚至微服务架构的基础能力。

接下来,你可以尝试:

  • 对接真实的 MySQL 数据库。
  • 实现"角色权限控制"(如只有 ADMIN 能删除用户)。
  • 添加 Token 刷新接口。

安全之路无止境,祝你编码愉快!

相关推荐
Java编程爱好者2 小时前
从 AQS 到 ReentrantLock:搞懂同步队列与条件队列,这一篇就够了
后端
鱼人2 小时前
Nginx 全能指南:从反向代理到负载均衡,一篇打通任督二脉
后端
UIUV2 小时前
node:child_process spawn 模块学习笔记
javascript·后端·node.js
Java编程爱好者2 小时前
如果明天 Spring 框架突然从世界上消失,Java 会发生什么?
后端
神奇小汤圆3 小时前
Spring让Java慢了30倍,JIT、AOT等让Java比Python快13倍,比C慢17%
后端
颜酱3 小时前
单调栈:从模板到实战
javascript·后端·算法
神奇小汤圆3 小时前
支付成功订单却没了?MyBatis连接池的坑我踩了
后端
雨中飘荡的记忆5 小时前
OpenClaw:开源AI助手平台的革命之路
后端
程序员鱼皮6 小时前
GitHub 关注突破 2w,我总结了 10 个涨星涨粉技巧!
前端·后端·github