Skip to content

Spring Security 概述 ​

Spring Security 是一个能够为基于 Spring 的企业应用系统提供声明式的安全访问控制解决方案的安全框架。由于它是 Spring 生态系统中的一员,因此它伴随着整个 Spring 生态系统不断修正、升级,在 spring boot 项目中加入 spring security 更是十分简单,使用 Spring Security 减少了为企业系统安全控制编写大量重复代码的工作。

Spring Security 快速开始 ​

此示例基于 Maven 构建的 Spring MVC 项目

初始化示例项目 ​

创建 maven 工程 spring-security-5.1.x,工程结构如下:

引入依赖 ​

在 spring mvc 项目的基础上增加 spring-security 的依赖,完整依赖如下:

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

    <parent>
        <artifactId>spring-security-sample</artifactId>
        <groupId>com.moon</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <modelVersion>4.0.0</modelVersion>
    <artifactId>spring-security-5.1.x</artifactId>
    <packaging>war</packaging>
    <description>
        Spring Security 5.1.x 版本使用示例工程(基于 Spring MVC 的 Web 项目)
    </description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>

        <spring-security.version>5.1.4.RELEASE</spring-security.version>
        <spring.version>5.1.5.RELEASE</spring.version>
    </properties>

    <dependencies>
        <!-- Spring Security 依赖 -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-web</artifactId>
            <version>${spring-security.version}</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-config</artifactId>
            <version>${spring-security.version}</version>
        </dependency>

        <!-- Spring MVC 依赖 -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
            <version>${spring.version}</version>
        </dependency>

        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>3.0.1</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.22</version>
        </dependency>
    </dependencies>

    <build>
        <finalName>${artifactId}</finalName>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.tomcat.maven</groupId>
                    <artifactId>tomcat7-maven-plugin</artifactId>
                    <version>2.2</version>
                </plugin>
                <!-- 编译插件 -->
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <configuration>
                        <source>1.8</source>
                        <target>1.8</target>
                    </configuration>
                </plugin>
                <!-- 资源打包插件 -->
                <plugin>
                    <artifactId>maven-resources-plugin</artifactId>
                    <configuration>
                        <encoding>utf-8</encoding>
                        <useDefaultDelimiters>true</useDefaultDelimiters>
                        <resources>
                            <resource>
                                <directory>src/main/resources</directory>
                                <filtering>true</filtering>
                                <includes>
                                    <include>**/*</include>
                                </includes>
                            </resource>
                            <resource>
                                <directory>src/main/java</directory>
                                <includes>
                                    <include>**/*.xml</include>
                                </includes>
                            </resource>
                        </resources>
                    </configuration>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>

</project>

配置 Spring 容器 ​

创建 com.moon.spring.security.config.ApplicationConfig 类,用于替代 applicationContext.xml 配置文件。对应在 web.xml 中的 ContextLoaderListener 等配置。

java
@Configuration
@ComponentScan(basePackages = "com.moon.spring.security",
        excludeFilters = {@ComponentScan.Filter(type = FilterType.ANNOTATION, value = Controller.class)})
public class ApplicationConfig {
}

配置 Servlet Context ​

本案例采用 Servlet3.0 无 web.xml 方式,创建 com.moon.spring.security.config.WebConfig 类,它对应于 web.xml 文件中的 DispatcherServlet 配置

这里因为使用了 Spring Security 框架,所以不需要自定义登陆授权的拦截器,由框架来实现

java
/**
 * 此配置类相当于 spring-mvc.xml 配置文件,相应 DispatcherServlet 的配置。
 * 在此类中配置 Spring MVC 的视频解析器
 */
@Configuration
@EnableWebMvc
@ComponentScan(basePackages = "com.moon.spring.security",
        includeFilters = {@ComponentScan.Filter(type = FilterType.ANNOTATION, value = Controller.class)})
public class WebConfig {
    // 视图解析器
    @Bean
    public InternalResourceViewResolver viewResolver() {
        InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
        viewResolver.setPrefix("/WEB-INF/view/");
        viewResolver.setSuffix(".jsp");
        return viewResolver;
    }
}

配置加载 Spring 容器 ​

在 init 包下创建 Spring 容器初始化类 SpringApplicationInitializer,此类实现 WebApplicationInitializer 接口,Spring 容器启动时加载 WebApplicationInitializer 接口的所有实现类。

注:一般自定义的初始化类会继承抽象现实类 org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer,它实现了 WebApplicationInitializer 接口。

java
/**
 * 实现 WebApplicationInitializer 接口。用于初始化 Spring 容器。
 * 此类相当于 web.xml 配置文件,使用了 servlet3.0 开发则不需要再定义 web.xml
 */
public class SpringApplicationInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
    /* 指定rootContext的配置类 */
    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class<?>[]{ApplicationConfig.class};
    }

    /* 指定servletContext的配置类 */
    @Override
    protected Class<?>[] getServletConfigClasses() {
        return new Class<?>[]{WebConfig.class};
    }

    /* 配置 servlet 访问地址映射 */
    @Override
    protected String[] getServletMappings() {
        return new String[]{"/"};
    }
}

实现认证功能 ​

认证登陆页面 ​

Spring Security 框架默认提供认证页面,不需要使用者额外开发。

  • 登陆的url:http://项目访问域名/login
  • 登出的url:http://项目访问域名/logout

Spring Security 安全配置 ​

Spring Security 提供了用户名密码登录、退出、会话管理等认证功能,只需要配置即可使用。

  • 在 config 包下创建 Spring Security 的配置类,继承 org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter 抽象类,安全配置类主要需要设置的内容包括:用户信息(即用户登陆的账号与密码)、密码编码器(即密码加密后比较的方式,也可以直接比较)、安全拦截机制(即配置需要拦截校验的url)。
  • 在类上标识 @EnableWebSecurity 注解,开启 Spring Security。
java
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 创建 UserDetailsService 实例。
     * 用于定义用户信息服务(查询用户信息)
     *
     * @return
     */
    @Bean
    public UserDetailsService userDetailsService() {
        // 正常情况是查询数据库,此示例为了简单直接创建保存在内存中的用户信息服务
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        // 创建用户信息与配置权限标识
        manager.createUser(User.withUsername("admin").password("123").authorities("p1").build());
        manager.createUser(User.withUsername("moon").password("456").authorities("p2").build());
        return manager;
    }

    /**
     * 密码编码器,即设置登陆时密码的校验
     *
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        // 此示例为了方便,暂时使用密码无转码的方式来验证
        return NoOpPasswordEncoder.getInstance();
    }

    /**
     * 安全拦截机制(最重要)
     *
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/check/**").authenticated() // 设置所有 /check/** 的请求必须认证通过
                .anyRequest().permitAll()  // 设置除了上面配置的 /check/**,其它的请求可以访问
                .and()
                .formLogin() // 允许表单登录
                .successForwardUrl("/login-success"); // 自定义登录成功的页面地址
    }
}

以上 Spring Security 的配置主要是在 userDetailsService() 方法中,返回了一个 UserDetailsService 给 spring 容器,Spring Security 会使用它来获取用户信息。示例暂时使用 InMemoryUserDetailsManager 实现类,并在其中分别创建了“admin”、“moon”两个用户,并设置密码和权限。

而在 configure() 方法中,通过 HttpSecurity 设置了安全拦截规则,其中包含了以下内容:

  1. url 匹配 /check/** 的资源,经过认证后才能访问。
  2. 其他 url 完全开放。
  3. 支持 form 表单认证,认证成功后跳转到 /login-success(自定义的成功页面url)。

注:HttpSecurity 更多的配置详见《附录:HttpSecurity 配置列表》

加载 Spring Security 配置类 ​

修改 SpringApplicationInitializer 的 getRootConfigClasses() 方法,添加 WebSecurityConfig.class:

java
public class SpringApplicationInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
    /* 指定rootContext的配置类 */
    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class<?>[]{ApplicationConfig.class, WebSecurityConfig.class};
    }
    ....
}

