aoe1231

看得完吗

Spring Security 入门

1、基本概念

1.1、什么是认证

进入移动互联网时代,大家每天都在刷手机,常用的软件有微信、支付宝、头条等,下边拿微信来举例子说明认证相关的基本概念,在初次使用微信前需要注册成为微信用户,然后输入账号和密码即可登录微信,输入账号和密码登录微信的过程就是认证。

系统为什么要认证?认证是为了保护系统的隐私数据与资源,用户的身份合法方可访问该系统的资源。

认证:用户认证就是判断一个用户的身份是否合法的过程,用户去访问系统资源时系统要求验证用户的身份信息,身份合法方可继续访问,不合法则拒绝访问。常见的用户身份认证方式有:用户名密码登录,二维码登录,手机短信登录,指纹认证等方式。

1.2、什么是会话

用户认证通过后,为了避免用户的每次操作都进行认证可将用户的信息保存在会话中。会话就是系统为了保持当前用户的登录状态所提供的机制,常见的有基于session方式、基于token方式等。

基于 session 方式的认证方式如下:

它的交互流程是,用户认证成功后,在服务端生成用户相关的数据保存在 session(当前会话)中,发给客户端的 session_id 存放到 cookie 中,这样用户客户端请求时带上 session_id 就可以验证服务器端是否存在 session 数据,以此完成用户的合法校验,当用户退出系统或 session 过期销毁时,客户端的 session_id 也就无效了。

基于 token 方式如下:

它的交互过程是,用户认证成功后,服务端生成一个 token 发给客户端,客户端可以放到 cookie 或 localStorage 等存储中,每次请求带上 token,服务端收到 token 通过验证后即可确认用户身份。

1.3、什么是授权

还拿微信来举例子,微信登录成功后用户即可使用微信的功能,比如,发红包、发朋友圈、添加好友等,没有绑定银行卡的用户是无法发红包的,绑定银行卡的用户才可以发红包,发红包功能、发朋友圈功能都是微信的资源即功能资源,用户拥有发红包功能的权限才可以正常使用发红包功能,拥有发朋友圈功能的权限才可以使用发朋友圈功能,这个根据用户的权限来控制用户使用资源的过程就是授权。

为什么要授权?

认证是为了保证用户身份的合法性,授权则是为了更细粒度的对隐私数据进行划分,授权是在认证通过后发生的,控制不同的用户能够访问不同的资源。

授权:授权是用户认证通过根据用户的权限来控制用户访问资源的过程,拥有资源的访问权限则正常访问,没有权限则拒绝访问。

1.4、授权的数据模型

如何进行授权即如何对用户访问资源进行控制,首选需要学习授权相关的数据模型。

授权可简单理解为 Who 对 What(Which)进行 How 操作,包括如下:

  • Who,即主体(Subject),主体一般是指用户,也可以是程序,需要访问系统中的资源;
  • What,即资源(Resource),如系统菜单、页面、按钮、代码方法、系统商品信息、系统订单信息等。系统菜单、页面、按钮、代码方法都属于系统功能资源,对于 web 系统每个功能资源通常对应一个 URL;系统商品信息、系统订单信息都属于实体资源(数据资源),实体资源由资源类型和资源实例组成,比如商品信息为资源类型,商品编号为001的商品为资源实例;
  • How,权限/许可(Permission),规定了用户对资源的操作许可,权限离开资源没有意义,如用户查询权限、用户添加权限、某个代码方法的调用权限、编号为001的用户的修改权限等,通过权限可知用户对哪些资源都有哪些操作许可。

主体、资源、权限关系如下图:

主体、资源、权限相关的数据模型如下:

  • 主体(用户id、账号、密码、...);
  • 资源(资源id、资源名称、访问地址、...);
  • 权限(权限id、权限标识、权限名称、资源id、...);
  • 角色(角色id、角色名称、...);
  • 角色和权限关系(角色id、权限id);
  • 主体(用户)和角色关系(用户id、角色id、...);

主体(用户)、资源、权限关系如下:

通常企业开发中将资源和权限表合并为一张权限表,如下:

  • 资源(资源id、资源名称、访问地址、...);
  • 权限(权限id、权限标识、权限名称、资源id、...);

通常企业开发中将资源和权限表合并为一张权限表,如下:

  • 资源(资源id、资源名称、访问地址、...);
  • 权限(权限id、权限标识、权限名称、资源id、...)。

合并为:

  • 权限(权限id、权限标识、权限名称、资源名称、资源访问地址、...)

修改后数据模型之间的关系如下图:

1.5、RBAC

如何实现授权?业界通常基于RBAC实现授权。

1.5.1、基于角色的访问控制

RBAC 基于角色的访问控制(Role-Based Access Control)是按角色进行授权,比如:主体的角色为总经理,可以查询企业运营报表、查询员工工资信息等,访问控制流程如下:

根据上图中的判断逻辑,授权代码可表示如下:

if (主体.hasRole("总经理角色id")) {
    查询工资
}

如果上图中查询工资所需要的角色变化为总经理和部门经理,此时就需要修改判断逻辑为“判断用户的角色是否是总经理或部门经理”,修改代码如下:

if (主体.hasRole("总经理角色id") || 主体.hasRole("部门经理角色id")) {
    查询工资
}

根据上边的例子发现,当需要修改角色的权限时就需要修改授权的相关代码,系统可扩展性差。

1.5.2、基于资源的访问控制

