Spring Security 5.0的DelegatingPasswordEncoder详解

本文参考自Spring Security 5.0.4.RELEASE 的官方文档,结合源码介绍了 DelegatingPasswordEncoder,对其工作过程进行分析并解决其中遇到的问题。包括 There is no PasswordEncoder mapped for the id “null” 非法参数异常的正确处理方法。

PasswordEncoder
首先要理解 DelegatingPasswordEncoder 的作用和存在意义,明白官方为什么要使用它来取代原先的 NoOpPasswordEncoder。

DelegatingPasswordEncoder 和 NoOpPasswordEncoder 都是 PasswordEncoder 接口的实现类。根据官方的定义,Spring Security 的 PasswordEncoder 接口用于执行密码的单向转换,以便安全地存储密码。

关于密码存储的演变历史这里我不多做介绍,简单来说就是现在数据库存储的密码基本都是经过编码的,而决定如何编码以及判断未编码的字符序列和编码后的字符串是否匹配就是 PassswordEncoder 的责任。

这里我们可以看一下 PasswordEncoder 接口的源码:

public interface PasswordEncoder {

/**
 * Encode the raw password. Generally, a good encoding algorithm applies a SHA-1 or
 * greater hash combined with an 8-byte or greater randomly generated salt.
 */
String encode(CharSequence rawPassword);

/**
 * Verify the encoded password obtained from storage matches the submitted raw
 * password after it too is encoded. Returns true if the passwords match, false if
 * they do not. The stored password itself is never decoded.
 *
 * @param rawPassword the raw password to encode and match
 * @param encodedPassword the encoded password from storage to compare with
 * @return true if the raw password, after encoding, matches the encoded password from
 * storage
 */
boolean matches(CharSequence rawPassword, String encodedPassword);

}
根据源码,我们可以直观地看到 PassswordEncoder 接口只有两个方法,一个是 String encode(CharSequence rawPassword),用于将字符序列(即原密码)进行编码;另一个方法是 boolean matches(CharSequence rawPassword, String encodedPassword),用于比较字符序列和编码后的密码是否匹配。

理解了 PasswordEncoder 的作用后我们来 Spring Security 5.0 之前默认 PasswordEncoder 实现类 NoOpPasswordEncoder。这个类因为不安全已经被标记为过时了。下面就让我们来看看它是如何地不安全的:

1 NoOpPasswordEncoder
事实上,NoOpPasswordEncoder 就是没有编码的编码器,源码如下:

@Deprecated
public final class NoOpPasswordEncoder implements PasswordEncoder {

public String encode(CharSequence rawPassword) {
    return rawPassword.toString();
}

public boolean matches(CharSequence rawPassword, String encodedPassword) {
    return rawPassword.toString().equals(encodedPassword);
}

/**
 * Get the singleton {@link NoOpPasswordEncoder}.
 */
public static PasswordEncoder getInstance() {
    return INSTANCE;
}

private static final PasswordEncoder INSTANCE = new NoOpPasswordEncoder();

private NoOpPasswordEncoder() {
}

}
可以看到,NoOpPasswordEncoder 的 encode 方法就只是简单地把字符序列转成字符串。也就是说,你输入的密码 ”123456” 存储在数据库里仍然是 ”123456”,这样如果数据库被攻破的话密码就直接泄露了,十分不安全。而且 NoOpPasswordEncoder 也就失去了所谓密码编码器的意义了。

不过正因其十分简单,在 Spring Security 5.0 之前 NoOpPasswordEncoder 是作为默认的密码编码器而存在到,它可以是你没有主动加密时的一个默认选择。

另外,NoOpPasswordEncoder 的实现是一个标准的饿汉单例模式,关于单例模式可以看这一篇文章:单例模式及其4种推荐写法和3类保护手段。

