SpringSecurity和JWT

原理

0.操作

在这里插入图片描述

1.security的流程

在这里插入图片描述

(1)认证流程

在这里插入图片描述
具体过程:
1.需要实现UserDetailsService,自定义loadUserByUsername

查询数据库,得到权限,封装成UserDetails对象返回

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Resource
    private UserMapper userMapper;

    @Resource
    private MenuMapper menuMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        // 查询用户信息
        User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getUserName, username));
        // 如果没有查询到用户就抛出异常
        if (Objects.isNull(user)) {
            throw new RuntimeException("用户名或密码错误");
        }
        // TODO 查询对应的权限信息
        // 写死测试固定权限
        // List<String> list = new ArrayList<>(Arrays.asList("test", "admin"));
        List<String> list = menuMapper.selectPermsByUserId(user.getId());

        // 把数据封装成 UserDetails 返回,参数:用户信息、权限列表
        return new LoginUser(user, list);
    }
}

2.实现登录操作

1.把传进来的用户名和密码封装成Authentication对象
2.利用AuthenticationManager的authenticate方法进行账号密码的校验
3。得到userId,作为JWT的内容
4.用户信息存储在redis
5.token返回给前端

    /**
     * 登录
     *
     * @param user
     * @return
     */
    @Override
    public ResponseResult login(User user) {
        // AuthenticationManager的authenticate方法  进行用户认证
        // 拿到用户输入的用户名和密码,封装成 Authentication 对象
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());

        // 让 authenticationManager 拿着信息帮助我们去进行认证操作,查数据库校验
        //要使用authenticationManager 是通过重写,然后注入,就可以在这直接用
        //返回的是一个完全认证的对象
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);

        // 如果认证没通过,给出对应的提示
        if (Objects.isNull(authenticate)) {
            throw new RuntimeException("用户名或密码错误");
        }
        // 认证通过,使用 userId 生成一个 jwt ,jwt 存入
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        String userId = loginUser.getUser().getId().toString();
        // 使用用户ID生成 jwt,解析 jwt 结果为 用户 id
        String jwt = JwtUtil.createJWT(userId);

        Map<String, String> map = new HashMap<>(1);
        map.put("token", jwt);

        // 把完整用户信息存入 redis ,userId 作为 key
        //用户存到redis是为了减少服务器压力,之后从redis得到用户信息
        redisCache.setCacheObject("login:" + userId, loginUser);

        // 登录成功,把token返回
        return new ResponseResult(200, "登录成功", map);
    }

3.JWT过滤器

