登录校验全解析:从原理到实践,保障系统安全

91 阅读20分钟

所谓登录校验指的是在我们服务端发送请求过来后,我们要对这个请求进行校验,看这个用户登录了没有,如果这个用户已经登录了,就正常放行 继续执行对应的业务操作,若发现其未登录,那么就不允许它执行相关的业务操作,给前端响应一个错误的结果,最终跳转到登录页面。

登录校验的大概实现思路

首先http协议是无状态的,也就是每一次请求都是独立的,下一次请求不会携带上一次请求的数据。服务器与浏览器之间进行交互就是基于http协议,那么比如我们通过浏览器来访问了登录这个接口并实现了登录的这个操作,但我们再执行其他业务操作时 服务器也并不知道这个员工有没有登录,因为http协议时无状态的,两次请求之间是独立的,所以就有了登录校验这个操作

解决方案:我们要在员工登录之后存储一个标记,我们就可以在每一个接口方法之前来做一个条件判断,如果这个员工已经登录了,那就执行正常的业务操作,否则就是没有登录就直接返回错误信息给前端,前端拿到这个错误信息后 它会自动地跳转到登录页面(其他的功能也是根据这个相同的逻辑来进行判断!)

相信你也注意到了上面的“每一个”会非常的繁琐 没错“白雪”,程序员是最会偷懒的,那么肯定会有更便捷的方法 ———— 统一拦截技术,可以来拦截浏览器发送过来的所有请求,就可以对其进行校验员工是否登录,那我们就可以获取之前所存入的这个登陆的标记,如果成功获取到了这个标记并没有问题,那就说明这个员工已经登录了,就直接放行 继续去访问正常的业务接口就可以,但如果获取到的登录标记是有问题的,我们还是给前端响应一个错误信息,前端就会自动的跳转到登录页面

所以登录校验就涉及到两个部分,一是登录标记(用户登录成功之后,每一次请求中,都可以获取到该标记),这就涉及到web开发中的会话技术;二是统一拦截(常见的技术一种是serverlet规范中的过滤器Filter,另一种是spring中提供的拦截器Interceptor)

所以我们接下来就会学习:

· 会话技术

· JWT令牌

· 过滤器Filter

· 拦截器Interceptor

会话技术

会话: 指的就是服务器 与浏览器之间的一次连接,直到有一方断开连接 会话才会结束,并且在一次会话中可以包含多次请求和响应。(比如我们打开了浏览器来访问我们web服务器上的资源,第一次请求我访问的是登录的接口① 进行登录操作,那接下来我可以继续在浏览器这当中去访问部门管理的接口②,来查询所有的部门数据,也可以再去访问员工管理接口③,来查询员工的数据)只要这个浏览器和服务器都没有关闭,那么这三次请求都是在一次会话中完成的。

这是一次会话,但将来右边这个web服务器会被多个浏览器同时来访问,假如还有两个浏览器都在访问 并已经和服务器建立好了连接了,现在一共就有三次会话,所以可以发现这个会话是和浏览器关联起来的


会话跟踪 : 一种维护浏览器状态的方法,服务器需要识别多次请求是否来自于同一浏览器,以便在同一次会话的多次请求间共享数据。

通俗来说就是今后服务器会收到很多的请求,服务器这时就需要识别出来这写些请求是不是来自同一个浏览器,如果不是 就说明是不同的会话,如果是 就说明是同一会话 就在同一次会话的多次请求间共享数据,这个过程就叫做 ————会话跟踪。

为什么要共享数据呢?比如在是由阿里云时,我们打开浏览器执行登录请求,然后再以后每一次请求中 我们都可以再阿里云的后台管理界面当中来获取当前登录的这个用户名,登录操作是一次请求响应 我们后面执行每一次操作又是一次请求响应,我们去拿到当前已经登录用户的用户名单就是在一次会话的多次请求之间来共享数据。

如何去实现会话跟踪呢?

我们前面有提到浏览器与服务器之间,在进行交互时使用的是无状态的http协议,也就是下一次请求 他并不会携带上一次请求的数据,每一次请求都是相互独立的,那这也就保证了http协议他的效率是比较高的,但也会造成

