【spring security】【尚硅谷】

前置知识

  • spring框架
  • springboot使用
  • javaWeb技术

Spring Security 框架简介

概述

Spring Security是基于Spring框架,是一套web安全性的完整的解决方案

关于安全的主要的两个区域“认证”和“授权”

web安全性主要包括用户认证(Authentication)用户授权(Authorization) 两个部分,同时也是Spring Security重要的核心功能

用户认证(Authentication):系统认为用户是否可以登录

用户授权(Authorization):判断用户是否有权限执行某操作

Spring Security特点

  • 和Spring无缝整合
  • 全面的权限控制
  • 专门为web开发设计
  • 旧版本不能脱离web环境使用
  • 新版本对整个框架进行了分层抽取,分成了核心模块和web模块。单独引入核心模块就可以脱离web环境
  • 重量级(依赖很多组件)

shiro特点

  • 轻量级,shiro主张的理念是把复杂的事情变得简单,针对对性能有更高要求的互联网应用有更好表现
  • 好处:不局限于web环境,可以脱离web环境使用
  • 缺陷:在web环境下一些特定的需求需要手动编写代码定制

推荐搭配:
ssm+shiro
springboot+springsecurity

Spring Security 入门案例

Spring Security 依赖


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

Spring Security 基本原理

Spring Security 本质是一个过滤器链

底层有很多过滤器

FilterSecurityIntercepter:是一个方法级的权限过滤器,基本位于过滤链的最底部

ExceptionTranslationFilter:是一个异常过滤器,用来处理在认证授权过程中抛出的异常

UsernamePasswordAuthenticationFilter:对/login的POST请求做拦截,校验表单中用户名和密码

过滤器是如何加载的

1.使用 spring security 配置过滤器
DelegatingFilterProxy过滤器中调用initDelegate(webapplicationcontext)
initDelegate方法中调用FilterChinaProxy方法
FilterChinaProxy方法收集所有的过滤器,依次执行

UserDetailsService 接口(重要)

查询数据库中的用户名和密码的过程,需要实现UserDetailsService接口,返回一个User对象,这个User对象是安全框架提供的对象

创建类继承UsernamePasswordAuthenticationFilter,重写三个方法
attemptAuthentication 认证
successfulAuthentication 认证成功
unsuccessfulAuthentication 认证失败

PasswordEncoder 接口(重要)

密码加密接口,用于返回User对象里面的密码加密

Spring Security web权限方案

用户认证

第一种方式:通过配置文件


spring.security.user.name=atguigu
spring.security.user.password=atguigu

第二种方式:通过配置类,继承WebSecurityConfigurerAdapter类,重写三个configure方法,我们一般会通过自定义配置这三个方法来自定义我们的安全访问策略。


@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //AuthenticationManagerBuilder(身份验证管理生成器)
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 创建密码解析器
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        // 对密码加密
        String password = passwordEncoder.encode("atguigu");
        // 设置用户
        auth.inMemoryAuthentication()
                .withUser("atguigu").password(password).roles("admin");
    }

    //强散列哈希加密实现
    @Bean
    PasswordEncoder password(){
        return new BCryptPasswordEncoder();
    }
}

在项目实际开发中,实现的方法


