一、为什么我们需要 JWT?从一个真实的登录场景说起

在我们开发前后端分离的项目时,"用户登录状态" 这个问题几乎是绕不开的。假设您正在开发一个 Web 应用,前端用 Angular,后端用 Spring Boot,前端发起一个登录请求,后端验证账号密码之后,该怎么“记住”这个用户呢?

传统方案:Session + Cookie

这是最常见的做法:

  1. 用户登录成功,服务器创建一个 Session;
  2. JSESSIONID 放进 Cookie 返回给浏览器;
  3. 之后每个请求,浏览器自动带上这个 Cookie,后端就知道你是谁。

看起来不错?但是等您部署到线上就不妙了:

  • Session 是有状态的,每个用户都要在服务器内存里占一块空间,用户多了怎么办?
  • 如果您部署了多个后端服务,Session 怎么共享?引入 MySQL?那复杂度又上去了。
  • 移动端、跨域请求、非浏览器客户端,Cookie 不一定方便用。

所以,这一套在传统 MVC 项目里用得好好的方案,到了前后端彻底分离、服务横向扩展的架构下,变得不再合适。

第二方案:自定义 Token(比如 X-Auth-Token)

于是一些人想,那我就给用户生成个唯一 token,比如:

X-Auth-Token: 8ae0279abc123...

然后前端保存这个 token,发请求时加在 Header 里。

这就已经算是“无状态认证”了,但问题是——

  • token 是怎么生成的?自己拼个 UUID?
  • 有没有加密?能不能防止伪造?
  • 怎么知道过期了没?又得加时间戳和校验逻辑?

总之,标准性、安全性都不够。

所以,JWT来了

什么是JWT呢?

JSON Web Token(JSON网络令牌)
友情提示:理解这个缩写的英文原意有助于您记忆它的含义哦!
根据官方定义:

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

机器翻译:

JSON Web令牌是一种开放的、符合行业标准的RFC 7519方法,用于安全地表示双方之间的声明。

我对JWT的理解是:它是一个由社区广泛采用的统一标准的用来在客户端和服务器之间传递信息的一种令牌

这个 Token 是有结构、有签名、可解析的,不仅可以“知道你是谁”,还可以保证“你没有伪造”。

我们可以把用户的 ID、用户名、权限、过期时间,全部打包到这个 Token 里,签名后发给前端。前端保存这个 Token,发请求时带上它。服务器拿到 Token 验签通过,就知道你是谁,安全且无需存储状态。

接下来,我们就从原理讲起,一步步剖析 JWT 的结构、签名机制,并通过 Angular + Spring Boot 实现一个完整的登录认证流程。

二、入门知识

Token传递的是什么信息呢?这里我们就要剖析一下令牌的构成。

JWT的组成结构:

官方定义三部分构成:HEADER:ALGORITHM & TOKEN TYPE,PAYLOAD:DATA,VERIFY SIGNATURE.

事实上,JWT 令牌结构通常如下所示:xxxxx.yyyyy.zzzzz,本质是:

组成本质
Header声明类型和算法
Payload携带用户信息
Signature用密钥对前两部分进行签名

这是我在jwt官网解码示例令牌的结果:

jwt截图

签名原理

  • 签名 = HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secretKey)
  • 作用:防止令牌被篡改(任何对 Header/Payload 的修改都会导致签名验证失败)
  • 密钥 (secretKey) 必须仅服务端持有(客户端不可见)

三、Angular 18和Spring Boot的JWT简单实现

login组件(安装了Material框架):

使用Angular 18 推荐使用的信号量signal来创建变量:

  // 存储用户名
  username = signal<string>('');
  // 存储密码
  password = signal<string>('');

注入服务(使用inject()函数注入):

// 注入 AuthService 实例,用于处理登录逻辑
private authService = inject(AuthService);
// 注入 MatSnackBar 实例,用于显示提示消息
private snackBar = inject(MatSnackBar);
// 注入 Router 实例,用于路由跳转
private router = inject(Router);

发起请求:

