如何存储用户的密码才能算安全?

缘起

前段时间将一个集成了 spring-security-oauth2 的旧项目改造了一番,将 springboot 升级成了 springboot 2.0,众所周知 springboot 2.0 依赖的是 spring5,并且许多相关的依赖都发生了较大的改动,与本文相关的改动罗列如下,有兴趣的同学可以参考:Spring Security 5.0 New Features 。其中一个比较有意思的改动---- Spring Security 5.0 重构了密码编码器的实现(Password Encoding,由于大多数 PasswordEncoder 相关的算法是 hash 算法,所以本文将 PasswordEncoder 翻译成‘密码编码器’和并非‘密码加密器’),引起了我兴趣官方称之为

Modernized Password Encoding — 现代化的密码编码方式

另外,springboot2.0 的自动配置也做了一些调整,其中也有几点和 spring-security 相关,戳这里看所有细节 springboot2.0 迁移指南

一开始,我仅仅修改了依赖,将

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>1.5.4.RELEASE</version>
</parent>

升级成了

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.0.1.RELEASE</version>
</parent>

不出意料出现了兼容性的问题,我在尝试登陆时,出现了如下的报错

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

原因也很明显,正如 spring security 的更新文档中描述的那样,spring security 5 对 PasswordEncoder 做了相关的重构,原先默认配置的 PlainTextPasswordEncoder(明文密码)被移除了。随即我产生了疑问,spring security 在新版本中对于 passwordEncoder 进行了哪些改造,这些改造背后又是出于什么样的目的呢?卖个关子,先从远古时期的案例来一步步演化出所谓的“现代化密码编码方式”。

密码存储演进史

自从互联网有了用户的那一刻起,存储用户密码这件事便成为了一个健全的系统不得不面对的一件事。远古时期,明文存储密码可能还不被认为是一个很大的系统缺陷(事实上这是一件很恐怖的事)。提及明文存储密码,我立刻联想到的是 CSDN 社区在 2011 年末发生的 600 万用户密码泄露的事件,谁也不会想到这个和程序员密切相关的网站会犯如此低级的错误。明文存储密码使得恶意用户可以通过 sql 注入等攻击方式来获取用户名和密码,虽然安全框架和良好的编码规范可以规避很多类似的攻击,但依旧避免不了系统管理员,DBA 有途径获取用户密码这一事实。事实上,不用明文存储存储密码,程序员们早在 n 多年前就已经达成了共识。

不能明文存储,一些 hash 算法便被广泛用做密码的编码器,对密码进行单向 hash 处理后存储数据库,当用户登录时,计算用户输入的密码的 hash 值,将两者进行比对。单向 hash 算法,顾名思义,它无法(或者用不能轻易更为合适)被反向解析还原出原密码。这杜绝了管理员直接获取密码的途径,可仅仅依赖于普通的 hash 算法(如 md5,sha256)是不合适的,他主要有 3 个特点:

  1. 同一密码生成的 hash 值一定相同
  2. 不同密码的生成的 hash 值可能相同(md5 的碰撞问题相比 sha256 还要严重)
  3. 计算速度快。

以上三点结合在一起,破解此类算法成了不是那么困难的一件事,尤其是第三点,会在下文中再次提到,多快才算非常快?按照相关资料的说法:

modern hardware perform billions of hash calculations a second.

考虑到大多数用户使用的密码多为数字+字母+特殊符号的组合,攻击者将常用的密码进行枚举,甚至通过排列组合来暴力破解,这被称为 rainbow table。算法爱好者能够立刻看懂到上述的方案,这被亲切地称之为—打表,一种暴力美学,这张表是可以被复用的。

虽然仅仅依赖于传统 hash 算法的思路被否决了,但这种 hash 后比对的思路,几乎被后续所有的优化方案继承。

hash 方案迎来的第一个改造是对引入一个“随机的因子”来掺杂进明文中进行 hash 计算,这样的随机因子通常被称之为盐 (salt)。salt 一般是用户相关的,每个用户持有各自的 salt。此时狗蛋和二丫的密码即使相同,由于 salt 的影响,存储在数据库中的密码也是不同的,除非…为每个用户单独建议一张 rainbow table。很明显 salted hash 相比普通的单向 hash 方案加大了 hacker 攻击的难度。但了解过 GPU 并行计算能力之强大的童鞋,都能够意识到,虽然破解 salted hash 比较麻烦,却并非不可行,勤劳勇敢的安全专家似乎也对这个方案不够满意。