/**
 * spring security配置
 *
 */
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    /**
     * 自定义用户认证逻辑
     */
    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 认证失败处理类
     */
    @Autowired
    private AuthenticationEntryPointImpl unauthorizedHandler;

    /**
     * 退出处理类
     */
    @Autowired
    private LogoutSuccessHandlerImpl logoutSuccessHandler;

    /**
     * token认证过滤器
     */
    @Autowired
    private JwtAuthenticationTokenFilter authenticationTokenFilter;

    /**
     * 跨域过滤器
     */
    @Autowired
    private CorsFilter corsFilter;

    /**
     * 解决 无法直接注入 AuthenticationManager
     *
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 强散列哈希加密实现
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder() {

        return new BCryptPasswordEncoder();
    }

    /**
     * 身份认证接口
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {

        //在这里关联数据库和security
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }

    /**
     * anyRequest          |   匹配所有请求路径
     * access              |   SpringEl表达式结果为true时可以访问
     * anonymous           |   匿名可以访问
     * denyAll             |   用户不能访问
     * fullyAuthenticated  |   用户完全认证可以访问(非remember-me下自动登录)
     * hasAnyAuthority     |   如果有参数,参数表示权限,则其中任何一个权限可以访问
     * hasAnyRole          |   如果有参数,参数表示角色,则其中任何一个角色可以访问
     * hasAuthority        |   如果有参数,参数表示权限,则其权限可以访问
     * hasIpAddress        |   如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
     * hasRole             |   如果有参数,参数表示角色,则其角色可以访问
     * permitAll           |   用户可以任意访问
     * rememberMe          |   允许通过remember-me登录的用户访问
     * authenticated       |   用户登录后可访问
     */
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
    
        httpSecurity
                // CSRF禁用,因为不使用session
                .csrf().disable()
                // 认证失败处理类
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                // 基于token,所以不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                // 过滤请求
                .authorizeRequests()
                // 对于登录login 验证码captchaImage 允许匿名访问
                .antMatchers("/login", "/captchaImage").anonymous()
                .antMatchers(
                        HttpMethod.GET,
                        "/*.html",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js"
                ).permitAll()
                .antMatchers("/profile/**").permitAll()
                .antMatchers("/common/download**").permitAll()
                .antMatchers("/common/download/resource**").permitAll()
                .antMatchers("/swagger-ui.html").permitAll()
                .antMatchers("/swagger-resources/**").permitAll()
                .antMatchers("/webjars/**").permitAll()
                .antMatchers("/*/api-docs").permitAll()
                .antMatchers("/druid/**").permitAll()
                .antMatchers("/flowable/**").permitAll()
                .antMatchers("/socket/**").permitAll()
                .antMatchers("/api/common/**").permitAll()
                .antMatchers("/api/contract/**").permitAll()
                .antMatchers("/api/project/**").permitAll()
                .antMatchers("/api/document/**").permitAll()
                .antMatchers("/api/purchase/**").permitAll()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated()
                .and()
                .headers().frameOptions().disable();
        httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
        // 添加JWT filter
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 添加CORS filter
        httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
        httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
    }

    /***
     * 核心过滤器配置方法
     * @param web
     * @throws Exception
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        super.configure(web);
    }
}

认证管理器配置方法
AuthenticationManagerBuilder(身份验证管理生成器)
void configure(AuthenticationManagerBuilder auth) 用来配置认证管理器AuthenticationManager。说白了就是所有 UserDetails 相关的它都管,包含 PasswordEncoder 密码等

核心过滤器配置方法
WebSecurity(WEB安全)
void configure(WebSecurity web) 用来配置 WebSecurity 。而 WebSecurity 是基于 Servlet Filter 用来配置 springSecurityFilterChain 。而 springSecurityFilterChain 又被委托给了 Spring Security 核心过滤器 Bean DelegatingFilterProxy 。 相关逻辑你可以在WebSecurityConfiguration 中找到。我们一般不会过多来自定义 WebSecurity , 使用较多的使其ignoring() 方法用来忽略 Spring Security 对静态资源的控制

安全过滤器链配置方法
HttpSecurity(HTTP请求安全处理)
void configure(HttpSecurity http) 这个是我们使用最多的,用来配置 HttpSecurity 。 HttpSecurity 用于构建一个安全过滤器链 SecurityFilterChain 。SecurityFilterChain 最终被注入核心过滤器 。 HttpSecurity 有许多我们需要的配置。我们可以通过它来进行自定义安全访问策略。所以我们单独开一章来讲解这个东西

上述两种方法在实际的开发中并不适用,我们需要从数据库查询用户的信息,重点关注第三种方式

第三种方式:自定义编写实现类


// 第一步:创建配置类,设置使用哪个userDetailsService实现类
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(password());
    }

    @Bean
    PasswordEncoder password(){
        return new BCryptPasswordEncoder();
    }
}


// 第二步:编写实现类,返回User对象,User对象有用户名密码和操作权限
@Service("userDetailsService")
public class MyUserDetailsService implements UserDetailsService {


    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        // 查询数据库

        // 创建权限集合
        List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("admin");
        // 封装User对象并返回
        return new User("atguigu",new BCryptPasswordEncoder().encode("atguigu"),auths);
    }
}

用户认证–查询数据库

整合MyBatisPlus完成数据库操作
第一步 引入依赖


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

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

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

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.0.5</version>
</dependency>

第二步 创建数据库和数据表,配置数据库
user表,三个字段(id,username,password)


# 配置数据库连接
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.url=jdbc://localhost:3306/demo?serverTimezone=GMT%2B8

第三步 创建users表对应的实体类


@Data
public class Users {
    private Integer id;
    private String username;
    private String password;
}

第四步 整合MyBatisPlus,创建mapper接口,继承MyBatisPlus中的baseMapper接口


@Repository
@Mapper
// 在对应的mapper上面继承基本的类BaseMapper
public interface UserMapper extends BaseMapper<User> {
    // 所有的CRUD已经编写完成
    // 不需要像以前一样配置一大堆文件
}

第五步 在MyUserDetailsService中调用mapper里面的方法查询数据库,进行用户认证


@Service("userDetailsService")
public class MyUserDetailsService implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;


    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        // 调用userMapper方法,根据用户名查询数据库
        QueryWrapper<Users> wrapper = new QueryWrapper();
        // where username = s
        wrapper.eq("username",s);
        Users user = userMapper.selectOne(wrapper);
        // 判断
        if (user == null) {// 数据库中没有用户名,认证失败
            throw new UsernameNotFoundException("用户名不存在");
        }

        // 从查询数据库返回的user对象中,获取信息
        List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("admin");
        return new User(user.getUsername(),new BCryptPasswordEncoder().encode(user.getPassword()),auths);
    }
}

第六步 添加mapper扫描


// 扫描mapper所在的文件夹
@MapperScan("com.hupf.mptest.mapper")
@SpringBootApplication
public class MptestApplication {
    // 主程序
    public static void main(String[] args) {
        SpringApplication.run(MptestApplication.class, args);
    }
}

用户认证–自定义用户登陆页面

在配置类中实现相关配置


@Override
protected void configure(HttpSecurity http) throws Exception {

    http.formLogin()    // 自定义自己编写的登陆页面
            .loginPage("/index.html")   // 登陆页面设置
            .loginProcessingUrl("/user/login")     // 登录访问路径
            .defaultSuccessUrl("/test/index").permitAll()      // 登录成功之后,跳转路径
            .and()
            .authorizeRequests().antMatchers("/","/test/hello","/user/login").permitAll()   // 设置哪些路径不需要认证可以直接访问
            .anyRequest().authenticated()   // 所有用户都可以访问
            .and().csrf().disable();    // 关闭csrf防护

}

创建页面
resources/static/login.xml
spring security底层要求,页面中默认账号密码的名字必须是username和password

用户授权–基于权限访问控制

第一个方法:hasAuthority方法

判断当前的主体具有指定的权限,有权限返回true,没有返回false


@Override
protected void configure(HttpSecurity http) throws Exception {

    http.formLogin()    // 自定义自己编写的登陆页面
            .loginPage("/index.html")   // 登陆页面设置
            .loginProcessingUrl("/user/login")     // 登录访问路径
            .defaultSuccessUrl("/test/index").permitAll()      // 登录成功之后,跳转路径
            .and().authorizeRequests()
            //.antMatchers("/","/test/hello","/user/login").permitAll()   // 设置哪些路径不需要认证可以直接访问
            // 当前登录用户,只有具有admins权限才可以访问这个路径(/test/index)
            .antMatchers("/test/index").hasAuthority("admins")
            .anyRequest().authenticated()   // 所有用户都可以访问
            .and().csrf().disable();    // 关闭csrf防护

}

1、在配置类设置当前访问地址需要的权限
// 当前登录用户,只有具有admins权限才可以访问这个路径(/test/index)
.antMatchers("/test/index").hasAuthority("admins")

2、在UserDetailsService,给User对象设置权限
List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("admins");

如果有访问权限就可以访问页面,没有访问权限的用户,页面会显示
(type=Forbidden,status=403)

第二个方法:hasAnyAuthority方法

如果当前的主体有任何提供的角色(给定的作为一个都好分隔的字符串列表)的话,返回true


1、在配置类设置当前访问地址需要的权限
// 当前登录用户,只有具有admins权限才可以访问这个路径(/test/index)
.antMatchers("/test/index").hasAnyAuthority("admins","manager")

2、在UserDetailsService,给User对象设置权限
List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("admins");

第三个方法:hasRole方法

如果当前主体具有指定的角色,则返回true


1、在配置类设置当前访问地址需要的权限
// 源码中拼接成了 ROLE_role
.antMatchers("/test/index").hasRole("role")

2、在UserDetailsService,给User对象设置权限
List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("admins,ROLE_role");

第四个方法:hasAnyRole方法

如果当前主体具有任意一个指定的角色,则返回true


1、在配置类设置当前访问地址需要的权限
// 源码中拼接成了 ROLE_role
.antMatchers("/test/index").hasRole("role,a,b")

2、在UserDetailsService,给User对象设置权限
List<GrantedAuthority> auths = AuthorityUtils.commaSeparatedStringToAuthorityList("admins,ROLE_role");

用户授权–自定义403页面

在配置类配置


@Override
protected void configure(HttpSecurity http) throws Exception {

    // 配置没有权限访问跳转自定义的页面
    http.exceptionHandling().accessDeniedPage("/403.html");

    http.formLogin()    // 自定义自己编写的登陆页面
            .loginPage("/index.html")   // 登陆页面设置
            .loginProcessingUrl("/user/login")     // 登录访问路径
            .defaultSuccessUrl("/test/index").permitAll()      // 登录成功之后,跳转路径
            .and().authorizeRequests()
            //.antMatchers("/","/test/hello","/user/login").permitAll()   // 设置哪些路径不需要认证可以直接访问
            // 当前登录用户,只有具有admin权限才可以访问这个路径(/test/index)
            .antMatchers("/test/index").hasAuthority()
            .anyRequest().authenticated()   // 所有用户都可以访问
            .and().csrf().disable();    // 关闭csrf防护

}

用户授权–注解使用

认证授权注解使用,需要先开启注解支持

@EnableGlobalMethodSecurity(securedEnabled=true):开启注解功能,写在启动类上

@Secured

设置哪些角色可以访问方法


@RequestMapping("testSecured")
@ResponseBody
@Secured({"ROLE_admin","ROLE_user"})
public String hello(){
	return "hello";
}

@PreAuthorize

进入方法前的权限验证


// 启动类上开启注解
@EnableGlobalMethodSecurity(securedEnabled=true,prePostEnabled=true)

@RequestMapping("testSecured")
@ResponseBody
// @PreAuthorize("hasRole('admin')")
@PreAuthorize("hasAnyAuthority('admin')")
public String hello(){
	return "hello";
}

@PostAuthorize

使用不多,在方法执行后再进行权限验证,适合验证带有返回值的权限


@RequestMapping("testSecured")
@ResponseBody
@PostAuthorize("hasAnyAuthority('admin')")
public String hello(){
	return "hello";
}

没有访问权限的话,页面会返回没有权限,但是会访问方法

@PostFilter

权限验证之后对数据进行过滤,留下符合条件的数据


@RequestMapping("testSecured")
@ResponseBody
@PostFilter("filterObject.username == 'admin'")
public List<UserInfo> hello(){
	List<UseerInfo> list = new ArrayList<>();
	list.add(new UserInfo(1,"admin","1234"));
	list.add(new UserInfo(1,"user","1234"));
	return list;
}

@PreFilter

进入控制器之前对数据进行过滤


@RequestMapping("testSecured")
@ResponseBody
@PreFilter(value = "filterObject.id%2 == 0")
public List<UserInfo> hello(@RequestBody List<UserInfo> list){
	list.forEach(t->{
		System.out.println(t);
	});
	return list;
}

用户授权–用户注销

在配置类中添加退出映射地址

http.logout().logoutUrl("/logout").logoutSuccessUrl("/index").permitAll();

用户授权–自动登录(原理)

在这里插入图片描述

原理:
第一次发请求

  • 浏览器发送请求,到UsernamePasswordAuthenticationFilter,获取用户信息,判断是否认证成功
  • 认证成功后,RememberMeServices类中onLoginSuccess方法会调用tokenReponsitory会创建token
  • 将token加入到浏览器cookie中
  • 调用JdbcTokenRepositoryImpl将token写入数据库

第二次发请求

  • 浏览器再次发出请求,到RememberMeAuthenticationFilter
  • RememberMeAuthenticationFilter通过调用TokenRepository读取cokkie中的token
  • 与数据库中的token做比较判断是否可以自动登录

用户授权–自动登录(实现)

一、从JdbcTokenRepositoryImpl类中获取建表语句,创建数据表

二、配置类,注入数据源,配置操作数据库对象


@Autowired
private DataSource dataSource;

 @Bean
 public PersistentTokenRepository persistentTokenRepository(){
     JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
     jdbcTokenRepository.setDataSource(dataSource);
     // 创建表
     //jdbcTokenRepository.setCreateTableOnStartup(true);
     return jdbcTokenRepository;
 }

三、配置类配置自动登录


.and().rememberMe().tokenRepository(persistentTokenRepository())
.tokenValiditySeconds(60)// 设置有效时长,单位是秒
.userDetailsService(userDetailsService)

四、在登录页面添加复选框remember-me

用户授权–CSRF功能

CSRF是跨站请求伪造

跨站请求伪造:同一个浏览器中,一个已经认证的网站,其他网站可以获取认证后的所有信息

Spring Security 微服务权限方案

微服务权限方案(认证授权过程分析)

在这里插入图片描述

微服务权限方案(需求说明)

微服务权限管理案例主要功能:

  1. 登录认证
  2. 添加角色
  3. 为角色分配菜单
  4. 添加用户
  5. 为用户分配角色

微服务权限方案(数据模型介绍)

  1. 权限管理数据模型
    菜单表 acl_permission
    角色表 acl_role
    用户表 acl_user
    角色和菜单关系表 acl_role_permission
    用户和角色关系表 acl_user_role

  2. 案例涉及技术说明
    maven:管理依赖版本
    spring boot:本质就是spring
    mybatisplus:操作数据库
    spring cloud:gateway网关、Nacos注册中心
    redis:缓存数据库
    jwt:生成token字符串
    swagger:接口测试
    前端技术

微服务权限方案(搭建项目)

  1. 创建一个父工程 acl_parent:管理版本依赖
  2. 在父工程创建子模块
    common子模块
    service_base:工具类
    spring_security:权限配置,spring security相关
    infrastructure子模块
    api_gateway:网关,配置gateway
    service子模块
    service_acl:权限管理微服务模块

微服务权限方案(引入项目依赖)

acl_parent/pom.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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <packaging>pom</packaging>
    <modules>
        <module>common</module>
        <module>infrastructure</module>
        <module>service</module>
    </modules>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.8</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.atguigu</groupId>
    <artifactId>acl_parent</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>acl_parent</name>
    <description>Demo project for Spring Boot</description>

    <!--依赖版本管理-->
    <properties>
        <java.version>1.8</java.version>
        <mybatis-plus.version>3.0.5</mybatis-plus.version>
        <velocity.version>2.0</velocity.version>
        <swagger.version>2.7.0</swagger.version>
        <jwt.version>0.7.0</jwt.version>
        <fastjson.version>1.2.28</fastjson.version>
        <gson.version>2.8.2</gson.version>
        <json.version>20170516</json.version>
        <cloud-alibaba.version>0.2.2.RELEASE</cloud-alibaba.version>
    </properties>


    <dependencyManagement>
        <dependencies>
            <!-- spring cloud -->
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Finchley.SR2</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>

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

            <!-- mybatisplus 持久层 -->
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-boot-starter</artifactId>
                <version>${mybatis-plus.version}</version>
            </dependency>

            <!-- velocity 模板引擎 mybatis plus 代码生成器需要 -->
            <dependency>
                <groupId>org.apache.velocity</groupId>
                <artifactId>velocity-engine-core</artifactId>
                <version>${velocity.version}</version>
            </dependency>

            <dependency>
                <groupId>com.google.code.gson</groupId>
                <artifactId>gson</artifactId>
                <version>${gson.version}</version>
            </dependency>

            <!-- swagger -->
            <dependency>
                <groupId>io.springfox</groupId>
                <artifactId>springfox-swagger2</artifactId>
                <version>${swagger.version}</version>
            </dependency>
            <!-- swagger ui -->
            <dependency>
                <groupId>io.springfox</groupId>
                <artifactId>springfox-swagger-ui</artifactId>
                <version>${swagger.version}</version>
            </dependency>

            <!-- jwt -->
            <dependency>
                <groupId>io.jsonwebtoken</groupId>
                <artifactId>jjwt</artifactId>
                <version>${jwt.version}</version>
            </dependency>

            <dependency>
                <groupId>com.alibaba</groupId>
                <artifactId>fastjson</artifactId>
                <version>${fastjson.version}</version>
            </dependency>
            <dependency>
                <groupId>org.json</groupId>
                <artifactId>json</artifactId>
                <version>${json.version}</version>
            </dependency>

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

            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-bootstarter-data-redis</artifactId>
            </dependency>

            <dependency>
                <groupId>org.apache.commons</groupId>
                <artifactId>common-pool2</artifactId>
                <version>2.6.0</version>
            </dependency>

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

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

        </dependencies>
    </dependencyManagement>



    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

common/pom.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>acl_parent</artifactId>
        <groupId>com.atguigu</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>common</artifactId>
    <packaging>pom</packaging>
    <modules>
        <module>service_base</module>
        <module>spring_security</module>
    </modules>


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

        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>

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

        <!-- swagger -->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>${swagger.version}</version>
        </dependency>
        <!-- swagger ui -->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>${swagger.version}</version>
        </dependency>

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

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>common-pool2</artifactId>
            <version>2.6.0</version>
        </dependency>

    </dependencies>


</project>
service_base/pom.xml

spring_security/pom.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>common</artifactId>
        <groupId>com.atguigu</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>spring_security</artifactId>

    <dependencies>
        <dependency>
            <groupId>com.atguigu</groupId>
            <artifactId>service_base</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
            <version>2.0.6.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
        </dependency>
    </dependencies>


</project>
api_gateway/pom.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>infrastructure</artifactId>
        <groupId>com.atguigu</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>api_gateway</artifactId>


    <dependencies>
        <dependency>
            <groupId>com.atguigu</groupId>
            <artifactId>service_base</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

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

        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>


    </dependencies>

</project>
service/pom.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>acl_parent</artifactId>
        <groupId>com.atguigu</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>service</artifactId>
    <packaging>pom</packaging>
    <modules>
        <module>service_acl</module>
    </modules>


    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>

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


        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
        </dependency>

        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

        <dependency>
            <groupId>org.apache.velocity</groupId>
            <artifactId>velocity-engine-core</artifactId>
        </dependency>

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

        <dependency>
            <groupId>com.apache.code.gson</groupId>
            <artifactId>gson</artifactId>
        </dependency>

    </dependencies>

    <build>
        <resources>
            <resource>
                <directory>src/main/java</directory>
                <includes>
                    <include>**/*.xml</include>
                </includes>
                <filtering>false</filtering>
            </resource>
        </resources>
    </build>

