登录认证永远是社区网站最核心的功能之一。本文从无状态的HTTP协议讲起,详细梳理了Cookie、Session、Token、JWT等相关技术,并给出本项目的做法。

无状态的HTTP协议

HTTP 无状态协议,是指协议对于业务处理没有记忆能力,每次请求都是完全独立互不影响的,没有任何上下文信息。假如一直用这种原生无状态的 HTTP 协议,我们每换一个页面可能就得重新登录一次,这肯定是不行的。那么如何做呢?

在客户端第一次请求之后,服务端生成并向客户端分配唯一标识,然后后续客户端请求服务端的时候,只需要携带者唯一标识就可以了,如下图所示:

img

Cookie方案

Cookie的概念

HTTP Cookie(也叫 Web Cookie 或浏览器 Cookie)是服务器发送到用户浏览器并保存在本地的一小块数据。它会在浏览器下次向同一服务器再发起请求时,被携带并发送到服务器上。
通常 Cookie 用于告知服务端两个请求是否来自同一浏览器,如保持用户的登录状态。Cookie 使基于无状态的 HTTP 协议记录稳定的状态信息成为了可能。

Cookie 主要用于以下三个方面:

  • 会话状态管理(如用户登录状态、购物车等其它需要记录的信息)
  • 个性化设置(如用户自定义设置、主题等)
  • 浏览器行为跟踪(如跟踪分析用户行为等)

交互流程

服务器收到 HTTP 请求时,服务器可以在响应头里面添加一个 Set-Cookie 选项。浏览器收到响应后通常会保存下 Cookie,之后对该服务器每一次请求中都通过 Cookie 请求头部将 Cookie 信息发送给服务器。另外,Cookie 的过期时间、域、路径、有效期、适用站点都可以根据需要来指定。

img

安全问题

Cookie会存在一些安全问题:

  • Cookie 被浏览器发出之后可能被劫持,被用于非法行为
  • 跨站请求伪造(英语:Cross-site request forgery)。CSRF 利用的是网站对用户网页浏览器的信任。

Session(+Cookie)方案

Session的概念

如果说 Cookie 是客户端行为,那么 Session 就是服务端行为。

  • Cookie 机制在最初和服务端完成交互后,保持状态所需的信息都将存储在客户端,后续直接读取发送给服务端进行交互。
  • Session 代表服务器与浏览器的一次会话过程,并且完全由服务端掌控,实现分配 ID、会话信息存储、会话检索等功能。Session 机制将用户的所有活动信息、上下文信息、登录信息等都存储在服务端,只是生成一个唯一标识 ID 发送给客户端,称为Session-ID

image-20240331154148360

交互流程

当客户端第一次请求 session 对象时候,服务器会为客户端创建一个 session,并将通过特殊算法算出一个 session 的 ID,用来标识该 session 对象;

当浏览器下次请求别的资源的时候,浏览器会将 sessionID 放置到请求头中,服务器接收到请求后解析得到 sessionID,服务器找到该 id 的 session 来确定请求方的身份和一些上下文信息。

实现方式

注意:Session 和 Cookie 没有直接的关系。可以认为 Cookie 只是实现 Session 机制的一种方法途径而已,没有 Cookie 还可以用别的方法。

Session 的实现主要两种方式:Cookie 与 URL 重写,而 Cookie 是首选方式。因为各种现代浏览器都默认开通 Cookie 功能,但是每种浏览器也都有允许 Cookie 失效的设置,因此对于 Session 机制来说还需要一个备胎。

为了安全考虑,Web 应用通常会给 sessionId 设置一个过期时间,使得 sessionId 仅在某个时间段内有效,这样就可以有效地抵御攻击者盗用 sessionId 绕过身份认证的行为。

img

将会话标识号以参数形式附加在超链接的 URL 地址后面的技术称为 URL 重写
原始 URL:

1
http://taobao.com/getitem?name=baymax&action=buy

重写后的 URL:

1
http://taobao.com/getitem?sessionid=1wui87htentg&?name=baymax&action=buy

Session存在的问题

img

由于 Session 信息是存储在服务端的,因此如果用户量很大的场景,Session 信息占用的空间就不容忽视。另外,对于大型网站必然是集群化 &分布式的服务器配置。如果 Session 信息是存储在本地的,那么由于负载均衡的作用,原来请求机器 A 并且存储了 Session 信息,下一次请求可能到了机器 B,此时机器 B 上并没有 Session 信息。

分布式Session解决方案

目前有四种分布式session的解决方案

方案一:客户端存储

直接将信息存储在cookie中,好家伙,直接回归原始时代!

缺点:

  • 数据存储在客户端,存在安全隐患
  • cookie存储大小、类型存在限制
  • 数据存储在cookie中,如果一次请求cookie过大,会给网络增加更大的开销

