【长篇】登录认证、权限控制思路整理
在我论坛网站中,登录是第一个做的功能,可做的很简单,只是在河水两岸扔了块软绵绵的木板,以供临时的通行。登录认证、权限控制,这两个服务是隐私的入口,需要格外的注意。现在,社区爬取到了很多的用户数据,我觉得是时候对SSO、JWT这方面的知识做个总结了。
我将在这份md中,边写登录认证服务的代码,边记录与总结自己的思考和体会,之后会整理思路,希望能对读者有所帮助。本文所有实现,都以java为主,框架使用的是SpringBoot。
0. 需求
登录注册显然是必须的,此外在我的社区网站中,站长、版主、管理员、普通成员一共有这4种角色,大概可以这么看:
1 | 站长>管理员>版主>普通成员 |
普通成员拥有发表帖子、查看帖子、回复评论、私信任意用户等权限
版主则拥有版块管理、版内帖子管理、成员发言权管理等权限
管理员自然拥有版主管理、普通成员管理、版块与帖子管理等权限
站长自然拥有所有权限,包括对权限的修改等。
成员登录,从而进入网站大门,而对成员角色的认证,则是网站的一个小门。
依赖
具体实现上,我打算选用Shiro与JWT来完成。
1 | <!--Shiro--> |
选择Shiro是因为其相对轻量,而且shiro这个词我好像经常听到,莫名感觉很亲切。而后者JWT大概是我这份md的主线吧。
1. 密码加密存储
显然,无论如何我们都需要把用户存在数据库中,这个有状态服务是无法避免的。
用户表:Users,字段有邮箱、昵称、密码(加密存储)、身份等。
- 密码以密文的形式存储,用户登录时,将邮箱和密码输入,然后网站大门的系统会根据邮箱查询数据库,将用户提交的密码进行加密运算,将运算后得到值去盐与数据库中的密文进行比对,以确认用户是否合法。
关于hash加密
加密需要使用hash散列函数来计算,比如%取余就是最简单的hash运算,常见的hash算法,像MD5、sha256、sha1,MD5已经被能被人为的制造hash碰撞了,所以不推荐使用。
为什么要使用hash加密呢,因为hash是不可逆的,比如x%3=9你无法确定x的值。所以就算得到了数据库中保存的hash值,也难以恢复成原来的密码,而正确的密码取hash后的值,是不变的。
然而,单纯的hash运算后的加密数据,也并不保险,比如彩虹表就能暴力的破解hash。所以又出现了一种更安全的算法加盐hash,它会在hash运算中,带上随机数或字符串,从而增加彩虹表的破解难度
关于浏览器缓存
说个题外话,web服务器在返回文件时会返回一个eTag作为此文件的唯一标识会放在响应的header里,当浏览器发现再次请求的文件的eTag没有改变时,便不会再拉取文件,这是浏览器的缓存机制,可以节省流量。而eTag是怎么来的呢?我曾经以为是对文件取的hash运算,但后来发现并不是这样。eTag是由文件修改时间time与文件大小size进行按位异或运算,即:
1 | //二进制 按位异或运算 |
实现
借助Shiro提供的加密类即可。
1 | package cn.shirtiny.community.SHcommunity.Utils.Encryption; |
- 对应的配置文件
1 | #shiro加密密码使用的加密算法名 这里是SHA256 |
- 重写
Shiro获取用户认证信息的方法
1 | package cn.shirtiny.community.SHcommunity.Shiro; |
- 重写
Shiro密码校验方法
1 | package cn.shirtiny.community.SHcommunity.Shiro; |
2. Https
在上部分中:
密码以密文的形式存储,用户登录时,将邮箱和密码输入,然后网站大门的系统会根据邮箱查询数据库,将用户提交的密码进行加密运算,将运算后得到值去盐与数据库中的密文进行比对,以确认用户是否合法。
这样看起来似乎没什么问题,设想一下,如果有一名黑客,在用户发起登录的Post请求时窃听用户邮箱和密码,这该怎么办呢?应该使用https。对于https,我觉得,需要先从非对称加密说起。
关于非对称加密
最有名的非对称加密算法是RSA,非对称加密会生成一个密钥对,包含公钥和私钥。分为两种情况,私钥加密,使用公钥解密;公钥加密,使用私钥解密。
这样看,应该不是很清楚,我会举几个例子说明这两种情形:
情景一
A要发送Message给B
A需要先询问B的公钥,B将自己的公钥告诉对方
A使用B的公钥加密Message,然后把得到的密文发给B
B使用自己的私钥解密Message,获得明文
这样,第三者即使看完了整个通信过程,也无法知道Message的内容。但是,第三者知晓了B的公钥,他也可以使用B的公钥加密消息,向B发送消息,此时B是无法知道写信人是谁的。所以,公钥加密,使用私钥解密,这种情形无法防止伪造的问题。
情景二
- 依然是A要发送Message给B
- A先把自己公钥发给B
- 然后A使用自己的私钥加密了Message,发送给了B
- B根据A的公钥解密Message,便明确的知道Message确实是来自于A
私钥加密,就像一个人的字迹、签名,能够标识消息的来源。但在这个情形里,Message的内容显然被第三者看的一清二楚。
所以我们发现,只使用非对称加密算法,无法满足我们的需要,所以需要引入对称加密算法。
关于对称加密
对称加密有一个共享密钥PSKPre Shared Key,原数据使用PSK加密后得到密文,密文使用PSK解密后,便会回到明文。使用对称加密,需要事先发信双方都知道PSK的值。推荐使用AES对称加密算法,DES算法已经被攻破。
情景三
- 依然是A要发送Message给B
- A需要先询问B的公钥,B将自己的公钥告诉对方
- A把对称加密的密钥PSK放入Message,使用B的公钥加密后,发送给B
- B使用自己的私钥解密Message,得到PSK
- 然后,双方便可以使用对称加密算法的PSK进行通信
这样就可以防止Message内容的泄漏、修改、伪造了,看上去似乎是个很好的解决方式。
其实,这三种情形,都有一个更大的问题,A并不清楚对方是不是真正的B,B也同样如此。如何理解呢,这里是有个前提,通信是在网络上进行的,而网络上的身份和现实是不绑定的,就像百度搜索xx官网一样,没有一个充分的理由便不能确认它的真实性。此时需要一个中间的机构,比如百度认证的xx官网,我信任百度,便会相信这个xx官网是真正的。那这个组成信任链的中间机构是谁呢?
CA机构
比如Let's Encrypt、DigiCert、赛门铁克等CA机构,就是中间人。CA机构会颁发数字证书给信任的网站,数字证书是公钥和私钥的密钥对,证书由CA机构的私钥签发。浏览器信任CA机构,便会信任CA机构信任的网站,然后当用户访问受信任的网站时,浏览器便会提醒该站是安全可信的。
于是,便可以简单理解为情景四:
情景四
- 依然是A要发送Message给B
- A需要先询问B的CA签名公钥,B将CA签名过的公钥告诉对方
- A进行比对,确认是CA签发的,A信任CA,也就信任了B
- A把对称加密的密钥PSK放入Message,使用B的公钥加密后,发送给B
- B使用自己的私钥解密Message,得到PSK
- 然后,双方便可以使用对称加密算法的PSK进行通信
RSA非对称加密实现
使用java的security包即可。
1 | package cn.shirtiny.community.SHcommunity.Utils.Encryption; |
AES对称加密实现
与非对称加密类似
1 | package cn.shirtiny.community.SHcommunity.Utils.Encryption; |
3. Token
所以,使用Https传输,结合加盐Hash加密,便可以完成大门的系统。那么,还有个问题,小门怎么办,登录的大门进入了,权限控制的小门该如何对已登入用户进行认证。
- 如果,在用户通过小门时,再去通过用户邮箱查一遍他的角色信息呢?
这是不合适的,此时大门系统和小门系统都是能查到所有用户信息的,这会把重要数据损失的责任分散,如果发现用户信息泄漏了,便不知道该对哪个组件追责。而且,这种设计,会加大数据库的压力。
使用Token(令牌)
- 由大门系统发放Token,token有有效期,用户携带的令牌由小门系统认证,这样小门系统便被限制了视野范围。
使用令牌,无需查询用户信息,只需要能确认Token的合法性。但还有个问题,Token存哪呢?小门怎么知道Token是合法的呢?
先不谈把Token存在数据库或Redis里合不合适,先看这个,能把Token存Session里吗?
为什么Session不安全
对于每个用户,服务器都有一块独立内存,怎么标识这些内存所属用户的呢?Http请求之间并没有关联,服务器如果想要标识某个用户,就只能去依赖Cookie,给每个新用户发一个cookie,里面存放一个sessionId,每次用户请求时,就会携带这个cookie,服务器便会根据对应id访问对应内存。
所以说Session依赖于Cookie,而Cookie会被修改和伪造。完全可以把Cookie中的sessionId修改为另外一个用户的id,便可以伪造成别人,进而获得不该有的权限。
还有个问题,随着用户的增多,服务器内存便可能会不足,而且恶意的人可以不断使用空Cookie请求服务器,这会使服务器开辟出大量的无用内存。
最后一个问题,session是有状态的,由于在内存存储了数据,关机重启或宕机,数据便无法找回,新服务器无法承接旧服务器的工作。
为什么不把Token存在数据库或Redis
我们再回头来看Token存储在数据库或Redis中会怎样:
- 负担加重
这个是毋庸置疑的,如果在数据库多了张Token表,我们不止要去频繁的查询,而且还要去维护Token的有效期,显然存在数据库中是不可取的。看到这,您一定会想,存redis里不就完了,访问量高的话,就做性能优化、集群,甚至用消息队列等。并非如此,问题其实并不是出在性能上,而是不合理,尽量避免有状态服务,方便扩展,减少开销。
- 安全隐患
为什么会说有安全隐患呢,因为小门系统通过查询数据库或redis能够拿到任何人的Token,这是不安全的,我们不该让小门系统接触到这些敏感数据,尽量把用户的敏感数据只交给一个系统去负责。
那可以怎么做呢?如何既能避免有状态服务,又能安全的认证token合法性?
数字签名
什么是数字签名呢,其实这个概念在上面说非对称加密的时候提到过。而在这儿的情景,用户在大门系统登录时,由大门系统确认用户的合法性,然后将用户需要传输的信息,比如昵称、权限等信息使用大门系统的私钥与加密算法进行加密,这部分加密的数据就是数字签名。
在签名完成后,把数字签名与昵称、权限等明文信息(通常由Json、Base64编码)一起作为Token,一并交给用户。小门系统对用户进行权限认证时,会使用大门系统的公钥、以及加密时相应对的解密算法对数字签名解密,如果解密后的内容刚好与明文部分吻合,便通过用户合法认证,然后在看明文数据,查看该用户的权限,从而决定是否放行。
有了数字签名系统后,我们只需要维护好大门的私钥,公开大门的公钥,这样,用户如果需要校验令牌是否过期,只需要在本地校验。
这就是完成了简化版的JWTJson Web Token。
4. JWT
这是一个我服务器生成的JWT:
1 | eyJ0eXBlIjoiand0Iiwic2lnbkJ5Ijoic2hpcnRpbnkiLCJhbGciOiJSUzI1NiJ9 |
JWT按
.分为3个部分:head、payload、signature
上面JWT的解析结果为:
1 | { |
其中signature便是上文所说的数字签名,也是JWT解析时主要验证的部分。
用户请求时,可以在Http请求头携带Authorization字段,值为JWT字符串,这样服务器就无需存储用户信息,从而达成无状态服务,方便扩展。比如,JavaScript使用axios发送携带Authorization字段的请求:
1 | axios.post('/shPri/createInvitation', { |
那么,如果要存的话,JWT存哪呢?我们显然可以不用存在服务器上了,所以我们有两个选择:
- LocalStorage
LocalStorage和服务器没有任何关系,JS可以随意的操作LocalStorage,JWT存在这里,会被轻松拿到,安全性不高。
- Cookie
使用带有HttpOnly的cookie时,通过JavaScript无法访问,防范XSS(跨站脚本,会盗取cookie,应该尽量的过滤用户发布的信息,不让其发布敏感html),发送请求时会自动带上cookie。
JWT一旦被颁发,就无法撤回、一直合法、无法对其再做其他操作,所以一定要为JWT设置适当的过期时间。如果必须要实现对JWT的有效性管理,就避不开有状态服务。
JWT实现
使用jjwt包即可,这里结合上文的RSA非对称加密,使用RSA算法签名JWT。
1 | package cn.shirtiny.community.SHcommunity.Utils.JWT; |
- 配置类
1 |
|
- 相关配置文件
1 | Jwt公钥 base64编码的字符串 太长,省略一部分 |
- 如何使用,比如
1 | package cn.shirtiny.community.SHcommunity.Service.ServiceImpl; |
5. Shiro与JWT的整合
Shiro是基于Session做的认证和权限控制,现在要想修改为依赖JWT来做无状态服务,就不需要使用Session了。
- 在Shiro创建Subject时,不启用session
1 | package cn.shirtiny.community.SHcommunity.Shiro; |
- 关闭Shiro的Session存储策略
1 | //不启用session的subject工厂 |
- 自定Shiro拦截器,根据自己的需求来,下面是一个简单实现
1 | package cn.shirtiny.community.SHcommunity.Shiro; |
- 配置拦截器
1 | //过滤器工厂 实例名为shiroFilter 注入上面的管理器securityManager实例 |
shiro整体配置文件
1 | package cn.shirtiny.community.SHcommunity.Config; |