</project>
service_acl/pom.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>service</artifactId>
        <groupId>com.atguigu</groupId>
        <version>0.0.1-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>service_acl</artifactId>


    <dependencies>
        <dependency>
            <groupId>com.atguigu</groupId>
            <artifactId>spring_security</artifactId>
            <version>0.0.1-SNAPSHOT</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
        </dependency>
    </dependencies>
</project>

微服务权限方案(启动redis和nacos)

  1. 启动redis
  2. 启动nacos(注册中心)
    访问地址 http://localhost:8848/nacos/
    用户名 nacos
    密码 nacos

微服务权限方案(编写common工具类)

微服务权限方案(编写security工具类)

TokenWebSecurityConfig 核心配置类
SecurityUser、User 相关实体类
TokenAuthenticationFilter 授权过滤
TokenLoginFilter 认证过滤器
DefaultPasswordEncoder 密码处理
TokenLogoutHandler 退出处理器
TokenManager token操作工具类
UnauthorizedEntryPoint 未授权统一处理类

DefaultPasswordEncoder 密码处理


/**
 * PasswordEncoder 实现密码加密接口
 */
@Component
public class DefaultPasswordEncoder implements PasswordEncoder {


    public DefaultPasswordEncoder(){
        this(-1);
    }

    public DefaultPasswordEncoder(int strength){

    }

