Spring Security

前言

本文是Spring Security 自学笔记

配置体系

Spring Securiy 开发了一套完整的配置体系应对不同的认证和授权方法。我们可以根据不同场景对这些配置进行灵活的设置以实现业务需求。比如存储用户信息,可以把用户名密码保存在内存中,也可以保存在数据库里,如果使用了文件 LADP 协议,也可以放在文件系统中。要想合理使用这些配置,就需要先了解 Spring Securiy 配置体系。
在 Spring Securiy 中,初始化用户信息依赖的配置类是 WebSecurityConfigurer 接口,该接口实际上是一个空接口,继承了更为基础的 SecurityConfigurer 接口。在日常开发中,通常不需要我们自己实现这个接口,而是使用 WebSecurityConfigurerAdapter 类来简化该配置类的使用方式。
当我们在代码类路径中引入 Spring Security 框架之后,访问任何端点时就会弹出一个登录界面用来完成用户认证,这其实就是这部分默认配置在生效,该类中提供了一个 configure 方法,日常开发过程中,我们可以继承 WebSecurityConfigurerAdapter 类并且重写 configure() 方法完成配置工作。

protected void configure(HttpSecurity http) throws Exception {
    http
    .authorizeRequests() // 所有访问 HTTP 端点的 HttpServletRequest 进行限制
    .anyRequest().authenticated() // 对于所有请求都需要执行认证
    .and()
    .formLogin() // 用于指定使用表单登录作为认证方式,也就是会弹出一个登录界面;
    .and() 
    .httpBasic(); // 表示可以使用 HTTP 基础认证(Basic Authentication)方法来完成认证。
}     

用户体系

Spring Security 中的用户对象用来描述用户并完成对用户信息的管理,涉及如下四个核心对象:

  • UserDetails:描述 Spring Secuirty 中的用户。
  • GrantedAuthority:定义用户的操作权限。
  • UserDetailsService:定义了对 UserDetails 的查询操作。
  • UserDetailsManager:扩展 UserDetailsService,添加了创建用户、修改用户密码等功能。
UserDetails

该对象代表从数据库或内存中加载到的用户信息

public interface UserDetails extends Serializable {
    // 获取用户权限集合
	Collection<? extends GrantedAuthority> getAuthorities();

    // 获取密码
	String getPassword();
    
	// 获取用户名
	String getUsername();

    // 账户是否失效
	boolean isAccountNonExpired();

    // 账户是否被锁定
	boolean isAccountNonLocked();

    // 凭证是否失效
	boolean isCredentialsNonExpired();

    // 用户是否可用
	boolean isEnabled();
}

GrantedAuthority
public interface GrantedAuthority extends Serializable {
	//获取权限信息
    String getAuthority(); 
}

UserDetailsService

Spring Security 针对 UserDetails 提供了一个 UserDetailsService,该接口用来管理 UserDetails

public interface UserDetailsService {
    // 根据用户名加载用户
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

UserDetailsManager

继承自UserDetailsService,并扩展了该接口的功能

public interface UserDetailsManager extends UserDetailsService {
	// 创建用户
	void createUser(UserDetails user);

	// 更新用户
	void updateUser(UserDetails user);

	// 删除用户
	void deleteUser(String username);

	// 修改密码
	void changePassword(String oldPassword, String newPassword);

	// 用户是否存在
	boolean userExists(String username);
}

认证体系

认证流程

有了用户对象,我们就可以讨论具体的认证过程了。流程图如下:
在这里插入图片描述

解析:
1、用户提交用户名密码,被 UsernamePasswordAuthenticationFilter 拦截,并封装为 Authentication 子类,表单请求封装对象是 UsernamePasswordAuthenticationToken
2、过滤器将认证请求委托给认证管理器 AuthenticationManager
3、认证管理器的实现类为 ProviderManager,它维护了一个AuthenticationProvider 列表,SpringSecurity 支持多种认证方式,列表中的每个元素代表一种认证方式,在认证过程中它将遍历该列表并将认证委托给列表中的 AuthenticationProvider 完成
4、表单类请求对应的AuthenticationProvider 实现类为 DaoAuthenticationProvider,它又维护了一个UserDetailsService 实例负责加载用户信息。在完成认证逻辑后,重新填充 Authentication 并返回给 UsernamePasswordAuthenticationFilter
5、SpringContextHolder 安全上下文将重新填充过的 Authentication 设置到上下文中,以便后续从上下文中获取登录用户信息。

关键组件解析:

Authentication

认证对象代表认证请求本身,并保存用户登录时传输的用户名密码等信息以及认证完成后重新填充进去的权限等信息。需要和 UserDetails对象进行区分的是,Authentication 代表用户在登录的时候传过来的验证信息,包含用户名密码等。UserDetails 是用户实际存储的信息,实际上认证需要对比的就是这两个对象里封装的密码。

public interface Authentication extends Principal, Serializable {

    Collection<? extends GrantedAuthority> getAuthorities();

    Object getCredentials();

    Object getDetails();

    Object getPrincipal();

	boolean isAuthenticated();