Spring Security 初始化 ​

Spring Security 初始化,有两种情况:

  • 若当前环境没有使用 Spring 或 Spring MVC,则需要将 WebSecurityConfig (Spring Security 配置类) 传入超类,以确保获取配置,并创建 spring context。
  • 若当前环境已经使用 spring,应该在现有的 Spring Context 中注册 Spring Security (上一步已经做将 WebSecurityConfig 加载至 rootcontext ),此方法可以什么都不做。

在 init 包下定义 SpringSecurityApplicationInitializer 类,用于初始化 Spring Security:

java
public class SpringSecurityApplicationInitializer extends AbstractSecurityWebApplicationInitializer {
    public SpringSecurityApplicationInitializer() {
        //super(WebSecurityConfig.class);
    }
}

配置默认根路径请求 ​

在 WebConfig.java 配置类中添加默认请求根路径 / 时,重定向到 /login,此 url 为 spring security 默认提供的登陆页面:

java
public class WebConfig implements WebMvcConfigurer {
    ....
    /**
     * 配置 url 的与页面的映射关系
     *
     * @param registry
     */
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        // 增加默认 Url 根路径为 '/' 时,跳转到 /login。注:此 url 是 spring security 默认提供的登陆页面
        registry.addViewController("/").setViewName("redirect:/login");
    }
}

创建登陆成功后跳转页面 ​

在 LoginController 类中定义 /login-success 相应的映射方法:

java
/**
 * 登陆成功后跳转的请求url
 */
@RequestMapping(value = "/login-success", produces = {"text/plain;charset=UTF-8"})
public String loginSuccess() {
    return " 登录成功";
}

注意: 这里有一个小坑,如果使用 @GetMapping 注解的话,登陆后会报 405 错误:Request method 'POST' not supported。(具体原因以后分析)

测试 ​

  • 使用 maven 命令 clean tomcat7:run 启动项目,访问项目地址: http://localhost:8080/spring-security-5.1.x。页面会根据 WebConfig 中 addViewControllers 配置规则,跳转至 /login,这是 Spring Security 提供的登录页面。
  • 输入错误的用户名、密码

  • 输入正确的用户名、密码,登录成功

  • 请求 /logout 退出登陆。退出后再访问资源自动跳转到登录页面

实现授权功能 ​

实现授权需要对用户的访问进行拦截校验,校验用户的权限是否可以操作指定的资源,Spring Security 默认提供授权实现方法。

新增不同权限访问的 url 控制方法 ​

在 LoginController 添加 /check/p1 与 /check/p2

java
@GetMapping(value = "/check/p1", produces = {"text/plain;charset=UTF-8"})
public String checkPrivilege1() {
    return "访问资源p1";
}

@GetMapping(value = "/check/p2", produces = {"text/plain;charset=UTF-8"})
public String checkPrivilege2() {
    return "访问资源p2";
}

配置授权规则 ​

修改安全配置类 WebSecurityConfig.java 中配置授权规则:

java
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .antMatchers("/check/p1").hasAuthority("p1") // 设置不同权限访问的url
            .antMatchers("/check/p2").hasAuthority("p2") // 设置不同权限访问的url
            .antMatchers("/check/**").authenticated() // 设置所有 /check/** 的请求必须认证通过
            .anyRequest().permitAll()  // 设置除了上面配置的 /check/**,其它的请求可以访问
            .and()
            .formLogin() // 允许表单登录
            .successForwardUrl("/login-success"); // 自定义登录成功的页面地址
}
  • .antMatchers("/check/p1").hasAuthority("p1") 表示:访问 /check/p1 资源的 ur l需要拥有 p1 权限。
  • .antMatchers("/check/p2").hasAuthority("p2") 表示:访问 /check/p2 资源的 url 需要拥有 p2 权限。

测试 ​

启动项目。登陆 admin 用户,分别访问两个url,有权限时则正常访问,否则返回403(拒绝访问)

小结 ​

通过快速开始示例可知,Spring Security 提供了基于账号和密码的认证方式,通过安全配置即可实现请求拦截,授权功能。不需要开发者去实现登陆、授权控制等逻辑代码。

附录 ​

HttpSecurity 配置列表 ​

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

Spring Boot 集成 Spring Security ​

注:Spring Boot 集成 Spring Security 更多的集成功能示例详见【分布式微服务/SpringBoot】

Spring Boot 是一套 Spring 的快速开发框架,基于 Spring 4.0 设计,使用 Spring Boot 开发可以避免一些繁琐的工程搭建和配置,同时它集成了大量的常用框架,快速导入依赖包,避免依赖包的冲突。基本上常用的开发框架都支持 Spring Boot 开发,例如:MyBatis、Dubbo 等,Spring 家族更是如此,例如:Spring cloud、Spring mvc、Spring security 等,使用 Spring Boot 开发可以大大得高生产率。

Spring Boot 提供 spring-boot-starter-security 用于快速开发 Spring Security 应用。

创建示例工程 ​

创建 maven 工程 spring-security-boot-2.1.x,工程目录结构如下:

TODO: 待补充工程目录结构图

引入依赖

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>spring-security-sample</artifactId>
        <groupId>com.moon</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <modelVersion>4.0.0</modelVersion>
    <artifactId>spring-security-boot-2.1.x</artifactId>
    <packaging>jar</packaging>
    <description>
        Spring Boot 集成 Spring Security 示例项目。
        示例的 Spring Boot 版本:2.1.3.RELEASE。
        相应的 Spring Security 版本:5.1.4.RELEASE
    </description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <dependencyManagement>
        <!-- 因为继承自己项目的聚合项目,所以这里需要使用import的方式引入SpringBoot -->
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>2.1.3.RELEASE</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

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

        <!-- spring security依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>


        <!-- jsp依赖-->
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <scope>provided</scope>
        </dependency>
        <!-- jsp页面使用jstl标签 -->
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>jstl</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
            <scope>provided</scope>
        </dependency>
        <!-- 用于编译jsp -->
        <dependency>
            <groupId>org.apache.tomcat.embed</groupId>
            <artifactId>tomcat-embed-jasper</artifactId>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.0</version>
        </dependency>
    </dependencies>

    <build>
        <finalName>${artifactId}</finalName>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.apache.tomcat.maven</groupId>
                    <artifactId>tomcat7-maven-plugin</artifactId>
                    <version>2.2</version>
                </plugin>
                <!-- 编译插件 -->
                <plugin>
                    <groupId>org.apache.maven.plugins</groupId>
                    <artifactId>maven-compiler-plugin</artifactId>
                    <configuration>
                        <source>1.8</source>
                        <target>1.8</target>
                    </configuration>
                </plugin>
                <!-- 资源打包插件 -->
                <plugin>
                    <artifactId>maven-resources-plugin</artifactId>
                    <configuration>
                        <encoding>utf-8</encoding>
                        <useDefaultDelimiters>true</useDefaultDelimiters>
                        <resources>
                            <resource>
                                <directory>src/main/resources</directory>
                                <filtering>true</filtering>
                                <includes>
                                    <include>**/*</include>
                                </includes>
                            </resource>
                            <resource>
                                <directory>src/main/java</directory>
                                <includes>
                                    <include>**/*.xml</include>
                                </includes>
                            </resource>
                        </resources>
                    </configuration>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>

</project>

配置 spring 容器 ​

  • 创建 SpringBoot 项目启动类。SpringBoot 工程启动会自动扫描启动类所在包下的所有Bean,加载到 spring 容器。