RBAC 基于资源的访问控制(Resource Based Access Control)是按资源(或权限)进行授权,比如:用户必须具有查询工资权限才可以查询员工工资信息等,访问控制流程如下:

根据上图中的判断,授权代码可以表示为:

if (主体.hasPermission("查询工资权限标识")) {
    查询工资
}

优点:系统设计时定义好查询工资的权限标识,即使查询工资所需要的角色变化为总经理和部门经理也不需要修改授权代码,系统可扩展性强。

2、基于 Session 的认证方式

2.1、认证流程

基于 Session 认证方式的流程是,用户认证成功后,在服务端生成用户相关的数据保存在 session(当前会话),而发给客户端的 session_id 存放到 cookie 中,这样用客户端请求时带上 session_id 就可以验证服务器端是否存在 session 数据,以此完成用户的合法校验。当用户推出系统或 session 过期销毁时,客户端的 session_id 也就无效了。下图是 session 认证方式的流程图:

基于 Session 的认证机制由 Servlet 规范定制,Servlet 容器已实现,用户通过 HttpSession 的操作方法即可实现,如下是 HttpSession 相关的操作 API:

方法 含义
HttpSession getSession(Boolean create) 获取当前 HttpSession 对象
void setAttribute(String name, Object value) 向 session 中存放对象
Object getAttribute(String name) 从 session 中获取对象
void removeAttribute(String name) 移除 session 中的对象
void invalidate() 使 HttpSession 失效
... ...

2.2、创建工程

2.2.1、创建 maven 工程

本案例工程使用 maven 进行构建,使用 SpringMVC、Servlet3.0 实现。

pom.xml:

<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-study</artifactId>
        <groupId>com.clp</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>security-springmvc</artifactId>
    <packaging>war</packaging>

    <name>security-springmvc</name>
    <url>http://maven.apache.org</url>

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

    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
            <version>5.1.5.RELEASE</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.8</version>
        </dependency>
    </dependencies>

    <build>
        <finalName>security-springmvc</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>
                    <version>3.10.1</version>
                    <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>

2.2.2、Spring 容器配置

在 config包下定义 ApplicationConfig.java,它对应 web.xml 中 ContextLoaderListener 的配置。

@Configuration // 相当于 applicationContext.xml
@ComponentScan(basePackages = "com.clp.security.mvc",
        excludeFilters = {@ComponentScan.Filter(type = FilterType.ANNOTATION, value = Controller.class)}) // Spring 容器排除 @Controller 的类
public class ApplicationConfig {

    // 在此配置除了 Controller 的其他 bean,比如:数据库连接池、事务管理器、业务bean等

}

2.2.3、ServletContext 的配置

本案例采用 Servlet3.0 无web.xml方式,在 config 包下定义WebConfig.java

@Configuration // 相当于 springmvc.xml
@EnableWebMvc
@ComponentScan(basePackages = "com.clp.security.mvc",
        includeFilters = {@ComponentScan.Filter(type = FilterType.ANNOTATION, value = Controller.class)}) // SpringMvc容器要扫描 @Controller 类
public class WebConfig implements WebMvcConfigurer {

    // 视图解析器
    @Bean
    public InternalResourceViewResolver viewResolver() {
        InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
        viewResolver.setPrefix("/WEB-INF/view/");
        viewResolver.setSuffix(".jsp");
        return viewResolver;
    }

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/").setViewName("login");
    }

}

2.2.4、加载 Spring 容器

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

/**
 * 相当于 web.xml
 */
public class SpringApplicationInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
    @Override // Spring 容器,相当于加载 applicationContext.xml
    protected Class<?>[] getRootConfigClasses() {
        return new Class[]{ApplicationConfig.class}; // 指定 rootContext 的配置类
    }

    @Override // servletContext,相当于加载 springmvc.xml
    protected Class<?>[] getServletConfigClasses() {
        return new Class[]{WebConfig.class}; // 指定 servletContext 的配置类
    }

    @Override // url-mapping
    protected String[] getServletMappings() {
        return new String[]{"/"};
    }
}

SpringApplicationInitializer 相当于 web.xml,使用了 servlet3.0 开发则不需要再定义web.xml。

2.3、实现认证功能

2.3.1、认证页面

在 webapp/WEB-INF/view 下面定义认证页面 login.jsp,本案例只是测试认证流程,页面没有添加 css 样式,页面实现可填入用户名、密码,触发登录将提交表单信息至 /login,内容如下:

<%@ page contentType="text/html;charset=UTF-8" pageEncoding="utf-8" language="java" %>
<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>

在 WebConfig 中新增如下配置,将 / 直接导向 login.jsp 页面:

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/").setViewName("login");
    }

启动项目,访问 / 路径地址,进行测试:

2.3.2、认证接口

用户进入认证页面,输入账号和密码,点击登录,请求 /login 进行身份认证。

1、定义认证接口,此接口用于对传来的用户名、密码校验,若成功则返回该用户的详细信息,否则抛出错误异常:

/**
 * 认证服务
 */
public interface AuthenticationService {

    /**
     * 用户认证
     * @param authenticationRequest 用户认证请求
     * @return 认证成功的用户信息
     */
    UserDto authentication(AuthenticationRequest authenticationRequest);

}

认证请求结构:

@Data
public class AuthenticationRequest {
    // 认证请求参数:账号、密码
    private String username;
    private String password;
}

认证成功后返回用户的详细信息,也就是当前登录用户的信息:

@Data
public class UserDto {
    // 用户身份信息
    private String id;
    private String username;
    private String password;
    private String fullname;
    private String mobile;
}

