security · 2023-11-12 0

Spring Security 认证和授权

准备

maven 依赖

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.2.2.RELEASE</version>
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
</dependencies>

IndexController.java

@RestController
public class IndexController {

    @GetMapping("/index")
    public String index() {
        return "index";
    }

    @GetMapping("/index/admin001")
    public String index1() {
        return "admin001";
    }

    @GetMapping("/index/user001")
    public String index2() {
        return "user001";
    }
}

启动类:

@SpringBootApplication
public class SecurityBoot {

    public static void main(String[] args) {
        SpringApplication.run(SecurityBoot.class, args);
    }
}

一、认证

1.默认认证

默认表单模式,用户为 user,密码打印在控制台

例:访问 http://localhost:8080/index 会 302 重定向到 GET 请求 http://localhost:8080/login,输入用户名和密码,会 POST 请求到 http://localhost:8080/login, 验证成功后,会 302 重定向到 http://localhost:8080/index

2.指定用户名密码

默认的用户名密码在 org.springframework.boot.autoconfigure.security.SecurityProperties.User 指定

org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration#inMemoryUserDetailsManager 定义了 InMemoryUserDetailsManager 的 bean,用户名密码

方式一

使用 application.properties 指定用户名密码

spring.security.user.name=user001
spring.security.user.password=123

方式二

定义 UserDetailsManager 的 bean

@Configuration(proxyBeanMethods = false)
public class UserDetailsConfig {

    @Bean
    public UserDetailsManager users() {
        UserDetails user = User.withUsername("user002")
                .password("{noop}123")
                .roles("USER")
                .build();

        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(user);
        return manager;
    }
}

方式三

继承 WebSecurityConfigurerAdapter

@Configuration(proxyBeanMethods = false)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("user003")
                .password("{noop}123")
                .roles("admin");
    }
}

方式四

实现 UserDetailsService 接口,InMemoryUserDetailsManager 和 JdbcUserDetailsManager 就是实现了此接口

@Service
public class UserDetailServiceImpl implements UserDetailsService {

    private static Map<String, User> userMap = new HashMap<>();

    static {
        userMap.put("user004", new User("user004", "{noop}123",
                Arrays.asList(new SimpleGrantedAuthority("USER"))));
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userMap.get(username);
        if (user == null) {
            throw new UsernameNotFoundException(username);
        }
        return new User(user.getUsername(), user.getPassword(), user.getAuthorities());
    }
}

3.登陆认证方式

在使用的 Spring Boot 1.X 版本,依赖的 Security 4.X 版本,默认的验证模式是 httpbasic 认证。现在使用的是 Spring Boot 2.0 以上版本,依赖的 Security 5.X 版本,默认的验证模式是表单模式。

认证方式 有无状态 简介 应用场景
httpBasic (基础认证) 无状态 不使用cookie 对外API
formLogin (表单认证) 有状态 使用session会话 网站应用

方式一

httpBasic 登陆,请求后端页面时,弹出登陆框进行登陆,前端会增加请求头,如 Authorization: Basic dXNlcjAwMToxMjM=

httpBasic 模式要求传输的用户名密码使用 Base64 模式进行加密。如果用户名是 “user001”,密码是 “123”,则将字符串 "user001:123" 使用 Base64 编码算法加密成 “dXNlcjAwMToxMjM=”

请求头携带 Authorization,即可访问,curl -H "Authorization: Basic dXNlcjAwMToxMjM=" http://localhost:8080/index

@Configuration(proxyBeanMethods = false)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    // httpBasic 登陆
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic() // 开启 httpBasic 认证
                .and()
                .authorizeRequests()
                .anyRequest().authenticated(); // 所有请求都需要登录认证才能访问
    }
}

方式二

formLogin 登陆,请求后端资源时,会定向到登陆页面,使用表单登陆

@Configuration(proxyBeanMethods = false)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    // formLogin 登陆
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin() // 开启 formLogin 认证
                .and()
                .authorizeRequests()
                .anyRequest().authenticated(); // 所有请求都需要登录认证才能访问
    }
}

4.用户信息存储

方式一

基于内存

@Configuration(proxyBeanMethods = false)
public class UserDetailsConfig {

    @Bean
    public UserDetailsManager users() {
        UserDetails user = User.withUsername("user002")
                .password("{noop}123")
                .roles("USER")
                .build();

        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(user);
        return manager;
    }
}

