Session与JWT

121 阅读6分钟

Session

image.png

image.png

除了登录接口外,我们其他接口都要在Controller层里做登录判断,这太麻烦了。我们完全可以对每个接口过滤拦截一下,判断有没有登录,如果没有登录就直接结束请求,登录了才放行。这里我们通过过滤器来实现

@Component
public class LoginFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 简单的白名单,登录这个接口直接放行
        if ("/login".equals(request.getRequestURI())) {
            filterChain.doFilter(request, response);
            return;
        }

        // 已登录就放行
        User user = (User) request.getSession().getAttribute("user");
        if (user != null) {
            filterChain.doFilter(request, response);
            return;
        }

        // 走到这里就代表是其他接口,且没有登录
        // 设置响应数据类型为json(前后端分离)
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
        // 设置响应内容,结束请求
        out.write("请先登录");
        out.flush();
        out.close();
    }
}

Controller层就可以去除多余的登录判断逻辑了

@GetMapping("api")
public String api() {
    return "api成功返回数据";
}

@GetMapping("api2")
public String api2() {
    return "api2成功返回数据";
}

在有些情况下,就算加了过滤器后我们现在还不能在controller层将session代码去掉!因为在实际业务中对用户对象操作是非常常见的,而我们的业务代码一般都写在Service业务层,那么我们Service层想要操作用户对象还得从Controller那传参过来,就像这样

@GetMapping("api")
public String api(HttpSession session) {
    User user = (User) session.getAttribute("user");
    // 将用户对象传递给Service层
    userService.doSomething(user);
    return "成功返回数据";
}

每个接口都要这么写太麻烦了,有没有什么办法可以让我直接在Service层获取到用户对象呢?当然是可以的,我们可以通过SpringMVC提供的RequestContextHolder对象在程序任何地方获取到当前请求对象,从而获取我们保存在HttpSession中的用户对象。我们可以写一个上下文对象来实现该功能

public class RequestContext {
    public static HttpServletRequest getCurrentRequest() {
        // 通过`RequestContextHolder`获取当前request请求对象
        return ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest();
    }

    public static User getCurrentUser() {
        // 通过request对象获取session对象,再获取当前用户对象
        return (User)getCurrentRequest().getSession().getAttribute("user");
    }
}

然后我们在Service层直接调用我们写的方法就可以获取到用户对象啦

public void doSomething() {
    User user = RequestContext.getCurrentUser();
    System.out.println("service层---当前登录用户对象:" + user);
}

我们再在Controller层直接调用Service

@GetMapping("api")
public String api() {
    // 各种业务操作
    userService.doSomething();
    return "api成功返回数据";
}

JWT

image.png

image.png

image.png

先写一个JWT的工具类,工具类就提供两个方法一个生成一个解析

public final class JwtUtil {
    /**
     * 这个秘钥是防止JWT被篡改的关键,随便写什么都好,但决不能泄露
     */
    private final static String secretKey = "whatever";
    /**
     * 过期时间目前设置成2天,这个配置随业务需求而定
     */
    private final static Duration expiration = Duration.ofHours(2);

    /**
     * 生成JWT
     * @param userName 用户名
     * @return JWT
     */
    public static String generate(String userName) {
        // 过期时间
        Date expiryDate = new Date(System.currentTimeMillis() + expiration.toMillis());

        return Jwts.builder()
                .setSubject(userName) // 将userName放进JWT
                .setIssuedAt(new Date()) // 设置JWT签发时间
                .setExpiration(expiryDate)  // 设置过期时间
                .signWith(SignatureAlgorithm.HS512, secretKey) // 设置加密算法和秘钥
                .compact();
    }

