IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 网络协议 -> SpringSecurity全局方法安全性:预授权和后授权 -> 正文阅读

[网络协议]SpringSecurity全局方法安全性:预授权和后授权

作者:template-box

1、非Web应用程序能否使用Spring Security实现授权?

??前面关于SpringSecurity知识点介绍那么多,都是基于Web应用程序设计的。那么不是Web应用程序就不能使用SpringSecurity进行身份验证和授权吗?Spring Security非常适合不通过HTTP端点使用应用程序的场景。这里将介绍如何在方法级别上配置授权。我们将使用这种方法在Web和非Web应用程序中配置授权,我们将其称为全局方法安全性。

??对于非Web应用程序,全局方法安全性提供了在即使不使用端点的情况下实现授权规则的可能性。在Web应用程序中,这种方法使我们能够灵活地在应用程序地不同层上应用授权规则,而不仅仅是在端点级别。

2、启用全局方法安全性

??默认情况下,全局方法安全性是禁用的,因此如果想使用此功能,首先需要启动它。此外,全局方法安全性为应用授权提供了多种方法。简单地说,使用全局方法安全性,可以完成以下两项主要处理:

  • 调用授权:决定某人是否可以根据某些已实现的权限来调用方法(预授权),后者决定某人是否可以访问方法执行后返回的内容(后授权)。
  • 过滤:决定一个方法可以通过它的参数接受什么(预过滤),以及方法执行后调用者可以从该方法接收到什么(后过滤)。

2.1 理解调用授权

??配置与全局方法安全性一起使用的授权规则的一种方法是调用授权。调用授权方法是指应用授权规则,这些授权规则将决定是否可以调用某个方法,后者允许调用该方法,然后决定调用方是否可以访问返回的值。通常,需要根据所提供的参数或逻辑执行结果来决定调用方是否可以访问该逻辑。因此接下来讨论调用授权,然后将它应用到示例中。

??全局方法安全性是如何工作的?应用授权规则背后的机制是什么?当我们在应用程序中启用全局方法安全性时,实际上是启用了一个Spring切面处理。这个切面处理会拦截对应用授权规则的方法的调用,并根据这些授权规则决定是否将调用转发给被拦截的方法。

image-20220305211135892
??
Spring框架中的许多实现都依赖于面向切面编程(AOP)。全局方法安全性只是Spring应用程序中依赖于切面的众多组件之一。简单地说,我们将调用授权分类为:

  • 预授权:框架在方法调用之前检查授权规则。
  • 后授权:框架在方法调用之后检查授权规则。

2.1.1 使用预授权保护对方法的访问

??假设有一个findDocumentsByUser(String username)方法,它会为调用者返回指定用户的文档。调用者通过该方法的参数提供方法要用于检索文档的用户名。假设需要确保经过身份验证的用户智能获取他们自己的文档。是否可以对该方法应用一个规则,以便只允许该方法调用在接收到经过身份验证的用户的用户名作为参数时才能执行?答案是肯定的!这就是使用预授权可以做到的事情。

??当应用在特定情况下完全禁止任何人调用某个方法的授权规则时,我们就将这一处理称为预授权。这种方法意味着框架在执行方法之前要验证授权条件。如果根据所定义的授权规则,调用方没有权限,那么框架就不会将调用委托给方法。框架转而会抛出异常。这是目前为止最常用的全局方法安全性方法。

image-20220305212139334

??通常,如果某些条件不满足,那么可能根本不希望执行某个功能。在这种情况下,可以根据经过身份验证的用户应用条件,还可以引用方法通过其参数接收的值。

2.1.2 使用后授权保护对方法的调用

??在应用授权规则时,如果打算允许某人调用方法,但不一定获得方法返回的结果,就可以使用后授权。使用后授权,Spring Security就会在方法执行后检查授权规则。可以使用这种授权来限制在某些条件下对方法返回的访问。因为后授权发生在方法执行之后,所以可以对方法返回的结果应用授权规则。

image-20220305212835441

??通常,我们使用后授权根据方法执行后返回的内容应用授权规则。但是要谨慎使用后授权!如果方法在其执行过程中做了某些变更,那么无论最终授权是否成功,都会发生变化。

??提示:即使使用@Transactional注解,如果后授权失败,变更也不会回滚。后授权功能抛出的异常发生在事务管理器提交事务之后。

2.2 在项目中启用全局方法安全性

??这里将使用一个项目应用全局方法安全性提供的预授权和后授权特性。在Spring Security项目中,全局方法安全性在默认情况下是不启用的。要使用它,需要首先启用它。不过,启用此功能很简单。只需要在配置类上使用EnableGlobalMethodSecurity注解就可以做到这一点。