方案二:Session复制

session复制是小型企业应用使用较多的一种服务器集群session管理机制,在真正的开发使用的并不是很多,通过对web服务器(例如Tomcat)进行搭建集群。

存在的问题

session同步的原理是在同一个局域网里面通过发送广播来异步同步session的,一旦服务器多了,并发上来了,session需要同步的数据量就大了,需要将其他服务器上的session全部同步到本服务器上,会带来一定的网路开销,在用户量特别大的时候,会出现内存不足的情况。
优点

服务器之间的session信息都是同步的,任何一台服务器宕机的时候不会影响另外服务器中session的状态,配置相对简单
Tomcat内部已经支持分布式架构开发管理机制,可以对tomcat修改配置来支持session复制,在集群中的几台服务器之间同步session对象,使每台服务器上都保存了所有用户的session信息,这样任何一台本机宕机都不会导致session数据的丢失,而服务器使用session时,也只需要在本机获取即可。

方案三:Session绑定

利用Nginx实现。Nginx是一款自由的、开源的、高性能的http服务器和反向代理服务器,它可以实现反向代理、负载均衡、http服务器(动静代理)、正向代理。

我们利用Nginx的反向代理和负载均衡,之前是客户端会被分配到其中一台服务器进行处理,具体分配到哪台服务器进行处理还得看服务器的负载均衡算法(轮询、随机、ip-hash、权重等),但是我们可以基于Nginx的ip-hash策略,可以对客户端和服务器进行绑定,同一个客户端就只能访问该服务器,无论客户端发送多少次请求都被同一个服务器处理。

缺点

  • 容易造成单点故障,如果有一台服务器宕机,那么该台服务器上的session信息将会丢失
  • 前端不能有负载均衡,如果有,session绑定将会出问题

优点

  • 配置简单

方案四:基于redis存储session方案

基于redis存储session方案流程示意图如下:

img

优点:

  • 这是企业中使用的最多的一种方式。spring为我们封装好了spring-session,直接引入依赖即可
  • 数据保存在redis中,无缝接入,不存在任何安全隐患
  • redis自身可做集群,搭建主从,同时方便管理

缺点

  • 多了一次网络调用,web容器需要向redis访问

总结

  • 一般会将web容器所在的服务器和redis所在的服务器放在同一个机房,减少网络开销,走内网进行连接。

Token 方案

Token 是令牌的意思,由服务端生成并发放给客户端,是一种具有时效性的验证身份的手段。

Token 避免了 Session 机制带来的海量信息存储问题,也避免了 Cookie 机制的一些安全性问题,在现代移动互联网场景、跨域访问等场景有广泛的用途。

交互流程

img

  • 客户端将用户的账号和密码提交给服务器;
  • 服务器对其进行校验,通过则生成一个 token 值返回给客户端,作为后续的请求交互身份令牌;
  • 客户端拿到服务端返回的 token 值后,可将其保存在本地,以后每次请求服务器时都携带该 token,提交给服务器进行身份校验;
  • 服务器接收到请求后,解析关键信息,再根据相同的加密算法、密钥、用户参数生成 sign 与客户端的 sign 进行对比,一致则通过,否则拒绝服务;
  • 验证通过之后,服务端就可以根据该 Token 中的 uid 获取对应的用户信息,进行业务请求的响应。

可以发现,只需要一个 token 就可以实现身份认证了。

JWT

目前最流行的 Token 是JSON Web Token(JWT),下面以JWT为例说明。

Token 主要由三部分组成:

  • Header 头部信息:记录了使用的加密算法信息;
  • Payload 净荷信息:记录了用户信息和过期时间等;
  • Signature 签名信息:根据 header 中的加密算法和 payload 中的用户信息以及密钥 key 来生成,是服务端验证服务端的重要依据。

header 和 payload 的信息不做加密,只做一般的 base64 编码。服务端收到 token 后剥离出 header 和 payload 获取算法、用户、过期时间等信息,然后根据自己的加密密钥来生成 sign,并与客户端传来的 sign 进行一致性对比,来确定客户端的身份合法性。

这样就实现了用 CPU 加解密的时间换取存储空间,同时服务端密钥的重要性就显而易见,一旦泄露整个机制就崩塌了,这个时候就需要考虑 HTTPS 了。

一个典型的JSON对象如下,加密后长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyYWluaW5nIiwiY3JlYXRlZCI6MTY0NTcwMDQzNjkwMCwiZXhwIjoxNjQ2MzA1MjM2fQ.PvHUTYIZVyzPHHeolmjjMhcROEm5E9mMu7iOofz6CXI

1
2
3
4
5
{
"sub": "raining",
"created": 1645700436900,
"exp": 1646305236
}

查看JWT官网https://jwt.io/,可以发现:

image-20240331162807786