	void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

AuthenticationProvider

显然,Authentication 只代表了认证请求本身,而具体执行认证的过程和逻辑需要由 AuthenticationProvider完成,对应到上面认证流程图就是 DaoAuthenticationProvider 这个组件。定义如下:

public interface AuthenticationProvider {
    /**
	 * 执行认证逻辑,返回认证结果
     * @param authentication 包含了登录用户提交的用户名,密码等信息
     * @return Authentication 返回认证成功后将用户权限等其他信息重新组装后生成的新对象
	 */
	Authentication authenticate(Authentication authentication) throws AuthenticationException;

	/**
	 * 是否支持对 authentication 类型的认证,
     * 登录流程图中表单类登录请求该类的类型是 UsernamePasswordAuthenticationToken 这个子类
	 * 如果支持返回true,也可以返回null 表示需要下一个 AuthenticationProvider 进行认证
     * 这个方法就是决定具体使用哪种方式进行认证,比如表单登录认证还是其他类型
	 */
	boolean supports(Class<?> authentication);
}

// 这个是 supports 方法的一个实现
public boolean supports(Class<?> authentication) {
   return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}

定制化认证方案

上面 表单类 的认证的逻辑是在 AbstractUserDetailsAuthenticationProvider 中完成的,该类是 AuthenticationProvider 的子类,可以看到前面检索用户信息部分是先从缓存加载,如果没有在调 retrieveUser 方法获取,该方法是一个模板方法,由子类实现,所以检索用户信息的部分就交由子类 DaoAuthenticationProvider完成。

protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) 
		throws AuthenticationException {
    prepareTimingAttackProtection();
    try {
        // 获取 UserDetailsService 实现,加载用户
        UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
        return loadedUser;
    ......
}

而在DaoAuthenticationProvider 中维护了一个UserDetailsService 实例,加载用户信息部分由 UserDetailsService 完成。
因此,实现定制化认证方案就最终交给了实现 UserDetailsUserDetailsService 这两个接口。

扩展 UserDetails
@Data
@NoArgsConstructor // 反序列化要求有无参构造器
@AllArgsConstructor // 全参构造器用来初始化对象
public class CustomUserDetails implements UserDetails {
    
    private final User user;

    /**
     * GrantedAuthority 缺少默认构造器,无法完成反序列化
     * 所以放弃序列化 getAuthorities 而是用 permissions 代替进行序列化
     * 妙妙妙
     */
    private List<String> permissions;
    
    @Override
    @JsonIgnore
    public Set<? extends GrantedAuthority> getAuthorities() {
        if (CollectionUtils.isEmpty(permissions)) {
            return Collections.emptySet();
        }
        return permissions.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet());
    }

    ...
}

在 getAuthorities 方法上添加 @JsonIgnore 注解。因为GrantedAuthority类没有无参构造器,所以用 permissions 属性代替该属性。如果 redis 选择的序列化器为 Jackson2JsonRedisSerializer,下面是可选配置如下:

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory){
    Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
    
    //设置输入时忽略JSON字符串中存在而Java对象实际没有的属性
    objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);

    // 此项必须配置,否则会报java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to XXX
    objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
    objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

    jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
    
    RedisTemplate<String,Object>template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    template.setKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(jackson2JsonRedisSerializer);
    template.setHashKeySerializer(new StringRedisSerializer());
    template.setHashValueSerializer(jackson2JsonRedisSerializer);
    return template;
}

扩展 UserDetailsService
@Service
public class CustomUserDetailsService implements UserDetailsService {
    
    @Autowired
    private UserService userService;
    @Autowired
    private MenuService menuService;
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Supplier<UsernameNotFoundException> supplier =
                () -> new UsernameNotFoundException("Username" + username + "is invalid!");
        User user = Optional.ofNullable(userService.getUserByName(username)).orElseThrow(supplier);

        List<Menu> userMenus = menuService.getUserMenus(user.getId());
        Set<String> permissions = userMenus
            .stream()
            .map(Menu::getPath)
            .collect(Collectors.toSet());

        return new CustomUserDetails(user, permissions);
    }
}

整合定制化配置

如果需要提供自定义AuthenticationProvider,则可以在filter 中创建某种 Authentication 的子类,然后在自定义 AuthenticationProvider子类中进行验证。
最后整合进 spring security 中:

@Configuration
@RequiredArgsConstructor(onConstructor_ = @Autowired)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    // 自定义认证方式
    private final AuthenticationProvider authenticationProvider;

    // 自定义 UserDetailsService 完成用户信息的存储和查询
    private final UserDetailsService customUserDetailsService;

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

    /**
     * 构建 {@link AuthenticationManager}, 我们知道 AuthenticationManager 在认证
     * 流程中用于管理 {@link AuthenticationProvider} 并调该组件方法完成认证,这个方法提供了设置
     * AuthenticationProvider 的契机,如果用户自定义了 AuthenticationProvider 组件子类,可以在这里设置
     * eg:
     *  auth.authenticationProvider(authenticationProvider)
     *
     * @param auth the {@link AuthenticationManagerBuilder} to use
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
        auth
            .userDetailsService(customUserDetailsService)
        	.authenticationProvider(authenticationProvider);
    }
}

认证过滤器

UsernamePasswordAuthenticationFilter 代表表单类登录,认证逻辑如下:

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    // 获取登录用户名
    String username = this.determineUsername(authentication);
    boolean cacheWasUsed = true;
    
	// 先从缓存加载,这里实际上是没有缓存,因为对于登录请求不可能有很大的流量
    UserDetails user = this.userCache.getUserFromCache(username);
    if (user == null) {
        cacheWasUsed = false;
        try {
            // 交给子类检索
            user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
            
        ......
    }

    // 前置检查,检查账户是否被锁定、是否可用、是否过时
    this.preAuthenticationChecks.check(user);
        
    // 对比 UserDetails 中密码和 Authentication 中密码是否一致
    additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
    ......
        
    // 后置检查,检查账户密码是否过时
    this.postAuthenticationChecks.check(user);
    if (!cacheWasUsed) {
        // 放进缓存
        this.userCache.putUserInCache(user);
    }
    ......
        
    // 重新填充 Authentication 对象并返回
    return createSuccessAuthentication(principalToReturn, authentication, user);
}

如果是前后端分离模式, 要求在认证成功或者失败的时候能够返回对应的状态码。所以需要我们扩展该类在认证成功后将状态码等返回给前端。另外一种方式就是直接暴漏一个 http 端口,在该方法内调AuthenticationManager完成认证,然后返回成功状态给前端。

@Slf4j
public class LoginAuthenticationFilter extends UsernamePasswordAuthenticationFilter{
    private static final String SECURITY_TOKEN_CACHE = "security_token_cache";
    private final RedisCacheManager redisCacheManager;

    public LoginAuthenticationFilter(AuthenticationManager authenticationManager, RedisCacheManager redisCacheManager) {
        setAuthenticationManager(authenticationManager);
        this.redisCacheManager = redisCacheManager;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) 
    		throws AuthenticationException {
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        User user = getUser(request);
        String username = user.getUsername();
        String password = user.getPassword();
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    // 前端如果是 axios 提交请求发送的是 json格式,这里用request.getParameter 是获取不到值的,要从流中获取
    private User getUser(HttpServletRequest request) {
        String json = "";
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(request.getInputStream()))) {
            StringBuilder stringBuilder = new StringBuilder();

            String line;
            while ((line = reader.readLine()) != null) {
                stringBuilder.append(line);
            }
            
            json = stringBuilder.toString();
        } catch (IOException e) {
            e.printStackTrace();
        }

        if (json.isEmpty()) {
            throw new AuthenticationServiceException("Username Password not Found");
        }
        return JSONUtils.toObject(json, User.class);
    }
    
	// 认证成功,把认证用户信息放进缓存,同时返回生成的 jwt 和 200状态码给前端
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult){
        CustomUserDetails customUserDetails = (CustomUserDetails) authResult.getPrincipal();
        String token = TokenManager.createToken(customUserDetails.getUser().getId().toString());
        redisCacheManager.getCache(SECURITY_TOKEN_CACHE).putIfAbsent(customUserDetails.getUser().getId(), customUserDetails);
        log.info("Authentication success, cache user details to redis finished");
        ResponseUtils.output(response, R.success(token));
    }
}

按照上面的认证流程我已经完成了登录认证,接下来就是要访问被保护的资源(包括除登录外的其他接口),SpringSecutity 保持认证状态一般比较通用的做法是使用jwt(当然普通的 token 也可以),后端登录接口在认证完成后返回 token 给前端,前端将认证信息于请求头携带。后端根据 header 中所携带的信息获取用户的认证信息,所以现在需要创建一个过滤器用于获取 header 中所携带的信息并加载到上下文,这个过滤器应该继承于OncePerRequestFilter,这个过滤器确保在一次请求中,过滤器只通过一次不重复执行。
过程如下:
在这里插入图片描述


过滤器大体逻辑如下:

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    private static final String SECURITY_TOKEN = "security_token";
    private static final String SECURITY_TOKEN_CACHE = "security_token_cache";

    private final RedisCacheManager redisCacheManager;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader(SECURITY_TOKEN);
        if (!StringUtils.hasText(token)) {
            ResponseUtils.output(response, R.failed(HttpStatus.UNAUTHORIZED.value(), "Authentication Token not found"));
            return;
        }

        // 解析 token
        CustomUserDetails userDetails;
        try{
            String userId = TokenManager.parseIssuer(token);
            userDetails = (CustomUserDetails) redisCacheManager.getCache(SECURITY_TOKEN_CACHE).get(userId).get();
        }
        catch (Exception ex) {
            ResponseUtils.output(response, R.failed(HttpStatus.UNAUTHORIZED.value(), "Authentication Token expired"));
            return;
        }

        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
        // 将请求中的详细信息(即:IP、SessionId 等)封装到 UsernamePasswordAuthenticationToken 对象中方便后续校验
        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        // 把认证信息存入上下文,授权时也要从上下文获取权限信息
        SecurityContextHolder.getContext().setAuthentication(authentication);

        filterChain.doFilter(request, response);
    }
}

扩展 AuthenticationSuccessHandler

AuthenticationSuccessHandler是 Spring Security 中处理认证成功的接口。该接口中onAuthenticationSuccess方法是认证成功的回调接口。接口的执行契机位于AbstractAuthenticationProcessingFilter类successfulAuthentication内。

protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
        Authentication authResult) throws IOException, ServletException {
    SecurityContext context = SecurityContextHolder.createEmptyContext();
    context.setAuthentication(authResult);
    SecurityContextHolder.setContext(context);
    if (this.logger.isDebugEnabled()) {
        this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
    }
    this.rememberMeServices.loginSuccess(request, response, authResult);
    if (this.eventPublisher != null) {
        this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
    }
    // 在这里调用
    this.successHandler.onAuthenticationSuccess(request, response, authResult);
}

默认情况下认证成功后会重定向到首页,由子类SimpleUrlAuthenticationSuccessHandler提供实现。我们需要自定义子类实现认证成功后生成 token 并返回给前端

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) 
		throws IOException, ServletException {
    CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal();
    String token = TokenManager.createToken(customUserDetails.getUser().getId().toString());
    redisCacheManager.getCache(SECURITY_TOKEN_CACHE).putIfAbsent(customUserDetails.getUser().getId(), customUserDetails);
    log.info("Authentication success, cache user details to redis finished");
    ResponseUtils.output(response, R.success(token));
}

扩展 AuthenticationFailureHandler

有登录成功当然就有登录失败,AuthenticationFailureHandler 是认证失败接口,比如登录用户名或密码错误,都会回调该接口。接口提供了onAuthenticationFailure处理认证请求失败后如何返回。接口的执行契机同样位于AbstractAuthenticationProcessingFilter类,但是unsuccessfulAuthentication方法内。

protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException failed) throws IOException, ServletException {
    SecurityContextHolder.clearContext();
    this.logger.trace("Failed to process authentication request", failed);
    this.logger.trace("Cleared SecurityContextHolder");
    this.logger.trace("Handling authentication failure");
    this.rememberMeServices.loginFail(request, response);
    this.failureHandler.onAuthenticationFailure(request, response, failed);
}

前后分离实现该接口返回失败信息给前端

@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
    log.info("IP {} 于 {} 登录系统失败,失败原因:{}", request.getRemoteHost(), LocalDateTime.now(), exception.getMessage());
    // 也可以登记在数据库表里
    ResponseUtils.output(response, R.failed(HttpServletResponse.SC_UNAUTHORIZED, exception.getMessage()));
}

由于我的配置配置类里没有添加下面这行代码

http.formLogin();

所以初始化的时候上面处理认证成功和失败两个接口是没有初始化进Spring Seuciry里的,因此我在LoginAuthenticationFilter的构造器中注入这两个组件

public LoginAuthenticationFilter(AuthenticationManager authenticationManager, RedisCacheManager redisCacheManager) {
        setAuthenticationManager(authenticationManager);
        super.setAuthenticationFailureHandler(new RestfulAuthenticationFailureHandler());
        super.setAuthenticationSuccessHandler(new RestfulAuthenticationSuccessHandler(redisCacheManager));
    }

扩展 AccessDeniedHandler

AccessDeniedHandler 是 Spring Security 中的一个接口,它只有一个核心方法用于处理访问被拒绝的情况,即当一个已经认证的用户尝试访问他没有权限的资源时。

public interface AccessDeniedHandler {
    
	void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
			throws IOException, ServletException;
}

该方法的执行契机位于ExceptionTranslationFilterhandleSpringSecurityException 方法中

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
    try {
        // 执行下一个过滤器也就是 FilterSecurityInterceptor 的方法
        chain.doFilter(request, response);
    }
    catch (IOException ex) {
        throw ex;
    }
    // 当 FilterSecurityInterceptor 授权方法抛错时被这里捕捉
    catch (Exception ex) {
        ...
        // 处理认证或者授权异常
        handleSpringSecurityException(request, response, chain, securityException);
    }
}

此方法的任务是在访问被拒绝时修改 HTTP 响应,默认实现为 AccessDeniedHandlerImpl。默认情况下,Spring Security 会返回一个错误状态,但是没有状态码等。前后端分离当一个已经认证的用户尝试访问他没有权限的资源时,我们希望返回规定好的格式的错误消息。
因此,需要实现 AccessDeniedHandler 并重写 handle 方法的。在 handle 方法中,我们可以自定义响应,返回适当的 HTTP 状态码(如 403 Forbidden)和一个明确的错误消息。

public class RestfulAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        ResponseUtils.output(response, R.failed(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage()));
    }
}

授权体系

在配置体系中我们已经看到 Spring Security 默认是通过 WebSecurityConfigurerAdapter进行初始化配置的。
所有允许的配置项:

anonymous()允许匿名访问
authenticated()允许认证用户访问
denyAll()无条件禁止一切访问
hasAnyAuthority(String)允许具有任一权限的用户进行访问
hasAnyRole(String)允许具有任一角色的用户进行访问
hasAuthority(String)允许具有特定权限的用户进行访问
hasIpAddress(String)允许来自特定 IP 地址的用户进行访问
hasRole(String)允许具有特定角色的用户进行访问
permitAll()无条件允许一切访问

配置方式设置权限

Spring Security 提供了三种强大的匹配器(Matcher)来实现这一目标,分别是 MVC 匹配器、Ant 匹配器以及正则表达式匹配器。

MVC 匹配器

MVC 匹配器的使用方法比较简单,就是基于 HTTP 端点的访问路径进行匹配,如下所示:

http
	.authorizeRequests() 
    .mvcMatchers("/hello_user").hasRole("USER") 
    .mvcMatchers("/hello_admin").hasRole("ADMIN");

现在,如果你使用角色为“USER”的用户访问“/hello_admin”端点,那么将会得到 403:Forbidden 响应
重载的 mvcMatchers 方法可以针对路径一样但 HTTP 方法不同的端点进行访问控制

http
	.authorizeRequests() 
    .mvcMatchers(HttpMethod.POST, "/hello").authenticated() 
    .mvcMatchers(HttpMethod.GET, "/hello").permitAll() 
    .anyRequest().denyAll();
Ant 匹配器

Ant 匹配器的表现形式和使用方法与前面介绍的 MVC 匹配器非常相似,它也提供了如下所示的三个方法来完成请求与 HTTP 端点地址之间的匹配关系:

  • antMatchers(String patterns)
  • antMatchers(HttpMethod method)
  • antMatchers(HttpMethod method, String patterns)
    从方法定义上不难明白,我们可以组合指定请求的 HTTP 方法以及匹配的模式,例如:
http.authorizeRequests() 
    .antMatchers( "/hello").authenticated();
正则表达式匹配器

使用这一匹配器的主要优势在于它能够基于复杂的正则表达式对请求地址进行匹配,这是 MVC 匹配器和 Ant 匹配器无法实现的,如下所示的这段配置代码:

http.authorizeRequests()
   .mvcMatchers("/email/{email:.*(.+@.+\\.com)}")
   .permitAll()
   .anyRequest()
   .denyAll();

注意:规则的顺序是很重要的,越细粒度的规则应该先写,因为先匹配到的规则会覆盖后匹配的规则:如下所有的以 /admin 开始的所有内容都需要具有 ADMIN 角色的身份,即使是 /admin/login ,因为 /admin/login 已经被 /admin/** 匹配,因此第二个规则会被忽略
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/admin/login").permitAll()

因此登录页的规则应该在 /admin/** 规则之前,例如:

.antMatchers("/admin/login").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")

保护 URL 常用的方法有:

authenticated() // 需要用户登录
permitAll() // 指定 URL 无需保护,多用于静态资源文件
hasRole() // 限制单个角色访问,角色将被增加 "ROLE_" 所以 "ADMIN" 将和 "ROLE_ADMIN" 进行比较
hasAuthority() // 限制单个权限访问
hasAnyRole() // 允许多个角色访问
hasAnyAuthority() // 允许多个权限访问
access() // 该方法使用 SpEL 表达式,所以可以创建复杂的限制
hasIpAddress() // 限制IP地址或子网访问

注解方式设置权限

从 Spring Security2.0 版本开始支持服务层方法的安全性支持,也就是说在方法上添加注解就会限制对该方法的访问,更加灵活。
主要涉及到三个注解:

  • @PreAuthorize
  • @PostAuthorize
  • @Secured

首先需要在任何 @Configuration 实例上使用 @EnableGlobalMethodSecurity 启动基于注解的安全性支持,然后在方法(类或接口)上添加注解就会限制对该方法的访问,Spring Security 的原生注解支持为该方法定义了一组属性,这些将被传递给AccessDecisionManager 以供它作出实际的决定:

public class MenuController {

   @Autowired
   private MenuService menuService;

   @GetMapping("/getAllMenu")
   @PreAuthorize("isAnonymous()")
   public R getAllMenu() {
       List<Menu> menus = menuService.queryAllMenu();
       return R.success(menus);
   }

以上配置标明 getAllMenu 方法可匿名访问,底层使用 WebExpressionVoter 投票器。具体方法逻辑可以在 GlobalMethodSecurityConfiguration[143] 追踪。


授权流程
加载 Spring Security 内置过滤器

配置体系中我们只在WebSecurityConfigurerAdapter中简单配置了一行代码就实现了权限控制,但 Spring Security 具体是如何实现的。其实是通过过滤器拦截请求,从而实现对访问权限的限制。Spring Security 本质上其实就是一个过滤器链,但过滤器链是如何加载的需要详细探寻下。
当我们引入 Spring Security 依赖之后,Spring 容器中会被注入一个 WebSecurityConfiguration类:

@Configuration(proxyBeanMethods = false)
public class WebSecurityConfiguration implements ImportAware, BeanClassLoaderAware {
	/**
	 * 创建 Spring Security 过滤器链
	 */
	@Bean(name = "springSecurityFilterChain")
	public Filter springSecurityFilterChain() throws Exception {
		... 省略
        // 开始构造过滤器链
		return this.webSecurity.build();
	}

