spring security 認證原始碼跟蹤

尋找的路上發表於2021-11-28

spring security 認證原始碼跟蹤

​ 在跟蹤認證原始碼之前,我們先根據官網說明一下security的內部原理,主要是依據一系列的filter來實現,大家可以根據https://docs.spring.io/spring-security/site/docs/5.5.3/reference/html5/#servlet-hello 檢視相關的文件說明,英文不好的可以配合使用google翻譯。

security 原理說明

​ 在上圖中,紅色方框圈出來的是security 的filter,每一個http request都會經過上圖的每一個指定的過濾器。請求其中:

DelegatingFilterProxy:主要負責在servlet容器的生命週期和Spring上下文進行銜接,也就是說security的所有過濾器都委託給它進行代理。

FilterChainProxy:是一個特殊的過濾器,被包裝在DelegatingFilterProxy內部。它代理代理了SecurityFilterChain

SecurityFilterChain:SecurityFilterChain 確定應為此請求呼叫哪些 Spring 安全過濾器。

DelegatingFilterProxy

​ 這是一個過濾器,所以肯定會有doFilter方法,我們主要檢視內部的2個方法,首先從doFilter方法看起:

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {

   // Lazily initialize the delegate if necessary.
   Filter delegateToUse = this.delegate;
   if (delegateToUse == null) {
      synchronized (this.delegateMonitor) {
         delegateToUse = this.delegate;
         if (delegateToUse == null) {
             // 拿到Spring Web上下文
            WebApplicationContext wac = findWebApplicationContext();
            if (wac == null) {
               throw new IllegalStateException("No WebApplicationContext found: " +
                     "no ContextLoaderListener or DispatcherServlet registered?");
            }
             // 初始化委託filter
            delegateToUse = initDelegate(wac);
         }
         this.delegate = delegateToUse;
      }
   }

   // Let the delegate perform the actual doFilter operation.
   invokeDelegate(delegateToUse, request, response, filterChain);
}

// 初始化委託filter
protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
    	// 眾多filter中,會有一個是FilterChainProxy
		String targetBeanName = getTargetBeanName();
		Assert.state(targetBeanName != null, "No target bean name set");
		Filter delegate = wac.getBean(targetBeanName, Filter.class);
		if (isTargetFilterLifecycle()) {
			delegate.init(getFilterConfig());
		}
		return delegate;
	}

FilterChainProxy

​ 它也是一個過濾器,那一定也會有doFilter方法,我們檢視該方法

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
      throws IOException, ServletException {
    // 當前request是否已經清除了上下文,因為每一個請求都會經過這個過濾器
   boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
   if (!clearContext) {
      doFilterInternal(request, response, chain);
      return;
   }
   try {
      request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
       // 內部的filter方法,我們看到該方法
      doFilterInternal(request, response, chain);
   }
   catch (RequestRejectedException ex) {
      this.requestRejectedHandler.handle((HttpServletRequest) request, (HttpServletResponse) response, ex);
   }
   finally {
      SecurityContextHolder.clearContext();
      request.removeAttribute(FILTER_APPLIED);
   }
}

private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
    // 拿到防火牆配置,對於這裡不重要
		FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest) request);
		HttpServletResponse firewallResponse = this.firewall.getFirewalledResponse((HttpServletResponse) response);
    // 這裡可以看到,FilterChainProxy在這裡拿到了這次請求request具體還要經過的一系列過濾器鏈,其中包括CsrfFilter、UsernamePasswordAuthenticationFilter等過濾器,包含了SecurityFilterChain 涉及的filter
		List<Filter> filters = getFilters(firewallRequest);
		if (filters == null || filters.size() == 0) {
			if (logger.isTraceEnabled()) {
				logger.trace(LogMessage.of(() -> "No security for " + requestLine(firewallRequest)));
			}
			firewallRequest.reset();
			chain.doFilter(firewallRequest, firewallResponse);
			return;
		}
		if (logger.isDebugEnabled()) {
			logger.debug(LogMessage.of(() -> "Securing " + requestLine(firewallRequest)));
		}
		VirtualFilterChain virtualFilterChain = new VirtualFilterChain(firewallRequest, chain, filters);
		virtualFilterChain.doFilter(firewallRequest, firewallResponse);
	}

