spring boot + spring security + Jwt

spring security

1、简介

spring security 是企业应用系统的权限管理框架,应用安全性包括用户认证和用户授权两部分,用户认证一般要求用户提供用户名和密码,系统通过校验用户名和密码完成认证过程,用户授权是验证某个用户是否有权限执行某个操作。在一个系统中,不同用户所具有的权限是不同的。spring security 的主要核心功能为认证和授权。

2、原理

1.WebAsyncManagerIntegrationFilter:将 Security 上下文与 Spring Web 中用于处理异步请求映射的 WebAsyncManager 进行集成。

2.SecurityContextPersistenceFilter:在每次请求处理之前将该请求相关的安全上下文信息加载到 SecurityContextHolder 中,然后在该次请求处理完成之后,将 SecurityContextHolder 中关于这次请求的信息存储到一个“仓储”中,然后将 SecurityContextHolder 中的信息清除,例如在Session中维护一个用户的安全信息就是这个过滤器处理的。

3.HeaderWriterFilter:用于将头信息加入响应中。

4.CsrfFilter:用于处理跨站请求伪造。

5.LogoutFilter:用于处理退出登录。

6.UsernamePasswordAuthenticationFilter:用于处理基于表单的登录请求,从表单中获取用户名和密码。默认情况下处理来自 /login 的请求。从表单中获取用户名和密码时,默认使用的表单 name 值为 username 和 password,这两个值可以通过设置这个过滤器的usernameParameter 和 passwordParameter 两个参数的值进行修改。

7.DefaultLoginPageGeneratingFilter:如果没有配置登录页面,那系统初始化时就会配置这个过滤器,并且用于在需要进行登录时生成一个登录表单页面。

8.BasicAuthenticationFilter:检测和处理 http basic 认证。

9.RequestCacheAwareFilter:用来处理请求的缓存。

10.SecurityContextHolderAwareRequestFilter:主要是包装请求对象request。

11.AnonymousAuthenticationFilter:检测 SecurityContextHolder 中是否存在 Authentication 对象,如果不存在为其提供一个匿名 Authentication。

12.SessionManagementFilter:管理 session 的过滤器

13.ExceptionTranslationFilter:处理 AccessDeniedException 和
AuthenticationException 异常。

14.FilterSecurityInterceptor:可以看做过滤器链的出口。
RememberMeAuthenticationFilter:当用户没有登录而直接访问资源时, 从 cookie 里找出用户的信息, 如果 Spring Security 能够识别出用户提供的remember me cookie, 用户将不必填写用户名和密码, 而是直接登录进入系统,该过滤器默认不开启。

3、认证流程

在这里插入图片描述

4 主要依赖

		<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
            <version>2.3.1.RELEASE</version>
        </dependency>
		<dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>

5 代码

1 .配置类继承WebSecurityConfigurerAdapter

public class SnailSecurityAutoConfig extends WebSecurityConfigurerAdapter {
    private final CustomerSecurityProperties customerSecurityProperties;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final JwtAuthorizationTokenFilter jwtAuthorizationTokenFilter;
    private final CustomerUsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter;
    private final CustomerFilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource;
    private final CustomerAccessDecisionManager accessDecisionManager;

    public SnailSecurityAutoConfig(JwtAuthorizationTokenFilter jwtAuthorizationTokenFilter, CustomerSecurityProperties customerSecurityProperties, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtAccessDeniedHandler jwtAccessDeniedHandler, CustomerUsernamePasswordAuthenticationFilter usernamePasswordAuthenticationFilter, CustomerFilterInvocationSecurityMetadataSource filterInvocationSecurityMetadataSource, CustomerAccessDecisionManager accessDecisionManager) {
        this.customerSecurityProperties = customerSecurityProperties;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
        this.jwtAuthorizationTokenFilter = jwtAuthorizationTokenFilter;
        this.usernamePasswordAuthenticationFilter = usernamePasswordAuthenticationFilter;
        this.filterInvocationSecurityMetadataSource = filterInvocationSecurityMetadataSource;
        this.accessDecisionManager = accessDecisionManager;
    }