    // 进行MD5加密
    @Override
    public String encode(CharSequence charSequence) {
        return MD5.encode(charSequence.toString());
    }

    // 进行密码对比
    @Override
    public boolean matches(CharSequence charSequence, String encodedPassword) {
        return encodedPassword.equals( MD5.encode(charSequence.toString()) );
    }
}

TokenManager token操作工具类


@Component
public class TokenManager {

    // token有效时长
    private long tokenEcpiration = 24*60*60*1000;
    // 编码密钥
    private String tokenSingnKey = "123456";

    // 1.使用jwt根据用户名生成token
    public String createToken(String username){
        String token = Jwts.builder()
                .setSubject(username)//设置主体内容
                .setExpiration(new Date(System.currentTimeMillis()+tokenEcpiration))//设置有效时间
                .signWith(SignatureAlgorithm.HS512,tokenSingnKey).compressWith(CompressionCodes.GZIP).compact();//设置加密方式
        return token;
    }


    // 2.根据token字符串得到用户信息
    public String getUserInfoFromToken(String token){
        String userinfo = Jwts.parser()
                .setSignineKey(tokenSingnKey)
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
        return userinfo;
    }

    // 3.删除token
    public void removeToken(String token){
    }

}

TokenLogoutHandler 退出处理器


public class TokenLogoutHandler implements LogoutHandler{

