SpringSecurity整合jwt

23 篇文章 1 订阅
15 篇文章 0 订阅

前言:

准备把权限管理写到自己的jee项目中, 索性想到了SpringSecurity框架, 就来学一下!
如果想要更好的观看体验戳这里
项目已经放到了github上去了:戳这里

认证

登录校验流程

image-20220428201012076

token可以存在localstoryge

springsecurity完整流程

image-20220428204517444

就是一个过滤链

UsernamePasswordAuthenticationFilter:(认证)处理登录页面填写了用户名和密码之后的登录请求ExcpetionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException

FilterSecutityInterceptor: (授权)负责权限校验的过滤器

一个案例的认证流程

image-20220428205438403

image-20220430021048478

Authentication对象接口:它的实现类,表示当前访问系统的用户,封装了用户相关的信息。比如最开始登录的用户名和密码.

AuthenticationManager接口:定义了认证Authentication的方法

UserDetailsService接口:加载用户特定数据的核心接口,定义了一个根据用户名查询用户信息的方法。

UserDetails接口:提供核心用户信息,通过UserDetailsService根据用户名获取处理用户信息封装成UserDetails对象返回 然后将这些信息封装到Authentication对象中。这时的Authentication对象就有权限了.

image-20220428211947293

登录

如果要把用户信息在数据库中查询, 只需要, 把最右边的InMemoryUserDetaisManager类用数据库来替换

在生成用户登录token后, 返回给用户的同时, 可以将(userid,userdetails)存入redis中

  1. 自定义登录接口

​ 调用ProviderManager的方法进行认证 如果认证成功生成jwt把用户信息存入到redis中

  1. 自定义UserDetailsService

​ 在这个实现类中去查询数据库

解析jwt
  1. 自定jwt认证过滤器

    ​ 获取token,解析token中的userid,从Redis中获取用户信息, 存入SecuritycontextHolder

简单实现过程

1. 登录, 拿到token
自定一个登录接口

自定义登录接口需要让SprintSecurity放行,让用户不用登录也能访问这个登录接口在接口中通过AuthenticationManager的authenticate方法进行用户认证,所以要在SecurityConfig中配置把AuthenticationManager注入到容器。

调用ProviderManager的方法进行认证 如果认证成功生成jwt把用户信息存入到redis中

@Service
@Slf4j
public class LoginServiceImpl implements LoginService {


    //从容器中注入这个
    @Autowired
    AuthenticationManager authenticationManager;

    @Resource
    RedisService redisService;

    @Override
    public Result login(User user) {

        //先得到前端传入的账号密码Authentication对象
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(user.getUsername(),user.getPassword());


        //AuthenticationManager authentication进行用户认证
        Authentication authentication = authenticationManager.authenticate(authenticationToken);

        System.out.println("authencation: "+ authentication);


        //如果没有认证成功, 给出相应的提示
        if(Objects.isNull(authentication)){
            throw new RuntimeException("登陆失败");
        }

        //认证成功生成jwt, jwt存入Result中, 返回
        LoginUser loginUser = (LoginUser)authentication.getPrincipal();
        System.out.println("看一下在LoginService中的loginUser: "+ loginUser);
        String id = loginUser.getUser().getId().toString();
        Map<String, Object> claims = new HashMap<>();
        claims.put("id", id);
        String jwt = JwtUtils.createJWT(claims,10000000L);
        //把完整的用户信息存入redis userid作为key

        User userMap = loginUser.getUser();
        log.info("token: "+jwt);

        redisService.set("login"+loginUser.getUser().getId(),userMap);

        return new Result<>(jwt,true,"登录成功");

    }
}

user类

package com.feng.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * @projectName: springboot2
 * @package: com.feng.entity
 * @className: User
 * @author: Ladidol
 * @description:
 * @date: 2022/4/28 22:06
 * @version: 1.0
 */

@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
    private Long id;
    private String username;
    private String password;
}

image-20220429142427649

绷不住了, 这里的authenticate方法卡住了!

找到解决方法了, 就是前后端密码比对出问题了, 可能是加密的原因
> 现在出现了一个新问题 就是redis的set方法包空指针异常 这是因为用错了user对象, 主要用loginuser
redis中存的形式, 到底是存id还是user信息
2. 对密码加密存储

实践开发中不会将明文密码放入到数据库中。

详情请看:springsecurity中的密码加密

一般使用SpringSecurity提供的BCryptPasswordEncoder。

使用方法只需要将BCryptPasswordEncoder对象注入到spring容器中。定义一个SpringSecurity配置类要求继承WebSecurityConfigurerAdapter

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
        // passwordEncoder.encode()
        // passwordEncoder.mathches()
    }
}

这里可以加上自定义PasswordEncoder类:

@Component
@Slf4j
public class DefaultPasswordEncoder extends BCryptPasswordEncoder {
 
