关于Session

166 阅读8分钟

关于Session

服务器端的程序通常是基于HTTP协议的,而HTTP协议是一种“无状态”的通信协议,所以,它并不能保存来访的客户端的状态,只是简单的“请求、响应”的处理而已!也就是说,当同一个客户端多次访问同一个服务器端时,服务器并不能识别来访的客户端就是前序曾经来访过的客户端!

在开发实践中,是需要识别客户端身份的,所以,在编程技术上,可以使用Session机制来解决此问题。

Session的本质是存储在服务器端的内存中的一个K-V结构的数据,服务器端会为每一个来访的客户端的首次访问分配一个Session ID(本质上是一个UUID值,如果客户端的请求中没有携带Session ID,则服务器端生成并发回给客户端,如果客户端的请求中已经携带Session ID,则服务器端不会生成)此Session ID就是客户端访问服务器端的Session数据时使用的Key,所以,每个客户端在服务器上都有一份对应的Session数据(K-V中的Value)。

由于Session是存储在服务器端的内存中的数据,内存是非常重要的,且容量相对较小的存储设备,所以,必须设置一些清除Session的机制,默认的典型的清除机制就是“超时自动清除”,也就是说,某个客户端在最后一次提交请求后的多长时间内(常见的超时时间是15分钟或30分钟)没有再次提交请求,则服务器端会自动清除此客户端对应的Session数据。

由于Session是存储在服务器端的内存中的数据,所以,必然存在一些缺点:

  • 不适合存储大量的数据
    • 可以通过规范的开发,避免此问题
  • 不便于应用到集群或分布式系统中
    • 可以通过共享Session解决此问题
  • 不可以长时间存储
    • 无解

关于Token

**Token:**令牌,或票据

使用Token机制时,当客户端第1次向服务器提交请求时,或提交登录请求时,客户端直接发起请求,而服务器端会在验证登录成功后,生成此客户端对应的Token数据并响应到客户端,后续,客户端会携带此Token数据向服务器端发起请求,而服务器端会根据Token来识别客户端的身份。

在处理过程中,服务器端只需要检查Token、从Token中解析出客户端身份相关的数据即可,并不是必须在服务器端保存各Token数据,所以,Token可以设置较长时间的有效期,并不会长时间持续消耗服务器端的存储资源!所以,Token可以用于长时间表示用户的身份!

Token天生就适用于集群或分布式系统,因为各服务器端只需要具有相同的验证并解析Token的程序,就可以识别客户端的身份。

其实,Token的传输流程与Session ID基本上是相同的,最大的区别在于Session ID只是一个UUID数据,具有唯一性、随机性(不可预测性),但是,本身并不表示数据含义,而Token本身就是有数据含义的!

关于JWT

JWTJson Web Token

JWT的官网:jwt.io/

每个JWT数据都包含3个组成部分:

  • Header(头部信息):声明算法与Token的类型
  • Payload(载荷):数据
  • Signature:验证签名

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

例如,在项目的pom.xml中添加依赖项:

<!-- 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.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 = "kU4jrFA3iuI5jn25u743kfDs7a8pFEwS54hm";

    @Test
    public void generate() {
        Date exp = new Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000);

        Map<String, Object> claims = new HashMap<>();
        claims.put("id", 9527);
        claims.put("username", "ZhangSan");

        String jwt = Jwts.builder()
                // Header(头部信息):声明算法与Token的类型
                .setHeaderParam("alg", "HS256")
                .setHeaderParam("typ", "JWT")
                // Payload(载荷):数据,表现为Claims
                .setClaims(claims)
                .setExpiration(exp)
                // Signature:验证签名
                .signWith(SignatureAlgorithm.HS256, secretKey)
                // 完成
                .compact();
        System.out.println(jwt);
        // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjgwNzUwMjkwLCJ1c2VybmFtZSI6IlpoYW5nU2FuIn0.UV8rukk8kt9wMb0_n7xgxmjEG-ra2O32vL_7T572xXw
        // eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjgxNjE1ODY4LCJ1c2VybmFtZSI6IlpoYW5nU2FuIn0.vzZFkGQ8mZu0dPiRlXOWma0rr9Cvz9Hn6PWov3b8wNQ
    }

    @Test
    public void parse() {
        try {
            String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6OTUyNywiZXhwIjoxNjgxNjE1ODY4LCJ1c2VybmFtZSI6IlpoYW5nU2FuIn0.vzZFkGQ8mZu0dPiRlXOWma0rr9Cvz9Hn6PWov3b8wNQ";

            Claims claims = Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(jwt)
                    .getBody();

            Long id = claims.get("id", Long.class);
            String username = claims.get("username", String.class);

            System.out.println("id = " + id);
            System.out.println("username = " + username);
        } catch (Throwable e) {
            e.printStackTrace();
        }
    }

}

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