    private TokenManager tokenManager;
    private RedisTemplate redisTemplate;

    public TokenLogoutHandler(TokenManager tokenManager,RedisTemplate redisTemplate){
        this.redisTemplate = redisTemplate;
        this.tokenManager = tokenManager;
    }

    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication){
        // 1.从header里面获取token
        // 2.token不为空,移除token,从redis删除token
        String token = request.getHeader("token");
        if (token != null) {
            // 移除
            tokenManager.removeToken(token);

            // 从token获取用户名
            String username = tokenManager.getUserInfoFromToken(token);
            redisTemplate.delete(username);
        }
        ResponseUtil.out(response, R.ok);
    }
}

微服务权限方案(编写security认证处理过滤器)

UnauthorizedEntryPoint 未授权统一处理类


/**
 * 未授权的统一处理类
 */
public class UnauthorizedEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws Exception{
        ResponseUtil.out(response,OK.error());
    }


}

TokenAuthenticationFilter 授权过滤


/**
 * 授权过滤
 */
public class TokenAuthenticationFilter extends BasicAuthenticationFilter {

    private TokenManager tokenManager;
    private RedisTemplate redisTemplate;

    public TokenAuthenticationFilter(
            AuthenticationManager authenticationManager,
            TokenManager tokenManager,
            RedisTemplate redisTemplate){
        super(authenticationManager);
        this.redisTemplate=redisTemplate;
        this.tokenManager=tokenManager;
    }


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        //获取当前认证成功用户权限信息
        UsernamePasswordAuthenticationToken authRequest = getAuthentication(request);
        //判断如果有权限信息,放到权限上下文中
        if(authRequest != null){
            SecurityContextHolder.getContext().setAuthentication(authRequest);
        }
        chain,doFilter(request,response);
    }


    public UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request){
        // 从header获取token
        String token = request.getHeader("token");
        if(token!=null){
            //从token获取用户名
            String username = tokenManager.getUserInfoFromToken(token);
            //从redis获取对应权限列表
            List<String> permissionValueList = (List<String>)redisTemplate.opsForValue().get(username);
            Collection<GrantedAuthority> authority = new ArrayList<>();
            for(String permissionValue:permissionValueList){
                SimpleGrantedAuthority auth = new SimpleGrantedAuthority();
                authority.add(auth);
            }
            return new UsernamePasswordAuthenticationToken(username,token,authority);
        }
        return null;
    }

}

