SpringSecurity (十) 登录流程剖析(上)

前言

Spring Seucirty 中默认的一套登录流程是十分完善并且严谨的。但是项目需求非常多样化,很多时候,我们可能还需要对 Spring Security登录流程进行定制,定制的前提是我们需要深刻理解Spring Security登录流程,然后在此基础之上,完成对登录流程的定制。

登录流程分析

要搞清楚Spring Security认证流程,我们得先认识与之相关的三个基本组件:AuthenticationManager、ProviderManager以及AuthenticationProvider,同时还需要去了解接入认证功能的过滤器AbstractAuthenticationprocessingFilter。

1. AuthenticationManager

从名称上看,这是一个认证管理器,它定义了Spring Security过滤器要如何执行认证操作。AuthenticationManager在认证成功后,会返回一个Authentication对象,这个Authentication对象会被设置到SecurityContextHolder中。如果我们不想用Security提供一套认证机制,那么我们也可以自定义认证流程,认证成功后,手动将Authentication存入到SecurityContextHolder中

public interface AuthenticationManager {

	Authentication authenticate(Authentication authentication) throws AuthenticationException;

}

从AuthenticationManager源码中可以知道,AuthenticationManager对传入的Authentication对象进行身份认证,此时传入的Authentication参数只有用户名/密码等简单的属性,如果认证成功,返回的Authentication的属性会得到完全填充,包括用户所具备的角色信息。

AuthenticationManager是一个接口,它有很多的实现类,我们也可以自定义AuthenticationManager的实现类,不过在实际自定义中,我们用的最多的还是ProviderManager。在Spring Security中,默认也是使用ProviderManager

2.AuthenticationProvider

我们知道Spring Security支持多种不同的认证方式,不同的认证方式对应不同的身份类型,AuthenticationProvider就是针对不同的身份类型执行具体的身份认证。例如,常见的DaoAuthenticationProvider用来支持用户名/密码登录,RememberMeAuthenticationProvider用来支持“记住我”的认证。

public interface AuthenticationProvider {

	Authentication authenticate(Authentication authentication) throws AuthenticationException;

	
	boolean supports(Class<?> authentication);

}
  • authenticate:用来执行具体的身份认证
  • supports:用来判断当前的AuthenticationProvider是否支持对应的身份类型。

当使用用户名/密码登录时,对应的AuthenticationProvider实现类是DaoAuthenticationProvider,而DaoAuthenticationProvider继承AbstractUserDetailsAuthenticationProvider并没有重写authenticate方法,所以具体的认证逻辑在AbstractDetailsAuthenticationProvider的authenticate方法中。我们就从

AbstractUserDetailsAuthenticationProvider的authenticate开始看。

