Spring Security 多種加密方案共存,老破舊系統整合利器!

關於密碼加密的問題,松哥之前已經和大家聊過了,參考:

這篇文章中,松哥給大家介紹了兩種密碼加密方案,但是兩種都是獨立使用的!能不能在同一個項目中同時存在多種密碼加密方案呢?答案是肯定的!

今天松哥就來和大家聊一聊,如何在 Spring Security 中,讓多種不同的密碼加密方案並存。

本文是 Spring Security 系列第 31 篇,閱讀前面文章有助於更好的理解本文:

  1. 挖一個大坑,Spring Security 開搞!
  2. 松哥手把手帶你入門 Spring Security,別再問密碼怎麼解密了
  3. 手把手教你定製 Spring Security 中的表單登錄
  4. Spring Security 做前後端分離,咱就別做頁面跳轉了!統統 JSON 交互
  5. Spring Security 中的授權操作原來這麼簡單
  6. Spring Security 如何將用戶數據存入數據庫?
  7. Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!
  8. Spring Boot + Spring Security 實現自動登錄功能
  9. Spring Boot 自動登錄,安全風險要怎麼控制?
  10. 在微服務項目中,Spring Security 比 Shiro 強在哪?
  11. SpringSecurity 自定義認證邏輯的兩種方式(高級玩法)
  12. Spring Security 中如何快速查看登錄用戶 IP 地址等信息?
  13. Spring Security 自動踢掉前一個登錄用戶,一個配置搞定!
  14. Spring Boot + Vue 前後端分離項目,如何踢掉已登錄用戶?
  15. Spring Security 自帶防火牆!你都不知道自己的系統有多安全!
  16. 什麼是會話固定攻擊?Spring Boot 中要如何防禦會話固定攻擊?
  17. 集羣化部署,Spring Security 要如何處理 session 共享?
  18. 松哥手把手教你在 SpringBoot 中防禦 CSRF 攻擊!so easy!
  19. 要學就學透徹!Spring Security 中 CSRF 防禦源碼解析
  20. Spring Boot 中密碼加密的兩種姿勢!
  21. Spring Security 要怎麼學?爲什麼一定要成體系的學習?
  22. Spring Security 兩種資源放行策略,千萬別用錯了!
  23. 松哥手把手教你入門 Spring Boot + CAS 單點登錄
  24. Spring Boot 實現單點登錄的第三種方案!
  25. Spring Boot+CAS 單點登錄,如何對接數據庫?
  26. Spring Boot+CAS 默認登錄頁面太醜了,怎麼辦?
  27. 用 Swagger 測試接口,怎麼在請求頭中攜帶 Token?
  28. Spring Boot 中三種跨域場景總結
  29. Spring Boot 中如何實現 HTTP 認證?
  30. Spring Security 中的四種權限控制方式

爲什麼要加密?常見的加密算法等等這些問題我就不再贅述了,大家可以參考之前的:Spring Boot 中密碼加密的兩種姿勢!,咱們直接來看今天的正文。

1.PasswordEncoder

在 Spring Security 中,跟密碼加密/校驗相關的事情,都是由 PasswordEncoder 來主導的,PasswordEncoder 擁有衆多的實現類:

這些實現類,有的已經過期了,有的用處不大。對於我們而言,最常用的莫過於 BCryptPasswordEncoder。

PasswordEncoder 本身是一個接口,裏邊只有三個方法:

public interface PasswordEncoder {
	String encode(CharSequence rawPassword);
	boolean matches(CharSequence rawPassword, String encodedPassword);
	default boolean upgradeEncoding(String encodedPassword) {
		return false;
	}
}
  • encode 方法用來對密碼進行加密。
  • matches 方法用來對密碼進行比對。
  • upgradeEncoding 表示是否需要對密碼進行再次加密以使得密碼更加安全,默認爲 false。

PasswordEncoder 的實現類,則具體實現了這些方法。

2.PasswordEncoder 在哪裏起作用

對於我們開發者而言,我們通常都是在 SecurityConfig 中配置一個 PasswordEncoder 的實例,類似下面這樣:

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