    @Override
    public String encode(CharSequence rawPassword) {
        return rawPassword.toString();
    }
 
    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        if (rawPassword == null) {
            throw new IllegalArgumentException("rawPassword cannot be null");
        }
        if (encodedPassword == null || encodedPassword.length() == 0) {
            log.error("Empty encoded password");
            throw new IllegalArgumentException("encodedPassword is null");
        }
        return encodedPassword.equals(rawPassword);
    }
}
3. 对token的校验

定义jwt认证过滤器

获取请求中的token字段

解析token获取其中的userid

从redis中获取用户信息

存入SecurityContextHolder便于其他过滤器使用

@Component
@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Resource
    RedisService redisService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // 获取token
        String token = request.getHeader("token");
        log.info("从前端拿到的token: "+token);
        if (token == null) {
            filterChain.doFilter(request, response);
            return ;
        }

        // 解析token
        Claims claims = JwtUtils.verifyJwt(token);
        String id = String.valueOf(claims.get("id"));
        String redisKey = "login" + id;
        log.info("解析得到的redisKey: "+redisKey);

        // 从redis中读取信息
        User user = (User) redisService.get(redisKey);
        log.info("解析到的用户: "+user);
        if (user == null) {
            throw new RuntimeException("用户未登录");
        }

        // 存入SecurityContextHolder, 其他filter会通过这个来获取当前用户信息
        // 获取权限信息封装到authentication中
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
                = new UsernamePasswordAuthenticationToken(user, null, null);
        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);

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



========================================================================================

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Bean
    public PasswordEncoder passwordEncoder() {
        //用户输入的密码, 和数据库中的密码的密文进行比较
        //所以数据库中存储的密码使用这个加密后的密文
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // 关闭csrf, 好像是因为前后端分离
                .csrf().disable()
                // 不通过session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 允许登录接口匿名访问
                .antMatchers("/user/login").anonymous()
                //.antMatchers("/hello/user/login").anonymous()
                // 除上面之外所有请求要进行认证
                .anyRequest().authenticated();

        // 配置认证过滤器
        http
                .addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
    }
}
4. 注销登录

从redis中删除对象就行

@Service
@SuppressWarnings("all")
@Slf4j
public class LogoutServiceImpl implements LogoutService {

    @Resource
    RedisService redisService;
    @Override
    public Result logout() {
        /**
         * 获取SecurityContext对象中的用户id
         * 当用户发起退出登录的请求时经过JwtAuthenticationTokenFilter过滤器认证时
         * 会将authentication(UsernamePasswordAuthenticationToken)对象存入到SecurityContext中
         * 这里可以通过authentication.getPrincipal()直接获取到User对象
         */
        UsernamePasswordAuthenticationToken authentication
                = (UsernamePasswordAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
        log.info("Principal: "+String.valueOf(authentication.getPrincipal()));
        LoginUser loginUser = new LoginUser();
        loginUser.setUser((User) authentication.getPrincipal());
        String id = loginUser.getUser().getId().toString();

        // 删除redis中的值
        redisService.del("login" + id);
        return new Result(true, "注销成功");
    }
}

不知道为啥会出现这个问题, 就是principal中的从登录的时候的LoginUser对象变成了User对象.待定解决!

image-20220502134109063

5. 授权

终于到了, SpringSecurity的主要功能了, 授权.

例如, 在网上商城中, 普通顾客是只有买东西, 付款和加入购物车等简单购物功能, 不存在添加商品, 删除商品修改商品信息的功能, 只有店主或者管理员才能有相应权限!

不同的用户需要有不同的权限, 来对应不同的访问. 这里主要通过权限系统去实现!

在刚刚的UserDetailsService中没做的授权给做了:

/**
 * @projectName: springboot2
 * @package: com.feng.service.impl
 * @className: UserDetailsServiceImpl
 * @author: Ladidol
 * @description: 从数据库中, 通过username查询用户信息
 * @date: 2022/4/28 22:19
 * @version: 1.0
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Resource
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 查询用户信息

        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        System.out.println("username是"+username);
        wrapper.eq(User::getUsername, username);
        User user = userMapper.selectOne(wrapper);
        if (user == null) {
            // 异常信息会被ExceptionTranslationFilter捕获到
            throw new RuntimeException("用户不存在或密码错误");
        }
        // 授权
        // todo 这里可以用权限集来做
        List<String> permissions = new ArrayList<>(Arrays.asList("test","admin"));


        System.out.println("loginUser是:"+new LoginUser(user,permissions));
        // 数据封装成UserDetails返回
        return new LoginUser(user,permissions);
    }
}

在配置类上面开启相关配置:

/**
 * @author ladidol
 */
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)//注解开启相关配置
public class SecurityConfig extends WebSecurityConfigurerAdapter {

给一个前端响应接口加上树妖特定权限的注解:

@RestController
@RequestMapping
public class HelloController {

