Java中实现登录认证

695 阅读15分钟

在浏览网站的过程中,经常会遇到需要登录的情况,有些页面只有登录之后才可以访问。在登录之后可以连续访问很多次网站,但是有时候过一段时间就需要重新登录。还有一些网站,在打开浏览器时就自动登录了,而且在很长时间内都不会失效,这又是什么情况?其实这里面涉及Cookie、Session以及token等相关知识,现在就来揭开它们的神秘面纱。

1. 会话技术介绍

什么是会话?

  • 在我们日常生活当中,会话指的就是谈话、交谈。

  • 在web开发当中,会话指的就是浏览器与服务器之间的一次连接,我们就称为一次会话。

    在用户打开浏览器第一次访问服务器的时候,这个会话就建立了,直到有任何一方断开连接,此时会话就结束了。在一次会话当中,是可以包含多次请求和响应的。

    比如:打开了浏览器来访问web服务器上的资源(浏览器不能关闭、服务器不能断开),以京东为例

    • 第1次:访问的是登录的接口,完成登录操作
    • 第2次:访问的是商品展示接口,查询所有相关商品数据
    • 第3次:访问的是购物车接口,查询购物车数据

    只要浏览器和服务器都没有关闭,以上3次请求都属于一次会话当中完成的。

image-20240413100055654

需要注意的是:会话是和浏览器关联的,当有三个浏览器客户端和服务器建立了连接时,就会有三个会话。同一个浏览器在未关闭之前请求了多次服务器,这多次请求是属于同一个会话。比如:1、2、3这三个请求都是属于同一个会话。当我们关闭浏览器之后,这次会话就结束了。而如果我们是直接把web服务器关了,那么所有的会话就都结束了。

会话跟踪

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

服务器会接收很多的请求,但是服务器是需要识别出这些请求是不是同一个浏览器发出来的。比如:1和2这两个请求是不是同一个浏览器发出来的,3和5这两个请求不是同一个浏览器发出来的。如果是同一个浏览器发出来的,就说明是同一个会话。如果是不同的浏览器发出来的,就说明是不同的会话。而识别多次请求是否来自于同一浏览器的过程,这就称为会话跟踪。

我们使用会话跟踪技术就是要完成在同一个会话中,多个请求之间进行共享数据。

为什么要共享数据呢?

由于HTTP是无状态协议,在后面请求中怎么拿到前一次请求生成的数据呢?此时就需要在一次会话的多次请求之间进行数据共享。

现在几种主要会话跟踪技术:

  1. Cookie技术
  2. Session技术
  3. token(令牌)

2. Cookie技术

Cookie技术是通过在客户端记录的信息确定用户身份。HTTP是一种无连接协议,客户端和服务器交互仅仅限于请求/响应过程,结束后断开,下一次请求时,服务器会认为是一个新的客户端,为了维护他们之间的连接,让服务器知道这是前一个用户发起的请求,必须在一个地方保存客户端信息。

比如第一次请求了登录接口,登录接口执行完成之后,我们就可以设置一个cookie,在cookie当中我们就可以来存储用户相关的一些数据信息。如我可以在cookie当中来存储当前登录用户的用户名,用户的ID。服务器端在给客户端在响应数据的时候,会自动的将cookie响应给浏览器,浏览器接收到响应回来的cookie之后,会自动的将cookie的值存储在浏览器本地。接下来在后续的每一次请求当中,都会将浏览器本地所存储的cookie自动地携带到服务端。

之后再次请求时,在服务端就可以获取到cookie值,然后经过检验这个cookie值是否存在,若不存在,则说明该客户端之前是并没有登录的,存在,则说明该客户端已经登录过了。

上面提到了很多次自动,为什么呢?是因为cookie它是HTPP协议当中所支持的技术,而各大浏览器厂商都支持了这一标准。在HTTP协议官方给我们提供了一个响应头和请求头:

  • 响应头 Set-Cookie :设置Cookie数据的
  • 请求头 Cookie:携带Cookie数据的

在代码中测试一下,这里是基于spring boot项目做的,通过一个前端表单设置post请求,携带登录数据。