剩下的事情,都是由系統調用的。今天我們就來揭開系統調用的神祕面紗!我們一起來看下系統到底是怎麼調用的!

首先,松哥在前面的文章中和大家提到過,Spring Security 中,如果使用用戶名/密碼的方式登錄,密碼是在 DaoAuthenticationProvider 中進行校驗的,大家可以參考:SpringSecurity 自定義認證邏輯的兩種方式(高級玩法)

我們來看下 DaoAuthenticationProvider 中密碼是如何校驗的:

protected void additionalAuthenticationChecks(UserDetails userDetails,
		UsernamePasswordAuthenticationToken authentication)
		throws AuthenticationException {
	if (authentication.getCredentials() == null) {
		throw new BadCredentialsException(messages.getMessage(
				"AbstractUserDetailsAuthenticationProvider.badCredentials",
				"Bad credentials"));
	}
	String presentedPassword = authentication.getCredentials().toString();
	if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
		throw new BadCredentialsException(messages.getMessage(
				"AbstractUserDetailsAuthenticationProvider.badCredentials",
				"Bad credentials"));
	}
}

可以看到,密碼校驗就是通過 passwordEncoder.matches 方法來完成的。

那麼 DaoAuthenticationProvider 中的 passwordEncoder 從何而來呢?是不是就是我們一開始在 SecurityConfig 中配置的那個 Bean 呢?

我們來看下 DaoAuthenticationProvider 中關於 passwordEncoder 的定義,如下:

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
	private PasswordEncoder passwordEncoder;
	public DaoAuthenticationProvider() {
		setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
	}
	public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
		this.passwordEncoder = passwordEncoder;
		this.userNotFoundEncodedPassword = null;
	}

	protected PasswordEncoder getPasswordEncoder() {
		return passwordEncoder;
	}
}

從這段代碼中可以看到,在 DaoAuthenticationProvider 創建之時,就指定了 PasswordEncoder,似乎並沒有用到我們一開始配置的 Bean?其實不是的!在 DaoAuthenticationProvider 創建之時,會制定一個默認的 PasswordEncoder,如果我們沒有配置任何 PasswordEncoder,將使用這個默認的 PasswordEncoder,如果我們自定義了 PasswordEncoder 實例,那麼會使用我們自定義的 PasswordEncoder 實例!

從何而知呢?

我們再來看看 DaoAuthenticationProvider 是怎麼初始化的。

DaoAuthenticationProvider 的初始化是在 InitializeUserDetailsManagerConfigurer#configure 方法中完成的,我們一起來看下該方法的定義:

public void configure(AuthenticationManagerBuilder auth) throws Exception {
	if (auth.isConfigured()) {
		return;
	}
	UserDetailsService userDetailsService = getBeanOrNull(
			UserDetailsService.class);
	if (userDetailsService == null) {
		return;
	}
	PasswordEncoder passwordEncoder = getBeanOrNull(PasswordEncoder.class);
	UserDetailsPasswordService passwordManager = getBeanOrNull(UserDetailsPasswordService.class);
	DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
	provider.setUserDetailsService(userDetailsService);
	if (passwordEncoder != null) {
		provider.setPasswordEncoder(passwordEncoder);
	}
	if (passwordManager != null) {
		provider.setUserDetailsPasswordService(passwordManager);
	}
	provider.afterPropertiesSet();
	auth.authenticationProvider(provider);
}

從這段代碼中我們可以看到:

  1. 首先去調用 getBeanOrNull 方法獲取一個 PasswordEncoder 實例,getBeanOrNull 方法實際上就是去 Spring 容器中查找對象。
  2. 接下來直接 new 一個 DaoAuthenticationProvider 對象,大家知道,在 new 的過程中,DaoAuthenticationProvider 中默認的 PasswordEncoder 已經被創建出來了。
  3. 如果一開始從 Spring 容器中獲取到了 PasswordEncoder 實例,則將之賦值給 DaoAuthenticationProvider 實例,否則就是用 DaoAuthenticationProvider 自己默認創建的 PasswordEncoder。

至此,就真相大白了,我們配置的 PasswordEncoder 實例確實用上了。

