登录是每个网站中都经常用到的一个功能,在页面上我们输入账号密码,敲一下回车键,就登录了,但这背后的登录原理你是否清楚呢?
传统登录方式
最原始的登录方式就是每个网站都拥有自己的登录系统,独立维护自己的账户和登陆状态,用户访问每个系统都需要重新登录,系统与系统之间不能共享账户。登录状态不能共享会导致一些弊端:
- 当员工需要使用公司多个内部系统,就需要在每个系统都注册一个账户,如果员工离职了,那么每个系统还要各自删除该员工的账户信息
- 明明都是这一个用户在使用系统,却要多次登录
上面登录方式弊端的根源是用户的登录状态无法实现共享,其中“状态”指的是什么呢?又如何实现“状态共享”呢?
有状态与无状态
我们都知道 HTTP
是一种无状态的协议,客户端每次发送请求时,首先要和服务器端建立一个连接,在请求完成后又会断开这个连接。这种方式可以节省传输时占用的连接资源,但同时也存在一个问题:每次请求都是独立的,服务器端无法判断本次请求和上一次请求是否来自同一个用户,进而也就无法判断用户的登录状态。
为了解决 HTTP
无状态的问题,Lou Montulli
在 1994 年的时候,推出了 Cookie
。
有了 Cookie
之后,服务器端就能够获取到客户端传递过来的信息了,如果需要对信息进行认证,还需要通过 Session
。
有了 Cookie
和 Session
之后,我们就可以进行登录认证了。
解释一下认证、授权的含义:认证指检查用户是不是登录了;授权指该用户能做什么,比如该用户能看到哪些菜单
服务端Session
服务端Session
的登录方式是最经典的一种登录方式,现在仍然有大量的企业在使用。
单系统单服务
用户首次登录时:
用户访问 a.com/pageA
,并输入密码登录。服务器验证密码无误后,会创建 SessionId
,并将它保存起来。服务器端响应这个 HTTP
请求,并通过 Set-Cookie
头信息,将 SessionId
写入 Cookie
中。
第一次登录完成之后,后续的访问就可以直接使用 Cookie
进行身份验证了:
用户访问 a.com/pageB
页面时,会自动带上第一次登录时写入的 Cookie
。服务器端比对 Cookie
中的 SessionId
和保存在服务器端的 SessionId
是否一致。如果一致,则身份验证成功。
单系统单服务的架构中,服务器充当很多角色,既要认证、授权,也要处理业务逻辑。
单系统服务集群
当访问并发量高了,单台服务器肯定支撑不住,需要对服务做集群部署、负载均衡。使用nginx
或者ribbon
负载均衡时,希望用轮询方式进行负载。但是如果使用轮询方式的话,可能会访问不同的Tomcat
,而Session
存储在每台节点服务器的tomcat
内存中,会导致Session
不能共享,则访问每一个节点服务器时都需要重新下发一个新的Session
。
为了每个节点服务器能共享Session
,tomcat
有内置集群方法,可以同步复制节点tomcat
内存中的Session
,只需修改tomcat
配置文件即可:
- 修改
server.xml
,取消注释<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"/>
- 修改应用
web.xml
,增加节点<distributable />
Seesion
复制是小型企业应用使用较多的一种服务器集群Seesion
管理机制,在真正的开发使用的并不是很多,通过对web
服务器(例如Tomcat
)进行搭建集群。Seesion
复制方式有着以下的缺点:
- 每个服务都需要去修改配置文件
- 占用带宽,带来一定的网路开销
- 一旦并发量上来了,同步的数据量很大,服务器会出现内存不足的情况,复制
Session
可能会造成Session
丢失
那有没有更好的方法解决Seesion
一致性问题呢?文件系统?数据库?分布式缓存?
其实这些都可以,只是性能有差异,文件系统、数据库的性能不会很高,分布式缓存解决这一问题。分布式缓存大致有两种:1、Memcached
;2、Redis
;更多场景下还是会使用Redis
存储。
Spring Session
提供了一套创建和管理Servlet HttpSession
的方案。Spring Session
提供了集群Session(Clustered Sessions)
功能,默认采用外置的Redis
来存储Session
数据,以此来解决Session
共享的问题。
基于redis
存储session
方案优点:
spring
为我们封装好了spring-session
,直接引入依赖即可- 数据保存在
redis
中,无缝接入,不存在任何安全隐患 redis
自身可做集群,搭建主从,同时方便管理
缺点:
- 多了一次网络调用,
web
容器需要向redis
访问
基于Session单点登录
单点登录是指在同一帐号平台下的多个应用系统中,用户只需登录一次,即可访问所有相互信任的应用系统。本质就是在多个应用系统中共享登录状态。举例来说,百度贴吧和百度地图是百度公司旗下的两个不同的应用系统,如果用户在百度贴吧登录过之后,当他访问百度地图时无需再次登录,那么就说明百度贴吧和百度地图之间实现了单点登录。
上面服务器做了集群,我们不知道登录认证是经过哪个节点服务,因此每个服务器都得有登录认证模块,以后业务拓展,系统可能需要新增其他集群服务,那么新增的每个节点服务也得有登录认证模块,业务模块复用性低,拓展性差。
每个子系统都需要实现自个认证服务,但是它们的Session
或者Token
都是共享的,是可以抽离成一个中央认证服务(CAS
),所有的子系统都通过中央认证服务进行登录、认证。
CAS
是Central Authentication Service
的缩写,中央认证服务,一种独立开放指令协议。CAS
是 Yale
大学发起的一个开源项目,旨在为 Web
应用系统提供一种可靠的单点登录方法。CAS
包含两个部分: CAS Server
和 CAS Client
。
- CAS Server负责验证用户并授予访问应用程序
- CAS Client保护
CAS
应用程序和检索CAS
服务器的授权用户的身份。
CAS Client
与受保护的客户端应用部署在一起,以 Filter
方式保护受保护的资源。对于访问受保护资源的每个 Web
请求,CAS Client
会分析该请求的 Http
请求中是否包含 Service Ticket
,如果没有,则说明当前用户尚未登录,于是将请求重定向到指定好的 CAS Server
登录地址,并传递 Service
(也就是要访问的目的资源地址),以便登录成功过后转回该地址。用户在第 3 步中输入认证信息,如果登录成功,CAS Server
随机产生一个相当长度、唯一、不可伪造的 Service Ticket
,并缓存以待将来验证,之后系统自动重定向到 Service
所在地址,并为客户端浏览器设置一个 Ticket Granted Cookie(TGC)
,CAS Client
在拿到 Service
和新产生的 Ticket
过后,在第 5,6 步中与 CAS Server
进行身份核实,以确保 Service Ticket
的合法性。
在该协议中,所有与 CAS
的交互均采用 SSL
协议,确保 ST
和 TGC
的安全性。协议工作过程中会有 2 次重定向的过程,但是 CAS Client
与 CAS Server
之间进行 Ticket
验证的过程对于用户是透明的。
其中有几个关键概念:
- 存储在
CASTGC cookie
中的TGT(Ticket Granting Ticket)
代表用户的SSO
会话,表示用户已经登陆了。 ST
(服务票证)作为url
中的GET
参数传输,代表CAS
服务器授予特定用户对CASified
应用程序的访问权限(也就是表示用户有没有权限访问应用服务器)。- 应用服务器再验证通过用户有权限访问(验证
ST
)时,会下发一个Session
,每个系统的应用服务器都会下发和存储自己的Seesion
,表示用户可以请求应用服务器。
另外,CAS
协议中还提供了 Proxy
(代理)模式,以适应更加高级、复杂的应用场景,具体介绍可以参考 CAS 官方网站上的相关文档。
Cookie跨域问题
CAS Server
下发的SSO SeesionId
会存储到客户端Cookie
中,用户在访问不同应用系统时需要携带Cookie
,但是不同应用系统的域名可能会不同,因此存在Cookie
跨域问题。每个Cookie
都对应着一个domain
,网站A
域名下的请求只会携带该域名中的Cookie
,这时用网站A
登录用户访问访问网站B
时请求不会携带网站A
域名中的Cookie
,存在跨域的问题,解决方案:
- 设置网站的二级域名使
Cookie
在不同站点共享; - 同服务器上部署多个网站,用路由区分站点的访问路径;
- 如果两个站点既没有设置相同的二级域名,也不是部署在统一服务器上,比如站点
A
(https://zhuanlan.com
)与站点B
(https://live.com
),可以使用浏览器localStorage
来存储,然后在前端请求拦截器中携带自定义请求头(提前是后端需要配置接纳该请求头)
// 二级域名
{
https://zhuanlan.zhihu.com/: 网站A // 设置网站A中Cookie domain为二级域名zhihu.com
https://live.zhihu.com/: 网站B // 设置网站B中Cookie domain为二级域名zhihu.com
}
// 路由
{
https://zhihu.com/zhuanlan/#/xxx: 网站A // 网站A中Cookie domain为zhihu.com
https://zhihu.com/live/#/xxx: 网站B // 网站B中Cookie domain为zhihu.com
}
服务器Session存在的问题
虽然我们使用服务器Session
的方式完成了登录验证,但仍然存在一些问题:
- 由于服务端需要对接大量的客户端,也就需要存放大量的
Session
,这样会导致服务器压力过大。 - 如果服务端是一个集群,需要使用
Seesion
复制同步登录态,将Session
同步到每一台机器上,或者存储在Redis
服务器中,无形中增加了服务端维护成本。
客户端Token
Session
的维护给服务端造成很大困扰,我们必须找地方存放它,又要考虑分布式的问题,甚至要单独为了它启用一套 Redis
集群。有没有更好的办法?为了解决服务器Session
机制暴露出的诸多问题,我们可以使用 Token
的登录方式。
单系统单服务
用户首次登录时:
- 用户输入账号密码,并点击登录。
- 服务器端验证账号密码无误,创建
Token
。 - 服务器端将
Token
返回给客户端,由客户端自由保存。
后续页面访问时:
- 用户访问
a.com/pageB
时,带上第一次登录时获取的Token
。 - 服务器端验证
Token
,有效则身份验证成功。
Token 机制的特点
根据上面的案例,我们可以分析出 Token
的优缺点:
- 服务器端不需要存放
Token
,所以不会对服务器端造成压力,即使是服务器集群,也不需要增加维护成本。 Token
可以存放在前端任何地方,可以不用保存在Cookie
中,提升了页面的安全性。Token
下发之后,只要在生效时间之内,就一直有效,如果服务器端想收回此Token
的权限,并不容易。
Token怎么生成
实施 Token
验证的方法挺多的,还有一些标准方法,比如 JWT
,读作:jot
,表示:JSON Web Tokens
。JWT
标准的 Token
有三个部分:
header
(头部)payload
(数据)signature
(签名)
中间用点分隔开,并且都会使用 Base64
编码,所以真正的 Token
看起来像这样:
eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJuaW5naGFvLm5ldCIsImV4cCI6IjE0Mzg5NTU0NDUiLCJuYW1lIjoid2FuZ2hhbyIsImFkbWluIjp0cnVlfQ.SwyHTEx_RQppr97g4J5lKXtabJecpejuef8AqKYMAJc
Header
每个 JWT token
里面都有一个 header
,也就是头部数据。里面包含了使用的算法,这个 JWT
是不是带签名的或者加密的。主要就是说明一下怎么处理这个 JWT token
。
头部里包含的东西可能会根据 JWT
的类型有所变化,比如一个加密的 JWT
里面要包含使用的加密的算法。唯一在头部里面要包含的是 alg 这个属性,如果是加密的 JWT
,这个属性的值就是使用的签名或者解密用的算法。如果是未加密的 JWT
,这个属性的值要设置成 none
。
形如:
{
"alg": "HS256",
"typ": "JWT"
}
意思是这个 JWT
用的算法是 HS256
。上面的内容得用 base64url
的形式编码一下,所以就变成这样:
eyJhbGciOiJIUzI1NiJ9
这部分JSON
经过Base64
编码后形成Token
的第一部分。
Payload
Payload
里面是 Token
的具体内容,这些内容里面有一些是标准字段,你也可以添加其它需要的内容。下面是标准字段:
iss
:Issuer
,发行者sub
:Subject
,主题aud
:Audience
,观众exp
:Expiration time
,过期时间nbf
:Not before
iat
:Issued at
,发行时间jti
:JWT ID
比如下面这个 Payload
,用到了 iss
发行人,还有 exp
过期时间这两个标准字段。另外还有两个自定义的字段,一个是 name
,还有一个是 admin
。
{
"iss": "ninghao.net",
"exp": "1438955445",
"name": "wanghao",
"admin": true
}
使用 base64url
编码以后就变成了这个样子:
eyJpc3MiOiJuaW5naGFvLm5ldCIsImV4cCI6IjE0Mzg5NTU0NDUiLCJuYW1lIjoid2FuZ2hhbyIsImFkbWluIjp0cnVlfQ
Signature
JWT
的最后一部分是 Signature
,这部分内容有三个部分,先是用 Base64
编码的 header.payload
,再用加密算法加密一下,加密的时候要放进去一个 Secret
,这个相当于是一个密码,这个密码秘密地存储在服务端。
header
payload
secret
const encodedString = base64UrlEncode(header) + "." + base64UrlEncode(payload);
HMACSHA256(encodedString, 'secret');
处理完成以后看起来像这样:
SwyHTEx_RQppr97g4J5lKXtabJecpejuef8AqKYMAJc
最后这个在服务端生成并且要发送给客户端的 Token
看起来像这样:
eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJuaW5naGFvLm5ldCIsImV4cCI6IjE0Mzg5NTU0NDUiLCJuYW1lIjoid2FuZ2hhbyIsImFkbWluIjp0cnVlfQ.SwyHTEx_RQppr97g4J5lKXtabJecpejuef8AqKYMAJc
客户端收到这个 Token
以后把它存储下来,下回向服务端发送请求的时候就带着这个 Token
。服务端收到这个 Token
,然后进行验证,通过以后就会返回给客户端想要的资源。
签发与验证 JWT
在应用里实施使用基于 JWT
这种 Token
的身份验证方法,你可以先去找一个签发与验证 JWT
的功能包。无论你的后端应用使用的是什么样的程序语言,系统,或者框架,你应该都可以找到提供类似功能的包。
签发 JWT
在项目里随便添加一个 .js
文件,比如 index.js
,在文件里添加下面这些代码:
const jwt = require('jsonwebtoken')
// Token 数据
const payload = {
name: 'wanghao',
admin: true
}
// 密钥
const secret = 'ILOVENINGHAO'
// 签发 Token
const token = jwt.sign(payload, secret, { expiresIn: '1day' })
// 输出签发的 Token
console.log(token)
非常简单,就是用了 jsonwebtoken
里面提供的 jwt.sign
功能,去签发一个 token
。这个 sign
方法需要三个参数:
playload
:签发的token
里面要包含的一些数据。secret
:签发token
用的密钥,在验证token
的时候同样需要用到这个密钥。options
:一些其它的选项。
验证 JWT
验证 JWT
的用效性,确定一下用户的 JWT
是我们自己签发的,首先要得到用户的这个 JWT Token
,然后用 jwt.verify
这个方法去做一下验证,如果不是该自己Secret
签发的Token
,校验会不通过。这个方法是 Node.js
的 jsonwebtoken
这个包里提供的,在其它的应用框架或者系统里,你可能会找到类似的方法来验证 JWT
。
// 验证 Token
jwt.verify(token, 'secret', (error, decoded) => {
if (error) {
console.log(error.message)
return
}
console.log(decoded)
})
单系统服务集群
单系统服务集群时对基于Token
的登录认证不会带来影响,因为Token
是存储在客户端。但是我们一般都会将Map<Token, UserInfo>
(Token
与用户信息的映射关系)存储到Redis
中,避免每次都需要查表获取用户信息。
基于Token单点登录
使用Token可能遇到哪些问题呢?
Token能不能被盗?
Token
在https
协议中是不容易被盗取的,https
中的SSL
协议会对请求头和请求体进行加密,也就是说传输给服务端的Cookie
是经过加密的,请求被拦截是不容易解密的。当然,如果你的电脑安装了有些植入木马的恶意程序,窃取你的账户和令牌,这就没办法了,所以还是倡导健康上网,拒绝盗版软件。- 密钥直接放在服务器中被开发人员窃取了怎么办?
防火防盗却防不住自家人怎么办?密钥一般都不会直接放在项目中,可能会放在文件系统或者github
上,而且不同环境有着不同的密钥。为了防止开发人员读取到密钥后进行打印,可以加上代码审查环节,提交上去的代码会自动扫描,如果发现打印密钥的关键代码,果断开除员工。另外,密钥也需要不定时更换。 - Token如何续期?
令牌的有效期到了,如果让用户重新走一遍上面的流程,再申请一个新的令牌,很可能体验不好,而且也没有必要。具体方法是,服务端颁发令牌的时候,一次性颁发两个令牌,一个用于获取数据,另一个用于获取新的令牌(refresh token
字段)。令牌到期前(前端倒计时判断过期或者请求发现登录异常),用户使用refresh token
发一个请求,去更新令牌。
oAuth2.0
在传统的客户端-服务器身份验证模式中,客户端请求服务器上访问受限的资源(受保护的资源)时,需要使用资源所有者的凭据在服务器上进行身份验证。资源所有者为了给第三方应用提供受限资源的访问权限,需要与第三方共享它的凭据。这就导致一些问题和局限:
- 第三方应用需要存储资源所有者的凭据以供将来使用。该凭据通常是明文密码。
- 服务器需要支持密码身份认证,尽管密码认证有固有的安全缺陷。
- 第三方应用获得了对资源所有者的受保护资源的过于宽泛的访问权限,从而导致资源所有者不能限制对资源的有限子集的访问时限或权限。
- 资源所有者不能撤销某个第三方的访问权限而不影响其它第三方,并且必须更改他们的密码才能做到。
- 与任何第三方应用的妥协导致对终端用户的密码及该密码所保护的所有数据的妥协。
OAuth通过引入授权层以及从资源所有者角色分离出客户端角色来解决这些问题。详细说就是,OAuth
在"客户端"与"服务提供商"之间,设置了一个授权层(authorization layer
)。"客户端"不能直接登录"服务提供商",只能登录授权层,以此将用户与客户端区分开来。"客户端"登录授权层所用的令牌(token
),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。"客户端"登录授权层以后,"服务提供商"根据令牌的权限范围和有效期,向"客户端"开放用户储存的资料。
CAS和OAuth2区别
CAS
的单点登录时保障客户端的用户资源的安全,OAuth2
则是保障服务端的用户资源的安全;CAS
客户端要获取的最终信息是,这个用户到底有没有权限访问我(CAS
客户端)的资源;oauth2
获取的最终信息是,我(oauth2
服务提供方)的用户的资源到底能不能让你(oauth2
的客户端)访问;CAS
的单点登录,资源都在客户端这边,不在CAS
的服务器那一方。用户在给CAS
服务端提供了用户名密码后,作为CAS
客户端并不知道这件事。随便给客户端个ST
,那么客户端是不能确定这个ST
是用户伪造还是真的有效,所以要拿着这个ST
去服务端再问一下,这个用户给我的是有效的ST
还是无效的ST
,是有效的我才能让这个用户访问。OAuth2
认证,资源都在OAuth2
服务提供者那一方,客户端是想索取用户的资源。所以在最安全的模式下,用户授权之后,服务端并不能直接返回token
,通过重定向送给客户端,因为这个token
有可能被黑客截获,如果黑客截获了这个token
,那用户的资源也就暴露在这个黑客之下了。于是聪明的服务端发送了一个认证code
给客户端(通过重定向),客户端在后台,通过https
的方式,用这个code
,以及另一串客户端和服务端预先商量好的密码,才能获取到token
和刷新token
,这个过程是非常安全的。如果黑客截获了code
,他没有那串预先商量好的密码,他也是无法获取token
的。这样oauth2
就能保证请求资源这件事,是用户同意的,客户端也是被认可的,可以放心的把资源发给这个客户端了。CAS
登录和OAuth2
在流程上的最大区别就是,通过ST
或者code
去认证的时候,需不需要预先商量好的密码。
OAuth定义了四种角色:
- 资源所有者
能够许可对受保护资源的访问权限的实体。当资源所有者是个人时,它被称为最终用户。 - 资源服务器
托管受保护资源的服务器,能够接收和响应使用访问令牌对受保护资源的请求。 - 客户端
使用资源所有者的授权代表资源所有者发起对受保护资源的请求的应用程序。术语“客户端”并非特指任何特定的的实现特点(例如:应用程序是否是在服务器、台式机或其他设备上执行)。 - 授权服务器
在成功验证资源所有者且获得授权后颁发访问令牌给客户端的服务器。
授权服务器和资源服务器之间的交互超出了本规范的范围。授权服务器可以和资源服务器是同一台服务器,也可以是分离的个体。一个授权服务器可以颁发被多个资源服务器接受的访问令牌。
抽象的协议流程
+--------+ +---------------+
| |--(A)- Authorization Request ->| Resource |
| | | Owner |
| |<-(B)-- Authorization Grant ---| |
| | +---------------+
| |
| | +---------------+
| |--(C)-- Authorization Grant -->| Authorization |
| Client | | Server |
| |<-(D)----- Access Token -------| |
| | +---------------+
| |
| | +---------------+
| |--(E)----- Access Token ------>| Resource |
| | | Server |
| |<-(F)--- Protected Resource ---| |
+--------+ +---------------+
图中所示的抽象的OAuth 2.0
流程描述了四种角色之间的交互,包括以下步骤:
(A)客户端从资源所有者处请求授权。授权请求可以直接向资源所有者发起(如图所示),或者更可取的是通过授权服务器作为中介间接发起。
(B)客户端收到授权许可,这是一个代表资源所有者的授权的凭据,使用本规范中定义的四种许可类型之一或者使用扩展许可类型表示。授权许可类型取决于客户端请求授权所使用的方法以及授权服务器支持的类型。
(C)客户端与授权服务器进行身份认证并出示授权许可以请求访问令牌。
(D)授权服务器验证客户端身份并验证授权许可,若有效则颁发访问令牌。
(E)客户端从资源服务器请求受保护资源并出示访问令牌进行身份验证。
(F)资源服务器验证访问令牌,若有效则处理该请求。
客户端从资源所有者获得授权许可(步骤(A)和(B)所示)的更好方法是使用授权服务器作为中介
授权许可是一个代表资源所有者授权(访问受保护资源)的凭据,客户端用它来获取访问令牌。RFC 6749
规范定义了四种许可类型——授权码、隐式许可、资源所有者密码凭据和客户端凭据——以及用于定义其他类型的可扩展性机制。
以github授权为例讲述授权码类型
首先需要到github
上进行应用注册,注册完后GitHub
会返回客户端 ID(client_id)
和客户端密钥(client_secret
)
客户端前端台以Session
方式认证:
客户端前端台以Token
方式认证:
参考:
基于 Token 的身份验证:JSON Web Token(附:Node.js 项目)
CAS
Seesion复制
阮一峰OAuth 2.0 的四种方式
RFC 6749中文版
RFC 6749-OAuth 2.0 授权框架简体中文翻译
github OAuth documentation