Spring Security 實戰乾貨——搞清楚UserDetails

  • 2019 年 12 月 5 日
  • 筆記

1. 前言

前一篇介紹了 Spring Security 入門的基礎準備。從今天開始我們來一步步窺探它是如何工作的。我們又該如何駕馭它。請多多關注公眾號: Felordcn 。本篇將通過 Spring Boot 2.x 來講解 Spring Security 中的用戶主體UserDetails。以及從中找點樂子。

2. Spring Boot 集成 Spring Security

這個簡直老生常談了。不過為了照顧大多數還是說一下。集成 Spring Security 只需要引入其對應的 Starter 組件。Spring Security 不僅僅能保護Servlet Web 應用,也可以保護Reactive Web應用,本文我們講前者。我們只需要在 Spring Security 項目引入以下依賴即可:

    <dependencies>          <!--  actuator 指標監控  非必須 -->          <dependency>              <groupId>org.springframework.boot</groupId>              <artifactId>spring-boot-starter-actuator</artifactId>          </dependency>          <!--  spring security starter 必須  -->          <dependency>              <groupId>org.springframework.boot</groupId>              <artifactId>spring-boot-starter-security</artifactId>          </dependency>          <!-- spring mvc  servlet web  必須  -->          <dependency>              <groupId>org.springframework.boot</groupId>              <artifactId>spring-boot-starter-web</artifactId>          </dependency>          <!--   lombok 插件 非必須       -->          <dependency>              <groupId>org.projectlombok</groupId>              <artifactId>lombok</artifactId>              <optional>true</optional>          </dependency>          <!-- 測試   -->          <dependency>              <groupId>org.springframework.boot</groupId>              <artifactId>spring-boot-starter-test</artifactId>              <scope>test</scope>          </dependency>          <dependency>              <groupId>org.springframework.security</groupId>              <artifactId>spring-security-test</artifactId>              <scope>test</scope>          </dependency>      </dependencies>

3. UserDetailsServiceAutoConfiguration

啟動項目,訪問Actuator端點http://localhost:8080/actuator會跳轉到一個登錄頁面http://localhost:8080/login如下:

要求你輸入用戶名 Username (默認值為user)和密碼 Password 。密碼在springboot控制台會打印出類似 Using generated security password: e1f163be-ad18-4be1-977c-88a6bcee0d37 的字樣,後面的長串就是密碼,當然這不是生產可用的。如果你足夠細心會從控制台打印日誌發現該隨機密碼是由UserDetailsServiceAutoConfiguration 配置類生成的,我們就從它開始順藤摸瓜來一探究竟。

3.1 UserDetailsService

UserDetailsService接口。該接口只提供了一個方法:

UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

該方法很容易理解:通過用戶名來加載用戶 。這個方法主要用於從系統數據中查詢並加載具體的用戶到Spring Security中。

3.2 UserDetails

從上面UserDetailsService 可以知道最終交給Spring Security的是UserDetails 。該接口是提供用戶信息的核心接口。該接口實現僅僅存儲用戶的信息。後續會將該接口提供的用戶信息封裝到認證對象Authentication中去。UserDetails 默認提供了:

  • 用戶的權限集, 默認需要添加ROLE_ 前綴
  • 用戶的加密後的密碼, 不加密會使用{noop}前綴
  • 應用內唯一的用戶名
  • 賬戶是否過期
  • 賬戶是否鎖定
  • 憑證是否過期
  • 用戶是否可用

如果以上的信息滿足不了你使用,你可以自行實現擴展以存儲更多的用戶信息。比如用戶的郵箱、手機號等等。通常我們使用其實現類:

org.springframework.security.core.userdetails.User

該類內置一個建造器UserBuilder 會很方便地幫助我們構建UserDetails 對象,後面我們會用到它。

3.3 UserDetailsServiceAutoConfiguration

UserDetailsServiceAutoConfiguration 全限定名為:

org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration

源碼如下:

@Configuration  @ConditionalOnClass(AuthenticationManager.class)  @ConditionalOnBean(ObjectPostProcessor.class)  @ConditionalOnMissingBean({ AuthenticationManager.class, AuthenticationProvider.class, UserDetailsService.class })  public class UserDetailsServiceAutoConfiguration {        private static final String NOOP_PASSWORD_PREFIX = "{noop}";        private static final Pattern PASSWORD_ALGORITHM_PATTERN = Pattern.compile("^\{.+}.*$");        private static final Log logger = LogFactory.getLog(UserDetailsServiceAutoConfiguration.class);        @Bean      @ConditionalOnMissingBean(              type = "org.springframework.security.oauth2.client.registration.ClientRegistrationRepository")      @Lazy      public InMemoryUserDetailsManager inMemoryUserDetailsManager(SecurityProperties properties,              ObjectProvider<PasswordEncoder> passwordEncoder){          SecurityProperties.User user = properties.getUser();          List<String> roles = user.getRoles();          return new InMemoryUserDetailsManager(                  User.withUsername(user.getName()).password(getOrDeducePassword(user, passwordEncoder.getIfAvailable()))                          .roles(StringUtils.toStringArray(roles)).build());      }        private String getOrDeducePassword(SecurityProperties.User user, PasswordEncoder encoder) {          String password = user.getPassword();          if (user.isPasswordGenerated()) {              logger.info(String.format("%n%nUsing generated security password: %s%n", user.getPassword()));          }          if (encoder != null || PASSWORD_ALGORITHM_PATTERN.matcher(password).matches()) {              return password;          }          return NOOP_PASSWORD_PREFIX + password;      }    }