在服务器端,我们没有办法仅仅通过http协议区分这两次请求是否来自于同一浏览器,是否来自于同一会话,所以此时同一会话当中的多次请求之间是没有办法进行数据共享的,解决这个问题就得用到——会话跟踪技术,一共有三种方案:

● 客户端会话跟踪技术: Cookie (传统)
● 服务端会话跟踪技术: Session (传统)
● 令牌技术 (主流)

方案一:Cookie

Cookie是客户端会话跟踪技术,它是存储在客户端浏览器的,我们可以在浏览器第一次发起请求服务器的时候来设置一个cookie。比如第一次请求了登录接口,执行完之后 我们就可以设置一个cookie,在cookie中我们就可以来存储相关的一些数据信息 如当前登录用户的用户名、ID;服务器在给客户端响应数据的时候会自动地将这个cookie响应给浏览器,当浏览器接收到响应回来的cookie之后,会自动的将这个cookie的值存储在浏览器本地,接下来在后续的每一次请求当中都会将浏览器本地所存储的cookie自动地携带到服务端,在服务端就可以获取到这个cookie的值,并判断一下这个cookie的值是否存在,如果不存在这个cookie那就说明这个客户端之前是没有访问登录接口的,如果存在那就说明这个客户端之前已经登录完成了;可以看见这一切都是自动化进行的,因为cookie是http协议中所支持的技术,那在http协议中就给我们提供了一个响应头和一个请求头,分别是响应头setcookie和请求头cookie

那就意味着在给浏览器响应cookie的时候是通过响应头(setcookie),响应对应的数据就是cookie对应的值,前面的name就是cookie的名称,后面的value就是cookie的值

这个响应头返回给浏览器,浏览器会自动的解析这个响应头,然后拿到响应头对应的这个数据部分cookie,就将cookie的值存储在浏览器本地,那在后续的每一次请求当中,都会将浏览器所存储的对应的cookie值,直接在请求头当中,通过cookie这个请求头携带到服务器端

以上就是基于cookie进行会话跟踪的整个流程

我们来启动程序,并访问c1:

这就是我们在服务器端所响应回来的cookie,那浏览器拿到这个响应回来的数据之后,它会自动的解析响应头,如果看到有一个响应头叫set cookie,它会自动地将这个cookie值存储在浏览器本地,我们来看看是存储在哪里的:

这个时候我们再去访问c2,看下在请求头中是不是携带了刚刚响应回来并存储了的cookie: (是有的)

可以发现我们访问c1这个接口我们所设置的cookie,我们访问c2的时候我们又拿到了我们所设置的cookie的值,所以我们就可以在多次请求之间来共享数据了。 (cookie在进行会话跟踪的时候最为核心的就是一个请求头cookie——就是用来携带cookie的数据 和一个响应头setcookie ——用来设置cookie数据请求头,)

cookie的优缺点:

优点: Cookie是HTTP协议中所支持的技术,像setcookie这个响应头的解析以及cookie请求头数据的携带都是浏览器自动进行的。

● 缺点:

♦ 移动端APP无法使用Cookie

♦ 不安全,用户可以自己禁用Cookie

♦ Cookie不能跨域

这三个维度中只要有一个不同,就是跨域了,cookie就不能用了

这里IP和端口号都不一样。

方案二:Session

session是服务器端会话跟踪技术,所以它是存储在服务器端的,Session的底层就是基于刚刚介绍的cookie来实现的;现在我们要基于Session来进行会话跟踪,那浏览器在第一次请求服务器的时候,我们就可以直接在服务器当中来获取到会话对象Session,那如果是第一次请求,这个session对象是不存在的,这个时候服务器会自动创建一个会话对象session,每一个会话对象Session都有一个id,服务器端再给浏览器响应数据的时候,它会将这个session的id通过cookie响应给浏览器,其实就是在响应头当中增加一个Set cookie响应头对应的值就是JSESSIONID,代表的就是服务器端会话对象session的id,对应的值是1,1就是session的id,当浏览器接收到这个响应数据之后,它会自动的将这个cookie存储在浏览器本地,然后再后续每一次请求中 都会将cookie的数据获取出来 并且携带到服务端,服务器拿到这个cookie的值 也就是这个session的id之后,就会从众多的session当中来找到当前请求对应的会话对象session,这样我们就可以在同一次会话的多次请求之间来共享数据,这就是基于Session进行会话跟踪的流程。

