Springboot+SpringSecurity结合JWT实现认证和鉴权

概述

Spring Security 是 Spring 社区的一个顶级项目,也是 Spring Boot 官方推荐使用的安全框架。Spring Security和Shiro也是当前广大应用使用比较广泛的两个安全框架。

Spring Security 应用级别的安全主要包含两个主要部分,即登录认证(Authentication)和访问授权(Authorization),首先用户登录的时候传入登录信息,登录验证器完成登录认证并将登录认证好的信息存储到请求上下文,然后再进行其他操作,如在进行接口访问、方法调用时,权限认证器从上下文中获取登录认证信息,然后根据认证信息获取权限信息,通过权限信息和特定的授权策略决定是否授权。

项目搭建

在IDEA中创建Springboot项目,引入以下内容

在这里插入图片描述

其中Springboot DevTools、Lombok不是必须件

引入依赖

项目搭建好后,引入JWT依赖。为了方便调试,本例中还将引入Swagger项目。由于引入了Swagger,所以在之后的权限拦截中要额外配置Swagger资源跳过验证,具体后文会说。

JWT

<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

Swagger

我并没有使用Swagger-ui,而是使用了Layui来作为Swagger的展示,所以没有引用到swagger-ui的依赖。swagger-ui-layer只支持RestController的接口,请根据自己项目实际情况选择Swagger的UI框架。

<!-- swagger2  -->
<!--swagger本身不支持spring mvc的,springfox把swagger包装了一下,让他可以支持springmvc-->
<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-swagger2</artifactId>
    <version>2.6.1</version>
</dependency>
<dependency>
    <groupId>com.github.ohcomeyes</groupId>
    <artifactId>swagger-ui-layer</artifactId>
    <version>1.2</version>
</dependency>

配置Swagger(没有用到Swagger的直接跳过)

添加一个Swagger配置类,除了常规配置外,还需要增加一个令牌属性,使其可以在接口调用的时候传递令牌。

package com.vansen.zuanyunfei_backend.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.builders.ParameterBuilder;
import springfox.documentation.builders.PathSelectors;
import springfox.documentation.builders.RequestHandlerSelectors;
import springfox.documentation.schema.ModelRef;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.service.Parameter;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

import java.util.ArrayList;
import java.util.List;

/**
 * Swagger config class
 *
 * @author Nicemorning
 * @date Create in 1:58 2020/7/24 0024
 */
@Configuration
@EnableSwagger2
public class SwaggerConfig {
    @Bean
    public Docket createRestApi() {
        // Construct request parameters,put token into request header
        ParameterBuilder parameterBuilder = new ParameterBuilder();
        List<Parameter> parameters = new ArrayList<>();
        parameterBuilder.name("Authorization")
                .description("AuthorizationToken")
                .modelRef(new ModelRef("string"))
                .parameterType("header")
                .required(false)
                .build();
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                .apis(RequestHandlerSelectors.any())
                .paths(PathSelectors.any())
                .build()
                .globalOperationParameters(parameters);
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("title")
                .description("description")
                .version("1.0")
                .contact(new Contact("name", "url", "email"))
                .build();
    }
}

启动项目后,在浏览器中打开http://localhost:8080/api-docs.html即可查看Swagger的UI展示。

由于引入了SpringSecurity,但是没有对其进行配置。所以不出意外的话会进入SpringSecurity默认的登录页面。

在这里插入图片描述

编写SpringSecurity配置类

编写一个自定义的SpringSecurity配置了,继承WebSecurityConfigurerAdapter抽象类,该抽象类定义了一些默认的configure()方法,我们需要重写这些方法来实现自定义的安全逻辑。

@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        super.configure(auth);
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        super.configure(web);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        super.configure(http);
    }
}

其中常用的是configure(AuthenticationManagerBuilder auth)configure(HttpSecurity http),可以简单的理解为其分别针对的逻辑为认证授权。其中configure(WebSecurity web)主要用于对web请求做一些过滤操作。

配置不需要鉴权的路径

通常而言,静态资源、登录注册以及Swagger-ui是不需要鉴权的,可以让任何用户随意访问,我们只需要在configure(WebSecurity web)方法中添加ignore()配置即可。

@Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring()
                // static resources
                .antMatchers("/favicon.ico")
                .antMatchers("/hello")
                .antMatchers("/static/**")
                .antMatchers("**.js", "**.html", "**.css")
                // swagger
                .antMatchers("/v2/api-docs",
                        "/configuration/ui",
                        "/swagger-resources",
                        "/configuration/security",
                        "/swagger-ui.html",
                        "/webjars/**",
                        "/swagger-resources/configuration/ui",
                        "/swagger-ui.html").
                antMatchers("/api-docs.html");
    }

也可以直接在configure(HttpSecurity http)中使用permitAll()来实现。

antMatchers(HttpMethod.POST, "/**").permitAll()

配置鉴权逻辑

直接上代码。

@Override
protected void configure(HttpSecurity http) throws Exception {
    // 由于使用的是JWT,所以需要禁用掉CSRF
    http.cors().and().csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .httpBasic().authenticationEntryPoint(new CusAuthenticationEntryPoint())
            .and()
            .authorizeRequests()
            .anyRequest().authenticated()
            .and()
            .addFilter(new AuthenticationTokenFilter(authenticationManager()));
    http.headers().cacheControl();
}

其中cors()csrf()是CSRF(跨站请求伪造)和CORS(跨域资源共享)的意思,将其禁用掉可以开启跨域功能。sessionManagement是配置SpringSecurity的Session策略,由于使用无状态的JWT来做Token,所以这里将Session创建策略通过sessionCreationPolicy方法定义为STATELESS,即Spring Security不创建和使用Session。

关于Session创建策略的选项区别,可以直接查看源码注释。

/**
 * Specifies the various session creation policies for Spring Security.
 *
 * @author Luke Taylor
 * @since 3.1
 */
public enum SessionCreationPolicy {
	/** Always create an {@link HttpSession} */
	ALWAYS,
	/**
	 * Spring Security will never create an {@link HttpSession}, but will use the
	 * {@link HttpSession} if it already exists
	 */
	NEVER,
	/** Spring Security will only create an {@link HttpSession} if required */
	IF_REQUIRED,
	/**
	 * Spring Security will never create an {@link HttpSession} and it will never use it
	 * to obtain the {@link SecurityContext}
	 */
	STATELESS
}

回到刚才的配置中,authenticationEntryPoint配置的是一个自定义的CusAuthenticationEntryPoint处理类,还有一个是accessDeniedHandler。他们的作用分别为:

  1. AuthenticationEntryPoint 用来解决匿名用户访问无权限资源时的异常
  2. AccessDeineHandler 用来解决认证过的用户访问无权限资源时的异常

如果需要增加AccessDeineHandler的话,只需要将上面的语句改成:

// .httpBasic().authenticationEntryPoint(new CusAuthenticationEntryPoint())
// 注释掉原来的EntryPoint配置,使用下面的语句进行配置
.exceptionHandling().authenticationEntryPoint(new CusAuthenticationEntryPoint()).accessDeniedHandler(new CusAccessDeiedHandler())

往下继续读配置,会出现authorizeRequests方法,这个方法需要和anyRequestantMatchers配合使用,是用于指定路径是否需要鉴权的设置。鉴权的设置常用的有以下几种方法:

  1. permitAll() 允许无条件访问
  2. anonymous() 允许匿名访问
  3. hasRole() 只允许有特定权限的用户访问
  4. authenticated() 需要进行认证

最后一句addFilter方法可以添加自定义的认证过滤器,上方配置的authenticated会流转到这里所配置的过滤器中进行认证。

至此,需要留意几个关键点:

  1. 配置需要鉴权或不需要认证的URL路径
  2. 根据需要决定是否关闭跨域限制
  3. 添加自定义的异常处理(详见下文)
  4. 添加自定义的认证过滤器(详见下文)

暂时先把上方的配置注释掉,重启一下项目,再次进入http://localhost:8080/api-docs.html。此时可以看到已经能够正常进入页面,而不会被SpringSecurity所拦截了。

在这里插入图片描述

编写自定义异常处理类

上文有提到,异常处理类有两个,分别是:

  1. AuthenticationEntryPoint 用来解决匿名用户访问无权限资源时的异常
  2. AccessDeineHandler 用来解决认证过的用户访问无权限资源时的异常

接下来我们将对以上两个类进行自定义业务逻辑

AuthenticationEntryPoint

自定义一个CusAuthenticationEntryPoint类,需要实现AuthenticationEntryPoint接口,并添加@Component注解,将该类交给Spring进行管理。

