3.1. 基于Spring-Security的鉴权模块

3.1.1. 为什么要使用Spring Security

选择安全框架,Apache Shiro已经提供了足够强大且灵活的权限管理功能,它的优势在于简单易于上手。

但若要结合Spring Cloud微服务框架来使用,就需要考虑功能更多更全更容易与Spring Cloud整合的Spring Security了,毕竟Spring Cloud似乎已经在主流框架中有着不可撼动的地位。

本项目采用Spring Boot 2.1.3.RELEASE&Spring Cloud Greenwich.RELEASE搭建,并考虑了开放接口服务和第三方登录服务,因此从技术选型上来说,提供OAuth2 ServerOAuth2 Client模块的Spring Security更加适合。所以将从Spring Security开始下手,剖析Spring Cloud系列源码。

阅读官方文档,能够发现Spring Security提供的模块有点多:

模块说明
spring-security-remoting集成 Spring Remoting的安全校验
spring-security-web提供网页安全校验,基于URL的访问控制
spring-security-ldapLDAP轻量目录访问协议功能模块
spring-security-oauth2-*用户资源授权功能模块
spring-security-acl访问控制列表功能模块
spring-security-cas单点登录功能模块
spring-security-openidopenId功能模块

3.1.2. 快速集成Spring-Security及表单登录

文中未列出代码的文件:

ApiResult类参考:ApiResult.java
BasicErrorCode类参考:BasicErrorCode.java
PasswordUtils类参考:PasswordUtils.java
JsonUtils类参考:JsonUtils.java
完整POM文件参考:auth-pom.xml

部分来自Spring-Security官方文档

# 如果你的项目使用了Spring Boot
<dependencies>
    <!-- ... other dependency elements ... -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
</dependencies>

# 如果没有使用Spring Boot,可以考虑这样做
<dependencyManagement>
    <dependencies>
        <!-- ... other dependency elements ... -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-bom</artifactId>
            <version>5.1.4.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
<dependencies>
    <!-- ... other dependency elements ... -->
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-config</artifactId>
    </dependency>
</dependencies>

在集成Spring Security之前,先编写一些Controller方法,和配置一些必要参数用于测试。

import df.zhang.BasePackage;
import df.zhang.base.pojo.ApiResult;
import df.zhang.util.JsonUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.*;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.bind.annotation.*;

/**
 * 鉴权模块启动类。{@link BasePackage}为root包下的类文件,为各模块的Application指引包名路径。
 *
 * @author df.zhang Email: 84154025@qq.com
 * @date 2019-04-21
 * @since 1.0.0
 */
@SpringBootApplication(scanBasePackageClasses = BasePackage.class)
@Slf4j
@RestController
public class AuthApplication implements CommandLineRunner {
    public static void main(String[] args) {
        SpringApplication.run(AuthApplication.class, args);
    }

    /**
     * 使用自定义的ObjectMapper将对象序列化为JSON字符串,参考{@link JsonUtils}
     *
     * @return org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
     * @date 2019-05-04 03:21
     * @author df.zhang
     * @since 1.0.0
     */
    @Bean
    public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter() {
        return new MappingJackson2HttpMessageConverter(JsonUtils.getObjectMapper());
    }

    @Override
    public void run(String... args) {
        log.info("My Cloud Authorization Running...");
    }

    /**
     * 配置需要登录认证后访问的controller
     *
     * @return df.zhang.base.pojo.ApiResult&lt;java.lang.String&gt;
     * @date 2019-05-04 03:22
     * @author df.zhang
     * @since 1.0.0
     */
    @GetMapping("authenticated")
    public ApiResult<String> testAuthenticated() {
        return ApiResult.<String>success().res("authenticated");
    }

    /**
     * 配置仅可以匿名访问的controller
     *
     * @return df.zhang.base.pojo.ApiResult&lt;java.lang.String&gt;
     * @date 2019-05-04 03:22
     * @author df.zhang
     * @since 1.0.0
     */
    @GetMapping("anonymous")
    public ApiResult<String> testAnonymous() {
        return ApiResult.<String>success().res("anonymous");
    }
}

