Spring Security

1、简介

Spring Security是一个能够为基于Spring的企业应用系统提供声明式(注解)的安全访问控制解决方案的安全框架。
它提供了一组可以在Spring应用上下文中配置的Bean,充分利用了Spring IOC、DI和AOP功能,为应用系统提供声明式的安全访问控制功能,减少了为企业系统安全访问控制编写大量重复代码的工作。

2、认证授权

2.1 认证(登录)

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

2.2 会话

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

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

2)基于token的认证方式
它的交互流程是,用户认证成功后,服务端生成一个token(令牌,一个字符串标识)发给客户端,客户端可以
放到cookie或 localStorage等存储中,每次请求时带上token,服务端收到token通过验证后即可确认用户
身份。这种方式主要用于分布式系统中,将token和用户信息存储在Redis中,实现会话共享。
在这里插入图片描述

2.3 授权

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

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

2.4 RBAC

RBAC用户角色权限控制,是实现授权的一种模型。
在这里插入图片描述

至少需要五张表:

  • 三张业务数据表:用户表、角色表、菜单(权限)表。
  • 两张关系表:用户角色关系表、角色菜单关系表。
  • 最终建立用户和角色的多对多关系,角色和权限的多对多关系

3、SpringSecurity

3.1 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>
    <groupId>com.mmy</groupId>
    <artifactId>spring-security-01</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>SpringSecurity01</name>

    <properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-boot.version>2.3.7.RELEASE</spring-boot.version>
    </properties>

    <dependencies>

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

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

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

    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>2.3.7.RELEASE</version>
                <configuration>
                    <mainClass>com.mmy.SpringSecurity01Application</mainClass>
                </configuration>
                <executions>
                    <execution>
                        <id>repackage</id>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

3.2 配置application.properties

#服务器端口
server.port=8080
#项目访问路径
server.servlet.context-path=/SpringSecurity01

#自定义登录用户名密码
spring.security.user.name=admin
spring.security.user.password=123456

#配置数据源
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/db_security?serverTimezone=UTC
spring.datasource.username=root
spring.datasource.password=1234

#配置mybatis
#指定sql映射文件的位置
mybatis.mapper-locations=classpath:mybatis/mapper/*.xml
#输出日志
mybatis.configuration.log-impl=org.apache.ibatis.logging.slf4j.Slf4jImpl
#开启驼峰命名映射规则
mybatis.configuration.map-underscore-to-camel-case=true

3.3 配置多用户

定义一个被@Configuration注解标注的WebSecurityConfigurerAdapter的子类,即SpringSecurity的配置类;重写void | configure(AuthenticationManagerBuilder)方法配置用户:

     /*
      配置用户信息:
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
       
        //模拟内存用户 -- 在内存中配置两个用户
        auth.inMemoryAuthentication()
                .withUser("jake")//用户名
                .password("123")//密码
                .roles("CEO","CTO","CTO")//角色
                .authorities("sys:add", "sys:delete", "sys:update", "sys:query")//权限
                .and()
                .withUser("smith")//用户名
                .password("456")//密码
                .roles("CMO")//角色
                .authorities("sys:query");//权限
     }

3.4 配置加密器

package com.mmy.config;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    /*
      配置用户信息:
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //模拟内存用户 -- 在内存配置两个用户
        auth.inMemoryAuthentication()
                .withUser("jake")//用户名
                .password(passwordEncoder().encode("123"))//密码加密
                .roles("CEO","CTO","CTO")//角色
                .authorities("sys:add", "sys:delete", "sys:update", "sys:query")//权限
                .and()
                .withUser("smith")//用户名
                .password(passwordEncoder().encode("456"))//密码加密
                .roles("CMO")//角色
                .authorities("sys:query");//权限
    }

    /*
      配置加密器,即配置PasswordEncoder的bean对象:

      从Spring5开始,强制要求密码加密;
      如果非不想密码加密,可以返回一个PasswordEncoder过期的实现NoOpPasswordEncoder
      的实例,但是不建议,不安全;
     */
    @Bean
    public PasswordEncoder passwordEncoder(){
        //返回PasswordEncoder的实现BCryptPasswordEncoder的实例
        return new BCryptPasswordEncoder();
    }
}

加密器的常用方法:

String | encode(String):
对密码进行加密,参数是未加密的密码,返回值是加密后的密码。


boolean | matches(String,String):
对密码进行匹配,参数一是未加密的密码,参数二是加密的密码,匹配则返回true,不匹配则返回false

3.5 获取当前登录的用户信息

3.5.1 向请求处理方法参数注入Principal对象

    /*
      处理/项目/userInfo1的请求,然后将Principal对象转成json响应给客户端;
     */
    @RequestMapping("/userInfo1")
    public Principal getUserInfo1(Principal principal){
        return principal;
    }

3.5.2 向请求处理方法参数注入Authentication对象

    /*
      处理/项目/userInfo2的请求,然后将Authentication对象转成json响应给客户端;
     */
    @RequestMapping("/userInfo2")
    public Authentication getUserInfo2(Authentication authentication){
        return authentication;
    }

3.5.3 获取Authentication对象