java
@SpringBootApplication
public class SpringSecurityBootApp {
    public static void main(String[] args) {
        SpringApplication.run(SpringSecurityBootApp.class, args);
    }
}
  • 在 resources 目录下,创建 Spring Boot 配置文件 application.yml。配置 web 项目的相关配置
yml
server:
  port: 8080
  servlet:
    context-path: /

spring:
  application:
    name: spring-security-boot

配置 Servlet Context ​

  • 创建 Servlet Context 配置类 WebConfig。由于 Spring boot starter 自动装配机制,不需要像 Spring MVC 项目,不用标识 @EnableWebMvc 与 @ComponentScan 注解,如下:
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
    /**
     * 配置 url 的与页面的映射关系
     *
     * @param registry
     */
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        // 增加默认 Url 根路径为 '/' 时,跳转到 /login。注:此 url 是 spring security 默认提供的登陆页面
        registry.addViewController("/").setViewName("redirect:/login");
    }
}
  • 视图解析器在 application.yml 文件中配置。指定项目上页面的前后缀
yml
spring:
  mvc:
    view: # 配置视图解析器
      prefix: /WEB-INF/view/
      suffix: .jsp

Spring Security 安全配置 ​

创建 Spring Security 安全配置类 WebSecurityConfig,由于 Spring boot starter 自动装配机制,无需使用 @EnableWebSecurity 注解开启 Spring Security,配置的内容和上面 Spring MVC 项目一样,如下:

java
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    /**
     * 创建 UserDetailsService 实例。
     * 用于定义用户信息服务(查询用户信息)
     *
     * @return
     */
    @Bean
    public UserDetailsService userDetailsService() {
        // 正常情况是查询数据库,此示例为了简单直接创建保存在内存中的用户信息服务
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        // 创建用户信息与配置权限标识
        manager.createUser(User.withUsername("admin").password("123").authorities("p1").build());
        manager.createUser(User.withUsername("moon").password("456").authorities("p2").build());
        return manager;
    }

    /**
     * 密码编码器,即设置登陆时密码的校验
     *
     * @return
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        // 此示例为了方便,暂时使用密码无转码的方式来验证
        return NoOpPasswordEncoder.getInstance();
    }

    /**
     * 安全拦截机制(最重要)
     *
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/check/p1").hasAuthority("p1") // 设置不同权限访问的url
                .antMatchers("/check/p2").hasAuthority("p2") // 设置不同权限访问的url
                .antMatchers("/check/**").authenticated() // 设置所有 /check/** 的请求必须认证通过
                .anyRequest().permitAll()  // 设置除了上面配置的 /check/**,其它的请求可以访问
                .and()
                .formLogin() // 允许表单登录
                .successForwardUrl("/login-success"); // 自定义登录成功的页面地址
    }
   
}

测试 ​

创建与上面 Spring MVC 示例一样的控制层。启动项目进行登陆、退出、授权等测试。

示例项目访问地址:http://127.0.0.1:8080/

Spring Security 应用详解 ​

Spring Security 提供了非常好的认证、授权扩展方法,比如:快速开始的示例中将用户信息存储到内存中,实际开发中用户信息通常在数据库,Spring security 可以实现从数据库读取用户信息,Spring security 还支持多种授权方法。

自定义认证登录页面 ​

在上面基础示例中,默认配置没有明确设定一个登录页面的URL,因此 Spring Security 会根据启用的功能自动生成一个登录页面 URL,并使用默认 URL 处理登录的提交内容,登录后跳转的到默认 URL 等等。尽管 Spring Security 提供的默认登陆页面很方便快速启动和运行,但实际应用程序都希望定义自己的登录页面。

配置流程 ​

  • 在项目的 /src/main/webapp/WEB-INF/view/ 目录下,创建自定义认证页面 login.jsp
jsp
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="utf-8" %>
<html>
<head>
    <title>用户登录</title>
</head>
<body>
<form action="login" method="post">
    用户名:<input type="text" name="username"/><br/>
    密&nbsp;&nbsp;&nbsp;码:
    <input type="password" name="password"/><br/>
    <input type="submit" value="登录">
</form>
</body>
</html>
  • 在 web mvc 配置类 WebConfig.java 中,配置登陆时跳转到自定义的认证页面
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        // 配置跳转到自定义的认证登陆页面,url 按需定,此示例是 /login-view
        registry.addViewController("/").setViewName("redirect:/login-view");
        // 配置自定义登陆 url 指向自定义的页面 login.jsp
        registry.addViewController("/login-view").setViewName("login");
    }
}
  • 修改 Spring Security 安全配置类 WebSecurityConfig,配置表单登录认证相关信息
java
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .antMatchers("/check/p1").hasAuthority("p1") // 设置不同权限访问的url
            .antMatchers("/check/p2").hasAuthority("p2") // 设置不同权限访问的url
            .antMatchers("/check/**").authenticated() // 设置所有 /check/** 的请求必须认证通过
            .anyRequest().permitAll()  // 设置除了上面配置的 /check/**,其它的请求可以访问
            .and()
            .formLogin() // 允许表单登录
            .loginPage("/login-view") // 指定自定义登陆页面url
            .loginProcessingUrl("/login") // 指定登陆认证表单提交的url
            .successForwardUrl("/login-success"); // 自定义登录成功的页面地址
}

配置项解析:

  • formLogin():允许表单登录
  • loginPage("/login-view"):指定自定义登录页,spring security 以重定向方式跳转到 /login-view
  • loginProcessingUrl("/login"):指定登录处理的 URL,也就是用户名、密码表单提交的目的路径
  • successForwardUrl("/login-success"):指定登录成功后的跳转URL

必须允许所有用户访问自定义登录页(例如为验证的用户),这个 formLogin().permitAll() 方法允许任意用户访问基于表单登录的所有的URL。

测试与跨域问题 ​

  • 测试,启动项目。访问项目根路径,当用户没有认证(登陆)时访问系统的资源会重定向到 login-view,即 login.jsp 页面

但点击登陆会报错。

问题原因:spring security 为防止 CSRF(Cross-site request forgery 跨站请求伪造)的发生,限制了除了 get 以外的大多数方法。因为表单的提交是 post 请求

  • 解决方法1:配置 Spring Security 安全配置类 WebSecurityConfig,屏蔽 CSRF 控制,即 spring security 不再限制 CSRF。
java
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable() // 屏蔽 CSRF(Cross-site request forgery跨站请求伪造)控制
            ....
}
  • 解决方法2:在 login.jsp 页面添加一个 token,spring security 会验证 token,如果 token 合法则可以继续请求。
jsp
<form action="login" method="post">
    <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
    ....
</form>

小坑记录 ​

因为示例项目是使用聚合项目的结构

启动项目时会找不到jsp页面

问题出现的原因是:IDEA默认工作路径为当前工程路径(Project),而不是模块的路径, 所以导致多模块无法定位到 /WEB-INF/ 下的 jsp 文件;而在独立的模块的工程中,工作路径就是模块路径 ,因此可以定位。故在多模块的工程中,设置 Working directory 为当前模块即可。

或者直接选择模块目录

配置后即可正常访问到jsp页面

自定义连接数据库认证 ​

前面的示例都是将用户信息存储在内存中,实际项目中用户信息存储在数据库中。

创建数据库 ​

  • 创建 user_db 数据库
sql
CREATE DATABASE `user_db` CHARACTER SET 'utf8' COLLATE 'utf8_general_ci';
  • 创建 t_user 表
sql
CREATE TABLE `t_user` (
	`id` BIGINT ( 20 ) NOT NULL COMMENT '用户id',
	`username` VARCHAR ( 64 ) NOT NULL,
	`password` VARCHAR ( 64 ) NOT NULL,
	`fullname` VARCHAR ( 255 ) NOT NULL COMMENT '用户姓名',
	`mobile` VARCHAR ( 11 ) DEFAULT NULL COMMENT '手机号',
PRIMARY KEY ( `id` ) USING BTREE 
) ENGINE = INNODB DEFAULT CHARSET = utf8 ROW_FORMAT = DYNAMIC;

INSERT INTO t_user ( `id`, `username`, `password`, `fullname` )
VALUES
	( 1, 'admin', '123', '管理员' ),( 2, 'moon', '123', '天锁斩月' );

配置连接数据库 ​

  • 修改项目依赖文件 pom.xml,引入 jdbc、mysql 数据库驱动 依赖
xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.48</version>
</dependency>
  • 修改项目配置文件 application.yml,增加数据库相关的配置
yml
spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/user_db
    username: root
    password: 123456

编写查询用户接口 ​

  • 创建用户实体类
java
@Data
public class UserDO {
    private String id;
    private String username;
    private String password;
    private String fullname;
    private String mobile;
}
  • 创建用户 DAO 接口
java
@Repository
public class UserDao {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    /**
     * 根据账号查询用户信息
     */
    public UserDO getUserByUsername(String username) {
        String sql = "select id,username,password,fullname,mobile from t_user where username = ?";
        // 连接数据库查询用户
        List<User> list = jdbcTemplate.query(sql, new Object[]{username}, new BeanPropertyRowMapper<>(UserDO.class));
        if (list != null && list.size() == 1) {
            return list.get(0);
        }
        return null;
    }
}