上面的类中我们可以看到该类为 Spring 容器中创建了一个名字为 “springSecurityFilterChain” 的 Filter,具体构建过程如下:

public final class WebSecurity extends AbstractConfiguredSecurityBuilder<Filter, WebSecurity>
		implements SecurityBuilder<Filter>, ApplicationContextAware, ServletContextAware {
            
	@Override
	protected Filter performBuild() throws Exception {
    	...
		// securityFilterChains是创建好的过滤器
		FilterChainProxy filterChainProxy = new FilterChainProxy(securityFilterChains);
		...

		Filter result = filterChainProxy;
		...
		return result;
	}
}

上面过程看到最终返回的Filter就是FilterChainProxy这个类型:
在这里插入图片描述

这个类有一个 filterChains 属性保存了 Spring Security 内置的这些过滤器。因为它本身就是一个过滤器,所以当有请求访问时会被它拦截然后执行它的 doFilter方法:

private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    ...
    // 获取所有过滤器,其中包括 Spring Security 内置过滤器
    List<Filter> filters = getFilters(firewallRequest);
    ...
    VirtualFilterChain virtualFilterChain = new VirtualFilterChain(firewallRequest, chain, filters);
    virtualFilterChain.doFilter(firewallRequest, firewallResponse);
}

private static final class VirtualFilterChain implements FilterChain {
	@Override
	public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
		...
		// 这里是根据索引挨个执行过滤器链内过滤器
		this.currentPosition++;
		Filter nextFilter = this.additionalFilters.get(this.currentPosition - 1);
		...
		nextFilter.doFilter(request, response, this);
	}

}

