Spring MVC 整合

Spring Security 提供了許多與 Spring MVC 的選用整合。本節將更詳細地介紹此整合。

@EnableWebMvcSecurity

從 Spring Security 4.0 開始,@EnableWebMvcSecurity 已被棄用。替代方案是 @EnableWebSecurity,它會根據類別路徑新增 Spring MVC 功能。

若要啟用 Spring Security 與 Spring MVC 的整合,請將 @EnableWebSecurity 註解新增至您的組態。

Spring Security 透過使用 Spring MVC 的 WebMvcConfigurer 來提供組態。這表示,如果您使用更進階的選項,例如直接與 WebMvcConfigurationSupport 整合,您需要手動提供 Spring Security 組態。

MvcRequestMatcher

Spring Security 提供了與 Spring MVC 如何比對 URL 的深度整合,透過 MvcRequestMatcher 實現。這有助於確保您的安全性規則與用於處理請求的邏輯相符。

若要使用 MvcRequestMatcher,您必須將 Spring Security 組態放置在與您的 DispatcherServlet 相同的 ApplicationContext 中。這是必要的,因為 Spring Security 的 MvcRequestMatcher 期望一個名為 mvcHandlerMappingIntrospectorHandlerMappingIntrospector Bean,由您的 Spring MVC 組態註冊,用於執行比對。

對於 web.xml 檔案,這表示您應該將組態放置在 DispatcherServlet.xml

<listener>
  <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>