login.html

 <!DOCTYPE html>
 <html lang="en">
 <head>
     <meta charset="UTF-8">
     <title>login</title>
 </head>
 <body>
     <form id="loginForm" action="/login" method="post">
         <label>用户名:<input id="username" type="text" name="username" autocomplete="off"></label>
         <br>
         <label>&emsp;码:<input id="password" type="password" name="password" autocomplete="off"></label>
         <br>
         <input type="submit" value="登录"/>
         <input type="reset" value="重置"/>
     </form>
 ​
     <script>
         document.getElementById("loginForm").onsubmit = () => {
             const username = document.getElementById("username").value;
             const password = document.getElementById("password").value;
 ​
             if (username.trim() === '' || password.trim() === '') {
                 alert('用户名和密码不能为空!');
                 return false; // 阻止表单提交
             }
         };
     </script>
 </body>
 </html>

在Controller类中实现cookie的设置等。

CookieController.java

 @RestController
 public class CookieController {
 ​
     /**
      * 登录(设置Cookie)
      */
     @PostMapping("/login")
     public String login(HttpServletResponse response, String username, String password) {
        // 创建Cookie
        Cookie cookie = new Cookie(username, password);
        // 设置Cookie存在最大时间 单位:秒
        cookie.setMaxAge(30);
        // 返回响应中设置Cookie
        response.addCookie(cookie);
 ​
        return "登录成功!";
     }
 ​
     /**
      * 获取Cookie
      */
     @GetMapping("/all-cookies")
     // 还可以通过注解获取 @CookieValue
     public String getCookies(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if(cookies != null){
           return Arrays.stream(cookies)
                 .map(cookie -> cookie.getName() + "=" + cookie.getValue())
                 .collect(Collectors.joining(",")); // 使用逗号分割
        }
 ​
        return "没有cookie值!";
     }
 }

然后就可以访问登录页面,进行测试一下。

image-20240413103437754

这里我们没有做校验,只是简单演示一下,故随便填写什么都是可以登录成功的,我们主要关注Cookie,这里我使用的用户名为admin,密码为123456。

image-20240413103749349

这里打开调试工具可以看一下,我们请求的返回响应。

image-20240413103942090

就可以看到返回了Set-Cookie字段值了,然后查看请求标头中并没有Cookie值,因为我们这是第一访问,这里随后还会提到。

先来看看这个cookie存储在哪的?

image-20240413104235036

这里可以看到Cookie的存储位置,重点关注一下名称、值和时间期限(Expires)三个地方。

有的人可能会发现这里怎么什么都没有?这是因为我们设置了时间期限已经过期了,如上图,浏览器这里Expire显示的是格林威治时间,而我们是东八区时间,所以浏览器这里显示的会比本地晚8个小时。因为我们设置了30s过期,故这里时间期限只有30s,超过30s之后(即浏览器显示的时间之后)该Cookie值会自动删除,超过这个时间访问,就会重新进行登录校验生成新的Cookie值。默认的时间期限是一次会话,即关闭浏览器之后就会自动删除,再次打开浏览器访问就需要重新登录校验了。

这里在登录状态且Cookie值未过期的情况下,我们访问另一个接口/all-cookie,获取一下cookie值。

image-20240413105200373

在抓包一下,看看请求标头内容是否包含Cookie。

image-20240413105256638

可以发现,浏览器会自动携带Cookie值请求。当Cookie值过期之中,再次访问时

image-20240413105744087

这里就需要重新登录校验了,然后请求标头中也不在携带Cookie值了。

这里会发现,密码不就暴漏了?故一般设置Cookie信息时,往往不会存入密码等信息,且正式项目中也应该进行编码加密处理,而不是直接明文展示Cookie值。

HTTP协议中支持的技术(Set-Cookie响应头的解析以及Cookie请求头数据的携带,都是浏览器自动进行的,是无需我们手动操作的),但是Cookie存在一些缺点:

  • 移动端APP(Android、IOS)中无法使用Cookie
  • 不安全,用户可以自己禁用Cookie
  • Cookie不能跨域

什么跨域呢?简单来说,就是协议、IP、端口任意一个值不同,就是跨域操作。不明白的大家可以自行查阅其他资料。

image-20240413110635069

3. Session技术

Session,中文称之为会话,通过在服务端记录的信息确定用户身份,这里这个session就是一个指的是会话。其本义是指有始有终的一系列动作、消息。例如打电话时,从拿起电话拨号到挂断电话之间的一系列过程就可以称为一个Session。Session时存在服务端的,具体流程如下图:

image-20240413112120069

注意:服务器端在给浏览器响应数据的时候它会将Session的ID通过Cookie响应给浏览器。其实在响应头当中增加了一个Set-Cookie响应头。浏览器会自动识别这个响应头,然后自动将Cookie存储在浏览器本地。