    /**
     * 忽略拦截url或静态资源文件夹 - web.ignoring(): 会直接过滤该url - 将不会经过Spring Security过滤器链
     * http.permitAll(): 不会绕开springsecurity验证,相当于是允许该路径通过
     *
     * @param web
     * @throws Exception
     */
    @Override
    public void configure(WebSecurity web)  {
        //放行swagger
        web.ignoring().antMatchers(HttpMethod.GET,
                "/v2/api-docs",
                "/swagger-resources",
                "/swagger-resources/**",
                "/configuration/ui",
                "/configuration/security",
                "/swagger-ui.html/**",
                "/webjars/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry authorizeRequests = http.authorizeRequests();
        for (CustomerSecurityProperties.InterceptPath interceptPath : customerSecurityProperties.getInterceptPath()){
            authorizeRequests.antMatchers(interceptPath.getUrl()).hasAnyRole(interceptPath.getRole().split("[,;]]"));
        }
        http
                .csrf().disable() //关闭spring自带的csrf
                .authorizeRequests()  //验证请求
                .antMatchers(customerSecurityProperties.getPublicPath()).permitAll()  //配置放行url
                .anyRequest().authenticated() //其余需要验证
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O object) {
                        object.setAccessDecisionManager(accessDecisionManager);
                        object.setSecurityMetadataSource(filterInvocationSecurityMetadataSource);
                        return object;
                    }
                }) //鉴权配置
                .and()
                .logout() //登出
                .logoutSuccessHandler(new CustomerLogoutSuccessHandler()) //成功登出handler
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint) //配置匿名访问无权限url 的handler
                .accessDeniedHandler(jwtAccessDeniedHandler)  //配置登录后访问无权限url 的handler
                .and() 
                .sessionManagement() //session管理
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS); //禁用session

        http.addFilterAt(usernamePasswordAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); //登录过滤器
        http.addFilterBefore(jwtAuthorizationTokenFilter, CustomerUsernamePasswordAuthenticationFilter.class);//token过滤验证

    }

}
	configure(WebSecurity web) 配置忽略拦截将不会经过Spring Security过滤器链,
	http.permitAll(): 不会绕开springsecurity验证,相当于是允许该路径通过

2.CustomerSecurityProperties 自定义配置

@Data
@Configuration
@ConfigurationProperties(prefix = "snail.security")
public class CustomerSecurityProperties {
    private Jwt jwt;
    private InterceptPath[] interceptPath = null;
    private String[] publicPath = {
            "/login",
    };

    @Data
    public static class Jwt{
        String subject = "uAuthentication";
        String header = "Authorization";
        String tokenStartWith = "Bearer";
        String base64Secret = "jdgdjguadgliwiweiuqiahdiwuhdiahdiwuqidihshdkhiwqheiuwdwhoqdwqdjgtfghdg";
        Long tokenValidityInSeconds = 7200000L;
    }

    @Data
    public static class InterceptPath{
        private String url;
        /**
         *角色用,或者;分割
         */
        private String role;
    }
}

3.自定义LogoutSuccessHandler

public class CustomerLogoutSuccessHandler implements LogoutSuccessHandler {

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        response.setContentType("Application/json;charset=utf-8");
        response.getWriter().write(JSON.toJSONString(ResponseVO.success()));
//        authentication.setAuthenticated(false);
    }
}

4.自定义AccessDeniedHandler

public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @SneakyThrows
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpConstance.STATUS_DENY);
        ResponseUtil.writeToResponse(response, JSON.toJSONString(ResponseVO.error(HttpConstance.STATUS_DENY,"禁止访问")));
    }
}

5.自定义AuthenticationEntryPoint

public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    private static  final Logger log= LoggerFactory.getLogger(JwtAuthenticationEntryPoint.class);
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
        log.error("JwtAuthenticationEntryPoint{}","没有凭证");
        httpServletResponse.setCharacterEncoding("UTF-8");
		httpServletResponse.setContentType("application/json;charset=UTF-8");
		httpServletResponse.setStatus(HttpConstance.STATUS_NO_AUTH);  
		PrintWriter pWriter = httpServletResponse.getWriter();
		pWriter.write(JSON.toJSONString(ResponseVO.error(HttpConstance.STATUS_NO_AUTH,"没有凭证,验证失败")));
		pWriter.flush();
		pWriter.close();
    }
}

6. 用户名密码登录验证