public abstract class AbstractUserDetailsAuthenticationProvider
		implements AuthenticationProvider, InitializingBean, MessageSourceAware {

	protected final Log logger = LogFactory.getLog(getClass());

	protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();

	private UserCache userCache = new NullUserCache();

	private boolean forcePrincipalAsString = false;

	protected boolean hideUserNotFoundExceptions = true;

	private UserDetailsChecker preAuthenticationChecks = new DefaultPreAuthenticationChecks();

	private UserDetailsChecker postAuthenticationChecks = new DefaultPostAuthenticationChecks();

	private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();

	
	protected abstract void additionalAuthenticationChecks(UserDetails userDetails,
			UsernamePasswordAuthenticationToken authentication) throws AuthenticationException;

	@Override
	public final void afterPropertiesSet() throws Exception {
		Assert.notNull(this.userCache, "A user cache must be set");
		Assert.notNull(this.messages, "A message source must be set");
		doAfterPropertiesSet();
	}

	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
				() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
						"Only UsernamePasswordAuthenticationToken is supported"));
		String username = determineUsername(authentication);
		boolean cacheWasUsed = true;
		UserDetails user = this.userCache.getUserFromCache(username);
		if (user == null) {
			cacheWasUsed = false;
			try {
				user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
			}
			catch (UsernameNotFoundException ex) {
				this.logger.debug("Failed to find user '" + username + "'");
				if (!this.hideUserNotFoundExceptions) {
					throw ex;
				}
				throw new BadCredentialsException(this.messages
						.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
			}
			Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
		}
		try {
			this.preAuthenticationChecks.check(user);
			additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
		}
		catch (AuthenticationException ex) {
			if (!cacheWasUsed) {
				throw ex;
			}
			
			cacheWasUsed = false;
			user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
			this.preAuthenticationChecks.check(user);
			additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
		}
		this.postAuthenticationChecks.check(user);
		if (!cacheWasUsed) {
			this.userCache.putUserInCache(user);
		}
		Object principalToReturn = user;
		if (this.forcePrincipalAsString) {
			principalToReturn = user.getUsername();
		}
		return createSuccessAuthentication(principalToReturn, authentication, user);
	}

	private String determineUsername(Authentication authentication) {
		return (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName();
	}

	
	protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
			UserDetails user) {
		
		UsernamePasswordAuthenticationToken result = new UsernamePasswordAuthenticationToken(principal,
				authentication.getCredentials(), this.authoritiesMapper.mapAuthorities(user.getAuthorities()));
		result.setDetails(authentication.getDetails());
		this.logger.debug("Authenticated user");
		return result;
	}

	protected void doAfterPropertiesSet() throws Exception {
	}

	public UserCache getUserCache() {
		return this.userCache;
	}

	public boolean isForcePrincipalAsString() {
		return this.forcePrincipalAsString;
	}

	public boolean isHideUserNotFoundExceptions() {
		return this.hideUserNotFoundExceptions;
	}

	
	protected abstract UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
			throws AuthenticationException;

	public void setForcePrincipalAsString(boolean forcePrincipalAsString) {
		this.forcePrincipalAsString = forcePrincipalAsString;
	}

	
	public void setHideUserNotFoundExceptions(boolean hideUserNotFoundExceptions) {
		this.hideUserNotFoundExceptions = hideUserNotFoundExceptions;
	}

	@Override
	public void setMessageSource(MessageSource messageSource) {
		this.messages = new MessageSourceAccessor(messageSource);
	}

	public void setUserCache(UserCache userCache) {
		this.userCache = userCache;
	}

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

	protected UserDetailsChecker getPreAuthenticationChecks() {
		return this.preAuthenticationChecks;
	}

	
	public void setPreAuthenticationChecks(UserDetailsChecker preAuthenticationChecks) {
		this.preAuthenticationChecks = preAuthenticationChecks;
	}

	protected UserDetailsChecker getPostAuthenticationChecks() {
		return this.postAuthenticationChecks;
	}

	public void setPostAuthenticationChecks(UserDetailsChecker postAuthenticationChecks) {
		this.postAuthenticationChecks = postAuthenticationChecks;
	}

	public void setAuthoritiesMapper(GrantedAuthoritiesMapper authoritiesMapper) {
		this.authoritiesMapper = authoritiesMapper;
	}

	private class DefaultPreAuthenticationChecks implements UserDetailsChecker {

		@Override
		public void check(UserDetails user) {
			if (!user.isAccountNonLocked()) {
				AbstractUserDetailsAuthenticationProvider.this.logger
						.debug("Failed to authenticate since user account is locked");
				throw new LockedException(AbstractUserDetailsAuthenticationProvider.this.messages
						.getMessage("AbstractUserDetailsAuthenticationProvider.locked", "User account is locked"));
			}
			if (!user.isEnabled()) {
				AbstractUserDetailsAuthenticationProvider.this.logger
						.debug("Failed to authenticate since user account is disabled");
				throw new DisabledException(AbstractUserDetailsAuthenticationProvider.this.messages
						.getMessage("AbstractUserDetailsAuthenticationProvider.disabled", "User is disabled"));
			}
			if (!user.isAccountNonExpired()) {
				AbstractUserDetailsAuthenticationProvider.this.logger
						.debug("Failed to authenticate since user account has expired");
				throw new AccountExpiredException(AbstractUserDetailsAuthenticationProvider.this.messages
						.getMessage("AbstractUserDetailsAuthenticationProvider.expired", "User account has expired"));
			}
		}

	}

	private class DefaultPostAuthenticationChecks implements UserDetailsChecker {

		@Override
		public void check(UserDetails user) {
			if (!user.isCredentialsNonExpired()) {
				AbstractUserDetailsAuthenticationProvider.this.logger
						.debug("Failed to authenticate since user account credentials have expired");
				throw new CredentialsExpiredException(AbstractUserDetailsAuthenticationProvider.this.messages
						.getMessage("AbstractUserDetailsAuthenticationProvider.credentialsExpired",
								"User credentials have expired"));
			}
		}

	}

}

AbstractUserDetailsAuthenticationProvider是一个抽象类,抽象方法在它的实现类DaoAuthenticationProvider中完成,AbstractUserDetailsAuthenticationProvider本身逻辑非常简单我们来捋一下:

(1) 一开始声明一个用户缓存对象userCache,默认情况下没有启动缓存对象.