修改自定义 UserDetailService 接口实现 ​

创建(修改)自定义 UserDetailService 接口实现类 CustomUserDetailsService,在 loadUserByUsername 方法中查询数据库获取用户信息

java
@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Autowired
    private UserDao userDao;

    /**
     * 自定义实现获取用户的逻辑
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 根据账号查询数据库
        UserDO user = userDao.getUserByUsername(username);

        if (user == null) {
            // 如果用户查不到,返回 null,由 Spring Security 框架 provider 来抛出异常
            return null;
        }

        return User.withUsername(user.getUsername())
                .password(user.getPassword()) // 设置密码
                .authorities("p1") // 设置权限,目前暂时使用静态数据
                .build();
    }
}

使用 BCryptPasswordEncoder ​

在示例项目 Spring Security 安全配置类中,配置相关的密码编码器,本示例使用 BCryptPasswordEncoder 加密密码

java
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

此时 UserDetails 中的密码存储是 BCrypt 格式,所以修改数据库表,存储 BCrypt 格式的密码

密码“123”加密后:$2a$10$v5KrQmZ9X6H7teU2tofKye4BlvQUkStUKwxWzNxnoNXwz9wjHuz0C

测试 ​

启动项目,测试是否成功认证登陆。

会话 ​

用户认证通过后,为了避免用户的每次操作都进行认证可将用户的信息保存在会话中。Spring Security 提供会话管理,认证通过后将身份信息放入 SecurityContextHolder 上下文,SecurityContext 与当前线程进行绑定,方便获取用户身份。

获取用户身份 ​

修改 LoginController 类,增加 getUsername 方法,通过 Spring Security 框架的 SecurityContextHolder.getContext().getAuthentication() 方法获取当前登录用户信息

java
@RestController
public class LoginController {
    /**
     * 登陆成功后跳转的请求url
     */
    @RequestMapping(value = "/login-success", produces = {"text/plain;charset=UTF-8"})
    public String loginSuccess() {
        return getUsername() + "登录成功";
    }

    /**
     * 通过 Spring Security 框架的 SecurityContext 对象获取当前用户信息
     */
    private String getUsername() {
        String username = null;
        // 获取当前认证通过的用户身份信息
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        // 用户身份
        Object principal = authentication.getPrincipal();

        if (principal == null) {
            username = "匿名";
        }

        if (principal instanceof UserDetails) {
            UserDetails userDetails = (UserDetails) principal;
            username = userDetails.getUsername();
        } else {
            username = principal.toString();
        }

        return username;
    }
}

启动项目测试,登陆成功看是否返回当前登陆用户

会话控制 ​

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

机制描述
always如果没有 session 存在就创建一个
ifRequired如果需要就创建一个 Session(默认)登录时
neverSpringSecurity 将不会创建 Session,但是如果应用中其他地方创建了 Session,那么 Spring Security 将会使用它
statelessSpringSecurity 将绝对不会创建 Session,也不使用 Session

通过 Spring Security 安全配置,对会话选项进行配置:

java
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable() // 屏蔽 CSRF(Cross-site request forgery跨站请求伪造)控制
            ....
            .and()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED); // 会话控制配置
}

默认情况下,Spring Security 会为每个登录成功的用户会新建一个 Session,就是 ifRequired

若选用 never,则指示 Spring Security 对登录成功的用户不创建 Session 了,但若你的应用程序在某地方新建了 session,那么 Spring Security 会用它的。

若使用 stateless,则说明 Spring Security 对登录成功的用户不会创建 Session 了,你的应用程序也不会允许新建 session。并且它会暗示不使用 cookie,所以每个请求都需要重新进行身份验证。这种无状态架构适用于 REST API 及其无状态认证机制。

会话超时 ​

可以在 sevlet 容器中设置 Session 的超时时间,修改 spring boot 项目配置文件,例如设置 Session 有效期为 3600s:

properties
server.servlet.session.timeout=3600s
yml
server:
  servlet:
    session:
      timeout: 3m # 默认值是30m(30分钟)

session 超时之后,可以通过 Spring Security 安全配置,设置超时后跳转的路径:

java
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.sessionManagement()
        .invalidSessionUrl("/login‐view?error=INVALID_SESSION"); // 配置会话失效后的跳转url
}

可以使用 httpOnly 和 secure 配置来保护会话 cookie:

  • httpOnly:如果为 true,那么浏览器脚本将无法访问 cookie
  • secure:如果为 true,则 cookie 将仅通过 HTTPS 连接发送

修改 spring boot 项目配置文件:

properties
server.servlet.session.cookie.http‐only=true
server.servlet.session.cookie.secure=true
yml
server:
  servlet:
    session:
      cookie:
        http-only: true
        secure: true

自定义退出 ​

默认退出 ​

Spring security 默认实现了 logout(退出)功能,默认url是:/logout

点击“Log Out”退出 成功。退出后访问其它 url 判断是否成功退出。

当退出后,会进行以下操作:

  • 使 HTTP Session 无效
  • 清除 SecurityContextHolder
  • 跳转到 /login-view?logout

自定义退出配置 ​

Spring Security 可以自定义退出成功的页面,可以通过安全配置类,进一步配置自定义退出功能:

java
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .authorizeRequests()
        ....
        .and()
        .logout() // 提供系统退出支持,使用 WebSecurityConfigurerAdapter 会自动被应用
        .logoutUrl("/logout") // 设置触发退出操作的URL, 默认是 /logout
        .logoutSuccessUrl("/login‐view?logout") // 退出之后跳转的URL。默认是 /login?logout
        .logoutSuccessHandler(logoutSuccessHandler) // 定制的 LogoutSuccessHandler,用于实现用户退出成功时的处理。如果指定了这个选项那么 logoutSuccessUrl() 的设置会被忽略。
        .addLogoutHandler(logoutHandler) // 添加一个 LogoutHandler,用于实现用户退出时的清理工作。默认 SecurityContextLogoutHandler 会被添加为最后一个 LogoutHandler
        .invalidateHttpSession(true); // 指定是否在退出时让 HttpSession 无效。默认设置为 true
}
  • logout() 提供系统退出支持,使用 WebSecurityConfigurerAdapter 会自动被应用
  • logoutUrl("/logout") 设置触发退出操作的URL ,默认是 /logout
  • logoutSuccessUrl("/login‐view?logout") 退出之后跳转的URL。默认是 /login?logout
  • logoutSuccessHandler(logoutSuccessHandler) 定制的 LogoutSuccessHandler,用于实现用户退出成功时的处理。如果指定了这个选项那么 logoutSuccessUrl() 的设置会被忽略
  • addLogoutHandler(logoutHandler) 添加一个 LogoutHandler,用于实现用户退出时的清理工作。默认 SecurityContextLogoutHandler 会被添加为最后一个 LogoutHandler
  • invalidateHttpSession(true) 指定是否在退出时让 HttpSession 无效。 默认设置为 true