1.自定义 AuthenticationProvider验证用户名密码,仿照 DaoAuthenticationProvider,当进行用户名密码验证时,AuthenticationManager 事实上是调用 CustomerAuthenticationProvider 进行验证

@Slf4j
@Data
public class CustomerAuthenticationProvider implements AuthenticationProvider {

    private PasswordEncoder passwordEncoder;

    /**
     * The password used to perform
     * {@link PasswordEncoder#matches(CharSequence, String)} on when the user is
     * not found to avoid SEC-2056. This is necessary, because some
     * {@link PasswordEncoder} implementations will short circuit if the password is not
     * in a valid format.
     */
    private volatile String userNotFoundEncodedPassword;

    private UserDetailsService userDetailsService;

    private UserDetailsPasswordService userDetailsPasswordService;

    private JwtTokenUtil jwtTokenUtil;

    public CustomerAuthenticationProvider(PasswordEncoder passwordEncoder, UserDetailsService userDetailsService , JwtTokenUtil jwtTokenUtil) {
        setJwtTokenUtil(jwtTokenUtil);
        setPasswordEncoder(passwordEncoder);
        setUserDetailsService(userDetailsService);
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = (String) authentication.getPrincipal();
        String password = (String) authentication.getCredentials();
        NormalUser userDetails = (NormalUser) userDetailsService.loadUserByUsername(username);

        log.debug(String.format("username:%s,password:%s",username,password));
        //验证密码
        Boolean isValid = PasswordUtil.isValid(password, userDetails.getPassword(), username, getPasswordEncoder());
        if (!isValid) {
            throw new BadCredentialsException("密码错误!");
        }
        //前后端分离,token生成
        String token = jwtTokenUtil.createToken(userDetails.getUsername(), userDetails.getAuthorities().toString());
        //String token = jwtTokenUtil.generateToken(userDetails);
        userDetails.setToken(token);
        return new UsernamePasswordAuthenticationToken(userDetails, password, userDetails.getAuthorities());
    }

    //支持的验证方式是用户名密码验证及其子类的验证方式
    @Override
    public boolean supports(Class<?> authentication) {
        return CustomerUsernamePasswordAuthenticationFilter.class.isAssignableFrom(authentication);
    }

}

2. 自定义AuthenticationManager 选择合适的认证方式进行认证

@Slf4j
public class CustomerAuthenticationManager implements AuthenticationManager {
    private CustomerAuthenticationProvider authenticationProvider;

    public CustomerAuthenticationManager(CustomerAuthenticationProvider authenticationProvider) {
        this.authenticationProvider = authenticationProvider;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        AuthenticationProvider authenticationProvider = getAuthenticationProvider();
        Authentication result = authenticationProvider.authenticate(authentication);
        if(result != null)log.info("CustomerAuthenticationManager{}",result.toString());
        else{
            log.info("CustomerAuthenticationManager{}","Authentication failed");
        }
        if (Objects.nonNull(result)){
            return result;
        }
       throw new ProviderNotFoundException("Authentication failed");
    }

    public CustomerAuthenticationProvider getAuthenticationProvider() {
        return authenticationProvider;
    }

    public void setAuthenticationProvider(CustomerAuthenticationProvider authenticationProvider) {
        this.authenticationProvider = authenticationProvider;
    }
}

3. 自定义CustomerUsernamePasswordAuthenticationFilter 仿写 UsernamePasswordAuthenticationFilter