2、认证服务实现:

@Service
public class AuthenticationServiceImpl implements AuthenticationService {
    // 模拟用户信息
    private Map<String, UserDto> userMap = new HashMap<>();

    {
        userMap.put("zhangsan", new UserDto("1010", "zhangsan", "123", "张三", "133443"));
        userMap.put("lisi", new UserDto("1011", "lisi", "456", "李四", "144553"));
    }

    /**
     * 用户认证,校验用户身份信息是否合法
     *
     * @param authenticationRequest 用户认证请求
     * @return
     */
    @Override
    public UserDto authentication(AuthenticationRequest authenticationRequest) {
        // 校验参数是否为空
        if (authenticationRequest == null || StringUtils.isEmpty(authenticationRequest.getUsername())
                || StringUtils.isEmpty(authenticationRequest.getPassword())) {
            throw new RuntimeException("账号或密码为空");
        }
        // 根据账号取查询数据库,这里测试程序采用模拟方法
        UserDto userDto = getUserDto(authenticationRequest.getUsername());
        if (userDto == null) {
            throw new RuntimeException("查询不到该用户");
        }
        // 校验密码
        if (!authenticationRequest.getPassword().equals(userDto.getPassword())) {
            throw new RuntimeException("账号或密码错误");
        }
        // 认证通过,返回用户的身份信息
        return userDto;
    }

    // 模拟用户查询
    @Nullable
    public UserDto getUserDto(String username) {
        return userMap.get(username);
    }
}

3、登录 Controller,对 /login 请求处理,它调用 AuthenticationService 完成认证并返回登录结果提示信息:

@RestController
public class LoginController {

    @Resource
    private AuthenticationService authenticationService;

    @PostMapping(value = "/login", produces = {"text/plain;charset=UTF-8"}) // produce 表示返回文本类型
    public String login(AuthenticationRequest authenticationRequest) {
        UserDto userDto = authenticationService.authentication(authenticationRequest);
        return userDto.getUsername() + "登录成功";
    }

}

2.4、实现会话功能

会话是指用户登录系统后,系统会记住该用户的登录状态,他可以在系统连续操作直到推出系统的过程。

认证的目的是对系统资源的保护,每次对资源的访问,系统必须得知道是谁在访问资源,才能对该请求进行合法性拦截。因此,在认证成功后,一般会把认证成功的用户信息放入 Session 中,在后续的请求中,系统能够从 Session 中获取到当前用户,用这样的方式来实现会话机制。

2.4.1、增加会话控制

首先在 UserDto 中定义一个 SESSION_USER_KEY ,作为 Session 中存放登录信息的 key。

public static final String SESSION_USER_KEY = "_user";

然后修改 LoginController,认证成功后,将用户信息放入当前会话,并增加用户登出方法,登出时将 session 置为失效。

@RestController
public class LoginController {

    @Resource
    private AuthenticationService authenticationService;

    @PostMapping(value = "/login", produces = {"text/plain;charset=UTF-8"}) // produce 表示返回文本类型
    public String login(AuthenticationRequest authenticationRequest, HttpSession session) {
        UserDto userDto = authenticationService.authentication(authenticationRequest);
        // 存入session
        session.setAttribute(UserDto.SESSION_USER_KEY, userDto);
        return userDto.getUsername() + "登录成功";
    }

    @GetMapping(value = "/logout", produces = {"text/plain;charset=UTF-8"}) // produce 表示返回文本类型
    public String logout(HttpSession session) {
        session.invalidate(); // 使 session 失效
        return "退出成功";
    }

    @GetMapping(value = "/r/r1", produces = {"text/plain;charset=UTF-8"}) // produce 表示返回文本类型
    public String r1(HttpSession session) {
        String fullname = null;
        Object object = session.getAttribute(UserDto.SESSION_USER_KEY);
        if (object == null) {
            fullname = "匿名";
        } else {
            fullname = ((UserDto) object).getFullname();
        }
        return fullname + "访问资源r1";
    }

}

2.5、实现授权功能

现在我们已经完成了用户身份凭证的校验以及登录的状态保持,并且我们也知道了如何获取当前登录用户(从Session中获取)的信息,接下来,用户访问系统需要经过授权,即需要完成如下功能:

  • 匿名用户(未登录用户)访问拦截:禁止匿名用户访问某些资源;
  • 登录用户访问拦截:根据用户的权限决定是否能访问某些资源。

2.5.1、增加权限数据

为了实现这样的功能,我们需要在UserDto里面增加权限属性,用于表示该登录用户所拥有的权限,同时修改UserDto的构造方法。

2.6、小结

基于Session的认证方式是一种常见的认证方式,至今还有非常多的系统在使用。我们在此小节使用SpringMvc技术对它进行简单实现,旨在让大家更清晰实在地了解用户认证、授权以及会话地功能意义及实现套路。而在正式生产项目中,我们往往会考虑使用第三方安全框架(如Spring Security、Shiro等安全框架)来实现认证授权功能,因为这样做能够提高一定程度生产力,提高软件标准化程度,另外往往这些框架地可扩展性考虑得非常全面。但是缺点也非常明显,这些通用化组件为了提高支持范围会增加很多可能我们不需要得功能,结构上也会比较抽象,如果我们不够了解它,一旦出现问题,将会很难定位。

3、Spring Security 快速上手

3.1、Spring Security 介绍

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

3.2、创建工程

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-web</artifactId>
        </dependency>