响应头中有SetCookie 里面有JSESSIONID,后面一长串id代表的就是服务器端session会话对象id,接着浏览器接收到后 要将其存起来:

我们再来访问s2,在请求头中有传递cookie的数据,将两个cookie都携带到了服务器端,服务器端接收到了这个session的id之后,它就会根据这个id找到这次请求对应的session会话对象

在idea的日志中会看到 s1和s2,两次请求获取到的是同一个绘画对象

Session的优缺点:

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

缺点: 在现在的企业项目开发中,最终部署的时候都是以集群的形式来进行部署,也就是说同一个项目会部署多份

当用户在访问的时候,会先经过一个前置服务器(负载均衡服务器),它将前端发起的请求均匀的发给后面的这三台服务器,但我们通过session来进行会话跟踪就会存在一个问题:用户打开浏览器要进行登录操作,此时发起登录请求,登录请求到达这个负载均衡服务器,它将这个请求转给了第一台tomcat服务器,服务器接收到请求后 要获取到会话对象session,并给浏览器响应数据会携带这个cookie,cookie的名字就是JSESSIONID;那再下一次请求的时候又会将这个cookie携带到服务端,那假如又执行了一次查询操作,要查询部门的数据,这次请求到达负载均衡服务器之后,它有可能会将这次请求转给第二台tomcat服务器!她就要到第二台tomcat服务器中根据JSESSIONID对应的id值去找对应的session会话对象,但第二台服务器中并没有id为1的这个session,这就造成了同一个浏览器发起了两次请求,结果获取到的不是同一个会话对象,这就是session的缺点——在服务器集群环境下无法直接使用session、 其他缺点就是cookie所含有的所有缺点!

令牌技术

这里说到的令牌——就是一个用户身份的标识,本质就是一个字符串儿~

通过令牌技术来会话跟踪,就可以在浏览器发起请求登录接口的时候,如果登录成功,生成一个令牌,这个令牌就是这个用户的合法身份凭证,接下来在响应数据的时候,就可以直接将这个令牌响应给前端,在前端接收到这个令牌之后,就要将这个令牌存储起来,可以存在cookie中,也可以存储在其他存储空间中,那接下来后续的每一次请求当中,都需要将这个令牌携带到服务端,并且需要来校验这个令牌的有效性,如果有效 那说明用户已经执行了登录操作,如果无效 那就说明用户之前并未执行登录操作,那如果是在同一次会话的多次请求之间,我们想共享数据,我们就可以将共享的数据存储到这个令牌当中。

优缺点

● 优点:

◆ 支持PC端、移动端

◆ 解决集群环境下的认证问题

◆ 减轻服务器端存储压力

● 缺点:

需要自己实现

什么是JWT令牌和基本组成

● 全称: JSON Web Token (jwt.io/) (Token-令牌)

● 定义了一种简洁的、自包含的格式,用于在通信双方以json数据格式安全的传输信息。由于数字签名的存在,这些信息是可靠的。

● 组成:

◆ 第一部分:Header(头), 记录令牌类型、签名算法等。例如: {"alg":"HS256","type":"JWT"}的JSON数据格式

那我们看到原始的数据格式是上面这样一个JSON格式的数据,它还要再进行一次编码(base64编码:是一种基于64个可打印的字符<AZ、az、0~9、+、/> 来表示二进制数据的编码方式,注意不是加密!是编码!那就意味着能解码)

◆ 第二部分:Payload(有效载荷 - 就是拿来装东西的), 携带一些自定义信息、默认信息等。例如: {"id":"1","username":"Tom"},原始数据也是json格式,也进行了base64编码。

◆ 第三部分:Signature(签名), 防止Token被篡改、确保安全性。将header、payload, 并加入指定秘钥, 通过指定签名算法自动计算而来。(不是base64编码) 因为这个部分所以整个jwt令牌是非常安全可靠的,一旦整个jwt令牌当中任何一个部分、字符被篡改了,那整个令牌再校验的时候都会失败。

