从零开始一个完整的全栈项目(6) - 为登录添加JWT验证

239 阅读6分钟

1. 《理论知识》

首先是一些理论知识:
本文理论知识来自于以下两篇文章:
www.freecodecamp.org/chinese/new…
www.freecodecamp.org/news/how-to…

1. 什么是JWT?

在我们正式开始之前,让我们快速回顾一下JSON Web Token(JWT)到底是什么。

JSON Web Token(JWT)是一种在两方之间紧凑的、URL安全的传输数据的方式,用来管理user session的状态。

它由开放标准(RFC 7519)定义,并由三个部分组成:header(头部)、payload(负载)以及一个Signature(加密)部分。

JWT在生成时会被签名,相同的签名JWT在收到时会被验证,以确保它在传输过程中没有被修改。

--出自

2. JWT工作原理

image.png

3. JWT的组成部分

JWT由3部分组成,每一部分用一个.隔开。

JWT的Header(头部)部分

第一个部分是头部,如下:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9

头部是一个JSON对象,包含了一个签名算法和一个令牌类型。它是由base64Url编码而成。

解码后如下:

{
  "alg": "RS256",
  "typ": "JWT"
}

其中,alg代表此次加密所使用的算法(algorithm)。
typ用于表明这个token(令牌)的类型是什么。我们现在传给服务器的token是一个JSON web token(JWT)。

JWT的Payload(负载)部分

第二部分是负载:

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0

这是一个包含数据声明的JSON对象,其中包含有关用户的信息和其他与身份验证相关的信息。

是JWT从一个实体传递到另一个实体的信息。它也是base64Url编码的。数据声明如下所示:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true,
  "iat": 1516239022
}

因为JWT通常用来进行用户身份验证(User Identification),所以通常,它的Payload(负载)部分都是用户的身份信息等机密信息。

JWT的signature(加密/签名)部分

最后一部分是加密/签名部分。

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  your-256-bit-secret
)

通常来说,它的组成部分是:你这次JWT的头部部分(header),负载部分(payload),以及一个“秘密”(secret)--这通常是用来加密的算法中的“密钥”内容。(原文:usually the contents of a key in a signing algorithm)。

用这“三部分”内容组合起来,进行一次加密,变成了JWT的签名部分(Signature)。

JWT被签名之后不能在传输的过程中被修改。一旦修改,则JWT验证会失败。

请注意:JWT的Signature(加密/签名)部分,虽然有过加密,但是这只是作为“身份验证(Validation)”来使用。它并不会对JWT的前两段(header,payload)进行任何加密,所以永远不要把“密码”等机密信息放入JWT并从服务器发回客户端。一个JWT的header和payload只应存一些公共内容。

4. JWT怎么进行验证

JWT具体的验证方法,会根据在头部(header)中写明的加密算法的不同,而略有不同。

但是通常来说,可以理解为:
服务器会保存一份JWT信息,然后当你传入一个JWT验证请求的时候,它会根据你传入的JWT的headerpayload,再加上本算法的“密钥”,生成一个Signature(JWT的第三段)。

如果这个Signature,和服务器上保留的JWT的Signature一致,那就是正确的。反正验证失败。


2. 《代码修改》

1. 创建一个JWT工具类,用于处理token的生成和验证

package com.quickstore.security;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@Component
public class JwtTokenProvider {

    @Value("${spring.security.jwt.secret}")
    private String jwtSecret;

    @Value("${spring.security.jwt.expiration}")
    private long jwtExpiration;

    private Key getSigningKey() {
        byte[] keyBytes = jwtSecret.getBytes();
        return Keys.hmacShaKeyFor(keyBytes);
    }

    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        return createToken(claims, userDetails.getUsername());
    }

    private String createToken(Map<String, Object> claims, String subject) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + jwtExpiration);

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(getSigningKey())
                .compact();
    }

    public String getUsernameFromToken(String token) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();

        return claims.getSubject();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(getSigningKey())
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }
} 

2. 创建一个JWT认证过滤器

package com.quickstore.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider tokenProvider;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtTokenProvider tokenProvider, UserDetailsService userDetailsService) {
        this.tokenProvider = tokenProvider;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            String jwt = getJwtFromRequest(request);

            if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
                String username = tokenProvider.getUsernameFromToken(jwt);
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception ex) {
            logger.error("Could not set user authentication in security context", ex);
        }

        filterChain.doFilter(request, response);
    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
} 

3. 修改AuthController来返回JWT token

