Shiro(三) 身份认证源码分析与 MD5 盐值加密

上一篇中我们已经搭建好了 Shiro 的 web 环境,这篇详细聊聊 Shiro 的身份认证, 也就是我们常说的登录.

1. 身份认证

身份验证:一般需要提供如身份 ID 等一些标识信息来表明登录者的身份,如提供 email, 电话号码,用户名/密码来证明。
在 shiro 中,用户需要提供 principals (身份)和 credentials (证明)给 shiro,从而应用能验证用户身份:

  • Principals: 身份,即主体的标识属性,可以是任何属性,如用户名、 邮箱等,唯一即可。一个主体可以有多个 principals,但只有一个 Primary principals,一般是用户名/邮箱/手机号。
  • Credentials: 证明/凭证,即只有主体知道的安全值,如密码/数字证 书等.

最常见的 principals 和 credentials 组合就是用户名/密码了.

2. 身份验证的基本流程

  • 收集用户身份/凭证,即如用户名/密码, 一般是页面的 form 表单提交到 controller.
  • 调用 Subject.login 进行登录,如果失败将得到相应的 AuthenticationException 异常,根据异常提示用户错误信息; 否则登录成功.
  • 创建自定义的 Realm 类,继承 org.apache.shiro.realm.AuthorizingRealm 类,实现 doGetAuthenticationInfo() 方法进行具体的身份认证, doGetAuthenticationInfo 方法返回 AuthenticationInfo 对象, 封装了真实的(一般是数据库中)用户信息, 密码比对最终是由 Shior 完成, 并会抛出合适的 AuthenticationException 异常.

3. 身份验证实现

3.1 在 login.jsp 添加登录表单

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
   <title>Login</title>
</head>
<body>
   <h1>Login Page</h1>

   <form action="login" method="post">
      UserName: <input type="text" name="userName">
      <br>
      <br>
      Password: <input type="password" name="password">
      <br>
      <br>

      <input type="submit" value="Submit">
   </form>
</body>
</html>

3.2 添加表单提交的 Controller

package club.javafamily.shiro.controller;

import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authc.*;
import org.apache.shiro.subject.Subject;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class LoginController {

   @PostMapping("/login")
   public String login(@RequestParam String userName,
                       @RequestParam String password)
   {
      Subject currentUser = SecurityUtils.getSubject();

      // 如果用户没有登录
      if(!currentUser.isAuthenticated()) {
         // 封装用户和密码为 UsernamePasswordToken
         UsernamePasswordToken token = new UsernamePasswordToken(userName, password);

         // 设置记住我, 在整个会话中记住身份.
         token.setRememberMe(true);

         try {
            // 执行登录
            currentUser.login(token);
         }
         catch(UnknownAccountException uae) {
            System.out.println("==用户不存在==" + uae.getMessage());
            throw uae;
         }
         catch(IncorrectCredentialsException ice) {
            System.out.println("====密码不正确====" + ice.getMessage());
            throw ice;
         }
         catch(LockedAccountException lae) {
            System.out.println("===账户被锁定==" + lae.getMessage());
            throw lae;
         }
         catch(ExcessiveAttemptsException eae) {
            System.out.println("=====尝试次数太多===" + eae.getMessage());
            throw eae;
         }
         catch(AuthenticationException e) {
            System.out.println("登录失败: " + token.getPrincipal() + ", " + e.getMessage() + "===" + e.getClass().getSimpleName());
            throw e;
         }
      }

      // 登录成功跳转到 list.jsp 页面
      return "list";
   }
}

常见的登录异常有:

  • UnknownAccountException: 表示用户不存在.
  • IncorrectCredentialsException: 表示密码不正确.
  • LockedAccountException: 表示账户被锁定, 比如管理员明确锁定.
  • ExcessiveAttemptsException: 表示登录次数太多, 当系统配置为在一段时间内仅允许一定数量的身份验证尝试并且当前会话在以下时间内无法成功进行身份验证时抛出.大多数系统会临时或永久锁定该帐户以防止进一步尝试.
  • AuthenticationException: 以上异常的父类, 表示认证失败异常.