io.jsonwebtoken.ExpiredJwtException: JWT expired at 2023-04-06T11:04:50Z. Current time: 2023-04-06T11:29:13Z, a difference of 1463885 milliseconds.  Allowed clock skew: 0 milliseconds.

当尝试解析JWT时,如果使用的secretKey与生成JWT时使用的不相同,会出现错误:

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

当尝试解析JWT时,JWT数据如果是篡改后的数据,可能出现以上SignatureException,也可能会出现以下错误:

io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"id":9527,"exp":1681615�͕ɹ������"}

**注意:**JWT数据是可能被篡改的,所以,一旦解析失败,应该不信任此JWT数据,例如向服务器直接响应错误,而不处理客户端的请求!并且,即使不知道生成JWT时使用的secretKey的情况下,仍有很多办法可以解析出JWT中的内容,所以,不要在JWT中存入敏感数据!

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

核心流程概述

sequenceDiagram
	participant Client as 客户端
	participant Server as 服务器端
    Client ->> + Server: 请求登录时,不携带JWT
    activate Client
    Server -->> - Client: 验证登录通过,响应JWT
    deactivate Client
    Client ->> Server: 携带JWT
	note right of Server: 尝试解析JWT,将解析的结果创建为认证对象,并存入到SecurityContext
	Server -->> Client: 响应结果

大致需要:

  • 验证用户登录时,如果视为登录成功,服务器端应该生成此用户对应的JWT数据,并响应到客户端
    • 不再需要将验证登录成功后的结果存入到SecurityContext
  • 当用户尝试执行某些需要认证的操作时,用户应该携带JWT,服务器端应该尝试解析JWT,并且验证JWT的真伪、识别用户的身份,将用户的相关信息存入到SecurityContext

验证登录成功后响应JWT

首先,在AdminServiceImpl中验证登录时,如果通过验证,不再向SecurityContext中存入认证信息:

1680762112678.png

然后,在IAdminService接口中,将登录的方法的返回值类型改为String,表示此方法在验证登录成功后,将返回JWT(String类型)数据:

/**
 * 验证管理员登录
 * @param adminLoginDTO 管理员的登录信息,至少封装用户名与密码原文
 * @return 验证登录通过后的JWT
 */
String login(AdminLoginDTO adminLoginDTO);

并且,也修改AdminServiceImpl中的重写的方法,在验证登录成功后,生成并返回JWT数据:

@Override
public String login(AdminLoginDTO adminLoginDTO) {
    log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);

    // 创建认证信息对象
    Authentication authentication = new UsernamePasswordAuthenticationToken(
            adminLoginDTO.getUsername(), adminLoginDTO.getPassword());
    // 调用认证管理器执行认证
    Authentication authenticationResult
            = authenticationManager.authenticate(authentication);
    log.debug("验证登录成功,返回的Authentication为:{}", authenticationResult);
    // 如果没有出现异常,则表示验证登录成功,需要将认证信息存入到Security上下文
    // log.debug("即将向SecurityContext中存入Authentication");
    // SecurityContext securityContext = SecurityContextHolder.getContext();
    // securityContext.setAuthentication(authenticationResult);

    // ========== 以下是新增的代码片段 ==========
    
    // 处理验证登录成功后的结果中的当事人
    Object principal = authenticationResult.getPrincipal();
    log.debug("获取验证登录成功后的结果中的当事人:{}", principal);
    AdminDetails adminDetails = (AdminDetails) principal;

    // 需要写入到JWT中的数据
    Map<String, Object> claims = new HashMap<>();
    claims.put("id", adminDetails.getId());
    claims.put("username", adminDetails.getUsername());
    log.debug("即将生成JWT数据,包含的账号信息:{}", claims);

    // 生成JWT,并返回JWT
    String secretKey = "kU4jrFA3iuI5jn25u743kfDs7a8pFEwS54hm";
    Date exp = new Date(System.currentTimeMillis() + 10 * 24 * 60 * 60 * 1000);
    String jwt = Jwts.builder()
            .setHeaderParam("alg", "HS256")
            .setHeaderParam("typ", "JWT")
            .setClaims(claims)
            .setExpiration(exp)
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
    log.debug("生成了JWT数据,并将返回此JWT数据:{}", jwt);
    return jwt;
}