(2) hideUserNotFoundExceptions 表示是否隐藏用户名查找失败的异常,默认是true.为了确保系统安全,用户在登录失败时候只会给出一个模糊提示,例如:“用户名或密码错误”.在SpringSecurity内部,如果用户名查找失败,则会抛出UsernameNotFountException异常,但是该异常会被自动隐藏,转而通过一个BadCredentialsException异常来替代它.这样我们在处理登录失败异常时,无论用户名输入错误还是密码输入错误,收到的总是BadCredentialsException,这样做的一个好处是可以避免一些新手将用户名输入错误和密码输入错误两个异常分开表示.

(3) forcePrincipalAsString 表示是否强制将Principal对象当成字符串来处理,默认是false.Authentication中的Principal属性类型是一个Object.正常来说,通过principal属性可以获取到当前用户对象(UserDetails),但是如果forcePrincipalAsString设置为true,则Authentication中的principal属性返回就是当前用户名,而不是用户对象.

(4) preAuthenticationChecks 对象则是用于做用户状态检查,在用户认证过程中,需要检验用户状态是否正常,例如账户是否被锁定,账户是否可用,账户是否过期等.

(5) postAuthenticationChecks 对象主要负责在密码校验成功后,检查密码是否过期.

(6) additionalAuthenticationChecks 是一个抽象方法,主要就是校验密码,具体的实现在DaoAuthenticationProvider中.

(7) authenticate 方法就是核心的校验方法了.在方法中,首先从登录数据中获取用户名,然后根据用户名去缓存中查询用户对象,如过查询不到,则根据用户调用retrieveUser方法从数据库加载数据;如果没有加载到用户,则抛出异常.拿到用户对象后,首先调用preAuthenticationChecks.check方法进行用户状态检查,然后调用additionalAuthenticationChecks方法进行密码的校验操作,最后调用

postAuthenticationChecks.check方法检查密码是否过期,当所有步骤都顺利后,调用createSuccessAuthentication方法创建一个认证后的UsernamePasswordAuthenticationToken对象并返回.认证后的对象中包含了认证主体,凭证以及角色信息.

这就是AbstractUserDetailsAuthenticationProvider类的工作流程,有几个抽象方法是在DaoAuthenticationProvider中实现的,我们来看一下.

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

	
	private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";

	private PasswordEncoder passwordEncoder;

	
	private volatile String userNotFoundEncodedPassword;

	private UserDetailsService userDetailsService;

	private UserDetailsPasswordService userDetailsPasswordService;

	public DaoAuthenticationProvider() {
		setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder());
	}

	@Override
	@SuppressWarnings("deprecation")
	protected void additionalAuthenticationChecks(UserDetails userDetails,
			UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
		if (authentication.getCredentials() == null) {
			this.logger.debug("Failed to authenticate since no credentials provided");
			throw new BadCredentialsException(this.messages
					.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
		}
		String presentedPassword = authentication.getCredentials().toString();
		if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
			this.logger.debug("Failed to authenticate since password does not match stored value");
			throw new BadCredentialsException(this.messages
					.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
		}
	}

	@Override
	protected void doAfterPropertiesSet() {
		Assert.notNull(this.userDetailsService, "A UserDetailsService must be set");
	}

	@Override
	protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
			throws AuthenticationException {
		prepareTimingAttackProtection();
		try {
			UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
			if (loadedUser == null) {
				throw new InternalAuthenticationServiceException(
						"UserDetailsService returned null, which is an interface contract violation");
			}
			return loadedUser;
		}
		catch (UsernameNotFoundException ex) {
			mitigateAgainstTimingAttack(authentication);
			throw ex;
		}
		catch (InternalAuthenticationServiceException ex) {
			throw ex;
		}
		catch (Exception ex) {
			throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
		}
	}

	@Override
	protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
			UserDetails user) {
		boolean upgradeEncoding = this.userDetailsPasswordService != null
				&& this.passwordEncoder.upgradeEncoding(user.getPassword());
		if (upgradeEncoding) {
			String presentedPassword = authentication.getCredentials().toString();
			String newPassword = this.passwordEncoder.encode(presentedPassword);
			user = this.userDetailsPasswordService.updatePassword(user, newPassword);
		}
		return super.createSuccessAuthentication(principal, authentication, user);
	}

	private void prepareTimingAttackProtection() {
		if (this.userNotFoundEncodedPassword == null) {
			this.userNotFoundEncodedPassword = this.passwordEncoder.encode(USER_NOT_FOUND_PASSWORD);
		}
	}

	private void mitigateAgainstTimingAttack(UsernamePasswordAuthenticationToken authentication) {
		if (authentication.getCredentials() != null) {
			String presentedPassword = authentication.getCredentials().toString();
			this.passwordEncoder.matches(presentedPassword, this.userNotFoundEncodedPassword);
		}
	}

	
	public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
		Assert.notNull(passwordEncoder, "passwordEncoder cannot be null");
		this.passwordEncoder = passwordEncoder;
		this.userNotFoundEncodedPassword = null;
	}

	protected PasswordEncoder getPasswordEncoder() {
		return this.passwordEncoder;
	}

	public void setUserDetailsService(UserDetailsService userDetailsService) {
		this.userDetailsService = userDetailsService;
	}

	protected UserDetailsService getUserDetailsService() {
		return this.userDetailsService;
	}

	public void setUserDetailsPasswordService(UserDetailsPasswordService userDetailsPasswordService) {
		this.userDetailsPasswordService = userDetailsPasswordService;
	}

}

