Spring Security使用和源码解析

本文详细介绍了Spring Security的认证过程,包括UsernamePasswordAuthenticationToken的生成、AuthenticationManager的认证处理以及SecurityContextHolder的核心组件。还探讨了Spring Security的核心过滤器链,如SecurityContextPersistenceFilter、FilterSecurityInterceptor等。此外,文章提到了如何开启Spring Security,处理多个安全配置,以及如何将自定义过滤器纳入Spring Security的过滤器链中。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一个web工程,如果涉及到私人信息或不可公开的资源,则必然要对访问者做过滤和认证,访问者只能获取跟自己相关或有权限访问的信息,这就是我们所熟知的登陆。简单的登陆通常是这样实现的:提供一个登陆接口,和一个过滤器。登陆接口用来用户名和密码校验,并将用户信息保存在http session中。过滤器则用来拦截所有未经授权的访问。

@RestController
@RequestMapping("/login")
public class LoginController {

    public void login(HttpServletRequest request, HttpServletResponse response) {
        String json;
        PrintWriter out = response.getWriter();
        String user = request.getParameter("usr");
        String pwd= request.getParameter("password");      
        if (pwd.equals(getPasswordByUserName(user))) {
            request.getSession().setAttribute("user", user);
            json = "{\"success\":true}";
        }
        else {
            json = "{\"success\":false}";
        }
        out.print(json);
        out.flush;
        out.close;
    }

}
public class AuthenticationFilter implements Filter {
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        if (!httpServletRequest.getServletPath().equals("/login")) {
            HttpSession session = ((HttpServletRequest) request).getSession(false);
            if (session == null || session.getAttribute("user") == null) {
                ((HttpServletResponse) response).setStatus(401);
                return;
            }
        }
        chain.doFilter(request, response);
    }

   
}

Spring security 作为一个强大且可高度定制化的认证和访问控制框架,是安全化 spring 应用的实际标准。它为Java应用提供认证和授权的功能,用户能够轻易地扩展以适应自己的需求。

Spring security 认证的过程


  1. 用户向url /login Post登陆请求,该请求从由loginPage指定的前台页面发送,携带表单参数username和password。(/login 是spring security 4 默认拦截的认证url,它并不指向任何物理资源,在spring security 4之前,该url是/j_spring_security_check,参数是j_username和j_password;可以通过login-processing-url自定义认证url。事实上,我们还可以绕过鉴权过滤器,直接向自定义的登陆rest接口post登陆请求,只要我们引入了spring security相关的包,所有认证的逻辑将会在request.login(username, password)里完成,如下所示)。
        @RequestMapping(value = "/my_login", method = RequestMethod.POST)
        public void login(@RequestParam("username") String username,
                          @RequestParam("password") String password,
                          HttpServletRequest request,
                          HttpServletResponse response) throws DmsException {
            try {
                request.login(username, password);
            }
            catch (ServletException e) {
                throw new MyException("0000", e);
            }
    
            request.getSession();
        }

     

  2. 根据传入的 username 和 password 生成 UsernamePasswordAuthenticationToken,AuthenticationManager接口的 authenticate 方法将对生成的Authentication进行认证处理。AuthenticationManager的默认实现类是ProviderManager,ProviderManager将认证委托给AuthenticatiionProvider处理。认证的本质是将Authentication和UserDetails中的用户信息进行比较。UserDetails包装的是拥有权限的用户信息,通常是从数据库中获取。
  3. UsernamePasswordAuthenticationFilter 过滤器在登陆成功后会通过SecurityContextHolder.getContext().setAuthentication() 将认证信息Authentication绑定到SecurityContext。
  4. 下一次请求时,过滤器链头的 SecurityContextPersistenceFilter 会从 Session 中获取已登陆用户信息并生成 Authentication,并将 认证信息对象绑定到 SecurityContext。如果是对需要权限的接口或资源的请求,Spring Security将从SecurityContext中获取用户的权限来判定是否可以访问该接口或资源。