@Configuration // 相当于 application.xml
@ComponentScan(basePackages = "com.clp.security.mvc",
        excludeFilters = {@ComponentScan.Filter(type = FilterType.ANNOTATION, value = Controller.class)}) // Spring 容器排除 @Controller 的类
public class ApplicationConfig {

    // 在此配置除了 Controller 的其他 bean,比如:数据库连接池、事务管理器、业务bean等

}

@Configuration // 相当于 springmvc.xml
@EnableWebMvc
@ComponentScan(basePackages = "com.clp.security.mvc",
        includeFilters = {@ComponentScan.Filter(type = FilterType.ANNOTATION, value = Controller.class)}) // SpringMvc容器要扫描 @Controller 类
public class WebConfig implements WebMvcConfigurer {

    // 视图解析器
    @Bean
    public InternalResourceViewResolver viewResolver() {
        InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
        viewResolver.setPrefix("/static/view/");
        viewResolver.setSuffix(".html");
        return viewResolver;
    }

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/").setViewName("login");
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**")
                .addResourceLocations("classpath:");
    }
}

3.3、认证

3.3.1、认证页面

Spring Security 默认提供认证页面,不需要额外开发。

3.3.2、安全配置

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

1)在 config 包下定义 WebSecurityConfig,安全配置的内容包括:用户信息、密码编辑器、安全拦截机制。

4、Spring Security 应用详解

4.1、集成 Spring Boot

4.1.1、Spring Boot 介绍

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 提供 spring-boot-starter-security 用于开发 Spring Security 应用。

4.1.2、创建 Maven 工程

<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.3.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>com.clp</groupId>
    <artifactId>spring-security-springboot</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-security-springboot</name>
    <description>spring-security-springboot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <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>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>3.1.0</version>
            <scope>provided</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

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

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

4.1.3、Spring 容器配置

Spring Boot 工程启动会自动扫描启动类所在包下的所有 Bean,加载到 Spring 容器。

1)Spring Boot 配置文件

在 resources 目录下添加 application.yaml,内容如下:

server:
  port: 8080
  servlet:
    context-path: /security-springboot
  application:
    name: security-springboot

4.1.4、Servlet Context 配置

由于 springboot starter 自动装配机制,这里无需使用 @EnableWebMvc 与 @ComponentScan,WebConfig 如下:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override // 默认 Url 根路径跳转到 /login,此 url 为 spring security 提供
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/").setViewName("redirect:/login");
    }
}

视图解析器配置在 application.yaml 中:

spring:
  mvc:
    view:
      prefix: /WEB-INF/views/
      suffix: .jsp

4.1.5、安全配置

由于 springboot starter 自动装配机制,这里无需使用 @EnableWebSecurity,WebSecurityConfig 内容如下:

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean // 定义用户信息服务(查询用户信息)
    public UserDetailsService userDetailsService() {
        InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
        manager.createUser(User.withUsername("zhangsan").password("123").authorities("p1").build());
        manager.createUser(User.withUsername("lisi").password("456").authorities("p2").build());
        return manager;
    }

    @Bean // 密码编码器
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/r/r1").hasAuthority("p1")
                .antMatchers("/r/r2").hasAuthority("p2")
                .antMatchers("/r/**").authenticated() // 所有 /r/** 的请求必须认证通过
                .anyRequest().permitAll() // 除了 /r/**,其他的请求可以访问
                .and()
                .formLogin() // 允许表单登录
                .successForwardUrl("/login-success"); // 自定义登录成功的页面地址
    }
}
@RestController
public class LoginController {

    @Resource
    private AuthenticationService authenticationService;
    
    @RequestMapping(value = "/login-success", produces = {"text/plain;charset=UTF-8"})
    public String loginSuccess() {
        return "登录成功";
    }

    @GetMapping(value = "/r/r1", produces = {"text/plain;charset=UTF-8"}) // produce 表示返回文本类型
    public String r1(HttpSession session) {
        String fullname = null;
        Object object = session.getAttribute(UserDto.SESSION_USER_KEY);
        if (object == null) {
            fullname = "匿名";
        } else {
            fullname = ((UserDto) object).getFullname();
        }
        return fullname + "访问资源r1";
    }

    @GetMapping(value = "/r/r2", produces = {"text/plain;charset=UTF-8"}) // produce 表示返回文本类型
    public String r2(HttpSession session) {
        String fullname = null;
        Object object = session.getAttribute(UserDto.SESSION_USER_KEY);
        if (object == null) {
            fullname = "匿名";
        } else {
            fullname = ((UserDto) object).getFullname();
        }
        return fullname + "访问资源r2";
    }

}

4.2、工作原理

4.2.1、结构总览

Spring Security 所解决的问题就是安全访问控制,而安全访问控制功能其实就是对所有进入系统的请求进行拦截,校验每个请求是否能够访问它所期望的资源。根据前边知识的学习,可以通过Filter或AOP等技术来实现,Spring Security 对 Web 资源的保护是靠 Filter 实现的,所以从 Filter 入手,逐步深入 Spring Security 原理。

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

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

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

下面介绍过滤器链中主要的几个过滤器及其作用:

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

4.2.2、认证流程

4.2.2.1、认证流程

让我们仔细分析认证流程:

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

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

4.2.2.2、AuthenticationProvider

通过前面的 Spring Security 认证流程我们得知,认证管理器(AuthenticationManager)委托 AuthenticationProvider 完成认证工作。AuthenticationProvider 是一个接口,定义如下:

public interface AuthenticationProvider {
    Authentication authenticate(Authentication authentication) throws AuthenticationException;
    boolean supports(Class<?> var1);
}

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

Spring Security 中维护着一个 List<AuthenticationProvider> 列表,存储多种认证方式,不同的认证方式使用不同的 AuthenticationProvider。如使用用户名密码登录时,使用 AuthenticationProvider1,短信登录时使用 AuthenticationProvider2 等等这样的例子很多。

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

我们在 DaoAuthenticationProvider 的基类 AbstractUserDetailsAuthenticationProvider 发现以下代码:

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

也就是说当 web 表单提交用户名密码时,Spring Security 由 DaoAuthenticationProvider 处理。

最后,我们来看一下 Authentication(认证信息的结构),它是一个接口,我们之前提到的 UsernamePasswordAuthenticationToken 就是它的实现之一:

public interface Authentication extends Principal, Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();
    Object getCredentials(); // 获取凭证
    Object getDetails(); // 获取细节信息
    Object getPrincipal(); // 获取身份
    boolean isAuthenticated(); // 是否认证通过
    void setAuthenticated(boolean var1) throw IllegalArgumentException;
}
  • Authentication 是 spring-security 包中的接口,直接继承自 Principal 类,而 Principal 是位于 java.security 包中的。它是表示着一个抽象主体身份,任何主体都有一个名称,因此包含一个 getName() 方法;
  • getAuthorities(),权限信息列表,默认是 GrantedAuthority 接口的一些实现类,通常是代表权限信息的一系列字符串;
  • getCredentials(),凭证信息,用户输入的密码字符串,在认证过后通常会被移除,用于保障安全;
  • getDetails(),细节信息,web 应用中的实现接口通常为 WebAuthenticationDetails,它记录了访问者的 ip 地址和 sessionId 的值;
  • getPrincipal(),身份信息,大部分情况下返回的是 UserDetails 接口的实现类,UserDetails 代表用户的详细信息,那从 Authentication 中取出来的 UserDetails 就是当前登录用户信息,它也是框架中的常用接口之一。

 4.2.2.3、UserDetailService

1、认识 UserDetailService

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

DaoAuthenticationProvider 中包含了一个 UserDetailsService 实例,它负责根据用户名提取用户信息 UserDetails(包含密码),而后 DaoAuthenticationProvider 会去对比 UserDetailsService 提取的用户密码与用户提交的密码是否匹配作为认证成功后的关键依据,因此可以通过将自定义的 UserDetailService 公开为 spring bean 来定义自定义身份验证。

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

很多人把 DaoAuthenticationProvider 和 UserDetailsService 的职责搞混淆,其实 UserDetailsService 只负责从特定的地方(通常是数据库)加载用户信息,仅此而已。而 DaoAuthenticationProvider 的职责更大,它完成完整的认证流程,同时会把 UserDetails 填充至 Authentication。

上面一直提到 UserDetails 是用户信息,咱们看一下它的真面目:

public interface UserDetails extends Serializable {
    Collection<? extends GrantedAuthority> getAuthorities();
    String getPassword();
    String getUsername();
    boolean isAccountNonExpired();
    boolean isAccountNonLocked();
    boolean isCredentialIsNonExpired();
    boolean isEnabled();
}

它和 Authentication 接口很类似,比如它们都拥有 username,aithorities。Authentication 的 getCredentials() 与 UserDetails 中的 getPassword() 需要被区分对待,前者是用户提交的密码凭证,后者是用户实际存储的密码,认证其实就是对这两者的比对。Authentication 中的 getAuthorities()  实际是由 UserDetails 的 getAuthorities() 传递而形成的。还记得 Authoritication 接口中的 getDetails() 方法吗?其中的 UserDetails 用户详细信息便是经过了 AuthenticationProvider 认证之后被填充的。

通过实现 UserDetailsService 和 UserDetails,我们可以完成对用户信息获取方式以及用户信息字段的扩展。

Spring Security 提供的 InMemoryUserDetailManager(内存认证),jdbcUserDetailsManager(jdbc认证)就是 UserDetailsService 的实现类,主要区别无非就是从内存还是从数据库加载用户。

2、测试

自定义 UserDetailsService:

@Service
public class SpringDataUserDetailsService implements UserDetailsService {
    // 根据账号查询用户信息
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 登录账号
        System.out.pringln("username=" + username);
        // 根据账号去数据库查询 ...
        // 这里暂时使用静态数据
        UserDetails userDetails = User.withUsername(username).password("123").authorities("p1").build();
        return userDetails;
    }
}

4.2.2.4、PasswordEncoder

1、认识 PasswordEncoder

DaoAuthenticationProvider 认证处理器通过 UserDetailsService 获取到 UserDetails 后,它是如何与请求 Authentication 中的密码做对比呢?

在这里 Spring Security 为了使用多种多样的加密类型,又做了抽象,DaoAuthenticationProvider 通过 PasswordEncoder 接口的 matches() 方法进行密码的对比,而具体的密码对比细节取决于实现:

public interface PasswordEncoder {
    String encode(CharSequence var1);
    boolean matches(CharSequence var1, String var2);
    default boolean upgradeEncoding(String encodePassword) {
        return false;
    }
}

而 Spring Security 提供很多内置的 PasswordEncoder,能够开箱即用,使用某种 PasswordEncoder 只需要进行如下声明即可:

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

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

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

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

实际项目中推荐使用 BCryptPasswordEncoder、Pbkdf2PasswordEncoder、SCryptPasswordEncoder等,感兴趣可以看看这些 PasswordEncoder 的具体实现。