Spring Security 内置过滤器开始介入请求,授权流程涉及的组件中最重要的入口就是FilterSecurityInterceptor这个过滤器,它位于FilterChainProxy过滤器链中的最后一个,上图中也可以看到,下面看下授权流程:
当用户完成登录认证之后,访问受限资源请求会通过认证相关过滤器,因为请求头中已经携带了 token,但是会被 FilterSecurityInterceptor 过滤器拦截。
在这里插入图片描述

解析:
1、拦截请求,已认证用户访问受保护的 web 资源将被 FilterSecurityInterceptor 拦截
2、获取访问资源策略,FilterSecurityInterceptor 会从 SecurityMetadataSource 子类获取要访问资源所需要的权限列表 Collection,SecurityMetadataSource 其实就是访问策略的抽象,初始化的时候 http 中配置的访问策略会被加载进去:
3、最后 FilterSecurityInterceptor 会调 AccessDecisionManager 进行授权决策,若决策通过,则允许访问资源,否则禁止。

授权决策

上面我们看到请求最终有没有访问站点权限是通过AccessDecisionManager 这个组件进行授权决策通过的,授权体系各组件依赖关系如下:
在这里插入图片描述

组件分析:
AccessDecisionManager
AccessDecisionManager 采用投票的方式来确定是否能够访问受保护资源,该子类中包含一系列 AccessDecisionVoter 将会被用来对请求进行投票,而 AccessDecisionManager 本身拥有不同的子类实现用以统计投票结果并最终决定是否有权访问受保护资源。另外从上面 UML 图可见这里使用到设计模式中的策略模式。