先调用SecurityContextHolder.getContext()方法拿到SecurityContext对象,再调用
SecurityContext对象的getAuthentication()方法拿到Authentication对象。

    /*
      处理/项目/userInfo3的请求,然后将获取的Authentication对象转成json响应给客户端;
     */
    @RequestMapping("/userInfo3")
    public Authentication getUserInfo3(){

        SecurityContext context = SecurityContextHolder.getContext();
        Authentication authentication = context.getAuthentication();

        return authentication;
    }

以jake登录,前端响应的json数据:
在这里插入图片描述

3.6 url访问授权

在SpringSecurity的配置类中,重写void | configure(HttpSecurity http)/方法,通过对http请求的拦截验证来/配置用户、角色、权限,实现url访问授权。

使用方法级别的授权,只需在controller对应的请求处理方法上标注相应的注解,就无需再在SpringSecurity的配置类中配置url请求授权了:

  • 1)@PreAuthorize:在方法调用进行权限检查。
  • 2)@PostAuthorize:在方法调用进行权限检查。
  • 3)@EnableGlobalMethodSecurity(prePostEnabled = true):标注在SpringSecurity的配置类上,开启方法级别的授权,prePostEnabled = true表示启用@PreAuthorize和@PostAuthorize注解。

开启方法级别的授权:
SpringSecurity的配置类com.mmy.config.SecurityConfig.java:

package com.mmy.config;

@Configuration
//开启方法级别的授权
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    /*
      配置用户信息:
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //模拟内存用户 -- 在内存配置两个用户
        auth.inMemoryAuthentication()
                .withUser("jake")//用户名
                .password(passwordEncoder().encode("123"))//密码
                .roles("CEO","CTO","CTO")//角色
                .authorities("sys:add", "sys:delete", "sys:update", "sys:query")//权限
                .and()
                .withUser("smith")//用户名
                .password(passwordEncoder().encode("456"))//密码
                .roles("CMO")//角色
                .authorities("sys:query");//权限
    }

    /*
      配置加密器,即配置PasswordEncoder的bean对象:
     */
    @Bean
    public PasswordEncoder passwordEncoder(){
        //返回PasswordEncoder的实现BCryptPasswordEncoder的实例
        return new BCryptPasswordEncoder();
    }

    /*
      通过对http请求的拦截验证来配置用户、角色、权限,实现url访问授权:
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.formLogin()//提供一个登录表单
                .successForwardUrl("/welcome")//登录成功转发请求 /项目/welcome
                .failureForwardUrl("/login");//登录失败转发请求 /项目/login,回到登录表单

        /*
          url请求授权:
         */
        /*
          http.authorizeRequests()
                //具有sys:add权限可发出/项目/add的请求
                .antMatchers("/add").hasAnyAuthority("sys:add")
                //具有sys:delete权限可发出/项目/delete的请求
                .antMatchers("/delete").hasAnyAuthority("sys:delete")
                //具有sys:update权限可发出/项目/update的请求
                .antMatchers("/update").hasAnyAuthority("sys:update")
                //具有sys:query权限可发出/项目/query的请求
                .antMatchers("/query").hasAnyAuthority("sys:query");
        */
        http.authorizeRequests()
                //对/项目/free的请求,不受任何限制,直接放行
                .antMatchers("/free").permitAll();

        http.authorizeRequests()
                //其它所有请求,登录后就可以请求
                .anyRequest().authenticated();
    }
}

方法级别的授权:
控制层上com.mmy.controller.HelloController2.java:

package com.mmy.controller;

@RestController
public class HelloController2 {

    //处理/项目/welcome的请求,向客户端响应字符串文本"welcome"
    @RequestMapping("/welcome")
    public String welcome(){
        return "welcome";
    }

    /*
      处理/项目/add的请求,向客户端响应字符串文本"add"

      @PreAuthorize("hasAnyAuthority('sys:add')")具有sys:add权限可发出
      /项目/add的请求即执行该方法;
     */
    @RequestMapping("/add")
    @PreAuthorize("hasAnyAuthority('sys:add')")
    public String add(){
        return "add";
    }

    /*
      处理/项目/delete的请求,向客户端响应字符串文本"delete"

      @PreAuthorize("hasAnyAuthority('sys:delete')")具有sys:delete权限可发出
      /项目/delete的请求即执行该方法;
     */
    @RequestMapping("/delete")
    @PreAuthorize("hasAnyAuthority('sys:delete')")
    public String delete(){
        return "delete";
    }

    /*
      处理/项目/update的请求,向客户端响应字符串文本"update"

      @PreAuthorize("hasAnyAuthority('sys:update')")具有sys:update权限可发出
      /项目/update的请求即执行该方法;
     */
    @RequestMapping("/update")
    @PreAuthorize("hasAnyAuthority('sys:update')")
    public String update(){
        return "update";
    }

    /*
      处理/项目/query的请求,向客户端响应字符串文本"query"

      @PreAuthorize("hasAnyAuthority('sys:query')")具有sys:query权限可发出
      /项目/query的请求即执行该方法;
     */
    @RequestMapping("/query")
    @PreAuthorize("hasAnyAuthority('sys:query')")
    public String query(){
        return "query";
    }