2、使用 BCryptPasswordEncoder

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

测试发现认证失败,提示:Encoded password does not like BCrypt。

原因:由于 UserDetails 中存储的是原始密码(比如:123),它不是 BCrypt 格式);

3、测试 BCrypt

添加依赖:

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

编写测试方法:

@RunWith(SpringRunner.class)
public class TestBCrypt {
    @Test
    public void test1() {
        // 对原始密码加密 hashPw1 和 hashPw2 结果不一样
        String hashPw1 = BCript.hashpw("123", BCrypt.getSalt());
        String hashPw2 = BCript.hashpw("123", BCrypt.getSalt());
        System.out.printlm(hashpw);
        // 校验原始密码和 BCrypt 密码是否一致
        boolean checkpw1 = BCrypt.checkpw("123", hashPw1);
        System.out.println(checkpw1); // true
        boolean checkpw2 = BCrypt.checkpw("123", hashPw2);
        System.out.println(checkpw2); // true
    }
}

4.2.3、授权流程

4.2.3.1、授权流程

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

Spring Security 的授权流程如下:

分析授权流程:

  1. 拦截请求:已认证用户访问受保护的 web 资源将被 SecurityFilterChain 中的 FilterSecurityInterceptor 的子类拦截;
  2. 获取资源访问策略:FilterSecurityInterceptor 会从 SecurityMetadataSource 的子类 DefaultFilterInvocationSecurityMetadataSource 获取要访问当前资源所需要的权限 Collection<ConfigAttribute>;
  3. 最后,FilterSecurityInterceptor 会调用 AccessDecisionManager 进行授权决策,若决策通过,则允许访问资源,否则将禁止访问。

SecurityMetadataSource 其实就是读取访问策略的抽象,而读取的内容,其实就是我们配置的访问规则,读取访问策略如:

http.authorizeRequests()
    .antMatchers("r/r1").hasAuthority("p1")
    .antMatchers("r/r2").hasAuthority("p2")

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

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

这里着重说明一下 decide 的参数:

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

decide 接口就是用来鉴定当前用户是否有访问对应受保护资源的权限。

4.2.3.2、授权决策

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

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

AccessDecisionVoter 使一个接口,其中定义有3个方法,具体结构如下所示:

public interface AccessDecisionVoter<S> {
    int ACCESS_GRANTED = 1;
    int ACCESS_ABSTAIN = 0;
    int ACCESS_DENIED = -1;
    
    boolean supports(ConfigAttribute var1);
    boolean supports(Class<?> var1);
    int vote(Authentication var1, S var2, Collection<ConfigAttribute> var3);
}

vote() 方法的返回结果会是 AccessDecisionVoter 中定义的三个常量之一。ACCESS_GRANTED 表示同意,ACCESS_DENIED 表示拒绝,ACCESS_ABSTAIN 表示弃权。如果一个 AccessDecisionVoter 不能判定当前 Authentication 是否拥有访问对应受保护对象的权限,则其 vote() 方法的返回值应当为弃权 ACCESS_ABSTAIN。

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

AffirmativeBased 的逻辑是:

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

Spring Security 默认使用的是 AffirmativeBased。

ConsensusBased 的逻辑是:

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

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

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

Spring Security 也内置一些投票者实现类如 RoleVoter、AuthenticatedVoter 和 WebExpressionVoter 等,可以自行查阅资料进行学习。

4.3、自定义认证

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

4.3.1、自定义登录页面

在快速上手中,你可能会想知道登录页面从哪里来的,因为我们并没有提供任何的 HTML 或 JSP 文件。Spring Security 的默认配置没有明确设定一个登录页面的 URL,因此 Spring Security 会根据启用的功能自动生成一个登录页面的 URL,并使用默认 URL 处理登录的提交内容,登录后跳转到默认的 URL 等等。尽管自动生成的登录页面很方便快速启动和运行,但大多数应用程序都希望定义自己的登录页面。

4.3.1.1、认证页面

比如我们使用的自定义登录页面为:webapp/WEB-INF/views/login.jsp

配置视图解析器的前缀和后缀:

spring:
    mvc:
        view:
            prefix: /WEV-INF/views/
            suffix: .jsp

4.3.1.2、配置认证页面

在 WebConfig.java 中配置认证页面地址:

// 默认 URL 根路径跳转到 /login,此 url 为 spring security 提供
@Override
public void addViewConrollers(ViewControllerRegistry registry) {
    registry.addViewController("/").setViewName("redirect:/login-view");
    registry.addViewController("/login-view").setViewName("login");
}

4.3.1.3、安全配置

在 WebSecurityConfig 中配置表单登录信息:

// 配置安全拦截机制
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
        .antMatchers("/r/**").authenticated()
        .anyRequest().permitAl()
        .and()
        .formLogin() // 允许表单登录
            .loginPage("/login-view") // 登录页面url
            .loginProcessingUrl("/login") // 登录处理的url
            .successForwardUrl("/login-success") // 登录成功后访问的自定义url地址
            .permitAll();
}
  • 允许表单登录;
  • 指定我们自己的登录页,spring security 以重定向方式跳转到 /login-view;
  • 指定登录处理的 URL,也就是用户名、密码表单提交的目的路径;
  • 指定登录成功后的跳转 URL;
  • 我们必须允许所有用户访问我们的登录页(例如为验证的用户),这个 formLogin().permitAll() 方法允许任意用户访问基于表单登录的所有 URL。

4.3.1.4、测试