public interface AccessDecisionManager {

	/**
	 * authentication: 认证用户,也就是要访问资源的用户
	 * object:被访问资源
	 * configAttributes:被访问资源的访问策略,通过SecurityMetadataSource
	 */
	void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
			throws AccessDeniedException, InsufficientAuthenticationException;

	/**
	 * 表明这个 AccessDecisionManager 是否能够根据 ConfigAttribute 处理当前请求
	 */
	boolean supports(ConfigAttribute attribute);

	/**
	 * 表明这个 AccessDecisionManager 子类是否能够提供被访问资源类型的访问策略
	 */
	boolean supports(Class<?> clazz);

下面逐个看下 Spring Security 中提供的 AccessDecisionManager 子类实现是如何统计投票结果的:

AffirmativeBased (Spring Security 缺省值)

/**
 * 任意一个 AccessDecisionVoter 投票同意,则授权通过
 * 没有同意票,但是有反对票,则授权不通过
 * 全部弃权,授权通过
 */
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) 
		throws AccessDeniedException {
		int deny = 0;
		for (AccessDecisionVoter voter : getDecisionVoters()) {
			int result = voter.vote(authentication, object, configAttributes);
			switch (result) {
			case AccessDecisionVoter.ACCESS_GRANTED:
				return;
			case AccessDecisionVoter.ACCESS_DENIED:
				deny++;
				break;
			default:
				break;
			}
		}
		...
	}

ConsensusBased

/**
 * 同意票大于反对票,授权通过,否则授权不通过
 * 同意票和反对票相同,则授权通过
 * 任意一个 AccessDecisionVoter 弃权,则授权不通过
 */
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) 
		throws AccessDeniedException {
    int grant = 0;
    int deny = 0;
    for (AccessDecisionVoter voter : getDecisionVoters()) {
        int result = voter.vote(authentication, object, configAttributes);
        switch (result) {
        case AccessDecisionVoter.ACCESS_GRANTED:
            grant++;
            break;
        case AccessDecisionVoter.ACCESS_DENIED:
            deny++;
            break;
        default:
            break;
        }
    }
    ...
}

UnanimousBased

