Day12-JWT

249 阅读6分钟

关于Token

**Token:**令牌,票据

Token机制是用于解决服务器端识别客户端身份的。

在使用Token机制时,当客户端首次向服务器提交请求时,或提交登录的请求时,客户端是直接将请求发送到服务器端的,并不做特殊处理,而服务器端会按需处理请求(例如客户端提交的是登录请求,则处理登录),并且将客户端的身份数据生成一个Token,并将此Token响应到客户端去,后续,客户端需要携带此Token提交各种请求,服务器端也会根据此Token数据来识别客户端的身份。

与Session不同,Token是由服务器端的程序(自行编写的)生成的数据,是一段有意义的数据,相比之下,Session机制中的Session ID是一个UUID值,仅保证唯一性,数据本身是没有意义的!Token不需要在服务器端存在匹配的数据,因为自身就是数据!

在处理过程中,服务器端只需要检查Token,并从Token中解析出客户端身份相关的数据即可,在服务器端的内存中并不需要保存Token的数据,所以,Token是可以设置较长甚至很长的有效期的,不会消耗服务器端用于存储数据的内存资源。

同时,Token天生就适用于集群或分布式系统,只需要各服务器具有相同的检查Token和解析Token的程序即可。

关于JWT

**JWT:**JSON Web Token

JWT的官网:jwt.io/

每个JWT数据都是由3大部分组成的:

  • Header:声明算法与Token类型
  • Payload:数据
  • Verify Signature:验证签名

关于JWT编程的工具包:jwt.io/libraries?l…

例如,在项目中添加JJWT的依赖项:

<!-- JJWT(Java JWT) -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

接下来,就可以在项目中尝试生成、解析JWT数据,例如:

package cn.tedu.csmall.passport;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.junit.jupiter.api.Test;

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

public class JwtTests {

    String secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";

    @Test
    void generate() {
        Map<String, Object> claims = new HashMap<>();
        claims.put("id", 9527);
        claims.put("username", "spring");

        String jwt = Jwts.builder()
                // Header:声明算法与Token类型
                .setHeaderParam("alg", "HS256")
                .setHeaderParam("typ", "JWT")
                // Payload:数据,具体表现为Claims
                .setClaims(claims)
                .setExpiration(new Date(System.currentTimeMillis() + 2 * 60 * 1000))
                // Verify Signature:验证签名
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
        System.out.println(jwt);
    }

    @Test
    void parse() {
        String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjc4MjQzNDYwLCJ1c2VybmFtZSI6InNwcmluZyJ9.HZni3OQYS1YwTEpBoNPPz222UrgCcdD1j7nBDgoZxzs";
        Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
        Object id = claims.get("id");
        Object username = claims.get("username");
        System.out.println("id = " + id);
        System.out.println("username = " + username);
    }

}

如果尝试解析的JWT已过期,会出现错误:

io.jsonwebtoken.ExpiredJwtException: JWT expired at 2023-03-08T10:30:16Z. Current time: 2023-03-08T10:41:32Z, a difference of 676763 milliseconds.  Allowed clock skew: 0 milliseconds.

如果尝试解析的JWT数据格式有误,会出现错误:

io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"id":952name":"spring"}

如果尝试解析的JWT数据签名有误,会出现错误:

io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

**注意:**即使不知道secretKey,其实也可以解析出JWT数据中的内容,例如将JWT数据粘贴到JWT的官网即可解析出内容,所以,不要在JWT中存入敏感数据!另外,即使在JWT官网或使用其它API可以解读出JWT中的数据,但是,也会提示“无法验证签名”的字样,包括解析失败时的异常信息也会提示“不要信任此次的解析结果”。

在项目中使用JWT识别用户的身份

核心流程

在项目中使用JWT识别用户的身份,大致需要:

  • 当用户通过登录的验证后,服务器应该生成JWT数据,并响应到客户端

    • 当通过验证后,不再需要(没有必要)将用户的认证信息存入到SecurityContext
  • 当用户尝试执行需要通过认证的操作时,用户应该自主携带JWT,并且,服务器端应该尝试解析此JWT,从而验证JWT的真伪,并识别用户的身份,如果一切无误,再将用户的认证信息存入到SecurityContext

登录成功后响应JWT

当用户通过登录的验证后,服务器应该生成JWT数据,并响应到客户端!

当通过验证后,不再将用户的认证信息存入到SecurityContext中,则在AdminServiceImpllogin()方法中调整:

// 使用JWT机制时,登录成功后不再需要将认证信息存入到SecurityContext,则注释或删除以下2行代码
// SecurityContext securityContext = SecurityContextHolder.getContext();
// securityContext.setAuthentication(authenticateResult);

IAdminService中,需要将login()方法的返回值类型改为String,表示登录成功后将返回JWT数据,例如:

/**
 * 管理员登录
 * @param adminLoginInfoDTO 封装了用户名、密码等相关信息的对象
 * @return 此管理员登录后得到的JWT数据
 */
