Files
rikako-note/spring/Spring Security/Spring Security.md

67 KiB
Raw Blame History

Spring Security

Spring Security简介

Spring Security作为一个安全框架向使用者提供了用户认证、授权、常规攻击保护等功能。

Spring Security自动配置

默认情况下在引入Spring Security的启动器依赖之后Spring Boot自动配置会做如下工作

  • 启用Spring Security的默认配置创建一个的bean对象bean对象名称为“springSecurityFilterChain”bean对象类型为SecurityFilterChain实现了Filter。该bean对象为负责应用中所有与安全相关的操作例如验证提交的username和password保护应用的url等
  • 创建一个UserDetailsService的bean对象并且产生一个为”user“的username和一个随机产生的password随机产生的password会输出在console日志中
  • 将名为springSecurityFilterChain的bean对象注册到servlet容器中用来对每个servlet请求进行处理

Spring Security会通过Spring Security会通过BCtyptHash解密算法来对密码的存储进行保护

Spring Security结构

DelegatingFilterProxy

Spring提供了Filter的一个实现类DelegatingFilterProxy其将Servlet容器的生命周期和Spring的ApplicationContext连接在一起。
DelegatingFilterProxy会通过标准的servlet容器机制被注册但是将所有工作都委托给Spring容器中实现了Filter的bean对象

DelegatingFilterProxy会在ApplicationContext中查找实现了Filter的bean对象并且调用该bean对象的doFilter方法

public void doFilter(ServletRequest request, 
   ServletResponse response, FilterChain chain) {
  // Lazily get Filter that was registered as a Spring Bean
  // For the example in DelegatingFilterProxy delegate is an instance of Bean Filter0
  Filter delegate = getFilterBean(someBeanName);
  // delegate work to the Spring Bean
  delegate.doFilter(request, response);
}

FilterChainProxy

Spring Security支持FilterChainProxyFilterChainProxy是一个由Spring Security提供的特殊FilterFilterChainProxy通过SecurityFilterChain允许向多个Filters委托工作。
FilterChainProxy是一个bean对象通过被包含在DelegatingFilterProxy中。

SecurityFilterChain

SecurityFilterChain通常被FilterChainProxy使用用来决定在该次请求中调用哪个Spring Security Filters。

SecurityFilters

Security Filters通过SecurityFilterChain API被插入到FilterChainProxy中。

添加自定义filter到SecurityFilterChain

大多数情况下默认的security是足够的但是也允许向security filter chain中添加自定义过滤器。

如下是一个自定义过滤器的示例示例中自定义filter会在认证后校验用户对于请求的租户是否拥有权限

import java.io.IOException;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.security.access.AccessDeniedException;

public class TenantFilter implements Filter {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        String tenantId = request.getHeader("X-Tenant-Id"); //(1)
        boolean hasAccess = isUserAllowed(tenantId); //(2)
        if (hasAccess) {
            filterChain.doFilter(request, response); //(3)
            return;
        }
        throw new AccessDeniedException("Access denied"); //(4)
    }

}

如下是自定义过滤器链的流程:

  1. 步骤从请求header中获取了tenantId
  2. 步骤校验了用户对于该tenant id代表的租户资源是否拥有访问权限
  3. 只有当用户对租户资源拥有访问权限时才会继续调用filterChain中剩余的部分
  4. 如果用户没有访问权限将会抛出AccessDeniedException

在定义完自定义过滤器后还需要将自定义filter添加到Security Filter Chain中添加代码如下所示例

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // ...
        .addFilterBefore(new TenantFilter(), AuthorizationFilter.class);
    return http.build();
}

由于权限校验操作在身份认证之后故而将TenantFilter加到AuthorizationFilter之前可以保证在执行TenantFilter时所有的身份认证操作都已经执行完成。

在向SecurityFilterChain注册filter时不应该将自定义filter注册为bean因为spring会将注册为bean的filter对象都添加到内置容器中这会造成注册为bean的filter被调用两次一次被Spring Seurity调用一次被container调用。

处理Security异常

ExceptionTranslationFilter将AccessDeniedExceptionAuthenticationException翻译为http response。

ExceptionTranslationFilter被插入到FilterChainProxy中作为SecurityFilterChain中的一个filter。
如果应用程序没有抛出AccessDeniedException或AuthenticationException那么ExceptionTranslationFilter并不会做任何事情。

ExceptionTranslationFilter的处理流程

  1. ExceptionTranslationFilter首先会调用filterChain.doFilter来执行后续的逻辑
  2. 在执行后续请求时如果抛出了AuthenticationException那么则会开启认证流程
    1. 清空SecurityContextHolder
    2. 保存HttpServletRequest当认证通过时还能重新发送原始请求
    3. AuthenticationEntryPoint用于重新请求用户的凭证,其可能会重定向到登录页面或是发送 www-authenticateheader
  3. 在执行后续请求时如果抛出了AccessDeniedException那么会调用AccessDeniedHandler用于处理访问拒绝。
  4. 如果执行后续逻辑时,抛出了非AuthenticationExceptionAccessDeniedException的异常那么异常将会被重新抛出ExceptionTranslationFilter并不会针对其进行处理
// ExceptionTranslationFilter的伪代码
try {
	filterChain.doFilter(request, response); 
} catch (AccessDeniedException | AuthenticationException ex) {
	if (!authenticated || ex instanceof AuthenticationException) {
		startAuthentication(); 
	} else {
		accessDenied(); 
	}
}

在多次认证之间保存request

如果用户在发送请求访问需要认证的资源时尚未经过身份认证那么需要将本次请求保存起来并且对用户进行认证待用户认证通过之后再重新执行保存的原始请求。在Spring Security中是通过RequestCache来实现保存请求的。

RequestCache

HttpServletRequest被保存在RequestCache中当用户经过重新认证后RequestCache将会用于重新发送原始请求。

在Spring Security中在sendStartAuthentication时会像requestCache中存储request并且重新执行认证流程认证通过后将会重新发送原始请求。

RequestCacheAwareFilter负责请求的重新发送。

默认情况下,会使用HttpSessionRequestCache如下代码展示了HttpSessionRequestCache的自定义使用

@Bean
DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
	HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
	// 匹配本次request时需要确保请求url参数中具有continue参数
	requestCache.setMatchingRequestParameterName("continue");
	http
		// ...
		.requestCache((cache) -> cache
			.requestCache(requestCache)
		);
	return http.build();
}

由于在认证失败时存储原始请求会占用内存如果想要禁止在抛出AuthenticationException后保存原始请求到RequestCache的行为可以为SecurityFilterChain指定NullRequestCache

@Bean
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
    RequestCache nullRequestCache = new NullRequestCache();
    http
        // ...
        .requestCache((cache) -> cache
            .requestCache(nullRequestCache)
        );
    return http.build();
}

