红茶的个人站点

  • 首页
  • 专栏
  • 开发工具
  • 其它
  • 隐私政策
Awalon
Talk is cheap,show me the code.
  1. 首页
  2. 其它
  3. 正文

Shiro 学习笔记1:基础

2023年9月8日 1015点热度 0人点赞 0条评论

1.简介

Shiro 的架构图:

各种客户端通过 Subject 与 Shiro 的核心组件 SecurityManager 交互,SecurityManager 包含以下几部分:

  • Authenticator,负责身份认证

  • Authorize,负责授权

  • Session Manager,管理 Session

  • SessionDAO,对 Session 进行持久化,可以使用 Redis、数据库等

  • Cache Manager,缓存管理

  • Realm,领域,负责从数据库查询数据以实现身份认证或授权

  • Cryptography,密码系统,用于对密码加密等

2.快速开始

新建一个 Maven 项目,选择使用骨架创建:

image-20230906175548816

因为网络的原因,从中央仓库下载骨架目录很可能会失败,阿里云 Maven 镜像上好像骨架目录是缺失的,如果生成框架目录失败,可以尝试用 maven 命令行来构建骨架(需要科学上网),具体可以参考这篇文章中用插件创建工程一节。

默认生成的框架中存在一个 JUnit 依赖,但是版本太老无法使用@Test注解,更换为比较新的版本:

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.13.2</version>
    <scope>test</scope>
</dependency>

我们还需要添加 Shiro 的依赖:

<dependency>
    <groupId>org.apache.shiro</groupId>
    <artifactId>shiro-core</artifactId>
    <version>1.3.2</version>
</dependency>

以及日志:

<!-- 添加slf4j日志api -->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.7.20</version>
</dependency>
<!-- 添加logback-classic依赖 -->
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.3</version>
</dependency>
<!-- 添加logback-core依赖 -->
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-core</artifactId>
    <version>1.2.3</version>
</dependency>
<dependency>
    <groupId>commons-logging</groupId>
    <artifactId>commons-logging</artifactId>
    <version>1.1.3</version>
</dependency>

还需要在/resources目录下为 logback 添加一个配置文件 logback.xml。

添加 Maven 编译插件:

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <configuration>
          <source>8</source>
          <target>8</target>
          <encoding>UTF-8</encoding>
        </configuration>
      </plugin>
    </plugins>
  </build>

这个插件的主要用途是指定项目使用的 Java 版本和编码,方便 IDE 进行语法检查。

在/resources目录下添加一个 Shiro 配置文件 shiro.ini:

#声明用户账号
[users]
jay=123

编写一个简单的登录模块:

public class ShiroApp {
    private Subject subject;
​
    public ShiroApp() {
        // 从配置文件加载 SecurityManager
        Factory<SecurityManager> factory = new IniSecurityManagerFactory("classpath:shiro.ini");
        SecurityManager securityManager = factory.getInstance();
        // 将 SecurityManager 关联到 SecurityUtils
        SecurityUtils.setSecurityManager(securityManager);
        // 获取 subject
        this.subject = SecurityUtils.getSubject();
    }
​
    public void login(String userName, String password){
        // 认证方式可以是多种多样,这里是采用用户密码进行身份认证
        AuthenticationToken token = new UsernamePasswordToken(userName, password);
        try {
            // 使用 subject 进行登录
            this.subject.login(token);
            System.out.println(String.format("登录成功,欢迎回来:%s", userName));
        }
        catch (AuthenticationException e){
            // 登录失败,抛出异常
            System.out.println("登录失败:"+e.getLocalizedMessage());
            throw e;
        }
    }
}

测试用例:

public class ShiroAppTests {
    private ShiroApp shiroApp = new ShiroApp();
​
    @Test
    public void testWrongLogin() {
        shiroApp.login("jay", "1234");
    }
​
    @Test
    public void testRightLogin() {
        shiroApp.login("zhangsan", "123");
    }
}