我們來簡單解讀一下該類,從@Conditional系列註解我們知道該類在類路徑下存在AuthenticationManager、在Spring 容器中存在Bean ObjectPostProcessor並且不存在Bean AuthenticationManager, AuthenticationProvider, UserDetailsService的情況下生效。千萬不要糾結這些類幹嘛用的! 該類只初始化了一個UserDetailsManager 類型的Bean。UserDetailsManager 類型負責對安全用戶實體抽象UserDetails的增刪查改操作。同時還繼承了UserDetailsService接口。

明白了上面這些讓我們把目光再回到UserDetailsServiceAutoConfiguration 上來。該類初始化了一個名為InMemoryUserDetailsManager 的內存用戶管理器。該管理器通過配置注入了一個默認的UserDetails存在內存中,就是我們上面用的那個user ,每次啟動user都是動態生成的。那麼問題來了如果我們定義自己的UserDetailsManager Bean是不是就可以實現我們需要的用戶管理邏輯呢?

3.4 自定義UserDetailsManager

我們來自定義一個UserDetailsManager 來看看能不能達到自定義用戶管理的效果。首先我們針對UserDetailsManager 的所有方法進行一個代理的實現,我們依然將用戶存在內存中,區別就是這是我們自定義的:

package cn.felord.spring.security;    import org.springframework.security.access.AccessDeniedException;  import org.springframework.security.core.Authentication;  import org.springframework.security.core.context.SecurityContextHolder;  import org.springframework.security.core.userdetails.UserDetails;  import org.springframework.security.core.userdetails.UsernameNotFoundException;    import java.util.HashMap;  import java.util.Map;    /**   * 代理 {@link org.springframework.security.provisioning.UserDetailsManager} 所有功能   *   * @author Felordcn   */  public class UserDetailsRepository {        private Map<String, UserDetails> users = new HashMap<>();          public void createUser(UserDetails user) {          users.putIfAbsent(user.getUsername(), user);      }          public void updateUser(UserDetails user) {          users.put(user.getUsername(), user);      }          public void deleteUser(String username) {          users.remove(username);      }          public void changePassword(String oldPassword, String newPassword) {          Authentication currentUser = SecurityContextHolder.getContext()                  .getAuthentication();            if (currentUser == null) {              // This would indicate bad coding somewhere              throw new AccessDeniedException(                      "Can't change password as no Authentication object found in context "                              + "for current user.");          }            String username = currentUser.getName();            UserDetails user = users.get(username);              if (user == null) {              throw new IllegalStateException("Current user doesn't exist in database.");          }            // todo copy InMemoryUserDetailsManager  自行實現具體的更新密碼邏輯      }          public boolean userExists(String username) {            return users.containsKey(username);      }          public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {          return users.get(username);      }  }

該類負責具體對UserDetails 的增刪改查操作。我們將其注入Spring 容器:

    @Bean      public UserDetailsRepository userDetailsRepository() {          UserDetailsRepository userDetailsRepository = new UserDetailsRepository();            // 為了讓我們的登錄能夠運行 這裡我們初始化一個用戶Felordcn 密碼採用明文 當你在密碼12345上使用了前綴{noop} 意味着你的密碼不使用加密,authorities 一定不能為空 這代表用戶的角色權限集合          UserDetails felordcn = User.withUsername("Felordcn").password("{noop}12345").authorities(AuthorityUtils.NO_AUTHORITIES).build();          userDetailsRepository.createUser(felordcn);          return userDetailsRepository;      }

為了方便測試 我們也內置一個名稱為Felordcn 密碼為12345UserDetails用戶,密碼採用明文 當你在密碼12345上使用了前綴{noop} 意味着你的密碼不使用加密,這裡我們並沒有指定密碼加密方式你可以使用PasswordEncoder 來指定一種加密方式。通常推薦使用Bcrypt作為加密方式。默認Spring Security使用的也是此方式。authorities 一定不能為null 這代表用戶的角色權限集合。接下來我們實現一個UserDetailsManager 並注入Spring 容器:

    @Bean      public UserDetailsManager userDetailsManager(UserDetailsRepository userDetailsRepository) {          return new UserDetailsManager() {              @Override              public void createUser(UserDetails user) {                  userDetailsRepository.createUser(user);              }                @Override              public void updateUser(UserDetails user) {                  userDetailsRepository.updateUser(user);              }                @Override              public void deleteUser(String username) {                  userDetailsRepository.deleteUser(username);              }                @Override              public void changePassword(String oldPassword, String newPassword) {                  userDetailsRepository.changePassword(oldPassword, newPassword);              }                @Override              public boolean userExists(String username) {                  return userDetailsRepository.userExists(username);              }                @Override              public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {                  return userDetailsRepository.loadUserByUsername(username);              }          };      }

這樣實際執行委託給了UserDetailsRepository 來做。我們重複 章節3. 的動作進入登陸頁面分別輸入Felordcn12345 成功進入。

3.5 數據庫管理用戶

經過以上的配置,相信聰明的你已經知道如何使用數據庫來管理用戶了 。只需要將 UserDetailsRepository 中的 users 屬性替代為抽象的Dao接口就行了,無論你使用Jpa還是Mybatis來實現。

4. 總結

今天我們對Spring Security 中的用戶信息 UserDetails 相關進行的一些解讀。並自定義了用戶信息處理服務。相信你已經對在Spring Security中如何加載用戶信息,如何擴展用戶信息有所掌握了。後面我們會由淺入深慢慢解讀Spring Security。