    //处理/项目/free的请求,向客户端响应字符串文本"free"
    @RequestMapping("/free")
    public String free(){
        return "free";
    }

    //处理/项目/ok的请求,向客户端响应字符串文本"ok"
    @RequestMapping("/ok")
    public String ok(){
        return "ok";
    }
}

对没有访问权限的url发出请求会抛出403错误状态码,所以定制错误页面
src/main/resources/static/error/403.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>403</title>
</head>
<body>
    <div align="center">
        <h3>403:你没有访问权限</h3>
    </div>
</body>
</html>

测试结果:

  • 1)不用登录可直接请求http://localhost:8080/SpringSecurity01/free。
  • 2)jake和smith登录成功后,都会转发请求/SpringSecurity01/welcome。
  • 3)jake和smith登录失败后,都会转发请求/SpringSecurity01/login回到登录页面。
  • 4)jake具有sys:add、sys:delete、sys:update、sys:query所有权限,所以登录后,
    http://localhost:8080/SpringSecurity01/add、http://localhost:8080/SpringSecurity01/delete、http://localhost:8080/SpringSecurity01/update、http://localhost:8080/SpringSecurity01/query都能请求。
  • 5)smith只有sys:query权限,所以登录后,http://localhost:8080/SpringSecurity01/query能请求, http://localhost:8080/SpringSecurity01/add、http://localhost:8080/SpringSecurity01/delete、http://localhost:8080/SpringSecurity01/update请求都会抛出403状态码,进入到403.html错误页面。
  • 6)jake和smith登录后,http://localhost:8080/SpringSecurity01/ok都能请求。

3.7 响应json

3.7.1 登录成功失败响应json

自定义认证成功处理器
com.mmy.handler.MyAuthenticationSuccessHandler.java:

package com.mmy.handler;
/*
  定义AuthenticationSuccessHandler接口的实现类并重写其抽象方法
  onAuthenticationSuccess(HttpServletRequest,HttpServletResponse,Authentication),
  在方法中完成登录成功后向客户端响应json,最后将其bean对象添加到IOC容器。
 */
@Component
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse 
response, Authentication authentication)
            throws IOException, ServletException {

        Map<String,Object> map = new HashMap<>();
        map.put("code", 200);
        map.put("msg", authentication.getPrincipal());//msg -- 用户信息

        ObjectMapper mapper = new ObjectMapper();
        String jsonStr = mapper.writeValueAsString(map);

        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(jsonStr);
    }
}

自定义认证失败处理器
com.mmy.handler.MyAuthenticationFailureHandler.java:

package com.mmy.handler;
/*
定义AuthenticationFailureHandler接口的实现类并重写其抽象方法
onAuthenticationFailure(HttpServletRequest,HttpServletResponse,AuthenticationException),
在方法中完成登录失败后向客户端响应json,最后将其bean对象添加到IOC容器。
*/
@Component
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse 
response, AuthenticationException exception)
            throws IOException, ServletException {

        Map<String,Object> map = new HashMap<>();
        map.put("code", 401);
        if (exception instanceof LockedException) {
            map.put("msg", "登陆失败,账户被锁定!");
        } else if (exception instanceof BadCredentialsException) {
            map.put("msg", "登陆失败,账户或者密码错误!");
        } else if (exception instanceof DisabledException) {
            map.put("msg", "登陆失败,账户被禁用!");
        } else if (exception instanceof AccountExpiredException) {
            map.put("msg", "登陆失败,账户已过期!");
        } else if (exception instanceof CredentialsExpiredException) {
            map.put("msg", "登陆失败,密码已过期!");
        } else {
            map.put("msg", "登陆失败!");
        }

        ObjectMapper mapper = new ObjectMapper();
        String jsonStr = mapper.writeValueAsString(map);

        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(jsonStr);
    }
}

SpringSecurity配置类中注入并应用认证成功处理器和认证失败处理器:
1、注入

    //向SpringSecurity配置类注入认证成功处理器和认证失败处理器
    @Autowired
    private AuthenticationSuccessHandler authenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;

2、url访问的登录表单权限

        http.formLogin()
            .successHandler(authenticationSuccessHandler)//登录成功,使用认证成功处理器处理
            .failureHandler(authenticationFailureHandler);//登录失败,使用认证失败处理器处理

3、测试结果
在这里插入图片描述

3.7.2 权限不足响应json

自定义拒绝访问处理器
com.mmy.handler.MyAccessDeniedHandler.java:

package com.mmy.handler;
/*
  定义AccessDeniedHandler接口的实现类并重写其抽象方法
  handle(HttpServletRequest,HttpServletResponse,AccessDeniedException),
  在方法中完成拒绝访问后向客户端响应json,最后将其bean对象添加到IOC容器。
 */
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, 
AccessDeniedException accessDeniedException)
            throws IOException, ServletException {

        Map<String,Object> map = new HashMap<>();
        map.put("code", 403);
        map.put("msg", "您没有访问权限");

        ObjectMapper mapper = new ObjectMapper();
        String jsonStr = mapper.writeValueAsString(map);

        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(jsonStr);
    }
}