上边这个简单示例,用户的认证信息(用户名和密码)保存在配置文件中,用相关 API 可以加载认证信息,并与客户端程序传递的用户名和密码进行比较,就可以知道身份是否正确,能否进行登录。

3.Realm

一般用户的认证信息(用户名和密码)是保存在数据库中的,如果要实现从数据库中查找认证信息并完成认证,需要实现Realm接口,该接口有多个继承层次:

1580050317405

要同时实现认证和授权逻辑,需要至少继承AuthorizingRealm这个抽象类。

这里我实现了一个从数据库里查询用户密码,作为认证信息的 Realm:

public class MyRealm extends AuthorizingRealm {
    /**
     * 获取授权信息
     *
     * @param principals
     * @return
     */
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        return null;
    }
​
    /**
     * 获取认证信息
     *
     * @param token
     * @return
     * @throws AuthenticationException
     */
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        // 从 token 中获取用户名
        UsernamePasswordToken upt = (UsernamePasswordToken) token;
        String userName = upt.getUsername();
        // 从数据库查询用户密码
        SqlSession sqlSession = MyBatisUtil.createSqlSession();
        UserService userService = new UserServiceImpl(sqlSession);
        String correctPassword = userService.getPasswordByUserName(userName);
        if (correctPassword == null || "".equals(correctPassword)) {
            throw new UnknownAccountException(String.format("没有名称为%s的用户", userName));
        }
        // 认证通过,返回认证信息
        sqlSession.close();
        return new SimpleAuthenticationInfo(userName, correctPassword, getName());
    }
}

示例中的持久层是用 MyBatis 实现的,完整内容可以查看文末的完整示例。

需要注意的是,Realm 只需要根据传入 Token 中的用户标识(这里是用户名)从持久层(这里是数据库)获取对应的登录信息(这里是用户名+密码),并不需要进行认证工作。具体的认证工作(密码比对)由调用 Realm 的 SecurityManager 的 Authenticator 模块执行。

关于 Shiro 登录流程的源码分析,可以观看这个视频。

实现了 Realm 后还需要告诉 SecurityManager 如何加载这个 Realm,我们这里的 SecurityManager 初始化是依赖于 ini 配置文件的,所以修改 ini 配置文件:

[main]
definitionRealm=cn.icexmoon.shirodemo.realm.MyRealm
securityManager.realms=$definitionRealm
#声明用户账号
;[users]
;jay=123

在[main]中向 SecurityManager 注册了 Realm。现在已经实现了从数据库查询用户认证信息,所以配置文件中的帐号信息可以注释或删除掉。

4.编码与解码

Shiro 的 jar 包中有一组关于编码与解码的工具类:

image-20230907121346104

利用这些内建的编解码器,可以确保 Shiro 编码和解码的一致性。

4.1.Hex

Hex 就是十六进制编码,也就是用两个16 进制数(0~f)表示 8 位二进制数(一个字节)。

一个十六进制数可以表示4位二进制数(2的4次方)。

可以用一个简单的测试用例体现这一过程:

public class EncodeTests {
    @Test
    public void testEncodeHex() {
        System.out.println("dec\tbinary\t\thex");
        System.out.println("------------------");
        for (byte a = 1; a > 0; a += 10) {
            String dec = Byte.valueOf(a).toString();
            String binary = toBinaryStr(a);
            String hex = toHexStr(a);
            System.out.println(String.format("%s\t%s\t%s", dec, binary, hex));
        }
    }

    private static String toBinaryStr(byte b) {
        return Integer.toBinaryString((b & 0xFF) + 0x100).substring(1);
    }

    private static String toHexStr(byte b) {
        return Hex.encodeToString(new byte[]{b});
    }
}

输出:

dec	binary		hex
------------------
1	00000001	01
11	00001011	0b
21	00010101	15
31	00011111	1f
41	00101001	29
51	00110011	33
61	00111101	3d
71	01000111	47
81	01010001	51
91	01011011	5b
101	01100101	65
111	01101111	6f
121	01111001	79