左侧 Encoded 部分就是 JWT 密文,中间用「.」分割成了三部分(右侧 Decoded 部分):

  • Header(头部),描述 JWT 的元数据,其中 alg 属性表示签名的算法(当前为 HS512)
  • Payload(负载),用来存放实际需要传递的数据,其中 sub 属性表示主题(实际值为用户名),created 属性表示 JWT 产生的时间,exp 属性表示过期时间。
  • Signature(签名),对前两部分的签名,防止数据篡改;这里需要服务器端指定一个密钥(只有服务器端才知道),不能泄露给客户端,然后使用 Header 中指定的签名算法,按照下面的公式产生签名:
1
2
3
4
5
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)

算出签名后,再把 Header、Payload、Signature 拼接成一个字符串,中间用「.」分割,就可以返回给客户端了。

其中,对于Payload,标准中注册的声明 (建议但不强制使用)包括如下几个部分

  • iss: jwt签发者;
  • sub: jwt所面向的用户;
  • aud: 接收jwt的一方;
  • exp: jwt的过期时间,这个过期时间必须要大于签发时间;
  • nbf: 定义在什么时间之前,该jwt都是不可用的;
  • iat: jwt的签发时间;
  • jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击;

客户端拿到 JWT 后,可以放在 localStorage,也可以放在 Cookie 里面。以后客户端再与服务器端通信的时候,就带上这个 JWT,一般放在 HTTP 的请求的头信息 Authorization 字段里。服务器端接收到请求后,再对 JWT 进行验证,如果验证通过就返回相应的资源。

Token方案的特点

  • Token 可以跨站共享,实现单点登录;
  • Token 机制无需太多存储空间。Token 包含了用户的信息,只需在客户端存储状态信息即可,对于服务端的扩展性很好
  • Token 机制的安全性依赖于服务端加密算法和密钥的安全性;

JWT的一些问题

JWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。不过,也正是由于 JWT 的无状态,也导致了它最大的缺点:不可控

比如说,我们想要在 JWT 有效期内废弃一个 JWT 或者更改它的权限的话,并不会立即生效,通常需要等到有效期过后才可以。再比如说,当用户 Logout 的话,JWT 也还有效。

这个问题不存在于 Session 认证方式中,因为在 Session 认证方式中,遇到这种情况的话服务端删除对应的 Session 记录即可。但是,使用 JWT 认证的方式就不好解决了。

目前主要有以下方案:

  • 将JWT存入内存数据库
  • 黑名单机制
  • 修改密钥(Secret)
  • 保持令牌的有效期限短并经常轮换。

另外,续签也会有一些问题,不再展开,详见https://juejin.cn/post/7110044736848658445

我们的做法

在本项目中,我们使用了JWT+Redis的方案,将对应的user信息存入Redis【token:userId】,验证签名和有效期后,去Redis中查询对应的user:

  • 如果查询到,则证明用户是登录状态;
  • 如果查询不到,说明该用户已经登出或者被强制下线;

那有小伙伴就要问了,你这样加个Redis,那JWT的无状态性不就没了,你这不就是传统的session吗?无非就是把session存到内存数据库里面了?参见https://www.zhihu.com/question/274566992/answer/1657470520。

image-20240331194239303

image-20240331195226097

其实是这样的,我们甚至可以不用JWT,直接针对每个用户生成一个UUID,也可以的。不过我们在项目中直接使用JWT了,多做一层验证。

来看一下开源项目的登录登出功能是怎么写的。

image-20240331173705284

登录其实就是生成token并返回:

image-20240331173551720

登出则是什么都不做:

image-20240331173636771

这样做存在不可控的问题,我们在这个的基础上加了一层Redis的判断~

登录控制

在完成了身份认证(filter中进行)之后,用户信息就被存进了一个ThreadLocal变量中,我们可以查看该变量确认用户是否登录。

然后这还不够,我们还需要在Interceptor中去检查用户是否具有权限访问相应的内容。

我们定义了一个permission注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.lang.annotation.*;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Permission {
/**
* 限定权限
*
* @return
*/
UserRole role() default UserRole.ALL;
}

具体的实现方案如下:

  1. 自定义Interceptor继承HandlerInterceptor
  2. 重写preHandle方法
    1. 如果handler instanceof HandlerMethod
      1. 获取方法上的permission注解
        1. 如果没找到,则获取该方法类上的permission注解
      2. 如果没找到permission注解,或者permission注解的值是UserRole.ALL
        1. (如果用户登录状态)更新用户活跃度
        2. 放行
      3. 如果用户没登录
        1. 直接跳转到登录界面
        2. 不放行
      4. 如果用户登录了,并且想访问admin页面,但是用户又不是admin
        1. 不放行
      5. 其他情况
        1. 放行
    2. 其他情况
      1. 放行

参考