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 通过 MvcRequestMatcher 与 Spring MVC 的 URL 匹配方式进行了深度集成。这有助于确保您的安全规则与处理请求的逻辑一致。

要使用 MvcRequestMatcher,必须将 Spring Security 配置放在与您的 DispatcherServlet 相同的 ApplicationContext 中。这是必要的,因为 Spring Security 的 MvcRequestMatcher 期望您的 Spring MVC 配置注册一个名为 mvcHandlerMappingIntrospectorHandlerMappingIntrospector bean,用于执行匹配。

对于 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 来提供授权规则是好的,因为它在代码路径中发生得非常早,有助于减少攻击面。方法安全确保即使有人绕过了 Web 授权规则,您的应用程序仍然是安全的。这被称为纵深防御

考虑一个控制器,其映射如下:

  • 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 path 属性,为此您可以使用 MvcRequestMatcher.Builder 创建多个共享相同 servlet path 的 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,它可以自动解析当前 Authentication.getPrincipal() 用于 Spring MVC 参数。通过使用 @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 对象的 Object。可以通过以下代码访问当前认证用户的 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 可能会返回一个实现 UserDetailsObject,并提供一个名为 getCustomUser 的方法来访问 CustomUser

  • 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
}

然后我们可以使用将 Authentication.getPrincipal() 作为根对象的 SpEL 表达式来访问 CustomUser

  • 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 作为我们自己的注解上的元注解来进一步移除对 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 ...
}

一旦它成为元注解,参数化也对您可用。

例如,考虑当您将 JWT 作为 principal 并且想要指定要检索哪个 claim 时。作为元注解,您可以这样做:

  • Java

  • Kotlin

@Target({ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal(expression = "claims['sub']")
public @interface CurrentUser {}
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.TYPE)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@AuthenticationPrincipal(expression = "claims['sub']")
annotation class CurrentUser

这已经相当强大了。但是,它也仅限于检索 sub claim。

为了使其更灵活,首先发布 AnnotationTemplateExpressionDefaults bean,如下所示:

  • Java

  • Kotlin

  • Xml

@Bean
public AnnotationTemplateExpressionDefaults templateDefaults() {
	return new AnnotationTemplateExpressionDeafults();
}
@Bean
fun templateDefaults(): AnnotationTemplateExpressionDefaults {
	return AnnotationTemplateExpressionDeafults()
}
<b:bean name="annotationExpressionTemplateDefaults" class="org.springframework.security.core.annotation.AnnotationTemplateExpressionDefaults"/>

然后您可以像这样为 @CurrentUser 提供一个参数:

  • Java

  • Kotlin

@Target({ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal(expression = "claims['{claim}']")
public @interface CurrentUser {
	String claim() default 'sub';
}
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.TYPE)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@AuthenticationPrincipal(expression = "claims['{claim}']")
annotation class CurrentUser(val claim: String = "sub")

这将允许您在整个应用程序集中拥有更大的灵活性,如下所示:

  • Java

  • Kotlin

@RequestMapping("/messages/inbox")
public ModelAndView findMessagesForUser(@CurrentUser("user_id") String userId) {

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

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

Spring MVC 异步集成

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 会自动在使用了 Spring MVC 表单标签 的表单中包含 CSRF 令牌。考虑以下 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,它可以自动解析当前 CsrfToken 用于 Spring MVC 参数。通过使用@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 暴露给任何外部域。