認證原始碼跟蹤

​ 回到認證這裡,在網上隨便搜一搜就能搜到spring scurity認證的幾種方式,這次我們主要跟蹤第三種認證方式:資料庫認證,也是我們平時在用的方式。先給大家說明一下資料庫認證的知識點,有個大概印象:

  1. UsernamePasswordAuthenticationFilter
  2. 實現UserDetailsService介面並注入到spring管理

這三種認證方式分為為:

1、在xml中配置賬號密碼

spring.security.user.name=user
spring.security.user.password=123456

2、在程式碼中將賬號、密碼載入到記憶體中

@Bean
public UserDetailsService userDetailsService() {
    UserDetails userDetails = User.withDefaultPasswordEncoder()
            .username("user")
            .password("password")
            .roles("USER")
            .build();
    return new InMemoryUserDetailsManager(userDetails);
}

3、從資料庫中讀取賬號進行認證校驗

public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 從資料庫嘗試讀取該使用者
        User user = userMapper.findByUserName(username);
        // 使用者不存在,丟擲異常
        if (user == null) {
            throw new UsernameNotFoundException("使用者不存在");
        }
        // 將資料庫形式的roles解析為UserDetails的許可權集
        // AuthorityUtils.commaSeparatedStringToAuthorityList是Spring Security
        //提供的用於將逗號隔開的許可權集字串切割成可用許可權物件列表的方法
        // 當然也可以自己實現,如用分號來隔開等,參考generateAuthorities
        user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));
        return user;
    }
}

​ 在這個例子中,我們會有一個自定義WebSecurityConfig類,其中定義了哪些Url路徑需要攔截,以及需要哪些許可權才能夠訪問,同時在這個配置中,注入一個一個密碼編碼類,預設是不採用加密方式NoOpPasswordEncoder

@EnableWebSecurity(debug = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                .antMatchers("/admin/api/**").hasRole("ADMIN")
                .antMatchers("/user/api/**").hasRole("USER")
                .antMatchers("/app/api/**").permitAll()
                .antMatchers("/css/**", "/index").permitAll()
                .antMatchers("/user/**").hasRole("USER")
                .and()
                .formLogin()
                .loginPage("/login")
                .failureUrl("/login-error")
                .permitAll();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
}

​ 我們先不去實現UserDetailsService介面,看看spring security是怎麼去實現認證的?

UsernamePasswordAuthenticationFilter

​ 首先找到UsernamePasswordAuthenticationFilter類,發現它繼承了AbstractAuthenticationProcessingFilter類,那我們就先看一下AbstractAuthenticationProcessingFilter類,發現這個類中主要有四個方法,分別是:

  1. doFilter(reqeust,response,chain):每個filter都會有的方法,最重要的一個。
  2. attemptAuthentication(request,response); 是個抽象方法,交給具體的實現類去實現認證的邏輯
  3. successfulAuthentication(request,response,chain,authenticationResult); 認證成功後的處理邏輯,通過不同的策略實現
  4. unsuccessfulAuthentication(request,resonse,failed);認證失敗後的處理邏輯,通過不同的策略實現
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws IOException, ServletException {
   if (!requiresAuthentication(request, response)) {
      chain.doFilter(request, response);
      return;
   }
   try {
       // 具體的認證方法,是個抽象方法,交給具體的實現類去實現認證的邏輯
      Authentication authenticationResult = attemptAuthentication(request, response);
      if (authenticationResult == null) {
         // return immediately as subclass has indicated that it hasn't completed
         return;
      }
      this.sessionStrategy.onAuthentication(authenticationResult, request, response);
      // Authentication success
      if (this.continueChainBeforeSuccessfulAuthentication) {
         chain.doFilter(request, response);
      }
       // 認證成功後的處理邏輯
      successfulAuthentication(request, response, chain, authenticationResult);
   }
   catch (InternalAuthenticationServiceException failed) {
      this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
      unsuccessfulAuthentication(request, response, failed);
   }
   catch (AuthenticationException ex) {
      // Authentication failed
       // 認證失敗後的處理邏輯
      unsuccessfulAuthentication(request, response, ex);
   }
}