TokenLoginFilter 认证过滤器


/**
 * 重写认证方法
 */
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {

    private TokenManager tokenManager;
    private RedisTemplate redisTemplate;
    private AuthenticationManager authenticationManager;

    public TokenLoginFilter(TokenManager tokenManager, RedisTemplate redisTemplate,
                            AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        this.redisTemplate = redisTemplate;
        this.tokenManager = tokenManager;
        this.setPostOnly(false);
        this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/acl/login", "POST"));
    }

    //1.获取表单提供的用户名、密码、权限信息
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        //获取表单提交数据
        User user = new ObjectMapper().readValue(request.getInputStream(), User.class);
        return authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), new ArrayList<>()));

    }

    //2.认证成功调用方法
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        // 认证成功后,获取到用户信息
        SecurityUser principal = (SecurityUser)authResultc.getPrincipal();
        // 根据用户名生成token
        String token = tokenManager.createToken(user.getCurrentUserInfo().getUsername());
        // 将用户名密码方法redis
        redisTemplate.opsForValue().set(user.getCurrentUserInfo().getUsername(),user.getPermissionValueList());
        // 返回token
        ResponseUtil.out(response, R.OK().data("token",token));
    }

    //3.认证失败调用方法
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        ResponseUtil.out(response, R.error());
    }
}