3.默認的是什麼?

同時大家看到,如果我們不進行任何配置,默認的 PasswordEncoder 也會被提供,那麼默認的 PasswordEncoder 是什麼呢?我們就從這個方法看起:

public DaoAuthenticationProvider() {
	setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
}

繼續:

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 org.springframework.security.crypto.password.LdapShaPasswordEncoder());
		encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
		encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
		encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
		encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
		encoders.put("scrypt", new SCryptPasswordEncoder());
		encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
		encoders.put("SHA-256", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
		encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
		encoders.put("argon2", new Argon2PasswordEncoder());

		return new DelegatingPasswordEncoder(encodingId, encoders);
	}

	private PasswordEncoderFactories() {}
}

可以看到:

  1. 在 PasswordEncoderFactories 中,首先構建了一個 encoders,然後給所有的編碼方式都取了一個名字,再把名字做 key,編碼方式做 value,統統存入 encoders 中。
  2. 最後返回了一個 DelegatingPasswordEncoder 實例,同時傳入默認的 encodingId 就是 bcrypt,以及 encoders 實例,DelegatingPasswordEncoder 看名字應該是一個代理對象。

我們來看下 DelegatingPasswordEncoder 的定義:

public class DelegatingPasswordEncoder implements PasswordEncoder {
	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 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);
	}
	public void setDefaultPasswordEncoderForMatches(
		PasswordEncoder defaultPasswordEncoderForMatches) {
		if (defaultPasswordEncoderForMatches == null) {
			throw new IllegalArgumentException("defaultPasswordEncoderForMatches cannot be null");
		}
		this.defaultPasswordEncoderForMatches = defaultPasswordEncoderForMatches;
	}

	@Override
	public String encode(CharSequence rawPassword) {
		return PREFIX + this.idForEncode + SUFFIX + this.passwordEncoderForEncode.encode(rawPassword);
	}

	@Override
	public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
		if (rawPassword == null && prefixEncodedPassword == null) {
			return true;
		}
		String id = extractId(prefixEncodedPassword);
		PasswordEncoder delegate = this.idToPasswordEncoder.get(id);
		if (delegate == null) {
			return this.defaultPasswordEncoderForMatches
				.matches(rawPassword, prefixEncodedPassword);
		}
		String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
		return delegate.matches(rawPassword, encodedPassword);
	}

	private String extractId(String prefixEncodedPassword) {
		if (prefixEncodedPassword == null) {
			return null;
		}
		int start = prefixEncodedPassword.indexOf(PREFIX);
		if (start != 0) {
			return null;
		}
		int end = prefixEncodedPassword.indexOf(SUFFIX, start);
		if (end < 0) {
			return null;
		}
		return prefixEncodedPassword.substring(start + 1, end);
	}

	@Override
	public boolean upgradeEncoding(String prefixEncodedPassword) {
		String id = extractId(prefixEncodedPassword);
		if (!this.idForEncode.equalsIgnoreCase(id)) {
			return true;
		}
		else {
			String encodedPassword = extractEncodedPassword(prefixEncodedPassword);
			return this.idToPasswordEncoder.get(id).upgradeEncoding(encodedPassword);
		}
	}

	private String extractEncodedPassword(String prefixEncodedPassword) {
		int start = prefixEncodedPassword.indexOf(SUFFIX);
		return prefixEncodedPassword.substring(start + 1);
	}
	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 + "\"");
		}
	}
}