SpringSecurity配置类中注入并应用拒绝访问处理器
1、注入

    //向SpringSecurity配置类注入拒绝访问处理器
    @Autowired
    private AccessDeniedHandler accessDeniedHandler;


2、url访问的登录表单权限

        http.formLogin()
            .successHandler(authenticationSuccessHandler)//登录成功,使用认证成功处理器处理
            .failureHandler(authenticationFailureHandler);//登录失败,使用认证失败处理器处理
            
        //应用我们自定义的拒绝访问处理器
        http.exceptionHandling().accessDeniedHandler(accessDeniedHandler);

3、测试结果
在这里插入图片描述

3.8 SpringSecurity认证授权

  • SpringSecurity所解决的问题就是安全访问控制,而安全访问控制其实就是对所有进入系统的请求进行拦截,校验每个请求是否能够访问它所期望的资源,SpringSecurity主要是通过Filter或AOP等技术来实现的。
  • 当初始化SpringSecurity时,其会创建一个名为springSecurityFilterChain的过滤器,类型为FilterChainProxy,所有请求都会先经过此过滤器;但FilterChainProxy只是一个代理,真正起作用的是它所代理的过滤器链中的各个过滤器;这些过滤器作为bean被Spring管理,它们是SpringSecurity核心,各有各的职责,但它们并不直接处理用户的认证和授权,而是把交给了认证管理器(AuthenticationManager)和决策(权限)管理器(AccessDecisionManager,进行处理。
    在这里插入图片描述
    在这里插入图片描述
    过滤器链中主要的几个过滤器及其作用:
  • 1>SecurityContextPersistenceFilter
    这个过滤器是整个拦截过程的入口和出口(也就是第一个和最后一个拦截);会在请求开始时从配置好的 SecurityContextRepository中获取SecurityContext,然后把它设置给SecurityContextHolder;在请求完成后将SecurityContextHolder持有的SecurityContext再保存到配置好的SecurityContextRepository,同时清除 SecurityContextHolder所持有的SecurityContext。
  • 2>UsernamePasswordAuthenticationFilter
    用于处理来自表单提交的认证。该表单必须提供对应的用户名和密码,其内部还有登录成功或失败后进行处理的AuthenticationSuccessHandler(认证成功处理器)和AuthenticationFailureHandler(认证失败处理器),这些都可以根据需求做相关改变。
  • 3>FilterSecurityInterceptor
    是用于保护web资源的,使用AccessDecisionManager(权限管理器)对当前用户进行授权访问。
  • 4>ExceptionTranslationFilter
    能够捕获来自过滤器的所有异常,并进行处理。但是它只会处理两类异常:AuthenticationException(认证异常)和AccessDeniedException(权限异常),其它异常它会继续抛出。

3.9 SpringSecurity认证流程

在这里插入图片描述

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

AuthenticationManager是认证相关的核心接口,也是发起认证的出发点,它的实现类为ProviderManager。而SpringSecurity支持多种认证方式,因此ProviderManager维护着一个List<AuthenticationProvider//>,存放了多种认证方式,最终实际的认证工作其实是由AuthenticationProvider完成的。web表单对应的AuthenticationProvider为DaoAuthenticationProvider,它的内部又维护着一个UserDetailsService负责UserDetails的获取,最终DaoAuthenticationProvider将UserDetails填充至Authentication。

3.9.1 AuthenticationProvider

通过SpringSecurity的认证流程可知,真正的认证工作是AuthenticationManager委托AuthenticationProvider完成的。

AuthenticationProvider是一个接口,定义如下:

public interface AuthenticationProvider {

 Authentication authenticate(Authentication authentication)
                  throws AuthenticationException;

 boolean supports(Class<?> authentication);
}

authenticate()方法定义了认证的实现过程,它的参数是一个Authentication,里面包含了登录用户所提交的用户、密码等。而返回值也是一个Authentication,这个Authentication则是在认证成功后,将用户的权限及其它信息重新组装后生成的Authentication。
认证方式有很多,不同的认证方式需要使用不同的AuthenticationProvider,如使用用户名密码方式登录时使用AuthenticationProvider1,短信登录方式时使用AuthenticationProvider2;而每个AuthenticationProvider需要实现supports()方法来表明自己支持的认证方式
使用表单提交方式登录,在提交请求时SpringSecurity会生成UsernamePasswordAuthenticationToken,它是一个Authentication,里面封装着用户提交的用户名、密码信息。而DaoAuthenticationProvider的父类AbstractUserDetailsAuthenticationProvider中有以下代码:

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

也就是说表单提交方式登录,是由DaoAuthenticationProvider来完成认证的。

3.9.2 Authentication

Authentication中封装了认证信息,它是一个接口,UsernamePasswordAuthenticationToken是它的实现之一。Authentication定义如下:

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

3.9.3 UserDetails

UserDetails中封装着认证成功后的用户信息。其定义如下:

public interface UserDetails extends Serializable {

	Collection<? extends GrantedAuthority> getAuthorities();
	
	String getPassword();

	String getUsername();

	......
}
  • 1)getAuthorities():查询到的用户权限信息列表,默认是GrantedAuthority接口的一些实现类,通常是代表权限信息的一系列字符串。Authentication中的权限列表其实就是UserDetails的权限列表传过去的。
  • 2)getUsername():用户名。
  • 3)getPassword():查询到的用户的真实密码