3.3 完善 Realm 的身份认证功能

上一篇中, 我们添加了一个 ShiroRealmsecurityManager, 但是仅仅是一个空实现的 class, 现在我们修改并完善身份认证功能.

3.3.1 构造一个 MockDao 代替数据库提供用户信息
package club.javafamily.shiro.dao;

import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.Map;

@Component("userDao")
public class MockUserDao implements UserDao {

   public void addUser(String userName, String password) {
      mockUsers.put(userName, password);
   }

   @Override
   public Object getUser(Object userName) {
      for (Map.Entry<String, String> entrty : mockUsers.entrySet()) {
         if(userName.equals(entrty.getKey())) {
            return entrty.getValue();
         }
      }

      return null;
   }

   @Override
   public Object getPassword(Object userName) {
      return mockUsers.get(userName);
   }

   private static final Map<String, String> mockUsers = new HashMap<>();

   {
      addUser("admin", "admin");
      addUser("user", "user");
      addUser("jack", "jack");
   }
}

这个很简单, 不多说, 你懂得…

3.3.2 将 UserDao 注入到 Realm

因为我们使用的是 xml 配置(现在企业基本不用了), 所以修改 applicationContext.xml:

   <bean id="shiroRealm" class="club.javafamily.shiro.realm.ShiroRealm">
      <property name="userDao" ref="userDao"></property>
   </bean>

   <bean id="userDao" class="club.javafamily.shiro.dao.MockUserDao"></bean>
3.3.3 实现自定义 Realm, 继承 AuthenticatingRealm
package club.javafamily.shiro.realm;

import club.javafamily.shiro.dao.UserDao;
import org.apache.shiro.authc.*;
import org.apache.shiro.realm.AuthenticatingRealm;
import org.apache.shiro.util.ByteSource;

public class ShiroRealm extends AuthenticatingRealm {

   /**
    * 获取认证信息, 具体的密码比对由 Shiro 来完成.
    * @param token 调用 Subject.login 传入的 token 对象
    * @return 认证信息
    * @throws AuthenticationException 当认证失败时
    */
   @Override
   protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
      System.out.println("doGetAuthenticationInfo: " + token.getPrincipal());

      Object userName = token.getPrincipal();

      Object user = userDao.getUser(userName);

      if(user == null) {
//         throw new UnknownAccountException("用户不存在!");
         // 也可以直接返回 null, shiro 会帮我们判断并抛出合适的异常
         return null;
      }

      // principal 为当前用户的用户名
      Object principal = userName;
      // credentials 为数据库中用户真实的密码
      Object credentials = userDao.getPassword(userName);
      // realmName realm 的名字, 直接调用父类的 getName 方法就好了
      String realmName = getName();

	    // 封装 SimpleAuthenticationInfo 对象并返回
      SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(principal, credentials, realmName);

      return authenticationInfo;
   }

   public UserDao getUserDao() {
      return userDao;
   }

   public void setUserDao(UserDao userDao) {
      this.userDao = userDao;
   }

   private UserDao userDao;
}
3.3.4 登录测试

file

可以看到当用户名不存在或者密码不对时都会抛出相应的异常, 这里帅帅我并没有处理错误页面, 因为这不是我们的重点, 并且现在基本也不会用 jsp 和 xml 配置, 这里只是为了让大家更清楚的看到 Shiro 的运行流程和底层原理.

4. 身份认证流程源码分析

这里我们首先从源码角度上分析一下在一次登录认证的过程中都发生了什么.

  • 首先调用 Subject.login(token) 进行登录
			Subject currentUser = SecurityUtils.getSubject();
......
			 // 封装用户和密码为 UsernamePasswordToken
			 UsernamePasswordToken token = new UsernamePasswordToken(userName, password);

			 try {
					// 执行登录
					currentUser.login(token);
			 }
  • Subject 的父类 DelegatingSubject 会将登录操作委派给 SecurityManager.