/**
 * 认证过滤器
 * @author bing_  @create 2022/1/5-14:12
 * 继承 OncePerRequestFilter 保证请求经过过滤器一次
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Resource
    private RedisCache redisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 获取 token ( 前端,用户登录后,将 token 放到请求头当中。所以这里从请求头中获取 token )
        String token = request.getHeader("token");

        if (!StringUtils.hasText(token)) {
            // 如果请求头没有 token ,放行,就不需要后面的操作。之后的过滤器就会拦截
            filterChain.doFilter(request, response);
            //必须要return,因为等请求转回来之后,不return还会执行下面的对token的操作
            return;
        }

        // token 不为空,解析 token
        String uesrId;
        try {
            Claims claims = JwtUtil.parseJWT(token);
            uesrId = claims.getSubject();
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("非法 token");
        }
        // 从 redis 中获取用户信息
        String redisKey = "login:" + uesrId;

        LoginUser loginUser = redisCache.getCacheObject(redisKey);

        if (Objects.isNull(loginUser)) {
            throw new RuntimeException("用户未登录");
        }

        // 将用户信息存入 SecurityContextHolder
        // 获取权限信息封装到 Authentication 中
        // 参数:用户信息、已认证状态、权限信息
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);

        //放行
        filterChain.doFilter(request, response);
    }
}

3.退出登录

从SecurityContextHolder拿到登录用户id
从redis删除

    public ResponseResult logout() {
        // 获取 SecurityContextHolder 中的用户 id
        UsernamePasswordAuthenticationToken authentication =
                (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();

        Long userId = loginUser.getUser().getId();

        // 删除 redis 中的值
        redisCache.deleteObject("login:" + userId);
        return new ResponseResult(200, "退出成功");
    }

一 基础登录和自定义登录问题

1.不做任何配置

引入了依赖之后,就会自动生效,会自动跳转的自带的登录页面

账号:user 密码:控制台输出(随机生成)

2.自定义登录逻辑

(1)UserDetailsService——通过用户名得到数据库数据

1.会加载用户名,把信息从数据库中取出来,返回一个UserDetails
2.会进行比较用户名和密码

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

2.UserDetails是一个接口

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

}

3.通过构造方法将数据形成User对象,然后交给框架去验证

(2)PasswordEncoder——密码解析器

官方推荐的实现类是BCryptPasswordEncoder,这是一个强散列算法,单向加密(因为每次生成不同的salt)

1.encode会把明文密码加密
2.matches把明文密码和加密好的密文比较,看是否一致
3.upgradeEncoding,对密文进行二次加密(一般不用)

public interface PasswordEncoder {
    String encode(CharSequence rawPassword);

    boolean matches(CharSequence rawPassword, String encodedPassword);

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

(3)开始自定义登录逻辑

1.因为要用到PasswordEncoder,但是不允许通过new它的实体类BCryptPasswordEncoder来使用它,所以,写一个配置文件,直接返回BCryptPasswordEncoder实例,然后在用的时候引入

@Configuration
public class SecurityConfig {

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

2,实现自定义登录逻辑

@Service
//实现UserDetailsService从而实现自定义登录逻辑
public class UserServiceImpl implements UserDetailsService {
    @Resource
    //这个时候引入的就是配置文件中写的BCryptPasswordEncoder实例
    private PasswordEncoder pw;


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if ("不可以用username从数据库中查出来名字"){
            throw new UsernameNotFoundException("用户名或密码错误");
        }
        if (!pw.matches("传进来的密码", "数据库的密码")) {
            throw new UsernameNotFoundException("用户名或密码错误");
        }
        //返回给前端UserDetails的实现类User
        return new User(username, "密文密码", AuthorityUtils.commaSeparatedStringToAuthorityList("权限1, 权限2"));
    }
}

3自定义登录页面

因为现在登录还是被拦截到自带的登录页面
1.实现WebSecurityConfigurerAdapter 完成配置

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //表单登录
        http.formLogin()
                //子定义登录页面
                .loginPage("/login.html")
                //自定义登录逻辑
                //表单的/login对应的不是接口的login
                //表单的/login是要去找自定义逻辑方法,所以在这配置login意思
                //就是把表单的登录和自定义逻辑方法连接起来
                .loginProcessingUrl("/login")
                //登录成功后跳转的页面
//                这种方式不行,因为这种属于GET请求,必须是POST
//                .successForwardUrl("main.html");
                .successForwardUrl("/toMain")
                .failureForwardUrl("/toFail");

        //关闭csrf
        http.csrf().disable();

        http.authorizeRequests()
                //放行登录页面,如果不放行,就会全部拦截,不停的重定向
                .antMatchers("/login.html").permitAll()
                .antMatchers("/fail.html").permitAll()
                //拦截所有页面,所有页面都要登录
                .anyRequest().authenticated();
    }
}

4.自定义表单name属性

UsernamePasswordAuthenticationFilter
里面规定了前台表单必须name是username和password,方法是post
也可再配置文件中修改为自定义的name

http.formLogin()
                .usernameParameter("自定义的表单的username")
                .passwordParameter("自定义的表单的password");

5.自定义成功跳转

跳转默认只能使用.successForwardUrl(“/toMain”)这种POST的方式

原理:

  • 进入successForwardUrl
    public FormLoginConfigurer<H> successForwardUrl(String forwardUrl) {
        this.successHandler(new ForwardAuthenticationSuccessHandler(forwardUrl));
        return this;
    }
  • 进入ForwardAuthenticationSuccessHandler
public class ForwardAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    private final String forwardUrl;

    public ForwardAuthenticationSuccessHandler(String forwardUrl) {
        Assert.isTrue(UrlUtils.isValidRedirectUrl(forwardUrl), () -> {
            return "'" + forwardUrl + "' is not a valid forward URL";
        });
        this.forwardUrl = forwardUrl;
    }

    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        request.getRequestDispatcher(this.forwardUrl).forward(request, response);
    }
}

可以看到这是一个AuthenticationSuccessHandler 接口的实现方法
构造函数中对于url进行是否valid的校验
然后onAuthenticationSuccess方法中进行了请求转发,所以必须是POST

那么如何进行GET方式的请求?

  • 写一个配置类,同样实现AuthenticationSuccessHandler ,然后再onAuthenticationSuccess方法中使用重定向
public class MyAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    private final String url;
    
    public MyAuthenticationSuccessHandler(String url) {
        this.url = url;
    }

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
    //如果是先后端分离,这就返回JSON
        response.sendRedirect(url);
    }
}
  • 然后调用这个自己写的实现类,传入GET请求
.successHandler(new MyAuthenticationSuccessHandler("http://baidu.com"))

6.自定义失败跳转

和成功一样,就是实现的接口不同,使用时的Handler不同

二 授权问题

1.访问控制url匹配

http.authorizeRequests()是对url进行访问控制

常用的访问控制方法如下:

  • anyRequest():表示匹配所有请求。.anyRequest().authenticated();表示对所有请求要认证。一定要放在最后,不然后面的不生效
  • antMatcher()
public C antMatchers(String... antPatterns)

参数是一个不定项参数,每一个参数都是一个ant表达式,用于匹配URL
匹配规则如下:
1.?:匹配一个字符
2.*:匹配0或多个字符
3.**:匹配0或多个目录
在这里插入图片描述

public abstract C mvcMatchers(HttpMethod method, String... mvcPatterns);

还可以定义请求类型

  • regexMatchers()
    和antMatchers的区别就是正则表达式进行匹配

  • mvcMatchers()
    .mvcMatchers("/image/**").servletPath("/xxx")相当于
    .antMatchers("/xxx/image/**").permitAll()

2.角色权限判断

所有的访问权限控制如下:

//放行所有
    static final String permitAll = "permitAll";
    //拒绝所有
    private static final String denyAll = "denyAll";
    //匿名访问时允许通行
    private static final String anonymous = "anonymous";
    //认证之后放行
    private static final String authenticated = "authenticated";
    //不允许记住的访问,必须完整登录才可以访问
    private static final String fullyAuthenticated = "fullyAuthenticated";
    //记住用户后可以放行
    private static final String rememberMe = "rememberMe";

除了上面的几种,还有一些:

1.根据权限匹配

//必须有这个权限才可(这个权限严格区分大小写)
.antMatchers("/fail.html").hasAnyAuthority("权限")
//有以下多个权限的一个就可访问
.antMatchers("/fail.html").hasAnyAuthority("1","2")

2.根据角色匹配

.antMatchers("/fail.html").hasRole("abc")
.antMatchers("/fail.html").hasAnyRole("abc","a")

注意:在返回给前端的User中,角色必须以ROLE_开头,但是在匹配中,不能加前缀,因为会自动补上,若加上就会报错

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if (!"admin".equals(username)){
            throw new UsernameNotFoundException("用户名或密码错误");
        }
        String encode = pw.encode("123456");
        //返回UserDetails的实现类User
        return new User(username,encode , AuthorityUtils.commaSeparatedStringToAuthorityList("权限1, 权限2,ROLE_abc"));
    }
}

3.限制ip地址进行访问

.antMatchers("/fail.html").hasIpAddress("127.0.0.1")

3.自定义403页面

在访问授权出问题就会403页面,如何自定义403页面?

1.实现处理拒绝访问类,实现自定义的拒绝访问处理

@Component
public class MyaccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;cahrset=utf-8");
        PrintWriter writer = response.getWriter();
        writer.write("{\n" +
                "    \"status\": 10000,\n" +
                "    \"msg\": \"权限不足\",\n" +
                "    \"data\": {\n" +
                "    }\n" +
                "}");
        writer.flush();
        writer.close();
    }
}

2.传入自定义拒绝访问对象,进行自定义异常处理

    //注入403自定义处理
@Resource
    private MyaccessDeniedHandler myaccessDeniedHandler;

        //异常处理
        http.exceptionHandling()
                .accessDeniedHandler(myaccessDeniedHandler);

4.access自定义方法

以上的权限判断都是基于access表达式

public ExpressionUrlAuthorizationConfigurer<H>.ExpressionInterceptUrlRegistry hasRole(String role) {
            return this.access(ExpressionUrlAuthorizationConfigurer.hasRole(ExpressionUrlAuthorizationConfigurer.this.rolePrefix, role));
        }

        public ExpressionUrlAuthorizationConfigurer<H>.ExpressionInterceptUrlRegistry hasAnyRole(String... roles) {
            return this.access(ExpressionUrlAuthorizationConfigurer.hasAnyRole(ExpressionUrlAuthorizationConfigurer.this.rolePrefix, roles));
        }

所以 .antMatchers("/login.html").permitAll()
.antMatchers("/login.html").access("permitAll()")
这两句话是等价

所以可以通过access自定义权限控制逻辑
以下实例:如果当前用户有访问main.html的权限,就可以访问,不然不行

1.自定义接口和实现类,实现自定义权限逻辑

public interface MyAuthService {
    /**
     *
     * @param request 得到请求
     * @param authentication 包含用户信息
     * @return
     */
    boolean hasMainAuth(HttpServletRequest request, Authentication authentication);
}
@Service
public class MyAuthServiceImpl implements MyAuthService{
    @Override
    public boolean hasMainAuth(HttpServletRequest request, Authentication authentication) {
        String requestURI = request.getRequestURI();
        Object principal = authentication.getPrincipal();//得到用户对象
        if (principal instanceof UserDetails) {
            //获得权限
            Collection<? extends GrantedAuthority> authorities =
                    ((UserDetails) principal).getAuthorities();
            //权限集合的泛型是GrantedAuthority接口,实现类为SimpleGrantedAuthority
            return authorities.contains(new SimpleGrantedAuthority(requestURI));
        }
        return false;
    }
}