??全局方法安全性为我们提供了以下3中方法定义授权规则:

  • 预授权/后授权注解
  • JSR 250注解,@RolesAllowed
  • @Secured注解

??因为几乎在所有情况下,预授权/后授权注解都是唯一使用的方法,所以我们将在这里讨论这种方法。要启用这种方法,需要使用EnableGlobalMethodSecurity注解的prePostEnabled属性。

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig {

}

??可以在任何身份验证方法中使用全局方法安全性,从HTTP Basic身份验证到OAuth2。为了保持简单并专注于新的细节,此处将介绍HTTP Basic身份验证的全局方法安全性。出于这个原因,本处项目的pom.xml文件只需要web和Spring Security依赖项。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

3、对权限和角色应用预授权

??这里实现一个预授权示例。预授权意味着在调用特定方法之前定义Spring Security应用的授权规则。如果违背了这些规则,框架就不会调用该方法。

??这里实现的应用程序有一个简单的场景。它暴露一个端点/hello,该端点会返回字符串"hello",后面跟着一个名称。为了获得该名称,控制器需要调用一个服务方法。此方法将应用预授权规则来验证用户是否具有写权限。

??这里添加了UserDetailsService和PasswordEncoder,以确保有一些用户可以进行身份验证。要验证该解决方案,需要两个用户:一个具有写权限,另一个没有写权限。这样就可以证明,第一个用户可以成功调用端点,而对于第二个用户,应用程序在尝试调用该方法时会抛出一个授权异常。