在DaoAuthenticationProvider中:

(1) 定义了USER_NOT_FOUND_PASSWORD常量,这个是当用户查找失败时的默认密码;passwordEncoder是一个密码加密和比对工具;userNotFoundEncodedPassword变量则用来保存默认密码加密后的值;userDetailsService是一个用户查找工具,userDetailsPasswordService则用来提供密码修改服务.

(2) 在DaoAuthenticationProvider的构造器中,默认就会指定PasswordEncoder,当然我们也可以通过set方法自定义PasswordEncoder

(3) additionalAuthenticationChecks 方法主要进行密码校验,该方法第一个参数UserDetails 是我们从数据中查询出来的用户对象,第二个参数authentication则是用户输入的参数.从这两个参数中分别提取出来用户密码,然后调用passwordEncoder.matches方法进行密码比对.

(4) retrieveUser. 这里我们需要关注一个地方,在方法的一开始,首先会调用prepareTimingAttackProtection方法,该方法作用的是使用PasswordEncoder对常量USER_NOT_FOUND_PASSWORD进行加密,将加密结果保存在userNotFoundEncodedPassword变量中,当根据用户名查找用户时,如果抛出了UsernameNotFoundException异常,则调用mitigateAgainstTimingAttack方法进行密码比对.这里是否觉得会有些多余,用户都没查找到怎么比对密码.需要注意的是,在调用mitigateAgainstTimingAttack方法进行密码比对时,使用了userNotFoundEncodedPassword变量作为默认密码和登录请求传来的用户密码进行比对.这里一个一个开始就注定要失败的密码比对.那为什么还要进行比对呢?这主要是为了避免旁道攻击(Side-channel attack).如果根据用户用户名查找用户失败,就直接抛出异常而步进行密码比对,这里有经过一个大量的测试,就会发现有的请求耗费时间明显小于其他请求,那么进而可以得出该请求的用户是一个不存在的用户名(因为用户名不存在,所以不需要密码比对,进而节省时间),这样就可以获取到系统信息.为了避免这一问题,所以当用户查找失败时,也会调用mitigateAgainstTimingAttack方法进行密码比对.

(6) createSuccessAuthentication 方法则是在登录成功后,常见一个全新的UsernamePasswordAuthenticationToken对象,同时会判断是否需要进行密码升级,如果需要进行密码升级,就会在该方法中进行加密方案升级.

通过对AbstractUserDetailsAuthenticationProvider和DaoAuthenticationProvider的说明,我们应该能大致明白AuthenticationProvider中的认证逻辑了.

在密码学中,旁道攻击(Side-channel attack) 又称倒信道攻击,边信道攻击.这种攻击方式不是暴力破解或者是研究加密算法的缩点.它是基于从密码系统的屋里实现中获取信息,比如时间,功率消耗,电磁泄漏等.这些信息可被利用对系统的进一步破解.

3. ProviderManager

ProviderManager 是AuthenticationManager的一个重要实现类,在开始学习之前,我们通过一幅图来了解以下 ProviderManager和AuthenticationProvider之间的关系.
在这里插入图片描述

在SpringSecurity中,由于系统可能同时支持多种不同的认证方式,例如同时支持用户名/密码登录,RememberMe认证,手机号码认证等,而不同的认证方式对应了不同的AuthenticationProvider,所以一个完整的认证流程可能由多个AuthenticationProvider来提供.

多个AuthenticationProvider去执行身份认证,最终得到认证结果.

ProviderManager本身也可以在配置一个AuthenticationManager作为parent,这样当ProviderManager认证失败之后,就可以进入到parent去中再次认证,理论上来说,ProviderManager的parent可以是任意类型的AuthenticationManager,但是通常都是由ProviderManager来扮演parent的角色,也就是ProviderManager是ProviderManager的parent.