this.authService.login(this.username(), this.password()).subscribe({
  // 登录成功的回调函数
  next: () => {
    // 显示登录成功的提示消息
    this.snackBar.open('登录成功!', '关闭', {
      duration: 3000,
      panelClass: ['success-snackbar']
    });
    this.router.navigate(['/profile']);
    // 设置正在加载中状态为 false
    this.isLoading.set(false);
  },
  // 登录失败的回调函数
  error: (error) => {
    // 设置错误消息
    this.errorMessage.set('用户名或密码错误');
    // 显示登录失败的提示消息
    this.snackBar.open('登录失败: ' + error.message, '关闭', {
      duration: 3000,
      panelClass: ['error-snackbar']
    });
    // 设置正在加载中状态为 false
    this.isLoading.set(false);
  }
});

AuthService:

认证服务:

login(username: string, password: string): Observable<any> {
  return this.http.post(this.apiUrl, { username, password }).pipe(
    tap((response: any) => {
      if (response.token) {
        localStorage.setItem('auth_token', response.token);
        this._isAuthenticated.set(true);
      } else {
        throw new Error('未收到令牌');
      }
    }),
    catchError(error => {
      // 处理HTTP错误
      let errorMessage = '登录失败';
      if (error.status === 401) {
        errorMessage = '用户名或密码错误';
      } else if (error.status === 0) {
        errorMessage = '无法连接到服务器';
      }
      return throwError(() => new Error(errorMessage));
    })
  );
}
您也可以封装一个通用 HttpErrorHandler 拦截器来集中处理这些错误。

SpringBoot 3.5示例代码:

AuthController(控制层):

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;


    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/login")
    public Map<String, String> login(@RequestBody AuthDTO.LoginRequest request) {
        String token = authService.login(request.getUsername(), request.getPassword());
        // 将 token 封装到一个 Map 中返回,键为 "token"
        return Map.of("token", token);
    }
}

AuthService(服务层):

@Service
public class AuthService {

    private final JwtUtil jwtUtil;
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final UserDetailsService userDetailsService;

    public AuthService(JwtUtil jwtUtil, PasswordEncoder passwordEncoder, UserRepository userRepository, UserDetailsService userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.passwordEncoder = passwordEncoder;
        this.userRepository = userRepository;
        this.userDetailsService = userDetailsService;
    }

    public UserDetails loadUserByUsername(String username) {
        return userDetailsService.loadUserByUsername(username);
    }

    public String login(String username, String password) {
        // 通过用户名查询用户信息
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new BadCredentialsException("用户不存在"));

        // 检查密码是否匹配
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new BadCredentialsException("密码错误");
        }
        return jwtUtil.generateToken(user);
    }

}

JwtUtil(实现jwt关键的工具类,主要是生成jwt令牌):

@Component
public class JwtUtil {
    // HMAC-SHA加密算法使用的密钥
    private final SecretKey secretKey;
    // JWT令牌的有效时长(小时)
    private final long expirationHours;

    /**
     * 构造函数,通过依赖注入获取JWT配置属性
     *
     * @param jwtConfigProperties JWT配置属性对象,包含密钥和过期时间
     */
    public JwtUtil(JwtConfigProperties jwtConfigProperties) {
        // 基于配置文件中的密钥字符串生成加密密钥
        // 使用HMAC-SHA算法,要求密钥长度至少为256位
        this.secretKey = Keys.hmacShaKeyFor(jwtConfigProperties.getSecret().getBytes(StandardCharsets.UTF_8));
        // 从配置中获取JWT的有效时长(小时)
        this.expirationHours = jwtConfigProperties.getExpirationHours();
    }

    /**
     * 根据用户信息生成JWT令牌
     *
     * @param user 包含用户信息的实体对象
     * @return 生成的JWT字符串
     */
    public String generateToken(User user) {
        return Jwts.builder()
                // 设置JWT的主题(Subject)为用户名,作为用户标识
                .setSubject(user.getUsername())
                // 设置JWT的签发时间(Issued At)为当前系统时间
                .setIssuedAt(new Date())
                // 设置JWT的过期时间(Expiration)
                // 通过当前时间加上配置的小时数(转换为毫秒)计算
                .setExpiration(new Date(System.currentTimeMillis() + expirationHours * 3600_000))
                // 使用之前生成的密钥对JWT进行签名,确保令牌完整性
                .signWith(secretKey)
                // 构建并返回最终的JWT字符串
                .compact();
    }