Spring Security身份认证架构

SecurityContextHolder

SecurityContextHolder用于存储用户的认证信息是Spring Security身份认证认证模型的核心SecurityContextHolder中包含有SecurityContextHolderSecurityContextHolder负责将当前线程和SecurityContext相关联。

如果想要将用户表示为已经通过身份认证最简单的方式是向SecurityContextHolder中设置值示例如下

SecurityContext context = SecurityContextHolder.createEmptyContext(); // 1
Authentication authentication =
    new TestingAuthenticationToken("username", "password", "ROLE_USER"); //2
context.setAuthentication(authentication);

SecurityContextHolder.setContext(context); //3

上述流程如下所示:

  1. 创建了一个空的SecurityContext。在最开始应该通过createEmptyContext方法创建一个空的SecurityContext而不是使用getContext().setAuthentication(authentication),从而避免在多线程下的竞争场景
  2. 第二步中创建了一个Authentication对象Spring Security不关注向SecurityContext中塞入了哪种类型的Authentication实现
  3. 在第三步中将SecurityContext设置到了SecurityContextHolder中spring使用设置的SecurityContext信息进行身份认证

如果后续操作想要访问当前已认证用户的信息可以访问SecurityContextHolder。

SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
Object principal = authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

在默认情况下,SecurityContextHolder使用ThreadLocal来存SecurityContext故而SecurityContext在同一线程内可以共享。在SecurityContextHolder中使用ThreadLocal是安全的不会发生当前请求完成而SecurityContext未被清理的情况Spring的FilterChainProxy保证SecurityContext会被清理。

在一些场景下可能不希望使用ThreadLocal来存储ContextHolder例如在swing程序下可能希望整个java虚拟机中的线程都共享同一个security context。可以在启动时为SecurityContextHolder指定一个strategy该strategy决定context应该如何被存储。

对于应用程序内所有线程共享的security context可以使用SecurityContextHolder.MODE_GLOBAL策略。

SecurityContext

Security Context用于存储用户的身份认证信息从SecurityContextHolder中获取。

Authentication

Authentication在Spring Security中具有两种使用场景

  • 在用户尚未被认证的场景,向AuthenticationManager提供用于身份认证的凭证
  • 在用户已经被认证的场景Authentication用于代表当前已经通过认证的用户此时可以通过SecurityContext来获取用户认证信息

Authentication包含有如下信息

  • 主体principal用于标识用户当通过username/password进行认证时其通常是UserDetails类的实例
  • 凭据credentials通常是password在许多场景下为了保证凭证不会被泄露在用户通过认证后凭证将会被清空
  • 权限authorities代表用户被授予的权限GrantedAuthority的实例集合

GrantedAuthority

GrantedAuthority是用户被授予的权限。

可以通过Authentication.getAuthorities()方法来获取用户被授予的权限集合,该方法会返回GrantedAuthority的集合。

AuthenticationManager

AuthenticationManager定义了Security Filters如何执行认证。

AuthenticationManager会对传递给其的Authentication执行认证操作如果认证成功会返回一个被完全填充的Authetication对象。

在AuthenticationManager的authenticate方法返回Authentication对象之后调用AuthenticationManager的controller会将返回的Authentication对象设置到SecurityContextHolder中。

AuthenticationManager的实现可以是任何类最通用的实现类是ProviderManager.

ProviderManager

ProviderManager是最常用的AuthenticationManager实现类ProviderMananger将认证工作委托给了一个AuthenticationProvider的实例集合。在集合中的每一个AuthenticationProvider都可以决定当前认证工作是认证成功、认证失败还是将认证工作交由集合中的后续AuthenticationProvider决定。

如果AuthenticationProvider集合中没有任何AuthenticationProvider可以决定当前认证工作是成功或是失败那么当前认证工作会失败并且抛出ProviderNotFoundException异常。该异常代表当前的认证方式没有被支持。

在实践中每个AuthenticationProvider都明确其应该负责的认证方式例如一个AuthenticationProvider负责用户名/密码形式的登录另一个AuthenticationProvider负责saml形式的登录。故而可以在支持多种认证方式的情况下只通过一个AuthenticationManager bean对象来进行整合多种认证方式。

对于ProviderManager可以配置一个parent ProviderManager当本级没有AuthenticationProvider能够完成认证工作时将会将认证工作委托给父级manager。

默认情况下ProviderManager将会在认证成功后清空任任何成功登录后返回Authenticaiton对象的凭证信息。

AuthenticationProvider

AuthenticationProvider负责执行特定类型的认证工作可以将多个AuthenticationProvider对象注入到同一个ProviderMananger中。

例如DaoAuthenticationProvider支持基于用户名/密码的认证JwtAuthenticationManager则是支持基于jwt形式的认证。

AuthenticationEntryPoint

AuthenticationEntryPoint用于服务端向用户请求凭证例如重定向到登录页面向客户端发送WWW-Authenticate响应等。)

一些场景下用户在请求资源时会主动发送在请求中包含凭据。在这些场景下spring security不用向客户返回相应来要求客户提供凭证。

在其他场景下,客户端可能在未认证的情况下发送请求来访问资源,这时,可以使用AuthenticationEntryPoint的实现来向客户请求凭证。AuthenticationEntryPoint的实现可以重定向到登录页,或是向客户端发送WWW-Authenticate响应,或是采用其他方式来向客户端请求凭据。

AbstractAuthenticationProcessingFilter

AbstractAuthenticationProcessingFilter用于认证用户的凭据。在凭据可以被认证之前spring security通过会通过AuthentciationEntryPoint来向客户请求凭据。

AbstractAuthenticationProcessingFilter通过如下流程来执行认证

  1. 当用户提交凭据时AbstractAuthenticationProcessingFilter会根据将要被认证的request来创建一个Authentication对象创建Authentication的类型基于AbstractAuthenticationProcessingFilter的继承类。例如UsernamePasswordAuthenticationFilter将会基于request中提交的username和password创建一个UsernamePasswordAuthenticationToken
  2. 接下来Authentication将会被传递给AuthenticationMananger用于认证
  3. 如果认证失败,那么
    1. SecurityContextHolder将会被清空
    2. RememberMeServices.loginFail方法将会被调用如果remeberMe没有被调用那么将不会执行任何操作
    3. AuthenticationFailureHandler将会被调用
  4. 如果认证成功:
    1. 将会通知SessionAuthenticaitonStrategy将会被提示有一个新的登录
    2. Authentication将会被设置到SecurityContextHolder中。如果想要在多个请求之前保存context需要手动调用SecurityContextRepository#saveContext方法调用后SecurityContext可以在后续的请求中自动被设置
    3. 调用RememberMeServices.loginSuccess方法如果没有设置rememberMe那么不会执行任何操作
    4. ApplicationEventPublisher将会发不InteractiveAuthenticationSuccessEvent事件
    5. AuthenticationSuccessHandler将会被调用