ProviderManager本身也可以有多个,多个ProviderManager共同用一个parent,当存在多个过滤器链的时候非常有用,当存在多个过滤器链时,不同的路径可能对应不同的认证方式,但是不同路径可能存在一些共有的认证方式,这些共有的认证方式可以在parent中统一处理.

根据上面我们重新描述一下ProviderManager和AuthenticationProvider的关系
在这里插入图片描述

我们再来重点看一下ProviderManager中的authenticate方法:

@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Class<? extends Authentication> toTest = authentication.getClass();
		AuthenticationException lastException = null;
		AuthenticationException parentException = null;
		Authentication result = null;
		Authentication parentResult = null;
		int currentPosition = 0;
		int size = this.providers.size();
		for (AuthenticationProvider provider : getProviders()) {
			if (!provider.supports(toTest)) {
				continue;
			}
			if (logger.isTraceEnabled()) {
				logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
						provider.getClass().getSimpleName(), ++currentPosition, size));
			}
			try {
				result = provider.authenticate(authentication);
				if (result != null) {
					copyDetails(authentication, result);
					break;
				}
			}
			catch (AccountStatusException | InternalAuthenticationServiceException ex) {
				prepareException(ex, authentication);
				
				throw ex;
			}
			catch (AuthenticationException ex) {
				lastException = ex;
			}
		}
		if (result == null && this.parent != null) {
		
			try {
				parentResult = this.parent.authenticate(authentication);
				result = parentResult;
			}
			catch (ProviderNotFoundException ex) {
			
			}
			catch (AuthenticationException ex) {
				parentException = ex;
				lastException = ex;
			}
		}
		if (result != null) {
			if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
			
				((CredentialsContainer) result).eraseCredentials();
			}
		
			if (parentResult == null) {
				this.eventPublisher.publishAuthenticationSuccess(result);
			}

			return result;
		}

		
		if (lastException == null) {
			lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound",
					new Object[] { toTest.getName() }, "No AuthenticationProvider found for {0}"));
		}
		
		if (parentException == null) {
			prepareException(lastException, authentication);
		}
		throw lastException;
	}

方法的源码还是非常清晰的:

(1) 首先获取authentication对象的类型.

(2)分别定义当前认证过程抛出的异常,parent中认证时抛出的异常,当前认证结果以及parent认证结果对应的变量.

(3)getProviders 用来获取当前ProviderManager所代理的所有AuthenticationProvider对象,遍历这些AuthenticationProvider对象进行身份认证.

(4)判断当前AuthenticationProvider是否支持当前Authentication对象,要是不支持,则继续处理列表中的下一个AuthenticationProvider对象.

(5) 调用provider.authenticate方法进行身份认证,如果认证成功返回认证后的Authentication对象,同时调用copyDetails方法给Authentication对象的details属性赋值.由于可能是多个AuthenticationProvider执行认证操作,所以如果抛出异常,则通过lastException变量来记录

(6) 在for循环执行完成后,如果result还是没有值,说明所以的AuthenticationProvider都认证失败,此时如果parent不为空,则调用parent的authenticate方法进行认证.

(7) 接下来,如果result不为空,就将result中的凭证擦除,防止泄露,如果使用了用户名/密码方式登录,那么所谓的擦除实际上就是将密码字段设置为null,同时将登录成功的事件发布出去(发布登录成功事件需要parentResult为null.如果步为null,表示在paretn中已经认证成功了,认证成功的事件也已经在parent中发布出去了,这样会导致认证成功得的事件重复发布).如果用户认证成功,此时就将result返回,后面的代码也就步在执行了.

(8) 如果前面没能返回result,说明认证失败,如果lastException为null,说明parent为null或者没有认证或者认证失败了但是没有抛出异常,此时构造ProviderNotFoundException异常赋值给lastException

(9)如果parentException为null,发布认证失败事件(如果parentException不为null,则说明认证失败事件已经发布过了).

(10)最后抛出lastException异常.

这就是ProvidrrManager中的authenticate的认证逻辑.

最后

这里,我们大致清楚了Authentication,AuthenticationManager,AuthenticationProvider以及ProviderManager的工作原理了,那么有一个问题,这些组件是如果关联起来的?这里就有一个很重要的过滤器--------AbstractAuthenticationProcessingFilter.这一块内容我们会在下一篇中介绍.

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

陈橙橙丶

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

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

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

打赏作者

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

抵扣说明:

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

余额充值