编写Java Configuration -- SecurityConfigurer

import org.springframework.context.annotation.*;
import org.springframework.security.config.annotation.web.configuration.*;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

/**
 * Spring Security配置类
 *
 * @author df.zhang Email: 84154025@qq.com
 * @date 2019-04-21
 * @since 1.0.0
 */
@EnableWebSecurity
@Configuration
public class SecurityConfigurer extends WebSecurityConfigurerAdapter {
    /**
     * 这段代码描述为Spring-Security启动项目创建一个admin账户,角色为ADMIN。
     * 该用户信息保存在内存中,项目停止时会被清除。
     *
     * @return org.springframework.security.core.userdetails.UserDetailsService
     * @date 2019-05-02 16:12
     * @author df.zhang
     * @since 1.0.0
     */
    @Override
    @Bean
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("admin").password("{noop}admin").roles("ADMIN").build());
        return manager;
    }
}

启动后访问服务地址,会跳出Spring自带的登录页面,输入账号密码即可登录成功。

3.1.2.1. 配置PasswordEncoder

在内存账号配置中能够看到密码使用了{noop}作为前缀,这是一种类似明文密码的写法,但事实上调试在Dao身份认证提供器DaoAuthenticationProvider时,类中第90行代码(也可以更前面)。

!passwordEncoder.matches(presentedPassword, userDetails.getPassword())

这行代码在校验密码正确性时,userDetails对象中的password属性值是{bcrypt}开头的,当然这可以看作是Spring Security的默认处理。

它的具体实现是org.springframework.security.crypto.bcrypt.BCryptPasswordEncoderBCrypt这个算法很有趣,首先它足够安全,其次它足够慢,因此需要在安全和效率中间做个取舍,那就是使用够快且和别人的不一样的MD5 & SHA-1

- 借助Apache Commons Codec来实现MD5 & SHA-1算法。
<dependency>
    <groupId>commons-codec</groupId>
    <artifactId>commons-codec</artifactId>
</dependency>
import org.apache.commons.codec.digest.DigestUtils;
import java.util.Objects;

/**
 * 密码工具类
 *
 * @author df.zhang Email: 84154025@qq.com
 * @date 2019-05-03
 * @since 1.0.0
 */
public final class PasswordUtils {
    /** 一袋盐*/
    private static final byte[] SALT = DigestUtils.md5("@~df.zhang~@");
    /** 交叉合并后byte数组的长度*/
    private static final int ENCODE_LEN = 32;

    /**
     * 密码加密工具,使用{@link DigestUtils}将密码转换为16位长度的MD5字节数组,
     * 然后与预先设置好的SALT数组交叉合并,得到最终32位长度的字节数组,转换为SHA-1字符串。
     *
     * @param rawPassword param1
     * @return java.lang.String
     * @date 2019-05-03 17:48
     * @author df.zhang
     * @since 1.0.0
     */
    public static String encode(CharSequence rawPassword) {
        assert Objects.nonNull(rawPassword);
        byte[] rawBytes = DigestUtils.md5(rawPassword.toString());
        byte[] encodeBytes = new byte[ENCODE_LEN];
        // 将两个字节数组交叉组合成一个字节数组,循环中可实现某个数组反转
        int jump = 2;
        for (int i = 0, j = 0; i < ENCODE_LEN; i += jump, j++) {
            encodeBytes[i] = rawBytes[j];
            encodeBytes[i + 1] = SALT[j];
        }
        return DigestUtils.sha1Hex(encodeBytes);
    }
}

在粗略的百万次测试中,大部分生成时间都在1500纳秒左右,也就是一秒钟可生成66万次,比网上资料所说直接MD5差一半,但至少要比0.3秒一次的BCrypt强很多。