3.9.4 UserDetailsService

现在已知DaoAuthenticationProvider处理了表单提交登录的认证,认证成功后会得到一个Authentication(UsernamePasswordAuthenticationToken实现),里面包含了认证成功的身份信息(Principal),这个身份信息就是一个UserDetails对象
而DaoAuthenticationProvider中又包含了一个UserDetailsService实例,它专门负责根据用户名查询用户的真实信息(UserDetails)。其定义如下:

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

而后DaoAuthenticationProvider会去对比,UserDetailsService查询到的UserDetails中的真实密码,和Authentication中用户提交的密码,是否匹配作为认证成功与否的关键依据,因此可以通过将自定义的 UserDetailsService 公开为Spring Bean来自定义身份验证。

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

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

3.9.5 自定义UserDetailsService从数据库查询用户

1、注入UserDetailsService的bean对象

//以匿名内部类形式向IOC容器中添加一个自定义的UserDetailsService的bean对象
    @Bean
    public UserDetailsService userDetailsService(){

        return new UserDetailsService() {
            /*
              重写loadUserByUsername()方法从数据库中根据用户名查询用户,参数
              username为用户录入的用户名,返回值UserDetails对象;
             */
            @Override
            public UserDetails loadUserByUsername(String username)
                                  throws UsernameNotFoundException {

                System.out.println(username);

                /*
                  从数据库中根据用户名查询用户......
                 */

                //将查询到的用户信息封装到UserDetails并返回(静态数据模拟)
                UserDetails userDetails = User
                        .withUsername(username)
                        .password(passwordEncoder().encode("123"))
                        .roles("CEO","CTO","CTO")
                        .authorities("sys:add", "sys:delete", "sys:update", "sys:query")
                        .build();
                
                return userDetails;
            }
        };
    }

2、配置用户信息

    //配置用户信息
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
          /*
          //模拟内存用户 -- 在内存配置两个用户
          auth.inMemoryAuthentication()
                .withUser("jake")//用户名
                .password(passwordEncoder().encode("123"))//密码
                .roles("CEO","CTO","CTO")//角色
                .authorities("sys:add", "sys:delete", "sys:update", "sys:query")//权限
                .and()
                .withUser("smith")//用户名
                .password(passwordEncoder().encode("456"))//密码
                .roles("CMO")//角色
                .authorities("sys:query");//权限
          */

		  //使用我们自定义的UserDetailsService从数据库查询用户进行认证
		  auth.userDetailsService(userDetailsService());
    }

3、SpringSecurity的配置类:

package com.mmy.config;

@Configuration
//开启方法级别的授权
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //向SpringSecurity配置类注入认证成功处理器和认证失败处理器
    @Autowired
    private AuthenticationSuccessHandler authenticationSuccessHandler;
    @Autowired
    private AuthenticationFailureHandler authenticationFailureHandler;
    //向SpringSecurity配置类注入拒绝访问处理器
    @Autowired
    private AccessDeniedHandler accessDeniedHandler;

//以匿名内部类形式向IOC容器中添加一个自定义的UserDetailsService的bean对象
    @Bean
    public UserDetailsService userDetailsService(){

        return new UserDetailsService() {
            /*
              重写loadUserByUsername()方法从数据库中根据用户名查询用户,参数
              username为用户录入的用户名,返回值UserDetails对象;
             */
            @Override
            public UserDetails loadUserByUsername(String username)
                                  throws UsernameNotFoundException {

                System.out.println(username);

                /*
                  从数据库中根据用户名查询用户......
                 */

                //将查询到的用户信息封装到UserDetails并返回(静态数据模拟)
                UserDetails userDetails = User
                        .withUsername(username)
                        .password(passwordEncoder().encode("123"))
                        .roles("CEO","CTO","CTO")
                        .authorities("sys:add", "sys:delete", "sys:update", "sys:query")
                        .build();
                
                return userDetails;
            }
        };
    }

    //配置用户信息
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
          /*
          //模拟内存用户 -- 在内存配置两个用户
          auth.inMemoryAuthentication()
                .withUser("jake")//用户名
                .password(passwordEncoder().encode("123"))//密码
                .roles("CEO","CTO","CTO")//角色
                .authorities("sys:add", "sys:delete", "sys:update", "sys:query")//权限
                .and()
                .withUser("smith")//用户名
                .password(passwordEncoder().encode("456"))//密码
                .roles("CMO")//角色
                .authorities("sys:query");//权限
          */