3.1 UserDetailsService和Password的配置类

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig {

    @Bean
    public UserDetailsService userDetailsService() {
        var service = new InMemoryUserDetailsManager();

        var u1 = User.withUsername("natalie")
                    .password("12345")
                    .authorities("read")
                .build();

        var u2 = User.withUsername("emma")
                .password("12345")
                .authorities("write")
                .build();

        service.createUser(u1);
        service.createUser(u2);

        return service;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
}

??要定义描述此方法的授权规则,需要使用@PreAuthorize注解。@PreAuthorize注解会将一个描述授权规则的SPEL(Spring Expression Language)表达式作为一个值来接收。这个示例要应用一个简单的规则。

??可以使用hasAuthority()方法根据用户的权限来为用户定义限制。

3.2 服务类在方法上定义预授权规则

@Service
public class NameService {

    //定义授权规则。只有具有写权限的用户才能调用该方法
    @PreAuthorize("hasAuthority('write')")
    public String getName() {
        return "Fantastico";
    }
}

3.3 实现端点并使用服务的控制器类

@RestController
public class HelloController {

    @Autowired
    private NameService nameService;

    @GetMapping("/hello")
    public String hello() {
        //调用为其应用预授权规则的方法
        return "Hello, " + nameService.getName();
    }
}

3.4 测试

??启动应用程序。我们希望只有用户emma有权调用端点,因为她有写授权。

??调用/hello端点并使用用户emma进行身份验证。

image-20220305215004807

??调用/hello端点并使用用户natalie进行身份验证。

image-20220305215033299

类似地,可以使用前面讨论过的任何其他表达式进行端点验证。

hasAnyAuthority():指定多个权限。用户必须至少拥有其中一种权限才能调用该方法。

hasRole():指定用户调用该方法所必须的角色。

hasAnyRole():指定多个角色。用户必须至少拥有其中一个角色才能调用该方法。

??接下来扩展这个示例,以表明如何使用方法参数的值定义授权规则。

??对于这个项目,此处定义了于第一个示例相同的ProjectConfig类,这样就可以继续使用之前的两个用户,Emma和Natalie。端点现在通过路径变量获取一个值,并调用一个服务类来获取给定用户名的“私密名称”。当然,在本示例中,私密名称只是一个指代用户的某个特征的名词而已,该特征不是每个人都能看到的。

3.5 定义测试端点的控制器类

@RestController
public class HelloController {

    @Autowired
    private NameService nameService;

    @GetMapping("/secret/names/{name}")
    public List<String> names(@PathVariable String name) {
        //调用受保护的端点
        return nameService.getSecretNames(name);
    }
}

3.6 NameService类定义了受保护的方法

@Service
public class NameService {


    private Map<String, List<String>> secretNames = Map.of(
            "natalie", List.of("Energico", "Perfecto"),
            "emma", List.of("Fantastico"));

    //使用#name表示授权表达式中的方法参数的值
    @PreAuthorize("#name == authentication.principal.username")
    public List<String> getSecretNames(String name) {

        return secretNames.get(name);
    }
}

??现在用于授权的表达式是==#name == authentication.principal.username==。在这个表达式中,我们使用#name引用名为name的getSecretNames()方法参数的值,并且可以直接访问身份验证对象,可以使用该对象来引用当前经过身份验证的用户。这里使用的表达式表明,只有在经过身份验证的用户的用户名与通过方法参数发送的值相同的情况下,才能调用该方法。换句话说,也就是用户只能检索自己的名称。

3.7 测试

??启动应用程序并测试。

??提供与用户名相等的路径变量的值

image-20220305215904581

??在使用emma进行身份验证时,我们试图获取natalie的秘密名称。但该调用不起作用:

image-20220305215952375

??不过,用户natalie可以获得她的秘密名称。

image-20220305220141693

请记住,可以讲全局方法安全性应用于应用程序的任何层。

4、应用后授权

??现在,假设希望允许对方法的调用,但在某些情况下,有希望确保调用者不会接收到返回值。当我们希望在方法调用后应用验证的授权规则时,就需要使用后授权。这可能听起来有点奇怪:为什么有人能够执行代码却得不到结果?当然,这与方法本身无关,但想象一下,假设这个方法会从数据源(比如Web服务或数据库)检索一些数据。我们可以确信该方法所做的处理,但是并不信认方法调用的第三方。因此我们要允许执行该方法,但要验证它返回的是什么,如果不满足条件,则不允许调用者访问返回值。

??要使用Spring Security应用后授权规则,需要使用@PostAuthorize注解,它类似于@PreAuthorize。该注解会接受定义授权规则的SpEL作为值。接下来将处理一个示例,在这个示例中,将展示如何使用@PostAuthorize注解并为方法定义后授权规则。

image-20220306185449296

4.1 启用全局方法安全性并定义用户

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig {

    @Bean
    public UserDetailsService userDetailsService() {
        var service = new InMemoryUserDetailsManager();

        var u1 = User.withUsername("natalie")
                    .password("12345")
                    .authorities("read")
                    .build();

        var u2 = User.withUsername("emma")
                    .password("12345")
                    .authorities("write")
                    .build();

        service.createUser(u1);
        service.createUser(u2);

        return service;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
}

??还需要声明一个类,以便用员工的姓名、书籍列表和角色列表来表示Employee对象。

4.2 Employee类的定义

public class Employee {

    private String name;
    private List<String> books;
    private List<String> roles;

    public Employee(String name, List<String> books, List<String> roles) {
        this.name = name;
        this.books = books;
        this.roles = roles;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public List<String> getBooks() {
        return books;
    }

    public void setBooks(List<String> books) {
        this.books = books;
    }

    public List<String> getRoles() {
        return roles;
    }

    public void setRoles(List<String> roles) {
        this.roles = roles;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Employee employee = (Employee) o;
        return Objects.equals(name, employee.name) &&
                Objects.equals(books, employee.books) &&
                Objects.equals(roles, employee.roles);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, books, roles);
    }
}

??我们可能需要从数据库获取员工的详细信息。为了使这个示例简单,这里使用了一个Map,其中包含了一些记录,我们将这些记录视为数据源。

4.3 定义已授权方法的BookService类

@Service
public class BookService {

    private Map<String, Employee> records =
            Map.of("emma",
                   new Employee("Emma Thompson",
                           List.of("Karamazov Brothers"),
                           List.of("accountant", "reader")),
                   "natalie",
                   new Employee("Natalie Parker",
                           List.of("Beautiful Paris"),
                           List.of("researcher"))
                  );
    //定义用于后授权的表达式
    @PostAuthorize("returnObject.roles.contains('reader')")
    public Employee getBookDetails(String name) {
        return records.get(name);
    }
}

??BookService类还包含为其应用授权规则的方法。注意,@PostAuthorize注解中使用的表达式引用了returnObject方法返回的值。后授权表达式可以使用方法返回的值,该值可在方法执行后使用。

4.4 实现端点的控制器类

??写一个控制器并实现一个端点,以便调用为其应用授权规则的方法。

@RestController
public class BookController {

    @Autowired
    private BookService bookService;

    @GetMapping("/book/details/{name}")
    public Employee getDetails(@PathVariable String name) {
        return bookService.getBookDetails(name);
    }
}

4.5 测试

??现在可以启动该应用程序并调用端点来观察该应用程序的行为。下面展示了调用端点的示例。任何用户都可以访问Emma的详细信息,因为返回的角色列表包含字符串"reader",但是没有用户可以获取natalie的详细信息。

??调用端点获取emma的详细信息,并使用用户emma进行身份验证。

image-20220306190606750

??调用端点获取emma的详细信息,并使用用户natalie进行身份验证。

image-20220306190655568

??调用端点获取natalie的详细信息,并使用用户emma进行身份验证。

image-20220306190748509

??调用端点natalie的详细信息,并使用用户natalie进行身份验证。

image-20220306190831081

提示:如果所面临的需求需要同时具有预授权与后授权,则可以在同意方法上同时使用@PreAuthorize和@PostAuthorize

5、实现方法的许可

??假设授权逻辑更加复杂,并且不能在一行代码中编写它。冗长的SpEL表达式肯定会让人不舒服。当需要实现复杂的授权规则时,不要编写冗长的SpEL表达式,而是要将逻辑放在一个单独的类中。Spring Security提供了许可的概念,这使得在单独的类中编写授权规则变得很容易,从而使应用程序更易于阅读和理解。

??这里将使用项目内的许可应用授权规则。这个场景中有一个管理文档的应用程序。任何文档都有一个所有者,即创建该文档的用户。要获取现有文档的详细信息,用户要么必须是管理员,要么必须是文档的所有者。需要实现一个许可评估器来解决这个需求。

5.1 Document类

public class Document {

    private String owner;

    public Document(String owner) {
        this.owner = owner;
    }

    public String getOwner() {
        return owner;
    }

    public void setOwner(String owner) {
        this.owner = owner;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Document document = (Document) o;
        return Objects.equals(owner, document.owner);
    }

    @Override
    public int hashCode() {
        return Objects.hash(owner);
    }
}

??为了模拟数据库并简化示例,这里创建了一个存储库类,它会在Map中管理几个文档实例。

5.2 管理几个Document实例的DocumentRepository类

@Repository
public class DocumentRepository {

    //用唯一的编码标识每个文档,并命名文档所有者
    private Map<String, Document> documents =
            Map.of("abc123", new Document("natalie"),
                    "qwe123", new Document("natalie"),
                    "asd555", new Document("emma"));


    public Document findDocument(String code) {
        //通过使用文档的唯一标识码获得文档
        return documents.get(code);
    }
}

5.3 实现受保护方法的DocumentService类

@Service
public class DocumentService {

    @Autowired
    private DocumentRepository documentRepository;

    @PostAuthorize("hasPermission(returnObject, 'ROLE_admin')")
    public Document getDocument(String code) {
        //使用hasPermission()表达式引用授权表达式
        return documentRepository.findDocument(code);
    }
}

??这里要用@PostAuthorize注解这个方法,并使用hasPermission() SpEL表达式。该方法引用本示例中进一步实现的外部授权表达式。同时,请注意提供欸hasPermission()方法的参数是returnObject,它表示方法返回的值,以及允许访问的角色的名称,也就是"ROLE_admin"。

??还需要实现许可逻辑。要通过编写一个实现PermissionEvaluator接口的对象来实现这一点。PermissionEvaluator接口提供了两种实现许可逻辑的方法。

  • 根据对象和许可:当前示例中使用了该方法,假定许可评估器要接收两个对象:一个是授权规则的对象,另一个则会提供实现许可逻辑所需的额外细节。
  • 根据对象ID、对象类型和许可:假定许可凭据器接收一个对象ID,可以使用该对象ID检索所需的对象。它还要接收一种对象类型,如果同一个许可评估器应用于多个对象类型,就可以使用这种类型的对象,而且它还需要一个提供额外信息的对象来对许可进行评估。

5.4 PermissionEvalutor接口定义

image-20220306192132792

??对于当前的示例,第一种方法就足够了。我们已经有了对象,在我们的示例中,他就是方法返回的值。还要发送角色名称“Role_admin”,根据示例场景的定义,该角色可以访问任何文档。当然,在这个示例中,可以直接在许可评估器类中使用角色的名称,并避免将其作为hasPermission()对象的值发送。在实际的场景中,情况可能更复杂。

??这里还想提一下,我们不必传递Authentication对象,Spring Security在调用hasPermission()方法时会自动提供此参数值。框架知道身份验证实例的值,因为它已经位于SpringContext中。

5.5 实现授权规则

@Component
public class DocumentsPermissionEvaluator
        implements PermissionEvaluator {

    @Override
    public boolean hasPermission(Authentication authentication,
                                 Object target,
                                 Object permission) {
        //将目标对象强制转换为Document
        Document document = (Document) target;
        //在我们的示例中,permission对象是角色名,因此要将其强制转换为String
        String p = (String) permission;

        //检查身份验证用户是否具有作为参数而接收的角色
        boolean admin =
           authentication.getAuthorities()
           .stream()
           .anyMatch(a -> a.getAuthority().equals(p));
        //如果管理员或经过管理员验证的用户是文档的所有者,则授予该许可。
        return admin || document.getOwner().equals(authentication.getName());
    }

    @Override
    public boolean hasPermission(Authentication authentication,
                                 Serializable targetId,
                                 String targetType,
                                 Object permission) {
        //这里不需要实现第二种方法,因为不会使用它
        return false;
    }
}

??为了让Spring Security知道新的PermissionEvaluator实现,必须在配置类中定义一个MethodSecurityExpressionHandler。

5.6 在配置类中配置PermissionEvaluator

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class ProjectConfig extends GlobalMethodSecurityConfiguration {

    @Autowired
    private DocumentsPermissionEvaluator evaluator;

    //重写createExpressionHandler()方法
    @Override
    protected MethodSecurityExpressionHandler createExpressionHandler() {
        //定义默认的安全表达式处理程序来设置自定义许可评估器
        var expressionHandler =
                new DefaultMethodSecurityExpressionHandler();
        //设置自定义许可评估器
        expressionHandler.setPermissionEvaluator(evaluator);
        //返回自定义表达式处理程序
        return expressionHandler;
    }

    @Bean
    public UserDetailsService userDetailsService() {
        var service = new InMemoryUserDetailsManager();

        var u1 = User.withUsername("natalie")
                    .password("12345")
                    .roles("admin")
                .build();

        var u2 = User.withUsername("emma")
                .password("12345")
                .roles("manager")
                .build();

        service.createUser(u1);
        service.createUser(u2);

        return service;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
}

??关于用户,唯一需要注意的是他们的角色。用户natalie是管理员,可以访问任何文档。用户emma是经理,之智能访问他自己的文档。

??为了测试程序,这里要定义一个端点。

5.7 定义控制器并实现端点

@RestController
public class DocumentController {

    @Autowired
    private DocumentService documentService;

    @GetMapping("/documents/{code}")
    public Document getDetails(@PathVariable String code) {
        return documentService.getDocument(code);
    }
}

5.8 测试

??用户natalie可以访问文档,而不管文档的所有者是谁。用户emma只能访问自己的文档。

??调用端点获取属于natalie的文档,并使用用户natalie进行身份验证。

image-20220306193222073

??调用端点以获取一个属于emma的文档,并使用用户natalie进行身份验证。

image-20220306193317463

??调用端点以获取属于natalie的文档,并使用用户emma进行身份验证。

image-20220306193410185

6、总结

  • Spring Security允许我们为应用程序的任何层应用授权规则,而不仅仅是端点层。要做到这一点,要启用全局方法安全性功能。
  • 全局方法安全性功能默认情况下是禁用的。要启用它,可以再应用程序的配置类上使用@EnableGlobalMethodSecurity注解。
  • 可以应用应用程序在调用方法之前进行检查的授权规则。如果违背了这些授权规则,框架就不允许执行该方法。当我们在方法调用之前检验授权规则时,需要使用预授权。
  • 要实现预授权,可以使用@PreAuthorize注解,并使用定义授权规则的SpEL表达式的值。
  • 如果希望仅在方法调用之后决定调用者是否可以使用返回值,以及执行流程是否可以继续,则要使用后授权。
  • 要实现后授权,需要使用@PostAuthorize注解,并且要使用代表授权规则的SpEL表达式的值。
  • 在实现复杂逻辑时,应该讲此逻辑分离到另一个类中,以使代码更易于阅读。在Spring Security中,实现这一点的一种常见方法是实现PermissionEvaluator。
  • Spring Security提供了像@RolesAllowed和@Secured注解这样的较老规范的兼容性。我们可以使用这些注解,但它们不如@PreAuthorize和@PostAuthorize强大,而且在实际场景中,在Spring中使用这些注解的概率非常小。
  网络协议 最新文章
使用Easyswoole 搭建简单的Websoket服务
常见的数据通信方式有哪些?
Openssl 1024bit RSA算法---公私钥获取和处
HTTPS协议的密钥交换流程
《小白WEB安全入门》03. 漏洞篇
HttpRunner4.x 安装与使用
2021-07-04
手写RPC学习笔记
K8S高可用版本部署
mySQL计算IP地址范围
上一篇文章      下一篇文章      查看所有文章
加:2022-03-08 22:56:03  更:2022-03-08 22:57:27 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/26 7:49:10-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码