Spring Security基本概念与使用 认证授权过程解析

289 阅读7分钟

前言

Spring Security是一个功能强大且高度可定制的Java安全框架,主要负责为Java程序提供声明式的身份验证和访问控制。Spring Security具备足够的灵活性,可以选择不同的身份验证方式、授权方式、密码编码器等。并且集成了一系列安全措施,包括 XSS(Cross-Site Scripting)攻击防范、CSRF 攻击防范、点击劫持攻击防范。

原理

Spring Security 虽然听说很复杂,但本质上就是使用多个Filter组成FilterChain为基础实现认证与授权,是一种“责任链”设计模式

基本概念

  1. 认证(Authentication)的过程就是一个确定用户身份的过程。认证就是确定 你是谁?
  2. 授权(Authorization):确定用户是否有权进行某个操作的过程。授权是确定 你有资格做什么?
  3. 过滤器链(Filter Chain):在 Web 应用程序中,请求经过一系列过滤器处理后才能到达servlet。Spring Security 提供了一系列过滤器来处理认证、授权、防止 CSRF(Cross-Site Request Forgery)攻击等方面的问题。
  4. 安全上下文(Security Context):Spring Security 将安全相关的信息存储在一个安全上下文中,这个上下文包括当前用户的身份信息、所拥有的权限、会话信息等。
  5. UserDetails 和 UserDetailsService:UserDetails 是 Spring Security 中用于表示用户信息的接口,它包含了用户的用户名、密码和角色等信息。UserDetailsService 是用于加载 UserDetails 对象的接口,它通常从数据库中获取用户信息。
  6. AccessDecisionManager:AccessDecisionManager 是 Spring Security 中用于判断用户是否有权访问资源的接口,它通常使用 Access Control List(ACL)或 Role-Based Access Control(RBAC)等技术来进行权限管理。

认证

SecurityContextHolder 与 SecurityContext

image.png

SecurityContextHolder 是用于存放当前用户信息的地方;其内部包含一个值,该值通常被认为是当前登录用户。

SecurityContextHolder 并不直接持有 SecurityContext,而是通过策略模式,由SecurityContextHolderStrategy代为持有

public static void setContext(SecurityContext context) {
    strategy.setContext(context);
}

由以下代码可知,SecurityContextHolder本质上就是我们平时使用的ThreadLocal

private static void initializeStrategy() {
    if (MODE_PRE_INITIALIZED.equals(strategyName)) {
       Assert.state(strategy != null, "When using " + MODE_PRE_INITIALIZED
             + ", setContextHolderStrategy must be called with the fully constructed strategy");
       return;
    }
    if (!StringUtils.hasText(strategyName)) {
       // Set default
       strategyName = MODE_THREADLOCAL;
    }
    if (strategyName.equals(MODE_THREADLOCAL)) {
       strategy = new ThreadLocalSecurityContextHolderStrategy();
       return;
    }
    if (strategyName.equals(MODE_INHERITABLETHREADLOCAL)) {
       strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
       return;
    }
    if (strategyName.equals(MODE_GLOBAL)) {
       strategy = new GlobalSecurityContextHolderStrategy();
       return;
    }
    // Try to load a custom strategy
    try {
       Class<?> clazz = Class.forName(strategyName);
       Constructor<?> customStrategy = clazz.getConstructor();
       strategy = (SecurityContextHolderStrategy) customStrategy.newInstance();
    }
    catch (Exception ex) {
       ReflectionUtils.handleReflectionException(ex);
    }
}

SecurityContext 中存储着当前认证用户的PrincipalCredentialsAuthorities。 默认的SecurityContext如下所示

public class SecurityContextImpl implements SecurityContext {
    // ......
    private Authentication authentication;
    // ......
}

其中Authentication即为当前登录对象,UsernamePasswordAuthenticationToken就是最常用的实现类

image.png

package org.springframework.security.authentication;

import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;
import org.springframework.util.Assert;