然后,还要调整AdminController中处理登录请求的方法,将Service中返回的JWT数据响应到客户端去:

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

通过API文档的调试功能测试登录,当登录成功后,响应的结果例如:

{
  "state": 20000,
  "data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiZXhwIjoxNjgxNjI2ODQwLCJ1c2VybmFtZSI6InJvb3QifQ.9atdNiIRsGb6Ll4g58rLOBi5BoGQb1MoHFNsraCjwTo"
}

以上JWT数据也可以放在测试方法中尝试解析。

解析客户端携带的JWT

客户端提交若干种不同的请求时,可能都需要携带JWT,在服务器端,处理若干种不同的请求之前也需要尝试接收并解析JWT,则应该使用**过滤器(Filter)**组件进行处理!

提示:过滤器是Java服务器端的组件中,最早接收到请求的组件,它执行在其它任何组件之前!在同一个项目中,允许存在若干个过滤器,形成过滤器链(Filter Chian),任何一个请求,必须被所有过滤器“放行”才可以被后续的组件(例如Controller等)进行处理!

在项目的根包下创建filter.JwtAuthorizationFilter类,继承自OncePerRequestFilter抽象类(将间接的实现Filter接口),并在类上添加组件注解:

package cn.tedu.csmall.passport.filter;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
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;

@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {

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

}

在处理过程中,首先,需要尝试接收客户端携带的JWT:

@Override
protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {
    // 根据业内惯用的做法,客户端应该将JWT保存在请求头(Request Header)中的名为Authorization的属性名
    String jwt = request.getHeader("Authorization");
    log.debug("尝试接收客户端携带的JWT数据,JWT:{}", jwt);
}

要使得以上过滤器生效,还需要在Spring Security的配置类中,将其添加在Spring Security的过滤器链中!则先在配置类中自动装配以上过滤器:

@Slf4j
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    // 新增代码
    @Autowired
    private JwtAuthorizationFilter jwtAuthorizationFilter;
    
    // 暂不关心其它代码
    
}

然后,在configurer(HttpSecurity http)方法中,添加此过滤器:

// 将自定义的JWT过滤器添加在Spring Security的UsernamePasswordAuthenticationFilter之前
http.addFilterBefore(jwtAuthorizationFilter, 
                     UsernamePasswordAuthenticationFilter.class);

在API文档中,通过“全局参数设置”中的“添加参数”,可以配置每个请求都将携带JWT数据:

1680766122673.png

1680766170043.png

此时,进行调试时,所有请求的反馈结果都是一片空白,并且,在服务器端的控制台中可以看到输出了客户端提交请求时携带的JWT数据!

然后,尝试解析JWT,并将解析得到的数据创建为Authentication存入到SecurityContext中:

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.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.Collection;

/**
 * <p>处理JWT的过滤器类</p>
 *
 * <p>此过滤器类的主要职责:</p>
 * <ul>
 *     <li>尝试接收客户端携带的JWT</li>
 *     <li>尝试解析接收到的JWT</li>
 *     <li>将解析成功后得到的结果创建为Authentication并存入到SecurityContext中</li>
 * </ul>
 *
 * @author java@tedu.cn
 * @version 0.0.1
 */
