3.1. 基于Spring-Security的鉴权模块 - luoyuchou的专栏 - CSDN博客

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-ldap

LDAP轻量目录访问协议功能模块

spring-security-oauth2-*

用户资源授权功能模块

spring-security-acl

访问控制列表功能模块

spring-security-cas

单点登录功能模块

spring-security-openid

openId功能模块

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()/匿名访问认证

AnonymousConfigurer

AnonymousAuthenticationFilter

AnonymousAuthenticationProvider

httpBasic()/Authorization请求头认证

HttpBasicConfigurer

BasicAuthenticationFilter

-

formLogin()/表单认证

FormLoginConfigurer

UsernamePasswordAuthenticationFilter

DaoAuthenticationProvider

oauth2Login()/OAuth2认证

OAuth2LoginConfigurer

OAuth2LoginAuthenticationFilter

-

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. 待续

Original url: Access
Created at: 2019-06-24 12:01:45
Category: default
Tags: none

请先后发表评论
  • 最新评论
  • 总共0条评论