- 回到SecurityConfigurer中,将自定义的密码加密工具注册到Spring Ioc容器,并重新配置内存账户。
public class SecurityConfigurer extends WebSecurityConfigurerAdapter {

    /**
     * 使用自定义的密码加密工具
     *
     * @return org.springframework.security.crypto.password.PasswordEncoder
     * @date 2019-05-02 17:23:25
     * @author df.zhang
     * @since 1.0.0
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new PasswordEncoder() {
            @Override
            public String encode(CharSequence rawPassword) {
                return PasswordUtils.encode(rawPassword);
            }

            @Override
            public boolean matches(CharSequence rawPassword, String encodedPassword) {
                return encode(rawPassword).equals(encodedPassword);
            }
        };
    }

    /**
     * 这段代码描述为Spring-Security启动项目创建一个admin账户,角色为ADMIN。
     * 该用户信息保存在内存中,项目停止时会被清除。
     * 使用密码加密工具后,内存账户的密码不能再直接配置为明文,需要进行加密。
     *
     * @return org.springframework.security.core.userdetails.UserDetailsService
     * @date 2019-05-02 16:12
     * @author df.zhang
     * @since 1.0.0
     */
    @Override
    @Bean
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("admin").password(PasswordUtils.encode("admin")).roles("ADMIN").build());
        return manager;
    }
}

3.1.2.2. 配置UserDetailsService

新建用户状态枚举类UserStateEnum 非必须。

该枚举类描述用户当前状态,Spring Security会根据用户登录时状态的不同,向用户响应不同的登录结果。

/**
 * 用户状态枚举类
 *
 * @author df.zhang Email: 84154025@qq.com
 * @date 2019-05-02
 * @since 1.0.0
 */
public enum UserStateEnum {
    /** 用户正常*/
    ENABLED(0),
    /** 用户已禁用*/
    DISABLED(1),
    /** 用户被锁定*/
    LOCKED(2),
    /** 用户已过期*/
    EXPIRED(3),
    /** 项目授权已过期*/
    CREDENTIALS_EXPIRED(4);

    private int state;
    UserStateEnum(int state) {
        this.state = state;
    }

    public int getState() {
        return this.state;
    }

    static UserStateEnum[] VALUES = UserStateEnum.values();

    public static UserStateEnum findByState(int state) {
        for (UserStateEnum userStateEnum : VALUES) {
            if (userStateEnum.state == state) {
                return userStateEnum;
            }
        }
        return ENABLED;
    }
}

新建自定义的UserDetails,里面的所有属性最好只能在初始化时设置,即没有set方法。也是非必须

仍然可以使用User.withUsername(username).password(password).build();的方式来构建

import df.zhang.auth.constant.UserStateEnum;
import org.springframework.security.core.*;
import java.util.Collection;

/**
 * 自定义的{@link UserDetails}实现类,存放用户登录信息。
 * 用户校验不是在{@link UserDetailsService}中完成,而是在各种provider中。
 * 所以需要将用户名和密码存放进来,校验成功后会放入缓存(redis)。
 *
 * @author df.zhang Email: 84154025@qq.com
 * @date 2019-05-02
 * @since 1.0.0
 */
public class CustomUserDetails implements UserDetails {
    private long userId;
    private String username;
    private String password;
    private UserStateEnum state;