Spring Security的核心组件


  • SecurityContextHolder,提供了一系列静态方法,代理一个SecurityContextHolderStrategy实例,方便用户对安全上下文的访问和设置。其初始化方法在静态块中被调用
        static {
    		initialize();
    	}    
        private static void initialize() {
    		if (!StringUtils.hasText(strategyName)) {
    			// Set default
                // 如果未设置strategyName属性,则默认使用ThreadLocal策略
    			strategyName = MODE_THREADLOCAL;
    		}
    
    		if (strategyName.equals(MODE_THREADLOCAL)) {
                // 线程局部变量安全持有策略,每个线程分别持有各自的SecurityContext
    			strategy = new ThreadLocalSecurityContextHolderStrategy();
    		}
    		else if (strategyName.equals(MODE_INHERITABLETHREADLOCAL)) {
                // 可继承线程局部变量安全持有策略,每新起一个线程,则从父线程继承SecurityContext
    			strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
    		}
    		else if (strategyName.equals(MODE_GLOBAL)) {
                // 全局安全上下文持有策略,所有的实例都共享同一个SecurityContext
                // 适用于富客户端应用,如swing应用。
    			strategy = new GlobalSecurityContextHolderStrategy();
    		}
    		else {
    			// Try to load a custom strategy
                // 用户可以自定义一个SecurityContextHolderStrategy实现类
    			try {
    				Class<?> clazz = Class.forName(strategyName);
    				Constructor<?> customStrategy = clazz.getConstructor();
    				strategy = (SecurityContextHolderStrategy) customStrategy.newInstance();
    			}
    			catch (Exception ex) {
    				ReflectionUtils.handleReflectionException(ex);
    			}
    		}
    
    		initializeCount++;
    	}

    提供的api包括getContext(),setContext(),clearContext(),createEmptyContext()。

  • Authentication,继承自 Principal,有如下接口方法:getAuthorities(),获取当前principal拥有的所有权限列表。如果还未通过鉴权,则返回空集合。getCredentials(),获取用户输入的密码凭证。通过认证之后被移除以确保安全。getDetails(),获取认证的额外信息,可能是ip地址或者证书的序列号,如果未使用的话则为null。getPrincipal(),获取正在被鉴权或已经通过鉴权的用户信息。
  • UserDetails,用户详情接口。getAuthorities(),获取用户的权限列表,不可为null。getPassword(),获取用户密码。getUserName(),获取用户名字。isAccountNonExpired(),用户是否未过期。isAccountNonLocked(),用户是否未锁定。isCredentialsNonExpired(),密码凭证是否未过期。isEnabled(),用户是否被启用。UserDetails的默认实现是org.springframework.security.core.userdetails.User。
  • UserDetailsService接口只有一个方法:UserDetails loadUserByUsername(String username),根据用户名获取用户详情。常用的实现类有InMemoryUserDetailsManager(从内存中获取)和JdbcUserDetailsManager(从数据库获取)。
  • AuthenticationManager接口同样只有一个方法:Authentication authenticate(Authentication authentication),对用户输入的authentication进行认证。默认的实现类是ProviderManager,实际的认证逻辑被委托给了AuthenticationProvider。源码非常直白易懂,不做赘述。
        public Authentication authenticate(Authentication authentication)
    			throws AuthenticationException {
    		Class<? extends Authentication> toTest = authentication.getClass();
    		AuthenticationException lastException = null;
    		Authentication result = null;
    		boolean debug = logger.isDebugEnabled();
    
    		for (AuthenticationProvider provider : getProviders()) {
    			if (!provider.supports(toTest)) {
    				continue;
    			}
    
    			if (debug) {
    				logger.debug("Authentication attempt using "
    						+ provider.getClass().getName());
    			}
    
    			try {
    				result = provider.authenticate(authentication);
    
    				if (result != null) {
    					copyDetails(authentication, result);
    					break;
    				}
    			}
    			catch (AccountStatusException e) {
    				prepareException(e, authentication);
    				// SEC-546: Avoid polling additional providers if auth failure is due to
    				// invalid account status
    				throw e;
    			}
    			catch (InternalAuthenticationServiceException e) {
    				prepareException(e, authentication);
    				throw e;
    			}
    			catch (AuthenticationException e) {
    				lastException = e;
    			}
    		}
    
    		if (result == null && parent != null) {
    			// Allow the parent to try.
    			try {
    				result = parent.authenticate(authentication);
    			}
    			catch (ProviderNotFoundException e) {
    				// ignore as we will throw below if no other exception occurred prior to
    				// calling parent and the parent
    				// may throw ProviderNotFound even though a provider in the child already
    				// handled the request
    			}
    			catch (AuthenticationException e) {
    				lastException = e;
    			}
    		}
    
    		if (result != null) {
    			if (eraseCredentialsAfterAuthentication
    					&& (result instanceof CredentialsContainer)) {
    				// Authentication is complete. Remove credentials and other secret data
    				// from authentication
    				((CredentialsContainer) result).eraseCredentials();
    			}
    
    			eventPublisher.publishAuthenticationSuccess(result);
    			return result;
    		}
    
    		// Parent was null, or didn't authenticate (or throw an exception).
    
    		if (lastException == null) {
    			lastException = new ProviderNotFoundException(messages.getMessage(
    					"ProviderManager.providerNotFound",
    					new Object[] { toTest.getName() },
    					"No AuthenticationProvider found for {0}"));
    		}
    
    		prepareException(lastException, authentication);
    
    		throw lastException;
    	}

    AuthenticationProvider的抽象实现类之一 AbstractUserDetailsAuthenticationProvider,这里完成了认证前后的一些检验工作,具体的认证细节须由子类完成。子类须实现抽象方法:

    UserDetails retrieveUser(String username, usernamePasswordAuthenticationToken authentication)	
                            throws AuthenticationException
    最终会调用UserDetailsService来获取userDetails。
    protected abstract void additionalAuthenticationChecks(UserDetails userDetails,
    			UsernamePasswordAuthenticationToken authentication)
    			throws AuthenticationException;
    DaoAuthenticationProvider实现该抽象方法如下:
        protected void additionalAuthenticationChecks(UserDetails userDetails,
    			UsernamePasswordAuthenticationToken authentication)
    			throws AuthenticationException {
    		Object salt = null;
    
    		if (this.saltSource != null) {
    			salt = this.saltSource.getSalt(userDetails);
    		}
    
    		if (authentication.getCredentials() == null) {
    			logger.debug("Authentication failed: no credentials provided");
    
    			throw new BadCredentialsException(messages.getMessage(
    					"AbstractUserDetailsAuthenticationProvider.badCredentials",
    					"Bad credentials"));
    		}
    
    		String presentedPassword = authentication.getCredentials().toString();
    
            // 校验密码是否有效
    		if (!passwordEncoder.isPasswordValid(userDetails.getPassword(),
    				presentedPassword, salt)) {
    			logger.debug("Authentication failed: password does not match stored value");
    
    			throw new BadCredentialsException(messages.getMessage(
    					"AbstractUserDetailsAuthenticationProvider.badCredentials",
    					"Bad credentials"));
    		}
    	}

    Spring Security的核心过滤器链


    核心过滤器链依次为:

  • ChannelProcessingFilter,确保web请求通过要求的协议传递,如果访问的channel错了,则在channel之间跳转。最普遍的用法是确保请求通过https协议传输,配置方式参考如下:
<bean id="channelProcessingFilter" class="org.springframework.security.web.access.channel.ChannelProcessingFilter">
   <property name="channelDecisionManager" ref="channelDecisionManager"/>
   <property name="securityMetadataSource">
     <security:filter-security-metadata-source request-matcher="regex">
       <security:intercept-url pattern="\A/secure/.*\Z" access="REQUIRES_SECURE_CHANNEL"/>
       <security:intercept-url pattern="\A/login.jsp.*\Z" access="REQUIRES_SECURE_CHANNEL"/>
       <security:intercept-url pattern="\A/.*\Z" access="ANY_CHANNEL"/>
     </security:filter-security-metadata-source>
   </property>
 </bean>

 <bean id="channelDecisionManager" class="org.springframework.security.web.access.channel.ChannelDecisionManagerImpl">
   <property name="channelProcessors">
     <list>
     <ref bean="secureChannelProcessor"/>
     <ref bean="insecureChannelProcessor"/>
     </list>
   </property>
 </bean>

 <bean id="secureChannelProcessor"
   class="org.springframework.security.web.access.channel.SecureChannelProcessor"/>
 <bean id="insecureChannelProcessor"
   class="org.springframework.security.web.access.channel.InsecureChannelProcessor"/>
  • SecurityContextPersistenceFilter,请求到达时,从SecurityContextRepository提取SecurityContext,填充到SecurityContextHolder,请求完成后将SecurityContext回存到SecurityContextRepository(通常是HttpSessionSecurityContextRepository),并清除SecurityContextHolder。
    // 从repo提取security context   
 SecurityContext contextBeforeChainExecution = repo.loadContext(holder);

		try {
            // 设置到SecurityContextHolder
			SecurityContextHolder.setContext(contextBeforeChainExecution);

			chain.doFilter(holder.getRequest(), holder.getResponse());

		}
		finally {
			SecurityContext contextAfterChainExecution = SecurityContextHolder
					.getContext();
			// Crucial removal of SecurityContextHolder contents - do this before anything
			// else.
            // 清空context holder
			SecurityContextHolder.clearContext();
            // 将context 存回repo
			repo.saveContext(contextAfterChainExecution, holder.getRequest(),
					holder.getResponse());
			request.removeAttribute(FILTER_APPLIED);

			if (debug) {
				logger.debug("SecurityContextHolder now cleared, as request processing completed");
			}
		}
  • ConcurrentSessionFilter,检查session是否过期,如果过期,则调用LogoutHandler进行登出。
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {
		HttpServletRequest request = (HttpServletRequest) req;
		HttpServletResponse response = (HttpServletResponse) res;

		HttpSession session = request.getSession(false);

		if (session != null) {
            // sessionRegistry通常维护了一个map,缓存了session信息,key为sessionID
			SessionInformation info = sessionRegistry.getSessionInformation(session
					.getId());

			if (info != null) {
				if (info.isExpired()) {
					// Expired - abort processing
					if (logger.isDebugEnabled()) {
						logger.debug("Requested session ID "
								+ request.getRequestedSessionId() + " has expired.");
					}
                        // session 过期,登出
					doLogout(request, response);

					this.sessionInformationExpiredStrategy.onExpiredSessionDetected(new SessionInformationExpiredEvent(info, request, response));
					return;
				}
				else {
					// Non-expired - update last request date/time
                    // 没过期,则刷新session的最后一次请求时间
					sessionRegistry.refreshLastRequest(info.getSessionId());
				}
			}
		}

		chain.doFilter(request, response);
	}
  • 认证过滤器,包括UsernamePasswordAuthenticationFilter,CasAuthenticationFilter,BasicAuthenticationFilter等,进行认证并在认证成功后往SecurityContextHolder中填充Authentication信息。
    public Authentication attemptAuthentication(HttpServletRequest request,
			HttpServletResponse response) throws AuthenticationException {
		if (postOnly && !request.getMethod().equals("POST")) {
			throw new AuthenticationServiceException(
					"Authentication method not supported: " + request.getMethod());
		}

		String username = obtainUsername(request);
		String password = obtainPassword(request);

		if (username == null) {
			username = "";
		}

		if (password == null) {
			password = "";
		}

		username = username.trim();

		UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
				username, password);

		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);
            // 最终调用AuthenticationProvider进行认证
		return this.getAuthenticationManager().authenticate(authRequest);
	}
  • SecurityContextHolderAwareRequestFilter,该过滤器将HttpServletRequest封装成继承自HttpServletRequestWrapper的SecurityContextHolderAwareRequestWrapper,并将登陆认证的逻辑添加到了request.login()方法里。
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
			throws IOException, ServletException {           
		chain.doFilter(this.requestFactory.create((HttpServletRequest) req,
				(HttpServletResponse) res), res);
	}
  • JaasApiIntegrationFilter,如果SecurityContextHolder中持有JaasAuthenticationToken,该Filter将使用包含在JaasAuthenticationToken中的Subject继续下游过滤器。
  • RememberMeAuthenticationFilter如果上述的认证过滤器没有更新SecurityContextHolder中的Authentication,那么该过滤器将尝试从请求中抽取一个remenber-me cookie,并使用对应的authentication进行认证和填充SecurityContextHolder。