    /**
     * 从JWT令牌中提取用户名
     *
     * @param token JWT字符串
     * @return 提取的用户名
     */
    public String extractUsername(String token) {
        // 解析JWT并获取Claims对象,然后从中提取主题(用户名)
        return getClaims(token).getSubject();
    }

    /**
     * 验证JWT令牌的有效性
     * 验证内容包括:签名是否有效、令牌是否过期、格式是否正确
     *
     * @param token JWT字符串
     * @return 如果令牌有效返回true,否则返回false
     */
    public boolean validateToken(String token) {
        try {
            // 尝试解析令牌,如果成功则表示签名和格式有效
            // 解析过程中会自动检查令牌的过期时间
            getClaims(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            // 捕获JWT解析异常(如签名无效、格式错误)或非法参数异常
            // 这些情况都表明令牌无效
            return false;
        }
    }

    /**
     * 解析JWT令牌并获取其中的Claims(声明)对象
     * Claims包含了JWT中存储的所有用户信息和元数据
     *
     * @param token JWT字符串
     * @return 包含JWT声明的Claims对象
     * @throws JwtException 如果令牌无效或已过期
     */
    private Claims getClaims(String token) {
        return Jwts.parserBuilder()
                // 设置用于验证签名的密钥,必须与生成令牌时使用的密钥相同
                .setSigningKey(secretKey)
                // 构建JWT解析器
                .build()
                // 解析JWT并获取JWS(JSON Web Signature)对象
                .parseClaimsJws(token)
                // 从JWS中获取Claims(声明)部分
                .getBody();
    }

}
签名原理基于 HMAC-SHA 算法对 Header+Payload 进行签名,服务端通过密钥校验是否篡改。如果您感兴趣其中的实现原理,我推荐您阅读博客:JWT 是什麼?一次搞懂 JWT 的組成和運作原理

SecurityConfig(提供的核心安全功能,避免大量手动实现配置):

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(@Lazy JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    // 配置安全过滤链,用于定义请求的访问规则和安全策略
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                // 禁用 CSRF 保护,因为在 RESTful 应用中通常不需要 CSRF 保护
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> auth
                        // 允许所有用户访问 /api/auth/login 的 POST 请求
                        .requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
                        // 其他所有请求都需要进行身份验证
                        .anyRequest().authenticated()
                )
                .sessionManagement(session -> session
                        // 设置会话创建策略为无状态,即不使用会话管理
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .exceptionHandling(exception -> exception
                        // 当用户未经过身份验证时,返回 401 状态码
                        .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

JwtAuthenticationFilter(JWT过滤器):

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final AuthService authService;

    public JwtAuthenticationFilter(JwtUtil jwtUtil, AuthService authService) {
        this.jwtUtil = jwtUtil;
        this.authService = authService;
    }

    @Override
    protected void doFilterInternal(@NonNull HttpServletRequest request,
                                    @NonNull HttpServletResponse response,
                                    @NonNull FilterChain filterChain) throws IOException {

        String token = extractToken(request);

        if (token != null && jwtUtil.validateToken(token)) {
            String username = jwtUtil.extractUsername(token);
            UserDetails userDetails = authService.loadUserByUsername(username);

            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());

            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        try {
            filterChain.doFilter(request, response);
        } catch (ServletException e) {
            throw new RuntimeException(e);
        }
    }

    private String extractToken(HttpServletRequest request) {
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            return authHeader.substring(7);
        }
        return null;
    }
}
前后端分离场景下通常禁用 CSRF,但如果是传统表单应用,需谨慎处理。

至此,一个基础的 JWT 登录功能就搭建完成啦!记得导入相应的依赖哦!不懂某处代码的含义和作用可以问AI,此处提供代码只是便于您参考或者快速搭建出一个登录原型,不至于和AI斗智斗勇好久得不出一个完美的实现。如有谬误,望您指正。

四、流程图

这是一个简单的JWT流程图:
jwt流程图

五、注意

  • JWT 内容仅 Base64 编码,非加密!敏感数据(如密码)不应存入 Payload
  • 令牌泄露 = 身份被盗用,务必使用 HTTPS 传输
  • 令牌过期时间(expirationHours)不宜过长

六、结语

最后,感谢潘老师开启我编程的道路,成为我学习编程的引路人。希望这篇文章可以对您有所帮助!


姜博瓒
1 声望0 粉丝

行百里者半九十