    public CustomUserDetails(long userId, String username, String password, UserStateEnum state) {
        this.userId = userId;
        this.username = username;
        this.password = password;
        this.state = state;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    public long getUserId() {
        return userId;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return state != UserStateEnum.EXPIRED;
    }

    @Override
    public boolean isAccountNonLocked() {
        return state != UserStateEnum.LOCKED;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return state != UserStateEnum.CREDENTIALS_EXPIRED;
    }

    @Override
    public boolean isEnabled() {
        return state == UserStateEnum.ENABLED;
    }
}

新建UserDetailsService实现类

UserDetailsService.loadUserByUsername(String username)可以基于任意数据库实现。但必须要保证返回的UserDetails中有鉴权需要的信息,如usernamepassword(加密后),如果用户设计时有加入用户状态,也可将用户状态封装入UserDetails

import df.zhang.auth.constant.UserStateEnum;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.*;

/**
 * 自定义的用户信息载入类,当输入用户名在平台数据库中不存在时,允许类中抛出异常{@link UsernameNotFoundException}
 *
 * @author df.zhang Email: 84154025@qq.com
 * @date 2019-05-02
 * @since 1.0.0
 */
@Slf4j
public class CustomUserDetailsServiceImpl implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("用户名[{}]尝试登录。", username);
        if (!"admin".equals(username)) {
            throw new UsernameNotFoundException(username);
        }
        return new CustomUserDetails(1L, "admin", "7445b0991419189b5c3848d2195f3cb9f99c3a25", UserStateEnum.ENABLED);
    }
}

调整SecurityConfigurerUserDetailsService的配置

@Override
@Bean
public UserDetailsService userDetailsService() {
    return new CustomUserDetailsServiceImpl();
}

可以尝试修改用户状态为不同类型的值,看看结果是什么样的。

3.1.2.3. 调整登录接口和响应

到目前为止,登录都是使用Spring的默认接口(“/login”)来实现,它提供了一个简单的登录页面和错误处理。但实际项目应用中,当前后端分离,当APP对接,这个默认接口就显得毫无意义。

重写SecurityConfigurer.configure(HttpSecurity http)

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 禁用匿名
                .anonymous().disable()
                // 配置所有请求都至少是登录用户才能访问
                .authorizeRequests().anyRequest().authenticated()
                // 配置表单登录,此处可以改登录路径
                .and().formLogin()
                // 配置成功处理类
                .successHandler((request, response, authentication) -> {
                    ApiResult<CustomUserDetails> apiResult = ApiResult.<CustomUserDetails>success()
                            .res((CustomUserDetails) authentication.getPrincipal());
                    apiResult.setMsg("登录成功");
                    response.setStatus(HttpServletResponse.SC_OK);
                    response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                    response.getWriter().print(JsonUtils.serialize(apiResult));
                    response.getWriter().flush();
                })
                // 配置失败处理类
                .failureHandler(((request, response, exception) -> {
                    ApiResult<String> apiResult = new ApiResult<String>()
                            .errorCode(BasicErrorCode.USERNAME_NOTFOUND);
                    apiResult.setMsg("用户名或密码错误");
                    response.setStatus(HttpServletResponse.SC_OK);
                    response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                    response.getWriter().print(JsonUtils.serialize(apiResult));
                    response.getWriter().flush();
                }));
    }

3.1.2.4. 调整非登录接口异常处理

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                ...
                配置异常处理器
                .and().exceptionHandling()
                .authenticationEntryPoint((request, response, authException) -> {
                    ApiResult<String> apiResult = new ApiResult<String>().errorCode(BasicErrorCode.USER_NOT_LOGIN);
                    apiResult.setMsg("用户未登录");
                    response.setStatus(HttpServletResponse.SC_OK);
                    response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                    response.getWriter().print(JsonUtils.serialize(apiResult));
                    response.getWriter().flush();
                })
                .accessDeniedHandler((request, response, accessDeniedException) -> {
                    ApiResult<String> apiResult = new ApiResult<String>().errorCode(BasicErrorCode.USER_UNAUTHORIZED);
                    apiResult.setMsg("无权限访问");
                    response.setStatus(HttpServletResponse.SC_OK);
                    response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
                    response.getWriter().print(JsonUtils.serialize(apiResult));
                    response.getWriter().flush();
                });
    }

3.1.2.4. 使用MockMvc调试

创建单元测试基类

import df.zhang.auth.AuthApplication;
import org.junit.Before;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