/**
 * An {@link org.springframework.security.core.Authentication} implementation that is
 * designed for simple presentation of a username and password.
 * <p>
 * The <code>principal</code> and <code>credentials</code> should be set with an
 * <code>Object</code> that provides the respective property via its
 * <code>Object.toString()</code> method. The simplest such <code>Object</code> to use is
 * <code>String</code>.
 *
 * @author Ben Alex
 */
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {

    private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

    private final Object principal;

    private Object credentials;

    /**
     * This constructor can be safely used by any code that wishes to create a
     * <code>UsernamePasswordAuthenticationToken</code>, as the {@link #isAuthenticated()}
     * will return <code>false</code>.
     *
     */
    public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
       super(null);
       this.principal = principal;
       this.credentials = credentials;
       setAuthenticated(false);
    }

    /**
     * This constructor should only be used by <code>AuthenticationManager</code> or
     * <code>AuthenticationProvider</code> implementations that are satisfied with
     * producing a trusted (i.e. {@link #isAuthenticated()} = <code>true</code>)
     * authentication token.
     * @param principal
     * @param credentials
     * @param authorities
     */
    public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
          Collection<? extends GrantedAuthority> authorities) {
       super(authorities);
       this.principal = principal;
       this.credentials = credentials;
       super.setAuthenticated(true); // must use super, as we override
    }

    @Override
    public Object getCredentials() {
       return this.credentials;
    }

    @Override
    public Object getPrincipal() {
       return this.principal;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
       Assert.isTrue(!isAuthenticated,
             "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
       super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
       super.eraseCredentials();
       this.credentials = null;
    }

}

AuthenticationManager

AuthenticationManager 是定义 Spring Security 的 Filter 如何执行认证的API。返回的认证是由调用 AuthenticationManager 的控制器(即 Spring Security的 Filter 实例)在SecurityContextHolder 上设置的。如果你不与 Spring Security 的 Filter 实例集成,你可以直接设置 SecurityContextHolder,不需要使用 AuthenticationManager

虽然 AuthenticationManager 的实现可以是任何东西,但最常见的实现是ProviderManager

ProviderManager

ProviderManager内包含多个Provider用于认证,ProviderManager是最常用的AuthenticationManager的实现。ProviderManager 委托给一个 List[AuthenticationProvider]实例。每个 AuthenticationProvider 都有机会表明认证应该是成功的、失败的,或者表明它不能做出决定并允许下游的 AuthenticationProvider 来决定。如果配置的 AuthenticationProvider 实例中没有一个能进行认证,那么认证就会以 ProviderNotFoundException 而失败,这是一个特殊的 AuthenticationException,表明 ProviderManager 没有被配置为支持被传入它的 Authentication 类型

image.png

同时,ProviderManager还可以为自己配置父级,当当前无法处理认证时,可以委托父级来处理

image.png

DaoAuthenticationProvider

DaoAuthenticationProvider是一个基于用户名密码的认证类,主要通过UserDetailsService来工作

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
    private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";
    private PasswordEncoder passwordEncoder;
    private volatile String userNotFoundEncodedPassword;
    private UserDetailsService userDetailsService;
    private UserDetailsPasswordService userDetailsPasswordService;
}
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
       throws AuthenticationException {
    prepareTimingAttackProtection();
    try {
       UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
       if (loadedUser == null) {
          throw new InternalAuthenticationServiceException(
                "UserDetailsService returned null, which is an interface contract violation");
       }
       return loadedUser;
    }
    catch (UsernameNotFoundException ex) {
       mitigateAgainstTimingAttack(authentication);
       throw ex;
    }
    catch (InternalAuthenticationServiceException ex) {
       throw ex;
    }
    catch (Exception ex) {
       throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
    }
}

AuthenticationEntryPoint

此类用于异常处理,会通过ExceptionHandlingConfigurer来配置进入ExceptionTranslationFilter从而进入过滤器链来处理异常

public void configure(H http) {
    AuthenticationEntryPoint entryPoint = getAuthenticationEntryPoint(http);
    ExceptionTranslationFilter exceptionTranslationFilter = new ExceptionTranslationFilter(entryPoint,
          getRequestCache(http));
    AccessDeniedHandler deniedHandler = getAccessDeniedHandler(http);
    exceptionTranslationFilter.setAccessDeniedHandler(deniedHandler);
    exceptionTranslationFilter = postProcess(exceptionTranslationFilter);
    http.addFilter(exceptionTranslationFilter);
}

UserDetails 与 UserDetailsService

UserDetailsService主要用于发现用户,或者说,去数据库或内存中查询用户与认证的具体的工作就是它来做的

image.png

UserDetails 则是 UserDetailsService 的发现结果;UserDetailsService在找到用户后,需要将信息包装为UserDetails

小结