应用场景:(登录认证)

我们还是先从浏览器发起请求来执行登录操作,此时会访问登录的接口,登录成功之后,会生成一个jwt令牌:

然后将生成的jwt令牌返回给前端 :

前端浏览器拿到jwt令牌之后,会将这个jwt令牌存储起来,然后在后续的请求当中每一次请求都会将这个jwt令牌携带到服务器:

那服务端接下来要进行统一拦截,拦截到这个请求之后,先来判断一下这次请求有没有把这个令牌带过来,如果没有,那直接拒绝访问;如果带过来了,还要来校验一下这个令牌是否是有效的,那如果有效我们就直接放行去进行请求处理就好

总结下来就是两步,一是生成令牌;二是校验令牌;

这就是JWT它最典型的应用场景。

如何生成和校验JWT令牌

要先引入jwt的相关依赖:

        <!--JWT令牌-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>

生成令牌:

    @Test
    public void testGenJwt(){
        Map<String, Object> claims = new HashMap<>();
        claims.put("id",1);
        claims.put("name","tom");
        String jwt = Jwts.builder()
                .signWith(SignatureAlgorithm.HS256, "itheima")//签名算法
                .setClaims(claims) //自定义内容(载荷)
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60))//设置有效期为1小时
                .compact();
        System.out.println(jwt);
    }

校验令牌:

 @Test
    public void testParseJwt(){
        Claims claims = Jwts.parser()
                .setSigningKey("itheima") //指定签名密钥
                .parseClaimsJws("eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoidG9tIiwiaWQiOjEsImV4cCI6MTczMTI5ODQ2MH0.MsGVDVNTGEj30JMaZPavEfPr34HrXuwuveJgT5LgNao")
                //解析令牌
                 .getBody();
        System.out.println(claims);
    }

注意事项

● JWT校验时使用的签名秘钥,必须和生成JWT令牌时使用的秘钥是配套的。

● 如果JWT令牌解析校验时报错,则说明JWT令牌被篡改或失效了,令牌非法。

登录-生成令牌

步骤:

· 引入JWT令牌操作工具类。

public class JwtUtils {

    private static String signKey = "itheima";
    private static Long expire = 43200000L;

    /**
     * 生成JWT令牌
     * @param claims JWT第二部分负载 payload 中存储的内容
     * @return
     */
    public static String generateJwt(Map<String, Object> claims){
        String jwt = Jwts.builder()
                .addClaims(claims)
                .signWith(SignatureAlgorithm.HS256, signKey)
                .setExpiration(new Date(System.currentTimeMillis() + expire))
                .compact();
        return jwt;
    }
    /**
     * 解析JWT令牌
     * @param jwt JWT令牌
     * @return JWT第二部分负载 payload 中存储的内容
     */
    public static Claims parseJWT(String jwt){
        Claims claims = Jwts.parser()
                .setSigningKey(signKey)
                .parseClaimsJws(jwt)
                .getBody();
        return claims;
    }
}

· 登录完成后,调用工具类生成JWT令牌,并返回。

在logincontroller中写,如果登录成功就生成JWT令牌、登录失败就返回错误信息

@Slf4j
@RestController
public class LoginController {

    @Autowired
    private EmpService empService;
    @PostMapping("/login")
    public Result Login(@RequestBody Emp emp){
        log.info("用户登录:{}",emp);
        Emp e = empService.login(emp);

        //登录成功,生成JWT令牌
        if(e != null){
            Map<String, Object> claims = new HashMap<>();
            claims.put("id",e.getId());
            claims.put("name",e.getName());
            claims.put("username",e.getUsername());

            String jwt = JwtUtils.generateJwt(claims);//jwt包含了当前登录的员工信息
            return Result.success(jwt);
        }

        //登录失败,返回错误信息
        return e != null?Result.success():Result.error("用户名或密码错误");
    }

}

我们先在postman中来测试一下:

可以看到我们成功拿到了令牌,登陆成功。