方式二

基于数据库

添加 jdbc 驱动

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>8.0.32</version>
    <scope>runtime</scope>
</dependency>

application.properties 配置文件

spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/security_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=123456

spring.security.user.name=user001
spring.security.user.password=123

执行 sql 语句

sql 语句在 org.springframework.security.core.userdetails.jdbc.users.ddl,其实是针对 HSQLDB 数据库的,mysql 的 sql 语句如下:

create table users(
    username varchar(50) not null primary key,
    password varchar(500) not null,
    enabled boolean not null
);

create table authorities (
    username varchar(50) not null,
    authority varchar(50) not null,
    constraint fk_authorities_users foreign key(username) references users(username)
);

create unique index ix_auth_username on authorities (username, authority);

增加 JdbcUserDetailsManager 的 bean

@Configuration(proxyBeanMethods = false)
public class UserDetailsConfig {

    @Autowired
    private DataSource dataSource;

    @Bean
    public UserDetailsManager users() {
        JdbcUserDetailsManager manager = new JdbcUserDetailsManager(dataSource);

        if (!manager.userExists("user005")) {
            UserDetails user = User.builder()
                    .username("user005")
                    .password("{noop}123")
                    .roles("USER")
                    .build();
            manager.createUser(user);
        }
        return manager;
    }
}

JdbcUserDetailsManager 实现了 UserDetailsService 的 loadUserByUsername,从数据库中查询数据

二、授权

鉴权的前提需要认证通过;认证不通过的状态码为401,鉴权不通过的状态码为403,两者是不同的。

Spring Security 可以基于角色的权限控制,也可以基于权限的权限控制。

以下基于角色的权限控制:

  • 不同的用户可以属于不同的角色
  • 不同的角色可以访问不同的接口

假设,存在两个角色 USER(普通用户) 和 ADMIN(管理员),角色 USER 可以访问接口 /index/user001,角色 ADMIN 可以访问接口 /index/admin001,所有用户认证后可以访问接口 /。

重新设置 HttpSecurity:

@Configuration(proxyBeanMethods = false)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.
                // 开启httpbasic认证
                httpBasic()
                .and()
                // 针对 HttpServletRequest 进行安全配置
                .authorizeRequests()
                // 设置角色 USER 可以访问接口 /index/user001
                .mvcMatchers("/index/user001").hasRole("USER")
                // 设置角色 ADMIN 可以访问接口 /index/admin001
                .mvcMatchers("/index/admin001").hasRole("ADMIN")
                // 设置其他接口认证后即可访问
                .anyRequest().authenticated();
    }
}

方式一

设置用户 admin001 同时拥有角色 USER 和 ADMIN

@Configuration(proxyBeanMethods = false)
public class UserDetailsConfig {

    @Bean
    public UserDetailsManager users() {
        UserDetails admin001 = User.withUsername("admin001")
                .password("{noop}123")
                // .roles("ADMIN")
                .roles("USER", "ADMIN")
                .build();

        UserDetails user001 = User.withUsername("user001")
                .password("{noop}123")
                .roles("USER")
                .build();

        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(admin001);
        manager.createUser(user001);

        return manager;
    }
}

方式二

设置角色 ADMIN 包含 USER

@Configuration(proxyBeanMethods = false)
public class UserDetailsConfig {

    @Bean
    public UserDetailsManager users() {
        UserDetails admin001 = User.withUsername("admin001")
                .password("{noop}123")
                .roles("ADMIN")
                // .roles("USER", "ADMIN")
                .build();

        UserDetails user001 = User.withUsername("user001")
                .password("{noop}123")
                .roles("USER")
                .build();

        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(admin001);
        manager.createUser(user001);

        return manager;
    }

    /**
     * 只需要往 Spring IOC 容器中注入一个 RoleHierarchy 类型的 Bean 就可以了
     * Spring Security 会处理这个Bean,生成一个 RoleHierarchyVoter,注入到 AccessDecisionManager 中,默认就是 AffirmativeBased
     */
    @Bean
    public RoleHierarchy roleHierarchy() {
        RoleHierarchyImpl hierarchy = new RoleHierarchyImpl();
        hierarchy.setHierarchy("ROLE_ADMIN > ROLE_STAFFn ROLE_STAFF > ROLE_USERn ROLE_USER > ROLE_GUEST");
        return hierarchy;
    }
}

三、自定义的登陆页

1.login.html