<!-- All Spring Configuration (both MVC and Security) are in /WEB-INF/spring/ -->
<context-param>
  <param-name>contextConfigLocation</param-name>
  <param-value>/WEB-INF/spring/*.xml</param-value>
</context-param>

<servlet>
  <servlet-name>spring</servlet-name>
  <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
  <!-- Load from the ContextLoaderListener -->
  <init-param>
    <param-name>contextConfigLocation</param-name>
    <param-value></param-value>
  </init-param>
</servlet>

<servlet-mapping>
  <servlet-name>spring</servlet-name>
  <url-pattern>/</url-pattern>
</servlet-mapping>

以下 WebSecurityConfiguration 放置在 DispatcherServletApplicationContext 中。

  • Java

  • Kotlin

public class SecurityInitializer extends
    AbstractAnnotationConfigDispatcherServletInitializer {

  @Override
  protected Class<?>[] getRootConfigClasses() {
    return null;
  }

  @Override
  protected Class<?>[] getServletConfigClasses() {
    return new Class[] { RootConfiguration.class,
        WebMvcConfiguration.class };
  }

  @Override
  protected String[] getServletMappings() {
    return new String[] { "/" };
  }
}
class SecurityInitializer : AbstractAnnotationConfigDispatcherServletInitializer() {
    override fun getRootConfigClasses(): Array<Class<*>>? {
        return null
    }

    override fun getServletConfigClasses(): Array<Class<*>> {
        return arrayOf(
            RootConfiguration::class.java,
            WebMvcConfiguration::class.java
        )
    }

    override fun getServletMappings(): Array<String> {
        return arrayOf("/")
    }
}

我們始終建議您透過比對 HttpServletRequest 和方法安全性來提供授權規則。

透過比對 HttpServletRequest 提供授權規則是好的,因為它在程式碼路徑中發生得非常早,並有助於減少 攻擊面。方法安全性確保即使有人繞過了網路授權規則,您的應用程式仍然安全。這被稱為 縱深防禦

考慮一個控制器,其對應如下

  • Java

  • Kotlin

@RequestMapping("/admin")
public String admin() {
	// ...
}
@RequestMapping("/admin")
fun admin(): String {
    // ...
}

若要限制對此控制器方法的存取權限給管理員使用者,您可以透過比對 HttpServletRequest 來提供授權規則,如下所示

  • Java

  • Kotlin

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
	http
		.authorizeHttpRequests((authorize) -> authorize
			.requestMatchers("/admin").hasRole("ADMIN")
		);
	return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http {
        authorizeHttpRequests {
            authorize("/admin", hasRole("ADMIN"))
        }
    }
    return http.build()
}

以下清單在 XML 中執行相同的操作

<http>
	<intercept-url pattern="/admin" access="hasRole('ADMIN')"/>
</http>

使用任一組態,/admin URL 都要求經過身份驗證的使用者是管理員使用者。但是,根據我們的 Spring MVC 組態,/admin.html URL 也會對應到我們的 admin() 方法。此外,根據我們的 Spring MVC 組態,/admin URL 也會對應到我們的 admin() 方法。

問題是我們的安全性規則僅保護 /admin。我們可以為 Spring MVC 的所有排列組合新增其他規則,但這將會非常冗長且乏味。

幸運的是,當使用 requestMatchers DSL 方法時,如果 Spring Security 偵測到 Spring MVC 在類別路徑中可用,它會自動建立 MvcRequestMatcher。因此,它將保護與 Spring MVC 將比對的相同 URL,透過使用 Spring MVC 來比對 URL。

使用 Spring MVC 時的一個常見需求是指定 servlet 路徑屬性,為此您可以使用 MvcRequestMatcher.Builder 來建立多個共享相同 servlet 路徑的 MvcRequestMatcher 實例

  • Java

  • Kotlin

@Bean
public SecurityFilterChain filterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception {
	MvcRequestMatcher.Builder mvcMatcherBuilder = new MvcRequestMatcher.Builder(introspector).servletPath("/path");
	http
		.authorizeHttpRequests((authorize) -> authorize
			.requestMatchers(mvcMatcherBuilder.pattern("/admin")).hasRole("ADMIN")
			.requestMatchers(mvcMatcherBuilder.pattern("/user")).hasRole("USER")
		);
	return http.build();
}
@Bean
open fun filterChain(http: HttpSecurity, introspector: HandlerMappingIntrospector): SecurityFilterChain {
    val mvcMatcherBuilder = MvcRequestMatcher.Builder(introspector)
    http {
        authorizeHttpRequests {
            authorize(mvcMatcherBuilder.pattern("/admin"), hasRole("ADMIN"))
            authorize(mvcMatcherBuilder.pattern("/user"), hasRole("USER"))
        }
    }
    return http.build()
}

以下 XML 具有相同的效果

<http request-matcher="mvc">
	<intercept-url pattern="/admin" access="hasRole('ADMIN')"/>
</http>

@AuthenticationPrincipal

Spring Security 提供了 AuthenticationPrincipalArgumentResolver,它可以自動解析 Spring MVC 引數的目前 Authentication.getPrincipal()。透過使用 @EnableWebSecurity,您會自動將其新增至您的 Spring MVC 組態。如果您使用基於 XML 的組態,則必須自行新增此項

<mvc:annotation-driven>
		<mvc:argument-resolvers>
				<bean class="org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver" />
		</mvc:argument-resolvers>
</mvc:annotation-driven>

一旦您正確地組態了 AuthenticationPrincipalArgumentResolver,您就可以完全在您的 Spring MVC 層中與 Spring Security 解耦。

考慮一種情況,自訂 UserDetailsService 傳回一個實作 UserDetails 和您自己的 CustomUser ObjectObject。目前已驗證使用者的 CustomUser 可以透過使用以下程式碼存取

  • Java

  • Kotlin

@RequestMapping("/messages/inbox")
public ModelAndView findMessagesForUser() {
	Authentication authentication =
	SecurityContextHolder.getContext().getAuthentication();
	CustomUser custom = (CustomUser) authentication == null ? null : authentication.getPrincipal();

	// .. find messages for this user and return them ...
}
@RequestMapping("/messages/inbox")
open fun findMessagesForUser(): ModelAndView {
    val authentication: Authentication = SecurityContextHolder.getContext().authentication
    val custom: CustomUser? = if (authentication as CustomUser == null) null else authentication.principal

    // .. find messages for this user and return them ...
}

從 Spring Security 3.2 開始,我們可以透過新增註解來更直接地解析引數

  • Java

  • Kotlin

import org.springframework.security.core.annotation.AuthenticationPrincipal;

// ...

@RequestMapping("/messages/inbox")
public ModelAndView findMessagesForUser(@AuthenticationPrincipal CustomUser customUser) {

	// .. find messages for this user and return them ...
}
@RequestMapping("/messages/inbox")
open fun findMessagesForUser(@AuthenticationPrincipal customUser: CustomUser?): ModelAndView {

    // .. find messages for this user and return them ...
}

有時,您可能需要以某種方式轉換 Principal。例如,如果 CustomUser 需要是 final,則無法擴充它。在這種情況下,UserDetailsService 可能會傳回一個實作 UserDetails 並提供名為 getCustomUser 的方法來存取 CustomUserObject

  • Java

  • Kotlin

public class CustomUserUserDetails extends User {
		// ...
		public CustomUser getCustomUser() {
				return customUser;
		}
}
class CustomUserUserDetails(
    username: String?,
    password: String?,
    authorities: MutableCollection<out GrantedAuthority>?
) : User(username, password, authorities) {
    // ...
    val customUser: CustomUser? = null
}

然後,我們可以使用 SpEL 表達式 來存取 CustomUser,該表達式使用 Authentication.getPrincipal() 作為根物件

  • Java

  • Kotlin

import org.springframework.security.core.annotation.AuthenticationPrincipal;

// ...

@RequestMapping("/messages/inbox")
public ModelAndView findMessagesForUser(@AuthenticationPrincipal(expression = "customUser") CustomUser customUser) {

	// .. find messages for this user and return them ...
}
import org.springframework.security.core.annotation.AuthenticationPrincipal

// ...

@RequestMapping("/messages/inbox")
open fun findMessagesForUser(@AuthenticationPrincipal(expression = "customUser") customUser: CustomUser?): ModelAndView {

    // .. find messages for this user and return them ...
}

我們也可以在我們的 SpEL 表達式中引用 Bean。例如,如果我們正在使用 JPA 來管理我們的使用者,並且如果我們想要修改並儲存目前使用者的一個屬性,我們可以使用以下程式碼

  • Java

  • Kotlin

import org.springframework.security.core.annotation.AuthenticationPrincipal;

// ...

@PutMapping("/users/self")
public ModelAndView updateName(@AuthenticationPrincipal(expression = "@jpaEntityManager.merge(#this)") CustomUser attachedCustomUser,
		@RequestParam String firstName) {

	// change the firstName on an attached instance which will be persisted to the database
	attachedCustomUser.setFirstName(firstName);

	// ...
}
import org.springframework.security.core.annotation.AuthenticationPrincipal

// ...

@PutMapping("/users/self")
open fun updateName(
    @AuthenticationPrincipal(expression = "@jpaEntityManager.merge(#this)") attachedCustomUser: CustomUser,
    @RequestParam firstName: String?
): ModelAndView {

    // change the firstName on an attached instance which will be persisted to the database
    attachedCustomUser.setFirstName(firstName)

    // ...
}

我們可以透過使 @AuthenticationPrincipal 成為我們自己的註解上的 meta-annotation,進一步消除我們對 Spring Security 的依賴性。下一個範例示範了我們如何在名為 @CurrentUser 的註解上執行此操作。

為了消除對 Spring Security 的依賴性,將由消耗應用程式建立 @CurrentUser。此步驟並非嚴格要求,但有助於將您對 Spring Security 的依賴性隔離到更中心的位置。

  • Java

  • Kotlin

@Target({ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal
public @interface CurrentUser {}
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.TYPE)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@AuthenticationPrincipal
annotation class CurrentUser

我們已將我們對 Spring Security 的依賴性隔離到單一檔案中。現在已指定 @CurrentUser,我們可以使用它來發出訊號以解析目前已驗證使用者的 CustomUser

  • Java

  • Kotlin

@RequestMapping("/messages/inbox")
public ModelAndView findMessagesForUser(@CurrentUser CustomUser customUser) {

	// .. find messages for this user and return them ...
}
@RequestMapping("/messages/inbox")
open fun findMessagesForUser(@CurrentUser customUser: CustomUser?): ModelAndView {

    // .. find messages for this user and return them ...
}

Spring MVC Async 整合

Spring Web MVC 3.2+ 對於 非同步請求處理 具有出色的支援。在沒有額外組態的情況下,Spring Security 會自動將 SecurityContext 設定為呼叫控制器傳回的 CallableThread。例如,以下方法會自動將其 Callable 與建立 Callable 時可用的 SecurityContext 一起調用

  • Java

  • Kotlin

@RequestMapping(method=RequestMethod.POST)
public Callable<String> processUpload(final MultipartFile file) {

return new Callable<String>() {
	public Object call() throws Exception {
	// ...
	return "someView";
	}
};
}
@RequestMapping(method = [RequestMethod.POST])
open fun processUpload(file: MultipartFile?): Callable<String> {
    return Callable {
        // ...
        "someView"
    }
}
將 SecurityContext 關聯到 Callable

更技術性地說,Spring Security 與 WebAsyncManager 整合。用於處理 CallableSecurityContext 是在調用 startCallableProcessingSecurityContextHolder 上存在的 SecurityContext

沒有與控制器傳回的 DeferredResult 的自動整合。這是因為 DeferredResult 由使用者處理,因此無法自動與其整合。但是,您仍然可以使用 並行支援 來提供與 Spring Security 的透明整合。

Spring MVC 和 CSRF 整合

Spring Security 與 Spring MVC 整合以新增 CSRF 保護。

自動令牌包含

Spring Security 會自動 將 CSRF 令牌包含在 使用 Spring MVC 表單標籤 的表單中。考慮以下 JSP

<jsp:root xmlns:jsp="http://java.sun.com/JSP/Page"
	xmlns:c="http://java.sun.com/jsp/jstl/core"
	xmlns:form="http://www.springframework.org/tags/form" version="2.0">
	<jsp:directive.page language="java" contentType="text/html" />
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
	<!-- ... -->

	<c:url var="logoutUrl" value="/logout"/>
	<form:form action="${logoutUrl}"
		method="post">
	<input type="submit"
		value="Log out" />
	<input type="hidden"
		name="${_csrf.parameterName}"
		value="${_csrf.token}"/>
	</form:form>

	<!-- ... -->
</html>
</jsp:root>

先前的範例輸出類似於以下的 HTML

<!-- ... -->

<form action="/context/logout" method="post">
<input type="submit" value="Log out"/>
<input type="hidden" name="_csrf" value="f81d4fae-7dec-11d0-a765-00a0c91e6bf6"/>
</form>

<!-- ... -->

解析 CsrfToken

Spring Security 提供了 CsrfTokenArgumentResolver,它可以自動解析 Spring MVC 引數的目前 CsrfToken。透過使用 @EnableWebSecurity,您會自動將其新增至您的 Spring MVC 組態。如果您使用基於 XML 的組態,則必須自行新增此項。

一旦正確組態了 CsrfTokenArgumentResolver,您就可以將 CsrfToken 公開給您的靜態 HTML 基礎應用程式

  • Java

  • Kotlin

@RestController
public class CsrfController {

	@RequestMapping("/csrf")
	public CsrfToken csrf(CsrfToken token) {
		return token;
	}
}
@RestController
class CsrfController {
    @RequestMapping("/csrf")
    fun csrf(token: CsrfToken): CsrfToken {
        return token
    }
}

重要的是要對其他網域保密 CsrfToken。這表示,如果您使用 跨來源共享 (CORS),則 不應CsrfToken 公開給任何外部網域。