/**
 * 单元测试基类
 *
 * @author df.zhang Email: 84154025@qq.com
 * @date 2019-05-02
 * @since 1.0.0
 */
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = AuthApplication.class)
public abstract class BaseTest {
    protected MockMvc mockMvc;
    @Autowired
    private WebApplicationContext context;

    @Before
    public void setupMockMvc() {
        mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(SecurityMockMvcConfigurers.springSecurity()).build();
    }
}

新建登录测试类

import df.zhang.test.BaseTest;
import org.junit.Test;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;

/**
 * 登录测试类
 *
 * @author df.zhang Email: 84154025@qq.com
 * @date 2019-05-04
 * @since 1.0.0
 */
public class LoginTest extends BaseTest {
    protected MockHttpSession session;

    @Test
    public void test() throws Exception {
        // 登录
        MvcResult result = mockMvc.perform(SecurityMockMvcRequestBuilders.formLogin().user("admin").password("admin"))
                .andReturn();
        System.out.println(result.getResponse().getContentAsString());
        session = (MockHttpSession) result.getRequest().getSession();
        // 匿名访问
        result = mockMvc.perform(MockMvcRequestBuilders.get("/anonymous")).andReturn();
        System.out.println(result.getResponse().getContentAsString());
        // 登录后请求仅匿名可访问的资源
        result = mockMvc.perform(MockMvcRequestBuilders.get("/anonymous").session(session)).andReturn();
        System.out.println(result.getResponse().getContentAsString());
        // 未登录请求需登录后才能访问的资源
        result = mockMvc.perform(MockMvcRequestBuilders.get("/authenticated")).andReturn();
        System.out.println(result.getResponse().getContentAsString());
        // 登录后请求登录后可访问的资源
        result = mockMvc.perform(MockMvcRequestBuilders.get("/authenticated").session(session)).andReturn();
        System.out.println(result.getResponse().getContentAsString());
    }
}

测试结果:

// 登录
{"code":"10000","msg":"登录成功","res":{"user_id":1,"username":"admin","password":"7445b0991419189b5c3848d2195f3cb9f99c3a25","enabled":true,"credentials_non_expired":true,"account_non_locked":true,"account_non_expired":true}}
// 匿名访问
{"code":"10000","msg":"success","res":"anonymous"}
// 登录后请求仅匿名可访问的资源
{"code":"11100","msg":"无权限访问","err_code":"11102","err_msg":"unauthorized"}
// 未登录请求需登录后才能访问的资源
{"code":"11100","msg":"用户未登录","err_code":"11101","err_msg":"not_login"}
// 登录后请求登录后可访问的资源
{"code":"10000","msg":"success","res":"authenticated"}

简单总结

最开始配置时,Spring Security默认提供了一套基于Form表单的配置,该配置为:

protected void configure(HttpSecurity http) throws Exception {
	http.formLogin();
}
  • 浏览其源码,可以看到它先初始化了表单登录配置器FormLoginConfigurer
public FormLoginConfigurer<HttpSecurity> formLogin() throws Exception {
	return getOrApply(new FormLoginConfigurer<>());
}
  • 然后在表单登录配置器**FormLoginConfigurer**中注册了用户名密码身份认证过滤器UsernamePasswordAuthenticationFilter
public UsernamePasswordAuthenticationFilter() {
	super(new AntPathRequestMatcher("/login", "POST"));
}
  1. 过滤器处理所有方法为POST的“/login”请求;
  2. 封装用户名密码身份认证令牌实例UsernamePasswordAuthenticationToken
  3. 调用身份认证管理器AuthenticationManager实例的authenticate(Authentication authentication)方法进行身份验证。具体的验证过程则由身份认证提供器AuthenticationProvider负责。
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		for (AuthenticationProvider provider : getProviders()) {
			if (!provider.supports(toTest)) {
				continue;
			}
		}
	}
}

一般来说
身份认证管理器AuthenticationManager的实现类都会是提供器管理者**ProviderManager
身份认证提供器AuthenticationProvider的实现类则是在配置PasswordEncoder时提到过的Dao身份认证提供器DaoAuthenticationProvider