//使用我们自定义的UserDetailsService从数据库查询用户进行认证
auth.userDetailsService(userDetailsService());
    }

    //配置加密器,即配置PasswordEncoder的bean对象
    @Bean
    public PasswordEncoder passwordEncoder(){
        //返回PasswordEncoder的实现BCryptPasswordEncoder的实例
        return new BCryptPasswordEncoder();
    }

    /*
      通过对http请求的拦截验证来配置用户、角色、权限,实现url访问授权:
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        /*
          这是前后端不分离的处理方式:
          http.formLogin()//提供一个登录表单
                .successForwardUrl("/welcome")//登录成功转发请求 /项目/welcome
                .failureForwardUrl("/login");//登录失败转发请求 /项目/login,回到登录表单
        */
        http.formLogin()
           .successHandler(authenticationSuccessHandler)//登录成功,使用认证成功处理器处理
           .failureHandler(authenticationFailureHandler);//登录失败,使用认证失败处理器处理

        //应用我们自定义的拒绝访问处理器
        http.exceptionHandling().accessDeniedHandler(accessDeniedHandler);

        /*
          url请求授权:
         */
        /*
        http.authorizeRequests()
                //具有sys:add权限可发出/项目/add的请求
                .antMatchers("/add").hasAnyAuthority("sys:add")
                //具有sys:delete权限可发出/项目/delete的请求
                .antMatchers("/delete").hasAnyAuthority("sys:delete")
                //具有sys:update权限可发出/项目/update的请求
                .antMatchers("/update").hasAnyAuthority("sys:update")
                //具有sys:query权限可发出/项目/query的请求
                .antMatchers("/query").hasAnyAuthority("sys:query");
        */
        http.authorizeRequests()
                //对/项目/free的请求,不受任何限制,直接放行
                .antMatchers("/free").permitAll();

        http.authorizeRequests()
                //其它所有请求,登录后就可以请求
                .anyRequest().authenticated();
    }
}

4、测试结果在这里插入图片描述

4、SpringSecurity图片验证码校验

SpringSecurity是通过过滤器链来完成的,所以它对验证码校验的解决思路是自定义一个校验验证码的过滤器,且放在过滤器链中用户认证过滤器的前面,在发出登录请求时就会先对验证码进行校验。如果验证码校验成功过滤器放行,再通过用户认证过滤器进行认证;如果验证码校验失败,则转发到一个控制器并给其传递一个异常对象,然后在控制器中抛出该异常对象,最后由SpringMVC的异常处理机制来处理。
在这里插入图片描述

4.1 引入hutool的依赖

引入hutool的依赖 — 生成验证码图片的一个工具API:

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.7.11</version>
</dependency>

4.2 前端

前端登录页面login.html

<!DOCTYPE html>
<!--引入thymeleaf-->
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登录页面</title>
</head>
<body>
    <h2>登录页面</h2>
    <form action="doLogin" method="post">
        <table>
            <tr>
                <td>用户名:</td>
                <td><input type="text" name="uname"></td>
            </tr>
            <tr>
                <td>密码:</td>
                <td><input type="password" name="upwd"></td>
            </tr>
            <tr>
                <td>验证码:</td>
                <td>
                    <input type="text" name="code">
                    <!--
                      src="codeImage":img标签发出/项目/codeImage请求接收服务器响应的
验证码图片;
                      οnclick="this.src=this.src":点击图片,让img标签重新发出请求,刷新
验证码图片;
                      th:text="${errMsg}":将model中的errMsg属性(错误信息)作为文本添加
到span中;
                    -->
                    <img src="codeImage" style="height:25px;cursor:pointer;" 
onclick="this.src=this.src"/>
                    <!--
                      th:text="${errMsg}"从model中取出errMsg属性的值(异常信息)作为
span的文本
                    -->
                    <span th:text="${errMsg}" style="color:red;"></span>
                </td>
            </tr>
            <tr>
                <td colspan="2">
                    <button type="submit">登录</button>
                </td>
            </tr>
        </table>
    </form>
</body>
</html>

4.3 验证码校验

自定义运行时异常
com.mmy.exception.CodeException.java:

package com.mmy.exception;

public class CodeException extends RuntimeException {

    public CodeException() {
    }

    public CodeException(String message) {
        super(message);
    }
}

自定义校验验证码的过滤器
com.mmy.filter.CodeFilter.java:

package com.mmy.filter;

/*
  1)class CodeFilter extends OncePerRequestFilter
    继承SpringWeb提供的OncePerRequestFilter类定义过滤器,就无须再注册
    过滤器,而且每次请求都会执行过滤器;
  2)@Component将自定义的过滤器加入容器;
 */
@Component
public class CodeFilter extends OncePerRequestFilter {

    //过滤器拦截到请求执行的方法
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, 
FilterChain chain)
            throws ServletException, IOException {

        //拿到请求的资源路径
        String path = req.getServletPath();
        //判断,如果是/doLogin的请求即登录就校验验证码,不是登录就放行
        if(path.equals("/doLogin")){
            //获取录入的验证码
            String code = req.getParameter("code");
            /*
              判断,如果录入的验证码不为空且和session中保存的验证码相同则放行,否则向
              request中存储自定义的CodeException异常对象,并转发到处理异常的控制器;
             */
            if(code!=null&&!code.isEmpty()){
                HttpSession session = req.getSession();
                LineCaptcha captcha=(LineCaptcha)session.getAttribute("SESSION_CODE");
                if(captcha.verify(code)){
                    //放行
                    chain.doFilter(req, resp);
                    return;
                }
            }
            //向request中存储自定义的CodeException异常对象,并转发请求/项目/codeException
            req.setAttribute("codeException", new CodeException("验证码错误"));
            req.getRequestDispatcher("/codeException").forward(req, resp);
            return;
        }

        //放行
        chain.doFilter(req, resp);
    }
}