为解决上述 salted hash 仍然存在的问题,一些新型的单向 hash 算法被研究了出来。其中就包括:Bcrypt,PBKDF2,Scrypt,Argon2。为什么这些 hash 算法能保证密码存储的安全性?因为他们足够慢,恰到好处的慢。这么说不严谨,只是为了给大家留个深刻的映像:慢。这类算法有一个特点,存在一个影响因子,可以用来控制计算强度,这直接决定了破解密码所需要的资源和时间,直观的体会可以见下图,在一年内破解如下算法所需要的硬件资源花费(折算成美元)

一年内破解如下算法所需要的硬件资源花费

这使得破解成了一件极其困难的事,并且,其中的计算强度因子是可控的,这样,即使未来量子计算机的计算能力爆表,也可以通过其控制计算强度以防破解。注意,普通的验证过程只需要计算一次 hash 计算,使用此类 hash 算法并不会影响到用户体验。

慢 hash 算法真的安全吗?

Bcrypt,Scrypt,PBKDF2 这些慢 hash 算法是目前最为推崇的 password encoding 方式,好奇心驱使我思考了这样一个问题:慢 hash 算法真的安全吗?

我暂时还没有精力仔细去研究他们中每一个算法的具体实现,只能通过一些文章来拾人牙慧,简单看看这几个算法的原理和安全性。

PBKDF2 被设计的很简单,它的基本原理是通过一个伪随机函数(例如 HMAC 函数),把明文和一个盐值作为输入参数,然后按照设置的计算强度因子重复进行运算,并最终产生密钥。这样的重复 hash 已经被认为足够安全,但也有人提出了不同意见,此类算法对于传统的 CPU 来说的确是足够安全,但 GPU 被搬了出来,前文提到过 GPU 的并行计算能力非常强大。

Bcrypt 强大的一点在于,其不仅仅是 CPU 密集型,还是 RAM 密集型!双重的限制因素,导致 GPU,ASIC(专用集成电路)无法应对 Bcrypt 带来的破解困境。

然后…看了 Scrypt 的相关资料之后我才意识到这个坑有多深。一个熟悉又陌生的词出现在了我面前:FPGA(现场可编程逻辑门阵列),这货就比较厉害了。现成的芯片指令结构如传统的 CPU,GPU,ASIC 都无法破解 Bcrypt,但是 FPGA 支持烧录逻辑门(如AND、OR、XOR、NOT),通过编程的方式烧录指令集的这一特性使得可以定制硬件来破解 Bcrypt。尽管我不认为懂这个技术的人会去想办法破解真正的系统,但,只要这是一个可能性,就总有方法会被发明出来与之对抗。Scrypt 比 Bcrypt 额外考虑到的就是大规模的自定义硬件攻击 ,从而刻意设计需要大量内存运算。

理论终归是理论,实际上 Bcrypt 算法被发明至今 18 年,使用范围广,且从未因为安全问题而被修改,其有限性是已经被验证过的,相比之下 Scrypt 据我看到的文章显示是 9 年的历史,没有 Bcrypt 使用的广泛。从破解成本和权威性的角度来看,Bcrypt 用作密码编码器是不错的选择。

spring security 废弃的接口

回到文档中,spring security 5 对 PasswordEncoder 做了相关的重构,原先默认配置的 PlainTextPasswordEncoder(明文密码)被移除了,想要做到明文存储密码,只能使用一个过期的类来过渡

@Bean
PasswordEncoder passwordEncoder(){
    return NoOpPasswordEncoder.getInstance();
}

实际上,spring security 提供了 BCryptPasswordEncoder 来进行密码编码,并作为了相关配置的默认配置,只不过没有暴露为全局的 Bean。使用明文存储的风险在文章一开始就已经强调过,NoOpPasswordEncoder 只能存在于 demo 中。

@Bean
PasswordEncoder passwordEncoder(){
    return new BCryptPasswordEncoder();
}

别忘了对你数据库中的密码进行同样的编码,否则无法对应。

更深层的思考

实际上,spring security 5 的另一个设计是促使我写成本文的初衷。

不知道有没有读者产生跟我相同的困扰:

  1. 如果我要设计一个 QPS 很高的登录系统,使用 spring security 推荐的 BCrypt 会不会存在性能问题?
  2. spring security 怎么这么坑,原来的密码编码器都给改了,我需要怎么迁移旧密码编码的应用程序?
  3. 万一以后出了更高效的加密算法,这种笨重的硬编码方式配置密码编码器是不是不够灵活?

在 spring security 5 提供了这样一个思路,应该将密码编码之后的 hash 值和加密方式一起存储,并提供了一个 DelegatingPasswordEncoder 来作为众多密码密码编码方式的集合。