if (SecurityContextHolder.getContext().getAuthentication() == null) {
            // 从request中抽取Remember-me cookie
			Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
					response);

			if (rememberMeAuth != null) {
				// Attempt authenticaton via AuthenticationManager
				try {
					rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);

					// Store to SecurityContextHolder
					SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
  • AnonymousAuthenticationFilter,如果上游认证机制仍然没有更新SecurityContextHolder,AnonymousAuthenticationFilter将给SecurityContextHolder填充一个AnonimousAuthenticationToken,即一个匿名用户。
if (SecurityContextHolder.getContext().getAuthentication() == null) {
			SecurityContextHolder.getContext().setAuthentication(
                    // 创建匿名用户认证信息
					createAuthentication((HttpServletRequest) req));

			if (logger.isDebugEnabled()) {
				logger.debug("Populated SecurityContextHolder with anonymous token: '"
						+ SecurityContextHolder.getContext().getAuthentication() + "'");
			}
		}
  • ExceptionTranslationFilter捕获security filter链抛出的异常,并只处理其中的AuthenticationException和AccessDeniedException。
public class ExceptionTranslationFilter extends GenericFilterBean {
  private void handleSpringSecurityException(HttpServletRequest request,
        HttpServletResponse response, FilterChain chain, RuntimeException exception)
        throws IOException, ServletException {
     if (exception instanceof AuthenticationException) {
       	// 如果认证异常,则重定向到登陆页
        sendStartAuthentication(request, response, chain,
              (AuthenticationException) exception);
     }
     else if (exception instanceof AccessDeniedException) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authenticationTrustResolver.isAnonymous(authentication) || authenticationTrustResolver.isRememberMe(authentication)) {
		  // 被拒绝访问的匿名用户或携带remember-me cookie的用户,重定向到登录页
           sendStartAuthentication(
                 request,
                 response,
                 chain,
                 new InsufficientAuthenticationException(
                       "Full authentication is required to access this resource"));
        }
        else {
           // accessDeniedHandler处理拒绝访问异常
           accessDeniedHandler.handle(request, response,
                 (AccessDeniedException) exception);
        }
     }
  }
}
  • FilterSecurityInterceptor,用于权限控制。从Authentication中获取用户的权限authorities,以及资源需要的访问权限,accessDecisionManager来决定当前用户是否有权访问该资源
    try {
                // authenticated认证对象,object访问的资源,attributes受保护的资源列表
			this.accessDecisionManager.decide(authenticated, object, attributes);
		}
		catch (AccessDeniedException accessDeniedException) {
			publishEvent(new AuthorizationFailureEvent(object, attributes, authenticated,
					accessDeniedException));

			throw accessDeniedException;
		}