当然,对于一般性字符内容来说,十六进制编码后没有任何可读性,因为其原始的二进制存储本身就因为字符集的不同有着不同的编码方式,没有什么可读性。

但是我们可以将十六进制编码作为一种“中间编码”,比如将某个字符串先转换为字节数组,再利用十六进制编码为字符串(char 数组),然后可以重新解码回字节数组,并重新转换为字符串。

可以用一个简单测试用例进行验证:

@Test
public void testEncodeHex2() throws UnsupportedEncodingException {
    String msg = "你好";
    char[] encoded = Hex.encode(msg.getBytes(StandardCharsets.UTF_8));
    byte[] decoded = Hex.decode(encoded);
    String strAfterHex = new String(decoded, StandardCharsets.UTF_8);
    Assert.assertEquals(msg, strAfterHex);
}

4.2.Base64

在 Web 编程中,如果需要浏览器用 url 参数传输中文,你就需要使用 Base64 对参数值进行编码。因为 HTTP 协议中,url 部分是仅支持 ASCII 字符集的,使用 Base64 可以将 UTF-8 字符集中不兼容 ASCII 的字符(通常是中文)编码成 ASCII 字符组成的字符串。

看一个简单示例:

public class Base64Tests {
    @Test
    public void testBase64(){
        String msg = "你好";
        String encoded = Base64.encodeToString(msg.getBytes(StandardCharsets.UTF_8));
        System.out.println(encoded);
    }
}

输出:

5L2g5aW9

当然,所有的编码方式都需要确保编码后的内容可以解码回“原文”。

有多个 jar 包都实现了 Base64,我们这里讨论的是 Shiro 自带的org.apache.shiro.codec.Base64。

用一个测试用例验证:

@Test
public void testEncodeAndDecode(){
    String msg = "你好";
    String encoded = Base64.encodeToString(msg.getBytes(StandardCharsets.UTF_8));
    byte[] decoded = Base64.decode(encoded);
    String strAfterBase64 = new String(decoded, StandardCharsets.UTF_8);
    Assert.assertEquals(msg, strAfterBase64);
}

5.散列算法

通常我们不会直接将用户的明文密码存储到数据库,那样存在密码泄露的风险。通常会使用散列算法对明文密码进行加密,数据库中存放的是加密后的密码。

散列算法是不可逆加密,即无法通过散列后的内容推导出原始明文密码,只能用于内容一致性验证。

散列算法有很多种,常见的有 MD5 和 SHA1 等。

Shiro 包含常见的散列算法实现:

image-20230907141048073

5.1.SHA1

看一个简单例子:

public class ShaTests {
    @Test
    public void testSha1(){
        final String PASSWROD = "12345";
        final String HASHED_PWD = new SimpleHash("SHA-1", PASSWROD).toString();
        System.out.println(HASHED_PWD);
    }
}

这使用 Shiro 提供的 SHA1 散列算法对密码进行加密,加密后的密码内容:

8cb2237d0679ca88db6464eac60da96345513964

和原文完全没有关系,这就可以很好的保护明文密码。但问题在于像 12345 或 password 这种很长见的密码,攻击者会提前收录进一个密码本,然后用常见的散列算法依次对密码本中的密码进行加密,获取到一个常见密码加密后的密码本,然后就可以用这个密码本对照这里加密后的密码进行暴力破解,理论上你的密码越简单,就越容易被这种攻击得逞。

5.2.Salt

应对这种攻击的一个解决方案是,在生成散列值的时候添加一个盐(salt)作为“随机变量”,让产生的散列值不那么具备“常见性”。此时只要攻击者不知道我们的盐是什么值,就无法生成和我们一样的加密后密码,也就无从得知对应的明文密码是什么,即使我们使用的是很简单的明文密码。

但这样并不代表攻击者无法破解系统。因为即使加密体系没有漏洞,只要你使用的是简单密码,攻击者也可以通过对登录接口进行暴力破解的方式依次尝试来成功登录。

下面是使用 salt 对密码进行加密的示例:

@Test
public void testSha2(){
    final String PASSWROD = "12345";
    String salt = "cn.icexmoon.shirodemo.123";
    final String HASHED_PWD = new SimpleHash("SHA-1", PASSWROD, salt).toString();
    System.out.println(HASHED_PWD);
}

加密后的密码:

9916bddfcaf8ae23e401e35af544a75acf91f79b

一般来说,salt 选取一个别人不容易猜到的内容就行。

当然,如果你加密部分的代码泄露了,使用和不使用 salt 也没有多少区别。当然,你也可以将 salt 存储到别的地方,或者用 RSA 进行加密,系统启动时才读取。

5.3.多次加密

除了加 salt 以外,我们还可以对加密后的内容再次加密,这样理论上可以提升破解难度以及降低密文与原始明文的关联性。

下面是一个多次加密的例子:

@Test
public void testSha4(){
    final String PASSWROD = "12345";
    String salt = "cn.icexmoon.shirodemo.123";
    int times = 2;
    final String HASHED_PWD = new SimpleHash("SHA-1", PASSWROD, salt, times).toString();
    System.out.println(HASHED_PWD);
}

这里times代表加密次数,默认为 1 次。

5.4.随机 Salt

除了使用固定 salt,对每个用户使用同一个 salt 加密以外。也可以为每个用户随机生成一个 salt 用于加密,当然,这样做就需要在存储密码的同时将对应的 salt 也存储起来。

两种方式我觉得各有利弊。

下面是一个使用随机产生的 salt 进行加密的示例:

@Test
public void testSha3(){
    final String PASSWROD = "12345";
    String salt = generateSalt();
    final String HASHED_PWD = new SimpleHash("SHA-1", PASSWROD, salt).toString();
    System.out.println(HASHED_PWD);
}

public static String generateSalt(){
    SecureRandomNumberGenerator randomNumberGenerator = new SecureRandomNumberGenerator();
    return randomNumberGenerator.nextBytes().toHex();
}

SecureRandomNumberGenerator 是 Shiro 用于产生随机数序列的工具。

6.案例:使用 SHA1 加密密码

6.1.表结构

先修改表结构,密码字段使用 40 位字符存储 SHA1 加密后的密文,添加一个 salt 字段存储 32 位字符的 salt:

CREATE TABLE `tb_user` (
  `id` int NOT NULL AUTO_INCREMENT,
  `username` varchar(20) DEFAULT NULL,
  `password` char(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `gender` char(1) DEFAULT NULL,
  `addr` varchar(30) DEFAULT NULL,
  `salt` char(32) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=26 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

SecureRandomNumberGenerator的nextBytes方法默认产生长度为 16 的 byte 数组,用 Hex 编码就是 32 个十六进制字符。

6.2.实体类

添加一个实体类,作为对散列后密码的抽象:

@Value
public class HashPassword {
    // 哈希后的密码内容
    String password;
    // 哈希时使用的 salt
    String salt;
}

修改实体类,添加一个 salt 属性:

@Data
public class User {
    private Integer id;
    private String username;
    private String password;
    private String gender;
    private String addr;
    private String salt;

    /**
     * 返回加密后的密码内容
     * @return 加密后的密码
     */
    public HashPassword getHashPassword(){
        return new HashPassword(password, salt);
    }
}

6.3.工具类

添加一个加密用的工具类:

public class EncryptorUtil {
    /**
     * 加密次数
     */
    public static final int TIMES = 2;
    /**
     * 散列算法
     */
    public static final String ALGORITHM_NAME = "SHA-1";

    /**
     * 用散列算法对明文密码进行加密
     * @param password 明文密码
     * @return 加密后的密码
     */
    public static HashPassword hashPassword(String password){
        final String SALT = generateSalt();
        final String HASHED_PWD = new SimpleHash(ALGORITHM_NAME, password, SALT, TIMES).toString();
        return new HashPassword(HASHED_PWD, SALT);
    }

    /**
     * 生成32位16进制数组成的随机字符串
     * @return 随机字符串
     */
    private static String generateSalt(){
        SecureRandomNumberGenerator randomNumberGenerator = new SecureRandomNumberGenerator();
        return randomNumberGenerator.nextBytes().toHex();
    }
}

6.4.DTO

添加一个 DTO 类,用于从前端传入包含明文密码的用户信息:

@Data
public class UserDTO {
    private Integer id;
    private String username;
    /**
     * 明文密码
     */
    private String password;
    private String gender;
    private String addr;

    public User getUser() {
        User user = new User();
        user.setId(id);
        user.setUsername(username);
        user.setGender(gender);
        user.setAddr(addr);
        // 对明文密码进行加密
        if (password != null && !password.isEmpty()) {
            HashPassword hashPassword = EncryptorUtil.hashPassword(password);
            user.setPassword(hashPassword.getPassword());
            user.setSalt(hashPassword.getSalt());
        }
        return user;
    }
}

getUser用于将其转换为使用加密后密码的实体类User。

6.5.服务层

服务层添加一个添加用户的方法:

public class UserServiceImpl extends BaseServiceImpl implements UserService {
	// ...
    @Override
    public void addUser(UserDTO userDTO) {
        this.userMapper.insert(userDTO.getUser());
    }
}

简单测试一下:

@Test
public void testUserAdd(){
    UserDTO dto = new UserDTO();
    dto.setUsername("icexmoon");
    dto.setAddr("北京路 116 号");
    dto.setGender("男");
    dto.setPassword("12345");
    userService.addUser(dto);
    sqlSession.commit();
}

现在数据库中保存的是加密后的密码:

image-20230907154132557

6.6.Realm

接下来要修改验证逻辑,让 Shiro 能够使用加密后的密码作为认证信息进行登录验证。

为 Realm 添加一个构造器,在其中设置 Realm 的密码匹配方式:

public class MyRealm extends AuthorizingRealm {
    public MyRealm() {
        // 设置密码匹配方式为 HASH 加密后匹配
        HashedCredentialsMatcher matcher = new HashedCredentialsMatcher(EncryptorUtil.ALGORITHM_NAME);
        // 设置加密次数
        matcher.setHashIterations(EncryptorUtil.TIMES);
        setCredentialsMatcher(matcher);
    }
}

修改认证方法,在返回的认证信息中使用加密后的密码和 salt:

protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
    // ...
    HashPassword correctPassword = userService.getPasswordByUserName(userName);
    if (correctPassword == null
        || correctPassword.getPassword() == null
        || correctPassword.getPassword().isEmpty()) {
        throw new UnknownAccountException(String.format("没有名称为%s的用户", userName));
    }
    // 认证通过,返回认证信息
    sqlSession.close();
    ByteSource salt = ByteSource.Util.bytes(correctPassword.getSalt());
    return new SimpleAuthenticationInfo(userName, correctPassword.getPassword(), salt, getName());
}

登录部分的代码和相关测试用例不需要修改,直接测试即可。

7.案例:身份授权

身份授权的过程与身份验证类似,只不过返回的是授权信息(AuthorizationInfo),其中包含的是用户拥有的角色(Role)。此外,授权是依托于身份认证的,也就是必须先执行登录,进行身份认证,认证通过,完成登录后,才能进行身份授权。

7.1.数据库

添加角色表:

CREATE TABLE `tb_role` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT 'id',
  `name` varchar(15) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色名称',
  `description` varchar(20) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色表'

添加用户和角色的关系表:

CREATE TABLE `tb_user_role` (
  `user_id` int NOT NULL COMMENT '用户id',
  `role_id` int NOT NULL COMMENT '角色id',
  PRIMARY KEY (`user_id`,`role_id`),
  KEY `role_fk` (`role_id`),
  CONSTRAINT `role_fk` FOREIGN KEY (`role_id`) REFERENCES `tb_role` (`id`),
  CONSTRAINT `user_fk` FOREIGN KEY (`user_id`) REFERENCES `tb_user` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户角色表'

7.2.实体类

添加角色表的实体类:

@Data
public class Role {
    public static final String ROLE_SYS_MANAGER = "sys_manager";
    public static final String ROLE_CUSTOMER = "customer";
    public static final String ROLE_EMPLOYEE = "employee";
    public static final String ROLE_DEP_MANAGER = "dep_manager";
    private Integer id;
    private String name;
    private String description;
}

修改用户的实体类,在其中加入角色属性:

@Data
public class User {
    // ...
    private List<Role> roles = new ArrayList<>();
}

角色相关属性是List类型,一个账户可以有多个角色(也可能没有)。

修改UserMapper的selectByUserName方法实现,改为使用 XML 配置用关联查询的方式查询关联的角色,并映射到结果集的roles属性中。

这里不展示具体代码,可以查看完整示例,或者参考这篇文章。

7.3.Realm

我们已经可以根据用户名查询到用户相关的角色和权限信息了,还需要在 Realm 中将这些信息写入“授权信息”(Authorization Info):

protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
    // 获取用户名,在这里主身份(Primary Principal)就是用户名
    String userName = (String) principals.getPrimaryPrincipal();
    // 从数据库中查询用户相关的角色和权限
    SqlSession sqlSession = MyBatisUtil.createSqlSession();
    UserService userService = new UserServiceImpl(sqlSession);
    User user = userService.getUserByUserName(userName);
    sqlSession.close();
    SimpleAuthorizationInfo sa = new SimpleAuthorizationInfo();
    // 添加角色到授权信息中
    List<String> roles = user.getRoles().stream()
        .map(Role::getName)
        .collect(Collectors.toList());
    sa.addRoles(roles);
    // 添加权限到授权信息中
    List<String> perms = user.getPerms().stream()
        .map(Permission::getName)
        .collect(Collectors.toList());
    sa.addStringPermissions(perms);
    return sa;
}

7.4.控制层

现在在控制层中可以使用 Shiro 通过检查当前用户是否拥有某个角色或权限来进行访问控制:

public class OrderController{
    private final String PERM_ORDER_ADD = "order:add";
    private final String PERM_ORDER_EDIT = "order:edit";
    private final String PERM_ORDER_DEL = "order:del";
    private final String PERM_ORDER_LIST = "order:list";

    /**
     * 返回订单列表
     */
    public void listOrder(Subject subject) {
        // 检查是否登录
        if (!subject.isAuthenticated()) {
            throw new RuntimeException("没有登录,需要登录");
        }
        // 检查是否拥有相关角色
        if (!subject.hasRole(Role.ROLE_SYS_MANAGER)
                && !subject.hasRole(Role.ROLE_CUSTOMER)) {
            // 只有系统管理员和顾客可以查看
            throw new RuntimeException("你没有相关权限");
        }
        // 检查是否有相关权限
        if (!subject.isPermitted(PERM_ORDER_LIST)) {
            throw new RuntimeException("你没有相关权限");
        }
        // 查询并返回订单信息
    }
}

7.5.测试

测试用例:

public class OrderControllerTests {
    private OrderController orderController = new OrderController();

    @Test
    public void testListOrder() {
        UserController userController = new UserController();
        Subject subject = userController.login("icexmoon", "12345");
        orderController.listOrder(subject);
    }
}

Shiro 授权流程的源码分析可以观看这个视频。

The End,谢谢阅读。

本文的完整示例可以从这里获取,对应的测试数据 SQL 可以从这里获取。

8.参考资料

  • 由浅入深掌握Shiro权限框架

本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: shiro
最后更新:2023年9月8日

魔芋红茶

加一点PHP,加一点Go,加一点Python......

点赞
< 上一篇
下一篇 >

文章评论

取消回复

*

code

COPYRIGHT © 2021 icexmoon.cn. ALL RIGHTS RESERVED.
本网站由提供CDN加速/云存储服务

Theme Kratos Made By Seaton Jiang

宁ICP备2021001508号

宁公网安备64040202000141号