@Component
@Slf4j
public class CusAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException e) throws IOException {
        log.error("未授权访问 路径:{} - {}", request.getRequestURI(), e.getMessage());
        ResMsg<Object> forbidden = ResMsg.errorMsg(request.getHeader("Authorization") == null ?
                AuthExceptionGroup.FORBIDEN.getMessage() :
                AuthExceptionGroup.BEARER_TOKEN_ERROR.getMessage());
        String s = objectMapper.writeValueAsString(forbidden);
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        response.getWriter().write(s);
        response.flushBuffer();
    }
}

这是我的配置示例,请根据项目实际需求自行改动。

AccessDeineHandler

自定义一个CusAccessDeniedHandler类,需要实现AccessDeniedHandler接口,该类同样需要交给Spring进行管理。

@Component
public class CusAccessDeniedHandler implements AccessDeniedHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        if (!response.isCommitted()) {
            ResMsg<Object> forbidden = ResMsg.errorMsg(AuthExceptionGroup.FORBIDEN.getMessage());
            String s = objectMapper.writeValueAsString(forbidden);
            response.getWriter().write(s);
            response.flushBuffer();
        }
    }
}

编写完成后,将这两个自定义的异常处理类添加到配置中,此时配置内容为:

http.cors().and().csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
//                .httpBasic().authenticationEntryPoint(new CusAuthenticationEntryPoint())
                .exceptionHandling()
                .authenticationEntryPoint(new CusAuthenticationEntryPoint())
                .accessDeniedHandler(new CusAccessDeniedHandler())
                .and()
                .authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .addFilter(new AuthenticationTokenFilter(authenticationManager()));
        http.headers().cacheControl();

编写自定义的认证过滤器

编写一个AuthenticationTokenFilter类,需要继承BasicAuthenticationFilter,该类不需要交给Spring管理。

BasicAuthenticationFilter中定义了默认的doFilterInternal过滤逻辑,我们可以重写它来实现自定义的业务逻辑。

public class AuthenticationTokenFilter extends BasicAuthenticationFilter {

    public AuthenticationTokenFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String tokenHeader = request.getHeader(ConstantParams.TOKEN_HEADER);
        // 如果请求头中没有Authorization信息则直接放行了
        if (tokenHeader == null || !tokenHeader.startsWith(ConstantParams.TOKEN_PREFIX)) {
            chain.doFilter(request, response);
            return;
        }
        // 如果请求头中有token,则进行解析,并且设置认证信息
        SecurityContextHolder.getContext().setAuthentication(getAuthentication(tokenHeader));
        super.doFilterInternal(request, response, chain);
    }

    private UsernamePasswordAuthenticationToken getAuthentication(String tokenHeader) {
        String token = tokenHeader.replace(ConstantParams.TOKEN_PREFIX, "").trim();
        String username = JwtUtil.getUsername(token);
        List<String> roles = JwtUtil.getUserRole(token);
        Collection<GrantedAuthority> authorities = new HashSet<>();
        if (username == null) {
            return null;
        }
        if (roles != null) {
            for (String role : roles) {
                authorities.add(new SimpleGrantedAuthority(role));
            }
        }
        return new UsernamePasswordAuthenticationToken(
                CurrentUser.ofId(1)
                        .setUsername(ConstantParams.USERNAME)
                        .setPassword(ConstantParams.PASSWORD)
                        .setAuthorities(authorities),
                null,
                authorities);
    }
}

其中UsernamePasswordAuthenticationToken是SpringSecurity默认的用户信息实体逻辑,整个自定义的认证逻辑可以根据自身业务需要进行改动。核心的流程其实就是doFilterInternal方法中所定义的内容。

本示例中,用户的请求如果需要进行认证的,将会进入到doFilterInternal方法中,在方法中取得请求的头部信息,如果没有Token的,直接放行。如果有头部的,则调用getAuthentication方法进行Token信息解析,用以取得当前访问的用户身份和权限范围。

doFilterInternal方法中,对于没有Token的将直接return,这一步的return会直接结束当前方法,继续执行调用者的逻辑。我们可以通过断点逐步调试的方式跟踪步骤,但是这里直接给出结论,return后会执行到刚才所自定义的CusAuthenticationEntryPoint中,也就是说其出发了匿名用户访问无权限资源时的异常。

上面的代码中,有一段

CurrentUser.ofId(1)
    .setUsername(ConstantParams.USERNAME)
    .setPassword(ConstantParams.PASSWORD)
    .setAuthorities(authorities)

用户的权限信息正常来说是通过数据库获得的,但是在此示例项目中,我直接将用户名和密码写在了ConstantParams类中,只用于做示范。