2.使用自定义access逻辑

.anyRequest().access("@MyAuthServiceImpl.hasMainAuth(request,authentication)");

5.基于注解的访问控制

1.注解需要开启@EnableGlobalMethodSecurity
2.注解可以写在service接口或者方法上,也可写在controller或其方法上,通常写在控制器方法上

  • @Secured

判断是否具有角色,参数要以ROLE_开头
相当于.antMatchers("/fail.html").hasRole("abc")
配置文件中的hasRole不能以ROLE_kait
而注解方式必须以它开头
都区分大小写
@EnableGlobalMethodSecurity(securedEnabled = true)开启@Secured注解

    @PostMapping("/toMain")
    @Secured(("ROLE_abc"))
    public String toMain() {
        return "redirect:main.html";
    }
  • @PreAuthorize/@PostAuthorize

两个都是判断权限的,一个是在方法之前判断,一个是方法结束之后判断
1.@EnableGlobalMethodSecurity(prePostEnabled = true)开启@PreAuthorize/@PostAuthorize
2.@PreAuthorize中是access表达式,在里面判断角色,可以加ROLE_也可不加

    @PostMapping("/toMain")
    @Secured(("ROLE_abc"))
    @PreAuthorize("hasAnyRole('ROLE_abc')")
    public String toMain() {
        return "redirect:main.html";
    }

    @PostMapping("toFail")
    @PreAuthorize("hasAnyRole('abc')")
    public String toFail() {
        return "redirect:fail.html";
    }