    /**
     * 解析JWT
     * @param token JWT字符串
     * @return 解析成功返回Claims对象,解析失败返回null
     */
    public static Claims parse(String token) {
        // 如果是空字符串直接返回null
        if (StringUtils.isEmpty(token)) {
            return null;
        }
		
        // 这个Claims对象包含了许多属性,比如签发时间、过期时间以及存放的数据等
        Claims claims = null;
        // 解析失败了会抛出异常,所以我们要捕捉一下。token过期、token非法都会导致解析失败
        try {
            claims = Jwts.parser()
                    .setSigningKey(secretKey) // 设置秘钥
                    .parseClaimsJws(token)
                    .getBody();
        } catch (JwtException e) {
            // 这里应该用日志输出,为了演示方便就直接打印了
            System.err.println("解析失败!");
        }
        return claims;
    }

工具类做好之后我们可以开始写登录接口了

@RestController
public class JwtController {
     @PostMapping("/login")
    public String login(@RequestBody User user) {
        // 判断账号密码是否正确,这一步肯定是要读取数据库中的数据来进行校验的,这里为了模拟就省去了
        if ("admin".equals(user.getUsername()) && "admin".equals(user.getPassword())) {
            // 如果正确的话就返回生成的token(注意哦,这里服务端是没有存储任何东西的)
            return JwtUtil.generate(user.getUsername());
        }
        return "账号密码错误";
    }
}

在后续会话中,用户访问其他接口时就可以校验token来判断其是否已经登录。前端将token一般会放在请求头的Authorization项传递过来,其格式一般为类型 + token。这个倒也不是一定得这么做,你放在自己自定义的请求头项也可以,只要和前端约定好就行。这里我们方便演示就将token直接放在Authorization项里了

@GetMapping("api")
public String api(HttpServletRequest request) {
    // 从请求头中获取token字符串
    String jwt = request.getHeader("Authorization");
    // 解析失败就提示用户登录
    if (JwtUtil.parse(jwt) == null) {
        return "请先登录";
    }
    // 解析成功就执行业务逻辑返回数据
    return "api成功返回数据";
}

@GetMapping("api2")
public String api2(HttpServletRequest request) {
    String jwt = request.getHeader("Authorization");
    if (JwtUtil.parse(jwt) == null) {
        return "请先登录";
    }
    return "api2成功返回数据";
}

如果每个接口都要手动判断一下用户有没有登录太麻烦了,所以我们做一个统一处理,这里我们换个花样用拦截器来做

public class LoginInterceptor extends HandlerInterceptorAdapter {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 简单的白名单,登录这个接口直接放行
        if ("/login".equals(request.getRequestURI())) {
            return true;
        }

        // 从请求头中获取token字符串并解析
        Claims claims = JwtUtil.parse(request.getHeader("Authorization"));
        // 已登录就直接放行
        if (claims != null) {
            return true;
        }

        // 走到这里就代表是其他接口,且没有登录
        // 设置响应数据类型为json(前后端分离)
        response.setContentType("application/json;charset=utf-8");
        PrintWriter out = response.getWriter();
        // 设置响应内容,结束请求
        out.write("请先登录");
        out.flush();
        out.close();
        return false;
    }
}

拦截器类写好之后,别忘了要使其生效,这里我们直接让SpringBoot启动类实现WevMvcConfigurer接口来做

@SpringBootApplication
public class LoginJwtApplication implements WebMvcConfigurer {

    public static void main(String[] args) {
        SpringApplication.run(LoginJwtApplication.class, args);
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 使拦截器生效
        registry.addInterceptor(new LoginInterceptor());
    }
}

这样就能省去接口中校验用户登录的逻辑了

@GetMapping("api")
public String api() {
    return "api成功返回数据";
}

@GetMapping("api2")
public String api2() {
    return "api2成功返回数据";
}

统一拦截做好之后接下来就是我们的上下文对象,JWT不像Session把用户信息直接存储起来,所以JWT的上下文对象要靠我们自己来实现

首先我们定义一个上下文类,这个类专门存储JWT解析出来的用户信息。我们要用到ThreadLocal,以防止线程冲突

public final class UserContext {
    private static final ThreadLocal<String> user = new ThreadLocal<String>();

    public static void add(String userName) {
        user.set(userName);
    }

    public static void remove() {
        user.remove();
    }

    /**
     * @return 当前登录用户的用户名
     */
    public static String getCurrentUserName() {
        return user.get();
    }
}

这个类创建好之后我们还需要在拦截器里做下处理

public class LoginInterceptor extends HandlerInterceptorAdapter {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        ...省略之前写的代码

        // 从请求头中获取token字符串并解析
        Claims claims = JwtUtil.parse(request.getHeader("Authorization"));
        // 已登录就直接放行
        if (claims != null) {
            // 将我们之前放到token中的userName给存到上下文对象中
            UserContext.add(claims.getSubject());
            return true;
        }

        ...省略之前写的代码
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 请求结束后要从上下文对象删除数据,如果不删除则可能会导致内存泄露
        UserContext.remove();
        super.afterCompletion(request, response, handler, ex);
    }
}

这样一个上下文对象就做好了,用法和之前一样,可以在程序的其他地方直接获取到数据,我们在Service层中来使用它

public void doSomething() {
    String currentUserName = UserContext.getCurrentUserName();
    System.out.println("Service层---当前用户登录名:" + currentUserName);
}

然后Controller层调用Service层

@GetMapping("api")
public String api() {
    userService.doSomething();
    return "api成功返回数据";
}

image.png