增加 thymeleaf 依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

/resources/static/ 下创建 login.html,static 目录的静态文件不用走 controller

<!DOCTYPE html>
<html lang="en" >
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
<body>
    <form action="/form/login" method="post">
        用户名:<input type="text" name="username"/> <br/>
        密码:<input type="text" name="password"/> <br/>
        <input type="submit" value="login"/>
    </form>
</body>
</html>

2.指定用户名密码

在 application.properties 指定用户名密码

spring.security.user.name=user001
spring.security.user.password=123

3.指定登陆页

继承 WebSecurityConfigurerAdapter 类,指定登陆页

@Configuration(proxyBeanMethods = false)
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 开启 formLogin 认证
                .formLogin()
                // 自定义登录页,其为 GET 请求,文件位于 /resources/static/login.html,不需要 Controller 跳转
                .loginPage("/login.html").permitAll()
                // 配置 form 表单 用户名的 name 属性值
                .usernameParameter("username")
                // 配置 form 表单 密码的 name 属性值
                .passwordParameter("password")
                // 自定义登录 action, 这个接口不需要自己编写,其为 POST 请求,如果不写 Spring Security 配置值同 loginPage,
                .loginProcessingUrl("/form/login")
                .and()
                // 针对 HttpServletRequest 进行安全配置
                .authorizeRequests()
                // 所有请求都需要登录认证才能访问
                .anyRequest().authenticated()
                .and()
                // 关闭 CSRF 保护功能,否则不支持 Post 请求
                .csrf().disable();
    }
}

四、原理

在 spring-boot-autoconfigure 模块,spring.factories 文件中,定义了与 Spring Security 相关的自动配置类有:

# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration,\
org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration,\
org.springframework.boot.autoconfigure.security.servlet.SecurityFilterAutoConfiguration,\
org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration,\
org.springframework.boot.autoconfigure.security.reactive.ReactiveUserDetailsServiceAutoConfiguration,\
org.springframework.boot.autoconfigure.security.rsocket.RSocketSecurityAutoConfiguration,\
org.springframework.boot.autoconfigure.security.saml2.Saml2RelyingPartyAutoConfiguration,\
org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration,\
org.springframework.boot.autoconfigure.security.oauth2.client.reactive.ReactiveOAuth2ClientAutoConfiguration,\
org.springframework.boot.autoconfigure.security.oauth2.resource.servlet.OAuth2ResourceServerAutoConfiguration,\
org.springframework.boot.autoconfigure.security.oauth2.resource.reactive.ReactiveOAuth2ResourceServerAutoConfiguration,\

SecurityAutoConfiguration 和 SecurityFilterAutoConfiguration 配置类,这两个配置类会自动装配 FilterChainProxy 和 DelegatingFilterProxy 到 Spring 容器中

1.自动装配 FilterChainProxy

SecurityAutoConfiguration 导入了3个配置类:SpringBootWebSecurityConfiguration、WebSecurityEnablerConfiguration、SecurityDataConfiguration

// SecurityAutoConfiguration.java
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(DefaultAuthenticationEventPublisher.class)
@EnableConfigurationProperties(SecurityProperties.class)
@Import({ SpringBootWebSecurityConfiguration.class, WebSecurityEnablerConfiguration.class,
        SecurityDataConfiguration.class })
public class SecurityAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean(AuthenticationEventPublisher.class)
    public DefaultAuthenticationEventPublisher authenticationEventPublisher(ApplicationEventPublisher publisher) {
        return new DefaultAuthenticationEventPublisher(publisher);
    }

}

1.WebSecurityEnablerConfiguration

对于 WebSecurityEnablerConfiguration,当 Spring 容器中没有名称为 springSecurityFilterChain 的 Bean 等条件时,就会加载该配置类,此时 @EnableWebSecurity 注解生效

// WebSecurityEnablerConfiguration.java
@Configuration(proxyBeanMethods = false)
@ConditionalOnBean(WebSecurityConfigurerAdapter.class)
@ConditionalOnMissingBean(name = BeanIds.SPRING_SECURITY_FILTER_CHAIN)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@EnableWebSecurity
public class WebSecurityEnablerConfiguration {

}
// EnableWebSecurity.java
@Retention(value = java.lang.annotation.RetentionPolicy.RUNTIME)
@Target(value = { java.lang.annotation.ElementType.TYPE })
@Documented
@Import({ WebSecurityConfiguration.class,
        SpringWebMvcImportSelector.class,
        OAuth2ImportSelector.class })