注意:如果想让在 GET 请求下退出,必须关闭防止 CSRF 攻击 csrf().disable()。如果开启了 CSRF,必须使用 post 方式请求 /logout

LogoutHandler ​

一般来说,LogoutHandler 的实现类被用来执行必要的清理,因而他们不应该抛出异常。下面是 Spring Security 提供的一些实现:

  • PersistentTokenBasedRememberMeServices 基于持久化 token 的 RememberMe 功能的相关清理
  • TokenBasedRememberMeService 基于 token 的 RememberMe 功能的相关清理
  • CookieClearingLogoutHandler 退出时 Cookie 的相关清理
  • CsrfLogoutHandler 负责在退出时移除 csrfToken
  • SecurityContextLogoutHandler 退出时 SecurityContext 的相关清理

链式 API 提供了调用相应的 LogoutHandler 实现的快捷方式,比如 deleteCookies()

授权 ​

概述 ​

授权的方式包括 web 授权和方法授权,web 授权是通过 url 拦截进行授权,方法授权是通过方法拦截进行授权。他们都会调用 accessDecisionManager 进行授权决策,若为 web 授权则拦截器为 FilterSecurityInterceptor;若为方法授权则拦截器为 MethodSecurityInterceptor。如果同时通过 web 授权和方法授权则先执行 web 授权,再执行方法授权,最后决策通过,则允许访问资源,否则将禁止访问。

类关系如下:

示例环境准备 ​

在 user_db 数据库创建如下表:

  • 角色表:
sql
DROP TABLE IF EXISTS `t_role`;
CREATE TABLE `t_role` (
	`id` VARCHAR ( 32 ) NOT NULL,
	`role_name` VARCHAR ( 255 ) DEFAULT NULL,
	`description` VARCHAR ( 255 ) DEFAULT NULL,
	`create_time` datetime DEFAULT NULL,
	`update_time` datetime DEFAULT NULL,
	`status` CHAR ( 1 ) NOT NULL,
	PRIMARY KEY ( `id` ),
	UNIQUE KEY `unique_role_name` ( `role_name` ) 
) ENGINE = INNODB DEFAULT CHARSET = utf8;

TRUNCATE TABLE `t_role`;
INSERT INTO `t_role` ( `id`, `role_name`, `description`, `create_time`, `update_time`, `status` ) VALUES( '1', '管理员', NULL, NULL, NULL, '' );
  • 用户角色关系表:
sql
DROP TABLE IF EXISTS `t_user_role`;
CREATE TABLE `t_user_role` (
	`user_id` VARCHAR ( 32 ) NOT NULL,
	`role_id` VARCHAR ( 32 ) NOT NULL,
	`create_time` datetime DEFAULT NULL,
	`creator` VARCHAR ( 255 ) DEFAULT NULL,
	PRIMARY KEY ( `user_id`, `role_id` ) 
) ENGINE = INNODB DEFAULT CHARSET = utf8;

TRUNCATE TABLE `t_user_role`;
INSERT INTO `t_user_role` ( `user_id`, `role_id`, `create_time`, `creator` ) VALUES ( '1', '1', NULL, NULL );
  • 权限表:
sql
DROP TABLE IF EXISTS `t_permission`;
CREATE TABLE `t_permission` (
`id` varchar(32) NOT NULL,
`code` varchar(32) NOT NULL COMMENT '权限标识符',
`description` varchar(64) DEFAULT NULL COMMENT '描述',
`url` varchar(128) DEFAULT NULL COMMENT '请求地址',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

TRUNCATE TABLE `t_permission`;
INSERT INTO `t_permission`(`id`,`code`,`description`,`url`) VALUES ('1','p1','测试资源1','/check/p1'),('2','p3','测试资源2','/check/p2');
  • 角色权限关系表:
sql
DROP TABLE IF EXISTS `t_role_permission`;
CREATE TABLE `t_role_permission` (
	`role_id` varchar(32) NOT NULL,
	`permission_id` varchar(32) NOT NULL,
	PRIMARY KEY (`role_id`,`permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

TRUNCATE TABLE `t_role_permission`;
INSERT INTO `t_role_permission` ( `role_id`, `permission_id` ) VALUES ( '1', '1' ),( '1', '2' );

修改自定义 UserDetailService 接口实现 ​

  • 创建权限表相应的实体类
java
@Data
public class PermissionDO {
    private String id;
    private String code;
    private String description;
    private String url;
}
  • 修改用户 dao 接口,增加根据 id 查询用户权限的方法
java
/**
 * 根据用户id查询用户权限
 */
public List<String> findPermissionsByUserId(String userId) {
    String sql = "SELECT * FROM t_permission WHERE id IN(\n" +
            "\n" +
            "SELECT permission_id FROM t_role_permission WHERE role_id IN(\n" +
            "  SELECT role_id FROM t_user_role WHERE user_id = ? \n" +
            ")\n" +
            ")\n";

    List<PermissionDO> list = jdbcTemplate.query(sql, new Object[]{userId}, new BeanPropertyRowMapper<>(PermissionDO.class));
    List<String> permissions = new ArrayList<>();
    list.forEach(c -> permissions.add(c.getCode()));
    return permissions;
}
  • 修改 UserDetailService 实现类 CustomUserDetailsService 中的 loadUserByUsername 方法,增加查询数据库获取用户权限
java
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    // 根据账号查询数据库
    UserDO user = userDao.getUserByUsername(username);

    if (user == null) {
        // 如果用户查不到,返回 null,由 Spring Security 框架 provider 来抛出异常
        return null;
    }

    // 根据用户id查询用户权限
    List<String> permissions = userDao.findPermissionsByUserId(user.getId());
    // 权限集合转数组
    String[] permissionArray = permissions.toArray(new String[permissions.size()]);

    return User.withUsername(user.getUsername())
            .password(user.getPassword()) // 设置密码
            .authorities(permissionArray) // 设置权限
            .build();
}

最后启动项目进行测试

web 授权 ​

配置不同 url 权限 ​

在 Spring Security 的安全配置中,通过给 http.authorizeRequests() 添加多个子节点来定制不同需求的URL权限。如:

java
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .authorizeRequests()
        .antMatchers("/check/p1").hasAuthority("p1") // 设置拥有p1权限访问的url
        .antMatchers("/check/p2").hasAuthority("p2") // 设置拥有p2权限访问的url
        .antMatchers("/check/p3").access("hasAuthority('p1') and hasAuthority('p2')") // 设置同时拥有p1、p2权限访问的url
        .antMatchers("/check/**").authenticated() // 设置所有 /check/** 的请求必须认证通过
        .anyRequest().permitAll()  // 设置除了上面配置的 /check/**,其它的请求可以访问
        .and()
        ....
}

http.authorizeRequests() 方法有多个子节点,每个 antMatchers 按照它们的声明顺序执行。示例配置说明如下:

  • antMatchers("/check/p1").hasAuthority("p1") 指定拥有p1权限才能访问 /check/p1
  • antMatchers("/check/p2").hasAuthority("p2") 指定拥有p2权限才能访问 /check/p2
  • antMatchers("/check/p3").access("hasAuthority('p1') and hasAuthority('p2')") 指定同时拥有p1、p2权限才能访问 /check/p3
  • antMatchers("/check/**").authenticated() 指定除了上面 p1、p2、p3等url之外的 /check/ 开头的资源,同时通过身份认证就能够访问。这里使用SpEL(Spring Expression Language)表达式
  • anyRequest().permitAll() 剩余的尚未匹配的资源,不做保护
配置注意事项 ​

规则的顺序是重要的,更具体、细粒度更小的规则应该先写。否则大范围的规则会覆盖后面小范围的规则,从而导致权限保护失效

如现在以 /admin 开始的所有内容都需要具有 ADMIN 角色的身份验证用户,即使是 /admin/login 路径(因为 /admin/login 已经被 /admin/** 规则匹配,因此第二个规则被忽略).

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

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

java
.antMatchers("/admin/login").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
保护 URL 常用的方法 ​
  • authenticated() 保护URL,需要用户登录
  • permitAll() 指定URL无需保护,一般应用与静态资源文件
  • hasRole(String role) 限制单个角色访问,角色将被增加“ROLE_”。所以“ADMIN”将和“ROLE_ADMIN”进行比较
  • hasAuthority(String authority) 限制单个权限访问
  • hasAnyRole(String… roles) 允许多个角色访问
  • hasAnyAuthority(String… authorities) 允许多个权限访问
  • access(String attribute) 该方法使用 SpEL表达式,所以可以创建复杂的限制
  • hasIpAddress(String ipaddressExpression) 限制IP地址或子网

方法授权 ​

从 Spring Security 2.0版本开始,它支持服务层方法的安全性的支持。主要有 @PreAuthorize,@PostAuthorize,@Secured 三类注解

可以在任何标识 @Configuration 注解的项目配置类上,使用 @EnableGlobalMethodSecurity 注释来启用基于注解的安全权限控制。

java
@Configuration
/*
 * @EnableGlobalMethodSecurity 注解配置启用基于注解的安全权限控制
 *  securedEnabled 为 true,代表启用 @Secured 注解
 *  prePostEnabled 为 true,代表启动 @PreAuthorize/@PostAuthorize 注解
 */
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    ....
}

然后在方法(在类或接口上)添加相应的注解即可限制对该方法的访问权限控制。 Spring Security 的原生注释支持为该方法定义了一组属性。 这些将被传递给 AccessDecisionManager 以供它作出实际的决定:

@Secured 注解 ​
java
public interface BankService {
    @Secured("IS_AUTHENTICATED_ANONYMOUSLY")
    public Account readAccount(Long id);
    
    @Secured("IS_AUTHENTICATED_ANONYMOUSLY")
    public Account[] findAccounts();
   
    @Secured("ROLE_TELLER")
    public Account post(Account account, double amount);
}

以上配置标明 readAccount、findAccounts 方法可匿名访问,底层使用 WebExpressionVoter 投票器,可从 AffirmativeBased 第23行代码跟踪

post 方法需要有 TELLER 角色才能访问,底层使用 RoleVoter 投票器。

@PreAuthorize 注解 ​
java
@GetMapping(value = "/check/p1", produces = {"text/plain;charset=UTF-8"})
@PreAuthorize("hasAuthority('p1')") // 拥有p1权限才可以访问
public String checkPrivilege1() {
    return getUsername() + "访问资源p1";
}

@GetMapping(value = "/check/p2", produces = {"text/plain;charset=UTF-8"})
@PreAuthorize("hasAuthority('p2')") // 拥有p2权限才可以访问
public String checkPrivilege2() {
    return getUsername() + "访问资源p2";
}

@GetMapping(value = "/check/p3", produces = {"text/plain;charset=UTF-8"})
@PreAuthorize("hasAuthority('p1') and hasAuthority('p3')") // 拥有p1和p3权限才可以访问
public String checkPrivilege3() {
    return getUsername() + "访问资源p3";
}

@GetMapping(value = "/check/p4", produces = {"text/plain;charset=UTF-8"})
@PreAuthorize("isAnonymous()") // 代表方法可匿名访问
public String checkPrivilege4() {
    return getUsername() + "访问资源p4";
}

以上配置标明 checkPrivilege4 方法可匿名访问,底层使用 WebExpressionVoter 投票器,可从 AffirmativeBased 第23行代码跟踪

Spring Security 工作原理 ​

框架总体结构概述 ​

Spring Security 构架的作用是安全访问控制,而安全访问控制功能其实就是对所有进入系统的请求进行拦截,校验每个请求是否能够访问它所期望的资源。可以通过 Filter 或 AOP 等技术来实现,Spring Security 对 Web 资源的保护是靠 Filter 链实现的

当初始化 Spring Security 时,会创建一个名为 SpringSecurityFilterChain 的 Servlet 过滤器,类型为 org.springframework.security.web.FilterChainProxy,它实现了 javax.servlet.Filter,因此外部的请求会经过此类,下图是 Spring Security 过虑器链结构图:

FilterChainProxy 是一个代理,真正起作用的是 FilterChainProxy 中 SecurityFilterChain 所包含的各个 Filter,同时这些 Filter 会作为 Bean 被 Spring 管理,它们是 Spring Security 核心,各有各的职责,但这些 Filter 并不直接处理用户的认证,也不直接处理用户的授权,而是交给了认证管理器(AuthenticationManager)和决策管理器(AccessDecisionManager)进行处理,下图是 FilterChainProxy 相关类的 UML 图示。

Spring Security 功能的实现主要是由一系列过滤器链相互配合完成。

过滤器链中主要的几个过滤器及其作用:

  • SecurityContextPersistenceFilter 这个 Filter 是整个拦截过程的入口和出口(也就是第一个和最后一个拦截器),会在请求开始时从配置好的 SecurityContextRepository 中获取 SecurityContext,然后把它设置给 SecurityContextHolder。在请求完成后将 SecurityContextHolder 持有的 SecurityContext 再保存到配置好的 SecurityContextRepository,同时清除 SecurityContextHolder 所持有的 SecurityContext;
  • UsernamePasswordAuthenticationFilter 用于处理来自表单提交的认证。该表单必须提供对应的用户名和密码,其内部还有登录成功或失败后进行处理的 AuthenticationSuccessHandler 和 AuthenticationFailureHandler,这些都可以根据需求做相关改变;
  • FilterSecurityInterceptor 是用于保护web资源的,使用 AccessDecisionManager 对当前用户进行授权访问,前面已经详细介绍过了;5.2. - ExceptionTranslationFilter 能够捕获来自 FilterChain 所有的异常,并进行处理。但是它只会处理两类异常:AuthenticationException 和 AccessDeniedException,其它的异常它会继续抛出。

认证流程 ​

整体流程概述 ​

认证过程如下:

  1. 用户提交用户名、密码被 SecurityFilterChain 中的 UsernamePasswordAuthenticationFilter 过滤器获取到,封装为请求 Authentication,通常情况下是 UsernamePasswordAuthenticationToken 这个实现类。
  2. 然后过滤器将 Authentication 提交至认证管理器(AuthenticationManager)进行认证
  3. 认证成功后,AuthenticationManager 身份管理器返回一个被填充满了信息的(包括上面提到的权限信息,身份信息,细节信息,但密码通常会被移除) Authentication 实例。
  4. SecurityContextHolder 安全上下文容器将第3步填充了信息的 Authentication,通过 SecurityContextHolder.getContext().setAuthentication(…) 方法,设置到其中。

可以看出 AuthenticationManager 接口(认证管理器)是认证相关的核心接口,也是发起认证的出发点,它的实现类为 ProviderManager。而 Spring Security 支持多种认证方式,因此 ProviderManager 维护着一个 List<AuthenticationProvider> 列表,存放多种认证方式,最终实际的认证工作是由 AuthenticationProvider 完成的。咱们知道 web 表单的对应的 AuthenticationProvider 实现类为 DaoAuthenticationProvider,它的内部又维护着一个 UserDetailsService 负责 UserDetails 的获取。最终 AuthenticationProvider 将 UserDetails 填充至 Authentication。

认证核心组件的大体关系如下:

AuthenticationProvider ​

在 Spring Security 认证流程中,认证管理器(AuthenticationManager)委托 AuthenticationProvider 完成认证工作。AuthenticationProvider 是一个接口,源码如下:

java
public interface AuthenticationProvider {

	Authentication authenticate(Authentication authentication)
			throws AuthenticationException;

	boolean supports(Class<?> authentication);
}

authenticate() 方法是用于定义认证的实现过程,它的参数是一个 Authentication 实例,里面包含了登录用户所提交的用户、密码等。而返回值也是一个 Authentication 实例,这个 Authentication 实例则是在认证成功后,将用户的权限及其他信息重新组装后生成。

Spring Security 中维护着一个 List<AuthenticationProvider> 列表,存放多种认证方式,不同的认证方式使用不同的 AuthenticationProvider

每个 AuthenticationProvider 需要实现 supports() 方法来表明自己所支持的认证方式,如使用表单方式认证,在提交请求时 Spring Security 会生成 UsernamePasswordAuthenticationToken,它是一个 Authentication,里面封装着用户提交的用户名、密码信息。

在 DaoAuthenticationProvider 的基类 AbstractUserDetailsAuthenticationProvider 中有如下代码:

java
public boolean supports(Class<?> authentication) {
	return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}

由此可以说明,当 web 表单提交用户名密码时,Spring Security 是由 DaoAuthenticationProvider 类来处理。

Authentication (认证信息) ​

Authentication (认证信息) ,是 Spring Security 框架中的接口,直接继承自 Principal 类,而 Principal 是位于 java.security 包中的。它是表示着一个抽象主体身份,任何主体都有一个名称,因此包含一个 getName() 方法。前面出现过的 UsernamePasswordAuthenticationToken 就是此接口的其中一个实现,接口源码如下:

java
public interface Authentication extends Principal, Serializable {

	Collection<? extends GrantedAuthority> getAuthorities();

	Object getCredentials();

	Object getDetails();

	Object getPrincipal();

	boolean isAuthenticated();

	void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
  • getAuthorities():获取权限信息列表,返回值默认是 GrantedAuthority 接口的一些实现类,通常是代表权限信息的一系列字符串。
  • getCredentials():获取凭证信息,用户输入的密码字符串,在认证过后通常会被移除,用于保障安全。
  • getDetails():获取细节信息,web 应用中的实现接口通常为 WebAuthenticationDetails,它记录了访问者的 ip 地址和 sessionId 的值。
  • getPrincipal():获取身份信息,大部分情况下返回的是 UserDetails 接口的实现类,UserDetails 代表用户的详细信息,那从 Authentication 中取出来的 UserDetails 就是当前登录用户信息,它也是框架中的常用接口之一。

UserDetailsService ​

概述 ​

DaoAuthenticationProvider 处理了 web 表单的认证逻辑,认证成功后既得到一个 Authentication (UsernamePasswordAuthenticationToken实现类实例),里面包含了身份信息(Principal)。这个身份信息就是一个 Object,大多数情况下它可以被强转为 UserDetails 对象。

DaoAuthenticationProvider 中包含了一个 UserDetailsService 实例,它负责根据用户名提取用户信息 UserDetails 实例(包含密码),然后 DaoAuthenticationProvider 会去对比 UserDetailsService 提取的用户密码与用户提交的密码是否匹配,作为认证成功的关键依据,因此可以通过自定义的 UserDetailsService 接口实现,然后放到 Spring 容器管理,从而来实现自定义的身份验证。

  • UserDetailsService 接口
java
public interface UserDetailsService {
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

DaoAuthenticationProvider 和 UserDetailsService 两个接口所负责的职责是不一样的,其中 UserDetailsService 只负责从特定的地方(通常是数据库)加载用户信息,仅此而已。而 DaoAuthenticationProvider 的职责更大,它完成完整的认证流程,同时会把 UserDetails 填充至 Authentication

  • UserDetails 接口
java
public interface UserDetails extends Serializable {

	Collection<? extends GrantedAuthority> getAuthorities();

	String getPassword();

	String getUsername();

	boolean isAccountNonExpired();

	boolean isAccountNonLocked();

	boolean isCredentialsNonExpired();

	boolean isEnabled();
}

UserDetails 和 Authentication 接口很类似,比如它们都拥有 username,authorities 等属性。Authentication 接口的 getCredentials() 方法与 UserDetails 接口中的 getPassword() 方法,需要被区分对待,前者是用户提交的密码凭证,后者是用户实际存储的密码,认证其实就是对这两者的比对。

Authentication 接口中的 getAuthorities() 方法实际是由 UserDetails 接口的 getAuthorities() 方法传递而形成的。Authentication 接口中的 getDetails() 方法中的 UserDetails 用户详细信息便是经过了 AuthenticationProvider 认证之后被填充的。

通过自定义实现 UserDetailsService 和 UserDetails,可以完成对用户信息获取方式以及用户信息字段的扩展。Spring Security 提供的 InMemoryUserDetailsManager(内存认证),JdbcUserDetailsManager(jdbc认证)就是 UserDetailsService 的实现类,主要区别是前者从内存加载用户数据,后者是从数据库加载用户数据。

自定义 UserDetailsService 接口实现 ​

在原来的安全配置类 WebSecurityConfig 中,注释了原来创建框架默认的 UserDetailsService 实现的实例化方法。然后编写自定义的 org.springframework.security.core.userdetails.UserDetailsService 接口实现类,重写 loadUserByUsername 方法,该方法就是用于实现获取用户的处理逻辑。

java
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

/**
 * 自定义 UserDetailsService 实现
 */
@Service
public class CustomUserDetailsService implements UserDetailsService {
    /**
     * 自定义实现获取用户的逻辑
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 入参为登陆的账号
        System.out.println("username=" + username);
        // 根据账号查询数据库,但此示例暂时使用静态数据模拟
        return User.withUsername(username)
                .password("123") // 设置密码
                .authorities("p1") // 设置权限
                .build();
    }
}

重启工程,请求认证,测试 SpringDataUserDetailsService 实现类中的 loadUserByUsername 方法是否被调用 ,查询用户信息。

PasswordEncoder ​

概述 ​

DaoAuthenticationProvider 认证处理器通过 UserDetailsService 获取到 UserDetails 后,就需要将查询到的用户密码与请求 Authentication 中的密码做对比。Spring Security 为了适应多种多样的加密类型,将校验方法抽象成接口,DaoAuthenticationProvider 通过 PasswordEncoder 接口的 matches 方法进行密码的对比,而具体的密码对比细节取决于具体的实现。PasswordEncoder 接口源码如下:

java
public interface PasswordEncoder {

	String encode(CharSequence rawPassword);

	boolean matches(CharSequence rawPassword, String encodedPassword);

	default boolean upgradeEncoding(String encodedPassword) {
		return false;
	}
}

Spring Security 提供很多内置的 PasswordEncoder 实现,能够开箱即用,具体使用哪种 PasswordEncoder,只需要在项目的配置类(或者配置文件)中,手动创建相应的现实类实例,并加入到 Spring 容器即可。如:

java
@Bean
public PasswordEncoder passwordEncoder() {
    return NoOpPasswordEncoder.getInstance();
}

以上示例 NoOpPasswordEncoder 采用字符串匹配方法,不对密码进行加密比较处理,密码比较流程如下:

  1. 用户输入密码(明文 )
  2. DaoAuthenticationProvider 获取 UserDetails(其中存储了用户的正确密码)
  3. DaoAuthenticationProvider 使用 PasswordEncoder 对输入的密码和正确的密码进行校验,密码一致则校验通过,否则校验失败。

NoOpPasswordEncoder 的校验规则拿输入的密码和 UserDetails 中的正确密码进行字符串比较,字符串内容一致则校验通过,否则校验失败。

实际项目中推荐使用 BCryptPasswordEncoder, Pbkdf2PasswordEncoder, SCryptPasswordEncoder 等

密码加密使用示例 BCryptPasswordEncoder ​
  • 在安全配置类 WebSecurityConfig 中,创建 BCryptPasswordEncoder 实例并加入到 Spring 容器
java
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}
  • 修改示例中自定义获取用户数据处理方法的逻辑,对 UserDetails 中的原始密码进行 BCrypt 加密
java
@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 根据账号查询数据库,但此示例暂时使用静态数据模拟
        // 对密码进行 BCrypt 加密
        String hashpw = BCrypt.hashpw("123", BCrypt.gensalt());
        return User.withUsername(username)
                .password(hashpw) // 设置密码
                .authorities("p1") // 设置权限
                .build();
    }

}

注:实际项目中存储在数据库中的密码并不是原始密码,都是经过加密处理的密码。

授权流程 ​

整体流程概述 ​

Spring Security 可以通过 http.authorizeRequests() 对 web 请求进行授权保护。Spring Security 使用标准 Filter 建立了对 web 请求的拦截,最终实现对资源的授权访问。授权流程如下:

  1. 拦截请求,已认证用户访问受保护的 web 资源将被 SecurityFilterChain 中的 FilterSecurityInterceptor 的子类拦截
  2. 获取资源访问策略,FilterSecurityInterceptor 会从 SecurityMetadataSource 的子类 DefaultFilterInvocationSecurityMetadataSource 获取要访问当前资源所需要的权限 Collection<ConfigAttribute>。SecurityMetadataSource 其实就是读取访问策略的抽象,而读取的内容就是项目所配置的访问规则, 读取访问策略如:

  1. FilterSecurityInterceptor 会调用 AccessDecisionManager 进行授权决策,若决策通过,则允许访问资源,否则将禁止访问。

授权决策 ​

AccessDecisionManager 采用投票的方式来确定是否能够访问受保护资源。

通过上图可以看出,AccessDecisionManager 中包含的一系列 AccessDecisionVoter 将会被用来对 Authentication 是否有权访问受保护对象进行投票,AccessDecisionManager 根据投票结果,做出最终决策。

AccessDecisionManager(访问决策管理器) ​

AccessDecisionManager(访问决策管理器)的核心接口源码如下:

java
public interface AccessDecisionManager {

    /**
     * 通过传递的参数来决定用户是否有访问对应受保护资源的权限
     */
	void decide(Authentication authentication, Object object,
			Collection<ConfigAttribute> configAttributes) throws AccessDeniedException,
			InsufficientAuthenticationException;

	boolean supports(ConfigAttribute attribute);

	boolean supports(Class<?> clazz);
}

其中decide() 方法就是用来鉴定当前用户是否有访问对应受保护资源的权限。方法的参数说明如下:

  • Authentication authentication:要访问资源的访问者的身份
  • Object object:要访问的受保护资源,web 请求对应 FilterInvocation
  • Collection<ConfigAttribute> configAttributes:是受保护资源的访问策略,通过 SecurityMetadataSource 获取。

AccessDecisionVoter (决策投票者) ​

AccessDecisionVoter 是一个接口,其中定义有三个方法,具体源码如下:

java
public interface AccessDecisionVoter<S> {

	int ACCESS_GRANTED = 1;
	int ACCESS_ABSTAIN = 0;
	int ACCESS_DENIED = -1;

	boolean supports(ConfigAttribute attribute);

	boolean supports(Class<?> clazz);

	int vote(Authentication authentication, S object,
			Collection<ConfigAttribute> attributes);
}

vote() 方法的返回结果会是 AccessDecisionVoter 中定义的三个常量之一。

  • ACCESS_GRANTED 表示同意
  • ACCESS_DENIED 表示拒绝
  • ACCESS_ABSTAIN 表示弃权

如果一个 AccessDecisionVoter 不能判定当前 Authentication 是否拥有访问对应受保护对象的权限,则其 vote() 方法的返回值应当为弃权 ACCESS_ABSTAIN

内置 AccessDecisionManager 实现类 ​

Spring Security 内置了三个基于投票的 AccessDecisionManager 实现类如下,它们分别是 AffirmativeBased、ConsensusBased 和 UnanimousBased

AffirmativeBased 处理逻辑是:

  1. 只要有 AccessDecisionVoter 的投票为 ACCESS_GRANTED 则同意用户进行访问
  2. 如果全部弃权也表示通过
  3. 如果没有一个人投赞成票,但是有人投反对票,则将抛出 AccessDeniedException

Spring security 默认使用的是 AffirmativeBased

ConsensusBased 处理逻辑是:

  1. 如果赞成票多于反对票则表示通过
  2. 反过来,如果反对票多于赞成票则将抛出 AccessDeniedException
  3. 如果赞成票与反对票相同且不等于0,并且属性 allowIfEqualGrantedDeniedDecisions 的值为 true,则表示通过,否则将抛出异常 AccessDeniedException。参数 allowIfEqualGrantedDeniedDecisions 的值默认为 true。
  4. 如果所有的 AccessDecisionVoter 都弃权了,则将视参数 allowIfAllAbstainDecisions 的值而定,如果该值为 true 则表示通过,否则将抛出异常 AccessDeniedException。参数 allowIfAllAbstainDecisions 的值默认为 false。

UnanimousBased 处理逻辑是:

  1. 如果受保护对象配置的某一个 ConfigAttribute 被任意的 AccessDecisionVoter 反对了,则将抛出 AccessDeniedException
  2. 如果没有反对票,但是有赞成票,则表示通过
  3. 如果全部弃权了,则将视参数 allowIfAllAbstainDecisions 的值而定,true 则通过,false 则抛出 AccessDeniedException

UnanimousBased 的逻辑与另外两种实现有点不一样,另外两种会一次性把受保护对象的配置属性全部传递给 AccessDecisionVoter 进行投票,而 UnanimousBased 会一次只传递一个 ConfigAttribute 给 AccessDecisionVoter 进行投票。这也就意味着如果 AccessDecisionVoter 的逻辑是只要传递进来的 ConfigAttribute 中有一个能够匹配则投赞成票,但是放到 UnanimousBased 中其投票结果就不一定是赞成了

内置投票者实现类(未整理) ​

Spring Security 也内置一些投票者实现类,如 RoleVoter、AuthenticatedVoter 和 WebExpressionVoter 等,待查阅资料学习

Spring Security OAuth 概述 ​

Spring Security OAuth2.0 是对 OAuth2.0 协议的一种实现,并且跟 Spring Security 相辅相成。

Spring Security OAuth2.0 与 Spring Cloud 整合 ​

Spring Security OAuth2.0 与 Spring Cloud 体系的集成也非常便利

此部分详细内容详见 《Spring Cloud Security 学习笔记》

项目终止维护通知 ​

Spring Security OAuth 项目生命周期已结束,不再积极维护。该项目已被 Spring Security 和 Spring Authorization Server 提供的 OAuth2 支持所取代。

I know where dry desert ends, green grass grows · MooN