    @GetMapping("/hello")
    public String hello() {
        return "hello SpringSecurity";
    }

    @PreAuthorize("hasAuthority('admin')")
    @GetMapping("/helloAdmin")
    public String hello2(){
        return "hello admin";
    }
}

将用户权限封装到UserDetails实现类LoginUser类中:

新加权限permissions:

@Data
@NoArgsConstructor
//这里需要把不是属性的get方法给忽略掉, 避免强转json的时候不能行!
@JsonIgnoreProperties({"username","password","enabled","accountNonExpired", "accountNonLocked", "credentialsNonExpired", "authorities"})
public class LoginUser implements UserDetails {

    private User user;

    private List<String> permissions;

    @JSONField(serialize = false) //保存到Redis中会避免乱码问题
    private List<SimpleGrantedAuthority> authorities;

    //两个参数的构造方法
    public LoginUser(User user, List<String> permissions) {
        this.user = user;
        this.permissions = permissions;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        if(authorities!=null){
            return authorities;
        }
        //把permissions中String类型的权限信息封装成SimpleGrantedAuthority对象
        authorities = new ArrayList<>();
        for (String permission : permissions) {
            SimpleGrantedAuthority authority = new SimpleGrantedAuthority(permission);
            authorities.add(authority);
        }
        return authorities;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        //先改成true
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

获得权限信息封装到authentication中:

image-20220502170334111

@Component
@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Resource
    RedisService redisService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // 获取token
        String token = request.getHeader("token");
        log.info("从前端拿到的token: "+token);
        if (token == null) {
            filterChain.doFilter(request, response);
            return ;
        }

        // 解析token
        Claims claims = JwtUtils.verifyJwt(token);
        String id = String.valueOf(claims.get("id"));
        String redisKey = "login" + id;
        log.info("解析得到的redisKey: "+redisKey);

        // 从redis中读取信息
        LoginUser loginUser = (LoginUser) redisService.get(redisKey);
        log.info("解析到的用户: "+loginUser);
        if (loginUser == null) {
            throw new RuntimeException("用户未登录");
        }

        // 存入SecurityContextHolder, 其他filter会通过这个来获取当前用户信息
        // 获取权限信息封装到authentication中
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken
                = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);

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

至此, 算是把这个过程走了一遍, 有点不好得就是权限是在登陆的时候死代码定义的, 真正开发的时候, 是要用到权限集, 通过数据库来查询!

6. 异常捕获

这里就用SpringSecurity的拦截器来捕获异常

import com.alibaba.fastjson.JSON;
import com.feng.utils.WebUtils;
import com.feng.utils.result.Result;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
            throws IOException, ServletException {
        Result<Integer> result = new Result<Integer>(HttpStatus.FORBIDDEN.value(), false,"权限不够!");
        String json = JSON.toJSONString(result);
        WebUtils.renderString(response, json);

    }
}


package com.feng.handler.authentication;

import com.alibaba.fastjson.JSON;
import com.feng.utils.WebUtils;
import com.feng.utils.result.Result;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
            throws IOException, ServletException {
        Result<Integer> result = new Result<Integer>(HttpStatus.UNAUTHORIZED.value(), false,"用户认证失败,请检查用户名或密码是否正确");
        String json = JSON.toJSONString(result);
        WebUtils.renderString(response, json);
    }
}

配置类中配置:

// 配置异常处理器
http
    .exceptionHandling()
    .authenticationEntryPoint(authenticationEntryPoint)
    .accessDeniedHandler(accessDeniedHandler);
7. 跨域问题

浏览器出于安全考虑 使用相同XMLHttpRequest对象发起 Http 请求时必须遵守同源策略,否则就说跨域的Http请求,默认情况下是不允许的,同源策略要求源相同才能正常进行通信,即协议,域名,端口号完全一直。

前后端分离项目,前端和后端一般都是不同源的,所以肯定会存在跨域请求的问题。

所以我们就要处理一下,让前端能进行跨域请求。

详情可以看一下我之前的博客

这里介绍一下另一种配置跨域配置:

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry
                // 设置允许跨域的路径
                .addMapping("/**")
                // 设置允许跨域请求的域名
                .allowedOriginPatterns("*")
                // 是否允许cookie
                .allowCredentials(true)
                // 设置允许的请求方式
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                // 设置允许的header属性
                .allowedHeaders("*")
                // 跨域允许时间
                .maxAge(3600);
    }
}

然后就是SpringSecurity配置中允许跨域:

// 允许跨域
http.cors();
8. 访问结果演示:
数据库:

在这里插入图片描述

项目结构:

在这里插入图片描述

登陆成功界面:

image-20220502172922929

访问一个需要有admin权限的接口

image-20220502173033535