@Slf4j
public class CustomerUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
    private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
    private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
    private boolean postOnly = true;
    private CustomerSecurityProperties customerSecurityProperties;
    private JwtTokenUtil jwtTokenUtil;

    public CustomerUsernamePasswordAuthenticationFilter(CustomerAuthenticationManager authenticationManager, CustomerSecurityProperties customerSecurityProperties, JwtTokenUtil jwtTokenUtil) {
        super(new AntPathRequestMatcher("/login", "PUT"));
        setAuthenticationManager(authenticationManager);
        setCustomerSecurityProperties(customerSecurityProperties);
        setJwtTokenUtil(jwtTokenUtil);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (postOnly && !request.getMethod().equalsIgnoreCase("PUT")) {
            throw new AuthenticationServiceException(
                    "Authentication method not supported: " + request.getMethod());
        }
        log.info("CustomerUsernamePasswordAuthenticationFilter {}", " inter attemptAuthentication");
        String username = obtainUsername(request);
        String password = obtainPassword(request);

        if (username == null) {
            username = "";
        }
        if (password == null) {
            password = "";
        }
        username = username.trim();
        log.debug("CustomerUsernamePasswordAuthenticationFilter{}", String.format("username:%s,password:%s", username, password));
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                username, password, null);

        // Allow subclasses to set the "details" property
        authRequest.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }
    @SneakyThrows
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        NormalUser userDetail = (NormalUser) authResult.getPrincipal();
        Collection<? extends GrantedAuthority> authorities = authResult.getAuthorities();
        //String token = jwtTokenUtil.createToken(user.getUsername(), authorities.toString());
        log.info("successfulAuthentication  {}",userDetail.getToken());
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=utf-8");
        response.setHeader("token", customerSecurityProperties.getJwt().getHeader() + userDetail.getToken());
        ResponseUtil.writeToResponse(response, JSON.toJSONString(ResponseVO.success("登录成功")));
    }
    @SneakyThrows
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        //super.unsuccessfulAuthentication(request, response, failed);
        String responseData = "";
        if (failed instanceof AccountExpiredException) {
            responseData = "账号过期";
        } else if (failed instanceof BadCredentialsException) {
            responseData = "账号或密码错误";
        } else if (failed instanceof CredentialsExpiredException) {
            responseData = "密码过期";
        } else if (failed instanceof DisabledException) {
            responseData = "账号不可用";
        } else if (failed instanceof LockedException) {
            responseData = "账号被锁定";
        } else if (failed instanceof InternalAuthenticationServiceException) {
            responseData = "用户不存在";
        } else {
            responseData = "未知错误";
        }
        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json;charset=utf-8");
        ResponseUtil.writeToResponse(response, JSON.toJSONString(ResponseVO.success("登录失败" + responseData)));
    }

    /**
     * Enables subclasses to override the composition of the password, such as by
     * including additional values and a separator.
     * <p>
     * This might be used for example if a postcode/zipcode was required in addition to
     * the password. A delimiter such as a pipe (|) should be used to separate the
     * password and extended value(s). The <code>AuthenticationDao</code> will need to
     * generate the expected password in a corresponding manner.
     * </p>
     *
     * @param request so that request attributes can be retrieved
     * @return the password that will be presented in the <code>Authentication</code>
     * request token to the <code>AuthenticationManager</code>
     */
    @Nullable
    protected String obtainPassword(HttpServletRequest request) {
        return request.getParameter(passwordParameter);
    }

    /**
     * Enables subclasses to override the composition of the username, such as by
     * including additional values and a separator.
     *
     * @param request so that request attributes can be retrieved
     * @return the username that will be presented in the <code>Authentication</code>
     * request token to the <code>AuthenticationManager</code>
     */
    @Nullable
    protected String obtainUsername(HttpServletRequest request) {
        return request.getParameter(usernameParameter);
    }

    /**
     * Sets the parameter name which will be used to obtain the username from the login
     * request.
     *
     * @param usernameParameter the parameter name. Defaults to "username".
     */
    public void setUsernameParameter(String usernameParameter) {
        Assert.hasText(usernameParameter, "Username parameter must not be empty or null");
        this.usernameParameter = usernameParameter;
    }

    /**
     * Sets the parameter name which will be used to obtain the password from the login
     * request..
     *
     * @param passwordParameter the parameter name. Defaults to "password".
     */
    public void setPasswordParameter(String passwordParameter) {
        Assert.hasText(passwordParameter, "Password parameter must not be empty or null");
        this.passwordParameter = passwordParameter;
    }

    /**
     * Defines whether only HTTP POST requests will be allowed by this filter. If set to
     * true, and an authentication request is received which is not a POST request, an
     * exception will be raised immediately and authentication will not be attempted. The
     * <tt>unsuccessfulAuthentication()</tt> method will be called as if handling a failed
     * authentication.
     * <p>
     * Defaults to <tt>true</tt> but may be overridden by subclasses.
     */
    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }

    public final String getUsernameParameter() {
        return usernameParameter;
    }

    public final String getPasswordParameter() {
        return passwordParameter;
    }


    public AuthenticationManager getAuthenticationManager() {
        return super.getAuthenticationManager();
    }

    @Autowired
    public void setAuthenticationManager(CustomerAuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }

    public CustomerSecurityProperties getCustomerSecurityProperties() {
        return customerSecurityProperties;
    }

    public void setCustomerSecurityProperties(CustomerSecurityProperties customerSecurityProperties) {
        this.customerSecurityProperties = customerSecurityProperties;
    }

    public JwtTokenUtil getJwtTokenUtil() {
        return jwtTokenUtil;
    }

    public void setJwtTokenUtil(JwtTokenUtil jwtTokenUtil) {
        this.jwtTokenUtil = jwtTokenUtil;
    }
}