SessionController.java

 @RestController
 public class SessionController {
     /**
      * 登录(设置Session)
      */
     @PostMapping("/login")
     public String login(HttpSession session, String username, String password) {
        // 设置session
        session.setAttribute("username", username);
        session.setAttribute("password", password);
 ​
        return "登录成功!";
     }
 ​
     /**
      * 获取Session
      */
     @GetMapping("/all-sessions")
     public String getCookies(HttpServletRequest request) {
        HttpSession session = request.getSession();
        String username = (String) session.getAttribute("username");
        String password = (String) session.getAttribute("password");
        Map<String, String> result = new HashMap<>();
        result.put("username", username);
        result.put("password", password);
 ​
        return result.toString();
     }
 }

好,现在还是进行测试一下:

image-20240413113155462

image-20240413113136053

这里的Session过期时间就为整个会话期间,一旦结束,就会要重新验证。

image-20240413113417669

这里给返回了一个JSESSIONID的字段,就是存储到服务端的Session_ID。再次请求时,浏览器就会携带这个作为Cookie值,服务端校验通过后,就可以访问登录后的一些功能。访问/all-sessions查看。

image-20240413113804125

image-20240413113826077

这里我们可以检验Session过期时间是否为整个会话期间,关闭浏览器,重新访问一下该接口,或者换一个浏览器访问。

image-20240413113956939

同Cookie一样,不要把密码等重要信息放入其中。

Session是存储在服务端的,安全,但同时也存在一些缺点:

  • 服务器集群环境下无法直接使用Session
  • 移动端APP(Android、IOS)中无法使用Cookie
  • 用户可以自己禁用Cookie
  • Cookie不能跨域

除此此为,当访问量比较大时,服务器端就会存在较大压力,且耗费很多存储空间。

4. token技术

什么是token?token其实就是我们称做令牌的,本质上就是一段字符串。是一个用户身份的标识,访问资源接口(API)时所需要的资源凭证

服务器对Token的存储方式:

  1. 存到数据库中,每次客户端请求的时候取出来验证(服务端有状态)
  2. 存到 redis 中,设置过期时间,每次客户端请求的时候取出来验证(服务端有状态)
  3. 不存,每次客户端请求的时候根据之前的生成方法再生成一次来验证(JWT,服务端无状态)

token身份校验过程:

image-20240413134900188

token相比cookie与session缺点就是需要我们自己实现了,包括令牌的生成、令牌的传递、令牌的校验等。

下面主要讲到JWT的技术。

4.1 JWT令牌

