登录认证《JWT+Filter+Interceptor》

登录认证《JWT+Filter+Interceptor》

忘记中二的少年 Lv3

登录认证《JWT+Filter+Interceptor》

$\int_{birth}^{death}卷dt = life$:1st_place_medal: :hamburger::happy::broken_heart:

一、会话技术

  • 会话:用户打开浏览器,访问web服务器的资源,会话建立,直到有一方断开连接,会话结束。在一次会话中可以包含多次请求和响应
  • 会话跟踪:一种维护浏览器状态的方法,服务器需要识别多次请求是否来自同一浏览器,以便在同一次会话的多次请求间共享数据
  • 会话跟踪方案
    1. 客户端会话跟踪技术:Cookie
    2. 服务端会话跟踪技术:Session
    3. 令牌技术

二、会话跟踪方案对比

2.1、方案一:Cookie【传统】

Cookie是保存在浏览器本地的,用户向后端发起登录请求之后,后端服务器会自动返回一个Cookie值给浏览器,同时浏览器将该Cookie值保存在浏览器本地中,之后的每一次浏览器请求,都会自动带上这个Cookie值,后端根据Cookie值判断该浏览器是否登陆过。

image-20230921213226025

优点:HTTP协议中支持的技术

缺点:

  1. 移动端APP无法使用Cookie
  2. 不安全,用户可以自己禁用Cookie
  3. Cookie不能跨域【跨域是什么?自己去了解】

代码实现:操作Cookie

下面实现操作Cookie的代码,通过访问:

  1. http://localhost:3000/study/setcookie:访问该地址,服务器会向前端发送一个Cookie
  2. http://localhost:3000/study/getcookie:访问改地址,服务器获取前端传递的Cookie
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Slf4j
@RestController
@RequestMapping("/study")
public class StudyController {
/**
* 访问地址:http://localhost:3000/study/setcookie 设置Cookie值在响应response中
* @param response
* @return
*/
@GetMapping("/setcookie")
public Result setCookie(HttpServletResponse response){
log.info("获取设置Cookie的请求");
response.addCookie(new Cookie("login_username","zhangsan"));
return Result.success();
}

/**
* 从请求request中获取Cookie值
* @param request
* @return
*/
@GetMapping("/getcookie")
public Result getCookie(HttpServletRequest request){
log.info("获取得到Cookie的请求");
Cookie[] cookies = request.getCookies(); //获取到所有Cookie
for(Cookie cookie : cookies){
if(cookie.getName().equals("login_username")){ //输出name为login_username的Cookie
System.out.println("login_username: " + cookie.getValue());
}
}
return Result.success();
}
}

设置Cookie效果图展示

首先F12打开浏览器的调试工具点击NetWork选项查看发送的请求信息

可以看到请求了http://localhost:3000/study/setcookie路径之后,服务器向前端的响应Response中包含Set-Cookie一项

image-20230921215243363

然后我们继续点击Application一项可以看到我们设置的Cookie值成功保存在浏览器本地存储

image-20230921215746870

获取Cookie效果图展示

继上述设置Cookie操作之后,继续访问页面http://localhost:3000/study/getcookie,同样的,打开调试工具,并点击NetWork选项查看请求request中的Cookie一栏,确实是我们刚才设置的cookie

image-20230921220327371

查看我们的后端的显示,确实得到了前端向后端请求中的cookie值

image-20230921220603439

2.2、方案二:Session【传统】

Session是保存在服务器中的,Session的底层是基于Cookie来实现的。

浏览器第一次向服务器发送请求的时候,服务器会自动创建会话对象Session,每一个会话对象都有一个ID,称之为Session的ID,接下来服务器向浏览器响应数据的时候会将Session的ID通过Cookie响应给浏览器,浏览器接收到这个响应数据之后会自动将存有SessionId的cookie保存在浏览器本地,然后后续的请求中,浏览器会将该Cookie的值携带到服务端,服务器拿到这个前端传来的cookie后,会从全部请求对象中找到当前请求对应的会话对象

image-20230921231659057

优点:存储在服务器,安全

缺点

  1. 服务器级群环境下无法直接使用Session
  2. Cookie的缺点【Session底层实现为Cookie】