package com.quickstore.controller;

import com.quickstore.model.User;
import com.quickstore.security.JwtTokenProvider;
import com.quickstore.service.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Collections;

@RestController
@RequestMapping("/auth")
public class AuthController {
    private static final Logger logger = LoggerFactory.getLogger(AuthController.class);

    private final UserService userService;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider tokenProvider;

    public AuthController(UserService userService, PasswordEncoder passwordEncoder, JwtTokenProvider tokenProvider) {
        this.userService = userService;
        this.passwordEncoder = passwordEncoder;
        this.tokenProvider = tokenProvider;
    }

    @PostMapping("/login")
    public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest loginRequest) {
        logger.info("Attempting login for user: {}", loginRequest.getUsername());
        
        User user = userService.findByUsername(loginRequest.getUsername());
        
        if (user != null && passwordEncoder.matches(loginRequest.getPassword(), user.getPasswordHash())) {
            logger.info("Login successful for user: {}", user.getUsername());
            
            UserDetails userDetails = new org.springframework.security.core.userdetails.User(
                    user.getUsername(),
                    user.getPasswordHash(),
                    Collections.singletonList(new org.springframework.security.core.authority.SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase()))
            );
            
            String token = tokenProvider.generateToken(userDetails);
            return ResponseEntity.ok(new LoginResponse(token, user.getUsername(), user.getRole()));
        }
        
        logger.warn("Login failed for user: {}", loginRequest.getUsername());
        return ResponseEntity.badRequest().body(null);
    }
}

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;
    }
}

class LoginResponse {
    private String token;
    private String username;
    private String role;

    public LoginResponse(String token, String username, String role) {
        this.token = token;
        this.username = username;
        this.role = role;
    }

    public String getToken() {
        return token;
    }

    public void setToken(String token) {
        this.token = token;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getRole() {
        return role;
    }

    public void setRole(String role) {
        this.role = role;
    }
} 

4. 更新SecurityConfig,配置JWT过滤器

package com.quickstore.config;

import com.quickstore.security.JwtAuthenticationFilter;
import com.quickstore.security.JwtTokenProvider;
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.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
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 SecurityFilterChain securityFilterChain(HttpSecurity http, JwtTokenProvider tokenProvider, UserDetailsService userDetailsService) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(new JwtAuthenticationFilter(tokenProvider, userDetailsService), 
                           UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

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

5. 验证JWT功能是否完成

  1. 使用Postman发送登录请求:
  2. 如果成功就会返回token(由服务器创建):
{
    "token": "eyJhbGciOiJIUzI1NiJ9...",
    "username": "admin",
    "role": "ADMIN"
}

image.png 返回200状态,并拿到token值。登录完成!

  1. 客户端发送JWT token回服务器,请求校验:
    在Header中添加我们拿到的token值:
Key: Authorization
Value: bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsImlhdCI6MTc0NzY1NDEzOSwiZXhwIjoxNzQ3NzQwNTM5fQ.Gpjmr0GujDFpJn0NTSCb8_1E2KJVitaMNgi9edXF0Q0

image.png 返回200状态,验证完成!

注:之所以value里面token前面还有一个bearer , 是因为我们在JwtAuthenticationFilter里面写了要以bearer 开头:

private String getJwtFromRequest(HttpServletRequest request) {
    String bearerToken = request.getHeader("Authorization");
    if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
        return bearerToken.substring(7);
    }
    return null;
}

至此,为登录功能添加JWT验证功能。完成。


题外话:
至此,一个全栈项目的“数据库搭建”“用户登录功能”“JWT验证”就完成了。面对面试官的话,就可以围绕这几个方面展开,展示你“全栈工程师”的能力(偏后端)了。

比如:

  • 使用SQL语句创建数据库
  • 使用script(脚本)建立数据库表
  • 完成用户登录功能
  • 对用户密码进行加密
  • 对访问链接进行过滤(application.yml里面规定了链接必须要以api开头, SecurityConfig里面规定了/auth/**类型的链接才能被允许通行(permit)。两个合在一起,就是api/auth/**类型的链接才能通行,其他不符合要求的访问请求,会被Spring boot的Security(安全校验)部分拒绝。)
  • JWT验证是什么意思
  • JWT token的三段式结构
  • 为什么JWT token具有安全性
  • JWT token是否可以伪造
  • 你在你的项目里是怎么添加JWT并使用什么软件进行验证等

希望能以此找个好工作,Good Luck!


下一篇:新用户创建(使用Postman)