/**
 * 这个统计逻辑和前两个不太一样,总体规则如下:

 * 如果没有反对票,但有同意票,则授权通过
 * 如果全部弃权,则授权不通过
 * 有任意一个反对票,则授权不通过
 */
public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) 
		throws AccessDeniedException {
    int grant = 0;
    List<ConfigAttribute> singleAttributeList = new ArrayList<>(1);
    singleAttributeList.add(null);
    for (ConfigAttribute attribute : attributes) {
        singleAttributeList.set(0, attribute);
        for (AccessDecisionVoter voter : getDecisionVoters()) {
            int result = voter.vote(authentication, object, singleAttributeList);
            switch (result) {
            case AccessDecisionVoter.ACCESS_GRANTED:
                grant++;
                break;
            case AccessDecisionVoter.ACCESS_DENIED:
                throw new AccessDeniedException(
                        this.messages.getMessage("AbstractAccessDecisionManager.accessDenied", "Access is denied"));
            default:
                break;
            }
        }
    }
	...
}

AccessDecisionManager的子类决策时都是通过AccessDecisionVoter 接口进行投票决策的,如授权体系组件依赖关系所示,不同子类通过收集统计投票结果最终对访问权限做出判断。下面看下AccessDecisionVoter 接口相关实现。

AccessDecisionVoter
AccessDecisionVoter 接口是具体投票的执行者,它负责投票的执行逻辑,返回同意:1,拒绝:0 以及弃权:-1 三个值。同样它也拥有不同的子类实现。

public interface AccessDecisionVoter<S> {

	int ACCESS_GRANTED = 1;

	int ACCESS_ABSTAIN = 0;

	int ACCESS_DENIED = -1;

	/**
	 * 表明这个子类是否能够处理传递过来的 ConfigAttribute 请求
	 * 该方法允许 AbstractSecurityInterceptor 检查每一个配置的资源访问规则
	 * 被 AccessDecisionManager 或 RunAsManager 或 AfterInvocationManager 消费
	 */
	boolean supports(ConfigAttribute attribute);

	/**
	 * 表明这个子类是否能够为提供资源访问策略提供投票规则
	 */
	boolean supports(Class<?> clazz);

	/**
	 * 投票返回结果是上述三个常量之一:ACCESS_GRANTED 表同意,ACCESS_ABSTAIN 表弃权
	 * ACCESS_DENIED 表拒绝。如果当前 AccessDecisionVoter 不能判断 Authentication 是否
	 * 拥有资源访问权限,返回 ACCESS_ABSTAIN 弃权
	 */
	int vote(Authentication authentication, S object, Collection<ConfigAttribute> attributes);

}

CORS 跨域

跨域是浏览器的一种同源安全策略,是浏览器单方面限制的,所以仅在客户端运行在浏览器中才需要考虑这个问题。从原理上讲,实际就是浏览器在 HTTP 请求的消息头部分新增一些字段,如下所示:

//浏览器自己设置的请求域名
Origin     
//浏览器告诉服务器请求需要用到哪些 HTTP 方法
Access-Control-Request-Method
//浏览器告诉服务器请求需要用到哪些 HTTP 消息头
Access-Control-Request-Headers

当浏览器进行跨域请求时会和服务器端进行一次的握手协议,从响应结果中可以获取如下信息:

//指定哪些客户端的域名允许访问这个资源
Access-Control-Allow-Origin 
//服务器支持的 HTTP 方法
Access-Control-Allow-Methods 
//需要在正式请求中加入的 HTTP 消息头
Access-Control-Allow-Headers

因此,实现 CORS 的关键是服务器。只要服务器合理设置这些响应结果中的消息头,就相当于实现了对 CORS 的支持,从而支持跨源通信。

CorsFilter

在 Spring 中存在一个 CorsFilter 过滤器,不过这个过滤器并不是 Spring Security 提供的,而是来自Spring Web MVC。在 CorsFilter 这个过滤器中,应该根据 CORS 配置来判断该请求是否合法,根据 CorsFilter,Spring Security 也在 HttpSecurity 工具类通过提供了 cors() 方法来创建 CorsConfiguration,使用方式如下所示:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .cors()
        .configurationSource(configurationSource());
}

@Bean
public UrlBasedCorsConfigurationSource configurationSource() {
    final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    final CorsConfiguration config = new CorsConfiguration();
    config.setAllowCredentials(true); // 允许cookies跨域
    config.addAllowedOriginPattern("*");// #允许向该服务器提交请求的URI,*表示全部允许
    config.addAllowedHeader("*");// #允许访问的头信息,*表示全部
    config.setMaxAge(18000L);// 预检请求的缓存时间(秒),即在这个时间段里,对于相同的跨域请求不会再预检了
    config.addAllowedMethod("*");
    source.registerCorsConfiguration("/**", config);
    return source;
}

会话控制

在 SpringSecurity 中可以通过以下选项准确控制会话何时创建以及 SpringSecurity 如何与之交互:

选项描述
ALWAYS始终创建一个 HttpSession
NEVERSpringgSecurity 不创建 HttpSession,但是如果已存在仍然会使用
IF_REQUIRED如果需要就创建一个 HttpSession
STATELESSSpringgSecurity 不创建 HttpSession,也不使用