我们再到前端来看一下,响应的结果:

我们点击登录之后,在浏览器后台抓取一下网络请求,重点看看上面login请求响应回来的数据,我们可以看到JWT令牌已经响应给了前端,此时前端会将JWT令牌存储在浏览器本地:

在后续的每一次请求中都会在请求头header中携带JWT令牌到服务端,请求头的名称为token:

登录进去之后我们再来点击部门管理,相当于又发起了一个请求

我们还是来抓取这个请求:可以在请求头中找到这个token

接下来我们就要在服务端统一拦截住所有的请求,来判断是否都携带有合法的JWT令牌,如果有就直接放行,主要会用到两种解决方案/一是Filter过滤器,二是Interceptor拦截器

过滤器Filter

● 概念: Filter 过滤器,是JavaWeb 三大组件(Servlet、Filter、Listener)之一。

● 过滤器可以把对资源的请求拦截下来,从而实现一些特殊的功能。

● 过滤器一般完成一些通用的操作,比如:登录校验、统一编码处理、敏感字符处理等。

Filter快速入门

1.定义Filter:定义一个类,实现Filter接口,并重写其所有的方法。

  1. 配置Filter: Filter类上加@WebFilter 注解,配置拦截资源的路径。引导类上加 @ServletComponentScan 开启Servlet组件支持。

执行流程

中间的doFilter就是放行操作。


疑问

● 放行后访问对应资源,资源访问完成后,还会回到Filter中吗?

● 如果回到Filter中,是重新执行还是执行放行后的逻辑呢?

执行放行后逻辑

拦截路径

有三种不同的拦截路径,我们之前快速入门是用的第三个拦截所有,我们再来试试拦截具体登录路径。(改为/login)

可以看到拦截到了这次请求,但我们这时再来执行一下查询部门的操作:/depts

可以看到并没有拦截这次请求


我们再来试试第二种目录拦截:

先来试试员工分页查询(不是部门depts开头)

不难发现并没有拦截到这次请求。

我们现在来试试查询部门(是/depts开头)的请求:

成功拦截到了

那删除部门的肯定也可以拦截到:

是的也拦截到了~

过滤器链

介绍:一个web应用中,可以配置多个过滤器,这多个过滤器就形成了一个过滤器链。

我们再之前的DemoFilter基础上再在filter包中创建一个AbcFilter过滤器,注意头上的注解还是“/*”——全部请求都拦截

可以看到Abc和Demo都拦截到了请求,但是过滤器的执行顺序是根据注解配置的Filter,优先级是按照过滤器类名(字符串)的自然排序。

大致步骤就如下图:


拦截器Interceptor

简介&快速入门

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

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

2.注册拦截器

拦截器-拦截路径

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

比如我们排除login登录路径:

拦截器-执行流程

DispatcherServlet-是我们前面所提到的前端控制器

当浏览器发出请求后,过滤器先执行放行前的逻辑,再进行放行操作doFilter()之后,才会经过前端控制器进入到拦截器,执行拦截器中的preHandle,由于我们设置的返回值为true,也就是放行,就去执行controller中的方法,之后再执行postHandle和afterCompletion方法,最终再来执行过滤器中放行之后的逻辑。

所以拦截器和过滤器之间的区别:

· 接口规范不同:过滤器需要实现fliter接口,而拦截器需要实现handlerinterceptor接口。

· 拦截范围不同:过滤器filter会拦截所有的资源(范围更大),而lnterceptor只会拦截spring环境中的资源。(上图中粉色框中的资源)

异常处理

在Controller的方法中进行try...catch处理(因为Controller很多,代码会很臃肿,不推荐)所以我们用——全局异常处理器。

不管是Mapper还是Service还是Controller中的异常最后都会抛给全局异常处理器。再给前端响应标准的统一响应结果Result,,如果这个 请求正常执行,就从Controller中响应一个成功的结果,如果出现异常就进入全局异常处理器。

定义一个全局异常处理器也很简单:

所以这里返回的是一个Result对象,但给前端响应的却是一个JSON格式,就是因为有这个注解进行转换,这个@RestControllerAdvice也表明了这是一个全局异常处理器,