DelegatingFilterProxy


一个标准Servlet过滤器的代理,代理的对象是一个实现了Filter接口的spring bean,通过过滤器名和bean名称的关联来指定被代理的过滤器。如在web.xml中这样配置(当然也可以使用基于Java的配置):

<filter>
<filter-name>myFilter</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>

<filter-mapping>
<filter-name>myFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

然后我们在spring 上下文中维护一个实现了Filter接口并且名称为myFilter的bean,那么这个过滤器bean将被代理到ServletContext中,当请求到达时,真正被执行的是过滤器bean中的过滤逻辑。

FilterChainProxy


spring security的过滤器链中包括了多个过滤器,如果将每个过滤器都通过一个DelegatingFilterProxy注册到web.xml,将非常的紊乱,FilterChainProxy的作用是代理security中所有的过滤器,这样,只需要将FilterChainProxy配置成bean,由DelegatingFilterProxy代理到ServletContext,所有的security过滤逻辑便可交由FilterChainProxy来控制执行。spring security已经在WebSecurityConfiguration中配置了这个bean,可通过@EnableWebSecurity注解来使security过滤器链生效:

@Bean(name = AbstractSecurityWebApplicationInitializer.DEFAULT_FILTER_NAME)
	public Filter springSecurityFilterChain() throws Exception {
		boolean hasConfigurers = webSecurityConfigurers != null
				&& !webSecurityConfigurers.isEmpty();
		if (!hasConfigurers) {
			WebSecurityConfigurerAdapter adapter = objectObjectPostProcessor
					.postProcess(new WebSecurityConfigurerAdapter() {
					});
			webSecurity.apply(adapter);
		}
		return webSecurity.build();
	}