代码实现:操作Session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Slf4j
@RestController
@RequestMapping("/study")
public class StudyController {
/**
* 设置Session对象,hashCode可以理解为SessionId每一个Session对象有唯一的hashCode
* @param session
* @return
*/
@GetMapping("/setsession")
public Result setSession(HttpSession session){
log.info("HttpSession-setSession:{}",session.hashCode());
session.setAttribute("loginUser","tom");
return Result.success();
}

/**
* 从HttpSession中获取Session,并获取其hashCode
* @param request
* @return
*/
@GetMapping("/getsession")
public Result getSession(HttpServletRequest request){
HttpSession session = request.getSession();
log.info("HttpSession-getSession:{}",session.hashCode());
Object loginUser = session.getAttribute("loginUser");
log.info("loginUser:{}",loginUser);
return Result.success();
}

}

设置Session的效果图

首先F12打开浏览器的调试工具点击NetWork选项查看发送的请求信息

可以看到请求了http://localhost:3000/study/setsession路径之后,服务器向前端的响应Response中包含Set-Cookie一项,并且保存的值是Session的ID

image-20230921230445503

然后我们继续点击Application一项可以看到我们设置的session值成功保存在浏览器本地存储

image-20230921230617782

获取Session效果图展示

继上述设置session操作之后,继续访问页面http://localhost:3000/study/getsession,同样的,打开调试工具,并点击NetWork选项查看请求request中的Cookie一栏,确实是我们刚才设置的session

image-20230921230938366

查看我们的后端的显示,看到setsession和getsession两次请求输出的ID值相同,表明确实是同一个Session对象

image-20230921231145486

2.3、方案三:令牌JWT【主流】

image-20230921233534209

优点

  1. 支持PC端、移动端
  2. 解决集群环境下的认证问题
  3. 减轻服务器端存储压力

缺点:需要自己实现

了解JWT

  • 全称JSON Web Token
  • 定义了一种简洁、自包含的格式,用于在通信双方以JSON数据格式安全的传输信息。由于数字签名的存在,这些信息是可靠的
  • 组成:
    1. 第一部分Header(头),记录令牌类型、签名算法等。例如:{“alg”:”HS256”,”type”:”JWT”}
    2. 第二部分Payload(有效载荷),携带一些自定义信息、默认信息等。例如:{“id”:”1”,”username”:”Tom”}
    3. 第三部分Signature(签名),防止Token被串改、确保安全性。将header、payload,并加入指定秘钥,通过指定签名算法计算而来

image-20230921234520362

细节:JWT组成的前两部分[Payload、Signature]JSON数据通过Base64编码组成,第三部分的数字签名融入前两部分的内容并加入秘钥通过特定算法计算出来的,所以核心就是第三部分的签名,该签名唯一标识我们的令牌

【Base64:是一种基于64个可打印字符(A-Z a-z 0-9 + /)来表示二进制数据的编码方式】

使用场景

  • 典型使用场景:登录认证。
    1. 登录成功后,生成令牌【令牌生成:登录成功后生成JWT令牌,并返回给前端】
    2. 后续每个请求,都要携带JWT令牌,系统在每次请求处理之前,先校验令牌,通过后,在处理【在请求到达服务端后,对令牌进行统一拦截,校验】

image-20230921235042977

代码实现

  • 要使用JWT技术,首先导入JWT的依赖
1
2
3
4
5
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>

生成JWT

生成JWT需要使用Jwts的三个方法

  • signWith:设置签名算法以及秘钥【jwt组成三部分的第一部分
  • setClaims:设置载荷数据【jwt组成三部分的第二部分
  • setExpiration:设置过期时间$(参数以毫秒为单位)$
1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void genJwt(){
Map<String, Object> claims = new HashMap<>(); //载荷部分接收一个Map作为参数
claims.put("id",1);
claims.put("name","tom");

String jwt = Jwts.builder()
.signWith(SignatureAlgorithm.HS256,"token") //设置签名算法
.setClaims(claims) //设置载荷数据
.setExpiration(new Date(System.currentTimeMillis()+3600 * 1000)) //设置令牌的过期时间
.compact();
System.out.println(jwt);
}

解析JWT

解析JWT使用的方法

  • setSigningKey:设置解析JWT的秘钥
  • parseClaimsJws:将JWT作为该方法的参数
  • getBody:获取到载荷部分的数据