​ 接著我們看回UsernamePasswordAuthenticationFilter類,發現它主要是重寫了AbstractAuthenticationProcessingFilter類的attemptAuthentication(request,response)認證方法。具體如下:

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
      throws AuthenticationException {
   if (this.postOnly && !request.getMethod().equals("POST")) {
      throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
   }
    // 獲取請求的使用者名稱
   String username = obtainUsername(request);
   username = (username != null) ? username : "";
   username = username.trim();
    // 獲取請求輸入的密碼
   String password = obtainPassword(request);
   password = (password != null) ? password : "";
    // 構造帶有使用者名稱、密碼的UsernamePasswordAuthenticationToken物件
   UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
   // Allow subclasses to set the "details" property
    // 設定認證的物件
   setDetails(request, authRequest);
   return this.getAuthenticationManager().authenticate(authRequest);
}

接著我們執行程式,直接訪問http://localhost:8080/admin/api/hello地址,重定向到登入頁後,隨意輸入賬號、密碼後,在UsernamePasswordAuthenticationFilter類的attemptAuthentication方法上打斷點進行跟蹤,跟蹤到DaoAuthenticationProvider類的retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)方法,

​ 在AbstractUserDetailsAuthenticationProvider抽象中的需要指定一個實現UserDetailsService 介面的實現類,如果我們沒有指定,就是會去載入預設的InMemoryUserDetailManager類。

​ 因為採用的是上面提過的第二種方式:在程式碼中將賬號、密碼載入到記憶體中,然後我們並沒有在記憶體中預先載入我們輸入的賬號、密碼,所以自然是認證不通過的。

UserDetailsService 介面

​ 想要通過自定義的認證方式,也就是上面提到的第三種認證方式:從資料庫中讀取賬號進行認證校驗。所以需要自己去實現UserDetailsService 介面。剛才我們在跟蹤程式碼的過程中,發現AbstractUserDetailsAuthenticationProvider類是需要一個實現了UserDetailsService介面的物件,於是我們就自定義一個實現該介面的實現類,並注入到spring容器中。

@Service
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 從資料庫嘗試讀取該使用者
        User user = userMapper.findByUserName(username);
        // 使用者不存在,丟擲異常
        if (user == null) {
            throw new UsernameNotFoundException("使用者不存在");
        }
        // 將資料庫形式的roles解析為UserDetails的許可權集
        // AuthorityUtils.commaSeparatedStringToAuthorityList是Spring Security
        //提供的用於將逗號隔開的許可權集字串切割成可用許可權物件列表的方法
        // 當然也可以自己實現,如用分號來隔開等,參考generateAuthorities
        user.setAuthorities(AuthorityUtils.commaSeparatedStringToAuthorityList(user.getRoles()));
        return user;
    }
}

​ 如上圖,我們重寫了UserDetailsService介面的loadUserByUsername(String username)方法,從而實現我們的自定義認證邏輯。然後我們再重啟服務,重新訪問http://localhost:8080/admin/api/hello,再次登入,並進行程式碼跟蹤,

這個時候就發現DaoAuthenticationProvider從自己的userDetailsService拿到了我們自定義的物件,接著就會走我們的自定義認證邏輯。

​ 認證原始碼跟蹤就到這裡,接下來是授權的原始碼跟蹤,跟蹤文章較短,但大家瞭解一下還是有些收穫的。加油!

相關文章