通过以下方式对该选项进行配置:

protected void configure(HttpSecurity http) throws Exception {
    http
        .sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
}

为什么使用了 JWT 之后就可以关闭 Session 了?
JWT 是自包含的,意味着每个 token 都包含了用户的所有认证和授权信息。因此,服务器不需要再去 session 中查找用户的认证和授权信息。

在传统的基于 session 的认证中,服务器为每个已认证的用户创建一个 session,并将 session ID 存储在 cookie 中。但在基于 JWT 的认证中,由于 JWT 已经包含了所有必要的信息,服务器不再需要维护 session。这减少了服务器的存储需求并简化了扩展。因此,设置为 SessionCreationPolicy.STATELESS 确保 Spring Security 不会创建或使用 session。


退出

Spring Security 默认在过滤器链中添加了LogoutFilter用于处理退出登录。另外系统还提供了LogoutHandler接口参与退出前如何处理和LogoutSuccessHandler接口参与退出成功后如何处理。

LogoutHandler

默认情况下 Spring Security 会内置SecurityContextLogoutHandlerLogoutSuccessEventPublishingLogoutHandler两个LogoutHandler的子类,前者用于清空 Spring Security 上下文:

public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
    Assert.notNull(request, "HttpServletRequest required");
    // 这里因为我们没有创建 session 所以不会进来
    if (this.invalidateHttpSession) {
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate();
            if (this.logger.isDebugEnabled()) {
                this.logger.debug(LogMessage.format("Invalidated session %s", session.getId()));
            }
        }
    }
    SecurityContext context = SecurityContextHolder.getContext();
    SecurityContextHolder.clearContext();
    if (this.clearAuthentication) {
        context.setAuthentication(null);
    }
}

后者用于发布退出登录事件,可以配合 Spring 事件监听机制做一些额外的操作:

public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
    if (this.eventPublisher == null) {
        return;
    }
    if (authentication == null) {
        return;
    }
    this.eventPublisher.publishEvent(new LogoutSuccessEvent(authentication));
}

LogoutSuccessHandler

一般情况下系统成功推出后需要重定向到登录 URL,Spring Security 默认的重定向地址是/login?logout。默认退出成功处理类是SimpleUrlLogoutSuccessHandler这个类什么都没有做而是调用了父类的 handle 方法:

protected void handle(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
			throws IOException, ServletException {
    // /login?logout
    String targetUrl = determineTargetUrl(request, response, authentication);
    if (response.isCommitted()) {
        this.logger.debug(LogMessage.format("Did not redirect to %s since response already committed.", targetUrl));
        return;
    }
    // 重定向
    this.redirectStrategy.sendRedirect(request, response, targetUrl);
}

前后端分离下需要向前端返回状态码和 json 返回数据,所以需要自定义该接口子类用于处理退出成功后操作:

public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
    // 这里需要清掉 redis 登录用户缓存
    String token = request.getHeader(HttpHeaders.AUTHORIZATION);
    if (!StringUtils.hasText(token)) {
        ResponseUtils.output(response, R.failed(HttpStatus.UNAUTHORIZED.value(), "Authentication Token not found"));
        return;
    }

    try{
        // 因为是退出登录,即使 token 解析异常也不报错,做正常返回,redis 里没值也不报错
        String userId = TokenManager.parseIssuer(token);
        redisCacheManager.getCache(SECURITY_TOKEN_CACHE).evictIfPresent(userId);
    }
    catch (Exception ex) {
        log.error("退出登录解析token异常");
        ex.printStackTrace();
    }
    ResponseUtils.output(response, R.success());
}

最后需要把我们自定义的 Handler 配置进 Spring Security

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .logout()
        .logoutSuccessHandler(new RestfulLogoutSuccessHandler(redisCacheManager));
}

总结

最后贴出我Spring Security 的完整配置

protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        http
            .authorizeRequests()
            .anyRequest()
            .authenticated();

        http
            .addFilter(new LoginAuthenticationFilter(authenticationManager(), redisCacheManager))
            .addFilterAfter(new TokenAuthenticationFilter(redisCacheManager), LoginAuthenticationFilter.class);

        http
            .exceptionHandling()
            .accessDeniedHandler(new RestfulAccessDeniedHandler());

        http
            .cors()
            .configurationSource(configurationSource());

        http
            .logout()
            .logoutSuccessHandler(new RestfulLogoutSuccessHandler(redisCacheManager));
    }

上面的配置中有的一点需要说明的是我没有定义登录和退出站点,所以登录请求过来之后先被 LoginAuthenticationFilter 拦截
完成认证后会返回 jwt token 给前端,前端在请求头带过来,然后被 TokenAuthenticationFilter 拦截获取请求头携带的 token
所以我的 TokenAuthenticationFilter 位置在 LoginAuthenticationFilter 之后。

关于跨域的配置可以阅读参考文献2
关于 csrf 和 session 的配置可以阅读参考文献1




参考文献

https://blog.csdn.net/ly1347889755/article/details/132609255
https://blog.csdn.net/m0_57042151/article/details/128670135

  • 24
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值