上方代码中涉及到了JwtUtil工具类,JWT的工具类百度上有一大堆。在本文的最后也会附上我所使用的JWT工具类源码,这里暂时跳过不讨论。

至此我们已经完成了拦截配置认证逻辑自定义异常处理自定义。还缺最后一步,即授权

配置自定义授权逻辑

在SpringSecurity配置类中的configure(AuthenticationManagerBuilder auth)方法内,添加自定义的授权业务类。

@Autowired
private CusUserDetailsService userDetailsService;

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

编写自定义授权逻辑类

SpringSecurity默认使用的是UserDetailsService授权业务类,如果没有什么特别高级的定制需求,只需要继承这个类重写一下接口即可。

自定义CusUserDetailsService接口,继承UserDetailsService

public interface CusUserDetailsService extends UserDetailsService {
}

自定义CusUserDetailsServiceImpl实现CusUserDetailsService,实现loadUserByUsername方法完成授权的业务自定义。通常情况下授权需要比对用户名密码等信息,是通过数据库获得的,在此我直接将用户名和密码写在了ConstantParams类中。

@Service
@Slf4j
public class CusUserDetailsServiceImpl implements CusUserDetailsService
{
    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        UserDetails userDetails = null;
        try {
            if (s.equals(ConstantParams.USERNAME)) {
                Set<GrantedAuthority> authorities = new HashSet<>();
                authorities.add(new SimpleGrantedAuthority("ALL"));
                userDetails = new User(ConstantParams.USERNAME, ConstantParams.PASSWORD, authorities);
            } else {
                String message = "Username not corrected";
                log.error(message);
                throw new UsernameNotFoundException(message);
            }
        } catch (Exception e) {
            log.error(e.getMessage());
        }
        return userDetails;
    }
}

其中的UserDetails是SpringSecurity自带的用户信息类,默认实现是User,其中包含以下信息,如果没有什么特别要求的话,直接使用就足够了。

public User(String username, String password, Collection<? extends GrantedAuthority> authorities) {
        this(username, password, true, true, true, true, authorities);
    }

public User(String username, String password, boolean enabled, boolean accountNonExpired, boolean credentialsNonExpired, boolean accountNonLocked, Collection<? extends GrantedAuthority> authorities) {
    if (username != null && !"".equals(username) && password != null) {
        this.username = username;
        this.password = password;
        this.enabled = enabled;
        this.accountNonExpired = accountNonExpired;
        this.credentialsNonExpired = credentialsNonExpired;
        this.accountNonLocked = accountNonLocked;
        this.authorities = Collections.unmodifiableSet(sortAuthorities(authorities));
    } else {
        throw new IllegalArgumentException("Cannot pass null or empty values to constructor");
    }
}

需要注意的是,loadUserByUsername(String s)中传入的s为用户登录或其他业务操作传入的用户名,我们可以通过用户名去数据库中查询该用户的密码,进行比对来决定接下来的业务如何处理。

Set<GrantedAuthority> authorities = new HashSet<>();
authorities.add(new SimpleGrantedAuthority("ALL"));

这一段代码则是将权限范围添加到该用户的UserDetails信息中,之后的认证也是从UserDetails获取该List来判断用户是否具有某项操作的权限。

至此,整个SpringSecurity的配置认证授权异常处理都已经实现完成,一般情况下这些内容已经足够实现项目丰富的权限管控要求了,如果还不够用的话,我们还可以通过实现AuthenticationProvider来自定义更多的认证模式,通过配置好的认证请求链中的顺序来进行更复杂的验证。本文未涉及到多认证模式的内容,需要的可以自行百度相关资料。

AuthenticationProvider和AuthenticationFilter方式的区别

AuthenticationProvider的默认实现是DaoAuthenticationProvider
AuthenticationProvider作为授权方式提供者,用于判断授权有效性,用户有效性,在判断用户是否有效性时,它依赖于UserDetailsService实例,开发人员可以自定义UserDetailsService的实现。

  1. additionalAuthenticationChecks 方法校验密码有效性
  2. retrieveUser 方法根据用户名获取用户
  3. createSuccessAuthentication 完成授权持久化

AuthenticationFilter的默认实现是UsernamePasswordAuthenticationFilter
AuthenticationFilter作为授权过滤器,开发人员可以自定义它的业务逻辑,并把它添加到默认过滤器前或者后去执行,主要用来到授权的持久化,它可以从请求上下文中获取你的username,password等信息,然后去判断它是否符合规则,最后通过authenticate方法去授权。默认的UsernamePasswordAuthenticationFilter过滤器,主要判断请求方式是否为post,并且对username和password进行了默认值的处理,总之,在这个过滤器里不会涉及到具体业务。