Username/Password Authentication

认证用户时最广泛使用的认证方式为用户名/密码认证。Spring Security对基于用户名和密码的认证提供了全面的支持。

可以通过如下方式来配置用户名和密码认证:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http
			.authorizeHttpRequests((authorize) -> authorize
				.anyRequest().authenticated()
			)
			.httpBasic(Customizer.withDefaults())
			.formLogin(Customizer.withDefaults());

		return http.build();
	}

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

		return new InMemoryUserDetailsManager(userDetails);
	}

}

上述示例向SecurityFilterChain中自动注册了InMemoryUserDetailsService并且向默认的AuthenticationManager中注册了DaoUserAuthenticationProvider并且启用了FormLogin和HttpBasic的认证。

发布AuthenticationManager bean

当想要自定义认证过程时可以将AuthenticationManager发布为bean对象示例如下

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http
			.authorizeHttpRequests((authorize) -> authorize
				.requestMatchers("/login").permitAll()
				.anyRequest().authenticated()
			);

		return http.build();
	}

	@Bean
	public AuthenticationManager authenticationManager(
			UserDetailsService userDetailsService,
			PasswordEncoder passwordEncoder) {
		DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
		authenticationProvider.setUserDetailsService(userDetailsService);
		authenticationProvider.setPasswordEncoder(passwordEncoder);

		return new ProviderManager(authenticationProvider);
	}

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

		return new InMemoryUserDetailsManager(userDetails);
	}

	@Bean
	public PasswordEncoder passwordEncoder() {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder();
	}

}

在上述示例中没有指定formLogin或是httpBasic来执行身份认证并且针对login请求其权限校验为permitAll故而login请求的实际身份认证工作由自定义的loginController来执行

当添加了上述Configuration类后可以指定自定义的Controller类

@RestController
public class LoginController {

	private final AuthenticationManager authenticationManager;

	public LoginController(AuthenticationManager authenticationManager) {
		this.authenticationManager = authenticationManager;
	}

	@PostMapping("/login")
	public ResponseEntity<Void> login(@RequestBody LoginRequest loginRequest) {
		Authentication authenticationRequest =
			UsernamePasswordAuthenticationToken.unauthenticated(loginRequest.username(), loginRequest.password());
		Authentication authenticationResponse =
			this.authenticationManager.authenticate(authenticationRequest);
		// ...
	}

	public record LoginRequest(String username, String password) {
	}

}

自定义AuthenticationManager

默认情况下SpringSecurity会在内部构建一个AuthenticationManager该AuthenticationManager由DaoAuthenticationProvider组成用于对username/password进行认证。

为了实现AuthenticationMananger的自定义可以发布一个AuthenticationManager bean对象spring security会使用发布的bean对象。

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http
			.authorizeHttpRequests((authorize) -> authorize
				.requestMatchers("/login").permitAll()
				.anyRequest().authenticated()
			)
			.httpBasic(Customizer.withDefaults())
			.formLogin(Customizer.withDefaults());

		return http.build();
	}

	@Bean
	public AuthenticationManager authenticationManager(
			UserDetailsService userDetailsService,
			PasswordEncoder passwordEncoder) {
		DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
		authenticationProvider.setUserDetailsService(userDetailsService);
		authenticationProvider.setPasswordEncoder(passwordEncoder);

		ProviderManager providerManager = new ProviderManager(authenticationProvider);
		providerManager.setEraseCredentialsAfterAuthentication(false);
		// provider为最常用的AuthenticationManager的实现类
		return providerManager;
	}

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

		return new InMemoryUserDetailsManager(userDetails);
	}

	@Bean
	public PasswordEncoder passwordEncoder() {
		return PasswordEncoderFactories.createDelegatingPasswordEncoder();
	}

}

除了发布自定义的AuthenticaitonMananger之外还可以通过修改AuthenticationManagerBuilder bean对象来实现。

AuthenticationManagerBuilder被发布为bean对象并且用于构建Spring Security的全局AuthenticationManager可以按如下方式来自定义

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		// ...
		return http.build();
	}

	@Bean
	public UserDetailsService userDetailsService() {
		// Return a UserDetailsService that caches users
		// ...
	}

	@Autowired
	public void configure(AuthenticationManagerBuilder builder) {
		builder.eraseCredentials(false);
	}

}

如果想要自定义登录页,可以通过如下方式:

public SecurityFilterChain filterChain(HttpSecurity http) {
	http
		.formLogin(form -> form
			.loginPage("/login")
			.permitAll()
		);
	// ...
}

并且在使用spring mvc时需要将/login和页面模板映射

@Controller
class LoginController {
	@GetMapping("/login")
	String login() {
		return "login";
	}
}

上述permitAll方法确保了所有用户对loginUrlloginProcessingUrlfailureUrl都拥有访问权限。

从请求中读取Username/Password

Spring Security提供如下方式来从请求中读取Username和password

  • Form
  • Basic
  • Digest

Form

在http用户名和密码被作为http form的形式x-www-form-urlencoded被传递时Spring Security支持获取form形式的username和password。

默认情况下Spring Security form login被开启但是如果在应用手动配置了SecurityFilterChain那么必须显式调用formLogin来指定。

public SecurityFilterChain filterChain(HttpSecurity http) {
	http
		.formLogin(withDefaults());
	// ...
}

非/login请求时UsernamePassword并不执行认证校验逻辑

当未认证用户访问受保护资源时AuthorizationFilter会抛出AccessDenied异常导致重定向到登录页面。

UsernamePasswordAuthenticationFilter当请求url并不为/login时并不执行认证逻辑而是交由后续AuthorizationFilter来校验当前用户是否由访问资源的权限。

UsernamePasswordAuthenticationFilter只是在用户发送/login请求执行登录时才通过authenticationManager来执行认证逻辑。

HttpBasic

在使用httpBasic时如果未经认证的用户请求受保护资源会返回WWW-Authenticate的header此时客户端应该在下次提交时在请求中添加用户名和密码。

在使用httpBasic时应该在请求的Authorizationheader中添加username和password的内容形式如下

Authorization: Basic <credentials>

其中<credentials>应该通过base64进行加密

Digest

目前应用中不应使用Digest。

password storage

in-memory authentication

InMemoryUserDetailsManager实现了UserDetailsService其将用户名和密码存储在内存中并且提供了基于username和password的认证。

使用实例如下所示:

@Bean
public UserDetailsService users() {
	UserDetails user = User.builder()
		.username("user")
		.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
		.roles("USER")
		.build();
	UserDetails admin = User.builder()
		.username("admin")
		.password("{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW")
		.roles("USER", "ADMIN")
		.build();
	return new InMemoryUserDetailsManager(user, admin);
}

在上述示例中,需要手动对密码进行散列和编码处理,可以通过User.withDefaultPasswordEncoder来自动对传入密码进行编码处理:

@Bean
public UserDetailsService users() {
	// The builder will ensure the passwords are encoded before saving in memory
	UserBuilder users = User.withDefaultPasswordEncoder();
	UserDetails user = users
		.username("user")
		.password("password")
		.roles("USER")
		.build();
	UserDetails admin = users
		.username("admin")
		.password("password")
		.roles("USER", "ADMIN")
		.build();
	return new InMemoryUserDetailsManager(user, admin);
}

jdbc authentication

JdbcDaoImpl实现了UserDetailsService,支持基于用户名和密码的认证,并且将密码存储在数据库中。

UserDetails

UserDetailsUserDetailsService返回,DaoAuthenticationProvider针对UserDetails及逆行校验并且返回Authentication。Authentication是基于UserDetails进行构建的。

UserDetailsService

DaoAuthenticationProvider通过UserDetailsService来获取UserDetails信息UserDetails信息中包含用户名、密码和用户其他信息。

可以通过自定义UserDetailsService的bean对象来自定义UserDetails的获取逻辑

@Bean
CustomUserDetailsService customUserDetailsService() {
	return new CustomUserDetailsService();
}

RememberMe

rememberMe要求在多个session之间应用能够记住用户的身份。其通常是由cookie实现的如果指定cookie在后续请求中被检测到那么会进行自动登录。

Spring Security提供了两种Remember Me实现一种是基于hash来保存token另一种则是通过数据库来保存token。

hash based token

该方案通过hash来实现有效的rememberMe策略在认证成功后如下cookie会被发送给客户端

base64(username + ":" + expirationTime + ":" + algorithmName + ":"
+algorithmHex(username + ":" + expirationTime + ":" password + ":" + key))

username:          As identifiable to the UserDetailsService
password:          That matches the one in the retrieved UserDetails
expirationTime:    The date and time when the remember-me token expires, expressed in milliseconds
key:               A private key to prevent modification of the remember-me token
algorithmName:     The algorithm used to generate and to verify the remember-me token signature

其中第四部分通过usernameexpirationTimepasswordkey以algorithmName算法生成。

当该cookie重新被发送到服务端时服务端会取出cookie的前三部分并通过UserDetailService查询出password该password为获取的UserDetails中的password为编码后的密码按照cookie中指定的算法结合存储在服务端的private key重新计算出第四部分并且将计算结果和第四部分进行比较。

该算法可以保证cookie并没有被篡改因为password和private key都存储在服务端如果手动修改username或是过期时间那么都会导致服务端计算的第四部分结果不匹配。

但是使用该hash-based方法时存在安全隐患因为所有捕获到该token的agent都可以使用该token访问用户资源直到token过期。

在用户意识到token已经被泄露时可以通过修改密码来使该token无效因为修改密码后第四部分的计算结果会发生变化导致旧token失效。

如果想要开启rememberMe可以通过如下方式开启

http
                .authorizeHttpRequests(authorize -> {
                    authorize.requestMatchers("/login").permitAll()
                            .anyRequest().authenticated();
                })
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults())
                .rememberMe(Customizer.withDefaults());
        return http.build();

将token持久化到数据库中

为了在多次session之间记住用户的身份信息可以通过将token存储到数据库中从而记住用户的身份。

RememberMe的接口及其实现

remember-me将会由UsernamePasswordAuthenticationFilter来使用与rememberMe相关的hook位于RememberMeServices接口中并且将会在适当的时机被调用。如下展示了rememberMe相关的接口定义

Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);

void loginFail(HttpServletRequest request, HttpServletResponse response);

void loginSuccess(HttpServletRequest request, HttpServletResponse response,
	Authentication successfulAuthentication);

AbstractAuthenticationProcessingFilter只会调用loginSuccess或loginFail方法autoLogin方法将会由RememberMeAuthenticationFilter进行调用,当SecurityContextHolder不包含Authentication对象时就会调用autoLogin方法。

TokenBasedRememberMeService

TokenBasedRemeberMeService支持上述基于hash的rememberMe方法。TokenBasedRememberMeServices会生成RememberMeAuthenticationToken该token将会由RememberMeAuthenticationProvider进行验证。

为了TokenBasedRememberMeServices生成的token必须能被RememberMeAuthenticationToken正确的校验,必须相同的key必须能够在两者之间进行共享。

默认情况下TokenBasedRememberMeService使用SHA-256来对token进行编码。如果想要针对TokenBasedRememberMeServices进行自定义可以发布自己的bean

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, RememberMeServices rememberMeServices) throws Exception {
	http
			.authorizeHttpRequests((authorize) -> authorize
					.anyRequest().authenticated()
			)
			.rememberMe((remember) -> remember
				.rememberMeServices(rememberMeServices)
			);
	return http.build();
}

@Bean
RememberMeServices rememberMeServices(UserDetailsService userDetailsService) {
	RememberMeTokenAlgorithm encodingAlgorithm = RememberMeTokenAlgorithm.SHA256;
	TokenBasedRememberMeServices rememberMe = new TokenBasedRememberMeServices(myKey, userDetailsService, encodingAlgorithm);
	// 修改编码算法
	rememberMe.setMatchingAlgorithm(RememberMeTokenAlgorithm.MD5);
	return rememberMe;
}

为了启用rememberMe需要在应用上下文中包含如下bean

@Bean
RememberMeAuthenticationFilter rememberMeFilter() {
    RememberMeAuthenticationFilter rememberMeFilter = new RememberMeAuthenticationFilter();
    rememberMeFilter.setRememberMeServices(rememberMeServices());
    rememberMeFilter.setAuthenticationManager(theAuthenticationManager);
    return rememberMeFilter;
}

@Bean
TokenBasedRememberMeServices rememberMeServices() {
    TokenBasedRememberMeServices rememberMeServices = new TokenBasedRememberMeServices();
    rememberMeServices.setUserDetailsService(myUserDetailsService);
    rememberMeServices.setKey("springRocks");
    return rememberMeServices;
}

@Bean
RememberMeAuthenticationProvider rememberMeAuthenticationProvider() {
    RememberMeAuthenticationProvider rememberMeAuthenticationProvider = new RememberMeAuthenticationProvider();
    rememberMeAuthenticationProvider.setKey("springRocks");
    return rememberMeAuthenticationProvider;
}