Dao身份认证提供器DaoAuthenticationProvider默认从UserDetailsService中获取登录的用户信息并用于身份校验,因为它是用户名密码身份认证过滤器UsernamePasswordAuthenticationFilter指定的用户名密码登录认证提供者。

这些配置都是在formLogin()之前完成的,若要深究其流程,就需要对Spring Security有个系统的了解。

3.1.3. 常用访问认证流程

HttpSecurity这个类中,Spring提供了多种访问认证方式,如下:

访问认证方式配置名称过滤器名称身份认证端点
anonymous()/匿名访问认证AnonymousConfigurerAnonymousAuthenticationFilterAnonymousAuthenticationProvider
httpBasic()/Authorization请求头认证HttpBasicConfigurerBasicAuthenticationFilter-
formLogin()/表单认证FormLoginConfigurerUsernamePasswordAuthenticationFilterDaoAuthenticationProvider
oauth2Login()/OAuth2认证OAuth2LoginConfigurerOAuth2LoginAuthenticationFilter-

3.1.3.4. 匿名访问认证

匿名访问认证需要在WebSecurityConfigurerAdapter中开启匿名访问,并指定哪些请求可以匿名访问。

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .authorizeRequests()
                // 配置/anonymous开头的请求仅可匿名访问,登录后不可访问
                .antMatchers("/anonymous/**").anonymous()
                // 配置所有请求需登录访问
                .anyRequest().authenticated()
                .and().anonymous();
    }

    /**
     * 配置Security需要忽略的访问路径,所有忽略的访问路径都不会再经过Security的任何Filter
     *
     * @param web {@link WebSecurity}
     * @date 2019-05-04 16:14
     * @author df.zhang
     * @since 1.0.0
     */
    @Override
    public void configure(WebSecurity web) {
        web.ignoring()
                // 使用浏览器访问任意路径,都会向服务器拿取页面图标信息。此处将其忽略
                .antMatchers("/favicon.ico");
    }

需要注意的是配置authorizeRequests()时,anonymous()authenticated()的顺序,若.anyRequest().authenticated()在前,匿名访问配置.antMatchers("/anonymous/**").anonymous()就不会生效。因为后一个会被前一个覆盖。

anonymous()方法返回一个基于表达式的URL权限配置器ExpressionUrlAuthorizationConfigurer,这是一个基于SPEL表达式的URL访问权限拦截器配置类,后续会在过滤器安全拦截器FilterSecurityInterceptor类中获取其具体的配置参数并传递给指定投票器来检查当前用户的URL访问权限。
过滤器安全拦截器FilterSecurityInterceptor主要用于保证URL在进入过滤器链之前拦截用户无权限访问的资源。

流程如下:

(此处应有图)

1. 访问任意路径,最先会经过匿名身份认证过滤器AnonymousAuthenticationFilter
  • 匿名身份认证过滤器AnonymousAuthenticationFilter用于获取当前上下文SecurityContextHolder.getContext()中的身份认证信息Authentication,没有就新建一个匿名身份认证令牌AnonymousAuthenticationToken
    源码如下:
public class AnonymousAuthenticationFilter extends GenericFilterBean implements InitializingBean {
	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {
		"获取当前线程下的身份认证信息"
		if (SecurityContextHolder.getContext().getAuthentication() == null) {
			"新建一个匿名身份认证令牌"
			SecurityContextHolder.getContext().setAuthentication(createAuthentication((HttpServletRequest) req));
		} 
		"继续执行过滤器链"
		chain.doFilter(req, res);
	}
	
	"新建一个匿名身份认证令牌"
	protected Authentication createAuthentication(HttpServletRequest request) {
		AnonymousAuthenticationToken auth = new AnonymousAuthenticationToken(key, principal, authorities);
		auth.setDetails(authenticationDetailsSource.buildDetails(request));
		return auth;
	}
}
2. 经过过滤器安全拦截器FilterSecurityInterceptor