访问一个需要权限noway, 报错的接口样例:

因为死代码写死了(权限test``admin)一定会访问失败

  1. token错误报错情况:

image-20220502181826635

  1. 权限不够

image-20220502181900501

一个正常的接口:

image-20220502173523142

注销接口:

image-20220502173555452

补充说明:

1. 抵御CSRF攻击

CSRF是指跨站请求伪造(Cross-site request forgery),是web常见的攻击之一。

SpringSecurity去防止CSRF攻击的方式就是通过csrf_token。后端会生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。

我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息,但是前后端分离的项目中我们使用的是token,而token并不存储在cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了。

前后端分离的项目天生不惧怕CSRF攻击,所以在配置文件中将csrf的功能关闭。

2. 认证成功处理器

在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果成功了会调用AuthenticationSuccessHandler的方法进行认证成功后的处理,AuthenticationSuccessHandler就是登录成功处理器。

我们也可以自定义成功处理器进行成功后的相应处理。

@Component
public class SGSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        System.out.println("认证成功了");
    }
}
3. 认证失败处理器

同样有失败处理器

@Component
public class SGFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        System.out.println("认证失败了");
    }
}
4. 注销成功处理器

同样有注销成功处理器

@Component
public class SGLogoutSuccessHandler implements LogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        System.out.println("注销成功");
    }
}

SecurityConfig类:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private AuthenticationSuccessHandler successHandler;

    @Autowired
    private AuthenticationFailureHandler failureHandler;

    @Autowired
    private LogoutSuccessHandler logoutSuccessHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
//                配置认证成功处理器
                .successHandler(successHandler)
//                配置认证失败处理器
                .failureHandler(failureHandler);

        http.logout()
                //配置注销成功处理器
                .logoutSuccessHandler(logoutSuccessHandler);

        http.authorizeRequests().anyRequest().authenticated();
    }
}

一些参考链接:

参考链接:

特别感谢:感谢芳芳的笔记

B站视频

看一下这个up主的戳一下

也可以看一下这个博主这个博主的简单体验一下, 这个自带登录页面的样子戳一下

这个也是就是全部流程:https://blog.csdn.net/MarconiYe/article/details/124437707

全网最细的springsecurity的配置:戳这里

debug链接:

数据密码解决There is no PasswordEncoder mapped for id “null” - 知乎 (zhihu.com)

一个小小小错误: SpringBoot接口访问报错:No adapter for handler

遇到转换成实体类不行的情况:

由于LoginUser的属性应该只有User类和permission

存入redis中会有以下情况, 把get方法都转换成了属性, 导致(LoginUser)强转的时候会报错Could not read JSON: Unrecognized field "enabled" (class com.feng.entity.LoginUser), not marked as ignorable

[
  "com.feng.entity.LoginUser",
  {
    "user":[
      "com.feng.entity.User",
      {
        "id":1,
        "username":"ladidol",
        "password":"$2a$10$aGK66orwO8v.eDG3l5Q88uWFzXvu8tQBquUlFz8VwZyZnqMVAbR\/6"
      }
    ]
  }
]
    
    
    
    ,
    "permissions":[
      "java.util.ArrayList",
      [
        "test",
        "admin"
      ]
    ],
    "authorities":[
      "java.util.ArrayList",
      [
        [
          "org.springframework.security.core.authority.SimpleGrantedAuthority",
          {
            "role":"test",
            "authority":"test"
          }
        ],
        [
          "org.springframework.security.core.authority.SimpleGrantedAuthority",
          {
            "role":"admin",
            "authority":"admin"
          }
        ]
      ]
    ],
    "password":"$2a$10$aGK66orwO8v.eDG3l5Q88uWFzXvu8tQBquUlFz8VwZyZnqMVAbR\/6",
    "enabled":true,
    "username":"ladidol",
    "accountNonExpired":true,
    "accountNonLocked":true,
    "credentialsNonExpired":true
  }
]

这时候只需要在LoginUser类上加上注解@JsonIgnoreProperties({"username","password","enabled","accountNonExpired", "accountNonLocked", "credentialsNonExpired", "authorities"})就可以得到正确的:

[
  "com.feng.entity.LoginUser",
  {
    "user":[
      "com.feng.entity.User",
      {
        "id":1,
        "username":"ladidol",
        "password":"$2a$10$aGK66orwO8v.eDG3l5Q88uWFzXvu8tQBquUlFz8VwZyZnqMVAbR\/6"
      }
    ],
    "permissions":[
      "java.util.ArrayList",
      [
        "test",
        "admin"
      ]
    ]
  }
]

END

复现结果过程中也出现了大大小小的问题. 感谢阅读!
后续我会将我的demo源码放到我的github上面, 欢迎star哦!
小小的博客传送门!

  • 22
    点赞
  • 85
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 6
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

兴趣使然的小小

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值