@Bean
PasswordEncoder passwordEncoder(){
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

负责生产 DelegatingPasswordEncoder 的工厂方法:

public class PasswordEncoderFactories {

   public static PasswordEncoder createDelegatingPasswordEncoder() {
      String encodingId = "bcrypt";
      Map<String, PasswordEncoder> encoders = new HashMap<>();
      encoders.put(encodingId, new BCryptPasswordEncoder());
      encoders.put("ldap", new LdapShaPasswordEncoder());
      encoders.put("MD4", new Md4PasswordEncoder());
      encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
      encoders.put("noop", NoOpPasswordEncoder.getInstance());
      encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
      encoders.put("scrypt", new SCryptPasswordEncoder());
      encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
      encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
      encoders.put("sha256", new StandardPasswordEncoder());

      return new DelegatingPasswordEncoder(encodingId, encoders);
   }

   private PasswordEncoderFactories() {}
}

如此注入 PasswordEncoder 之后,我们在数据库中需要这么存储数据:

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG 
{noop}password 
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc 
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=  
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0

还记得文章开始的报错吗?

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"

这个 id 就是因为我们没有为数据库中的密码添加 {bcrypt} 此类的前缀导致的。

你会不会担心密码泄露后,{bcrypt},{pbkdf2},{scrypt},{sha256} 此类前缀会直接暴露密码的编码方式?其实这个考虑是多余的,因为密码存储的依赖算法并不是一个秘密。大多数能搞到你密码的 hacker 都可以轻松的知道你用的是什么算法,例如,bcrypt 算法通常以 \2a 开头

稍微思考下,前面的三个疑问就可以迎刃而解,这就是文档中所谓的:能够自适应服务器性能的现代化密码编码方案

参考

Password Hashing: PBKDF2, Scrypt, Bcrypt

core-services-password-encoding

show me the code

spring security oauth2 的 github 代码示例,体会下 spring security 4 -> spring security 5 的相关变化。

https://github.com/lexburner/oauth2-demo

本文分享自微信公众号 - Kirito的技术分享(cnkirito)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2018-04-24

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏腾讯Bugly的专栏

iOS 9.1 深坑体验及其破解之道

众所周知,苹果iOS 9的推新速度已经打破了纪录,9.1刚刚于上周推出后,昨天,9.2 beta1已经出来了。 那么,到底iOS9都有哪些坑?网上能够搜索到的那...

29650
来自专栏FreeBuf

使用Kettle模型清洗全国弱口令Top 1000

前言 几天前,我在FreeTalk北京站演讲了《数据清洗在网络安全中的应用》,由于时间关系,很多内容并没有讲到,会议结束后很多人也私信问我很多问题。其实在这个信...

58480
来自专栏程序员的知识天地

Python爬虫入门,8个常用爬虫技巧盘点

编程对于任何一个新手来说都不是一件容易的事情,Python对于任何一个想学习的编程的人来说的确是一个福音,阅读Python代码像是在阅读文章,源于Python语...

20710
来自专栏大数据和云计算技术

HBase多模的机遇与挑战

首先提前祝大家中秋快乐,今天我们分享的文章来自云栖大会嘉宾:阿里云专家 封神的分享

19230
来自专栏牛客网

BAT面经

因为也许我当时因为要实现梦想只有一条途径,可如果你选择了一条路,这并不意味着你要放弃其他的方式。——《跳出我天地》

20530
来自专栏CDA数据分析师

强大的图形功能软件:S-Plus

【摘要】S语言是由AT&T 贝尔实验室开发的一种用来进行数据探索、统计分析、作图的解释型语言。它的丰富的数据类型(向量、数组、列表、对象等)特别有利于实现新的统...

265100
来自专栏ThoughtWorks

Serverless实战:打造个人阅读追踪系统

阅读习惯和个人知识管理体系 进入互联网时代,知识的获取成本变得前所未有的低廉,但是无论再好的知识,若是没有对个人产生价值的话,那也只不过是一种信息噪音而已。 我...

52860
来自专栏腾讯Bugly的专栏

关于 Android N 那些你不知道的事儿

今年3月,Google 破天荒提前半年发布了 Android N 开发者预览版。当然,作为一个不合格的谷粉并没有第一时间体验安装,因为至今仍然能够回忆起来去年今...

36560
来自专栏程序人生

上帝说:要有一门面向未来的语言,于是有了 erlang

今个谈谈 erlang。 这些文章流于表面,更多是简单的介绍。这篇文章不同,因为 erlang 并不是一门新语言,简单介绍它的人不算少,我希望这篇文章能在深度上...

436110
来自专栏Golang语言社区

一个调度系统的开发与性能优化

背景:随着Go的不断发展,流行度越来越高,业界对Go的认可度也越来越高,所以很多团队或者公司在遇到性能问题时都会尝试使用Go来重构系统,尤其是云计算领域,大家期...

21310

扫码关注云+社区

领取腾讯云代金券