public void login(AuthenticationToken token) throws AuthenticationException {
        clearRunAsIdentitiesInternal();
        Subject subject = securityManager.login(this, token); // 委派给 SecurityManager 进行身份认证

        PrincipalCollection principals;
  • SecurityManager 负责真正的身份验证逻辑;它会委托给 Authenticator 进行身份验证;

org.apache.shiro.mgt.DefaultSecurityManager#login

public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
        AuthenticationInfo info;
        try {
            info = authenticate(token);
        } catch (AuthenticationException ae) {

org.apache.shiro.mgt.AuthenticatingSecurityManager#authenticate

/**
     * Delegates to the wrapped {@link org.apache.shiro.authc.Authenticator Authenticator} for authentication.
     */
    public AuthenticationInfo authenticate(AuthenticationToken token) throws AuthenticationException {
        return this.authenticator.authenticate(token);
    }
  • Authenticator 才是真正的身份验证者,Shiro API 中核心的身份 认证入口点, Authenticator 可能会委托给相应的 AuthenticationStrategy 进 行多 Realm 身份验证,默认 ModularRealmAuthenticator 会调用 AuthenticationStrategy 进行多 Realm 身份验证

org.apache.shiro.authc.pam.ModularRealmAuthenticator#doAuthenticate

protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
        assertRealmsConfigured();
        Collection<Realm> realms = getRealms();
        if (realms.size() == 1) { // 由于我们只定义了一个 Realm(ShiroRealm)所以走 if 逻辑
            return doSingleRealmAuthentication(realms.iterator().next(), authenticationToken);
        } else {
            return doMultiRealmAuthentication(realms, authenticationToken);
        }
    }

由于我们只定义了一个 Realm (ShiroRealm)所以走 if 逻辑: doSingleRealmAuthentication

  • ModularRealmAuthenticator 会通过 Realm 获取 AuthenticationInfo
 protected AuthenticationInfo doSingleRealmAuthentication(Realm realm, AuthenticationToken token) {
        if (!realm.supports(token)) {
            String msg = "Realm [" + realm + "] does not support authentication token [" +
                    token + "].  Please ensure that the appropriate Realm implementation is " +
                    "configured correctly or that the realm accepts AuthenticationTokens of this type.";
            throw new UnsupportedTokenException(msg);
        }
        AuthenticationInfo info = realm.getAuthenticationInfo(token); // 这将最终调用我们自定义 Realm 的 doGetAuthenticationInfo 方法得到 info
				
				// 如果 info 为空, 则抛出用户不存在异常, 这也就是我们自定义 Realm 时查询用户密码为空即可以抛出异常又可以直接返回 null 的原因.
				if (info == null) {
            String msg = "Realm [" + realm + "] was unable to find account data for the " +
                    "submitted AuthenticationToken [" + token + "].";
            throw new UnknownAccountException(msg);
        }
        return info;

realm.getAuthenticationInfo(token); 最终将会调用我们自定义 Realm 的 doGetAuthenticationInfo 方法得到 info, 如果 info 为空, 则抛出用户不存在异常, 这也就是我们自定义 Realm 时查询用户密码为空即可以抛出异常又可以直接返回 null 的原因.

org.apache.shiro.realm.AuthenticatingRealm#getAuthenticationInfo:

 public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {

        AuthenticationInfo info = getCachedAuthenticationInfo(token); // 那拿缓存的 AuthenticationInfo
        if (info == null) {
            //otherwise not cached, perform the lookup:
            info = doGetAuthenticationInfo(token); // 如果没有, 则调用 AuthenticatingRealm 的 doGetAuthenticationInfo ---> 这就调用到我们自定义 Realm 的 doGetAuthenticationInfo 方法

            if (token != null && info != null) {
								// 将 AuthenticationInfo 放入缓存
                cacheAuthenticationInfoIfPossible(token, info);
            }
        }

        if (info != null) {
						// 如果 info 不为空就进行身份信息比对, 即身份认证
            assertCredentialsMatch(token, info);
        } else {
  • 密码匹配, 如果不匹配将会抛出密码错误的异常

org.apache.shiro.realm.AuthenticatingRealm#assertCredentialsMatch:

protected void assertCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) throws AuthenticationException {
        CredentialsMatcher cm = getCredentialsMatcher(); // 获取匹配器, AuthenticatingRealm 默认为 SimpleCredentialsMatcher
        if (cm != null) {
            if (!cm.doCredentialsMatch(token, info)) { // 进行密码匹配
                //not successful - throw an exception to indicate this:
                String msg = "Submitted credentials for token [" + token + "] did not match the expected credentials.";
                throw new IncorrectCredentialsException(msg);
            }
        } else {

匹配器: 我们看 AuthenticatingRealm 的构造就可以知道, 默认情况下匹配器为 SimpleCredentialsMatcher, 其核心为 equals 比较.

 public AuthenticatingRealm() {
        this(null, new SimpleCredentialsMatcher());
    }

既然密码匹配是由匹配器来完成的, 那么修改匹配器就可以达到按照一定规则比对匹配的目的, 比如 MD5 加密, 如果数据库存的是加密后的数据, 直接 equals 显示是不匹配的, 那么就可以修改匹配器, 在比对密码之前进行 MD5 加密处理.

  • 具体密码匹配的规则流程
public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
        Object tokenCredentials = getCredentials(token);
        Object accountCredentials = getCredentials(info);
        return equals(tokenCredentials, accountCredentials);
    }

5. 密码安全与 MD5 加密

一般来说, 为了保护用户的信息安全, 软件产品都会采用一些不可逆的(不能被破解的)加密算法对密码进行加密, 进而保护用户信息安全. Shiro 也提供了一些加密算法的支持, 比如 MD5 加密, MD5 也是一种用的比较多的加密算法, 我们也采取 MD5 加密.

上面分析了认证的流程, 我们知道密码匹配的具体任务由于匹配器来完成的, 而 AuthenticatingRealm 默认的匹配器是 SimpleCredentialsMatcher, 查看其继承体系可以发现:

file

我们可以看到, Shiro 提供了 MD5, Sha256 等加密算法, 但是都 deprecated 了, Shiro 提示我们, 可以使用 HashedCredentialsMatcher 并指定 hashAlgorithmName 属性即可.

 * @deprecated since 1.1 - use the HashedCredentialsMatcher directly and set its
 *             {@link HashedCredentialsMatcher#setHashAlgorithmName(String) hashAlgorithmName} property.
 */
public class Md5CredentialsMatcher extends HashedCredentialsMatcher {

因此我们就使用 HashedCredentialsMatcher 注入到我们的 Realm 中.

5.1 为 Realm 注入 HashedCredentialsMatcher

<bean id="shiroRealm" class="club.javafamily.shiro.realm.ShiroRealm">
      <property name="userDao" ref="userDao"></property>
      <property name="credentialsMatcher">
         <bean class="org.apache.shiro.authc.credential.HashedCredentialsMatcher">
            <property name="hashAlgorithmName" value="MD5"></property>
         </bean>
      </property>
   </bean>

5.2 数据库插入用户信息时需要先 MD5 加密

当配置了 HashedCredentialsMatcher 之后登录时 Shiro 会为我们先进行 MD5 编码再比对, 因此数据库中的密码必须也是 MD5 加密后的密码, 这样才能保证用户数据的安全.

要知道如果进行 MD5 加密, 只需要查看登录时 Shiro(HashedCredentialsMatcher) 如何 MD5 加密用户输入的密码即可. 经过上面的流程分析我们知道, 实际上密码匹配是发生在 CredentialsMatcherdoCredentialsMatch 方法, 查看 HashedCredentialsMatcherdoCredentialsMatch 方法:

		@Override
    public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
        Object tokenHashedCredentials = hashProvidedCredentials(token, info); // 获取用户登录信息封装的凭证
        Object accountCredentials = getCredentials(info); // 获取真实用户信息凭证(数据库查询出来的在 Realm 中我们封装到了 AuthenticationInfo)
        return equals(tokenHashedCredentials, accountCredentials); // 密码比对
    }

继续查看 hashProvidedCredentials

protected Hash hashProvidedCredentials(Object credentials, Object salt, int hashIterations) {
        String hashAlgorithmName = assertHashAlgorithmName(); // 加密算法---> 就是我们注入匹配器时指定的hashAlgorithmName ( MD5 )
        return new SimpleHash(hashAlgorithmName, credentials, salt, hashIterations);
    }
  • hashAlgorithmName: 加密算法—> 就是我们注入匹配器时指定的hashAlgorithmName ( MD5 )
  • credentials: 用户输入的密码
  • salt: 加密的盐值, 目前我们并没有, 后面会再说
  • hashIterations: 加密的次数, 默认为 1,

所以我们就知道密码匹配前 Shiro 将用户信息封装到了 SimpleHash, 而 SimpleHash 会将密码转化为 byte[], 并进行 hash 加密.

public SimpleHash(String algorithmName, Object source, Object salt, int hashIterations) throws CodecException, UnknownAlgorithmException {
      this.hexEncoded = null;
      this.base64Encoded = null;
      if (!StringUtils.hasText(algorithmName)) {
         throw new NullPointerException("algorithmName argument cannot be null or empty.");
      } else {
         this.algorithmName = algorithmName;
         this.iterations = Math.max(1, hashIterations);
         ByteSource saltBytes = null;
         if (salt != null) {
            saltBytes = this.convertSaltToBytes(salt);
            this.salt = saltBytes;
         }

         ByteSource sourceBytes = this.convertSourceToBytes(source); // 先转换为 bytesource
         this.hash(sourceBytes, saltBytes, hashIterations); // 在进行加密
      }
   }

最终密码匹配时的密码就是这个 hexEncoded, 因此我们就可以对插入数据库的密码进行加密:

public class MockUserDao implements UserDao {
	public void addUser(String userName, String password) {
      SimpleHash hash = new SimpleHash("MD5", password, null, 1);

      mockUsers.put(userName, hash.toHex()); // 存入 MD5 加密后的数据
   }

5.3 MD5 加密登录测试

file

5.4 加密加密再加密

实际上, 即便是 MD5 加密也不是一定安全的, 但是如果我们对结果加密加密再加密呢? 想要破解就没那么容易了.
经过我们上面的分析, SimpleHash 在加密的时候有一个 hashIterations 参数控制加密的次数, 默认为 1(所以我们向数据库添加数据时就传入的是 1), 而这个值又来自于 HashedCredentialsMatcherhashIterations 属性(5.2 节 hashProvidedCredentials 方法).因此我们在在 Realm 注入匹配器时可以配置这个加密次数.

5.4.1 配置加密次数
<!-- 配置 Realm
        1. 配置 userDao 用于查询用户数据
        2. 配置 credentialsMatcher 用于密码的加密, 一般使用单向不可逆的加密算法(比如 MD5),
            Shiro 为我们提供了 MD5 加密算法 Md5CredentialsMatcher, 但是已经过时, 官方推荐
            使用 HashedCredentialsMatcher 代替, 并配置 hashAlgorithmName 为 MD5 指定为
            MD5 加密.
            hashIterations: 配置加密的次数, 一般只加密一次可能比较容易破解, 所以可以根据密码安全性
            要求修改加密次数. 默认为 1 次.
   -->
   <bean id="shiroRealm" class="club.javafamily.shiro.realm.ShiroRealm">
      <property name="userDao" ref="userDao"></property>
      <property name="credentialsMatcher">
         <bean class="org.apache.shiro.authc.credential.HashedCredentialsMatcher">
            <!-- 加密算法名称 -->
            <property name="hashAlgorithmName" value="MD5"></property>
            <!-- 加密的次数 -->
            <property name="hashIterations" value="1024"></property>
         </bean>
      </property>
   </bean>

这里我们加密 1024 次(黑客, 咱们走着瞧!). 需要注意的是, 加密次数越多, 密码是越安全, 但是相应的加密时间也就多了, 因此, 需要在安全性和响应时间之间做一个权衡.

5.4.2 修改数据库入库加密

既然匹配时对用户密码加密 1024 次, 那么数据库添加用户信息时自然也就需要加密 1024 次咯, 你懂得…

public class MockUserDao implements UserDao {

   public void addUser(String userName, String password) {
      SimpleHash simpleHash = new SimpleHash("MD5", password, null, 1024); // 加密 1024 次
      mockUsers.put(userName, simpleHash.toHex());
   }

这里可能部分同学有一个疑惑, 为什么要调 SimpleHash 的 toHex(), 而不是 new String(simpleHash.getBytes())
这是因为传入 SimpleHash 的密码会被转化为 byte[], 然后再 hash 编码 hashIterations 次.

ByteSource sourceBytes = this.convertSourceToBytes(source);
this.hash(sourceBytes, saltBytes, hashIterations);

simpleHash 的 {@link SimpleHash#getBytes()} 是被 hash 编码后的 byte[], 如果用来构造 String 就乱码了, 在密码匹配时 {@link org.apache.shiro.authc.credential.HashedCredentialsMatcher#doCredentialsMatch } 通过乱码的 String 再转化为 byte[] 自然就是错误的.

5.4.3 加密次数运行测试

file

5.5 MD5 盐值加密, 加密加密再加密, 再加密

实际上经过上面的加密, 密码安全性已经很高了, 但是, 当不同的用户设置相同的密码的时候, 加密所得到的密文依然会一模一样, 为了解决这样的问题, 我们将使用 MD5 盐值加密, 这样, 就算不同的用户设置相同的密码, 加密后的数据依然是不一样的, 这样就极大程度的保护了用户的信息安全.

经过上面的分析, 我们看到, 插入 SimpleHash 的参数还有一个 salt 我们传入的是 null, 这个就是盐值加密的 , 一般来说, salt 需要使用一个唯一性的值, 比如 user id. 假设我们 UserName 唯一, 那么我们就用 UserName 作为 MD5 salt.

5.5.1 配置 Realm 的 salt

Shiro 进行密码匹配时 salt 来自于SimpleAuthenticationInfo

protected Object hashProvidedCredentials(AuthenticationToken token, AuthenticationInfo info) {
        Object salt = null;
				// 我们 ShiroRealm 构造的是 SimpleAuthenticationInfo, 是 SaltedAuthenticationInfo 的子类
        if (info instanceof SaltedAuthenticationInfo) {
            salt = ((SaltedAuthenticationInfo) info).getCredentialsSalt();
        } else {
            //retain 1.0 backwards compatibility:
            if (isHashSalted()) {
                salt = getSalt(token);
            }
        }
        return hashProvidedCredentials(token.getCredentials(), salt, getHashIterations());
    }

我们 ShiroRealm 构造的是 SimpleAuthenticationInfo, 是 SaltedAuthenticationInfo 的子类, 之前我们一直传入的是 null. 现在来传入 salt 进行盐值加密.

      // principal 为当前用户的用户名
      Object principal = userName;
      // credentials 为数据库中用户真实的密码
      Object credentials = userDao.getPassword(userName);
      // realmName realm 的名字, 直接调用父类的 getName 方法就好了
      String realmName = getName();
      // 使用 MD5 盐值加密的盐, 必须唯一.
//      ByteSource credentialsSalt = new SimpleByteSource((String) userName);
      ByteSource credentialsSalt = ByteSource.Util.bytes(userName); // 底层就是上面的代码

      SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(principal, credentials, credentialsSalt, realmName);

      return authenticationInfo;

构造 ByteSource 的 salt 有两种方式

  • new SimpleByteSource((String) userName);
  • ByteSource.Util.bytes(userName);

实际第二种底层也就是第一种方法, 只是 Shiro 替我们封装好了而已, 我们就使用第二种.

5.5.2 数据库入库加密使用盐值加密

一样的方式, 一样的套路, 不多说, 还是那个熟悉的味道哦.

public void addUser(String userName, String password) {
		  // 传入 userName 作为 salt
      SimpleHash simpleHash = new SimpleHash("MD5", password, userName, 1024);
      mockUsers.put(userName, simpleHash.toHex());
   }
5.5.3 MD5 盐值加密测试

这里帅帅就不给大家截图看了, 都是一样的套路, 你懂得.

源码传送门给你哦, 详细的不能再详细的注释, 保证小白也看得懂哦.

file

老铁, 关注不迷路哦.

file

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值