2 DelegatingPasswordEncoder
通过上面的学习我们可以知道,随着安全要求的提高之前的默认密码编码器 NoOpPasswordEncoder 已经被 “不推荐”了,那我们有理由推测现在的默认密码编码器换成了使用某一特定算法的编码器。可是这样便会带来三个问题:

有许多使用旧密码编码的应用程序无法轻松迁移;
密码存储的最佳做法(算法)可能会再次发生变化;
作为一个框架,Spring Security 不能经常发生突变。
简单来说,就是新的密码编码器和旧密码的兼容性、自身的稳健性以及需要一定的可变性(切换到更好的算法)。听起来是不是十分矛盾?那我们就来看看 DelegatingPasswordEncoder 是怎么解决这个问题的。在看解决方法之前先看使用 DelegatingPasswordEncoder 能达到的效果:

1 构造方法
下面我们来看看 DelegatingPasswordEncoder 的构造方法

public DelegatingPasswordEncoder(String idForEncode,
Map<String, PasswordEncoder> idToPasswordEncoder) {
if(idForEncode == null) {
throw new IllegalArgumentException("idForEncode cannot be null");
}
if(!idToPasswordEncoder.containsKey(idForEncode)) {
throw new IllegalArgumentException("idForEncode " + idForEncode + "is not found in idToPasswordEncoder " + idToPasswordEncoder);
}
for(String id : idToPasswordEncoder.keySet()) {
if(id == null) {
continue;
}
if(id.contains(PREFIX)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + PREFIX);
}
if(id.contains(SUFFIX)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + SUFFIX);
}
}
this.idForEncode = idForEncode;
this.passwordEncoderForEncode = idToPasswordEncoder.get(idForEncode);
this.idToPasswordEncoder = new HashMap<>(idToPasswordEncoder);
}
idForEncode 决定密码编码器的类型,idToPasswordEncoder 决定判断匹配时兼容的类型,而且 idToPasswordEncoder 必须包含 idForEncode (不然加密后就无法匹配了)。

围绕这个构造方法通常有如下两种创建思路:

工厂构造
首先是工厂构造。

PasswordEncoder passwordEncoder =
PasswordEncoderFactories.createDelegatingPasswordEncoder();
其具体实现如下:

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);

}
这个可以简单地理解为,遇到新密码 DelegatingPasswordEncoder 会委托给 BCryptPasswordEncoder(encodingId为bcryp*) 进行加密。同时,对历史上使用 ldap、MD4、MD5 等等加密算法的密码认证保持兼容(如果数据库里的密码使用的是MD5算法,那使用matches方法认证仍可以通过,但新密码会使bcrypt进行储存)。十分神奇,原理后面会讲。

定制构造
接下来是定制构造,其实和工厂方法是一样的,一般情况下推荐直接使用工厂方法。这里给一个小例子:

String idForEncode = "bcrypt";
Map encoders = new HashMap<>();
encoders.put(idForEncode, new BCryptPasswordEncoder());
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("sha256", new StandardPasswordEncoder());

PasswordEncoder passwordEncoder =
new DelegatingPasswordEncoder(idForEncode, encoders);
2 密码存储格式
密码的标准存储格式是:

1
{id}encodedPassword
其中,id 标识使用 PaswordEncoder 的种类,encodedPassword 是原密码被编码后的密码。

注意:
rawPassword、encodedPassword、 密码存储格式 (prefixEncodedPassword)这三者是不同的概念!
rawPassword 相当于字符序列”123456” ;
encodedPassword 是使用 id 为 “mycrypt” 对应的密码编码器 “123456” 编码后的字符串,假设为”qwertyuiop” ;
存储的密码 prefixEncodedPassword 是在数据库中,我们所能见到的形式,如“{mycrypt}qwertyuiop” ;
这个概念在后面讲matches方法的源码时会用到,请留意。

例如 rawPassword 为 password 在使用不同编码算法的情况下在数据库的存储如下:

{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
{noop}password
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0
这里需要指明:
密码的可靠性并不依赖于加密算法的保密。即密码的可靠在于,就算你知道我使用的是什么算法你也无法还原出原密码(当然,对于本身就可逆的编码算法来说就不是这样了,但这样的算法我们通常不会认为是可靠的)。而且,即使没有标明使用的是什么算法,***者也很容易根据一些规律从编码后的密码字符串中推测出编码算法,如 bcrypt 算法通常是以 $2a$ 开头。

3 密码编码与匹配
从上文可知,idForEncode 这个构造参数决定使用哪个PasswordEncoder进行密码的编码。编码的方法如下:

private static final String PREFIX = "{";
private static final String SUFFIX = "}";

@Override
public String encode(CharSequence rawPassword) {
return PREFIX + this.idForEncode + SUFFIX + this.passwordEncoderForEncode.encode(rawPassword);
}
所以用上文构造的 DelegatingPasswordEncoder 默认使用 BCryptPasswordEncoder,结果格式如下:

1
{bcrypt}2a10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
密码编码方法比较简单,重点在于匹配.匹配方法源码如下:

@Override
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
    if(rawPassword == null && prefixEncodedPassword == null) {
        return true;
    }
    //取出编码算法的id
    String id = extractId(prefixEncodedPassword);
    //根据编码算法的id从支持的密码编码器Map(构造时传入)中取出对应编码器
    PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
    if(delegate == null) {
    //如果找不到对应的密码编码器则使用默认密码编码器进行匹配判断,此时比较的密码字符串是 prefixEncodedPassword
        return this.defaultPasswordEncoderForMatches
            .matches(rawPassword, prefixEncodedPassword);
    }
    //从 prefixEncodedPassword 中提取获得 encodedPassword
    String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
    //使用对应编码器进行匹配判断,此时比较的密码字符串是 encodedPassword ,不携带编码算法id头
    return delegate.matches(rawPassword, encodedPassword);
}

这个匹配方法其实也挺好理解的。唯一需要特别注意的就是找不到对应密码编码器时使用的默认密码编码器,我们来看看 defaultPasswordEncoderForMatches 是什么。

4 defaultPasswordEncoderForMatches 及 id 为 null 异常
在 DelegatingPasswordEncoder 的源码里对应内容如下:

private static final String PREFIX = "{";
private static final String SUFFIX = "}";
private final String idForEncode;
private final PasswordEncoder passwordEncoderForEncode;
private final Map<String, PasswordEncoder> idToPasswordEncoder;

private PasswordEncoder defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder();

public void setDefaultPasswordEncoderForMatches(
    PasswordEncoder defaultPasswordEncoderForMatches) {
    if(defaultPasswordEncoderForMatches == null) {
        throw new IllegalArgumentException("defaultPasswordEncoderForMatches cannot be null");
    }
    this.defaultPasswordEncoderForMatches = defaultPasswordEncoderForMatches;
}

private class UnmappedIdPasswordEncoder implements PasswordEncoder {

    @Override
    public String encode(CharSequence rawPassword) {
        throw new UnsupportedOperationException("encode is not supported");
    }

    @Override
    public boolean matches(CharSequence rawPassword,
        String prefixEncodedPassword) {
        String id = extractId(prefixEncodedPassword);
        throw new IllegalArgumentException("There is no PasswordEncoder mapped for the id \"" + id + "\"");
    }
}

可以看到,DelegatingPasswordEncoder 里面 PREFIX 和 SUFFIX 是常量,idForEncode、passwordEncoderForEncode 和idToPasswordEncoder 是在构造方法中传入决定并不可修改的。只有 defaultPasswordEncoderForMatches 是有一个setDefaultPasswordEncoderForMatches 方法进行设置的可变对象。

而且它有一个私有的默认实现 UnmappedIdPasswordEncoder,这个所谓的默认实现的唯一作用就是抛出异常提醒你要自己选择一个默认密码编码器来取代它。通常我们只会可能用到它的 matches 方法,这个时候就会报抛出如下异常:

java.lang.IllegalArgumentException: There is no PasswordEncoder mapped for the id "null"
at org.springframework.security.crypto.password.DelegatingPasswordEncoder$UnmappedIdPasswordEncoder.matches(DelegatingPasswordEncoder.java:233)
at org.springframework.security.crypto.password.DelegatingPasswordEncoder.matches(DelegatingPasswordEncoder.java:196)
5 解决方法
遇到这个异常,最简单的做法就是明确提供一个 PasswordEncoder 对密码进行编码。如果是 从Spring Security 5.0 之前迁移而来的,由于之前默认使用的是 NoOpPasswordEncoder 并且数据库的密码保存格式不带有加密算法 id 头,会报 id 为 null 异常,所以应该明确提供一个NoOpPasswordEncoder 密码编码器。

这里有两种思路:其一就是使用 NoOpPasswordEncoder 取代 DelegatingPasswordEncoder 以恢复到之前版本的状态。这也是笔者在其他博客上看得比较多的一种解决方法;另外就是使用 DelegatingPasswordEncoder 的 setDefaultPasswordEncoderForMatches 方法指定默认的密码编码器为 NoOpPasswordEncoder。这两种方法孰优孰劣自然不言而喻,官方文档是这么说的:

Reverting to NoOpPasswordEncoder is not considered to be secure. You should instead migrate to using DelegatingPasswordEncoder to support secure password encoding.
恢复到 NoOpPasswordEncoder 被认为是不安全的。您应该转而使用 DelegatingPasswordEncoder 支持安全密码编码。

当然,你也可以将数据库保存的密码都加上一个 {noop} 前缀。这样 DelegatingPasswordEncoder 就知道要使用 NoOpPasswordEncoder了。这确实是一种方法,但没必要。这里我们来看一下前面的两种解决方法的实现:

1 使用NoOpPasswordEncoder取代DelegatingPasswordEncoder

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

2 使用 DelegatingPasswordEncoder 指定 defaultPasswordEncoderForMatches


@Bean
public  static PasswordEncoder passwordEncoder( ){
    DelegatingPasswordEncoder delegatingPasswordEncoder =
            (DelegatingPasswordEncoder) PasswordEncoderFactories.createDelegatingPasswordEncoder();
    //设置defaultPasswordEncoderForMatches为NoOpPasswordEncoder
    delegatingPasswordEncoder.setDefaultPasswordEncoderForMatches(NoOpPasswordEncoder.getInstance());
    return  delegatingPasswordEncoder;
}

原文地址:http://blog.51cto.com/13932491/2316588

时间: 2024-11-06 03:39:02

Spring Security 5.0的DelegatingPasswordEncoder详解的相关文章

Servlet 3.0 新特性详解

转自:https://www.ibm.com/developerworks/cn/java/j-lo-servlet30/ Servlet 3.0 新特性详解 张 建平2010 年 4 月 23 日发布 WeiboGoogle+用电子邮件发送本页面 6 Servlet 3.0 新特性概述 Servlet 3.0 作为 Java EE 6 规范体系中一员,随着 Java EE 6 规范一起发布.该版本在前一版本(Servlet 2.5)的基础上提供了若干新特性用于简化 Web 应用的开发和部署.其

spring boot 1.5.4 配置文件详解(八)

上一篇:spring boot 1.5.4 集成spring-Data-JPA(七) 1      Spring Boot配置文件详解 相信很多人选择Spring Boot主要是考虑到它既能兼顾Spring的强大功能,还能实现快速开发的便捷.我们在Spring Boot使用过程中,最直观的感受就是没有了原来自己整合Spring应用时繁多的XML配置内容,替代它的是在pom.xml中引入模块化的Starter POMs,其中各个模块都有自己的默认配置,所以如果不是特殊应用场景,就只需要在appli

idea spring+springmvc+mybatis环境配置整合详解