我们只需要将其注册到web.xml中(filter-name和bean名称保持一致):

	<filter>
		<filter-name>springSecurityFilterChain</filter-name>
		<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
	</filter>
	<filter-mapping>
		<filter-name>springSecurityFilterChain</filter-name>
		<url-pattern>/*</url-pattern>
	</filter-mapping>

开启spring security


开启spring security有两种方式:

(1)使用@EnableWebSecurity注解来开启创建springSecurityFilterCharin bean,并将过滤器链通过DelegatingFilterProxy注册到web.xml中。

(2)启动类继承AbstractSecurityWebApplicationInitializer,AbstractSecurityWebApplicationInitializer实现了WebApplicationInitializer ,因此会被spring检测到(参考https://mp.youkuaiyun.com/postedit/82085192)。AbstractSecurityWebApplicationInitializer会注册名为springSecurityFilterCharin的DelegatingFilterProxy过滤器,我们不需要再做额外操作。

多个spring security配置


有时候我们对于应用的不同部分要启用不同的鉴权机制,而spring security允许我们在一个应用中启用多个security配置。参考spring官方文档的配置:

@EnableWebSecurity
public class MultiHttpSecurityConfig {
	@Bean
	public UserDetailsService userDetailsService() throws Exception {
		InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
		manager.createUser(User.withUsername("user").password("password").roles("USER").build());
		manager.createUser(User.withUsername("admin").password("password").roles("USER","ADMIN").build());
		return manager;
	}

	@Configuration
	@Order(1)                                                        1
	public static class ApiWebSecurityConfigurationAdapter extends WebSecurityConfigurerAdapter {
		protected void configure(HttpSecurity http) throws Exception {
			http
				.antMatcher("/api/**")                               2
				.authorizeRequests()
					.anyRequest().hasRole("ADMIN")
					.and()
				.httpBasic();
		}
	}

	@Configuration                                                   3
	public static class FormLoginWebSecurityConfigurerAdapter extends WebSecurityConfigurerAdapter {

		@Override
		protected void configure(HttpSecurity http) throws Exception {
			http
				.authorizeRequests()
					.anyRequest().authenticated()
					.and()
				.formLogin();
		}
	}
}

其中@Order注解用于指定过滤器链的顺序,以确保对于每个请求,都被正确的过滤器链拦截。没有@Order注解的配置默认处于最后。如果不使用@Order注解,实现Ordered接口重写getOrder()方法效果是一样的。

自定义过滤器并纳入security过滤器链中


自定义Filter,并通过http.addFilter系列方法插入到过滤器链中,如:

protected void configure(HttpSecurity http) throws Exception {
        http
                .addFilterAfter(new CurrentUserPopulateFilter(userPool, userService), BasicAuthenticationFilter.class)
                .requestMatcher(getRequestMatcher())
                .authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .httpBasic()
                .and()
                .exceptionHandling()
                .accessDeniedHandler(new DmsAccessDeniedHandler())
                .and()
                .csrf().disable();
    }

如何配置spring security


细节太多不详谈了,看官方文档吧https://docs.spring.io/spring-security/site/docs/4.2.10.RELEASE/reference/htmlsingle/#jc

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值