關於密碼加密的問題,松哥之前已經和大家聊過了,參考:
這篇文章中,松哥給大家介紹了兩種密碼加密方案,但是兩種都是獨立使用的!能不能在同一個項目中同時存在多種密碼加密方案呢?答案是肯定的!
今天松哥就來和大家聊一聊,如何在 Spring Security 中,讓多種不同的密碼加密方案並存。
本文是 Spring Security 系列第 31 篇,閱讀前面文章有助於更好的理解本文:
- 挖一個大坑,Spring Security 開搞!
- 松哥手把手帶你入門 Spring Security,別再問密碼怎麼解密了
- 手把手教你定製 Spring Security 中的表單登錄
- Spring Security 做前後端分離,咱就別做頁面跳轉了!統統 JSON 交互
- Spring Security 中的授權操作原來這麼簡單
- Spring Security 如何將用戶數據存入數據庫?
- Spring Security+Spring Data Jpa 強強聯手,安全管理只有更簡單!
- Spring Boot + Spring Security 實現自動登錄功能
- Spring Boot 自動登錄,安全風險要怎麼控制?
- 在微服務項目中,Spring Security 比 Shiro 強在哪?
- SpringSecurity 自定義認證邏輯的兩種方式(高級玩法)
- Spring Security 中如何快速查看登錄用戶 IP 地址等信息?
- Spring Security 自動踢掉前一個登錄用戶,一個配置搞定!
- Spring Boot + Vue 前後端分離項目,如何踢掉已登錄用戶?
- Spring Security 自帶防火牆!你都不知道自己的系統有多安全!
- 什麼是會話固定攻擊?Spring Boot 中要如何防禦會話固定攻擊?
- 集羣化部署,Spring Security 要如何處理 session 共享?
- 松哥手把手教你在 SpringBoot 中防禦 CSRF 攻擊!so easy!
- 要學就學透徹!Spring Security 中 CSRF 防禦源碼解析
- Spring Boot 中密碼加密的兩種姿勢!
- Spring Security 要怎麼學?爲什麼一定要成體系的學習?
- Spring Security 兩種資源放行策略,千萬別用錯了!
- 松哥手把手教你入門 Spring Boot + CAS 單點登錄
- Spring Boot 實現單點登錄的第三種方案!
- Spring Boot+CAS 單點登錄,如何對接數據庫?
- Spring Boot+CAS 默認登錄頁面太醜了,怎麼辦?
- 用 Swagger 測試接口,怎麼在請求頭中攜帶 Token?
- Spring Boot 中三種跨域場景總結
- Spring Boot 中如何實現 HTTP 認證?
- 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);
}
從這段代碼中我們可以看到:
- 首先去調用 getBeanOrNull 方法獲取一個 PasswordEncoder 實例,getBeanOrNull 方法實際上就是去 Spring 容器中查找對象。
- 接下來直接 new 一個 DaoAuthenticationProvider 對象,大家知道,在 new 的過程中,DaoAuthenticationProvider 中默認的 PasswordEncoder 已經被創建出來了。
- 如果一開始從 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() {}
}
可以看到:
- 在 PasswordEncoderFactories 中,首先構建了一個 encoders,然後給所有的編碼方式都取了一個名字,再把名字做 key,編碼方式做 value,統統存入 encoders 中。
- 最後返回了一個 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 + "\"");
}
}
}
這段代碼比較長,我來和大家挨個解釋下:
- DelegatingPasswordEncoder 也是實現了 PasswordEncoder 接口,所以它裏邊的核心方法也是兩個:encode 方法用來對密碼進行編碼,matches 方法用來校驗密碼。
- 在 DelegatingPasswordEncoder 的構造方法中,通過 通過傳入的兩個參數 encodingId 和 encoders ,獲取到默認的編碼器賦值給 passwordEncoderForEncode,默認的編碼器實際上就是 BCryptPasswordEncoder。
- 在 encode 方法中對密碼進行編碼,但是編碼的方式加了前綴,前綴是
{編碼器名稱}
,例如如果你使用 BCryptPasswordEncoder 進行編碼,那麼生成的密碼就類似{bcrypt}$2a$10$oE39aG10kB/rFu2vQeCJTu/V/v4n6DRR0f8WyXRiAYvBpmadoOBE.
。這樣有什麼用呢?每種密碼加密之後,都會加上一個前綴,這樣看到前綴,就知道該密文是使用哪個編碼器生成的了。 - 最後 matches 方法的邏輯就很清晰了,先從密文中提取出來前綴,再根據前綴找到對應的 PasswordEncoder,然後再調用 PasswordEncoder 的 matches 方法進行密碼比對。
- 如果根據提取出來的前綴,找不到對應的 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 是最佳選擇。
好啦,這就是今天和小夥伴們分享的多種密碼加密方案問題,感興趣的小夥伴記得點個在看鼓勵下松哥哦~