匿名身份认证

Spring Security中采用了deny-by-default策略用以适配如下场景除少数资源外如homepage、login页面等访问其他所有的资源都需要进行身份认证。

Spring Security提供了匿名身份认证对于“匿名身份认证”的用户其实际和未经认证的用户没有任何区别。匿名认证只是为配置访问控制属性提供了更加便捷的方式在匿名认证时即使当前SecurityContextHolder中存在anonymous authentication对象在调用一些servlet api例如getCallerPrincipal方法时方法仍然会返回null。

在使用anonymous authentication时Spring Security的SecurityContextHolder一定含有Authentication对象即使用户未经认证Authentication对象也不可能为空。

匿名认证配置

匿名认证在Spring Security中是默认被提供的可以对其进行自定义或者关闭。

为了提供匿名认证特性,需要存在如下三个类:AnonymousAuthenticationTokenAnonymousAuthenticationProviderAnonymousAuthenticationFilter

  • AnonymousAuthenticationToken:该类是Authentication的实现类,并且存储了该匿名实体所拥有的权限GrantedAuthority
  • AnonymousAuthenticationProvider:该类被整合到ProviderManager的provider链中故而AnonymousAuthenticationToken可以被支持进行处理
  • AnonymousAuthenticationFilter该filter被嵌入到常规的认证机制之后如果在执行到该filter时当前SecurityContextHolder中不存在Authentication那么其会自动将AnonymousAuthenticationToken加入到SecurityContextHolder中。

定义filter和provider的方式如下所示filter和provider共享相同的key故而filter创建的token可以被provider接收

<bean id="anonymousAuthFilter"
	class="org.springframework.security.web.authentication.AnonymousAuthenticationFilter">
<property name="key" value="foobar"/>
<property name="userAttribute" value="anonymousUser,ROLE_ANONYMOUS"/>
</bean>

<bean id="anonymousAuthenticationProvider"
	class="org.springframework.security.authentication.AnonymousAuthenticationProvider">
<property name="key" value="foobar"/>
</bean>

userAttribute属性值,其格式为usernameInTheAuthenticationToken,grantedAuthority[,grantedAuthority]

处理logout

如果当前项目的类路径中包含spring-boot-starter-security依赖那么spring security会自动加入logout支持针对GETPOST/logout请求都会响应。

当通过get方式请求/logout时spring security将展示一个登出页面。但如果csrf保护被关闭那么不会展示登出确认页面而是直接关闭。

当请求/logout时将通过一系列logoutHandler来实现如下逻辑

  1. 将http session标记为失效SecurityContextLogoutHandler
  2. 清空SecurityContextHolderStrategy (SecurityContextLogoutHandler)
  3. 清空SecurityContextRepository(SecurityContextLogoutHandler)
  4. 清空所有rememberMe authenticationTokenRememberMeServices / PersistentTokenRememberMeServices
  5. 清空任何csrf token (LogoutSuccessEventPublishingLogoutHandler)
  6. 发送LogoutSuccessEventLogoutSuccessEventPublishingLogoutHandler

自定义登出url

在filter chain中logoutFilter位于AuthorizationFilter之前故而没有必要显式的对/logout url执行permit操作因为尚未执行到AuthorizationFilter之前就会通过logoutFilter执行登出操作

但是对于自定义的登出端口默认情况下logooutFilter只对/logout的请求进行处理若url不为/logout那么需要调用permitAll来让自定义登出端口可访问。

如果只是要修改spring security登出的url可以通过如下方式进行修改且不需要其他任何修改其单单只是改变了logoutFilter匹配的url

http
    .logout((logout) -> logout.logoutUrl("/my/logout/uri"))

但是如果自定义了logout success endpoint通过spring mvc建立的controller endpoint那么需要为自定义的logout endpoint执行permit操作来允许用户访问。因为只有当spring security中的操作都完成后才会指定自定义的endpoint操作。

可以通过如下方式为自定义的登出endpoint进行授权

http
    .authorizeHttpRequests((authorize) -> authorize
        .requestMatchers("/my/success/endpoint").permitAll()
        // ...
    )
    .logout((logout) -> logout.logoutSuccessUrl("/my/success/endpoint"))

logoutSuccessUrl为logout操作执行成功后重定向到的url该值默认为/login?logout

logoutUrl则是触发logout操作的url。

Authorization

结构

通过AuthenticationManagerGrantedAuthority对象集合被插入到Authentication对象中该集合代表被授予给当前主体的权限。插入到Authentication中的GrantedAuthority集合将会后续被AccessDecisionManager用于判断当前主体是否拥有访问权限。

GrantedAuthority接口中只有一个方法:

String getAuthority();

该方法只会被AuthorizationManager使用通过该方法可以获取代表该权限的String。如果GrantedAuthority无法通过一个String精确的描述那么该GrantedAuthority将被视为复杂的并且该GrantedAuthority的getAuthority方法必须返回null。

如下展示了一个复杂权限的示例:

一个GrantedAuthority中存储了多个客户的权限信息每个客户可以进行的操作和可以访问资源的权限都不相同。对于多个客户的权限信息很难勇单个String来表示故而复杂权限的实现里,getAuthority方法必须返回为null。

如果GrantedAuthority实现的getAuthority方法返回为null这将提示AuthorizationManager需要支持该特定GrantedAuthority实现类的解析从而才能读取authority实现中的内容。

Spring Security中包含一个GrantedAuthority的实现:SimpleGrantedAuthority。该实现能够让任何字符串转化为对应的GrantedAuthority。并且在Spring Security中所有的AuthenticationProvider都使用SimpleGrantedAuthority来注入权限到Authentication对象中。

默认情况下,基于角色的权限管理都以ROLE_作为代表权限字符串的开头故而如果当前存在Authorization Rule要求security context中拥有USER权限那么Spring Security中是否包含getAuthority返回为ROLE_USER的GrantedAuthority对象。

如果想要自定义基于角色的权限管理的前缀,可以通过GrantedAuthorityDefaults来进行自定义。通过注册GrantedAuthorityDefaults类型的bean对象可以进行自定义

@Bean
static GrantedAuthorityDefaults grantedAuthorityDefaults() {
	return new GrantedAuthorityDefaults("MYPREFIX_");
}

Invocation Handling

Spring Security通过拦截器保证了对受保护资源的访问对于method invocation和web request都能进行拦截。AuthorizationManager既会在调用发生前判断当前是否存在调权限也会在调用后判断是否调用结果允许被返回。

AuthorizationManager对AccessDecisionManager和AccessDecisionVoter两者都进行了取代。

AuthorizationManager被spring security的基于基于method组件、基于request组件、基于message组件调用并且AuthorizationManager负责做最后的权限控制决定。AuthorizationManager包含有如下两个方法