@Slf4j
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    public static final int JWT_MIN_LENGTH = 113;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {
        // 根据业内惯用的做法,客户端应该将JWT保存在请求头(Request Header)中的名为Authorization的属性名
        String jwt = request.getHeader("Authorization");
        log.debug("尝试接收客户端携带的JWT数据,JWT:{}", jwt);

        // 判断客户端是否携带了基本有效的JWT
        if (!StringUtils.hasText(jwt) || jwt.length() < JWT_MIN_LENGTH) {
            // 客户端没有携带有铲的JWT,则“放行”,交由后续的组件继续处理
            filterChain.doFilter(request, response);
            // 【重要】终止当前方法的执行,不执行接下来的代码
            return;
        }

        // TODO:1-声明secretKey不合理,应该集中管理
        // TODO:2-解析JWT时可能出现异常,需要处理
        // 客户端携带了基本有效的JWT,则尝试解析JWT
        String secretKey = "kU4jrFA3iuI5jn25u743kfDs7a8pFEwS54hm";
        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:{}", id);
        log.debug("从JWT中解析得到的管理员用户名:{}", username);

        // TODO:3-使用用户名的字符串作为“当事人”并不是最优解
        // TODO:4-需要调整使用真实的权限
        // 基于解析JWT的结果创建Authentication对象
        Object principal = username; // 当事人:暂时使用用户名
        Object credentials = null; // 凭证:应该为null
        Collection<SimpleGrantedAuthority> authorities = new ArrayList<>();
        authorities.add(new SimpleGrantedAuthority("暂时放一个山寨的权限"));
        Authentication authentication = new UsernamePasswordAuthenticationToken(
                principal, credentials, authorities);

        // 将Authentication存入到SecurityContext中
        log.debug("向SecurityContext中存入Authentication:{}", authentication);
        SecurityContext securityContext = SecurityContextHolder.getContext();
        securityContext.setAuthentication(authentication);

        // 过滤器链继续执行,相当于“放行”
        filterChain.doFilter(request, response);
    }

}

目前的测试结果表现为:

  • 携带有效的JWT,可以访问任何请求(需要删除各处理请求的方法上的获取当事人、检查权限的代码)
  • 成功的处理了某个请求后,在接下来的一段时间里,不携带JWT也可以请求成功
  • 如果重启服务器后,第1次发起的请求就没有携带JWT,会响应403

关于SecurityContext中的认证信息

因为Spring Security是根据SecurityContext中的Authentication来识别用户的身份的,而SecurityContext本身是基于Session机制的,所以,当携带有效的JWT成功访问后,以上过滤器就已经将Authentication存入到了SecurityContext中,也就存在于Session中了,在接下来的一段时间内(在Session的有效期内),即使不携带JWT也可以成功访问!

以上表现并不能算是一种“错误”,不一定是必须解决的问题!

如果希望实现“携带JWT就可以访问,不携带JWT就不可以访问”那些需要登录才允许访问的资源,可以:

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

    // 清空SecurityContext,避免【此前携带JWT成功访问后,在接下来的一段时间内不携带JWT也能访问】
    SecurityContextHolder.clearContext();
    
  • 【推荐】不使用Session,在Spring Security的配置类中的configurer(HttpSecurity http)方法中,将Session策略设置为“从不使用”即可:

    // 将Session策略设置为“从不使用”:STATELESS=无状态,即从不使用Session,NEVER=从不主动创建Session
            http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    

关于当事人

通常,当事人信息中应该包含用户的ID和用户名,而Authentication中的Principal的类型是Object,所以,你可以使用任何类型的数据作为当事人,并且,在需要获取当事人信息时,添加@AuthenticationPrincipal注解的参数也是你自行决定的当事人类型。

在项目的根包下创建security.LoginPrincipal类型,用于封装当事人信息,例如:

@Data
public class LoginPrincipal implements Serializable {

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

}

JwtAuthorizationFilter中,基于解析JWT的结果创建当事人对象:

1680775033798.png

并且,将此对象用为Authentication的当事人:

1680775091012.png

后续,当需要获取当事人信息时,直接注入即可,例如在AdminController中:

1680775150134.png