6.RememberMe的实现

会自动把用户信息存储在数据库中

(1)添加依赖

RememberMe的实现依赖于Spring-JDBC
所以需要导入mybatis和mysql的依赖

(2)实现

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    //引入自定义登录逻辑
    @Resource
    private UserServiceImpl userService;

    //引入配置文件中写好的数据源
    @Resource
    private DataSource dataSource;

    //引入写好的存储逻辑PersistentTokenRepository
    @Resource
    private PersistentTokenRepository tokenRepository;


    @Override
    protected void configure(HttpSecurity http) throws Exception {
      
        //实现记住用户
        http.rememberMe()
                        //前端记住我的复选框参数本来必须是name="remember-me",可以自定义
//                .rememberMeParameter()
                //失效时间,默认;两周(秒数)
//                .tokenValiditySeconds()
                //自定义记住我的逻辑
//                .rememberMeServices()
                //自定义登录逻辑
                .userDetailsService(userService)
                //指定存储位置(这里的参数类型为PersistentTokenRepository接口)
                .tokenRepository(tokenRepository);


    //实现PersistentTokenRepository存储逻辑
    @Bean
    public PersistentTokenRepository tokenRepository() {
        /**
         * PersistentTokenRepository是个接口,实现类有两种
         *      1.InMemoryTokenRepositoryImpl 存储在内存
         *      2.JdbcTokenRepositoryImpl 存储在数据库
         */
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        //设置数据源
        jdbcTokenRepository.setDataSource(dataSource);
        //启动时是否创建表,第一次要,之后注释掉
        jdbcTokenRepository.setCreateTableOnStartup(true);
        return jdbcTokenRepository;
    }
}

7.退出登录

1.默认