AuthorizationDecision check(Supplier<Authentication> authentication, Object secureObject);

default AuthorizationDecision verify(Supplier<Authentication> authentication, Object secureObject)
        throws AccessDeniedException {
    // ...
}

在调用AuthorizationManager#check方法时会向其中传入为做出访问决定所需的所有相关信息。特别是传递了secure object这令实际secure object调用中所有的参数都能够在check方法中被观测到。

例如假定该secure object是MethodInvocation通过MethodInvocation可以方便的访问调用方法和调用参数并且在check方法中实现一系列逻辑判断当前主体是否拥有权限执行操作。

check方法会返回AuthorizationDecision类型的返回值如果允许当前访问应该返回一个positive AuthorizationDecision如果拒绝当前访问应该返回一个negative AuthorizationDecision如果AuthorizationManager放弃做决定则应该返回null。

AuthorizationManager#verify方法会调用check方法若check方法返回negative AuthorizationDecision则verify方法会抛出AccessDeniedException。

基于委托的AuthorizationManager实现

Spring Security设计了一个委托AuthorizationManager其可以和开发者自己实现的复数个AuthorizationManager协作。

RequestMatcherDelegatingAuthorizationManager将会为当前请求匹配最适合的AuthorizationManager并将任务委托给匹配到的AuthorizationManager。对于基于方法的权限认证可以使用AuthorizationManagerBeforeMethodInterceptorAuthorizationManagerAfterMethodInterceptor

通过上述的委托机制可以将多个AuthenticationManager组合起来并通过轮询来做Authorization decision。

AuthorityAuthorizationManager

AuthorityAuthorizationManager是spring security提供的最通用的AuthorizationManager

AuthorityAuthorizationManager构造方法接收一系列的权限字符串并且在对Authentication进行校验时会校验构造函数中接收的权限字符串集合其中是否有一个权限字符串在authentication.getAuthorities中存在,如果任一权限字符串存在,那么权限校验通过,否则权限校验失败。

AuthenticatedAuthorizationManager

AuthenticatedAuthorizationManager可以用于区分anonymous、fully-authenticated、remember-me请求。这可以用于如下场景通过remember-me登录的用户只能够执行有限的操作但是通过full-authenticated登录的用户可以执行所有的操作。

AuthorizationManagers

AuthorizationManagers类中包含静态工厂方法来将多个单独的AuthorizationManager整合在一起构成更加复杂的表达式。

自定义AuthorizationManager

可以通过实现AuthorizationManager接口来实现任何逻辑的实现类。

ROLE继承

通过角色继承,可以配置某些角色包含其他角色,可执行其他角色能做的所有操作。

RoleHierarchyVoter作为RoleVoter的拓展版本,可以配置RoleHierarchy

@Bean
static RoleHierarchy roleHierarchy() {
    RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
    hierarchy.setHierarchy("ROLE_ADMIN > ROLE_STAFF\n" +
            "ROLE_STAFF > ROLE_USER\n" +
            "ROLE_USER > ROLE_GUEST");
    return hierarchy;
}

// and, if using method security also add
@Bean
static MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
	DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
	expressionHandler.setRoleHierarchy(roleHierarchy);
	return expressionHandler;
}

在上述示例中,具有ADMIN => STAFF => USER => GUEST的继承关系。一个拥有ROLE_ADMIN角色的用户将自动拥有其他角色的权限,其中>符号代表包含关系。

基于HttpServletRequest的权限认证

spring security允许基于request的级别构建认证模型。例如通过spring security可以构建如下权限认证模型所有/admin下的资源访问都需要特定的权限,而其他的资源访问则只需要身份认证通过即可。

默认情况下spring security需要每个请求都必须通过身份认证。故而在每次构建HttpSecurity实例时必须都要声明authorization rules

每次在声明HttpSecurity时都至少需要指定如下authorization rules,除此之外还可以指定更加复杂的auhorization rules

http
    .authorizeHttpRequests((authorize) -> authorize
        .anyRequest().authenticated()
    )

上述配置会指定在访问任何endpoint时至少都需要当前用户通过身份认证。

基于Request的Authorization Component实现

  1. 首先AuthorizationFilter会构建一个Supplier<Authenticaiton>该Supplier将会从SecurityContextHolder中获取Authentication独享
  2. AuthorizationFilter将会把Supplier<Authentication>HttpServletRequest传递给AuthorizationManagerAuthorizationManager将会在HttpSecurity的authorizeHttpRequests指定的authorization rules中匹配到的rule并执行。
    1. 如果当前鉴权操作被拒绝AuthorizationDeniedEvent事件将会被发布并会抛出AccessDeniedException异常在这种情况下ExceptionTranslationFilter将会对异常进行处理
    2. 如果当前鉴权校验通过,AuthorzationGrantedEvent事件将会被发布,AuthorizationFilter将会继续执行filterChain允许程序正常的继续执行

AuthorizationFilter默认情况下是SecurityFilterChain中的最后一个filter

默认情况下AuthorizationFilter是SecurityFilterChain中的最后一个filter。这意味着spring security中身份认证、漏洞保护和其他filter中的集成功能都不需要经过授权操作。如果向AuthorizationFilter之前加入自定义filter那么自定义filter也不需要经过授权校验如果向AuthorizationFilter之后加入自定义filter那么在执行自定义filter逻辑之前需要经过授权校验。

在通过spring mvc添加自定义的endpoint时需要明确controller method是在AuthorizationFilter之后才执行的在执行endpoint的逻辑之前需要保证该authorizeHttpRequests中定义的rules中对endpoint的访问应该被授权。

Authentication查询是延迟的

AuthorizationManager api使用了Supplier<Authentication>,故而在authorizeHttpRequests如果所有请求都被允许或所有请求都被拒绝的情况下并不会去查询Authentication这将会令请求处理得更快。

authorizing endpoint

当为spring security按顺序配置了多个rules时如果想要/endpoint只能够被拥有USER权限的用户访问,可以通过如下配置来指定:

@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
	http
		.authorizeHttpRequests((authorize) -> authorize
			.requestMatchers("/endpoint").hasAuthority("USER")
			.anyRequest().authenticated()
		)
        // ...

	return http.build();
}

如上所示对rules的配置可以拆分成多个pattern/rule集合。

AuthorizationFilter将会按照顺序对这些pattern/rule进行处理并对request执行匹配到的第一个rule。上述配置即意味着/endpoint的访问需要用户拥有USE权限而其他资源的访问则只需要用户经过身份认证即可。

请求匹配的pattern声明

如果需要声明匹配请求的pattern可以使用Ant或是regex两种方式。

Ant

Ant是Spirng Security默认的请求匹配方式。通过Ant进行匹配的示例如下