JWT全称:JSON Web Token(官网:jwt.io/

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

    简洁:是指jwt就是一个简单的字符串。可以在请求参数或者是请求头当中直接传递。

    自包含:指的是jwt令牌,看似是一个随机的字符串,但是我们是可以根据自身的需求在jwt令牌中存储自定义的数据内容。如:可以直接在jwt令牌中存储用户的相关信息。

    简单来讲,jwt就是将原始的json数据格式进行了安全的封装,这样就可以直接基于jwt在通信双方安全的进行信息传输了。

JWT的组成: (JWT令牌由三个部分组成,三个部分之间使用英文的点来分割)

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

  • 第二部分:Payload(有效载荷),携带一些自定义信息、默认信息等。 例如:{"id":"1","username":"Tom"}

  • 第三部分:Signature(签名),防止Token被篡改、确保安全性。将header、payload,并加入指定秘钥,通过指定签名算法计算而来。

    签名的目的就是为了防jwt令牌被篡改,而正是因为jwt令牌最后一个部分数字签名的存在,所以整个jwt 令牌是非常安全可靠的。一旦jwt令牌当中任何一个部分、任何一个字符被篡改了,整个令牌在校验的时候都会失败,所以它是非常安全可靠的。

image-20230106085442076

JWT是如何将原始的JSON格式数据,转变为字符串的呢?

其实在生成JWT令牌时,会对JSON格式的数据进行一次编码:进行base64编码

Base64:是一种基于64个可打印的字符来表示二进制数据的编码方式。既然能编码,那也就意味着也能解码。所使用的64个字符分别是A到Z、a到z、 0- 9,一个加号,一个斜杠,加起来就是64个字符。任何数据经过base64编码之后,最终就会通过这64个字符来表示。当然还有一个符号,那就是等号。等号它是一个补位的符号。

需要注意:Base64是编码方式,而不是加密方式。

JWT令牌最典型的应用场景就是登录认证:

  1. 在浏览器发起请求来执行登录操作,此时会访问登录的接口,如果登录成功之后,我们需要生成一个jwt令牌,将生成的 jwt令牌返回给前端。
  2. 前端拿到jwt令牌之后,会将jwt令牌存储起来。在后续的每一次请求中都会将jwt令牌携带到服务端。
  3. 服务端统一拦截请求之后,先来判断一下这次请求有没有把令牌带过来,如果没有带过来,直接拒绝访问,如果带过来了,还要校验一下令牌是否是有效。如果有效,就直接放行进行请求的处理。

在JWT登录认证的场景中,整个流程当中涉及到两步操作:

  1. 在登录成功之后,要生成令牌。
  2. 每一次请求当中,要接收令牌并对令牌进行校验。

然后进行代码中测试:

首先,需要引入依赖,这里我使用了jjwt依赖,还有许多其他的依赖选项,可以自行选择,在官方网站中也有推荐。

image-20240413140015829

点击libraries -> 筛选语言(java),这里就可以看到一些依赖包含的加密方式和maven仓库的引用(底部),也可以去其github仓库中查看更加详细的信息。

jjwt的依赖:

 <dependency>
     <groupId>io.jsonwebtoken</groupId>
     <artifactId>jjwt</artifactId>
     <version>0.9.1</version>
 </dependency>

JwtUtil.java

 public class JwtUtil {
     /**
      * jwt密钥
      * 此秘钥一定要保留好在服务端, 不能暴露出去, 否则sign就可以被伪造, 如果对接多个客户端建议改造成多个
      */
     private static final String secretKey = "testJwt";
     /**
      * jwt过期时间(亳秒)
      */
     private static final Long expire = 1800000L;
 ​
     /**
      * 生成Jwt令牌
      * @param claims 设置的信息
      * @return Jwt令牌
      */
     public static String generateJwt(Map<String, Object> claims) {
         return Jwts.builder()
                 // 设置的信息
                 .setClaims(claims)
                 // 设置签名使用的签名算法和签名使用的秘钥
                 .signWith(SignatureAlgorithm.HS256, secretKey.getBytes(StandardCharsets.UTF_8))
                 // 设置过期时间(毫秒)
                 .setExpiration(new Date(System.currentTimeMillis() + expire))
                 .compact();
     }
 ​
     /**
      * jwt解密
      * @param jwt jwt令牌
      * @return 设置进入jwt的信息
      */
     public static Claims ParseJwt(String jwt) {
         return Jwts.parser()
                 // 设置签名的秘钥
                 .setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8))
                 // 设置需要解析的jwt
                 .parseClaimsJws(jwt)
                 .getBody();
     }
 }

我将生成和解析jwt令牌进行封装,可以直接进行使用,其中secretKey和expire根据自己的需求进行更改即可。

JWTController.java

 @RestController
 public class JWTController {
 ​
     /**
      * 登录(生成jwt令牌)
      */
     @PostMapping("/login")
     public String login(String username, String password) {
 ​
        // 登录成功后,生成jwt令牌
        // 返回给前端,前端开发会保存jwt,以便每一次请求携带jwt令牌
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", username);
        claims.put("password", password);
        String jwt = JwtUtil.generateJwt(claims);
 ​
        // 解析JWT,这里一般会放在拦截器或过滤器中
        Claims claims1 = JwtUtil.ParseJwt(jwt);
        System.out.println("JWT解析后的数据:" + claims1);
 ​
        return "登录成功!Jwt令牌为:" + jwt;
     }
 }

这里我将解析JWT的功能也写到了api接口中,解析JWT一般是会放在拦截器或过滤器来进行校验的,这里只是为了演示效果。

注意,有的人或许测试时会报错,提示下面的错误:

image-20240413141826505

这是因为JDK版本过高,jjwt中的有些依赖已经别丢弃了,只需要在pom文件中添加如下依赖:

 <dependency>
 <groupId>javax.xml.bind</groupId>
 <artifactId>jaxb-api</artifactId>
 <version>2.3.1</version>
 </dependency>

然后进行登录请求:

image-20240413141248570

解析后的结果,在控制台输出:

image-20240413141315599

这里简单演示了JWT的使用,关于过滤器和拦截器可以查看下一篇博客,在那里还会继续使用到JWT令牌技术。

4.2 Sa-Token

这里也给大家推荐一个比较见到那好用的工具,即Sa-token。

image-20240413142228311

官方地址:sa-token.cc/

有兴趣的大家可以自行探索使用,目前我还在摸索中。