启动项目,检查是否正常运行

写完上面所提到的所有配置后,我们可以启动一下项目,检查一下是否能够正常启动。

在这里插入图片描述

通过上面的配置,项目正常启动。接下来我们需要写几个测试接口,看看具体的配置是否起到了作用。

编写测试接口

编写测试Controller

/**
 * @author Nicemorning
 * @date Create in 16:40 2020/7/26 0026
 */
@Api(tags = "Demo apis")
@RestController
@RequestMapping("demo")
public class DemoController {
    @ApiOperation("Login get token")
    @PostMapping("login")
    public String login() {
        // 需要生成Authentication对象,并将 Authentication 绑定到 SecurityContext,示例项目中没有编写这一部分
        List<String> roles = new ArrayList<>();
        roles.add(ConstantParams.ROLE_CLAIMS);
        return JwtUtil.createToken(ConstantParams.USERNAME, roles);
    }

    @ApiOperation("auth")
    @GetMapping("auth")
    public String auth(Integer id) {
        return "Auth id: " + id;
    }
}

其中包含了两个接口:登录接口(不需要权限)和鉴权接口(需要携带Token)

编写WebSecurity配置

将登录接口配置为不需要鉴权。

.antMatchers("/demo/login")

启动后浏览器打开http://localhost:8080/api-docs.html尝试。

在这里插入图片描述

不登录的情况下请求鉴权接口


无法访问,被拦截。

请求登录接口获得Token

在这里插入图片描述
可以访问,并且获得Token

携带Token请求鉴权接口

在头部中添加Token重新尝试鉴权接口

在这里插入图片描述

响应正常。

至此SpringBoot结合SpringSecurity使用JWT实现鉴权和认证已经全部完成,当前项目结构如图所示:

在这里插入图片描述

Swagger 跨域问题解决

我在本地调试时没有遇到,但是部署到服务器后出现了跨域的问题,解决办法如下:

定义一个CORS配置类,该类标记为配置类并实现WebMvcConfigurer接口。即可解决

@Configuration
public class CorsFilterConfig implements WebMvcConfigurer {
    @Bean
    public CorsFilter corsFilter() {
        final UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource = new UrlBasedCorsConfigurationSource();
        final CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.setAllowCredentials(true);
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        urlBasedCorsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration);
        return new CorsFilter(urlBasedCorsConfigurationSource);
    }
}

JWT工具类

public class JwtUtil implements Serializable {
    private static final long serialVersionUID = -649173465054267244L;

    private static final long EXPIRE_TIME = 24 * 60 * 60 * 1000;

    /**
     * description: 创建Token
     *
     * @param username 用户名
     * @return java.lang.String
     */
    public static String createToken(String username, List<String> roles) {
        HashMap<String, Object> map = new HashMap<>();
        map.put(ConstantParams.ROLE_CLAIMS, roles);
        return Jwts.builder()
                .signWith(SignatureAlgorithm.HS512, ConstantParams.SECRET)
                .setClaims(map)
                .setIssuer(ConstantParams.ISS)
                .setId(String.valueOf("1"))
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME))
                .compact();
    }

    public static String getIdentify(String token) {
        return getTokenBody(token).getSubject();
    }

    public static String getUsername(String token) {
        return getTokenBody(token).getSubject();
    }

    public static Integer getId(String token) {
        return Integer.parseInt(getTokenBody(token).getId());
    }

    /**
     * 获取用户角色
     *
     * @param token token
     * @return 用户角色
     */
    @SuppressWarnings("unchecked")
    public static List<String> getUserRole(String token) {
        return (List<String>) getTokenBody(token).get(ConstantParams.ROLE_CLAIMS);
    }

    /**
     * 判断Token是否过期
     *
     * @param token token
     * @return boolean
     */
    public static boolean isExpiration(String token) {
        return getTokenBody(token).getExpiration().before(new Date());
    }

    /**
     * 解析TOKEN
     *
     * @param token token
     * @return io.jsonwebtoken.Claims
     */
    private static Claims getTokenBody(String token) {
        return Jwts.parser()
                .setSigningKey(ConstantParams.SECRET)
                .parseClaimsJws(token)
                .getBody();
    }
}
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值