String login(AdminLoginInfoDTO adminLoginInfoDTO);

并且,调整AdminServiceImpl中的login()的声明与实现:

@Override
public String login(AdminLoginInfoDTO adminLoginInfoDTO) {
    log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginInfoDTO);
    Authentication authentication = new UsernamePasswordAuthenticationToken(
            adminLoginInfoDTO.getUsername(), adminLoginInfoDTO.getPassword());
    Authentication authenticateResult
            = authenticationManager.authenticate(authentication);
    log.debug("认证通过!(如果未通过,过程中将抛出异常,你不会看到此条日志!)");
    log.debug("认证结果:{}", authenticateResult);
    log.debug("认证结果中的当事人:{}", authenticateResult.getPrincipal());

    // 使用JWT机制时,登录成功后不再需要将认证信息存入到SecurityContext
    // SecurityContext securityContext = SecurityContextHolder.getContext();
    // securityContext.setAuthentication(authenticateResult);

    // 需要存入到JWT中的数据
    AdminDetails adminDetails = (AdminDetails) authenticateResult.getPrincipal();
    Map<String, Object> claims = new HashMap<>();
    claims.put("id", adminDetails.getId());
    claims.put("username", adminDetails.getUsername());
    // 权限待定

    // 生成JWT,以下代码是相对固定的
    String secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";
    String jwt = Jwts.builder()
            // Header:声明算法与Token类型
            .setHeaderParam("alg", "HS256")
            .setHeaderParam("typ", "JWT")
            // Payload:数据,具体表现为Claims
            .setClaims(claims)
            .setExpiration(new Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000))
            // Verify Signature:验证签名
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
    log.debug("生成了JWT数据:{}", jwt);
    return jwt;
}

然后,还需要调整AdminControllerlogin()方法,在调用Service的login()方法时获取返回的JWT,并响应到客户端,例如:

@PostMapping("/login")
public JsonResult<String> login(AdminLoginInfoDTO adminLoginInfoDTO) {
    log.debug("开始处理【管理员登录】的请求,参数:{}", adminLoginInfoDTO);
    String jwt = adminService.login(adminLoginInfoDTO);
    return JsonResult.ok(jwt);
}

解析客户端携带的JWT

客户端提交请求(无论是什么请求),都可能携带了JWT,在服务器端,处理多种不同的请求时都可能需要获取并尝试解析JWT,则应该使用过滤器Filter)组件进行处理!

提示:过滤器(Filter)是Java服务器端应用程序的核心组件之一,它是最早接收到请求的组件!过滤器可以选择对此请求进行“阻止”或“放行”!同一个项目中,允许存在若干个过滤器,形成“过滤器链”(FilterChain),任何一个请求,仅当过滤器链上的每个过滤器都选择“放行”才可以被控制器或其它组件进行处理!

在项目的根包下创建filter.JwtAuthorizationFilter类,继承自OncePerRequestFilter类,并在类上添加@Component注解,例如:

package cn.tedu.csmall.passport.filter;

import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class JwtAuthorizationFilter extends OncePerRequestFilter {

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

    }
}

然后,在过滤器的方法中接收JWT数据:

@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.debug("JWT过滤器开始执行……");
        // 根据业内惯用的做法,客户端提交的请求中的JWT应该存放于请求头(Request Header)中的名为Authorization属性中
        String jwt = request.getHeader("Authorization");
        log.debug("客户端携带的JWT:{}", jwt);

        // 放行
        filterChain.doFilter(request, response);
    }

}

然后,还需要在SecurityConfiguration中将此过滤器注册到Spring Security框架的过滤器链中:

image.png 在API文档中,通过“全局参数设置”来配置请求头中的JWT数据:

image.png

**注意:**在进行以上配置时,参数名称Authorization是严格区分大小写的,也不允许有多余的空格!

接下来,任何新打开的调试页面中都可以看到请求头中携带的数据:

image.png

image.png

package cn.tedu.csmall.passport.filter;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
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;
import java.util.ArrayList;
import java.util.List;

/**
 * <p>JWT过滤器</p>
 *
 * <p>此过滤器的主要作用</p>
 * <ul>
 *     <li>接收客户端提交的请求中的JWT</li>
 *     <li>尝试解析客户端提交的请求中的有效JWT</li>
 *     <li>将解析成功得到的数据创建为Authentication对象,并存入到SecurityContext中</li>
 * </ul>
 */
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    /**
     * JWT的最小长度值
     */
    public static final int JWT_MIN_LENGTH = 113;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.debug("JWT过滤器开始执行……");
        // 根据业内惯用的做法,客户端提交的请求中的JWT应该存放于请求头(Request Header)中的名为Authorization属性中
        String jwt = request.getHeader("Authorization");
        log.debug("客户端携带的JWT:{}", jwt);

        // 判断客户端是否携带了有效的JWT
        if (!StringUtils.hasText(jwt) || jwt.length() < JWT_MIN_LENGTH) {
            // 如果JWT无效,直接放行
            log.debug("客户端没有携带有效的JWT,将放行,由后续的过滤器等组件继续处理此请求……");
            filterChain.doFilter(request, response);
            return;
        }

        // 尝试解析JWT
        String secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";
        Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
        Object id = claims.get("id");
        Object username = claims.get("username");
        log.debug("解析JWT结束,id={},username={}", id, username);

        // 临时处理认证信息中的权限
        List<GrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("这是一个山寨的权限!"));

        // 创建Authentication对象
        Object principal = username;
        Object credentials = null;
        Authentication authentication = new UsernamePasswordAuthenticationToken(principal, credentials, authorities);

        // 将Authentication对象存入到SecurityContext中
        SecurityContext securityContext = SecurityContextHolder.getContext();
        securityContext.setAuthentication(authentication);

        // 放行
        filterChain.doFilter(request, response);
    }

}