由login方法调用 AuthenticationManager 的 authentication() 方法, 而在方法中,实际认证过程交由 AuthenticationProvider 进行;在 AuthenticationProvider中(涉及到 authenticate() -> retrieveUser() ) 在retrieveUser()中使用 UserDetailsService 去 loadUserByUsername,之后,要么发生异常后交由AuthenticationEntryPoint处理;要么返回UserDetails,然后将其包装为authentication返回给Controller

所以,我们在自己使用时最基础需要定义3个类,AuthenticationManagerAuthenticationProviderUserDetailsService

授权

HttpServletRequest授权

在Spring Security中,我们可以根据请求的路径来判断这个request是否需要鉴权以及是否通过校验

@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();
    }
}

上述配置的意思是,在 /api/ 路径下,所有对 /user/ 下的请求都需要具有USER角色,所有对 /admin/ 下的请求都需要具有ADMIN角色; 所有的请求都需要通过 认证。

Method授权

需要在配置类上使用@EnableMethodSecurity, 然后,你就可以立即用 @PreAuthorize@PostAuthorize@PreFilter 和 @PostFilter 注解任何 Spring 管理的类或方法,以授权方法调用,包括参数和返回值。以实现更加细粒度的鉴权

在访问对应方法时触发

以下所有注解中的Spring EL表达式针对的对象都为SecurityContext中存放的对象

@PreAuthorize

此注解要求在访问readAccount前,需要当前的认证用户满足条件hasRole('ADMIN'), 否则返回403

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

@PostAuthorize

此注解要求在访问readAccount前,需要当前的认证用户满足条件returnObject.owner == authentication.name, 否则返回403

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

同理,@PreFilter@PostFilter使用方法类似

自定义校验

通过编程授权方法的第一种方法是一个两步过程。

首先,声明一个 Bean,该 Bean 的方法需要一个 MethodSecurityExpressionOperations 或者 String 实例,如下所示:

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

然后,在注解中以如下方式引用该 Bean:

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

Spring Security 将在每次方法调用时调用该Bean上的给定方法。

这样做的好处是所有的授权逻辑都在一个单独的类中,可以独立进行单元测试并验证其正确性。它还可以访问完整的Java语言。

小结

请求级方法级
授权类型粗粒度细粒度
配置位置在配置类中声明局部到方法声明
配置方式DSL注解
授权的定义编程式SpEL

其他

LogoutFilter

HttpSecurity的配置

.logout(logout -> logout.logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler))

上面的代码会触发LogoutConfigurer去配置LogoutFilter

private LogoutFilter createLogoutFilter(H http) {
    // ......
    private SecurityContextLogoutHandler contextLogoutHandler = new SecurityContextLogoutHandler();
    // ......
    this.contextLogoutHandler.setSecurityContextRepository(getSecurityContextRepository(http));
    this.logoutHandlers.add(this.contextLogoutHandler);
    this.logoutHandlers.add(postProcess(new LogoutSuccessEventPublishingLogoutHandler()));
    LogoutHandler[] handlers = this.logoutHandlers.toArray(new LogoutHandler[0]);
    // 下面会配置logouthandlers
    LogoutFilter result = new LogoutFilter(getLogoutSuccessHandler(), handlers);
    result.setLogoutRequestMatcher(getLogoutRequestMatcher(http));
    result = postProcess(result);
    return result;
}

上面的配置会触发下面的LogoutFilter的构造函数

public LogoutFilter(LogoutSuccessHandler logoutSuccessHandler, LogoutHandler... handlers) {
    this.handler = new CompositeLogoutHandler(handlers);
    Assert.notNull(logoutSuccessHandler, "logoutSuccessHandler cannot be null");
    this.logoutSuccessHandler = logoutSuccessHandler;
    setFilterProcessesUrl("/logout");
}

LogoutFilterdoFilter

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
       throws IOException, ServletException {
       // 默认是logout
    if (requiresLogout(request, response)) {
       Authentication auth = SecurityContextHolder.getContext().getAuthentication();
       if (this.logger.isDebugEnabled()) {
          this.logger.debug(LogMessage.format("Logging out [%s]", auth));
       }
       // 按顺序执行handler
       this.handler.logout(request, response, auth);
       // 执行logout成功的handler
       this.logoutSuccessHandler.onLogoutSuccess(request, response, auth);
       return;
    }
    chain.doFilter(request, response);
}

最好还是去自己实现Handler来自定义退出成功处理器

public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler

引用

segmentfault.com/a/119000004…

developer.aliyun.com/article/113…

springdoc.cn/spring-secu…