TokenWebSecurityConfig 核心配置类


@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnable=true)
public class TokenWebSecurity extends WebSecurityConfigurerAdapter{
    private TokenManager tokenManager;
    private RedisTemplate redisTemplate;
    private DefaultPasswordEncoder defaultPasswordEncoder;
    private UserDetailsService userDetailsService;

    @Autowired
    public TokenWebSecurity(
            TokenManager tokenManager,
            RedisTemplate redisTemplate,
            DefaultPasswordEncoder defaultPasswordEncoder,
            UserDetailsService userDetailsService){
        this.defaultPasswordEncoder=defaultPasswordEncoder;
        this.redisTemplate=redisTemplate;
        this.tokenManager=tokenManager;
        this.userDetailsService=userDetailsService;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.exceptionHandling()
                .authenticationEntryPoint(new UnauthorizedEntryPoint())//没有访问权限
                .and().csrf().disable()
                .authorizeRequests()
                .anyRequest().authenticated()
                .and().logout().logoutUrl("/admin/acl/index/logout")//退出路径
                .addLogoutHandler(new TokenLogoutHandler(tokenManager,redisTemplate)).and()
                .addFilter(new TokenLoginFilter(authenticationManager(),tokenManager,redisTemplate))
                .addFilter(new TokenAuthenticationFilter(authenticationManager(),tokenManager,redisTemplate))
                .httpBasic();
    }