7. 登陆后鉴权配置类

1 自定义FilterInvocationSecurityMetadataSource,查询url的权限信息

public class CustomerFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
    private  final IUrlService iUrlService ;

    public CustomerFilterInvocationSecurityMetadataSource(IUrlService iUrlService) {
        this.iUrlService = iUrlService;
    }

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        Map<RequestMatcher,Collection<ConfigAttribute>> allRoleResource = new HashMap<>();
        for (NormalUrl normalUrl: iUrlService.loadAll() ){
            allRoleResource.put(new AntPathRequestMatcher(normalUrl.getUrl()), SecurityConfig.createList(normalUrl.getRole().split("[,;]")));
        }

        FilterInvocation filterInvocation = (FilterInvocation)object;
        HttpServletRequest request = filterInvocation.getRequest();

        for (Map.Entry<RequestMatcher,Collection<ConfigAttribute>> entry : allRoleResource.entrySet()){
            if (entry.getKey().matches(request)){
                return entry.getValue();
            }
        }
        return null;
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }
}

2 自定义决策管理器 CustomerAccessDecisionManager 检查 CustomerFilterInvocationSecurityMetadataSource 查询出的权限configAttributes进行决策

@Slf4j
public class CustomerAccessDecisionManager implements AccessDecisionManager {
@Override
public void decide(Authentication authentication, Object object, Collection configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
Iterator iterator = configAttributes.iterator();
if(!iterator.hasNext()){
return;
}
if(authentication == null){
throw new DeniedAccessException( “authentication 为空,没有访问权限”,“CustomerAccessDecisionManager”);
}

    while (iterator.hasNext()){
        ConfigAttribute configAttribute = iterator.next();
        String needCode = configAttribute.getAttribute();
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        for (GrantedAuthority grantedAuthority : authorities){
            log.info("AccessDecisionManager{} {}",grantedAuthority.getAuthority(),"ROLE_"+needCode);
            if(StringUtils.equals(grantedAuthority.getAuthority(),"ROLE_"+needCode)){
                return;
            }
        }
    }

    throw new DeniedAccessException( "authentication 没有访问权限","CustomerAccessDecisionManager");
}

@Override
public boolean supports(ConfigAttribute attribute) {
    return false;
}

@Override
public boolean supports(Class<?> clazz) {
    return FilterInvocation.class.isAssignableFrom(clazz);
}

}

其他工具类

1.处理密码加密

public class PasswordUtil {

    public static Boolean isValid(String password, String realPassword, String salt, PasswordEncoder passwordEncoder) {

        if (password == null || realPassword == null) {
            throw new IllegalArgumentException("密码参数错误");
        }

        if (passwordEncoder == null || passwordEncoder instanceof NoOpPasswordEncoder) {
            return Objects.equals(realPassword, password);
        }

        return passwordEncoder.matches(password + salt ,realPassword);
    }

}

2. 处理信息返回

public class ResponseUtil {
    public static void writeToResponse(HttpServletResponse response, Object message) throws IOException, NoSuchMethodException {
        if(response == null || message == null){
            throw new OperationFailedException("空异常","httpUtil");
        }
        PrintWriter writer = response.getWriter();
        try {
            if(message instanceof String ){
                writer.write((String)message);
            }else if(message instanceof Integer){
                writer.write((Integer) message);
            }else if(message instanceof char[]){
                writer.write((char[])message);
            }else{
                writer.write(message.toString());
            }
            writer.flush();
        }catch (Exception e){
            e.printStackTrace();
            throw  e;
        }finally {
            writer.close();
        }

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值