這段代碼比較長,我來和大家挨個解釋下:

  1. DelegatingPasswordEncoder 也是實現了 PasswordEncoder 接口,所以它裏邊的核心方法也是兩個:encode 方法用來對密碼進行編碼,matches 方法用來校驗密碼。
  2. 在 DelegatingPasswordEncoder 的構造方法中,通過 通過傳入的兩個參數 encodingId 和 encoders ,獲取到默認的編碼器賦值給 passwordEncoderForEncode,默認的編碼器實際上就是 BCryptPasswordEncoder。
  3. 在 encode 方法中對密碼進行編碼,但是編碼的方式加了前綴,前綴是 {編碼器名稱} ,例如如果你使用 BCryptPasswordEncoder 進行編碼,那麼生成的密碼就類似 {bcrypt}$2a$10$oE39aG10kB/rFu2vQeCJTu/V/v4n6DRR0f8WyXRiAYvBpmadoOBE.。這樣有什麼用呢?每種密碼加密之後,都會加上一個前綴,這樣看到前綴,就知道該密文是使用哪個編碼器生成的了。
  4. 最後 matches 方法的邏輯就很清晰了,先從密文中提取出來前綴,再根據前綴找到對應的 PasswordEncoder,然後再調用 PasswordEncoder 的 matches 方法進行密碼比對。
  5. 如果根據提取出來的前綴,找不到對應的 PasswordEncoder,那麼就會調用 UnmappedIdPasswordEncoder#matches 方法,進行密碼比對,該方法實際上並不會進行密碼比對,而是直接拋出異常。

OK,至此,相信大家都明白了 DelegatingPasswordEncoder 的工作原理了。

如果我們想同時使用多個密碼加密方案,看來使用 DelegatingPasswordEncoder 就可以了,而 DelegatingPasswordEncoder 默認還不用配置。

4.體驗

接下來我們稍微體驗一下 DelegatingPasswordEncoder 的用法。

首先我們來生成三個密碼作爲測試密碼:

@Test
void contextLoads() {
    Map<String, PasswordEncoder> encoders = new HashMap<>();
    encoders.put("bcrypt", new BCryptPasswordEncoder());
    encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
    encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
    DelegatingPasswordEncoder encoder1 = new DelegatingPasswordEncoder("bcrypt", encoders);
    DelegatingPasswordEncoder encoder2 = new DelegatingPasswordEncoder("MD5", encoders);
    DelegatingPasswordEncoder encoder3 = new DelegatingPasswordEncoder("noop", encoders);
    String e1 = encoder1.encode("123");
    String e2 = encoder2.encode("123");
    String e3 = encoder3.encode("123");
    System.out.println("e1 = " + e1);
    System.out.println("e2 = " + e2);
    System.out.println("e3 = " + e3);
}

生成結果如下:

e1 = {bcrypt}$2a$10$Sb1gAUH4wwazfNiqflKZve4Ubh.spJcxgHG8Cp29DeGya5zsHENqi
e2 = {MD5}{Wucj/L8wMTMzFi3oBKWsETNeXbMFaHZW9vCK9mahMHc=}4d43db282b36d7f0421498fdc693f2a2
e3 = {noop}123

接下來,我們把這三個密碼拷貝到 SecurityConfig 中去:

@Configuration("aaa")
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    @Bean
    protected UserDetailsService userDetailsService() {

        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("javaboy").password("{bcrypt}$2a$10$Sb1gAUH4wwazfNiqflKZve4Ubh.spJcxgHG8Cp29DeGya5zsHENqi").roles("admin").build());
        manager.createUser(User.withUsername("sang").password("{noop}123").roles("admin").build());
        manager.createUser(User.withUsername("江南一點雨").password("{MD5}{Wucj/L8wMTMzFi3oBKWsETNeXbMFaHZW9vCK9mahMHc=}4d43db282b36d7f0421498fdc693f2a2").roles("user").build());
        return manager;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/**").hasRole("admin")
                .antMatchers("/user/**").hasRole("user")
                ...
    }
}

這裏三個用戶使用三種不同的密碼加密方式。

配置完成後,重啓項目,分別使用 javaboy/123、sang/123 以及 江南一點雨/123 進行登錄,發現都能登錄成功。

5.意義何在?

爲什麼我們會有這種需求?想在項目種同時存在多種密碼加密方案?其實這個主要是針對老舊項目改造用的,密碼加密方式一旦確定,基本上沒法再改了(你總不能讓用戶重新註冊一次吧),但是我們又想使用最新的框架來做密碼加密,那麼無疑,DelegatingPasswordEncoder 是最佳選擇。

好啦,這就是今天和小夥伴們分享的多種密碼加密方案問題,感興趣的小夥伴記得點個在看鼓勵下松哥哦~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章