封装过滤器参数传递对象FilterInvocation对象并在FilterSecurityInterceptor.invoke(FilterInvocation fi)方法中检查当前用户是否有当前路径的访问权限;

public class FilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter {
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		"封装过滤器参数传递对象"
		FilterInvocation fi = new FilterInvocation(request, response, chain);
		invoke(fi);
	}

	public void invoke(FilterInvocation fi) throws IOException, ServletException {
		if ((fi.getRequest() != null) && (fi.getRequest().getAttribute(FILTER_APPLIED) != null) && observeOncePerRequest) {
			...
		} else {
			...
			"进入父类beforeInvocation方法"
			InterceptorStatusToken token = super.beforeInvocation(fi);
			try {
				fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
			} finally {
				super.finallyInvocation(token);
			}
			super.afterInvocation(token, null);
		}
	}
}

FilterSecurityInterceptor.invoke(FilterInvocation fi)方法的主要实现在抽象父类抽象的安全拦截器AbstractSecurityInterceptor中,检查可访问权限的主要代码为第233行的this.accessDecisionManager.decide(authenticated, object, attributes)

当前URL是否可访问将由访问决策管理器AccessDecisionManager进行选举,权限不通过会抛出AccessDeniedException异常,并中断本次过滤器处理,直接返回异常结果。

public abstract class AbstractSecurityInterceptor implements InitializingBean,
		ApplicationEventPublisherAware, MessageSourceAware {
	protected InterceptorStatusToken beforeInvocation(Object object) {
		...
		"object为FilterInvocation对象,标明当前URL为:/anonymous,且能够取得对应的访问控制配置ant [pattern = /anonymous/**]"
		Collection<ConfigAttribute> attributes = this.obtainSecurityMetadataSource()
				.getAttributes(object);
		if (attributes == null || attributes.isEmpty()) {
			...
		}
		"取得当前身份认证信息"
		Authentication authenticated = authenticateIfRequired();
		try {
			this.accessDecisionManager.decide(authenticated, object, attributes);
		} catch (AccessDeniedException accessDeniedException) {
			publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated, accessDeniedException));
			throw accessDeniedException;
		}
	}
}

当然在此之前,Spring Security还需要从项目的配置中找到与URL匹配的Web(SPEL)表达式配置属性WebExpressionConfigAttribute,通过对Web表达式配置管理器方法FilterSecurityInterceptor.obtainSecurityMetadataSource()的追溯,可以看到Web(SPEL)表达式配置属性WebExpressionConfigAttribute是如何传递:

  • anonymous()方法返回基于表达式的URL权限配置器ExpressionUrlAuthorizationConfigurer,在完成配置时将anonymous与所有请求匹配器RequestMatcherantMatchers("/anonymous/**")是其中之一)添加到表达式拦截器URL注册表ExpressionInterceptUrlRegistry实例的urlMappings集合中;

  • URL权限配置器方法ExpressionUrlAuthorizationConfigurer.createMetadataSource(H http)方法将所有urlMappings写入到基于表达式的过滤器调用安全元数据中心ExpressionBasedFilterInvocationSecurityMetadataSource实例中;

  • 根据上一步骤,由于基于表达式的URL权限配置器ExpressionUrlAuthorizationConfigurer继承自抽象的URL拦截配置器AbstractInterceptUrlConfigurer,在调用AbstractInterceptUrlConfigurer.configure(H http)创建过滤器FilterSecurityInterceptor时,将SecurityMetadataSource这个对象传递到了FilterSecurityInterceptor实例中;

  • FilterSecurityInterceptor在针对URL进行拦截时,会在SecurityMetadataSource中查找与当前URL匹配的RequestMatcher,若存在,就会进行下一步AccessDecisionManager.decide(authenticated, object, attributes)访问决策管理器)的检查。