关于当事人

通常,当事人信息中应该至少包含用户的ID和用户名,而认证信息(Authentication)中的当事人(Principal)的类型被设计为Object,所以,你可以使用任何类型的数据作为当事人!则可以自定义类封装用户的ID和用户名!

在项目的根包下创建security.LoginPrincipal类,例如:

@Data
public class LoginPrincipal implements Serializable {

    /**
     * 当事人的ID
     */
    private Long id;
    /**
     * 当事人的用户名
     */
    private String username;

}

在解析JWT时,将解析结果处理为期望的类型,例如:

// 尝试解析JWT
String secretKey = "gfd89uiKa89J043tAFrflkji9432kjfdsajm";
Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();
Long id = claims.get("id", Long.class); // 期望的类型
String username = claims.get("username", String.class); // 期望的类型
log.debug("解析JWT结束,id={},username={}", id, username);

然后,基于解析结果创建当事人对象:

// 创建当事人对象,用于存入到Authentication对象中
LoginPrincipal loginPrincipal = new LoginPrincipal();
loginPrincipal.setId(id);
loginPrincipal.setUsername(username);

然后,将当事人对象用于创建认证信息对象:

// 创建Authentication对象
Object principal = loginPrincipal; // 当事人对象
Object credentials = null;
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, credentials, authorities);

后续,在控制器类中处理请求的方法中,当需要当事人数据时,注入LoginPrincipal类型的参数即可:

@GetMapping("")
public JsonResult<List<AdminListItemVO>> list(
        @ApiIgnore @AuthenticationPrincipal LoginPrincipal loginPrincipal) {
    log.debug("开始处理【查询管理员列表】的请求,参数:无");
    log.debug("当事人信息:{}", loginPrincipal);
    log.debug("当事人信息中的ID:{}", loginPrincipal.getId());
    log.debug("当事人信息中的用户名:{}", loginPrincipal.getUsername());
    List<AdminListItemVO> list = adminService.list();
    return JsonResult.ok(list);
}

处理权限

在项目中添加fastjson依赖项:

<!-- fastjson:实现对象与JSON的相互转换 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.75</version>
</dependency>

AdminServiceImpl中的login()方法,当验证登录通过,将此管理员的权限列表转换为JSON格式的字符串,然后再存入到JWT中,例如:

image.png

则任何管理员成功登录后,得到的JWT中都将包含权限列表的信息!

JwtAuthorizationFilter中,解析JWT时,可以从中获取到此前存入的权限列表的JSON字符串,将此字符串反序列化为原本的类型,即ArrayList<SimpleGrantedAuthority>类型,并将此对象存入到认证信息中,例如:

image.png

至此,可以继续使用Spring Security检查各请求上配置的权限!

关于清除SecurityContext

因为Spring Security是根据SecurityContext中的认证信息来识别用户的身份的,而SecurityContext本身是基于Session机制的,当携带JWT成功访问后(在SecurityContext中已经存入了认证信息),在后续的一段时间内(在Session的有效期内),即使不携带JWT也可以成功访问!

可以在JWT过滤器刚刚开始执行时,就直接清空SecurityContext,即:

// 清空SecurityContext,避免【此前携带JWT成功访问后,在接下来的一段时间内不携带JWT也能访问】
SecurityContextHolder.clearContext();

**注意:**Spring Security本身使用了ThreadLocal处理SecurityContext,所以,以上的清除做法只对当前线程有效,如果将以上代码放在doFilter()之后,并不能解决问题!

或者,在Spring Security的配置类中的void configurer(HttpSecurity http)方法中,配置创建Session的策略为“从不使用Session”,即:

// 配置Spring Security创建Session的策略:STATELESS=从不使用Session,NEVER=不主动创建Session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

关于使用配置文件

生成和解析JWT使用的secretKey应该使用配置文件进行配置,例如,在application-dev.yml中添加配置:

secretKey: gfd89uiKa89J043tAFrflkji9432kjfdsajm

然后,在AdminServiceImplJwtAuthorizationFilter均不再使用原本的secretKey局部变量(删除原有代码),改为通过@Value注解读取以上配置文件中的配置值:

@Value("${secretKey}")
private String secretKey;