4.4 SpringSecurity的配置类

package com.mmy.config;

@Configuration
//开启方法级别的授权
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //注入自定义的UserDetailsService
    @Autowired
    private MyUserDetailsService myUserDetailsService;

    //注入自定义的校验验证码的过滤器
    @Autowired
    private CodeFilter codeFilter;

    //配置认证的
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //使用自定义的UserDetailsService从数据库查询用户进行认证
        auth.userDetailsService(myUserDetailsService);
    }

    //配置静态资源请求的
    @Override
    public void configure(WebSecurity web) throws Exception {
        //resources下的任意层次目录的任意静态资源请求都直接放行
        web.ignoring().mvcMatchers("/resources/**");
    }

    //配置请求的
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        //向过滤器链中用户认证过滤器之前添加自定义的校验验证码的过滤器
        http.addFilterBefore(codeFilter, UsernamePasswordAuthenticationFilter.class);

        //配置登录请求
        http.formLogin()
                //发出/项目/toLogin的请求到达自定义的登录页面
                .loginPage("/toLogin")
                //发出/项目/doLogin的请求进行登录
                .loginProcessingUrl("/doLogin")
                //用户名在登录表单中的name属性值为uname
                .usernameParameter("uname")
                //密码在登录表单中的name属性值为upwd
                .passwordParameter("upwd")
                //登录成功发出/项目/toIndex的请求,进入首页
                .successForwardUrl("/toIndex")
                //登录失败发出/项目/toLogin的请求,回到登录页面
                .failureForwardUrl("/toLogin")
                //以上请求都放行
                .permitAll();

        //配置登出请求
        http.logout()
                //发出/项目/toLogout的请求进行登出
                .logoutUrl("/toLogout")
                //登出成功发出/项目/toLogin的请求,回到登录页面
                .logoutSuccessUrl("/toLogin")
                //以上请求都放行
                .permitAll();

        //对/项目/codeImage和/项目/codeException的请求都直接放行
        http.authorizeRequests()
                .antMatchers("/codeImage","/codeException")
                .permitAll();

        //配置其它请求 --- 登录后才可以访问
        http.authorizeRequests().anyRequest().authenticated();

        //关闭跨站请求检查
        http.csrf().disable();
        //关闭跨域请求检查
        http.cors().disable();
    }

    //配置加密器
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}


4.5 验证码controller类

package com.mmy.controller;

@Controller
public class CodeController {

    //处理/项目/codeImage的请求,向客户端响应验证码图片
    @RequestMapping("/codeImage")
    public void codeImage(HttpSession session, HttpServletResponse response)
            throws IOException {

        //生成验证码图片 --- 宽 高 字符个数 干扰线个数
        LineCaptcha captcha = CaptchaUtil.createLineCaptcha(250, 100, 4, 20);

        //LineCaptcha对象存储到session中
        session.setAttribute("SESSION_CODE_IMAGE", captcha);

        //将验证码图片响应给img标签
        captcha.write(response.getOutputStream());
    }

    //处理/项目/codeException的请求,抛出request中存储的自定义的CodeException异常
    @RequestMapping("/codeException")
    public void codeException(HttpServletRequest request){
        throw (CodeException)request.getAttribute("codeException");
    }

    //当控制器抛出CodeException异常,向model中存储异常信息,并回到模板页面login.html
    @ExceptionHandler(CodeException.class)
    public String codeExceptionHandler(Exception e, Model model){
        model.addAttribute("errMsg", e.getMessage());
        return "login";
    }
}

4.6 测试

在这里插入图片描述

5、SpringSecurity记住我

用户在登录时选择了记住我,当用户登录成功后,在一定时间内(默认是15天)用户无需再登录可直接进入系统访问站内资源。

实现原理:
当用户发起登录请求时,会通过过滤器UsernamePasswordAuthenticationFilter进行用户认证;认证成功之后,SpringSecurity会使用RememberMeService为用户生成Token(令牌,一个随机字符串)并将它写入浏览器 的Cookie中,同时,RememberMeService内部的TokenRepository还会将Token持久化(存入数据库一份);当用户再次访问服务器资源的时候,首先会经过过滤器RememberMeAuthenticationFiler,该过滤器会读取当前请求携带的 Cookie中的Token,然后再去数据库中查找是否有相应的Token,如果有,则再通过UserDetailsService获取用户信息。
在这里插入图片描述

5.1 前端

<!DOCTYPE html>
<!--引入thymeleaf-->
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登录页面</title>
</head>
<body>
    <h2>登录页面</h2>
    <form action="doLogin" method="post">
        <table>
            <tr>
                <td>用户名:</td>
                <td><input type="text" name="uname"></td>
            </tr>
            <tr>
                <td>密码:</td>
                <td><input type="password" name="upwd"></td>
            </tr>
            <tr>
                <td>验证码:</td>
                <td>
                    <input type="text" name="code">
                    <!--
                      src="codeImage":img标签发出/项目/codeImage请求接收服务器响应的