@EnableGlobalAuthentication
@Configuration
public @interface EnableWebSecurity {

    /**
     * Controls debugging support for Spring Security. Default is false.
     * @return if true, enables debug support with Spring Security
     */
    boolean debug() default false;
}
  1. SpringWebMvcImportSelector 的作用:判断当前的环境是否包含 springmvc,因为 springsecurity 可以在非 spring 环境下使用,为了避免 DispatcherServlet 的重复配置,所以使用了这个注解来区分
  2. WebSecurityConfiguration:用来配置web安全的
  3. OAuth2ImportSelector:该类是为了对 OAuth2.0 开放授权协议进行支持。ClientRegistration 如果被引用,具体点也就是 spring-security-oauth2 模块被启用(引入依赖jar)时。会启用 OAuth2 客户端配置 OAuth2ClientConfiguration
  4. HttpSecurityConfiguration 会通过 @Autowired 去获取容器中的一个 AuthenticationManager 实例,如果没能获取到则使用依赖注入的 AuthenticationConfiguration 实例创建一个 AuthenticationManager 实例,这个实例其实就是 ProviderManager

WebSecurityConfiguration#springSecurityFilterChain() 最终生成了一个名称为 springSecurityFilterChain 的 Bean 实体,该 Bean 的实际类型其实为 FilterChainProxy,是由 WebSecurity#build() 方法创建的

// WebSecurityConfiguration.java
@Configuration(proxyBeanMethods = false)
public class WebSecurityConfiguration implements ImportAware, BeanClassLoaderAware {
    ...
    @Bean(name = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME)
    public Filter springSecurityFilterChain() throws Exception {
        boolean hasConfigurers = webSecurityConfigurers != null
                && !webSecurityConfigurers.isEmpty();
        if (!hasConfigurers) {
            WebSecurityConfigurerAdapter adapter = objectObjectPostProcessor
                    .postProcess(new WebSecurityConfigurerAdapter() {
                    });
            webSecurity.apply(adapter);
        }
        return webSecurity.build();
    }
    ...
}

2.自动装配 DelegatingFilterProxy

要加载 SecurityFilterAutoConfiguration 前,必须先加载配置类 SecurityAutoConfiguration

SecurityFilterAutoConfiguration,注入了一个名称为 springSecurityFilterChain 的 Bean,SecurityFilterAutoConfiguration#securityFilterChainRegistration 最终生成一个 DelegatingFilterProxyRegistrationBean 实体

@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
@EnableConfigurationProperties(SecurityProperties.class)
@ConditionalOnClass({ AbstractSecurityWebApplicationInitializer.class, SessionCreationPolicy.class })
@AutoConfigureAfter(SecurityAutoConfiguration.class) // 须先加载 SecurityAutoConfiguration
public class SecurityFilterAutoConfiguration {
    ...
    @Bean
    @ConditionalOnBean(name = DEFAULT_FILTER_NAME)
    public DelegatingFilterProxyRegistrationBean securityFilterChainRegistration(
            SecurityProperties securityProperties) {
        DelegatingFilterProxyRegistrationBean registration = new DelegatingFilterProxyRegistrationBean(
                DEFAULT_FILTER_NAME);
        registration.setOrder(securityProperties.getFilter().getOrder());
        registration.setDispatcherTypes(getDispatcherTypes(securityProperties));
        return registration;
    }
    ...
}

DelegatingFilterProxyRegistrationBean 实现了 ServletContextInitializer 接口,当系统执行 ServletWebServerApplicationContext.selfInitialize() 进行初始化时,会依次调用到: RegistrationBean.onStartup() --> DynamicRegistrationBean.register() --> AbstractFilterRegistrationBean.addRegistration()

// AbstractFilterRegistrationBean.java
    @Override
    protected Dynamic addRegistration(String description, ServletContext servletContext) {
        Filter filter = getFilter();
        return servletContext.addFilter(getOrDeduceName(filter), filter);
    }
// DelegatingFilterProxyRegistrationBean.java
    @Override
    public DelegatingFilterProxy getFilter() {
        return new DelegatingFilterProxy(this.targetBeanName, getWebApplicationContext()) {

            @Override
            protected void initFilterBean() throws ServletException {
                // Don't initialize filter bean on init()
            }

        };
    }