如何搭建一个社区系统 —— 3. 如何进行登录认证
登录认证永远是社区网站最核心的功能之一。本文从无状态的HTTP协议讲起,详细梳理了Cookie、Session、Token、JWT等相关技术,并给出本项目的做法。
无状态的HTTP协议
HTTP 无状态协议,是指协议对于业务处理没有记忆能力,每次请求都是完全独立互不影响的,没有任何上下文信息。假如一直用这种原生无状态的 HTTP 协议,我们每换一个页面可能就得重新登录一次,这肯定是不行的。那么如何做呢?
在客户端第一次请求之后,服务端生成并向客户端分配唯一标识,然后后续客户端请求服务端的时候,只需要携带者唯一标识就可以了,如下图所示:
Cookie方案
Cookie的概念
HTTP Cookie(也叫 Web Cookie 或浏览器 Cookie)是服务器发送到用户浏览器并保存在本地的一小块数据。它会在浏览器下次向同一服务器再发起请求时,被携带并发送到服务器上。
通常 Cookie 用于告知服务端两个请求是否来自同一浏览器,如保持用户的登录状态。Cookie 使基于无状态的 HTTP 协议记录稳定的状态信息成为了可能。
Cookie 主要用于以下三个方面:
- 会话状态管理(如用户登录状态、购物车等其它需要记录的信息)
- 个性化设置(如用户自定义设置、主题等)
- 浏览器行为跟踪(如跟踪分析用户行为等)
交互流程
服务器收到 HTTP 请求时,服务器可以在响应头里面添加一个 Set-Cookie 选项。浏览器收到响应后通常会保存下 Cookie,之后对该服务器每一次请求中都通过 Cookie 请求头部将 Cookie 信息发送给服务器。另外,Cookie 的过期时间、域、路径、有效期、适用站点都可以根据需要来指定。
安全问题
Cookie会存在一些安全问题:
- Cookie 被浏览器发出之后可能被劫持,被用于非法行为
- 跨站请求伪造(英语:Cross-site request forgery)。CSRF 利用的是网站对用户网页浏览器的信任。
Session(+Cookie)方案
Session的概念
如果说 Cookie 是客户端行为,那么 Session 就是服务端行为。
- Cookie 机制在最初和服务端完成交互后,保持状态所需的信息都将存储在客户端,后续直接读取发送给服务端进行交互。
- Session 代表服务器与浏览器的一次会话过程,并且完全由服务端掌控,实现分配 ID、会话信息存储、会话检索等功能。Session 机制将用户的所有活动信息、上下文信息、登录信息等都存储在服务端,只是生成一个唯一标识 ID 发送给客户端,称为
Session-ID
。
交互流程
当客户端第一次请求 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 绕过身份认证的行为。
将会话标识号以参数形式附加在超链接的 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存在的问题
由于 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方案流程示意图如下:
优点:
- 这是企业中使用的最多的一种方式。spring为我们封装好了spring-session,直接引入依赖即可
- 数据保存在redis中,无缝接入,不存在任何安全隐患
- redis自身可做集群,搭建主从,同时方便管理
缺点:
- 多了一次网络调用,web容器需要向redis访问
总结:
- 一般会将web容器所在的服务器和redis所在的服务器放在同一个机房,减少网络开销,走内网进行连接。
Token 方案
Token 是令牌的意思,由服务端生成并发放给客户端,是一种具有时效性的验证身份的手段。
Token 避免了 Session 机制带来的海量信息存储问题,也避免了 Cookie 机制的一些安全性问题,在现代移动互联网场景、跨域访问等场景有广泛的用途。
交互流程
- 客户端将用户的账号和密码提交给服务器;
- 服务器对其进行校验,通过则生成一个 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 | { |
查看JWT官网https://jwt.io/,可以发现:
左侧 Encoded 部分就是 JWT 密文,中间用「.」分割成了三部分(右侧 Decoded 部分):
- Header(头部),描述 JWT 的元数据,其中 alg 属性表示签名的算法(当前为 HS512)
- Payload(负载),用来存放实际需要传递的数据,其中 sub 属性表示主题(实际值为用户名),created 属性表示 JWT 产生的时间,exp 属性表示过期时间。
- Signature(签名),对前两部分的签名,防止数据篡改;这里需要服务器端指定一个密钥(只有服务器端才知道),不能泄露给客户端,然后使用 Header 中指定的签名算法,按照下面的公式产生签名:
1 | HMACSHA256( |
算出签名后,再把 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。
其实是这样的,我们甚至可以不用JWT,直接针对每个用户生成一个UUID,也可以的。不过我们在项目中直接使用JWT了,多做一层验证。
来看一下开源项目的登录登出功能是怎么写的。
登录其实就是生成token并返回:
登出则是什么都不做:
这样做存在不可控的问题,我们在这个的基础上加了一层Redis的判断~
登录控制
在完成了身份认证(filter中进行)之后,用户信息就被存进了一个ThreadLocal变量中,我们可以查看该变量确认用户是否登录。
然后这还不够,我们还需要在Interceptor中去检查用户是否具有权限访问相应的内容。
我们定义了一个permission注解:
1 | import java.lang.annotation.*; |
具体的实现方案如下:
- 自定义
Interceptor
继承HandlerInterceptor
- 重写
preHandle
方法- 如果
handler instanceof HandlerMethod
- 获取方法上的permission注解
- 如果没找到,则获取该方法类上的permission注解
- 如果没找到permission注解,或者permission注解的值是
UserRole.ALL
- (如果用户登录状态)更新用户活跃度
- 放行
- 如果用户没登录
- 直接跳转到登录界面
- 不放行
- 如果用户登录了,并且想访问admin页面,但是用户又不是admin
- 不放行
- 其他情况
- 放行
- 获取方法上的permission注解
- 其他情况
- 放行
- 如果