直接写推出标签,然后href赋值的是security的/logout,点击就直接实现了推出登录

<a href="/logout">退出登录</a>

2.用的到的配置


        //退出登录
        http.logout()
                //退出登录的URL,也就是<a href="/logout">退出登录</a>
                .logoutUrl("/logout")
                //退出成功跳转的页面
                .logoutSuccessUrl("/login.html");

8.CSRF

在这里插入图片描述

需要前端传过来token,会自动在后端进行比较,若成功就可以访问

三 Oauth2

在这里插入图片描述

四 JWT

在这里插入图片描述

1.JWT的结构

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

2.使用

  • 引入依赖
    <dependency>
        <groupId>com.auth0</groupId>
        <artifactId>java-jwt</artifactId>
        <version>3.4.0</version>
    </dependency>
  • 获取 token
   		public final static long EXPIRE_TIME=30*60*1000;//放在类中 ,30分钟的毫秒数
		
		//获取时间,用来做token的过期时间,设置30分钟
		Date date=new Date(System.currentTimeMillis()+EXPIRE_TIME)

        Map<String,Object> map=new HashMap<>();
        String sign = JWT.create().withHeader(map) //header  这个不设置也可以
                .withClaim("userId", 12)//payload 存储非敏感的信息 例如用户账号,不能存密码,防止被人解析
                .withExpiresAt(date)//指定令牌的过期时间
                .sign(Algorithm.HMAC256("!we2123")) ;//签名  保密复杂
        System.out.println(sign); //输出结果
  • 验证token
		//创建验证对象
        //验证时一定要跟生成时的 算法 和 签名 一致
        JWTVerifier build = JWT.require(Algorithm.HMAC256("!we2123")).build();
        //得到一个解码后的对象
        DecodedJWT verify = build.verify(" 这里放token ");
		/**
		以上两行代码就已经完成验证了,如果没有出异常,就验证成功!
		出现了就是验证失败 ,前提要保证算法一定要一致,不要出现算法不一致异常
		*/
		
		//从解码后的对象获取payload存储的信息
        System.out.println(verify.getClaim("userId").asInt()); //存什么类型 就as什么类型,否则为null
		System.out.println(verify.getClaim("username").asString());

        Date expiresAt = verify.getExpiresAt(); // 查看token过期时间
        SimpleDateFormat simpleDateFormat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        String format = simpleDateFormat.format(expiresAt);
        System.out.println(format);
  • 工具类
public class JWTUtil {
    //30分钟
    public final static long EXPIRE_TIME=30*60*1000;
    /**
     * 生成token header.payload.sing
     */
    public static String getToken(Map<String, String> map){
        Date date=new Date(System.currentTimeMillis()+EXPIRE_TIME); //默认30分钟过期
        //创建JWT builder
        JWTCreator.Builder builder = JWT.create();
        // payload
        map.forEach((k,v)->{
            builder.withClaim(k,v); //这里可以存放 用户id,用户名
        });
        //指定过期时间,sign ,生成token  这里的签名是指定好的
        //实际项目中可以 接收用户的密码来做签名,这样每一个用户对应一个签名
        java.lang.String token = builder.withExpiresAt(date).sign(Algorithm.HMAC256("1234"));
        return token;
    }

    /**
     * 验证token合法性
     */
    public static boolean verify(String token){
        try {
            //如果抛出异常,证明签名不一致 / token过期
            JWT.require(Algorithm.HMAC256("1234")).build().verify(token);
            return true;
        }catch (Exception e){
            return false;
        }
    }

    /**
     * 获得token中的信息无需secret解密也能获得
     */
    public static String getUserName(String token){
        DecodedJWT decode = JWT.decode(token);
        //假设存储的是一个phone
        String phone = decode.getClaim("phone").asString();
        return phone;
    }

3. jwt验证出现的异常

验证时会先验证签名,再验证令牌有没有过期

# 签名验证异常 (生成时和验证时的签名不一致,会出现此异常)
 - SignatureVerificationException
# 算法不匹配异常  (生成时和验证时的算法不一致,会出现此异常)
 - AlgorithmMismatchException
# 令牌过期异常 (生成时设置的时间超时后,再次验证会出现此异常)
 - TokenExpiredException
# 失效的payload异常  (出现此异常的原因有:可能有人使用base64解析payload,更改了数据payload里解析的数据,再次传过来,验证时会出现此异常)
 - InvalidClaimException
  • 1
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值