idea spring+springmvc+mybatis环境配置整合详解 1.配置整合前所需准备的环境: 1.1:jdk1.8 1.2:idea2017.1.5 1.3:Maven 3.5.2 2.查看idea中是否安装Maven插件: 2.1:File --> Settings --> Plugins 2.2:如下图所示的步骤进行操作(注:安装完插件,idea会重新启动) 3.idea创建Maven项目的步骤 4.搭建目录结构 下图就是我搭建Maven项目之后,添加对应的目录和文件 5.p

spring boot slf4j日记记录配置详解

转 spring boot slf4j日记记录配置详解 2017年12月26日 12:03:34 阅读数:1219 Spring-Boot--日志操作[全局异常捕获消息处理?日志控制台输出+日志文件记录] 最好的演示说明,不是上来就贴配置文件和代码,而是,先来一波配置文件的注释,再来一波代码的测试过程,最后再出个技术在项目中的应用效果,这样的循序渐进的方式,才会让读者更加清楚的理解一项技术是如何运用在项目中的,虽然本篇很简单,几乎不用手写什么代码,但是,比起网上其他人写的同类型的文章来说,我只能

Spring Boot 配置加载顺序详解

使用 Spring Boot 会涉及到各种各样的配置,如开发.测试.线上就至少 3 套配置信息了.Spring Boot 可以轻松的帮助我们使用相同的代码就能使开发.测试.线上环境使用不同的配置. 在 Spring Boot 里面,可以使用以下几种方式来加载配置.本章内容基于 Spring Boot 2.0 进行详解. 1.properties文件: 2.YAML文件: 3.系统环境变量: 4.命令行参数: 等等-- 我们可以在 Spring Beans 里面直接使用这些配置文件中加载的值,如:

Spring全家桶——SpringBoot之AOP详解

Spring全家桶--SpringBoot之AOP详解 面向方面编程(AOP)通过提供另一种思考程序结构的方式来补充面向对象编程(OOP). OOP中模块化的关键单元是类,而在AOP中,模块化单元是方面. 准备工作 首先,使用AOP要在build.gradle中加入依赖 //引入AOP依赖 compile "org.springframework.boot:spring-boot-starter-aop:${springBootVersion}" 然后在application.yml中

springsecurity启动出现org.springframework.beans.factory.parsing.BeanDefinitionParsingException: Configuration problem: You must use a 3.0 schema with Spring Security 3.0.

在换了spring-security的jar包以后启动出现org.springframework.beans.factory.parsing.BeanDefinitionParsingException: Configuration problem: You must use a 3.0 schema with Spring Security 3.0.Please update your schema declarations to the 3.0.3 schema (spring-securi

【OAuth2.0】Spring Security OAuth2.0篇之初识

不吐不快 因为项目需求开始接触OAuth2.0授权协议.断断续续接触了有两周左右的时间.不得不吐槽的,依然是自己的学习习惯问题,总是着急想了解一切,习惯性地钻牛角尖去理解小的细节,而不是从宏观上去掌握,或者说先用起来(少年,一辈子辣么长,你这么着急合适吗?).好在前人们已经做好了很好的demo,我自己照着抄一抄也就理解了大概如何用,依旧手残党,依旧敲不出好代码.忏悔- WHAT? 项目之中实际使用OAuth2.0实现是用的Spring Security OAuth2.0,一套基于Spring S

IIS7.0 Appcmd 命令详解和定时重启应用池及站点的设置

IIS7.0 Appcmd 命令详解 废话不说!虽然有配置界面管理器!但是做安装包的时候命令创建是必不可少的!最近使用NSIS制作安装包仔细研究了一下Appcmd的命令,可谓是功能齐全. 上网查了些资料,那些博客大部分都是转载的别人的.都是些基本的介绍,很多命令都没介绍到(不知道是不是我走眼了). 就连微软的 技术资源库 也不详细: 附地址:http://technet.microsoft.com/zh-cn/library/cc772200(WS.10).aspx(反正我找了一遍!没找到我要的