从安全角度下看用户密码管理
2024-06-15
用户密码是一个烫手的山芋,如何处理这个东西确实很费脑筋。当用户在你的产品注册时,他的密码你就有责任保护好,但是,用户每次登录时都会用到密码,此时密码这个危险的东西就会在客户端、网络、服务器三方之间流动,我们怎么能保证密码在频繁的传递中不会被泄露呢?
我们在设计用户注册登录系统时,有几个基本共识是要有的:
- 一定要使用HTTPS来保证传输安全,不要为了省事或者通过自己所谓的其他安全手段保证传输安全,HTTPS是最好的方案。
- 密码不能明文传输,无论是在客户端、网络还是服务器,都不能出现明文密码。比如用户密码明文写入数据库,写入日志等操作绝对不允许。
- 尽可能约束用户设置的密码强度,比如123456这种密码就禁止设置,一般都要求数字字母混合的组合,且密码长度不能过短。
用户密码面临的风险有哪些?我们梳理一下:
- 客户端被植入了病毒,用户输入密码时就被窃取了
- 网络传输时被恶意抓包窃听
- 服务器面临被拖库的风险,同时内部人员也有可能看到密码数据库
网络传输安全我们通过HTTPS来保证,客户端和服务端的安全,我们通过加密来保证。这里介绍一个普通安全强度的系统如何将密码从客户端传输到服务端,然后存储到数据库的全过程。
这里的普通安全强度是指,即使用户使用了弱密码、客户端被监听、服务端被拖库、泄露了存储的密文和盐值等问题同时发生,也能最大限度地避免用户明文密码被逆推出来。
首先是用户注册的过程:
1)用户在客户端注册,输入明文密码:123456
`password = 123456`
2) 客户端对password进行简单哈希摘要计算,比如这里选择了常用的MD5。大家都知道MD5做密码哈希容易被彩虹表撞库,所以一般还会加盐来做哈希。这里盐怎么设计呢?盐可以分为动态盐和固定盐,动态盐就是去请求一次服务器,拿到服务器生成的随机盐。固定盐就是一个固定不变的字符串,每个用户都一样。其实还有一种叫伪动态盐,指服务器不需要额外通信就能得到的信息,比如由用户名+一些自定义前缀组合成的字符串,比如用户名为lijunshi,我们伪动态盐就设置为myapp-lijunshi,这也能做到每个用户的盐不一样。通过这一步,我们将简单的密码转换为复杂的字符串了。
client_hash = MD5(MD5(password) + salt)
-
第二步从安全角度来看是有问题的,因为碰撞成本太低了。假设服务端没有做碰撞次数的限制,那么客户端做暴力破解的成本也很低,这是因为MD5计算太快了,客户端可以在短时间内碰撞海量密码。针对这个情况,更优的方式是让客户端计算成本变高,增加计算每个密码哈希的时间,这里引入慢哈希函数来解决这个问题。BCrypt是常用的慢哈希函数,假设在之前MD5是0.01ms一次计算,那BCrypt就是100ms一次计算,拖慢客户端计算哈希的时间。
client_hash = BCrypt(MD5(password) + salt)
-
接下来客户端计算好的哈希值传输通过HTTPS传输到服务端,服务端开始存储账密。服务端需要考虑防范两个风险:
- 外部风险,服务器万一被拖库了,怎么保证用户的明文密码不泄露?
- 内部风险,账密数据库表被内部人员看到了里面的数据,怎么保证他们也不能从数据表里反推出用户密码?
其实这内外风险本质都是一样的,就是数据库数据暴露了也不会逆推出用户密码。
在这阶段,服务器可以为每一个密码生成一个随机盐值。
server_salt = GetRandomSalt()
-
将服务器动态盐值混入客户端传来的哈希值再做一次哈希,产生最终的密文,并和上一步随机生成的盐值一起写入数据库。
server_hash = SHA256(client_hash + sever_salt)
DB.Save(server_hash, server_salt)
之前看到有的团队在第五步做哈希时,依旧采用了慢哈希BCrypt,这里真的大可不必。之前在压测一些登录接口时,发现一个登录接口居然把CPU吃满,并发量一直上不出,后面排查就是发现服务器自己用了慢哈希BCrypt自己把自己拖垮,服务器调用BCrypt给服务器带来太多压力了,所以用常用的SHA256就好。
登录步骤:
1)客户端输入明文密码
password = 123456
经过哈希
client_hash = BCrypt(MD5(password) + salt)
2)服务端收到传上来的哈希值,从数据库中取出登录用户对应的密文哈希server_hash以及盐值sever_salt,采用相同的哈希算法,对客户端传来的哈希值、服务端存储的盐值计算摘要结果。
`result = SHA256(client_hash + sever_salt)`
3) 比较上一步结果跟数据库存储的哈希值是否一致,相同说明密码正确,反之说明密码错误。
`ok = compare(result, server_hash)`
回顾上面的过程, 运算压力最大的过程(慢哈希)是在客户端进行,对服务端压力很小,也不惧怕因网络通信被截取而导致明文密码泄露。 最后是对我们设计的这个注册登录系统的安全性进行推敲,假设我们的用户密码数据库泄露了,最终会被逆推出用户明文密码吗?
换个问法,我给了你一个用户的server_hash和server_salt,你也知道客户端和服务端的处理登录的流程和使用的算法,你可以成功登录用户账号吗?
因为 server_hash = SHA256(client_hash + sever_salt)
,首先我们要逆推出client_hash,所以要不断碰撞。client_hash可不是简单的1234,这是个32位的复杂字符串,要碰撞出来很难的。即使碰撞出了正确的client_hash,接下来还得碰撞出password。因为client_hash = BCrypt(MD5(password) + salt)
,慢哈希导致了客户端计算成本非常高,要短时间碰撞出来password并不可能。