http
    .authorizeHttpRequests((authorize) -> authorize
        .requestMatchers("/resource/**").hasAuthority("USER")
        .anyRequest().authenticated()
    )

通过Ant可以声明占位符并且在匹配时进行捕获以供后续使用示例如下

http
    .authorizeHttpRequests((authorize) -> authorize
        .requestMatchers("/resource/{name}").access(new WebExpressionAuthorizationManager("#name == authentication.name"))
        .anyRequest().authenticated()
    )

上述请求匹配中name占位符对应的值被捕获并通过捕获的值来判断用户是否拥有访问权限。

regex

除了通过Ant来进行匹配外spring security还支持通过regex来对请求进行匹配。

如下展示了一个通过regex来进行请求匹配的示例匹配的是请求地址中包含username并且username完全由字母和数字组成的请求

http
    .authorizeHttpRequests((authorize) -> authorize
        .requestMatchers(RegexRequestMatcher.regexMatcher("/resource/[A-Za-z0-9]+")).hasAuthority("USER")
        .anyRequest().denyAll()
    )
通过Http Method来进行匹配

spring security中的rules匹配还支持通过Http method来进行匹配。

如下示例代表所有GET请求都需要拥有read权限所有的POST请求都需要拥有write权限

http
    .authorizeHttpRequests((authorize) -> authorize
        .requestMatchers(HttpMethod.GET).hasAuthority("read")
        .requestMatchers(HttpMethod.POST).hasAuthority("write")
        .anyRequest().denyAll()
    )
自定义Matcher来进行匹配

如下示例定义了通过自定义Matcher的匹配

RequestMatcher printview = (request) -> request.getParameter("print") != null;
http
    .authorizeHttpRequests((authorize) -> authorize
        .requestMatchers(printview).hasAuthority("print")
        .anyRequest().authenticated()
    )

Authorizing Request

一旦请求匹配了特定的rule可以通过如下方式来对请求执行鉴权逻辑

  • permitAll该请求无需经过权限校验请求访问的是public endpoint在这种情况下Authentication并不会从session中被获取
  • denyAll该请求在任何场景下都是不允许的在这种情况下也不会从session中获取Authentication
  • hasAuthority:该操作需要请求对应的Authenticaiton对象所拥有的GrantedAuthority集合中,存在指定的权限
  • hasRole:类似于hasAuthority,但是在要求的请求中需要补充ROLE_前缀,hasRole("XXX")等价于hasAuthority("ROLE_XXX")
  • hasAnyAuthority:要求请求拥有指定权限集合中的任意一个
  • hasRole:拥有指定角色中的任意一个
  • access该请求将会使用自定义的AuthorityManager来判断是否拥有权限来进行访问

如下是一个较为复杂的权限认证示例:

import static jakarta.servlet.DispatcherType.*;

import static org.springframework.security.authorization.AuthorizationManagers.allOf;
import static org.springframework.security.authorization.AuthorityAuthorizationManager.hasAuthority;
import static org.springframework.security.authorization.AuthorityAuthorizationManager.hasRole;

@Bean
SecurityFilterChain web(HttpSecurity http) throws Exception {
	http
		// ...
		.authorizeHttpRequests(authorize -> authorize                                 //(1)
            .dispatcherTypeMatchers(FORWARD, ERROR).permitAll() //(2)
			.requestMatchers("/static/**", "/signup", "/about").permitAll()         //(3)
			.requestMatchers("/admin/**").hasRole("ADMIN")                             //(4)
			.requestMatchers("/db/**").access(allOf(hasAuthority("db"), hasRole("ADMIN")))   (5)
			.anyRequest().denyAll()                                                //(6)
		);

	return http.build();
}

通过Spel表达式来执行权限认证

spring security将其所有authorization字段和方法封装在一个root object中最通用的root object即是SecurityExpressionRoot,其是WebSecurityExpressionRoot的父类。spring security在执行authorization expression时将root object应用为StandardEvaluationContext

使用Authorization表达式

root object提供的常用方法如下

  • permitAll
  • denyAll
  • hasAuthority
  • hasRole
  • hasAnyAuthority
  • hasAnyRole
  • hasPermission

root object中提供的常用fields如下

  • authentication当前关联的Authentidcation对象
  • principal:当前Authentication#getPrincipal调用返回的值

如下示例通过spel指定了认证url

<http>
    <intercept-url pattern="/static/**" access="permitAll"/>
    <intercept-url pattern="/admin/**" access="hasRole('ADMIN')"/>
    <intercept-url pattern="/db/**" access="hasAuthority('db') and hasRole('ADMIN')"/>
    <intercept-url pattern="/**" access="denyAll"/>
</http>
使用path变量

如下实例为url指定了path变量并且通过spel使用了path变量的值

<http>
    <intercept-url pattern="/resource/{name}" access="#name == authentication.name"/>
    <intercept-url pattern="/**" access="authenticated"/>
</http>
使用数据库来进行权限认证

如果想要使用单独的微服务实例来执行授权操作可以创建自定义的AuthorizationManager并且将rule指定为anyRequest。

定义的自定义AuthorizationManager示例如下所示

@Component
public final class OpenPolicyAgentAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {
    @Override
    public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext context) {
        // make request to Open Policy Agent
    }
}

之后可以通过HttpSecurity配置rule对应的authorizationManager

@Bean
SecurityFilterChain web(HttpSecurity http, AuthorizationManager<RequestAuthorizationContext> authz) throws Exception {
	http
		// ...
		.authorizeHttpRequests((authorize) -> authorize
            .anyRequest().access(authz)
		);

	return http.build();
}

SecurityMatcher

类似于RequestMatcherSecurityMatcher用于决定HttpSecurity是否应该被用于该请求使用示例如下

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http
			.securityMatcher("/api/**")
			.authorizeHttpRequests(authorize -> authorize
				.requestMatchers("/user/**").hasRole("USER")
				.requestMatchers("/admin/**").hasRole("ADMIN")
				.anyRequest().authenticated()
			)
			.formLogin(withDefaults());
		return http.build();
	}
}

基于方法的权限认证

除了基于http请求的权限认证外spring security还支持基于方法的权限认证。可以通过在@Configuration类上指定@EnableMethodSecurity来启用基于方法的权限认证。

在通过注解开启基于方法的权限认证之后可以在spring管理的类或方法上添加@PreAuthorize, @PostAuthorize, @PreFilter, 和@PostFilter等注解来对方法调用进行权限认证,包括对其参数和方法返回值。

默认情况下spring security并不会开启基于方法的权限认证。

Method Security的工作原理

