1. 程式人生 > >Spring Security 5.0的DelegatingPasswordEncoder詳解

Spring Security 5.0的DelegatingPasswordEncoder詳解

地理 The 不同的 fault wpa 存在 版本 直接 tex

本文參考自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;
}

Spring Security 5.0的DelegatingPasswordEncoder詳解