根据这个流程,可以自定义一个URL权限拦截器。

3. 进入AffirmativeBasedWebExpressionVoter检查当前用户是否有当前路径的访问权限;

AbstractSecurityInterceptor(也就是FilterSecurityInterceptor)中,accessDecisionManager的具体实现为AffirmativeBased,访问决策管理器**AccessDecisionManager**接口有三个不同逻辑的实现,描述如下:

投票器实现自访问决策投票器AccessDecisionVoter接口,其实现有抽象类AbstractAclVoter、AuthenticatedVoter、Jsr250Voter、PreInvocationAuthorizationAdviceVoter、RoleVoter、WebExpressionVoter等。

  • WebExpressionVoter用于检查在项目中配置的URL访问控制;

投票器有三种结果判定

投票结果说明对应值
ACCESS_GRANTED肯定票1
ACCESS_ABSTAIN弃权票0
ACCESS_DENIED否决票-1
  • AffirmativeBased一票肯定管理器。

    1. 其中任意一票肯定,权限予以通过;
    2. 若全部弃权,权限予以通过;
    3. 若无肯定票,但有任一一票否定,则权限不予通过。
  • ConsensusBased计分投票管理器,不计弃权票。

    1. 肯定票多于否决票,权限予以通过;
    2. 否决票多于肯定票,权限不予通过;
    3. 若两者票数相同(含全部弃权),根据allowIfEqualGrantedDeniedDecisions决策。
  • UnanimousBased一票否决管理器。

    1. 任意一票否决,权限不予通过;
    2. 若有一票肯定,权限予以通过;
    3. 若全部弃权,根据allowIfAllAbstainDecisions决策。

WebExpressionVoter源码第42行,Spring Security将从第3步传递下来的Collection<ConfigAttribute> attributes中获取到WebExpressionConfigAttribute对象,它将作为SPEL的表达式Expression。

而在第48行,AuthenticationFilterInvocation将作为SPEL Contenxt的root。

public class WebExpressionVoter implements AccessDecisionVoter<FilterInvocation> {
	private SecurityExpressionHandler<FilterInvocation> expressionHandler = new DefaultWebSecurityExpressionHandler();

	 
	 "Authentication是当前匿名用户身份认证令牌
	 FilterInvocation是由第2步中FilterSecurityInterceptor向下传递的过滤器间调用数据封装类,URL为:/anonymous
	 Collection<ConfigAttribute>则是第3步中取得的WebExpressionConfigAttribute集合
	 该表达式用于SPEL,此时有一个元素为:authorizeExpression = anonymous"
	public int vote(Authentication authentication, FilterInvocation fi, Collection<ConfigAttribute> attributes) {
		...
		WebExpressionConfigAttribute weca = findConfigAttribute(attributes);

		"将authentication和fi封装成WebSecurityExpressionRoot对象
		见DefaultWebSecurityExpressionHandler.createSecurityExpressionRoot()"
		EvaluationContext ctx = expressionHandler.createEvaluationContext(authentication, fi);
		ctx = weca.postProcess(ctx, fi);

		"已知 authorizeExpression = anonymous ctx.root = WebSecurityExpressionRoot
		 通过SPEL获取WebSecurityExpressionRoot实例中anonymous的值(bool)
		 见下文SecurityExpressionRoot"
		return ExpressionUtils.evaluateAsBoolean(weca.getAuthorizeExpression(), ctx) ? ACCESS_GRANTED : ACCESS_DENIED;
	}
}

AuthenticationFilterInvocation两个对象最终会被封装成类型为WebSecurityExpressionRoot的对象,其父类为SecurityExpressionRoot,其中提供了一个is-getter方法isAnonymous()。通过表达式[Expression = anonymous]可以得到,其值为true

public abstract class SecurityExpressionRoot implements SecurityExpressionOperations {
	public final boolean isAnonymous() {
		return trustResolver.isAnonymous(authentication);
	}
}
4. 待续
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值