歡迎光臨
每天分享高質量文章

Spring Security 5.0 的 DelegatingPasswordEncoder 詳解

(點擊上方公眾號,可快速關註)


來源:鯨臨於空 ,

blog.csdn.net/alinyua/article/details/80219500

本文參考自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類保護手段

https://blog.csdn.net/alinyua/article/details/79776613

2 DelegatingPasswordEncoder

通過上面的學習我們可以知道,隨著安全要求的提高之前的預設密碼編碼器 NoOpPasswordEncoder 已經被 “不推薦”了,那我們有理由推測現在的預設密碼編碼器換成了使用某一特定演算法的編碼器。可是這樣便會帶來三個問題:

  • 有許多使用舊密碼編碼的應用程式無法輕鬆遷移;

  • 密碼儲存的最佳做法(演算法)可能會再次發生變化;

  • 作為一個框架,Spring Security 不能經常發生突變。

簡單來說,就是新的密碼編碼器和舊密碼的兼容性、自身的穩健性以及需要一定的可變性(切換到更好的演算法)。聽起來是不是十分矛盾?那我們就來看看 DelegatingPasswordEncoder 是怎麼解決這個問題的。在看解決方法之前先看使用 DelegatingPasswordEncoder 能達到的效果:

1 構造方法

下麵我們來看看 DelegatingPasswordEncoder 的構造方法

public DelegatingPasswordEncoder(String idForEncode,

    Map 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 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 密碼儲存格式

密碼的標準儲存格式是:

{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,結果格式如下:

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

}

【關於投稿】


如果大家有原創好文投稿,請直接給公號發送留言。


① 留言格式:
【投稿】+《 文章標題》+ 文章鏈接

② 示例:
【投稿】《不要自稱是程式員,我十多年的 IT 職場總結》:http://blog.jobbole.com/94148/

③ 最後請附上您的個人簡介哈~



看完本文有收穫?請轉發分享給更多人

關註「ImportNew」,提升Java技能

赞(0)

分享創造快樂