1
2
3
4
5
6
7
8
@Test
public void parseJWT(){
Claims payload = Jwts.parser()
.setSigningKey("token") //输入JWT的秘钥
.parseClaimsJws("生成的JWT") //将JWT放入parseClaimJws
.getBody();
System.out.println(payload);
}

注意事项

  • JWT校验时使用的签名秘钥,必须和生成JWT令牌时使用的秘钥是相同的。
  • 如果JWT令牌解析校验时报错,则说明JWT被篡改或失效了,令牌非法。

编写一个JWT工具类【JwtUtils】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class JwtUtil {
/**
* 生成jwt
* 使用Hs256算法, 私匙使用固定秘钥
*
* @param secretKey jwt秘钥 [签名]
* @param ttlMillis jwt过期时间(毫秒) [token的过期时间]
* @param claims 设置的信息 [把json数据放进map然后当做参数传递]
* @return
*/
public static String createJWT(String secretKey, long ttlMillis, Map<String, Object> claims) {
// 指定签名的时候使用的签名算法,也就是header那部分
SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;

// 生成JWT的时间,设置JWT的过期时间
long expMillis = System.currentTimeMillis() + ttlMillis;
Date exp = new Date(expMillis);

// 设置jwt的body
JwtBuilder builder = Jwts.builder()
// 如果有私有声明,一定要先设置这个自己创建的私有的声明,这个是给builder的claim赋值,一旦写在标准的声明赋值之后,就是覆盖了那些标准的声明的
.setClaims(claims)
// 设置签名使用的签名算法和签名使用的秘钥
.signWith(signatureAlgorithm, secretKey.getBytes(StandardCharsets.UTF_8))
// 设置过期时间
.setExpiration(exp);

return builder.compact();
}

/**
* 解析Token
*
* @param secretKey jwt秘钥 此秘钥一定要保留好在服务端, 不能暴露出去, 否则sign就可以被伪造
* @param token 加密后的token
* @return
*/
public static Claims parseJWT(String secretKey, String token) {
// 得到DefaultJwtParser
Claims claims = Jwts.parser()
// 设置签名的秘钥
.setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
// 设置需要解析的jwt
.parseClaimsJws(token).getBody();
return claims;
}

}

三、过滤器Filter

这了给出一篇简短高效的文章:有关Filter的使用方法

3.1、概述

  • 概念:Filter过滤器,是JavaWeb三大组件(Servlet、Filter、Listener)之一
  • 过滤器可以把对资源的请求拦截下来,从而实现一些特殊功能
  • 过滤器一般完成一些通用操作,比如:登录校验、统一编码处理、敏感字符处理等

image-20230922140633954


3.2、快速入门

  1. 定义Filter:定义一个类,实现Filter接口,并重写其所有方法,Filter类上加上@WebFilter注解,配置拦截资源路径。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @WebFilter(urlPatterns = "/*")  //配置拦截路径
    public class DemoFilter implements Filter {
    @Override //初始化方法,只调用一次: 通常做环境以及资源的准备工作
    public void init(FilterConfig filterConfig) throws ServletException {
    System.out.println("init 初始化开始了...");
    Filter.super.init(filterConfig);
    }

    @Override //每次拦截到请求之后调用,调用多次
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    System.out.println("拦截到了请求...【放行前逻辑】");
    //doFilter是放行操作
    chain.doFilter(request,response);
    System.out.println("拦截到了请求...【放行后逻辑】");
    }

    @Override //销毁方法,只会调用一次: 通常做资源和环境的销毁工作
    public void destroy() {
    System.out.println("destory 销毁执行了...");
    Filter.super.destroy();
    }
    }
  2. 配置Filter:引导类上加@ServletComponentScan开启Servlet组件支持

    1
    2
    3
    4
    5
    6
    7
    @SpringBootApplication		
    @ServletComponentScan() //扫描Servlet的组件
    public class ServerApplication {
    public static void main(String[] args) {
    SpringApplication.run(ServerApplication.class, args);
    }
    }
  • 通常在init方法中做一些资源以及环境的准备工作

  • 通常在destory方法做资源的释放以及环境的清理工作

  • 在Spring启动类上加上注解ServletComponentScan:因为Filter是JavaWeb三大组件之一并不属于SpringBoot,因此想要在SpringBoot上使用JavaWeb的三大组件那就需要加上注解,加上注解就表示当前项目是支持Servlet相关组件的