spring security基于方法的权限认证对于如下场景非常方便

  • 细粒度的授权逻辑,例如方法调用的参数或返回值对权限认证逻辑有影响
  • 在service层执行权限认证逻辑

Spring Security基于方法的权限认证是通过spring aop实现的。

基于方法的授权,其可分为两部分:

  • before目标方法执行前的权限认证
  • after 目标方法执行后的权限认证

如果想要对service bean中的方法进行权限认证可以参照如下示例

@Service
public class MyCustomerService {
    @PreAuthorize("hasAuthority('permission:read')")
    @PostAuthorize("returnObject.owner == authentication.name")
    public Customer readCustomer(String id) { ... }
}

当method security被启用时执行Customer#readCustomer将会进行如下逻辑:

  1. spring aop将会调用readCustomer的proxy methodAuthorizationManagerBeforeMethodInterceptor继承了PointcutAdvisor在调用proxy method时会调用所有满足@PreAuthorize pointcut的advisor
  2. 该interceptor将会调用PreAuthorizeAuthorizationManager#check方法
  3. authorization manager将会使用MethodSecurityExpressionHandler来转化注解中的spel表达式并且从MethodSecurityExpressionRoot中构建相应的EvaluationContext上下文
  4. interceptor将使用上下文来计算spel表达式其会从Authentication中获取权限并且校验是否拥有permission:read的权限
  5. 如果校验通过spring aop会继续调用被代理的方法
  6. 如果校验不通过,那么会抛出AccessDeniedException异常,并发布AuthorizationDeniedEvent事件,ExceptionTranslationFilter会将异常捕获并返回403响应
  7. 在方法执行返回后spring aop将会调用满足@PostAuthorize pointcutAuthorizationManagerAfterMethodInterceptor,并执行和方法执行前一样的校验,
  8. 如果表达式校验通过,那么继续正常执行
  9. 如果表达式校验失败,那么会抛出AccessDeniedException异常,并发布AuthorizationDeniedEvent事件,ExceptionTranslationFilter会将异常捕获并返回403响应

多个注解组合

如上述示例所示,如果向同一方法中指定多个method security注解,那么注解会一次执行,同一时刻只会针对一个注解的逻辑进行执行。

故而,在执行多个注解时,多个注解的校验逻辑为and操作。

不支持重复注解

虽然method security支持在同一方法中指定多个method security注解,但是,不支持在同一方法中指定多个相同的method security注解。无法在同一方法上指定两个@PreAuthorize注解。

并且每个注解都有其自己的pointcut每个注解也有其自己的method interceptor。

支持通过向角色授权来替换复杂的spel表达式

有时候可能会引入复杂的spel表达式

@PreAuthorize("hasAuthority('permission:read') || hasRole('ADMIN')")

这种场景下,可以将permission:read权限授予给ROLE_ADMIN,可以通过RoleHierarchy来实现:

@Bean
static RoleHierarchy roleHierarchy() {
    return new RoleHierarchyImpl("ROLE_ADMIN > permission:read");
}

故而上述spel表达式可以被简化为如下形式

@PreAuthorize("hasAuthority('permission:read')")

通过注解来执行权限校验

在开启method security之后支持向类、方法、接口上添加注解来执行权限校验操作。

@PreAuthorize

@PreAuthorize的使用示例如下所示

@Component
public class BankService {
	@PreAuthorize("hasRole('ADMIN')")
	public Account readAccount(Long id) {
        // ... is only invoked if the `Authentication` has the `ROLE_ADMIN` authority
	}
}

只有当spel表达式hasRole('ADMIN')校验通过时,才会实际调用readAccount方法。

@PostAuthorize

@PostAuthorize使用示例如下所示

@Component
public class BankService {
	@PostAuthorize("returnObject.owner == authentication.name")
	public Account readAccount(Long id) {
        // ... is only returned if the `Account` belongs to the logged in user
	}
}

通过@PostAuthorize可以针对方法调用的返回结果进行校验。

@PreFilter

通过@PreFilter注解可以对参数进行过滤使用示例如下所示

@Component
public class BankService {
	@PreFilter("filterObject.owner == authentication.name")
	public Collection<Account> updateAccounts(Account... accounts) {
        // ... `accounts` will only contain the accounts owned by the logged-in user
        return updated;
	}
}

上述示例中会过滤掉accounts中所有不满足spel表达式的对象。

@PreFilter注解支持array、collection、map、stream如下示例中的四种方式其过滤逻辑都一样

@Component
public class BankService {
	@PostFilter("filterObject.owner == authentication.name")
	public Collection<Account> readAccounts(String... ids) {
        // ... the return value will be filtered to only contain the accounts owned by the logged-in user
        return accounts;
	}
}
@PostFilter

@PostFilter的使用示例如下所示

@Component
public class BankService {
	@PostFilter("filterObject.owner == authentication.name")
	public Collection<Account> readAccounts(String... ids) {
        // ... the return value will be filtered to only contain the accounts owned by the logged-in user
        return accounts;
	}
}

上述注解会过滤accounts中所有spel表达式校验不通过的account。

@PostFilter同样支持array、collection、map、stream形式其使用和@PreFilter类似

@PostFilter("filterObject.owner == authentication.name")
public Account[] readAccounts(String... ids)

@PostFilter("filterObject.value.owner == authentication.name")
public Map<String, Account> readAccounts(String... ids)

@PostFilter("filterObject.owner == authentication.name")
public Stream<Account> readAccounts(String... ids)
在接口或类级别声明注解

对于method security同样支持在方法级别或类级别使用注解。

在类级别使用注解的示例如下所示:

@Controller
@PreAuthorize("hasAuthority('ROLE_USER')")
public class MyController {
    @GetMapping("/endpoint")
    public String endpoint() { ... }
}

在类级别指定注解后,类中所有的方法都支持该注解的行为:

@Controller
@PreAuthorize("hasAuthority('ROLE_USER')")
public class MyController {
    @GetMapping("/endpoint")
    public String endpoint() { ... }
}

也可以同时在方法级别和类级别指定注解,此时方法级别的注解会覆盖类级别注解的行为:

@Controller
@PreAuthorize("hasAuthority('ROLE_USER')")
public class MyController {
    @GetMapping("/endpoint")
    @PreAuthorize("hasAuthority('ROLE_ADMIN')")
    public String endpoint() { ... }
}

在方法spel中指定自定义的bean调用

如下为自定义的bean service

@Component("authz")
public class AuthorizationLogic {
    public boolean decide(MethodSecurityExpressionOperations operations) {
        // ... authorization logic
    }
}

可以通过如下方式在表达式中调用自定义的bean service

@Controller
public class MyController {
    @PreAuthorize("@authz.decide(#root)")
    @GetMapping("/endpoint")
    public String endpoint() {
        // ...
    }
}