验证码图片;
                      οnclick="this.src=this.src":点击图片,让img标签重新发出请求,刷新
验证码图片;
                      th:text="${errMsg}":将model中的errMsg属性(错误信息)作为文本添加
到span中;
                    -->
                    <img src="codeImage" style="height:25px;cursor:pointer;" 
onclick="this.src=this.src"/>
                    <!--
                      th:text="${errMsg}"从model中取出errMsg属性的值(异常信息)作为
span的文本
                    -->
                    <span th:text="${errMsg}" style="color:red;"></span>
                </td>
            </tr>
            <tr>
                <td></td>
                <td>
                    <input type="checkbox" name="remember-me">记住我
                </td>
            </tr>
            <tr>
                <td colspan="2">
                    <button type="submit">登录</button>
                </td>
            </tr>
        </table>
    </form>
</body>
</html>

5.2 SpringSecurity的配置类

1、注入,并配置TokenRepository的bean对象

    //注入数据源
    @Autowired
    private DataSource dataSource;

    //配置TokenRepository的bean对象
    @Bean
    public PersistentTokenRepository tokenRepository(){

        //创建的是PersistentTokenRepository的实现类JdbcTokenRepositoryImpl的对象
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        //给TokenRepository注入数据源(因为其要操作数据库存储Token)
        tokenRepository.setDataSource(dataSource);
        //第一次实现记住我功能时会自动创建存储Token的表,二次之后要改为false不再建表
        tokenRepository.setCreateTableOnStartup(true);

        return tokenRepository;
    }

2、在配置请求中 配置记住我功能

        //配置记住我功能
        http.rememberMe()
                //记住我checkbox的name属性值
                .rememberMeParameter("remember-me")
                //指定用于持久化Token的TokenRepository
                .tokenRepository(tokenRepository())
                //指定获取用户信息的UserDetailsService
                .userDetailsService(myUserDetailsService);

package com.mmy.config;

@Configuration
//开启方法级别的授权
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //注入自定义的UserDetailsService
    @Autowired
    private MyUserDetailsService myUserDetailsService;

    //注入自定义的校验验证码的过滤器
    @Autowired
    private CodeFilter codeFilter;

    //配置认证的
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        //使用自定义的UserDetailsService从数据库查询用户进行认证
        auth.userDetailsService(myUserDetailsService);
    }

    //配置静态资源请求的
    @Override
    public void configure(WebSecurity web) throws Exception {
        //resources下的任意层次目录的任意静态资源请求都直接放行
        web.ignoring().mvcMatchers("/resources/**");
    }

    //注入数据源
    @Autowired
    private DataSource dataSource;

    //配置TokenRepository的bean对象
    @Bean
    public PersistentTokenRepository tokenRepository(){

        //创建的是PersistentTokenRepository的实现类JdbcTokenRepositoryImpl的对象
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        //给TokenRepository注入数据源(因为其要操作数据库存储Token)
        tokenRepository.setDataSource(dataSource);
        //第一次实现记住我功能时会自动创建存储Token的表,二次之后要改为false不再建表
        tokenRepository.setCreateTableOnStartup(true);

        return tokenRepository;
    }

    //配置请求的
    @Override
    protected void configure(HttpSecurity http) throws Exception {

        //向过滤器链中用户认证过滤器之前添加自定义的校验验证码的过滤器
        http.addFilterBefore(codeFilter, UsernamePasswordAuthenticationFilter.class);

        //配置登录请求
        http.formLogin()
                //发出/项目/toLogin的请求到达自定义的登录页面
                .loginPage("/toLogin")
                //发出/项目/doLogin的请求进行登录
                .loginProcessingUrl("/doLogin")
                //用户名在登录表单中的name属性值为uname
                .usernameParameter("uname")
                //密码在登录表单中的name属性值为upwd
                .passwordParameter("upwd")
                //登录成功发出/项目/toIndex的请求,进入首页
                .successForwardUrl("/toIndex")
                //登录失败发出/项目/toLogin的请求,回到登录页面
                .failureForwardUrl("/toLogin")
                //以上请求都放行
                .permitAll();

        //配置记住我功能
        http.rememberMe()
                //记住我checkbox的name属性值
                .rememberMeParameter("remember-me")
                //指定用于持久化Token的TokenRepository
                .tokenRepository(tokenRepository())
                //指定获取用户信息的UserDetailsService
                .userDetailsService(myUserDetailsService);

        //配置登出请求
        http.logout()
                //发出/项目/toLogout的请求进行登出
                .logoutUrl("/toLogout")
                //登出成功发出/项目/toLogin的请求,回到登录页面
                .logoutSuccessUrl("/toLogin")
                //以上请求都放行
                .permitAll();

        //对/项目/codeImage和/项目/codeException的请求都直接放行
        http.authorizeRequests()
                .antMatchers("/codeImage","/codeException")
                .permitAll();

        //配置其它请求 --- 登录后才可以访问
        http.authorizeRequests().anyRequest().authenticated();

        //关闭跨站请求检查
        http.csrf().disable();
        //关闭跨域请求检查
        http.cors().disable();
    }

    //配置加密器
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

5.3 测试

在这里插入图片描述
在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值