当用户没有认证时访问系统的资源会重定向到 login-view 页面,但是用户输入正确的账号和密码后,点击登录会报403错误。

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

解决方法1:屏蔽 CSRF 控制,即 spring security 不再限制 CSRF。配置 WebSecurityConfig:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable() // 屏蔽 CSRF 控制,即 spring security 不再限制 CSRF
        ...
}

解决方法2:在 login.jsp 页面添加一个 token,spring security 会验证 token,如果 token 合法则可以继续请求。修改 login.jsp:

<form action="login" method="post">
    <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
    ...
</form>

4.3.2、连接数据库认证

前边的例子我们是将用户信息存储在内存中,实际项目中用户信息存储在数据库中,本节实现从数据库读取用户信息。根据前边对认证流程研究,只需重新定义 UserDetailService 即可实现用户账号查询数据库。

4.3.2.1、创建数据库

创建 user-db 数据库:

CREATE DATABASE `user_db` CHARACTER SET 'utf8' COLLATE 'utf8_general_ci';

创建 t_user 表:

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

4.3.2.2、代码实现

4.4、会话

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

4.4.1、获取用户身份

编写 LoginController,实现 r/r1、/r/r2 的测试资源,并修改 loginSuccess() 方法,注意 getUsername() 方法,Spring Security 获取当前登录用户信息的方法为 SecurityContextHolder.getContext().getAuthentication()。

4.4.2、会话控制

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

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

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

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
}
  • 默认情况下,Spring Security 会为每个登录成功的用户新建一个 Session,就是 ifRequired。
  • 若选用 never,则指示 Spring Security 对登录成功的用户不创建 Session 了,但若你的应用程序在某地方新建了 Session,那么 Spring Security 会用它;
  • 若使用 stateless,则说明 Spring Security 对登录成功的用户不会创建 Session 了,你的应用程序也不会允许新建 Session,并且它会暗示不使用 cookie,所以每个请求都需要重新进行身份验证。这种无状态架构适用于 REST API 及其无状态认证控制。

4.4.2.1、会话超时

可以在 servlet 容器中设置 Session 的超时时间,如下设置 Session 有效期为 3600 s :

server:
    servlet:
        session:
            timeout: 3600s

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

http.sessionManagement()
    .expiredUrl("/login-view?error=EXPIRED_SESSION")
    .invalidSessionUrl("/login-view?error=INVALID_SESSION");

expired 指 session 过期,invalidSession 指传入的 sessionId 无效。

4.4.2.2、安全会话 cookie

我们可以使用 httpOnly 和 secure 标签来保护我们的会话 cookie:

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

spring boot 配置文件:

server:
    servlet:
        session:
            cookie:
                http-only: true
                secure: true

4.6、退出

Spring Security 默认实现了 logout 退出,访问 /logout ,果然不出所料,退出功能 Spring 也替我们做好了。

点击 “Log Out” 退出成功。退出后访问其他 url 判断是否成功退出。这里也可以自定义退出成功的页面:

// 在 WebSecurityConfig 的 protected void configure(HttpSecurity http) 中配置:
.and()
    .logout()
    .logoutUrl("/logout")
    .logoutSuccessUrl("/login-view?logout");

当退出操作触发时,将发生:

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

但是,类似于配置登录功能,我们可以进一步自定义退出功能:

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

注意:如果让 logout 在 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()。

4.7、授权

4.7.1、概述

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

类关系如下:

4.7.2、准备环境

4.7.2.1、数据库环境

在 t_user 数据库创建如下表:

角色表:

CREATE TABLE `t_role` (
    `id` varchar(32) NOT NULL,
    `role_name` varchar(255) DEFAULT NULL,
    `description` varchar DEFAULT NULL,
    `create_time` datetime DEFAULT NULL,
    `update_time` datetime DEFAULT NULL,
    `status` char(1) NOT NULL,
    PRIMARY KEY `unique_role_name` (`role_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

insert into `t_role`(`id`, `role_name`, `description`, `create_time`, `update_time`, `status`)
values ('1', '管理员', NULL, NULL, NULL, '');

用户角色关系表:t_user_role

权限表:t_permission

查询用户所属角色的权限信息:

SELECT * FROM t_permission WHERE id IN (
    SELECT permission_id FROM t_role_permission WHERE role_id IN (
        SELECT role_id FROM t_user_role WHERE user_id = '1'
    )
);

4.7.2.2、修改 UserDetailService

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    // 将来连接数据库根据账号查询用户信息
    UserDto userDto = userDao.getUserByUsername(username);
    if (userDto == null) {
        // 如果用户查询不到,返回null,由 provider 来抛出异常
        return null;
    }
    // 根据用户的id查询用户权限
    List<String> permissions = userDao.findPermissionByUserId(userDto.getId);
    // 将 permissions 转成数组
    String permissionArray = new String[permission.size()];
    permissions.toArray(permissionArray);
    UserDetails userDetails = User.withUsername(userDto.getUsername()).password(userDto.getPassword())
        .authorities(permissionArray).build();
    return userDetails;
}

4.7.3、web 授权

即使用 HttpSecurity 类来配置权限信息。

保护 URL 常用的方法有:

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

注意:规则的顺序是重要的,更具体的规则应该先写。现在以 /admin 开始的所有内容都需要具有 ADMIN 角色的身份验证用户,即使是 /admin/login 路径(因为 /admin/login 已经被 /admin/** 规则匹配,因此第二个规则被忽略):

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

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

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

4.7.4、方法授权

现在我们已经掌握了如何使用 http.authorizeRequests(0 对 web 资源进行授权保护,从 Spring Security 2.0 版本开始,它支持服务层方法的安全性的支持。本节学习 @PreAuthorize、@PostAuthorize、@Secured 三类注解。

我们可以在任何 @Configuration 实例上使用 @EnableGlobalMethodSecurity 注解:

@EnableGlobalMethodSecurity(securedEnabled = true)
public class MethodSecurityConfig {
    // ...
}

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

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 amout);
}

以上配置表明 readAccount()、findAccounts() 方法可以匿名访问,底层使用 WebExpressionVoter 投票器,可从 AffirmativeBased 第23行代码跟踪。post() 方法需要有 TELLER 角色才能访问,底层使用 RoleVoter 投票器。

使用如下代码可启用 prePost (@PreAuthorize 和 @PostAuthorize)注解的支持:

@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurityConfig {
    // ...
}

相应 Java 代码如下:

public interface BankService {
    @PreAuthorize("isAnonymous()")
    public Account readAccount(Long id);
    
    @PreAuthorize("isAnonymous()")
    public Account[] findAccounts();
    
    @PreAuthorize("hasAuthority('p_transfer' and hasAuthority('p_read_account')")
    public Account post(Account account, double amount);
}

以上配置表明 readAccount()、findAccount() 方法可匿名访问,post() 方法需要同时拥有 p_transfer 和 p_read_account 权限才能访问,底层使用 WebExpressionVoter 投票器,可从 AffirmativeBased 第23行代码跟踪。

5、分布式系统认证方案

5.1、什么是分布式系统

随着软件环境和需求的变化,软件的架构由单体结构演变为分布式架构,具有分布式架构的系统叫分布式系统,分布式系统的运行通常依赖网络,它将单体结构的系统分为若干服务,服务之间通过网络交互来完成用户的业务处理,当前流行的微服务架构就是分布式系统架构,如下图:

分布式系统具体有如下基本特点:

  • 分布性:每个部分都可以独立部署,服务之间交互通过网络进行通信。比如:订单服务、商品服务。
  • 伸缩性:每个部分都可以集群方式部署,并可针对部分节点进行硬件及软件扩容,具有一定的伸缩能力。
  • 共享性:每个部分都可以作为共享资源对外提供服务,多个部分可能有操作共享资源的情况。
  • 开放性:每个部分根据需求都可以对外发布共享资源的访问接口,并可允许第三方系统访问。

5.2、分布式认证需求

分布式系统的每个服务都会有认证、授权的需求,如果每一个服务都实现一套认证授权逻辑会非常冗余,考虑分布式系统共享性的特点,需要由独立的认证服务处理系统认证授权的请求;考虑分布式系统开放性的特点,不仅对系统内部服务提供保证,对第三方系统也要提供认证。分布式认证的需求总结如下:

5.2.1、统一认证授权

提供独立的认证服务,统一处理认证授权。无论是不同类型的用户,还是不同种类的客户端(web端、H5、APP——,均采用一致的认证、权限、会话机制,实现统一认证授权。

要实现统一则认证方式必须可扩展,支持各种认证需求,比如:用户名密码认证、短信验证码、二维码、人脸识别等认证方式,并可以非常灵活地切换。

5.2.2、应用接入认证

应提供扩展和开放能力,提供安全的系统对接机制,并可开放部分API给接入第三方使用,一方应用(内部系统服务)和三方应用(第三方应用)均采用统一机制接入。

5.3、分布式认证方案

5.3.1、选型分析

5.3.1.1、基于 session 的认证方式

在分布式的环境下,基于 session 的认证会出现一个问题,每个应用服务都需要在 session 中存储用户身份信息,通过负载均衡将本地的请求分配到另一个应用服务需要将 session 信息带过去,否则会重新认证。

这个时候,通常的做法有下面几种:

  • Session 复制:多台应用服务器之间同步 session,使 session 保持一致,对外透明。
  • Session 粘贴:当用户访问集群中某台服务器后,强制指定后续所有请求均落到此机器上。
  • Session 集中存储:将 Session 存入分布式缓存中,所有服务器应用实例统一从分布式缓存中存取 Session。

总体来讲,基于 session 认证的认证方式,可以更好地在服务器端对会话进行控制,且安全性较高。但是,session 机制方式基于 cookie,在复杂多样的移动客户端上不能有效地使用,并且无法跨域,另外随着系统地扩展需提高 session 的复制、粘贴及存储的容错性。

5.3.1.2、基于 token 的认证方式

基于 token 的认证方式,服务端不用存储认证数据,易维护扩展性强,客户端可以把 token 存在任意地方,并且可以实现 web 和 app 统一认证机制。其缺点也很明显,token 由于自包含信息,因此一般数据量较大,而且每次请求都需要传递,因此比较占带宽。另外,token 的签名操作也会给 cpu 带来额外的处理负担。

5.3.2、技术方案

根据选型的分析,决定采用基于 token 的认证方式,它的优点是:

  • 适合统一认证的机制,客户端、一方应用、三方应用都遵循一致的认证机制;
  • token 认证方式对第三方应用接入更适合,因为它更开放,可使用当前流行的开放协议 Oauth2.0、JWT 等;
  • 一般情况服务端无需存储会话信息,减轻了服务端的压力。

分布式系统认证技术方案见下图:

 

posted on 2022-10-17 18:56  啊噢1231  阅读(45)  评论(0编辑  收藏  举报

导航

回到顶部