    //调用userDetailsService和密码处理
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService).passwordEncoder(defaultPasswordEncoder);
    }

    // 不用认证就可以直接访问的路径
    @Override
    protected void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/api/**");
    }

}

微服务权限方案(编写UserDetailsService)


@Service("userDetailsService")
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService userService;
    @Autowired
    private PermissionService permissionService;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        // 根据用户名查询数据
        User user = userService.selectByUsername(s);
        if(user==null){
            throws new UsernameNotFoundException("用户不存在");
        }

        User curUser = new User();
        BeanUtils.copyProperties(user,curUser);

        // 根据用户查询用户权限列表
        List<String> permissionList = permissionService.selectPermissionValueByUserId(user.getId());
        SecurityUser securityUser = new SecurityUser();
        securityUser.setCurrentUserInfo(curUser);
        securityUser.setPermissionValueList(permissionList);

        return securityUser;
    }
}

微服务权限方案(整合网关和前端)

前后端分离,端口不同会产生跨域
访问协议,IP,port,有一个不同,默认都是不能访问的


@Configuration
public class CorsConfig {
    // 解决跨域问题
    @Bean
    public CorsWebFilter corsWebFilter(){
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedMethod("*");
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
        source.registerCorsConfiguration("/**",config);

        return new CorsWebFilter(source);
    }
}

源码分析 认证流程详解

1.认证流程
在这里插入图片描述

UsernamePasswordAuthenticationFilter

第一步:
查看父类AbstractAuthenticationProcessingFilter中的doFilter方法,判断请求是post请求才继续

第二步:
调用子类attemptAuthentication方法进行认证,认证成功之后,把认证信息封装到Authentication对象中,就是查看账号密码对不对

第三步:
this.sessionStrategy.onAuthentication(authResult,request,response)
session策略处理,配置最大并发数

第四步:
认证成功,执行successfulAuthentication
认证失败,执行unsuccessfulAuthentication

UsernamePasswordAuthenticationFilter类中的attemptAuthentication方法,进行身份认证;判断是post提交,获取请求中的用户名和密码;创建UsernamePasswordAuthenticationToken对象,标记为没认证的状态,将请求中的一些属性信息设置给对象,调用authenticate方法做认证

查看UsernamePasswordAuthenticationToken的构建过程
UsernamePasswordAuthenticationToken有两个构造方法,通过this.setAuthenticated()设置认证状态,true表示认证成功,false表示没认证

查看ProviderManager源码,查看authenticate方法,将表单数据封装成要求的数据类型Details,进行比对,返回Details类型

查看successfulAuthentication方法,认证成功后的操作,将信息封装到SecurityContextHolder对象中

查看unsuccessfulAuthentication,认证失败后的操作,创建SecurityContextHolder对象,设置一些关于认证失败的信息

源码分析 权限访问流程详解

主要使用ExceptionTranslationFilter过滤器FilterSecurityInterceptor过滤器实现

ExceptionTranslationFilter过滤器,处理异常的过滤器,doFilter方法
对前端异常,直接放行
对后端异常,进行相应的处理

FilterSecurityInterceptor过滤器,过滤连中最后一个过滤器,判断当前请求是否有权限,doFilter方法,判断当前请求是否可以访问资源

源码分析 认证信息共享详解

认证成功后,通过session进行多个请求之前的共享

请求之间认证共享
认证成功的方法,把认证信息放到对象
把认证信息对象Authentication,封装到SecurityContext里面,存入SecurityContextHolder里面
SecurityContextHolder.getContext().setAuthentication(authResult);

查看SecurityContext对象,对Authentication进行封装

查看SecurityContextHolder对象,使用ThreadLocal进行操作,与当前线程做绑定

查看SecurityContextPermissionFilter过滤器,把认证信息对象Authentication和session进行绑定,最开始的过滤器,判断session有没有认证信息,如果有认证信息,就取出SecurityContext对象,如果没有就创建一个新的SecurityContext对象,将SecurityContext放入SecurityContextHolder对象,放行执行其他过滤器,其他过滤器执行完后返回,去除SecurityContext,再放入session

源码分析 主要的过滤器

  • WebAsyncManagerIntegrationFilter
  • SecurityContextPersistenceFilter
  • HeaderWriterFilter
  • CorsFilter
  • LogoutFilter
  • RequestCacheAwareFilter
  • SecurityContextHolderAwareRequestFilter
  • AnonymousAuthenticationFilter
  • SessionManagementFilter
  • ExceptionTranslationFilter
  • FilterSecurityInterceptor
  • UsernamePasswordAuthenticationFilter
  • BasicAuthenticationFilter

源码分析 核心组件介绍

  1. SecurityContextHolder:提供对SecurityContext的访问
  2. SecurityContext:持有Authentication对象和其他可能需要的信息
  3. AuthenticationManager:其中可以包含多个AuthenticationProvider
  4. ProviderManager:对象为AuthenticationManager接口的是西安类
  5. AuthenticationProvider:主要用来进行认证操作的类,调用其中的authenticate()方法去进行认证操作
  6. Authentication:Spring Security方式的认证主体,也就是认证信息封装类
  7. GranteAuthority:对认证主体的应用层面的授权,含当前用户的权限信息,通常使用角色表示
  8. UserDetails:构建Authentication对象必须的信息,可以自定义,可能需要访问DB得到
  9. UserDetailsService:通过username构建UserDetails对象,通过loadUserByUsername根据username获取UserDetails对象(可以在这里基于自身业务进行自定义的实现,如通过数据库,xml,缓存获取等)
    10.WebSecurityConfigurerAdapter:自定义了一个springSecurity安全框架的配置类 继承WebSecurityConfigurerAdapter,重写其中的方法configure
  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值