3.3、Filter拦截路径

拦截路径 URLPatterns值 含义
拦截具体路径 /login 只有访问/login路径时,才会被拦截
目录拦截 /emps/* 访问/emps下所有资源,都会被拦截
拦截所有 /* 访问所有资源,都会被拦截【

3.4、过滤器链

  • 介绍:一个web应用中,可以配置多个过滤器,这多个过滤器就形成了一个过滤器链
  • 顺序:注解配置的Filter,优先级是按照过滤器类名(字符串)的自然排序

image-20230922141513652

举个例子

我创建了一个AFilter和一个BFilter因为两个拦截器的名字A比B靠前,所以过滤器链的先后顺序是

  • 浏览器请求资源:浏览器$\to$ AFilter$\to $BFilter$\to$服务端
  • 服务端返回资源:浏览器$\leftarrow$ AFilter$\leftarrow $BFilter$\leftarrow$后端资源

定义的两个测试过滤器如下:

启动服务,发送请求,查看拦截结果可以看到是A拦截器先捕获到请求

3.5 登录操作过滤器的实现(例子)

image-20230922163502367

步骤:

  1. 获取请求 url
  2. 判断请求 url 中是否包含login,如果包含,说明是登录操作,放行
  3. 获取请求头中的令牌(token)
  4. 判断令牌是否存在,如果不存在,返回错误结果(未登录)
  5. 解析 token ,如果解析失败,返回错误结果(未登录)
  6. 放行

代码实现的基本逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Slf4j
@WebFilter("/*")
public class LoginFilter implements Filter {

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest)request;
HttpServletResponse res = (HttpServletResponse)response;
//1. 获取到请求的url
String url = req.getRequestURI().toString();
log.info("拦截到请求,请求地址为: {}",url);
//2. 判断请求是否包含login,如果包含,则说明是登录操作,放行
if(url.contains("login")){
log.info("登录操作...");
chain.doFilter(request,response);
return;
}
//3. 获取请求头中的令牌(token)
String token = req.getHeader("token");

//4. 判断令牌是否存在,如果不存在,返回错误结果(未登录)
if(!StringUtils.hasLength(token)){
log.info("请求头token为空,尚未登录...");
Result error = Result.error("NOT_LOGIN");
//手动转换 对象-->json--------》阿里巴巴的fastJSON
String notLogin = JSONObject.toJSONString(error);
res.getWriter().write(notLogin);
return;
}

//5. 解析token如果解析失败,返回错误结果
try {
JwtUtil.parseJWT("token",token);
} catch (Exception e) {
e.printStackTrace();
log.info("解析token异常..");
Result error = Result.error("NOT_LOGIN");
//手动转换 对象-->json--------》阿里巴巴的fastJSON
String notLogin = JSONObject.toJSONString(error);
res.getWriter().write(notLogin);
return;
}
log.info("令牌合法...");
//6. 放行
chain.doFilter(request,response);
}
}

四、拦截器Interceptor

4.1、概述

  • 概念:是一种动态拦截方法调用的机制,类似于过滤器。Spring框架提供的,用来动态拦截控制器方法的执行。
  • 作用:拦截请求,在指定的方法调用前后,根据业务需要执行预先设定的代码

4.2、快速入门

  1. 定义拦截器,实现HandlerInterceptor接口,并重写其所有方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    @Component	//交给IOC管理
    public class LoginCheckInterceptor implements HandlerInterceptor {
    @Override //目标资源方法执行前执行,返回true:放行,返回false:不放行
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    System.out.println("preHandle方法执行了...");
    return true;
    }

    @Override //目标资源方法执行后执行
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
    System.out.println("postHandle方法执行了...");
    }

    @Override //视图渲染完毕后执行,最后执行
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    System.out.println("视图渲染完毕执行....");
    }
    }
  2. 注册拦截器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Configuration  //表示当前类是一个配置类
    public class WebConfig implements WebMvcConfigurer {
    @Autowired
    private LoginCheckInterceptor loginCheckInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(loginCheckInterceptor).addPathPatterns("/**");
    }
    }

4.3、Intercept拦截路径

  • 拦截器可以根据需求,配置不同的拦截路径:

    1
    2
    3
    4
    5
    6
    @Override
    public void addInterceptors(InterceptorRegistry registry){
    registry.addInterceptor(loginCheckInterceptor)
    .addPathPatterns("/**") //需要拦截那些资源
    .excludePathPatterns("/login"); //不需要拦截那些资源
    }
    1. addPathPatterns():配置需要拦截的路径
    2. excludePathPatterns():配置不需要拦截的路径
拦截路径 含义 举例
/* 一级路径 能匹配/depts、/emps、/login、不能匹配/depts/1
/** 任意级路径 能匹配/depts、/depts/1、/depts/1/2
/depts/* /depts下的一级路径 能匹配/depts/1,不能匹配/depts/1/2、/depts
/depts/** /depts下的任意路径 能匹配/depts、/depts/1、/depts/1/2、不能匹配/emps/1

4.4、拦截器执行流程

image-20230922224235319

使用浏览器访问web服务器时

定义的过滤器Filter会首先拦截到请求,然后执行放行前的逻辑,接着执行放行操作(doFilter)

由于当前是基于SpiringBoot开发,所以放行之后会入到Spring的环境当中访问定义的Controller

因为Tomcat是一个Servlet容器,可以识别Servlet程序,但是不识别Controller

SpringWeb提供了一个核心的Servlet容器(称之为前端控制器:DispatcherServlet)

由DispatchServelt将请求转发给Controller然后执行对应的接口方法

但是因为定义了拦截器,所以请求先到拦截器然后才到Controller

FilterInterceptor区别

  • 接口规范不同:过滤器需要实现Filter接口,而拦截器需要实现HandlerInterceptor接口
  • 拦截范围不同:过滤器Filter会拦截所有资源,而Interceptor只会拦截Spring环境中的资源

4.5、登录操作拦截器的实现(例子)

  1. 获取请求 url
  2. 判断请求 url 中是否包含login,如果包含,说明是登录操作,放行
  3. 获取请求头中的令牌(token)
  4. 判断令牌是否存在,如果不存在,返回错误结果(未登录)
  5. 解析 token ,如果解析失败,返回错误结果(未登录)
  6. 放行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Slf4j
@Component
public class LoginCheckInterceptor implements HandlerInterceptor {
@Override //目标资源方法执行前执行,返回true:放行,返回false:不放行
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) throws Exception {
//1. 获取到请求的url
String url = req.getRequestURI().toString();
log.info("拦截到请求,请求地址为: {}",url);
//2. 判断请求是否包含login,如果包含,则说明是登录操作,放行
if(url.contains("login")){
log.info("登录操作...");
return true;
}
//3. 获取请求头中的令牌(token)
String token = req.getHeader("token");

//4. 判断令牌是否存在,如果不存在,返回错误结果(未登录)
if(!StringUtils.hasLength(token)){
log.info("请求头token为空,尚未登录...");
Result error = Result.error("NOT_LOGIN");
//手动转换 对象-->json--------》阿里巴巴的fastJSON
String notLogin = JSONObject.toJSONString(error);
res.getWriter().write(notLogin);
return false;
}

//5. 解析token如果解析失败,返回错误结果
try {
JwtUtil.parseJWT("token",token);
} catch (Exception e) {
e.printStackTrace();
log.info("解析token异常..");
Result error = Result.error("NOT_LOGIN");
//手动转换 对象-->json--------》阿里巴巴的fastJSON
String notLogin = JSONObject.toJSONString(error);
res.getWriter().write(notLogin);
return false;
}
log.info("令牌合法...");
//6. 放行
return true;
}

@Override //目标资源方法执行后执行
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
System.out.println("postHandle方法执行了...");
}

@Override //视图渲染完毕后执行,最后执行
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
System.out.println("视图渲染完毕执行....");
}
}

  • 标题: 登录认证《JWT+Filter+Interceptor》
  • 作者: 忘记中二的少年
  • 创建于 : 2023-10-05 00:00:00
  • 更新于 : 2023-10-05 20:24:25
  • 链接: https://github.com/HandsomeXianc/HandsomeXianc.github.io/2023/10/05/登录认证《JWT+Filter+Interceptor》/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。